diff --git a/.gitignore b/.gitignore index b056479..dd3322f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ automation/data/research/ automation/data/sustainability_raw/ automation/images/generated/ data/*.db +*.db +automation/data/mcp_*.json +automation/data/*_session.txt diff --git a/AGENTS.md b/AGENTS.md index cb2ffb0..c1cb98d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,17 @@ yu-zhi-ran/ - `USE_POSTGRES=false` falls back to SQLite (used in tests) - Models have timezone-aware `DateTime(timezone=True)` columns +### Prompts (`prompt_configs` table) +- **DB 是唯一来源**,修改 prompt 直接 `UPDATE prompt_configs SET content = '...' WHERE key = '...';` +- 代码 `scripts/prompt_loader.py` 中的 `_PROMPT_DEFAULTS` **仅作种子数据**,第一次写入后就不再生效 +- 新增 prompt:在 `_PROMPT_DEFAULTS` 添加定义 → 重启后自动补入 DB(仅当该 key 不存在时) +- 修改 prompt:**直接改 DB,不要改代码**(除非要更新种子供新环境用) +- DB 不可用时回退代码默认值(仅紧急模式) + +### Prompt quality checks (`compliance_checker.py`) +- 软质量问题(AI套话/人称混用/阅读体验)只降分、不挡流程(`passed=true`) +- 硬合规问题(敏感词/法律/品牌)扣分多且阻塞流程 + ### LLM - `call_llm()` in `core/nvidia_client.py` — reads active provider from DB `LLMConfig.is_active`, API key from env - DeepSeek reasoning models return `reasoning_content` (thinking) + `content` (answer). `call_llm` prefers `content`, falls back to tail of `reasoning_content` diff --git a/automation/data/search_cache.json b/automation/data/search_cache.json index 9f69c6f..c82c2c3 100644 --- a/automation/data/search_cache.json +++ b/automation/data/search_cache.json @@ -1,62 +1,83 @@ { + "_metadata": { + "updated_at": "2026-05-23T01:39:18.420136" + }, "以旧换新 二手交易 闲置 循环 2026": [ { - "title": "循环消费实战:10件物品,用3年省了2万", - "url": "https://sspai.com/post/109886", - "content": "实用、好用的正版软件推荐,二手交易平台对比,闲置物品变现指南", - "source": "webfetch" + "title": "漆云兰:2026年\"以旧换新\"再进阶:从\"规模效应\"到\"效能红利\"", + "url": "https://www.drc.gov.cn/DocView.aspx?chnid=379&leafid=1338&docid=2909735", + "content": "2026年以旧换新政策再进阶,从追求规模效应转向效能红利,聚焦设备更新和消费品以旧换新,推动循环经济发展。", + "source": "opencode_webfetch" }, { - "title": "商务部:以旧换新政策成效显著,2026年绿色家电销售增长40%", - "url": "https://36kr.com/p/3818280989443202", - "content": "国家持续推行以旧换新政策,带动循环消费市场快速增长", - "source": "webfetch" + "title": "从\"断舍离\"到新商机 \"闲置经济\"开启万亿新蓝海", + "url": "https://finance.sina.com.cn/jjxw/2026-01-09/doc-inhftpvn1314064.shtml", + "content": "2024年二手商品交易额达1.69万亿元,同比增长28%。Z世代成为主力,闲置经济从\"低价淘货\"转向兴趣表达与品质体验,闲鱼、转转等平台领跑。", + "source": "opencode_webfetch" }, { - "title": "二手交易平台闲鱼年交易额突破5000亿,循环经济成为新趋势", - "url": "https://36kr.com/p/3816942423000196", - "content": "闲鱼用户数超3亿,每天上架商品超200万件,二手交易已成常态", - "source": "webfetch" + "title": "2026年二手电商:万亿市场的变局与机遇", + "url": "https://www.sohu.com/a/978503810_122498610", + "content": "2026年二手电商继续高速增长,行业从\"野蛮生长\"走向\"规范发展\"的关键转折点,标准化、专业化的平台迎来机遇。", + "source": "opencode_webfetch" + }, + { + "title": "低碳循环经济成年轻人新风尚 闲置交易与旧物修复催生万亿新赛道", + "url": "https://www.xkb.com.cn/articleDetail/494615", + "content": "2026年中国闲置交易市场规模预计突破3.74万亿元,旧物修复市场达1285亿元。青年极简践行率超63%,旧物修复师从业人数突破26万人。", + "source": "opencode_webfetch" } ], "新能源车 骑行 绿色通勤 低碳出行 2026": [ { - "title": "低碳生活账单:用3年省了8万,碳足迹降了60%", - "url": "https://sspai.com/post/109889", - "content": "从交通(电动车+共享)、饮食(植物为主)、消费(二手优先)三个维度,展示真实账单变化", - "source": "webfetch" + "title": "开局十五五 奋楫新征程 | 绿色低碳出行蔚然成风 民生福祉提质增效", + "url": "https://news.qq.com/rain/a/20260417A07ZEK00", + "content": "从目前运营数据来看,日均骑行量能达到1000人次以上,后续也会持续做好车辆运维等工作,在保障居民便捷骑行的同时,守护好城区市容秩序。", + "source": "opencode_webfetch" }, { - "title": "2026年中国新能源车产销突破1500万辆,渗透率超55%", - "url": "https://36kr.com/p/3816942423000196", - "content": "新能源汽车市场持续高速增长,充电基础设施逐步完善", - "source": "webfetch" + "title": "政策退坡与技术突围并行 2026中国新能源汽车发展五大趋势", + "url": "https://k.sina.cn/article_1262737061_4b43d6a500103249k.html", + "content": "2026年新能源汽车产业链将围绕'降本增效'与'绿色低碳'两大核心加速升级,动力电池回收体系逐步完善,关键资源循环利用率提升至30%以上。", + "source": "opencode_webfetch" }, { - "title": "共享骑行从中国走向全球:中国自行车品牌海外营收增长60%", - "url": "https://www.36kr.com/feed", - "content": "国产电动自行车、共享单车品牌加速出海,绿色出行成为全球共识", - "source": "webfetch" + "title": "【深度解析】全球视野下,“低碳”出行如何重塑未来?", + "url": "https://www.sohu.com/a/814079598_121956424", + "content": "随着技术进步和基础设施完善,预计到2030年新能源汽车将占据全球新车销售的半壁江山。倡导步行和骑行的城市规划理念,有助于改善城市空气质量并提升公众健康水平。", + "source": "opencode_webfetch" + }, + { + "title": "“行进中的低碳”:绿色交通正在成为中国人的自觉", + "url": "https://www.news.cn/20240814/4fd2aa039aa14c45b4322d953dc0bee4/c.html", + "content": "民众出行更倾向于'绿色座驾',新能源车零售渗透率高达51.10%;共享的单车、电动车穿梭于大街小巷,全国刮起'骑行风';各地加快公共交通电动化、氢能化。", + "source": "opencode_webfetch" } ], "干净饮食 有机食品 植物基 本地食材 2026": [ { - "title": "干净饮食新趋势:有机食品市场规模突破2000亿", - "url": "https://36kr.com/p/3818280989443202", - "content": "中国人对食品安全和健康饮食的关注度持续提升,有机食品消费快速增长", - "source": "webfetch" + "title": "植物为主的饮食:护健康兼利环保", + "url": "https://www.familydoctor.cn/hlthsci/zhiwu-yinshi-hu-jiankang-jianli-huanbao-564572.html", + "content": "介绍星球健康饮食框架,以植物性食物为主、动物性食物为辅,强调全谷物、豆类、坚果及本地当季食材,解析植物蛋白与动物蛋白互补效应,阐述降低慢性病风险及减少碳排放的环境效益。", + "source": "opencode_webfetch" }, { - "title": "植物基食品在中国:从尝鲜到日常", - "url": "https://sspai.com/post/109869", - "content": "植物肉、植物奶等替代蛋白产品逐步走进中国消费者的日常生活", - "source": "webfetch" + "title": "植物為主飲食納每日飲食指南 千禧之愛攜7-ELEVEN推蔬果佔一半", + "url": "https://focus.586.com.tw/2026/05/14/p387573/", + "content": "2026年台湾卫福部首将植物为主饮食纳入每日饮食指南草案,推动蔬果占餐盘一半运动,强调植物性食物为主、搭配适量白肉与蛋,可降低34%糖尿病风险及减少碳排。", + "source": "opencode_webfetch" }, { - "title": "本地食材运动兴起:社区团购+农场直供模式重塑食品供应链", - "url": "https://36kr.com/p/3817849322964098", - "content": "消费者越来越关注食材来源,本地化、短链供应成为新趋势", - "source": "webfetch" + "title": "2026年必囤!TOP10有机食材清单", + "url": "https://www.yuejiaxmz.com/news/view/1455481", + "content": "有机品牌素养生活深耕十二年,覆盖杂粮、菌菇、海产等四大品类,坚持3年土壤转换期、低温物理加工、一袋一码全链路溯源,从内蒙古小米到庆元香菇定义真有机标准。", + "source": "opencode_webfetch" + }, + { + "title": "有机菜VS常规菜:该怎么选更科学?", + "url": "https://www.familydoctor.cn/hlthsci/youji-cai-changgui-cai-gaizenme-xuan-kexue-436606.html", + "content": "多维度对比有机与常规蔬菜,有机蔬菜在抗氧化物质和微量元素上更具优势、碳足迹低40%,但价格贵2-5倍;建议敏感人群优先选有机叶菜,根茎类可选常规,均衡多样化才是核心。", + "source": "opencode_webfetch" } ], "零浪费 自带杯 极简生活 可持续时尚 2026": [ @@ -81,99 +102,105 @@ ], "绿色家电 一级能效 以旧换新 节能 2026": [ { - "title": "一级能效家电占比超90%,国补政策推动绿色消费", - "url": "https://36kr.com/p/3818280989443202", - "content": "政府加大绿色家电补贴力度,以旧换新政策带动家电市场增长", - "source": "webfetch" + "title": "商务部等5部门办公厅关于做好2026年家电以旧换新、数码和智能产品购新补贴工作的通知", + "url": "https://www.gov.cn/zhengce/zhengceku/202512/content_7053369.htm", + "content": "2026年1月1日起,对个人消费者购买1级能效冰箱、洗衣机、电视、空调、热水器、电脑6类家电,按最终售价15%补贴,每件不超过1500元。", + "source": "opencode_webfetch" }, { - "title": "智能家居节能革命:AI管家让家庭能耗降低30%", - "url": "https://sspai.com/post/109945", - "content": "AI技术赋能家电,实现智能化能源管理,家庭电费支出显著下降", - "source": "webfetch" + "title": "国家发展改革委 财政部关于2026年实施大规模设备更新和消费品以旧换新政策的通知", + "url": "https://www.ndrc.gov.cn/xwdt/tzgg/202512/t20251230_1402852.html", + "content": "6类家电中1级能效产品按售价15%补贴,每件不超1500元。支持手机、平板等4类数码产品购新补贴15%,每件不超500元。", + "source": "opencode_webfetch" }, { - "title": "德国房屋节能改造经验的中国实践", - "url": "https://sspai.com/post/109889", - "content": "通过外墙保温、三层玻璃窗、热回收新风系统,实现旧房节能改造", - "source": "webfetch" + "title": "北京市2026年家电以旧换新、数码和智能产品购新补贴实施细则", + "url": "https://www.beijing.gov.cn/zhengce/zhengcefagui/202602/t20260206_4497165.html", + "content": "在京个人消费者购买1级能效6类家电享15%补贴,每件不超1500元;4类数码产品每件不超500元。每日10时开放领资格,先购先得。", + "source": "opencode_webfetch" + }, + { + "title": "国家发展改革委 财政部关于2026年实施大规模设备更新和消费品以旧换新政策的通知", + "url": "https://www.gov.cn/zhengce/zhengceku/202512/content_7053236.htm", + "content": "重点推广绿色低碳智能产品,1级能效家电按售价15%补贴,每件不超1500元。各地可按比例配套资金,优先支持高能效水效产品。", + "source": "opencode_webfetch" } ], "碳账户 碳普惠 个人碳减排 蚂蚁森林 2026": [ { - "title": "IIGF观点 | 浅析我国碳账户体系发展现状及未来展望", - "url": "https://iigf.cufe.edu.cn/info/1012/6225.htm", - "content": "2022年我国碳账户探索步入活跃期。2016年支付宝上线'蚂蚁森林'个人碳账户,用户通过低碳行为收集虚拟能量,能量积攒到一定数额可通过公益组织在现实世界种树,截至2020年5月参与者已达5.5亿。2022年十余家机构先后推出个人碳账户,将碳账户与普惠金融挂钩——积分越多信用等级越高,可享信贷利率优惠。浙江衢州已建立覆盖工业、农业、能源等七大领域的239.6万个碳账户,发放企业碳账户贷款294亿元、个人碳账户贷款48亿元。碳账户面临三大问题:碳排放数据采集核算缺乏统一标准、各平台数据无互联互通机制、数据安全缺乏法规保障。", + "title": "个人碳账户助推绿色新风尚 碳普惠机制激励公众", + "url": "https://caijing.chinadaily.com.cn/a/202407/22/WS669db161a3107cd55d26be3f.html", + "content": "碳普惠是绿色低碳发展领域针对公众参与减排的创新机制,个人碳账户是碳普惠的重要工具。政府、企业、金融机构三类主体推出的个人碳账户产品已达上百种,但仍面临顶层政策指引不足、价值消纳体系不完善等挑战。", "source": "opencode_webfetch" }, { - "title": "中信碳账户3周年:共创碳普惠行业标准 焕发可持续消费潜能", - "url": "https://www.citicbank.com/about/companynews/banknew/message/202504/t20250423_3563037.html", - "content": "2025年4月22日第56个世界地球日,国内首个银行主导的个人碳账户'中信碳账户'上线3周年,用户超2150万,累计碳减排量超19万吨。中信信用卡联合中汇信碳推出全国首个银行业无纸化金融场景碳普惠方法学,对电子借记卡、电子信用卡、电子账单、线上缴费、线上贷款等7类场景碳减排量进行科学量化并计入碳账户。全新'绿信分'体系从四大维度记录用户绿色生活足迹。2024年'绿色消费'主题活动吸引超4000万人次参与。深圳自2021年印发碳普惠体系建设方案以来已发布6份碳普惠方法学,形成完整制度体系。", + "title": "什么是个人碳账户?(延伸阅读)", + "url": "https://paper.people.com.cn/rmrbwap/html/2024-05/20/nw.D110000renmrb_20240520_2-08.htm", + "content": "个人碳账户是碳普惠机制的重要工具,可视化每个人的减排贡献。推出主体包括地方政府、互联网平台企业(如蚂蚁森林)以及金融机构。激励模式分实物奖励、资产累积、碳普惠减排量交易三种。", "source": "opencode_webfetch" }, { - "title": "个人碳账户助推绿色新风尚 — 新华网", - "url": "https://www.xinhuanet.com/fortune/20240722/c928f68f99bc4416bd0027da30203bce/c.html", - "content": "生态环境部宣教中心与中华环保联合会发布的《中国碳普惠发展与实践进展报告(2023)》显示,我国碳普惠取得显著进展。垃圾分类、绿色出行、光盘行动等日常行为均可被'个人碳账户'记录并换取收益。四川泸州'绿芽积分'小程序注册用户超35万,日活超4万,累计减碳超320吨。'个人数字碳账本'已服务北京'绿色生活季'、山西'三晋绿色生活'、黑龙江'碳惠冰城'等平台。全国碳普惠平台和碳账户产品已达上百种,推出主体包括地方政府、互联网平台企业(有ESG需求)、金融机构三类。专家建议加快出台碳普惠顶层设计政策,统一标准。", - "source": "opencode_webfetch" - }, - { - "title": "中国个人碳账户前路在何方? — 对话地球", + "title": "中国个人碳账户前路在何方?", "url": "https://dialogue.earth/zh/3/60096048/", - "content": "过去三年中国经历了全球最大规模的个人碳足迹核算实践。2015年广州启动国内最早的个人碳账户,2016年支付宝'蚂蚁森林'上线,用户通过低碳行为获'绿色能量'折算成现实树木。2022年碳账户产品从不到10个猛增到60多个,至少7家科技企业(腾讯、美团、阿里)、7家银行、16个城市和4个省份参与。2024年中国自愿碳市场将个人碳账户平台排除在外,碳普惠未能被纳入全国碳市场,也未出台相关法规推广。武汉市政府运营的碳账户允许用户用积分抵扣房贷利息(4.5万克碳积分抵扣90元贷款),低碳行为计量标准为:公交每次212.5克、地铁每公里78.4克、骑行每公里93.3克碳减排量。", + "content": "2022年中国个人碳账户产品从不到十个激增至超60个,蚂蚁森林是最知名的平台。但2024年自愿碳市场将个人碳账户排除在外,碳普惠未能纳入碳市场,产品数量大幅减少,未来发展面临不确定性。", + "source": "opencode_webfetch" + }, + { + "title": "中信碳账户3周年:共创碳普惠行业标准", + "url": "https://www.citicbank.com/about/companynews/banknew/message/202504/t20250423_3563037.html", + "content": "中信碳账户是国内首个银行主导的个人碳账户,上线三周年用户超2150万,累计碳减排量超19万吨。推出全国首个银行业无纸化金融场景碳普惠方法学,覆盖电子卡、线上缴费等7类场景。", "source": "opencode_webfetch" } ], "环保科技 绿色产品 可持续材料 2026": [ { - "title": "《中国消费市场绿色低碳趋势调查报告(2025—2026)》重磅发布", - "url": "https://news.qq.com/rain/a/20260422A01E4A00", - "content": "报告揭示了消费市场从'单点减碳'到'全链共生'、从'概念营销'到'价值共创'的转型趋势。2026年4月22日发布,涵盖绿色消费理念在供应链各环节的渗透与落地实践。", + "title": "李长荣Lacyon™鞋大底循环材料首秀2026国际橡塑展,可持续解决方案赋能产业链绿色转型", + "url": "https://www.lcycic.com.cn/news/lcy-lacyontm-sole-circular-material-debuts-at-chinaplas-2026-driving-green-transition", + "content": "李长荣展示Lacyon™热塑性弹性体硫化物循环材料用于鞋大底,可减少5-10%制程废弃物并降低41%生产碳排放,实现全鞋回收再制。", "source": "opencode_webfetch" }, { - "title": "2026 可持续包装趋势指南:企业必看的环保包装新方向", - "url": "https://www.dhl.com/discover/zh-cn/logistics-advice/sustainability-and-green-logistics/sustainable-packaging-trends", - "content": "DHL发布,指出2026年可持续包装六大趋势:新一代生物可降解材料(PLA、菌丝体基包装)爆发增长;循环包装模式(押金返还机制)加速普及;智能包装借助二维码/NFC引导回收;轻量化设计降低运输碳排放;个性化环保轻奢风兴起;全球一次性塑料禁令与回收含量法规持续趋严。", + "title": "巴斯夫正式发布全新减少产品碳足迹(ReducedPCF)的高性能Elastollan® TPU产品组合", + "url": "https://www.basf.com/cn/zh/media/news-releases/asia-pacific/2026/05/apac-26-49", + "content": "巴斯夫推出ReducedPCF热塑性聚氨酯(TPU)产品组合,可降低10-30%产品碳足迹,在上海首发生产,面向鞋材、运动休闲、线缆等行业。", "source": "opencode_webfetch" }, { - "title": "工业产品绿色设计指南(2026年版)", - "url": "http://www.ecopv.org.cn/upload/gfzwh/file/20260428/1777343910650427.pdf", - "content": "国家层面发布的官方指南,涵盖长寿命、无害化、轻量化、节能、节水、节材、降噪、节空间、易回收再生、可重复使用、零碳等11大绿色设计重点方向。针对汽车、工程机械、风电、光伏、锂电池、家用电器、纺织等行业提出具体解决方案,推动'人工智能+绿色设计'及标准体系建设。", + "title": "科思创循环材料赋能,润米科技于CHINAPLAS面向全球发售新一代90分R-GO系列咖啡箱", + "url": "https://www.covestro.com/press/zh-chs/ninetygo-launches-coffee-suitcase-made-with-recycled-materials-from-covestro-cn/", + "content": "90分R-GO系列咖啡箱外壳采用含90%消费后回收再生聚碳酸酯制成,箱壳碳足迹较上一代减少逾67%,不含有意添加的PFAS。", "source": "opencode_webfetch" }, { - "title": "2025-2026年中国绿色消费行为白皮书 - 艾媒咨询", - "url": "https://www.sohu.com/a/997098772_121864818", - "content": "报告显示绿色消费核心受众为21-40岁中青年群体(占比78.69%),已婚已育人群达66.70%,家庭育儿需求是重要驱动因素。健康意识与环保责任是消费者购买绿色产品两大核心动因,消费场景呈现'生存型>生活型>享受型'递减趋势。", + "title": "瑞讯生物材料首家生物基THF和PTMEG商业化工厂完成机械竣工,并发布全新非粮生物基PTMEG品牌Xatryx®", + "url": "https://www.cnu.com.cn/hotnews/202604/186577.html", + "content": "瑞讯生物材料启东工厂机械竣工,年产5万吨bioTHF和bioPTMEG,发布非粮生物基PTMEG品牌Xatryx®,原料取自玉米芯等二代生物质。", "source": "opencode_webfetch" } ], "AI工具 人工智能 效率提升 2026": [ + { + "title": "2026年必备AI工具清单,效率提升10倍", + "url": "https://www.toutiao.com/article/7621431281997447715", + "content": "精选2026年最值得掌握的AI工具,覆盖文本写作、图像创作等场景,从入门到进阶,助你打造AI工作流。", + "source": "opencode_webfetch" + }, + { + "title": "2026年职场效率革命:10款AI工具重塑生产力格局", + "url": "https://developer.baidu.com/article/detail.html?id=5602703", + "content": "精选2026年职场人必备的10款AI生产力工具,涵盖智能文档处理、视频创作、数据分析、跨语言协作等核心场景。", + "source": "opencode_webfetch" + }, { "title": "2026年必备的40个AI工具软件,办公效率提升120%", "url": "https://zhuanlan.zhihu.com/p/2009728985890828767", - "content": "知乎专栏文章,系统梳理2026年最值得关注的40款AI工具,涵盖通用大模型(ChatGPT/Claude/Gemini/DeepSeek)、AI思维导图(boardmix/Miro)、AI编程(GitHub Copilot/Cursor)、AI写作(Notion AI/Grammarly/Jasper)、AI绘图(Midjourney/Stable Diffusion/Adobe Firefly)、AI视频(Runway/Synthesia/CapCut)、AI音频(ElevenLabs/Suno)、AI生成PPT(博思AIPPT/Gamma)八大类,每类均分析核心优势与局限性,并提供选型指南。", + "content": "进入2026年,AI工具已成为日常工作与生活中不可或缺的伙伴,重塑着各行各业的生产力边界。", "source": "opencode_webfetch" }, { - "title": "2026年真正实用的10款AI神器:让创作、效率与思维整理实现质的飞跃", - "url": "https://blog.csdn.net/lgf228/article/details/157800617", - "content": "CSDN技术博客精选10款职场AI工具,涵盖对话助手(DeepSeek免费全能/通义千问阿里生态/豆包内容加速)、办公效率(WPS AI深度集成/ChatExcel自然语言操作表格/飞书AI会议纪要自动化)、内容创作(Midjourney/即梦本土绘图/可灵AI视频)、思维整理(博思白板AI生成思维导图/GetNote知识管理)。包含行业实践案例:锡盟融媒体中心用DeepSeek+即梦将内容生产效率提升30%+,影视制作团队从4-6人缩减至1-2人。", - "source": "opencode_webfetch" - }, - { - "title": "2026年成熟企业提升工作流程效率的顶级人工智能工具", - "url": "https://www.ranktracker.com/zh/blog/top-ai-tools-business-workflow-efficiency/", - "content": "RankTracker企业效率专题,分析AI在零售(Yieldigo AI定价优化)、流程自动化(UiPath RPA机器人)、品牌设计(Design.com AI标志生成)、知识管理(Notion AI)四大场景的落地实践。指出到2026年AI已从实验性技术变为企业日常工具,企业正用AI自动化重复任务、加速数据分析、优化客户支持与营销活动、支持财务预测和产品开发。", - "source": "opencode_webfetch" - }, - { - "title": "2026年AI技术演进与职场变革全景展望:探索效率突破与职业转型新路径", - "url": "https://zhuanlan.zhihu.com/p/1981743817511178354", - "content": "知乎深度分析文章,指出开源框架(如DeepSeek-MoE)与轻量化模型(Phi-3-mini等38亿参数模型)持续降低AI开发门槛;企业微信AI机器人、微信'元宝'助手、支付宝智能客服等应用将AI无缝嵌入日常生产生活场景,推动'人工智能+千行百业'进入规模化落地新阶段。探讨AI对职场变革的双向影响:效率突破与职业转型路径。", + "title": "2026最新AI办公工具汇总:20款国内AI工具推荐", + "url": "https://chatexcel.com/blog/latest-ai-tools-summary", + "content": "盘点2026年国内AI工具,包括写作、Excel、PPT等场景,覆盖ChatExcel、豆包、腾讯元宝、WPS AI等,提升办公效率。", "source": "opencode_webfetch" } ] diff --git a/automation/templates/wechat.html b/automation/templates/wechat.html index 6b65577..9bbe2db 100644 --- a/automation/templates/wechat.html +++ b/automation/templates/wechat.html @@ -139,10 +139,6 @@
-
- 作者:宇之然 AI 助手 - 更新日期:{{DATE}} -

{{TITLE}}

@@ -163,7 +159,6 @@
diff --git a/automation/templates/xiaohongshu.html b/automation/templates/xiaohongshu.html index 7dfbaaa..cfe7413 100644 --- a/automation/templates/xiaohongshu.html +++ b/automation/templates/xiaohongshu.html @@ -139,10 +139,6 @@
-
- 作者:宇之然 AI 助手 - 更新日期:{{DATE}} -

{{TITLE}}

@@ -163,7 +159,6 @@
diff --git a/automation/templates/zhihu.html b/automation/templates/zhihu.html index ac653d9..59c93f7 100644 --- a/automation/templates/zhihu.html +++ b/automation/templates/zhihu.html @@ -146,10 +146,6 @@
-
- 作者:宇之然 AI 助手 - 更新日期:{{DATE}} -

{{TITLE}}

@@ -170,7 +166,6 @@ diff --git a/platform/backend/app/api/metrics.py b/platform/backend/app/api/metrics.py index 9aa43f6..65c560e 100644 --- a/platform/backend/app/api/metrics.py +++ b/platform/backend/app/api/metrics.py @@ -321,6 +321,27 @@ def _extract_zhihu_post_id(url: str) -> str: raise ValueError("无法从URL中提取知乎文章/回答ID") +def _fetch_zhihu_page(post_id: str) -> dict: + """多种方式尝试获取知乎文章数据,返回原始 JSON""" + UA = _ZHIHU_UA + # 方式1:专栏 API + urls = [ + f"https://zhuanlan.zhihu.com/api/posts/{post_id}", + f"https://www.zhihu.com/api/v4/posts/{post_id}", + f"https://www.zhihu.com/api/v4/answers/{post_id}", + ] + exceptions = [] + for url in urls: + try: + resp = http_requests.get(url, headers={"User-Agent": UA}, timeout=10) + if resp.status_code == 200: + return resp.json() + exceptions.append(f"{url} → {resp.status_code}") + except Exception as e: + exceptions.append(f"{url} → {e}") + raise RuntimeError(f"知乎API已封禁,无法获取数据({'; '.join(exceptions)})") + + class ZhihuFetchRequest(BaseModel): topic_id: str zhihu_url: str @@ -338,17 +359,9 @@ def fetch_zhihu_metrics( raise HTTPException(status_code=404, detail="选题不存在") post_id = _extract_zhihu_post_id(data.zhihu_url) - api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}" try: - resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15) - if resp.status_code == 404: - api_url = f"https://www.zhihu.com/api/v4/answers/{post_id}" - resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15) - if resp.status_code != 200: - raise HTTPException(status_code=502, detail=f"知乎API返回 {resp.status_code}") - - raw = resp.json() + raw = _fetch_zhihu_page(post_id) platform = "zhihu" existing = db.query(ContentMetrics).filter( diff --git a/platform/backend/app/api/publishing.py b/platform/backend/app/api/publishing.py index 5a25186..be13748 100644 --- a/platform/backend/app/api/publishing.py +++ b/platform/backend/app/api/publishing.py @@ -2,7 +2,7 @@ """发布管理 API""" from fastapi import APIRouter, HTTPException, Depends, Request from pydantic import BaseModel -from datetime import datetime +from datetime import datetime, timezone, timedelta from typing import Optional, List from ..database import get_db @@ -86,8 +86,9 @@ async def create_publish_record( )) topic.status = '已发布' - topic.updated_at = datetime.now() - topic.published_at = datetime.now().date() + _now = datetime.now(timezone(timedelta(hours=8))) + topic.updated_at = _now + topic.published_at = _now.date() db.commit() db.expire_all() db.refresh(topic) diff --git a/platform/backend/app/api/search_providers.py b/platform/backend/app/api/search_providers.py new file mode 100644 index 0000000..f365e4d --- /dev/null +++ b/platform/backend/app/api/search_providers.py @@ -0,0 +1,141 @@ +import logging +import sys +import json +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime, timezone + +from ..database import get_db +from ..models import SearchProvider +from .auth import get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/search-providers", tags=["search_providers"]) + + +@router.get("") +def list_providers(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + providers = db.query(SearchProvider).order_by(SearchProvider.priority).all() + return [p.to_dict() for p in providers] + + +@router.post("") +def create_provider(data: dict, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + p = SearchProvider( + name=data.get("name", ""), + provider_type=data.get("provider_type", ""), + api_key=data.get("api_key", ""), + api_url=data.get("api_url", ""), + console_url=data.get("console_url", ""), + priority=data.get("priority", 99), + enabled=data.get("enabled", True), + daily_limit=data.get("daily_limit", 1500), + extra_config=data.get("extra_config", {}), + ) + db.add(p) + db.commit() + db.refresh(p) + return p.to_dict() + + +@router.put("/{provider_id}") +def update_provider(provider_id: int, data: dict, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first() + if not p: + raise HTTPException(status_code=404, detail="Provider not found") + for key in ("name", "provider_type", "api_key", "api_url", "console_url", "priority", "enabled", "daily_limit"): + if key in data: + setattr(p, key, data[key]) + if "extra_config" in data: + p.extra_config = data["extra_config"] + p.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(p) + return p.to_dict() + + +@router.delete("/{provider_id}") +def delete_provider(provider_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first() + if not p: + raise HTTPException(status_code=404, detail="Provider not found") + db.delete(p) + db.commit() + return {"ok": True} + + +@router.post("/{provider_id}/test") +def test_provider(provider_id: int, data: dict = {}, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first() + if not p: + raise HTTPException(status_code=404, detail="Provider not found") + query = data.get("query", "测试搜索") + try: + test_key = data.get("api_key") or p.api_key + test_url = data.get("api_url") or p.api_url + if p.provider_type == "baidu": + import requests + resp = requests.post( + test_url, + headers={"Authorization": f"Bearer {test_key}", "Content-Type": "application/json"}, + json={"messages": [{"role": "user", "content": query}], "search_source": "baidu_search_v2", "resource_type_filter": [{"type": "web", "top_k": 3}]}, + timeout=15 + ) + if resp.status_code != 200: + return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"} + return {"ok": True, "results": resp.json().get("results", [])[:3]} + elif p.provider_type == "qiniu": + import requests + resp = requests.post( + test_url, + headers={"Authorization": f"Bearer {test_key}", "Content-Type": "application/json"}, + json={"query": query, "max_results": 3, "search_type": "web"}, + timeout=15 + ) + if resp.status_code != 200: + return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"} + return {"ok": True, "results": resp.json().get("results", resp.json().get("data", []))[:3]} + elif p.provider_type == "tinyfish": + import requests + resp = requests.get( + test_url, + params={"query": query, "max_results": 3}, + headers={"X-API-Key": test_key}, + timeout=15 + ) + if resp.status_code != 200: + return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"} + return {"ok": True, "results": resp.json().get("results", resp.json().get("data", []))[:3]} + elif p.provider_type == "bing": + import requests + resp = requests.get( + test_url, + params={"q": query, "count": 3, "mkt": "zh-CN"}, + headers={"Ocp-Apim-Subscription-Key": test_key}, + timeout=15 + ) + if resp.status_code != 200: + return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"} + return {"ok": True, "results": resp.json().get("webPages", {}).get("value", [])[:3]} + elif p.provider_type == "mcp": + import subprocess, json as _json + mcp_script = Path(__file__).resolve().parent.parent.parent.parent.parent / "scripts" / "mcp_search_server.py" + r = subprocess.run( + [sys.executable, str(mcp_script), "--query", query], + capture_output=True, text=True, timeout=90, + ) + if r.returncode != 0: + return {"ok": False, "error": f"子进程失败: {r.stderr[:200]}"} + return {"ok": True, "results": _json.loads(r.stdout)[:3]} + return {"ok": False, "error": f"Unknown provider_type: {p.provider_type}"} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@router.post("/reset-usage") +def reset_usage(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + db.query(SearchProvider).update({SearchProvider.usage_today: 0}) + db.commit() + return {"ok": True} diff --git a/platform/backend/app/api/system.py b/platform/backend/app/api/system.py index 7617459..07b1d5c 100644 --- a/platform/backend/app/api/system.py +++ b/platform/backend/app/api/system.py @@ -3,16 +3,17 @@ import subprocess from fastapi import APIRouter, HTTPException, Depends, Body from sqlalchemy.orm import Session from sqlalchemy import func -from datetime import datetime, date +from datetime import datetime, date, timezone, timedelta from pathlib import Path from typing import Dict, Any, List, Optional import os +import os import json from ..database import get_db from ..models import Topic, Article, TaskConfig, TaskLog -from ..core.generator import run_creator, get_generator_status -from ..core.optimizer import run_optimizer, get_optimizer_status -from ..core.collector import run_collector, get_collector_status +from ..core.generator import run_creator, get_generator_status, _running_processes as _generator_running +from ..core.optimizer import run_optimizer, get_optimizer_status, _running_processes as _optimizer_running +from ..core.collector import run_collector, get_collector_status, _running_processes as _collector_running import threading from ..core.sync import sync_all_topics from ..core.scheduler import scheduler @@ -26,6 +27,32 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs" logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/system", tags=["system"]) +_active_monitors: Dict[int, dict] = {} + +def _monitor_subprocess(log_id: int, proc, module_id: str, task_name: str, db_session_factory): + """阻塞等待子进程退出(最长 1800s),完成后更新 task_logs""" + try: + returncode = proc.wait(timeout=1800) + except subprocess.TimeoutExpired: + proc.kill() + returncode = -1 + logger.warning("Subprocess %s (pid=%s) killed after 1800s timeout", module_id, proc.pid) + finished_at = datetime.now(timezone.utc) + try: + db = db_session_factory() + log = db.query(TaskLog).filter(TaskLog.id == log_id).first() + if log: + log.status = "success" if returncode == 0 else "failed" + log.finished_at = finished_at + if log.started_at: + log.duration = int((finished_at - log.started_at).total_seconds()) + db.commit() + db.close() + except Exception as e: + logger.warning("Failed to update task log %s: %s", log_id, e) + finally: + _active_monitors.pop(log_id, None) + def _aggregate_status_counts(q): """聚合状态计数,兼容中英文状态值""" raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all() @@ -60,12 +87,22 @@ def get_status(db: Session = Depends(get_db)): } } -@router.post("/generate/run", dependencies=[Depends(get_current_user)]) +@router.post("/generate/run") def trigger_generation(topic_id: Optional[str] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): logger.info(f"Generation triggered by {current_user.username}, topic_id={topic_id}") try: result = run_creator(topic_id) - return {"message": "内容创作已后台启动", "pid": result.get("pid")} + from ..database import SessionLocal + log = TaskLog(module_id="scheduled_generate", task_name="🤖 内容创作", status="running", message="内容创作已启动", triggered_by="manual", started_at=datetime.now(timezone.utc)) + db.add(log) + db.commit() + log_id = log.id + proc_info = result.get("proc") or result + proc = _generator_running.get("generator", {}).get("process") if "pid" in result else None + if proc: + t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_generate", "🤖 内容创作", SessionLocal), daemon=True) + t.start() + return {"message": "内容创作已后台启动", "pid": result.get("pid"), "log_id": log_id} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -76,12 +113,21 @@ def generation_status(): return {"status": "idle", "message": "当前无运行中的创作任务"} return status -@router.post("/collect/run", dependencies=[Depends(get_current_user)]) +@router.post("/collect/run") def trigger_collection(db: Session = Depends(get_db), current_user=Depends(get_current_user)): logger.info(f"Manual collection triggered by {current_user.username}") try: result = run_collector() - return {"message": "内容采集已后台启动", "result": result} + from ..database import SessionLocal + log = TaskLog(module_id="scheduled_collect", task_name="📡 内容采集", status="running", message="内容采集已启动", triggered_by="manual", started_at=datetime.now(timezone.utc)) + db.add(log) + db.commit() + log_id = log.id + proc = _collector_running.get("collector", {}).get("process") if "pid" in result else None + if proc: + t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_collect", "📡 内容采集", SessionLocal), daemon=True) + t.start() + return {"message": "内容采集已后台启动", "result": result, "log_id": log_id} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -92,11 +138,20 @@ def collection_status(): return {"status": "idle", "message": "当前无运行中的采集任务"} return status -@router.post("/review/run", dependencies=[Depends(get_current_user)]) +@router.post("/review/run") def trigger_review(topic_ids: Optional[List[str]] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): try: result = run_optimizer(topic_ids) - return {"message": "合规审查已后台启动", "pid": result.get("pid")} + from ..database import SessionLocal + log = TaskLog(module_id="scheduled_optimize", task_name="🔍 合规审查", status="running", message="合规审查已启动", triggered_by="manual", started_at=datetime.now(timezone.utc)) + db.add(log) + db.commit() + log_id = log.id + proc = _optimizer_running.get("optimizer", {}).get("process") if "pid" in result else None + if proc: + t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_optimize", "🔍 合规审查", SessionLocal), daemon=True) + t.start() + return {"message": "合规审查已后台启动", "pid": result.get("pid"), "log_id": log_id} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -177,17 +232,24 @@ def trigger_metrics_sync(): raise HTTPException(status_code=500, detail=str(e)) @router.post("/refresh-search-cache/run") -def trigger_refresh_search_cache(): +def trigger_refresh_search_cache(db: Session = Depends(get_db), current_user=Depends(get_current_user)): try: import sys as sys_mod scripts_dir = Path(__file__).parent.parent.parent.parent / "scripts" + from ..database import SessionLocal as _ss proc = subprocess.Popen( [sys_mod.executable, str(scripts_dir / "opencode_search.py"), "--refresh-cache"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=scripts_dir.parent.parent ) logger.info("Search cache refresh started (pid=%s)", proc.pid) - return {"message": "搜索缓存刷新已后台启动", "pid": proc.pid} + log = TaskLog(module_id="scheduled_refresh_search_cache", task_name="🔍 搜索缓存", status="running", message="搜索缓存刷新已启动", triggered_by="manual", started_at=datetime.now(timezone.utc), result_data={"pid": proc.pid}) + db.add(log) + db.commit() + log_id = log.id + t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_refresh_search_cache", "🔍 搜索缓存", _ss), daemon=True) + t.start() + return {"message": "搜索缓存刷新已后台启动", "pid": proc.pid, "log_id": log_id} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -259,6 +321,7 @@ def get_modules_status(db: Session = Depends(get_db)): "scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00", "params_desc": {"auto_pass_threshold": "自动通过分数阈值"}}, "scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00", "params_desc": {}}, "scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00", "params_desc": {}}, + "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*", "params_desc": {}}, } modules = [] diff --git a/platform/backend/app/api/task_logs.py b/platform/backend/app/api/task_logs.py index d211791..5ef9b7f 100644 --- a/platform/backend/app/api/task_logs.py +++ b/platform/backend/app/api/task_logs.py @@ -19,6 +19,7 @@ MODULES = { "scheduled_optimize": "🔍 合规审查", "scheduled_optimize_sources": "📡 信息源优化", "scheduled_metrics_sync": "📊 指标同步", + "scheduled_task_monitor": "⏰ 任务监控", } @router.get("", response_model=List[TaskLogResponse]) @@ -68,6 +69,29 @@ def list_modules(db: Session = Depends(get_db), admin_user=Depends(get_current_a }) return result + +@router.get("/log-types") +def list_log_types(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)): + used = db.query(TaskLog.module_id).distinct().all() + used_ids = [r[0] for r in used] + result = [] + for mid, name in MODULES.items(): + if mid in used_ids or True: + log_file_map = { + "scheduled_refresh_search_cache": "opencode_search", + "scheduled_fetch_trends": "trends", + "scheduled_collect": "collector", + "scheduled_generate": "creator", + "scheduled_optimize": "optimizer", + "scheduled_optimize_sources": "collector", + "scheduled_metrics_sync": "sync", + } + result.append({"module_id": mid, "name": name, "log_file": log_file_map.get(mid, mid)}) + for mid in used_ids: + if mid not in MODULES: + result.append({"module_id": mid, "name": mid, "log_file": mid}) + return result + @router.get("/{log_id}", response_model=TaskLogResponse) def get_task_log(log_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)): log = db.query(TaskLog).filter(TaskLog.id == log_id).first() diff --git a/platform/backend/app/api/tasks.py b/platform/backend/app/api/tasks.py index 788192b..7a2a662 100644 --- a/platform/backend/app/api/tasks.py +++ b/platform/backend/app/api/tasks.py @@ -1,4 +1,5 @@ import uuid +import threading from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from typing import List, Optional @@ -8,6 +9,8 @@ from ..models import ContentTask, Topic from ..schemas import ContentTaskCreate, ContentTaskResponse from .auth import get_current_user, org_filter +_creator_semaphore = threading.Semaphore(3) + router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -240,6 +243,7 @@ def _get_module_detail_data(module_id: str, db, ROOT, DATA_DIR, LOGS_DIR, today_ "scheduled_optimize": {"name": "🔍 合规审查", "description": "LLM 审查已创作文章,检查合规、打分数、优化建议"}, "scheduled_optimize_sources": {"name": "📡 信息源优化", "description": "AI 分析当前类别和信息源的市场匹配度,给出调整建议"}, "scheduled_metrics_sync": {"name": "📊 指标同步", "description": "从各平台公开 API 获取已发布文章的互动数据(点赞、阅读、评论等)"}, + "scheduled_task_monitor": {"name": "⏰ 任务监控", "description": "每小时自动检查卡死/中断任务,标记为失败以便重新执行"}, } meta = MODULE_META.get(module_id, {"name": module_id, "description": ""}) @@ -430,7 +434,6 @@ def run_creator_task( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - import threading from datetime import datetime, timezone now = datetime.now(timezone.utc) @@ -448,44 +451,51 @@ def run_creator_task( db.commit() db.refresh(task) - from ..core.generator import run_creator - def _run(): from ..database import SessionLocal + from ..core.generator import run_creator_blocking from datetime import datetime, timezone - new_db = SessionLocal() + import traceback + _creator_semaphore.acquire() try: - new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first() - if new_task: - new_task.message = "创作脚本运行中..." - new_task.progress = 30 - new_db.commit() - result = run_creator(topic_id) - new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first() - if new_task: - finished = datetime.now(timezone.utc) - new_task.status = "completed" - new_task.finished_at = finished - new_task.progress = 100 - new_task.message = "创作完成" - new_task.result_data = result or {} - if new_task.started_at: - new_task.duration = int((finished - new_task.started_at).total_seconds()) - new_db.commit() - except Exception as e: - new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first() - if new_task: - finished = datetime.now(timezone.utc) - new_task.status = "failed" - new_task.finished_at = finished - new_task.error_msg = str(e) - if new_task.started_at: - new_task.duration = int((finished - new_task.started_at).total_seconds()) - new_db.commit() - finally: - new_db.close() + new_db = SessionLocal() + try: + new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first() + if new_task: + new_task.message = "创作脚本运行中..." + new_task.progress = 30 + new_db.commit() - thread = threading.Thread(target=_run) + result = run_creator_blocking(topic_id) + + new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first() + if new_task: + finished = datetime.now(timezone.utc) + new_task.status = "completed" + new_task.finished_at = finished + new_task.progress = 100 + new_task.message = "创作完成" + new_task.result_data = {"stdout": (result or {}).get("stdout", "")[:2000]} if isinstance(result, dict) else {"raw": str(result)[:2000]} + if new_task.started_at: + new_task.duration = int((finished - new_task.started_at).total_seconds()) + new_db.commit() + except Exception as e: + new_db.rollback() + new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first() + if new_task: + finished = datetime.now(timezone.utc) + new_task.status = "failed" + new_task.finished_at = finished + new_task.error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" + if new_task.started_at: + new_task.duration = int((finished - new_task.started_at).total_seconds()) + new_db.commit() + finally: + new_db.close() + finally: + _creator_semaphore.release() + + thread = threading.Thread(target=_run, daemon=True) thread.start() return task \ No newline at end of file diff --git a/platform/backend/app/core/scheduler.py b/platform/backend/app/core/scheduler.py index 1915358..37fcc3f 100644 --- a/platform/backend/app/core/scheduler.py +++ b/platform/backend/app/core/scheduler.py @@ -27,6 +27,8 @@ MODULES = { "scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00"}, "scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00"}, "scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"}, + "scheduled_reset_search_usage": {"name": "🔁 搜索用量重置", "cron": "00:05"}, + "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"}, } def _log_task(module_id: str, status: str, message: str = None, @@ -107,6 +109,7 @@ class TaskScheduler: ("scheduled_optimize", self._run_optimize, "合规审查"), ("scheduled_optimize_sources", self._run_optimize_sources, "信息源优化"), ("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"), + ("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"), ] for module_id, fn, name in MODULE_JOBS: @@ -129,6 +132,17 @@ class TaskScheduler: ) logger.info(f"调度任务: {module_id} -> {schedule}") + # 每小时运行的任务监控:检测卡死/中断任务 + self.scheduler.add_job( + self._run_task_monitor, + CronTrigger(hour='*/1'), + id='scheduled_task_monitor', + replace_existing=True, + max_instances=1, + coalesce=True + ) + logger.info("调度任务: scheduled_task_monitor -> 每小时") + self.scheduler.start() self._started = True logger.info("Scheduler started with dynamic schedule from TaskConfig") @@ -441,6 +455,95 @@ class TaskScheduler: started_at=started, finished_at=datetime.now(timezone.utc)) logger.exception("[Scheduled] Metrics sync failed: %s", e) + def _run_reset_search_usage(self): + """每日凌晨重置搜索 API 提供商用量计数""" + started = datetime.now(timezone.utc) + _log_task("scheduled_reset_search_usage", "running", started_at=started) + try: + from ..database import SessionLocal + from ..models import SearchProvider + db = SessionLocal() + try: + total = db.query(SearchProvider).update({SearchProvider.usage_today: 0, SearchProvider.last_used_at: None}) + db.commit() + _log_task("scheduled_reset_search_usage", "success", + message=f"已重置 {total} 个提供商用量", + result_data={"reset_count": total}, + started_at=started, finished_at=datetime.now(timezone.utc)) + logger.info("[Scheduled] Reset %d search providers usage", total) + finally: + db.close() + except Exception as e: + _log_task("scheduled_reset_search_usage", "failed", + message=str(e), + error_trace=traceback.format_exc(), + started_at=started, finished_at=datetime.now(timezone.utc)) + logger.exception("[Scheduled] Reset search usage failed: %s", e) + + def _run_task_monitor(self): + """每小时检查卡死/中断的任务,标记为失败""" + started = datetime.now(timezone.utc) + _log_task("scheduled_task_monitor", "running", started_at=started) + stuck_tasklog_timeout = 7200 # 超过2小时视为卡死 + stuck_contenttask_timeout = 10800 # 超过3小时视为卡死 + try: + from ..database import SessionLocal + from ..models import TaskLog, ContentTask + db = SessionLocal() + try: + now = datetime.now(timezone.utc) + cutoff_tasklog = now.timestamp() - stuck_tasklog_timeout + cutoff_content = now.timestamp() - stuck_contenttask_timeout + marked = 0 + + # 检查 TaskLog 中卡死的 running 记录 + stuck_logs = db.query(TaskLog).filter( + TaskLog.status == "running", + TaskLog.started_at.isnot(None) + ).all() + for log in stuck_logs: + if log.started_at.timestamp() < cutoff_tasklog: + log.status = "failed" + log.finished_at = now + log.error_trace = "系统监控:任务运行超时(超过2小时)或进程中断,已自动标记为失败" + if log.started_at: + log.duration = int((now - log.started_at).total_seconds()) + marked += 1 + logger.warning("[TaskMonitor] 标记 TaskLog %d (%s) 为失败(超时)", log.id, log.module_id) + + # 检查 ContentTask 中卡死的 running 记录 + stuck_tasks = db.query(ContentTask).filter( + ContentTask.status == "running", + ContentTask.started_at.isnot(None) + ).all() + for task in stuck_tasks: + if task.started_at.timestamp() < cutoff_content: + task.status = "failed" + task.finished_at = now + task.error_msg = "系统监控:任务运行超时(超过3小时)或进程中断,已自动标记为失败" + if task.started_at: + task.duration = int((now - task.started_at).total_seconds()) + marked += 1 + logger.warning("[TaskMonitor] 标记 ContentTask %s (%s) 为失败(超时)", task.task_id, task.stage) + + if marked: + db.commit() + logger.info("[TaskMonitor] 已标记 %d 个卡死任务为失败", marked) + + _log_task("scheduled_task_monitor", "success", + message=f"检查完成,标记 {marked} 个卡死任务", + result_data={"marked_failed": marked}, + started_at=started, finished_at=datetime.now(timezone.utc)) + finally: + db.close() + except Exception as e: + import traceback + _log_task("scheduled_task_monitor", "failed", + message=str(e), + error_trace=traceback.format_exc(), + started_at=started, finished_at=datetime.now(timezone.utc)) + logger.exception("[TaskMonitor] 监控检查失败: %s", e) + def get_jobs(self): """返回当前所有定时任务的状态""" jobs = [] diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py index b6f3c32..b67ea58 100644 --- a/platform/backend/app/database.py +++ b/platform/backend/app/database.py @@ -53,6 +53,7 @@ def init_db(): for table, col, typ in [ ("users", "org_id", "VARCHAR DEFAULT 'default'"), ("topics", "org_id", "VARCHAR DEFAULT 'default'"), + ("topics", "reviewed_at", "TIMESTAMP"), ("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"), ("platform_configs", "image_count_min", "INTEGER DEFAULT 0"), ("platform_configs", "image_count_max", "INTEGER DEFAULT 0"), @@ -82,6 +83,7 @@ def init_db(): ("prompt_configs", "temperature", "FLOAT"), ("prompt_configs", "max_tokens", "INTEGER"), ("prompt_configs", "created_by", "VARCHAR"), + ("search_providers", "console_url", "VARCHAR"), ("keyword_domain_map", "id", "INTEGER PRIMARY KEY"), ("keyword_domain_map", "pattern", "VARCHAR"), ("keyword_domain_map", "domain", "VARCHAR"), @@ -124,10 +126,11 @@ def init_db(): conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}")) except Exception: pass - # Create roles and menus tables if they don't exist + # Create roles, menus, and search_providers tables if they don't exist for tbl_sql in [ "CREATE TABLE IF NOT EXISTS roles (id SERIAL PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, description VARCHAR DEFAULT '', is_system BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())", "CREATE TABLE IF NOT EXISTS menus (id SERIAL PRIMARY KEY, parent_id INTEGER REFERENCES menus(id), name VARCHAR NOT NULL, path VARCHAR NOT NULL, icon VARCHAR DEFAULT '', sort_order INTEGER DEFAULT 0, roles JSON DEFAULT '[]'::json, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())", + "CREATE TABLE IF NOT EXISTS search_providers (id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, provider_type VARCHAR NOT NULL, api_key VARCHAR, api_url VARCHAR, priority INTEGER DEFAULT 1, enabled BOOLEAN DEFAULT TRUE, daily_limit INTEGER DEFAULT 1500, usage_today INTEGER DEFAULT 0, extra_config JSON DEFAULT '{}'::json, last_used_at TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT now())", ]: try: conn.execute(text(tbl_sql)) diff --git a/platform/backend/app/initial_data.py b/platform/backend/app/initial_data.py index f65473c..fcfd9e3 100644 --- a/platform/backend/app/initial_data.py +++ b/platform/backend/app/initial_data.py @@ -6,7 +6,7 @@ from .database import SessionLocal, init_db from .models import ( Topic, TopicField, TopicConfigField, TopicStatusConfig, User, Case, LLMConfig, SystemConfig, PlatformConfig, - CollectorCategory, CollectorSource, Role, Menu + CollectorCategory, CollectorSource, Role, Menu, SearchProvider ) import bcrypt @@ -71,6 +71,17 @@ def import_initial_data(): db.commit() print("✅ 插入默认系统配置") + # 初始化默认搜索 API 提供商 + if db.query(SearchProvider).count() == 0: + providers = [ + SearchProvider(name="百度千帆", provider_type="baidu", api_key="", api_url="https://qianfan.baidubce.com/v2/ai_search/web_search", console_url="https://console.bce.baidu.com/qianfan/ais/console/onlineService", priority=1, enabled=True, daily_limit=50), + SearchProvider(name="opencode云搜索", provider_type="mcp", api_key="", api_url="", console_url="https://opencode.ai", priority=2, enabled=True, daily_limit=99999), + ] + for p in providers: + db.add(p) + db.commit() + print("✅ 插入默认搜索 API 提供商") + if db.query(PlatformConfig).count() == 0: platforms = [ { diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py index a9916fe..8015882 100644 --- a/platform/backend/app/main.py +++ b/platform/backend/app/main.py @@ -9,7 +9,7 @@ from pathlib import Path from .database import engine, get_db, init_db from .models import Base -from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs +from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers from .initial_data import import_initial_data from .core.scheduler import scheduler @@ -103,6 +103,7 @@ app.include_router(config_items.router) app.include_router(role_configs.router) app.include_router(menu_configs.router) app.include_router(menu_configs.public_router) +app.include_router(search_providers.router) # 挂载自动生成的图片(必须先于前端根挂载) PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py index 083d9a5..906dfba 100644 --- a/platform/backend/app/models.py +++ b/platform/backend/app/models.py @@ -2,7 +2,44 @@ from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boo from sqlalchemy.sql import func from sqlalchemy.orm import relationship from .database import Base -from datetime import datetime +from datetime import datetime, timezone + + +class SearchProvider(Base): + __tablename__ = "search_providers" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String, nullable=False, comment="显示名称") + provider_type = Column(String, nullable=False, comment="baidu / qiniu / tinyfish") + api_key = Column(String, nullable=True, comment="API密钥") + api_url = Column(String, nullable=True, comment="API地址") + priority = Column(Integer, default=1, comment="优先级,越小越优先") + enabled = Column(Boolean, default=True) + daily_limit = Column(Integer, default=1500, comment="每日调用上限") + usage_today = Column(Integer, default=0, comment="当日已用次数") + console_url = Column(String, nullable=True, comment="官网控制台地址") + extra_config = Column(JSON, default=dict, comment="额外配置") + last_used_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "provider_type": self.provider_type, + "api_key": self.api_key, + "api_url": self.api_url, + "console_url": self.console_url, + "priority": self.priority, + "enabled": self.enabled, + "daily_limit": self.daily_limit, + "usage_today": self.usage_today, + "extra_config": self.extra_config or {}, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } class AuditLog(Base): @@ -221,6 +258,7 @@ class Topic(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) generated_at = Column(DateTime(timezone=True), nullable=True) + reviewed_at = Column(DateTime(timezone=True), nullable=True) ready_at = Column(Date) published_at = Column(Date) compliance_score = Column(Integer) diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py index d9c9a93..532238b 100644 --- a/platform/backend/app/schemas.py +++ b/platform/backend/app/schemas.py @@ -112,6 +112,7 @@ class TopicResponse(TopicBase): created_at: Optional[datetime] = None updated_at: Optional[datetime] = None generated_at: Optional[datetime] = None + reviewed_at: Optional[datetime] = None ready_at: Optional[date] = None published_at: Optional[date] = None compliance_score: Optional[int] = None diff --git a/platform/frontend/admin.html b/platform/frontend/admin.html index ce6a7f8..53154e5 100644 --- a/platform/frontend/admin.html +++ b/platform/frontend/admin.html @@ -31,6 +31,7 @@ 菜单管理 运行日志 AI 助手 + 搜索API @@ -441,16 +442,8 @@
- - - - - - - - - - + + 加载日志 @@ -480,7 +473,119 @@
+
+
+ 新增提供商 + 测试全部 + 重置用量 + 共 {{ searchProviders.length }} 个 +
+
加载中...
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
测试中...
+
+
+ {{ searchProviderTestResult.ok ? '✅ 连接成功' : '❌ 连接失败' }} +
+
{{ searchProviderTestResult.error }}
+
+
+
{{ r.title }}
+
{{ r.url }}
+
+
+
+ +
@@ -761,6 +866,7 @@ const llmConfigs = ref([]); }; const logType = ref('creator'); + const logTypes = ref([]); const users = ref([]); const usersLoading = ref(false); const userDialogVisible = ref(false); @@ -810,10 +916,25 @@ const llmConfigs = ref([]); const logDate = ref(new Date().toISOString().slice(0, 10)); const logContent = ref(''); const logsLoading = ref(false); + const loadLogTypes = async () => { + try { + logTypes.value = await api.get('/api/admin/task-logs/log-types'); + logTypes.value.unshift({ module_id: 'creator', name: '创作日志', log_file: 'creator' }); + logTypes.value.unshift({ module_id: 'optimizer', name: '审查日志', log_file: 'optimizer' }); + logTypes.value.unshift({ module_id: 'research', name: '研究日志', log_file: 'research' }); + logTypes.value.unshift({ module_id: 'outline', name: '大纲日志', log_file: 'outline' }); + logTypes.value.unshift({ module_id: 'writer', name: '写作日志', log_file: 'writer' }); + logTypes.value.unshift({ module_id: 'publisher', name: '发布日志', log_file: 'publisher' }); + logTypes.value.unshift({ module_id: 'collector', name: '收集日志', log_file: 'collector' }); + logTypes.value.unshift({ module_id: 'trends', name: '趋势日志', log_file: 'trends' }); + } catch (e) { console.error('加载日志类型失败:', e); } + }; const fetchLogs = async () => { logsLoading.value = true; try { - const resp = await fetch(`/api/system/logs/${logDate.value}?log_type=${logType.value}`, { + const module = logTypes.value.find(t => t.name === logType.value); + const logFile = module ? module.log_file : logType.value; + const resp = await fetch(`/api/system/logs/${logDate.value}?log_type=${logFile}`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}` } }); if (resp.ok) { @@ -863,12 +984,89 @@ const llmConfigs = ref([]); } catch (e) { console.error('加载AI助手配置失败', e); } }; const saveAssistantPrompt = async () => { - assistantSaving.value = true; try { - await api.post('/api/admin/systemconfigs', { key: 'assistant_system_prompt', value: assistantPrompt.value, description: 'AI 助手系统提示词' }); - ElementPlus.ElMessage.success('保存成功'); - } catch (e) { ElementPlus.ElMessage.error('保存失败: ' + e.message); } - finally { assistantSaving.value = false; } + const configs = await api.get('/api/admin/systemconfigs'); + const cfg = configs.find(c => c.key === 'assistant_system_prompt'); + if (cfg) { await api.put(`/api/admin/systemconfigs/${cfg.id}`, { value: assistantPrompt.value, description: 'AI 助手系统提示词' }); } + else { await api.post('/api/admin/systemconfigs', { key: 'assistant_system_prompt', value: assistantPrompt.value, description: 'AI 助手系统提示词' }); } + ElMessage.success('保存成功'); + } catch (e) { ElMessage.error('保存失败: ' + e.message); } + }; + + const searchProviders = ref([]); + const searchProvidersLoading = ref(false); + const searchProviderDialogVisible = ref(false); + const searchProviderDialogTitle = ref('新增搜索API'); + const searchProviderSaving = ref(false); + const searchProviderForm = reactive({ id: null, name: '', provider_type: 'baidu', api_key: '', api_url: '', console_url: '', priority: 1, daily_limit: 1500, enabled: true }); + const searchProviderTestVisible = ref(false); + const searchProviderTesting = ref(false); + const searchProviderTestResult = ref({ ok: false, error: '', results: [] }); + + const loadSearchProviders = async () => { + searchProvidersLoading.value = true; + try { searchProviders.value = await api.get('/api/search-providers'); } + catch (e) { ElMessage.error('加载搜索API失败: ' + e.message); } + finally { searchProvidersLoading.value = false; } + }; + const addSearchProvider = () => { + searchProviderDialogTitle.value = '新增搜索API'; + searchProviderForm.id = null; + searchProviderForm.name = ''; + searchProviderForm.provider_type = 'baidu'; + searchProviderForm.api_key = ''; + searchProviderForm.api_url = ''; + searchProviderForm.console_url = ''; + searchProviderForm.priority = 1; + searchProviderForm.daily_limit = 1500; + searchProviderForm.enabled = true; + searchProviderDialogVisible.value = true; + }; + const editSearchProvider = (row) => { + searchProviderDialogTitle.value = '编辑搜索API'; + Object.assign(searchProviderForm, { id: row.id, name: row.name, provider_type: row.provider_type, api_key: row.api_key, api_url: row.api_url, console_url: row.console_url || '', priority: row.priority, daily_limit: row.daily_limit, enabled: row.enabled }); + searchProviderDialogVisible.value = true; + }; + const saveSearchProvider = async () => { + searchProviderSaving.value = true; + try { + const body = { name: searchProviderForm.name, provider_type: searchProviderForm.provider_type, api_key: searchProviderForm.api_key, api_url: searchProviderForm.api_url, console_url: searchProviderForm.console_url, priority: searchProviderForm.priority, daily_limit: searchProviderForm.daily_limit, enabled: searchProviderForm.enabled }; + if (searchProviderForm.id) { await api.put(`/api/search-providers/${searchProviderForm.id}`, body); ElMessage.success('更新成功'); } + else { await api.post('/api/search-providers', body); ElMessage.success('创建成功'); } + searchProviderDialogVisible.value = false; + await loadSearchProviders(); + } catch (e) { ElMessage.error('保存失败: ' + e.message); } + finally { searchProviderSaving.value = false; } + }; + const deleteSearchProvider = async (id) => { + try { await ElMessageBox.confirm('确定删除该提供商吗?', '提示', { type: 'warning' }); await api.delete(`/api/search-providers/${id}`); ElMessage.success('删除成功'); await loadSearchProviders(); } + catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); } + }; + const updateSearchProvider = async (row) => { + try { await api.put(`/api/search-providers/${row.id}`, { enabled: row.enabled }); } + catch (e) { ElMessage.error('更新失败: ' + e.message); } + }; + const testSearchProvider = async (row) => { + searchProviderTesting.value = true; + searchProviderTestVisible.value = true; + searchProviderTestResult.value = { ok: false, error: '', results: [] }; + try { + const r = await api.post(`/api/search-providers/${row.id}/test`, { query: '测试搜索' }); + searchProviderTestResult.value = r; + } catch (e) { searchProviderTestResult.value = { ok: false, error: e.message, results: [] }; } + finally { searchProviderTesting.value = false; } + }; + const testAllSearchProviders = async () => { + for (const p of searchProviders.value) { + try { + const r = await api.post(`/api/search-providers/${p.id}/test`, { query: '测试搜索' }); + ElMessage({ type: r.ok ? 'success' : 'error', message: `${p.name}: ${r.ok ? 'OK' : '失败 ' + (r.error || '')}` }); + } catch (e) { ElMessage.error(`${p.name} 测试失败: ${e.message}`); } + } + }; + const resetSearchUsage = async () => { + try { await api.post('/api/search-providers/reset-usage'); ElMessage.success('用量已重置'); await loadSearchProviders(); } + catch (e) { ElMessage.error('重置失败: ' + e.message); } }; const formatDate = (dateStr) => { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }; @@ -877,7 +1075,8 @@ const llmConfigs = ref([]); const tabLoaders = { llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs, users: fetchUsers, - orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig, logs: fetchLogs, + logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig, + searchproviders: loadSearchProviders, }; const loadedTabs = new Set([]); @@ -909,8 +1108,12 @@ const llmConfigs = ref([]); logout, currentUser, isAdmin, redirectToPage, users, usersLoading, userDialogVisible, userDialogTitle, userSubmitting, userForm, userPage, userPageSize, paginatedUsers, fetchUsers, addUser, showEditUserDialog, submitUser, deleteUser, - logType, logDate, logContent, logsLoading, fetchLogs, + logType, logTypes, logDate, logContent, logsLoading, fetchLogs, assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt, + searchProviders, searchProvidersLoading, searchProviderDialogVisible, searchProviderDialogTitle, searchProviderSaving, searchProviderForm, + searchProviderTestVisible, searchProviderTesting, searchProviderTestResult, + loadSearchProviders, addSearchProvider, editSearchProvider, saveSearchProvider, deleteSearchProvider, updateSearchProvider, + testSearchProvider, testAllSearchProviders, resetSearchUsage, }; } }); diff --git a/platform/frontend/articles.html b/platform/frontend/articles.html index 512f0b3..59cc1a5 100644 --- a/platform/frontend/articles.html +++ b/platform/frontend/articles.html @@ -222,12 +222,17 @@ const ArticlesApp = { const titleEl = doc.querySelector('h1'); const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || ''); const body = doc.body; - if (body) body.querySelectorAll('script, style, svg, img, nav, footer, .interaction').forEach(el => el.remove()); - const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : []; - const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n'); - navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`) - .then(() => this.$message.success('已复制到剪贴板')) - .catch(() => this.$message.error('复制失败')); + if (body) body.querySelectorAll('script, style, nav, footer, .interaction, .ad, aside, .comment').forEach(el => el.remove()); + const container = doc.createElement('div'); + if (titleEl) { const h1 = doc.createElement('h1'); h1.textContent = title; container.appendChild(h1); } + body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => container.appendChild(el.cloneNode(true))); + const cleanHtml = container.innerHTML; + const blob = new Blob([cleanHtml], { type: 'text/html' }); + const plainText = container.textContent; + const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([plainText], { type: 'text/plain' }) }); + navigator.clipboard.write([item]).then(() => this.$message.success('✅ 已复制(含格式和配图),Ctrl+V 粘贴')).catch(() => { + navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败')); + }); }, async deleteArticle(article) { try { diff --git a/platform/frontend/calendar.html b/platform/frontend/calendar.html index 3b908c0..e952712 100644 --- a/platform/frontend/calendar.html +++ b/platform/frontend/calendar.html @@ -189,14 +189,15 @@ - + @@ -407,11 +408,51 @@ function getDayMeta(year, month, day) { loadingCalendar.value = true; calendarError.value = ''; errorMsg.value = ''; - try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; } + try { + const [calEntries, topicList] = await Promise.all([ + api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`), + api('/api/topics?limit=200'), + ]); + topics.value = topicList; + + const virtualEntries = []; + (topicList || []).forEach(t => { + const s = t.status; + if ((s === 'ready' || s === '待发布') && t.ready_at) { + const d = new Date(t.ready_at); + if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) { + virtualEntries.push({ id: 'topic_ready_' + t.id, title: t.title, planned_date: t.ready_at, platform: '', status: 'planned', platform_icon: '📋', topic_status: 'ready', _source: 'topic' }); + } + } else if ((s === 'published' || s === '已发布') && t.published_at) { + const d = new Date(t.published_at); + if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) { + virtualEntries.push({ id: 'topic_pub_' + t.id, title: t.title, planned_date: t.published_at, platform: '', status: 'published', platform_icon: '📄', topic_status: 'published', _source: 'topic' }); + } + } + }); + entries.value = [...(calEntries || []), ...virtualEntries]; + } catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; } finally { loadingCalendar.value = false; } }; const fetchStats = async () => { - try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); } + try { + const [calStats, topicList] = await Promise.all([ + api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`), + api('/api/topics?limit=200').catch(() => []), + ]); + const s = { planned: calStats.planned || 0, published: calStats.published || 0, delayed: calStats.delayed || 0, cancelled: calStats.cancelled || 0 }; + (topicList || []).forEach(t => { + const st = t.status; + if ((st === 'ready' || st === '待发布') && t.ready_at) { + const d = new Date(t.ready_at); + if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.planned++; + } else if ((st === 'published' || st === '已发布') && t.published_at) { + const d = new Date(t.published_at); + if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.published++; + } + }); + stats.value = s; + } catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); } }; const fetchTopics = async () => { try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); ElMessage.error('加载选题失败: ' + e.message); } @@ -423,7 +464,14 @@ function getDayMeta(year, month, day) { const openDayDialog = (day) => { selectedDay.value = day; selectedDayEntries.value = entries.value.filter(e => { const pd = new Date(e.planned_date); return pd.getFullYear() === day.year && pd.getMonth() + 1 === day.month && pd.getDate() === day.day; }); dayDialogVisible.value = true; }; const openCreateDialog = () => { isEdit.value = false; entryForm.value = { id: null, title: '', planned_date: `${selectedDay.value.year}-${selectedDay.value.month}-${selectedDay.value.day}`, platform: 'zhihu', topic_id: null, status: 'planned', notes: '' }; entryDialogVisible.value = true; }; - const openEntryDialog = (entry) => { isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true; }; + const openEntryDialog = (entry) => { + if (entry._source === 'topic') { + const topicId = entry.id.replace(/^topic_(ready|pub)_/, ''); + window.location.href = '/topics.html?topic_id=' + topicId; + return; + } + isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true; + }; const saveEntry = async () => { saving.value = true; @@ -463,7 +511,6 @@ function getDayMeta(year, month, day) { isAdmin.value = d.user.role === 'admin'; fetchEntries(); fetchStats(); - fetchTopics(); }) .catch(() => { localStorage.removeItem('authToken'); diff --git a/platform/frontend/index.html b/platform/frontend/index.html index e3a8e4f..6773587 100644 --- a/platform/frontend/index.html +++ b/platform/frontend/index.html @@ -327,22 +327,51 @@ this.loadingPlan = true; try { const token = localStorage.getItem('authToken'); - const now = new Date(); - const year = now.getFullYear(); - const month = now.getMonth() + 1; - const resp = await fetch(`/api/calendar?year=${year}&month=${month}`, { - headers: { 'Authorization': 'Bearer ' + token } + const today = new Date(); + today.setHours(0, 0, 0, 0); + const end = new Date(today); + end.setDate(end.getDate() + 7); + + const [entries, topics] = await Promise.all([ + fetch(`/api/calendar?year=${today.getFullYear()}&month=${today.getMonth() + 1}`, { + headers: { 'Authorization': 'Bearer ' + token } + }).then(r => r.ok ? r.json() : []), + fetch('/api/topics?limit=200', { + headers: { 'Authorization': 'Bearer ' + token } + }).then(r => r.ok ? r.json() : []) + ]); + + const events = []; + (entries || []).forEach(e => { + events.push({ + id: 'cal_' + e.id, + date: e.planned_date, + title: e.title, + status: e.status === 'published' ? 'published' : 'planned' + }); }); - if (resp.ok) { - const all = await resp.json(); - const today = new Date(); - today.setHours(0, 0, 0, 0); - const end = new Date(today); - end.setDate(end.getDate() + 7); - this.upcomingEntries = (all || []) - .filter(e => { const d = new Date(e.planned_date); return d >= today && d < end; }) - .sort((a, b) => a.planned_date.localeCompare(b.planned_date)); - } + (topics || []).forEach(t => { + const s = t.status; + if ((s === 'ready' || s === '待发布') && t.ready_at) { + events.push({ + id: 'topic_ready_' + t.id, + date: t.ready_at, + title: t.title, + status: 'planned' + }); + } else if ((s === 'published' || s === '已发布') && t.published_at) { + events.push({ + id: 'topic_pub_' + t.id, + date: t.published_at, + title: t.title, + status: 'published' + }); + } + }); + + this.upcomingEntries = events + .filter(e => { const d = new Date(e.date); return d >= today && d < end; }) + .sort((a, b) => a.date.localeCompare(b.date)); } catch (e) { console.error('获取近期计划失败:', e); } finally { this.loadingPlan = false; } diff --git a/platform/frontend/tasks.html b/platform/frontend/tasks.html index 7efaf78..6bed584 100644 --- a/platform/frontend/tasks.html +++ b/platform/frontend/tasks.html @@ -100,9 +100,9 @@ {{ mod.last_status === 'success' ? '正常' : mod.last_status === 'failed' ? '失败' : '空闲' }}
-
最后运行{{ mod.last_run || '从未' }}
+
最后运行{{ mod.last_run || '从未' }}✅成功❌失败⏳运行中
下次运行{{ mod.next_run || '—' }}
-
累计运行{{ mod.total_runs }} 次 {{ mod.success_runs }} 成功 {{ mod.failed_runs }} 失败
+
累计运行{{ mod.total_runs }} 次 {{ mod.success_runs }} 成功 {{ mod.failed_runs }} 失败 {{ mod.running }} 运行中
立即运行 查看详情 @@ -619,6 +619,7 @@ const TasksApp = { 'scheduled_metrics_sync': { icon: 'IconDashboard', name: '指标同步', defaultTime: '06:00' }, 'scheduled_refresh_search_cache': { icon: 'IconRefresh', name: '搜索缓存', defaultTime: '01:00' }, 'scheduled_fetch_trends': { icon: 'IconRefresh', name: '热点趋势', defaultTime: '01:10' }, + 'scheduled_task_monitor': { icon: 'IconRefresh', name: '任务监控', defaultTime: '*' }, }; const MODULE_TRIGGER_ENDPOINTS = { scheduled_collect: '/api/system/collect/run', @@ -762,7 +763,7 @@ const TasksApp = { }, async triggerModule(modId) { const endpoint = this.MODULE_TRIGGER_ENDPOINTS[modId]; - if (!endpoint) { ElMessage.error('未知模块'); return; } + if (!endpoint) { ElMessage.info('此模块自动运行,无需手动触发'); return; } this.runningModule = modId; try { await this.api(endpoint, { method: 'POST' }); diff --git a/platform/frontend/topics.html b/platform/frontend/topics.html index e4ee8a5..fdc8129 100644 --- a/platform/frontend/topics.html +++ b/platform/frontend/topics.html @@ -22,8 +22,11 @@ .topic-card-meta { display: flex; flex-direction: column; gap: 3px; font-size: var(--font-size-caption); color: var(--color-text-regular); margin-bottom: 10px; } .topic-card-actions { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border); } .topic-card-actions .el-button { margin: 0; width: 100%; justify-content: center; padding: 8px 4px !important; } - .topic-card.is-checked { border-color: var(--color-primary); box-shadow: 0 0 0 1px rgba(64,158,255,0.2); } - .topic-card-actions .el-button--danger { grid-column: 1 / -1; } + .topic-card.is-checked { border-color: var(--color-primary); box-shadow: 0 0 0 1px rgba(64,158,255,0.2); } + .topic-card-actions .el-button--danger { grid-column: 1 / -1; } + .search-area { background:#f8faff; border:1px solid #e8edf5; border-radius:8px; padding:12px 16px; margin-bottom:12px; } + .search-area .el-form-item { margin-bottom:6px; } + .search-toggle { cursor:pointer; user-select:none; font-size:14px; color:#409eff; } } @@ -53,9 +56,52 @@ 待发布 ({{ statusStats.ready }}) 已发布 ({{ statusStats.published }})
- +
+ 🔍 {{ showSearch ? '收起搜索' : '展开搜索' }} + 筛选条件已生效,共 {{ filteredTopics.length }} 条 +
+
+ + + + + + + + + + ~ + + + + + ~ + + + + + + + ~ + + + + + ~ + + + + + + 查询 + 重置 + + + +
+ - + @@ -64,9 +110,10 @@ - - - + + + +