配置全面迁移数据库:PromptConfig、TaskConfig动态调度、敏感词/清洗规则/趋势映射/平台标签/痛点模板全部可编辑

- 新增 PromptConfig 模型 + API,支持提示词在线编辑(16条默认)
- 调度器动态读取 TaskConfig.schedule,admin 可调执行时间
- 新增 KeywordDomainMap、SensitiveWord、ContentCleanRule、TrendFieldMapping 表
- DOMAINS、TREND_DOMAIN_MAP、PLATFORM_TAGS、china_pains、RSS关键词、priority_weights 全部迁移到 DB
- tasks.html 重构:卡片网格+配置/产出/历史/提示词四个Tab,折叠显示
- 清理冗余代码:DEFAULT_PROMPTS死代码、collector.py unreachable代码、compliance_checker bug
- strip_thinking_html 改用 DB 规则优先
This commit is contained in:
Yuzhiran Dev
2026-05-22 11:18:23 +08:00
parent a8e0a76e07
commit 1855f190f5
31 changed files with 2927 additions and 1127 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
- **Backend**: FastAPI 0.104 + SQLAlchemy 2.0 + PostgreSQL 15 (`yzr_nr`)
- **Frontend**: Vue 3 (CDN, no build step) + Element Plus — static HTML served by FastAPI
- **Auth**: JWT (`python-jose` + bcrypt), default admin `admin/admin123`
- **Scheduler**: APScheduler (daily cron: 01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources)
- **Scheduler**: APScheduler (daily cron: 01:00 searchcache, 01:10 trends, 01:30 collect, 02:00 generate, 03:00 optimize, 05:00 sources, 06:00 metrics)
- **Task DB**: `TaskLog` (module_id/status/error_trace/result_data/triggered_by) + `TaskConfig` (params/enabled/schedule)
- **LLM**: Multi-provider (opencode-go primary, nvidia backup). API keys only in `.env`, not DB.
## Commands
+11 -3
View File
@@ -3,7 +3,7 @@
> 本文件为项目进度唯一真理源,所有进度信息以此为准。
> 其他文档中的进度描述一律以本文为准。
**最后更新**2026-05-18 (v16)
**最后更新**2026-05-22 (v17)
---
@@ -47,7 +47,7 @@
| 模块 | 状态 | 说明 |
|------|------|------|
| 后端 API (auth/topics/articles/publishing/calendar/metrics/assets/tasks/platform_config/admin) | ✅ 完成 | 核心 11 个 API 模块,JWT 认证 |
| 扩展 API (cases/audit/llm_configs/system_configs/optimizer_logs/task_logs) | ✅ 完成 | 新增案例库、审计、LLM 配置等模块 |
| 扩展 API (cases/audit/llm_configs/system_configs/optimizer_logs/task_logs/task_configs) | ✅ 完成 | 新增案例库、审计、LLM配置、任务配置、任务运行记录模块 |
| 前端页面 (仪表盘/选题/日历/数据/素材/任务/平台/系统管理/用户/日志/文章) | ✅ 完成 | Vue 3 + Element Plus SPA |
| 数据库 (PostgreSQL 15) | ✅ 运行中 | `yzr_nr` 库 |
| 服务 | ✅ 运行中 | 端口 8001 |
@@ -137,14 +137,22 @@
| metrics 冗余统计移除 | 2026-05-18 | 删除与仪表盘重复的 4 个概览卡片 |
| writer max_tokens 提升 | 2026-05-19 | 标题/标签 max_tokens 500→1000,修复推理模型思考链占用导致输出截断 |
| 预览滚动条修复 | 2026-05-19 | 改用 height:68vh 替代 flex+calc,避免 Element Plus 对话框内滚动冲突 |
| 定时任务 DB 化 | 2026-05-22 | TaskLog 新增 module_id/error_trace/triggered_by/result_dataTaskConfig 模型新建含 params/schedule/enabledscheduler.py 全 7 个任务执行前后写 TaskRunmodules/status 从 DB 读取 |
| 平台配置弹窗修复 | 2026-05-22 | el-dialog 移入 tab 内部(与表格同级)解决响应式问题;表单字段补全 |
| AI 思考内容清洗 | 2026-05-22 | 新建 content_cleaner.py 集中管理清洗规则;writer.py/compliance_optimizer.py 统一引用;THINKING_PATTERNS 增强;strip_ai_preface 处理代码围栏块 |
| 复制正文去噪音 | 2026-05-22 | copyContent 只提取 p/h1-h4/li 元素,去 style/svg/script/img |
| platforms.html 入口合并 | 2026-05-22 | uni-nav 移除"平台"独立入口;admin.html 恢复"平台配置"tab 加启用中/全部筛选 |
| opencode_search.py 日志 | 2026-05-22 | 补 FileHandler + StreamHandler,解决管理后台显示"从未运行" |
| scheduler.json 导入修复 | 2026-05-22 | 补 import json,修复 sources(05:00) 执行时报错阻断 metrics(06:00) |
### ⏳ 待办
| 任务 | 优先级 | 备注 |
|------|--------|------|
| admin.html 任务管理 tabTaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI |
| M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 |
| 现有文章重新创作(清除 AI 思考内容) | 中 | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py |
| 数据追踪接入 (阅读量/互动) | 中 | 需要对接平台 API |
| 选题库数据库同步 | 低 | 将选题从 markdown 同步至 PG |
| 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 |
---
+136
View File
@@ -1,4 +1,140 @@
[
{
"id": "SUS-6FC92B82",
"country": "Global",
"category": "循环消费",
"title": "氪星晚报|马斯克旗下SpaceX启动史上最大规模IPO计划;全国首张综合性司机服务地图上线;海关总署在粤发布《海关支持粤港澳大湾区建设若干措施》",
"core_idea": "<h2><strong>大公司:</strong></h2>\n <p><a href=\"https://36kr.com/newsflashes/3818743882974345\" rel=\"noopener noreferrer\" target=\"_blank\"><strong>SpaceX据悉计划五年内实现每年1万次发射</strong></a></p>\n <p>据美国联邦航空管理局(FA",
"data_facts": "数据点: 50, 40, 50",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3816942732133510?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-5E0A0291",
"country": "Global",
"category": "循环消费",
"title": "新石器NewClaw:AI一体化解决方案,零门槛当无人车指挥官| 2026AI Partner·北京亦庄AI+产业大会",
"core_idea": "<blockquote>\n <p>管理一千台无人车需要多少人?答案是:一个人,一部手机,一句话。当自动驾驶逐渐“平权”,真正的瓶颈从技术转向了规模化运营。</p>\n </blockquote>\n <p>新石器用七年时间走完从合规落地、规模量产到万台运营的三级跳,如今推出AI Agent“Neo Claw”——让用户像聊天一样指挥车队,把单人管理效率从10台拉升到100台以上。颉晶华强调,A",
"data_facts": "数据点: 50, 1, 12",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3818927367046018?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-47A4DB4F",
"country": "Global",
"category": "循环消费",
"title": "业绩快报 | 唯品会一季度净营收266亿元,SVIP用户贡献超50%线上销售额",
"core_idea": "<p>5月21日美股盘前,唯品会发布2025年第一季度财报。一季度内,唯品会实现净营收266亿元(人民币,下同),Non-GAAP净利润23亿元。</p>\n <p>观察核心运营数据,一季度其实现商品交易总额(GMV)569亿元,同比增长8.6%,订单量1.73亿单,同比增长3.2%。同时,平台于该季度活跃用户数为4170万,同比实现正增长。</p>\n <p>在春节期间穿戴和年货需求集中释放、透",
"data_facts": "数据点: 8.6, 3.2, 15",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3818915823764610?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-8064DDFA",
"country": "Global",
"category": "循环消费",
"title": "从概念到产线一:AI在工业制造领域的深水区探索| 2026AI Partner·北京亦庄AI+产业大会",
"core_idea": "<blockquote>\n <p>AI在工业制造领域,不是“锦上添花”的辅助工具,而是“重新设计工厂”的核心引擎。从AOI报废板的秒级识别,到刀具参数的动态优化,再到打通设计、生产、供应链的全链路智能——这场对话告诉我们,每1%的效率提升都是真金白银,AI的价值不是叠加功能,而是把“人等货”变成“货等人”。</p>\n </blockquote>\n <p>演讲拆解了AI从概念到产线的落地路径",
"data_facts": "数据点: 1, 1, 80",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3818903062004870?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-43DC92C1",
"country": "US",
"category": "循环消费",
"title": "城市级AI服务:从试点到常态化,机器人的实景作战与规模化落地| 2026AI Partner·北京亦庄AI+产业大会",
"core_idea": "<blockquote>\n <p>当Robotaxi还在为L4苦苦挣扎时,酷哇的环卫机器人、无人小巴、机器狗已经在50多个城市“上岗”赚钱了。</p>\n </blockquote>\n <p>具身智能最大的瓶颈不是算法,而是数据——没有量产就没有数据,没有数据就无法进化。酷哇的解法是“以战养战”:让机器人在真实运营中一边干活一边成长,用万台规模反哺模型迭代。李柯宏强调,中国是全球少有的支持机",
"data_facts": "数据点: 20, 20, 5500",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3818889074557829?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-82A67B8B",
"country": "Global",
"category": "循环消费",
"title": "把确定性,写进农业:四个外行、两次失败、三千万学费换来的答案| 2026AI Partner·北京亦庄AI+产业大会",
"core_idea": "<blockquote>\n <p>两次失败、三千万学费——创业没有爽剧剧本,这是陆渔科技深耕农业AI真实的“入场券”。当99%的人还在用AI写文案、做设计时,有人把它扔进了鱼塘,只为解决一个最朴素的问题:不确定性。</p>\n </blockquote>\n <p>鲁敏用18年IT男转型“新农民”的经历,揭开了水产养殖最残酷的真相:1.38万亿的市场,数字化渗透率不足5%,一叶方塘,百万归零。",
"data_facts": "数据点: 99, 5, 300",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3818800487679111?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-20916619",
"country": "Global",
"category": "循环消费",
"title": "从算力到价值:AI时代的基础设施重构与产业增长新引擎| 2026AI Partner·北京亦庄AI+产业大会",
"core_idea": "<blockquote>\n <p>token经济如何重塑AI产业链?从芯片到智算中心,从模型服务到终端应用,token正在成为贯穿全链的计价单位。而当推理算力需求超越训练,智算中心的角色正从算力仓库变为token工厂,一个万亿级市场的大门刚刚打开。</p>\n </blockquote>\n <p>token正在成为AI时代的新质生产力单位。宋琛指出,随着Agent成为新交互入口,单次任务to",
"data_facts": "数据点: 61, 60, 35",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3817502775329667?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-37567F2B",
"country": "Global",
"category": "循环消费",
"title": "开场致辞 建设“全域人工智能之城” | 2026AI Partner·北京亦庄AI+产业大会",
"core_idea": "<blockquote>\n <p>AI的聚光灯正从炫酷的C端应用,转向轰鸣的工厂、无声的手术室和奔跑的人形机器人。当“落地”成为当下的关键词,2026北京亦庄AI+产业大会吹响了“走!带着AI去前线”的号角,在产业第一线求解人工智能的真实生产力。我们记录下这场务实者的聚会,捕捉那些让技术扎根泥土的坚定声音。</p>\n </blockquote>\n <p>5月19日,2026北京亦庄AI+产",
"data_facts": "数据点: 1.37, 40, 75",
"global_advantage": "需进一步分析全球优势",
"china_pain_point": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"localization_suggestion": "需基于中国现实调整实施",
"mvp_action": "建议先小规模试点验证",
"source_url": "https://36kr.com/p/3818784445465731?f=rss",
"credibility_rating": "⭐",
"china_applicability": "⭐⭐",
"collection_date": "2026-05-22",
"status": "待验证"
},
{
"id": "SUS-0A6B894E",
"country": "US",
+390
View File
@@ -0,0 +1,390 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
from datetime import datetime
import json
from ..database import get_db
from ..models import KeywordDomainMap, SensitiveWord, ContentCleanRule, TrendFieldMapping, CollectorCategory
from .auth import get_current_admin
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
DEFAULT_KEYWORD_DOMAIN_MAP = [
{"pattern": r"AI|人工智能|大模型|GPT|机器学习|深度学习|聊天机器人|LLM", "domain": "AI工具", "sort_order": 1},
{"pattern": r"远程|居家办公|自由职业|数字游民|远程协作", "domain": "远程工作", "sort_order": 2},
{"pattern": r"可持续|环保|低碳|绿色|碳中和|循环|零浪费|垃圾分类|节能", "domain": "可持续生活", "sort_order": 3},
{"pattern": r"知识管理|笔记|Obsidian|Notion|第二大脑|读书|阅读", "domain": "知识管理", "sort_order": 4},
{"pattern": r"数字生活|数码|手机|电脑|智能|APP|应用、软件", "domain": "数字生活", "sort_order": 5},
{"pattern": r"科技|人文|教育|心理|哲学|社会学", "domain": "科技人文", "sort_order": 6},
]
DEFAULT_TREND_FIELD_MAP = [
{"trend_keyword": "远程工作", "field_name": "未来工作方式", "sort_order": 1},
{"trend_keyword": "AI工具", "field_name": "AI与效率", "sort_order": 2},
{"trend_keyword": "可持续生活", "field_name": "可持续生活系统", "sort_order": 3},
{"trend_keyword": "知识管理", "field_name": "个人知识工厂", "sort_order": 4},
{"trend_keyword": "数字生活", "field_name": "科技人文交叉", "sort_order": 5},
{"trend_keyword": "科技人文", "field_name": "科技人文交叉", "sort_order": 6},
{"trend_keyword": "个人成长", "field_name": "个人成长", "sort_order": 7},
{"trend_keyword": "副业", "field_name": "个人成长", "sort_order": 8},
{"trend_keyword": "AI创作", "field_name": "AI与效率", "sort_order": 9},
{"trend_keyword": "未来工作", "field_name": "未来工作方式", "sort_order": 10},
{"trend_keyword": "效率工具", "field_name": "AI与效率", "sort_order": 11},
{"trend_keyword": "家庭教育", "field_name": "科技人文交叉", "sort_order": 12},
]
DEFAULT_CHINA_PAIN_TEMPLATES = {
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证",
}
DEFAULT_GLOBAL_RSS_KEYWORDS = ['sustainable', 'green', 'eco', 'circular', 'climate', 'carbon', 'zero waste', 'renewable', 'recycle', '环保', '可持续', '碳中和', '循环经济', '零浪费', '低碳', '生态']
DEFAULT_PRIORITY_WEIGHTS = {"audience_match": 0.3, "data_availability": 0.25, "uniqueness": 0.2, "executability": 0.15, "brand_fit": 0.1}
DEFAULT_DOMAINS = ["远程工作", "AI工具", "可持续生活", "知识管理", "数字生活", "科技人文"]
DEFAULT_SENSITIVE_WORDS = [
{"word": "国家主席", "category": "political"},
{"word": "政治局", "category": "political"},
{"word": "常委", "category": "political"},
{"word": "军委", "category": "political"},
{"word": "统战部", "category": "political"},
{"word": "颠覆国家", "category": "political"},
{"word": "分裂主义", "category": "political"},
{"word": "台独", "category": "political"},
{"word": "疆独", "category": "political"},
{"word": "藏独", "category": "political"},
{"word": "赌博", "category": "prohibited"},
{"word": "毒品", "category": "prohibited"},
{"word": "迷药", "category": "prohibited"},
{"word": "枪支", "category": "prohibited"},
{"word": "炸药", "category": "prohibited"},
{"word": "色情", "category": "prohibited"},
{"word": "低俗", "category": "prohibited"},
{"word": "反动", "category": "prohibited"},
{"word": "邪教", "category": "prohibited"},
{"word": "保证赚钱", "category": "misleading"},
{"word": "一夜暴富", "category": "misleading"},
{"word": "100%有效", "category": "misleading"},
{"word": "包治百病", "category": "misleading"},
{"word": "绝对正确", "category": "misleading"},
{"word": "国家机密", "category": "legal"},
{"word": "军事秘密", "category": "legal"},
{"word": "绝密", "category": "legal"},
{"word": "迷信", "category": "legal"},
]
DEFAULT_CONTENT_CLEAN_RULES = [
{"rule_type": "thinking", "pattern": r"^(好的|好的,|好[的,]|我来|让我|我将|我这就).*?(?=\n|$)", "description": "AI思考模式1", "sort_order": 1},
{"rule_type": "thinking", "pattern": r"^(以下|下面是|这是|为您|根据).*?(?=\n|$)", "description": "AI思考模式2", "sort_order": 2},
{"rule_type": "preface", "pattern": r"^(基于|\u3010.*?\u3011|这里.*)", "description": "AI前缀模式", "sort_order": 3},
{"rule_type": "verbosity", "pattern": r"^首先|^其次|^最后", "description": "AI废话-首先其次", "sort_order": 4},
{"rule_type": "verbosity", "pattern": r"^总的来说$", "description": "AI废话-总的来说", "sort_order": 5},
{"rule_type": "verbosity", "pattern": r"^值得注意的是$", "description": "AI废话-值得注意的是", "sort_order": 6},
{"rule_type": "verbosity", "pattern": r"^换句话说$", "description": "AI废话-换句话说", "sort_order": 7},
{"rule_type": "verbosity", "pattern": r"^总而言之$", "description": "AI废话-总而言之", "sort_order": 8},
{"rule_type": "verbosity", "pattern": r"^简而言之$", "description": "AI废话-简而言之", "sort_order": 9},
{"rule_type": "verbosity", "pattern": r"^一言以蔽之$", "description": "AI废话-一言以蔽之", "sort_order": 10},
{"rule_type": "verbosity", "pattern": r"^可以说$", "description": "AI废话-可以说", "sort_order": 11},
{"rule_type": "verbosity", "pattern": r"^不难发现$", "description": "AI废话-不难发现", "sort_order": 12},
{"rule_type": "verbosity", "pattern": r"^由此可见$", "description": "AI废话-由此可见", "sort_order": 13},
{"rule_type": "verbosity", "pattern": r"^综上所述$", "description": "AI废话-综上所述", "sort_order": 14},
{"rule_type": "verbosity", "pattern": r"^通过以上", "description": "AI废话-通过以上", "sort_order": 15},
{"rule_type": "html_thinking", "pattern": r"<p[^>]*>(好的|好的,|好[的,]|我来|让我|我将|我这就)", "description": "AI思考-HTML模式", "sort_order": 16},
]
class KeywordDomainMapResponse(BaseModel):
id: int
pattern: str
domain: str
sort_order: int
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class KeywordDomainMapCreate(BaseModel):
pattern: str
domain: str
sort_order: int = 0
class KeywordDomainMapUpdate(BaseModel):
pattern: Optional[str] = None
domain: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class SensitiveWordResponse(BaseModel):
id: int
word: str
category: str
is_active: bool
added_by: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class SensitiveWordCreate(BaseModel):
word: str
category: str = "general"
class ContentCleanRuleResponse(BaseModel):
id: int
rule_type: str
pattern: str
description: Optional[str] = None
is_active: bool
sort_order: int
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class ContentCleanRuleCreate(BaseModel):
rule_type: str
pattern: str
description: Optional[str] = None
sort_order: int = 0
class ContentCleanRuleUpdate(BaseModel):
pattern: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
sort_order: Optional[int] = None
class TrendFieldMappingResponse(BaseModel):
id: int
trend_keyword: str
field_name: str
sort_order: int
is_active: bool
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TrendFieldMappingCreate(BaseModel):
trend_keyword: str
field_name: str
sort_order: int = 0
class TrendFieldMappingUpdate(BaseModel):
trend_keyword: Optional[str] = None
field_name: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class SystemConfigValueResponse(BaseModel):
key: str
value: Optional[str] = None
description: Optional[str] = None
def _ensure_defaults(db: Session):
if db.query(KeywordDomainMap).count() == 0:
for item in DEFAULT_KEYWORD_DOMAIN_MAP:
db.add(KeywordDomainMap(**item))
if db.query(SensitiveWord).count() == 0:
for item in DEFAULT_SENSITIVE_WORDS:
db.add(SensitiveWord(**item))
if db.query(ContentCleanRule).count() == 0:
for item in DEFAULT_CONTENT_CLEAN_RULES:
db.add(ContentCleanRule(**item))
if db.query(TrendFieldMapping).count() == 0:
for item in DEFAULT_TREND_FIELD_MAP:
db.add(TrendFieldMapping(**item))
for name, pain in DEFAULT_CHINA_PAIN_TEMPLATES.items():
cat = db.query(CollectorCategory).filter(CollectorCategory.name == name).first()
if cat and not cat.pain_template:
cat.pain_template = pain
db.commit()
@router.get("/keyword-domain-map", response_model=List[KeywordDomainMapResponse])
def list_keyword_domain_map(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
return db.query(KeywordDomainMap).order_by(KeywordDomainMap.sort_order).all()
@router.post("/keyword-domain-map", response_model=KeywordDomainMapResponse)
def create_keyword_domain_map(data: KeywordDomainMapCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = KeywordDomainMap(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/keyword-domain-map/{item_id}", response_model=KeywordDomainMapResponse)
def update_keyword_domain_map(item_id: int, data: KeywordDomainMapUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(KeywordDomainMap).filter(KeywordDomainMap.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/keyword-domain-map/{item_id}")
def delete_keyword_domain_map(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(KeywordDomainMap).filter(KeywordDomainMap.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/sensitive-words", response_model=List[SensitiveWordResponse])
def list_sensitive_words(category: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
q = db.query(SensitiveWord)
if category:
q = q.filter(SensitiveWord.category == category)
return q.order_by(SensitiveWord.id).all()
@router.post("/sensitive-words", response_model=SensitiveWordResponse)
def create_sensitive_word(data: SensitiveWordCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = SensitiveWord(**data.model_dump(), added_by=admin_user.username)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/sensitive-words/{item_id}", response_model=SensitiveWordResponse)
def update_sensitive_word(item_id: int, enabled: bool = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(SensitiveWord).filter(SensitiveWord.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
if enabled is not None:
item.is_active = enabled
db.commit()
db.refresh(item)
return item
@router.delete("/sensitive-words/{item_id}")
def delete_sensitive_word(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(SensitiveWord).filter(SensitiveWord.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/content-clean-rules", response_model=List[ContentCleanRuleResponse])
def list_content_clean_rules(rule_type: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
q = db.query(ContentCleanRule)
if rule_type:
q = q.filter(ContentCleanRule.rule_type == rule_type)
return q.order_by(ContentCleanRule.sort_order).all()
@router.post("/content-clean-rules", response_model=ContentCleanRuleResponse)
def create_content_clean_rule(data: ContentCleanRuleCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = ContentCleanRule(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/content-clean-rules/{item_id}", response_model=ContentCleanRuleResponse)
def update_content_clean_rule(item_id: int, data: ContentCleanRuleUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(ContentCleanRule).filter(ContentCleanRule.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/content-clean-rules/{item_id}")
def delete_content_clean_rule(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(ContentCleanRule).filter(ContentCleanRule.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/trend-field-mapping", response_model=List[TrendFieldMappingResponse])
def list_trend_field_mapping(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
return db.query(TrendFieldMapping).filter(TrendFieldMapping.is_active == True).order_by(TrendFieldMapping.sort_order).all()
@router.post("/trend-field-mapping", response_model=TrendFieldMappingResponse)
def create_trend_field_mapping(data: TrendFieldMappingCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = TrendFieldMapping(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/trend-field-mapping/{item_id}", response_model=TrendFieldMappingResponse)
def update_trend_field_mapping(item_id: int, data: TrendFieldMappingUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(TrendFieldMapping).filter(TrendFieldMapping.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/trend-field-mapping/{item_id}")
def delete_trend_field_mapping(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(TrendFieldMapping).filter(TrendFieldMapping.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/system-config/{key}", response_model=SystemConfigValueResponse)
def get_system_config(key: str, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
from ..models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if not cfg:
default_map = {
"trend_domains": json.dumps(DEFAULT_DOMAINS),
"rss_default_keywords": json.dumps(DEFAULT_GLOBAL_RSS_KEYWORDS),
"priority_weights": json.dumps(DEFAULT_PRIORITY_WEIGHTS),
}
if key in default_map:
return SystemConfigValueResponse(key=key, value=default_map[key], description=f"系统默认配置 - {key}")
raise HTTPException(status_code=404, detail="未找到")
return SystemConfigValueResponse(key=cfg.key, value=cfg.value, description=cfg.description)
@router.put("/system-config/{key}")
def update_system_config(key: str, value: str, description: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
from ..models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if cfg:
cfg.value = value
if description is not None:
cfg.description = description
else:
cfg = SystemConfig(key=key, value=value, description=description or key)
db.add(cfg)
db.commit()
return {"message": "保存成功", "key": key, "value": value}
@router.get("/system-configs")
def list_system_configs(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
from ..models import SystemConfig
configs = db.query(SystemConfig).order_by(SystemConfig.key).all()
defaults = {
"trend_domains": json.dumps(DEFAULT_DOMAINS),
"rss_default_keywords": json.dumps(DEFAULT_GLOBAL_RSS_KEYWORDS),
"priority_weights": json.dumps(DEFAULT_PRIORITY_WEIGHTS),
}
result = []
for c in configs:
result.append({"key": c.key, "value": c.value, "description": c.description, "is_default": False})
for k, v in defaults.items():
if not any(x["key"] == k for x in result):
result.append({"key": k, "value": v, "description": f"系统默认 - {k}", "is_default": True})
return result
+117
View File
@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from ..database import get_db
from ..models import PromptConfig
from .auth import get_current_admin
router = APIRouter(prefix="/api/admin/prompt-configs", tags=["admin"])
class PromptConfigBase(BaseModel):
key: str
module_id: Optional[str] = None
category: str = "prompt"
version: str = "v1"
content: str
variables: List[dict] = []
description: Optional[str] = None
enabled: bool = True
temperature: Optional[float] = None
max_tokens: Optional[int] = None
created_by: Optional[str] = None
class PromptConfigCreate(PromptConfigBase):
pass
class PromptConfigUpdate(BaseModel):
content: Optional[str] = None
variables: Optional[List[dict]] = None
description: Optional[str] = None
enabled: Optional[bool] = None
temperature: Optional[float] = None
max_tokens: Optional[int] = None
class PromptConfigResponse(PromptConfigBase):
id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
@router.get("", response_model=List[PromptConfigResponse])
def list_prompts(
module_id: Optional[str] = None,
category: Optional[str] = None,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
q = db.query(PromptConfig)
if module_id:
q = q.filter(PromptConfig.module_id.in_([module_id, "all"]))
if category:
q = q.filter(PromptConfig.category == category)
prompts = q.order_by(PromptConfig.module_id, PromptConfig.key).all()
if not prompts:
_ensure_defaults(db)
prompts = q.order_by(PromptConfig.module_id, PromptConfig.key).all()
return [PromptConfigResponse.model_validate(p) for p in prompts]
@router.get("/{prompt_id}", response_model=PromptConfigResponse)
def get_prompt(prompt_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
prompt = db.query(PromptConfig).filter(PromptConfig.id == prompt_id).first()
if not prompt:
raise HTTPException(status_code=404, detail="未找到该配置")
return prompt
@router.post("", response_model=PromptConfigResponse)
def create_prompt(data: PromptConfigCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
existing = db.query(PromptConfig).filter(PromptConfig.key == data.key).first()
if existing:
raise HTTPException(status_code=400, detail=f"key '{data.key}' 已存在")
prompt = PromptConfig(**data.model_dump())
db.add(prompt)
db.commit()
db.refresh(prompt)
return prompt
@router.put("/{prompt_id}", response_model=PromptConfigResponse)
def update_prompt(prompt_id: int, data: PromptConfigUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
prompt = db.query(PromptConfig).filter(PromptConfig.id == prompt_id).first()
if not prompt:
raise HTTPException(status_code=404, detail="未找到该配置")
for field, value in data.model_dump(exclude_unset=True).items():
if value is not None:
setattr(prompt, field, value)
db.commit()
db.refresh(prompt)
return prompt
@router.delete("/{prompt_id}")
def delete_prompt(prompt_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
prompt = db.query(PromptConfig).filter(PromptConfig.id == prompt_id).first()
if not prompt:
raise HTTPException(status_code=404, detail="未找到该配置")
db.delete(prompt)
db.commit()
return {"message": "删除成功"}
def _ensure_defaults(db: Session):
existing_keys = {p.key for p in db.query(PromptConfig).all()}
from .task_configs import DEFAULT_PROMPTS
for p in DEFAULT_PROMPTS:
if p["key"] not in existing_keys:
db.add(PromptConfig(**p))
db.commit()
+57 -43
View File
@@ -9,7 +9,7 @@ from typing import Dict, Any, List, Optional
import os
import json
from ..database import get_db
from ..models import Topic, Article
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
@@ -61,7 +61,7 @@ def get_status(db: Session = Depends(get_db)):
}
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
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)
@@ -93,7 +93,7 @@ def collection_status():
return status
@router.post("/review/run", dependencies=[Depends(get_current_user)])
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
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")}
@@ -247,47 +247,61 @@ def get_scheduler_status():
@router.get("/modules/status", dependencies=[Depends(get_current_user)])
def get_modules_status():
today_str = date.today().isoformat()
log_based: dict = {
"scheduled_collect": {"name": "📡 内容采集", "log": LOGS_DIR / f"collector_{today_str}.log"},
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "log": LOGS_DIR / f"opencode_search_{today_str}.log"},
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "log": LOGS_DIR / f"trends_{today_str}.log"},
"scheduled_generate": {"name": "🤖 内容创作", "log": LOGS_DIR / f"creator_{today_str}.log"},
"scheduled_optimize": {"name": "🔍 合规审查", "log": LOGS_DIR / f"optimizer_{today_str}.log"},
"scheduled_optimize_sources": {"name": "📡 信息源优化", "log": LOGS_DIR / f"optimizer_sources_{today_str}.log"},
"scheduled_metrics_sync": {"name": "📊 指标同步", "log": LOGS_DIR / f"metrics_sync_{today_str}.log"},
def get_modules_status(db: Session = Depends(get_db)):
configs = db.query(TaskConfig).all()
config_map = {c.module_id: c for c in configs}
MODULE_META = {
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "cron": "01:00", "params_desc": {"refresh_queries": "搜索关键词列表"}},
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10", "params_desc": {}},
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30", "params_desc": {"max_topics": "最大选题数", "categories": "采集类别"}},
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00", "params_desc": {"auto_review": "自动合规审查"}},
"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": {}},
}
jobs = {j['id']: j for j in scheduler.get_jobs()}
modules = []
for mod_id, cfg in log_based.items():
log_file = cfg["log"]
last_run = None
task_count = 0
success_rate = None
if log_file.exists():
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
last_run = mtime.strftime("%Y-%m-%d %H:%M")
content = log_file.read_text(encoding="utf-8", errors="ignore")
task_count = content.count("完成") + content.count("success") + content.count("SUCCESS")
total = task_count + content.count("失败") + content.count("failed") + content.count("ERROR")
success_rate = round(task_count / total * 100) if total > 0 else None
status = "running" if mod_id in jobs else "stopped"
job = jobs.get(mod_id)
next_run = None
if job and job.get("next_run_time"):
try:
next_dt = datetime.fromisoformat(job["next_run_time"])
next_run = next_dt.strftime("%Y-%m-%d %H:%M")
except Exception:
next_run = job["next_run_time"]
for mod_id, meta in MODULE_META.items():
cfg = config_map.get(mod_id)
latest = db.query(TaskLog).filter(TaskLog.module_id == mod_id).order_by(TaskLog.started_at.desc()).first()
next_run = _get_next_run(mod_id)
total = db.query(TaskLog).filter(TaskLog.module_id == mod_id).count()
success = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "success").count()
failed = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "failed").count()
running = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "running").count()
modules.append({
"id": mod_id,
"title": cfg["name"],
"status": status,
"last_run": last_run or "从未运行",
"next_run": next_run or "待计划",
"task_count": task_count,
"success_rate": success_rate if success_rate is not None else 0,
"module_id": mod_id,
"title": meta["name"],
"enabled": cfg.enabled if cfg else True,
"params": cfg.params if cfg else {},
"params_desc": meta["params_desc"],
"schedule": cfg.schedule if cfg else meta["cron"],
"cron_default": meta["cron"],
"status": "running" if running else ("stopped" if not (cfg and cfg.enabled) else "idle"),
"last_run": latest.started_at.strftime("%Y-%m-%d %H:%M") if latest and latest.started_at else None,
"last_status": latest.status if latest else None,
"last_message": latest.message if latest else None,
"last_result": latest.result_data if latest else None,
"next_run": next_run,
"total_runs": total,
"success_runs": success,
"failed_runs": failed,
"running": running,
})
return {"modules": modules, "scheduler": {"running": scheduler._started, "jobs": scheduler.get_jobs()}}
jobs = scheduler.get_jobs()
return {"modules": modules, "scheduler": {"running": scheduler._started, "jobs": jobs}}
def _get_next_run(mod_id: str) -> Optional[str]:
for job in scheduler.get_jobs():
if job["id"] == mod_id and job["next_run_time"]:
try:
dt = datetime.fromisoformat(job["next_run_time"])
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return job["next_run_time"]
return None
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from ..database import get_db
from ..models import TaskConfig, TaskLog
from ..schemas import TaskConfigBase, TaskConfigUpdate, TaskConfigResponse, TaskLogResponse
from .auth import get_current_admin
router = APIRouter(prefix="/api/admin/task-configs", tags=["admin"])
DEFAULT_CONFIGS = {
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "cron": "01:00", "params": {}},
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10", "params": {}},
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30", "params": {"max_topics": 20}},
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00", "params": {"auto_review": True}},
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00", "params": {"auto_pass_threshold": 80}},
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00", "params": {}},
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00", "params": {}},
}
@router.get("", response_model=List[TaskConfigResponse])
def list_configs(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
configs = db.query(TaskConfig).order_by(TaskConfig.id).all()
if not configs:
_ensure_defaults(db)
configs = db.query(TaskConfig).order_by(TaskConfig.id).all()
return [TaskConfigResponse.model_validate(c) for c in configs]
@router.get("/{module_id}", response_model=TaskConfigResponse)
def get_config(module_id: str, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
cfg = db.query(TaskConfig).filter(TaskConfig.module_id == module_id).first()
if not cfg:
_ensure_defaults(db)
cfg = db.query(TaskConfig).filter(TaskConfig.module_id == module_id).first()
return cfg
@router.put("/{module_id}", response_model=TaskConfigResponse)
def update_config(module_id: str, data: TaskConfigUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
cfg = db.query(TaskConfig).filter(TaskConfig.module_id == module_id).first()
if not cfg:
_ensure_defaults(db)
cfg = db.query(TaskConfig).filter(TaskConfig.module_id == module_id).first()
if data.enabled is not None:
cfg.enabled = data.enabled
if data.params is not None:
cfg.params = data.params
if data.schedule is not None:
cfg.schedule = data.schedule
if data.last_modified_by:
cfg.last_modified_by = data.last_modified_by
db.commit()
db.refresh(cfg)
return cfg
@router.get("/history/{module_id}", response_model=List[TaskLogResponse])
def get_module_history(module_id: str, db: Session = Depends(get_db), admin_user=Depends(get_current_admin), limit: int = 20):
logs = db.query(TaskLog).filter(TaskLog.module_id == module_id).order_by(TaskLog.started_at.desc()).limit(limit).all()
return [TaskLogResponse.model_validate(l) for l in logs]
def _ensure_defaults(db: Session):
existing = {c.module_id for c in db.query(TaskConfig).all()}
for mid, info in DEFAULT_CONFIGS.items():
if mid not in existing:
db.add(TaskConfig(module_id=mid, enabled=True, params=info.get("params", {}), schedule=info.get("cron", "")))
db.commit()
+60 -46
View File
@@ -1,55 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import List, Optional
from datetime import datetime, timezone
from ..database import get_db
from ..models import TaskLog
from ..schemas import TaskLogBase, TaskLogResponse
from .auth import get_current_admin
router = APIRouter(prefix="/api/admin/tasklogs", tags=["admin"])
router = APIRouter(prefix="/api/admin/task-logs", tags=["admin"])
MODULES = {
"scheduled_refresh_search_cache": "🔍 搜索缓存",
"scheduled_fetch_trends": "🔥 热点趋势",
"scheduled_collect": "📡 内容采集",
"scheduled_generate": "🤖 内容创作",
"scheduled_optimize": "🔍 合规审查",
"scheduled_optimize_sources": "📡 信息源优化",
"scheduled_metrics_sync": "📊 指标同步",
}
@router.get("", response_model=List[TaskLogResponse])
def list_task_logs(
request: Request,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
topic_id: Optional[str] = None,
task_name: Optional[str] = None,
status: Optional[str] = None
module_id: Optional[str] = None,
status: Optional[str] = None,
date: Optional[str] = None,
limit: int = 50,
):
"""获取任务日志列表(可过滤)"""
query = db.query(TaskLog)
if topic_id:
query = query.filter(TaskLog.topic_id == topic_id)
if task_name:
query = query.filter(TaskLog.task_name == task_name)
if module_id:
query = query.filter(TaskLog.module_id == module_id)
if status:
query = query.filter(TaskLog.status == status)
logs = query.order_by(TaskLog.started_at.desc()).all()
if date:
try:
dt = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
next_day = datetime(dt.year, dt.month, dt.day + 1, tzinfo=timezone.utc)
query = query.filter(TaskLog.started_at >= dt, TaskLog.started_at < next_day)
except ValueError:
pass
logs = query.order_by(TaskLog.started_at.desc()).limit(limit).all()
return [TaskLogResponse.model_validate(l) for l in logs]
@router.get("/modules")
def list_modules(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
today = datetime.now(timezone.utc).date().isoformat()
result = []
for mid, name in MODULES.items():
latest = db.query(TaskLog).filter(TaskLog.module_id == mid).order_by(TaskLog.started_at.desc()).first()
total = db.query(TaskLog).filter(TaskLog.module_id == mid).count()
success = db.query(TaskLog).filter(TaskLog.module_id == mid, TaskLog.status == "success").count()
failed = db.query(TaskLog).filter(TaskLog.module_id == mid, TaskLog.status == "failed").count()
running = db.query(TaskLog).filter(TaskLog.module_id == mid, TaskLog.status == "running").count()
result.append({
"module_id": mid,
"name": name,
"last_run": latest.started_at.isoformat() if latest and latest.started_at else None,
"last_status": latest.status if latest else None,
"last_message": latest.message if latest else None,
"total_runs": total,
"success_runs": success,
"failed_runs": failed,
"running": running,
})
return result
@router.get("/{log_id}", response_model=TaskLogResponse)
def get_task_log(
log_id: int,
request: Request,
db: Session = Depends(get_db),
admin_user = Depends(get_current_admin)
):
"""获取单个任务日志详情"""
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()
if not log:
raise HTTPException(status_code=404, detail="日志不存在")
raise HTTPException(status_code=404, detail="记录不存在")
return log
@router.post("", response_model=TaskLogResponse)
def create_task_log(
log_data: TaskLogBase,
request: Request,
db: Session = Depends(get_db),
admin_user = Depends(get_current_admin)
):
"""创建任务日志(用于手动记录)"""
def create_task_log(log_data: TaskLogBase, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
log = TaskLog(**log_data.model_dump())
db.add(log)
db.commit()
@@ -57,35 +84,22 @@ def create_task_log(
return log
@router.put("/{log_id}", response_model=TaskLogResponse)
def update_task_log(
log_id: int,
log_update: TaskLogBase,
request: Request,
db: Session = Depends(get_db),
admin_user = Depends(get_current_admin)
):
"""更新任务日志"""
def update_task_log(log_id: int, log_update: TaskLogBase, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
if not log:
raise HTTPException(status_code=404, detail="日志不存在")
update_data = log_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
raise HTTPException(status_code=404, detail="记录不存在")
data = log_update.model_dump(exclude_unset=True)
for field, value in data.items():
setattr(log, field, value)
db.commit()
db.refresh(log)
return log
@router.delete("/{log_id}")
def delete_task_log(
log_id: int,
request: Request,
db: Session = Depends(get_db),
admin_user = Depends(get_current_admin)
):
"""删除任务日志"""
def delete_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()
if not log:
raise HTTPException(status_code=404, detail="日志不存在")
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(log)
db.commit()
return {"message": "删除成功"}
+25 -3
View File
@@ -213,6 +213,25 @@ def get_module_detail(module_id: str, db: Session = Depends(get_db), current_use
LOGS_DIR = ROOT / "automation" / "logs"
today_str = date_mod.today().isoformat()
try:
return _get_module_detail_data(module_id, db, ROOT, DATA_DIR, LOGS_DIR, today_str)
except Exception as e:
return {
"module_id": module_id,
"title": module_id,
"description": "",
"status": "stopped",
"inputs": {},
"outputs": {"error": str(e)},
"history": [],
"log_excerpt": "",
}
def _get_module_detail_data(module_id: str, db, ROOT, DATA_DIR, LOGS_DIR, today_str):
from datetime import datetime as dt_mod, date as date_mod
from pathlib import Path as PathMod
import json as json_mod
import re as re_mod
MODULE_META = {
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "description": "通过 opencode webfetch 联网搜索,刷新 8 个分类的搜索缓存,供内容采集器使用"},
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "description": "从百度、微博、知乎实时热搜 API 抓取当天热点,LLM 补充,存入 trends.json"},
@@ -279,7 +298,7 @@ def get_module_detail(module_id: str, db: Session = Depends(get_db), current_use
pass
elif module_id == "scheduled_collect":
from ..models import Topic, CollectorCategory
from ..models import CollectorCategory
pending = db.query(Topic).filter(Topic.status.in_(["pending", "待处理"])).count()
total_topics = db.query(Topic).count()
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
@@ -296,16 +315,19 @@ def get_module_detail(module_id: str, db: Session = Depends(get_db), current_use
pending_t = db.query(Topic).filter(Topic.status.in_(["pending", "待处理"])).count()
inputs["待创作选题"] = pending_t
outputs["待审查"] = review
try:
from ..models import Article
recent_articles = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id, isouter=True).order_by(Article.created_at.desc()).limit(5).all()
outputs["最新文章"] = []
seen_articles = set()
for a in recent_articles:
art = a.Article if hasattr(a, 'Article') else a[0]
tid = a.topic_title if hasattr(a, 'topic_title') else (a[1] if len(a) > 1 else "")
art = a[0]
tid = a[1] if len(a) > 1 else ""
if art.id not in seen_articles:
seen_articles.add(art.id)
outputs["最新文章"].append({"id": art.id, "platform": art.platform, "topic": tid, "status": art.status, "created": art.created_at.isoformat() if art.created_at else ""})
except Exception as e:
outputs["最新文章_错误"] = str(e)
elif module_id == "scheduled_optimize":
outputs["待审查选题"] = db.query(Topic).filter(Topic.status.in_(["review", "待审查"])).count()
+108
View File
@@ -0,0 +1,108 @@
import os
from typing import Optional, Dict, Any
USE_POSTGRES = os.getenv('USE_POSTGRES', 'true').lower() == 'true'
if USE_POSTGRES:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
POSTGRES_CONFIG = {
'host': os.getenv('PG_HOST', '127.0.0.1'),
'port': os.getenv('PG_PORT', '5432'),
'database': os.getenv('PG_DATABASE', 'yzr_nr'),
'user': os.getenv('PG_USER', 'yzr_nr'),
'password': os.getenv('PG_PASSWORD', 'aTX3WKKnPfRnM5PC')
}
SQLALCHEMY_DATABASE_URL = (
f"postgresql://{POSTGRES_CONFIG['user']}:{POSTGRES_CONFIG['password']}"
f"@{POSTGRES_CONFIG['host']}:{POSTGRES_CONFIG['port']}/{POSTGRES_CONFIG['database']}"
)
_engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
else:
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
from sqlalchemy import create_engine
_engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
def _get_session():
session = _SessionLocal()
try:
return session
except:
session.close()
raise
_PROMPT_CACHE: Dict[str, Dict[str, Any]] = {}
_CACHE_VERSION = "v2"
def load_prompt_config(key: str, module_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
if key in _PROMPT_CACHE and _PROMPT_CACHE[key].get("_v") == _CACHE_VERSION:
return _PROMPT_CACHE[key]
try:
session = _get_session()
try:
from .models import PromptConfig
prompt = session.query(PromptConfig).filter(
PromptConfig.key == key,
PromptConfig.enabled == True
).first()
if not prompt and module_id:
prompt = session.query(PromptConfig).filter(
PromptConfig.key == key,
PromptConfig.module_id.in_([module_id, "all"]),
PromptConfig.enabled == True
).first()
if prompt:
result = {
"_v": _CACHE_VERSION,
"content": prompt.content,
"temperature": prompt.temperature,
"max_tokens": prompt.max_tokens,
"variables": prompt.variables or [],
"description": prompt.description,
"category": prompt.category,
}
_PROMPT_CACHE[key] = result
return result
finally:
session.close()
except Exception as e:
import warnings
warnings.warn(f"load_prompt_config({key}) failed: {e}")
return None
def clear_prompt_cache():
_PROMPT_CACHE.clear()
def get_prompt(key: str, module_id: Optional[str] = None, **kwargs) -> str:
cfg = load_prompt_config(key, module_id)
if cfg:
content = cfg["content"]
for var in cfg.get("variables", []):
name = var.get("name")
if name and name in kwargs:
content = content.replace("{" + name + "}", str(kwargs[name]))
elif name:
default = var.get("default_value", "")
content = content.replace("{" + name + "}", str(default))
return content
return ""
def get_llm_params(key: str, module_id: Optional[str] = None) -> Dict[str, Any]:
cfg = load_prompt_config(key, module_id)
if cfg:
return {
"temperature": cfg.get("temperature"),
"max_tokens": cfg.get("max_tokens"),
}
return {}
+203 -84
View File
@@ -2,11 +2,15 @@
定时任务调度器
基于 APScheduler,支持在 FastAPI 生命周期内运行定时任务
"""
import os
import sys
import logging
from datetime import datetime
import os, sys, logging, json
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT / 'scripts'))
sys.path.insert(0, str(PROJECT_ROOT))
from prompt_loader import get_prompt, get_prompt_params
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from .generator import run_creator_blocking
@@ -15,6 +19,69 @@ from .collector import run_collector_blocking
logger = logging.getLogger(__name__)
MODULES = {
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "cron": "01:00"},
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"},
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30"},
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00"},
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00"},
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00"},
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"},
}
def _log_task(module_id: str, status: str, message: str = None,
error_trace: str = None, result_data: dict = None,
started_at: datetime = None, finished_at: datetime = None,
triggered_by: str = "scheduler", next_run_time: datetime = None):
try:
from ..database import SessionLocal
from ..models import TaskLog
db = SessionLocal()
try:
duration = None
if started_at and finished_at:
duration = int((finished_at - started_at).total_seconds())
log = TaskLog(
module_id=module_id,
task_name=MODULES.get(module_id, {}).get("name", module_id),
status=status,
message=message,
error_trace=error_trace,
triggered_by=triggered_by,
result_data=result_data or {},
started_at=started_at or datetime.now(timezone.utc),
finished_at=finished_at,
duration=duration,
next_run_time=next_run_time,
)
db.add(log)
db.commit()
finally:
db.close()
except Exception:
pass
def _wrap_task(module_id: str, target_fn, *args, **kwargs):
started = datetime.now(timezone.utc)
status = "running"
error_trace = None
result_data = None
try:
result = target_fn(*args, **kwargs)
status = "success"
if isinstance(result, dict):
result_data = {k: v for k, v in result.items() if isinstance(v, (str, int, float, bool, list, dict)) and k not in ("stdout", "stderr")}
return result
except Exception as e:
status = "failed"
import traceback
error_trace = traceback.format_exc()
raise
finally:
_log_task(module_id, status=status, message=None, error_trace=error_trace,
result_data=result_data, started_at=started,
finished_at=datetime.now(timezone.utc))
class TaskScheduler:
def __init__(self):
self.scheduler = BackgroundScheduler()
@@ -24,64 +91,47 @@ class TaskScheduler:
if self._started:
logger.warning("Scheduler already started")
return
# 使用 CronTrigger 设置每日固定时间点
# 顺序: 搜索缓存(01:00)→趋势(01:10)→采集(01:30)→创作(02:00)→审查(03:00)→源优化(05:00)→指标(06:00)
from ..database import SessionLocal
from ..models import TaskConfig
db = SessionLocal()
try:
configs = {c.module_id: c for c in db.query(TaskConfig).all()}
finally:
db.close()
MODULE_JOBS = [
("scheduled_refresh_search_cache", self._run_refresh_search_cache, "搜索缓存"),
("scheduled_fetch_trends", self._run_fetch_trends, "热点趋势"),
("scheduled_collect", self._run_collect, "内容采集"),
("scheduled_generate", self._run_generate, "内容创作"),
("scheduled_optimize", self._run_optimize, "合规审查"),
("scheduled_optimize_sources", self._run_optimize_sources, "信息源优化"),
("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"),
]
for module_id, fn, name in MODULE_JOBS:
cfg = configs.get(module_id)
if cfg and not cfg.enabled:
logger.info(f"跳过禁用任务: {module_id}")
continue
schedule = (cfg.schedule if cfg else None) or MODULES.get(module_id, {}).get("cron", "01:00")
try:
hour, minute = map(int, schedule.split(":"))
except (ValueError, AttributeError):
hour, minute = 1, 0
self.scheduler.add_job(
self._run_refresh_search_cache,
CronTrigger(hour=1, minute=0),
id='scheduled_refresh_search_cache',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_fetch_trends,
CronTrigger(hour=1, minute=10),
id='scheduled_fetch_trends',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_collect,
CronTrigger(hour=1, minute=30),
id='scheduled_collect',
)
self.scheduler.add_job(
self._run_generate,
CronTrigger(hour=2, minute=0),
id='scheduled_generate',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_optimize,
CronTrigger(hour=3, minute=0),
id='scheduled_optimize',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_optimize_sources,
CronTrigger(hour=5, minute=0),
id='scheduled_optimize_sources',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_metrics_sync,
CronTrigger(hour=6, minute=0),
id='scheduled_metrics_sync',
fn,
CronTrigger(hour=hour, minute=minute),
id=module_id,
replace_existing=True,
max_instances=1,
coalesce=True
)
logger.info(f"调度任务: {module_id} -> {schedule}")
self.scheduler.start()
self._started = True
logger.info("Scheduler started: 01:00 search 01:10 trends 01:30 collect 02:00 create 03:00 review 05:00 sources 06:00 metrics")
logger.info("Scheduler started with dynamic schedule from TaskConfig")
def shutdown(self):
if self.scheduler.running:
self.scheduler.shutdown()
@@ -89,6 +139,8 @@ class TaskScheduler:
def _run_fetch_trends(self):
"""定时刷新热点趋势(百度/微博/知乎实时热搜 + LLM补充)"""
started = datetime.now(timezone.utc)
_log_task("scheduled_fetch_trends", "running", started_at=started)
try:
logger.info("[Scheduled] Fetching hot trends...")
import subprocess
@@ -100,14 +152,26 @@ class TaskScheduler:
for line in result.stdout.strip().split("\n"):
if line.strip():
logger.info("[Trends] %s", line.strip())
logger.info("[Scheduled] Trends refreshed successfully")
_log_task("scheduled_fetch_trends", "success",
message="趋势刷新成功",
result_data={"output_lines": len(result.stdout.splitlines())},
started_at=started, finished_at=datetime.now(timezone.utc))
else:
logger.warning("[Scheduled] Trends refresh failed: %s", result.stderr[-500:])
_log_task("scheduled_fetch_trends", "failed",
message=f"返回码 {result.returncode}",
error_trace=result.stderr[-500:],
started_at=started, finished_at=datetime.now(timezone.utc))
except Exception as e:
_log_task("scheduled_fetch_trends", "failed",
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Trends refresh error: %s", e)
def _run_refresh_search_cache(self):
"""定时刷新搜索缓存(通过 opencode webfetch"""
started = datetime.now(timezone.utc)
_log_task("scheduled_refresh_search_cache", "running", started_at=started)
try:
logger.info("[Scheduled] Refreshing search cache via opencode...")
import subprocess
@@ -122,48 +186,95 @@ class TaskScheduler:
if line.strip():
logger.warning("[SearchCache] %s", line.strip())
if result.returncode == 0:
_log_task("scheduled_refresh_search_cache", "success",
message="搜索缓存刷新成功",
result_data={"output_lines": len(result.stdout.splitlines())},
started_at=started, finished_at=datetime.now(timezone.utc))
logger.info("[Scheduled] Search cache refreshed")
else:
_log_task("scheduled_refresh_search_cache", "failed",
message="部分失败",
error_trace=result.stderr[-500:],
started_at=started, finished_at=datetime.now(timezone.utc))
logger.warning("[Scheduled] Search cache refresh may have partial failures")
except subprocess.TimeoutExpired:
_log_task("scheduled_refresh_search_cache", "failed",
message="超时",
started_at=started, finished_at=datetime.now(timezone.utc))
logger.warning("[Scheduled] Search cache refresh timed out")
except Exception as e:
_log_task("scheduled_refresh_search_cache", "failed",
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Search cache refresh error: %s", e)
def _run_generate(self):
started = datetime.now(timezone.utc)
_log_task("scheduled_generate", "running", started_at=started)
try:
logger.info("[Scheduled] Starting content generation...")
result = run_creator_blocking()
logger.info("[Scheduled] Generation completed: %s", result)
created_id = result.get("topic_id") if isinstance(result, dict) else None
review_result = None
if created_id:
logger.info("[Scheduled] Running compliance review on %s...", created_id)
review_result = run_optimizer_blocking([created_id])
if review_result.get("ok"):
logger.info("[Scheduled] Review completed for %s", created_id)
else:
logger.warning("[Scheduled] Review failed: %s", review_result.get("error"))
_log_task("scheduled_generate", "success",
message=f"创作完成" + (f", 选题 {created_id}" if created_id else ""),
result_data={"topic_id": created_id, "review_ok": review_result.get("ok") if review_result else None},
started_at=started, finished_at=datetime.now(timezone.utc))
except Exception as e:
_log_task("scheduled_generate", "failed",
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Generation pipeline failed: %s", e)
def _run_optimize(self):
started = datetime.now(timezone.utc)
_log_task("scheduled_optimize", "running", started_at=started)
try:
logger.info("[Scheduled] Starting compliance review...")
result = run_optimizer_blocking()
_log_task("scheduled_optimize", "success",
message="合规审查完成",
result_data={"processed": result.get("processed", 0), "passed": result.get("passed", 0)},
started_at=started, finished_at=datetime.now(timezone.utc))
logger.info("[Scheduled] Review completed: %s", result)
except Exception as e:
_log_task("scheduled_optimize", "failed",
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Review failed: %s", e)
def _run_collect(self):
started = datetime.now(timezone.utc)
_log_task("scheduled_collect", "running", started_at=started)
try:
logger.info("[Scheduled] Starting topic collection...")
result = run_collector_blocking()
topics_count = result.get("topics_count", 0)
_log_task("scheduled_collect", "success",
message=f"采集完成,找到 {topics_count} 个选题",
result_data={"topics_count": topics_count, "output": str(result.get("output", ""))[:200]},
started_at=started, finished_at=datetime.now(timezone.utc))
logger.info("[Scheduled] Collection completed: %s", result.get("output", "")[-200:])
except Exception as e:
_log_task("scheduled_collect", "failed",
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Collection failed: %s", e)
def _run_optimize_sources(self):
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
started = datetime.now(timezone.utc)
_log_task("scheduled_optimize_sources", "running", started_at=started)
try:
logger.info("[Scheduled] Starting source optimization with AI...")
from .nvidia_client import call_llm
@@ -183,32 +294,16 @@ class TaskScheduler:
cat_names = [c.name for c in cats]
src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources)
prompt = f"""你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。
prompt = get_prompt("sources_optimization",
n=len(cat_names),
cat_names="\n".join(f"- {n}" for n in cat_names),
n2=len(sources),
src_summary=src_summary,
year=datetime.now().year,
)
当前配置的类别({len(cat_names)}个):
{chr(10).join(f'- {n}' for n in cat_names)}
当前配置的信息源({len(sources)}个):
{src_summary}
请完成以下任务:
1. 评估每个类别是否仍符合2026年中国市场真实热点(基于你的知识)
2. 评估每个信息源是否可能在中国正常访问
3. 建议新增或删除的类别(最多2条)
4. 建议新增的信息源搜索词(最多3条,包含具体搜索词)
输出 JSON 格式:
{{
"category_assessment": [{{"name": "类别名", "status": "保留/淘汰/合并", "reason": "原因"}}],
"source_assessment": [{{"name": "源名", "status": "保留/淘汰/替换", "reason": "原因"}}],
"suggested_new_categories": [{{"name": "类别名", "search_query": "搜索词", "reason": "推荐原因"}}],
"suggested_new_sources": [{{"name": "源名", "type": "web_search", "query": "搜索词", "focus": "聚焦领域"}}],
"summary": "一句话总结本次优化建议"
}}
只输出JSON,不要其他文字。"""
resp = call_llm(prompt, temperature=0.5, max_tokens=2000)
params = get_prompt_params("sources_optimization")
resp = call_llm(prompt, temperature=params.get("temperature", 0.5), max_tokens=params.get("max_tokens", 3000))
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
result = json.loads(resp)
@@ -222,12 +317,25 @@ class TaskScheduler:
db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
db.commit()
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
_log_task("scheduled_optimize_sources", "success",
message=result.get("summary", "优化完成"),
result_data={"categories_assessed": len(result.get("category_assessment", [])),
"sources_assessed": len(result.get("source_assessment", [])),
"suggested_cats": len(result.get("suggested_new_categories", [])),
"suggested_srcs": len(result.get("suggested_new_sources", []))},
started_at=started, finished_at=datetime.now(timezone.utc))
db.close()
except Exception as e:
_log_task("scheduled_optimize_sources", "failed",
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
def _run_metrics_sync(self):
"""定时从各平台公开API获取发布文章的效果数据(当前仅支持知乎)"""
started = datetime.now(timezone.utc)
_log_task("scheduled_metrics_sync", "running", started_at=started)
try:
logger.info("[Scheduled] Starting metrics sync (zhihu auto-fetch)...")
from ..database import SessionLocal
@@ -286,6 +394,10 @@ class TaskScheduler:
if count:
db.commit()
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
_log_task("scheduled_metrics_sync", "success",
message=f"同步完成,{count} 篇知乎文章",
result_data={"articles_synced": count},
started_at=started, finished_at=datetime.now(timezone.utc))
# 生成指标反馈:按 field 聚合表现,写入 metrics_feedback.json 供 collector 读取
try:
import json as json_mod
@@ -317,9 +429,16 @@ class TaskScheduler:
except Exception as e_fb:
logger.warning("[Scheduled] Metrics feedback generation failed: %s", e_fb)
else:
_log_task("scheduled_metrics_sync", "success",
message="无已发布的知乎文章",
started_at=started, finished_at=datetime.now(timezone.utc))
logger.info("[Scheduled] Metrics sync: no zhihu articles to sync")
db.close()
except Exception as e:
_log_task("scheduled_metrics_sync", "failed",
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Metrics sync failed: %s", e)
def get_jobs(self):
+43
View File
@@ -61,6 +61,49 @@ def init_db():
("platform_configs", "min_words", "INTEGER DEFAULT 0"),
("platform_configs", "max_words", "INTEGER DEFAULT 0"),
("platform_configs", "website_url", "VARCHAR"),
("task_logs", "module_id", "VARCHAR"),
("task_logs", "error_trace", "TEXT"),
("task_logs", "triggered_by", "VARCHAR DEFAULT 'scheduler'"),
("task_logs", "result_data", "JSON DEFAULT '{}'::json"),
("task_logs", "next_run_time", "TIMESTAMP"),
("task_configs", "module_id", "VARCHAR UNIQUE"),
("task_configs", "enabled", "BOOLEAN DEFAULT TRUE"),
("task_configs", "params", "JSON DEFAULT '{}'::json"),
("task_configs", "schedule", "VARCHAR"),
("task_configs", "last_modified_by", "VARCHAR"),
("prompt_configs", "key", "VARCHAR UNIQUE"),
("prompt_configs", "module_id", "VARCHAR"),
("prompt_configs", "category", "VARCHAR DEFAULT 'prompt'"),
("prompt_configs", "version", "VARCHAR DEFAULT 'v1'"),
("prompt_configs", "content", "TEXT"),
("prompt_configs", "variables", "JSON DEFAULT '[]'::json"),
("prompt_configs", "description", "VARCHAR"),
("prompt_configs", "enabled", "BOOLEAN DEFAULT TRUE"),
("prompt_configs", "temperature", "FLOAT"),
("prompt_configs", "max_tokens", "INTEGER"),
("prompt_configs", "created_by", "VARCHAR"),
("keyword_domain_map", "id", "INTEGER PRIMARY KEY"),
("keyword_domain_map", "pattern", "VARCHAR"),
("keyword_domain_map", "domain", "VARCHAR"),
("keyword_domain_map", "sort_order", "INTEGER DEFAULT 0"),
("keyword_domain_map", "is_active", "BOOLEAN DEFAULT TRUE"),
("sensitive_words", "id", "INTEGER PRIMARY KEY"),
("sensitive_words", "word", "VARCHAR"),
("sensitive_words", "category", "VARCHAR DEFAULT 'general'"),
("sensitive_words", "is_active", "BOOLEAN DEFAULT TRUE"),
("sensitive_words", "added_by", "VARCHAR"),
("content_clean_rules", "id", "INTEGER PRIMARY KEY"),
("content_clean_rules", "rule_type", "VARCHAR"),
("content_clean_rules", "pattern", "TEXT"),
("content_clean_rules", "description", "VARCHAR"),
("content_clean_rules", "is_active", "BOOLEAN DEFAULT TRUE"),
("content_clean_rules", "sort_order", "INTEGER DEFAULT 0"),
("collector_categories", "pain_template", "TEXT"),
("trend_field_mappings", "id", "INTEGER PRIMARY KEY"),
("trend_field_mappings", "trend_keyword", "VARCHAR"),
("trend_field_mappings", "field_name", "VARCHAR"),
("trend_field_mappings", "sort_order", "INTEGER DEFAULT 0"),
("trend_field_mappings", "is_active", "BOOLEAN DEFAULT TRUE"),
]:
try:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
+4 -1
View File
@@ -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, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant
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
from .initial_data import import_initial_data
from .core.scheduler import scheduler
@@ -83,6 +83,8 @@ app.include_router(admin.router)
app.include_router(audit.router)
app.include_router(optimizer_logs.router)
app.include_router(cases.router)
app.include_router(task_configs.router)
app.include_router(prompt_configs.router)
app.include_router(task_logs.router)
app.include_router(llm_configs.router)
app.include_router(system_configs.router)
@@ -94,6 +96,7 @@ app.include_router(tasks.router)
app.include_router(platform_config.router)
app.include_router(collector_mgmt.router)
app.include_router(assistant.router)
app.include_router(config_items.router)
# 挂载自动生成的图片(必须先于前端根挂载)
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
+153 -3
View File
@@ -471,24 +471,59 @@ class TaskLog(Base):
__tablename__ = "task_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
module_id = Column(String, nullable=False, index=True) # scheduled_collect / scheduled_generate 等
task_name = Column(String, nullable=False)
topic_id = Column(String, nullable=True)
status = Column(String, nullable=False)
topic_id = Column(String, nullable=True, index=True)
status = Column(String, nullable=False) # pending / running / success / failed / cancelled
message = Column(Text, nullable=True)
error_trace = Column(Text, nullable=True)
triggered_by = Column(String, default="scheduler") # scheduler / manual / api
result_data = Column(JSON, default=dict) # 产出摘要:{topics_found, articles_created, issues_fixed, ...}
started_at = Column(DateTime(timezone=True), server_default=func.now())
finished_at = Column(DateTime(timezone=True), nullable=True)
duration = Column(Integer, nullable=True)
duration = Column(Integer, nullable=True) # seconds
next_run_time = Column(DateTime(timezone=True), nullable=True)
def to_dict(self):
return {
"id": self.id,
"module_id": self.module_id,
"task_name": self.task_name,
"topic_id": self.topic_id,
"status": self.status,
"message": self.message,
"error_trace": self.error_trace,
"triggered_by": self.triggered_by,
"result_data": self.result_data or {},
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
"duration": self.duration,
"next_run_time": self.next_run_time.isoformat() if self.next_run_time else None,
}
class TaskConfig(Base):
__tablename__ = "task_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
module_id = Column(String, unique=True, nullable=False)
enabled = Column(Boolean, default=True)
params = Column(JSON, default=dict) # 各任务自定义参数,JSON 格式
schedule = Column(String, nullable=True) # cron 表达式,覆盖默认
last_modified_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"module_id": self.module_id,
"enabled": self.enabled,
"params": self.params or {},
"schedule": self.schedule,
"last_modified_by": self.last_modified_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -527,6 +562,101 @@ class LLMConfig(Base):
}
class PromptConfig(Base):
__tablename__ = "prompt_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
key = Column(String, unique=True, nullable=False, index=True)
module_id = Column(String, nullable=True, index=True)
category = Column(String, default="prompt") # prompt / rule / template
version = Column(String, default="v1")
content = Column(Text, nullable=False)
variables = Column(JSON, default=[]) # [{name, description, default_value}]
description = Column(String, nullable=True)
enabled = Column(Boolean, default=True)
temperature = Column(Float, nullable=True)
max_tokens = Column(Integer, nullable=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"key": self.key,
"module_id": self.module_id,
"category": self.category,
"version": self.version,
"content": self.content,
"variables": self.variables or [],
"description": self.description,
"enabled": self.enabled,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"created_by": self.created_by,
"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 KeywordDomainMap(Base):
__tablename__ = "keyword_domain_map"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
pattern = Column(String, nullable=False)
domain = Column(String, nullable=False)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=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, "pattern": self.pattern, "domain": self.domain,
"sort_order": self.sort_order, "is_active": self.is_active,
"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 SensitiveWord(Base):
__tablename__ = "sensitive_words"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
word = Column(String, nullable=False)
category = Column(String, default="general")
is_active = Column(Boolean, default=True)
added_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id, "word": self.word, "category": self.category,
"is_active": self.is_active, "added_by": self.added_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class ContentCleanRule(Base):
__tablename__ = "content_clean_rules"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
rule_type = Column(String, nullable=False) # thinking / preface / verbosity / html_thinking
pattern = Column(Text, nullable=False)
description = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id, "rule_type": self.rule_type, "pattern": self.pattern,
"description": self.description, "is_active": self.is_active,
"sort_order": self.sort_order,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class SystemConfig(Base):
__tablename__ = "system_configs"
@@ -557,6 +687,7 @@ class CollectorCategory(Base):
name = Column(String, unique=True, nullable=False)
description = Column(Text, nullable=True)
search_query = Column(String, nullable=True)
pain_template = Column(Text, nullable=True)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -570,6 +701,7 @@ class CollectorCategory(Base):
"name": self.name,
"description": self.description,
"search_query": self.search_query,
"pain_template": self.pain_template,
"sort_order": self.sort_order,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
@@ -577,6 +709,24 @@ class CollectorCategory(Base):
}
class TrendFieldMapping(Base):
__tablename__ = "trend_field_mappings"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
trend_keyword = Column(String, nullable=False)
field_name = Column(String, nullable=False)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id, "trend_keyword": self.trend_keyword, "field_name": self.field_name,
"sort_order": self.sort_order, "is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class CollectorSource(Base):
"""采集信息源(可在运营管理中动态编辑)"""
__tablename__ = "collector_sources"
+28
View File
@@ -458,13 +458,18 @@ class CaseResponse(CaseBase):
class TaskLogBase(BaseModel):
module_id: str
task_name: str
topic_id: Optional[str] = None
status: str
message: Optional[str] = None
error_trace: Optional[str] = None
triggered_by: str = "scheduler"
result_data: Dict[str, Any] = {}
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
duration: Optional[int] = None
next_run_time: Optional[datetime] = None
class TaskLogResponse(TaskLogBase):
@@ -473,6 +478,29 @@ class TaskLogResponse(TaskLogBase):
model_config = ConfigDict(from_attributes=True)
class TaskConfigBase(BaseModel):
module_id: str
enabled: bool = True
params: Dict[str, Any] = {}
schedule: Optional[str] = None
class TaskConfigUpdate(BaseModel):
enabled: Optional[bool] = None
params: Optional[Dict[str, Any]] = None
schedule: Optional[str] = None
last_modified_by: Optional[str] = None
class TaskConfigResponse(TaskConfigBase):
id: int
last_modified_by: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class LLMConfigBase(BaseModel):
name: str
system_prompt: Optional[str] = None
+133 -446
View File
@@ -22,65 +22,70 @@
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
<div class="filter-bar">
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
<el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button>
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
<el-button size="default" :type="activeTab === 'platformconfigs' ? 'primary' : ''" @click="switchTab('platformconfigs')">平台配置</el-button>
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
<el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button>
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
</div>
</div>
<div v-if="activeTab === 'cases'">
<div v-if="activeTab === 'users'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCaseDialog()">新增案例</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ cases.length }} 个</span>
<el-button type="primary" size="small" @click="addUser">新增用户</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ users.length }} 个</span>
</div>
<div v-if="casesLoading" class="card-loading">加载中...</div>
<template v-else-if="cases.length === 0">
<div v-if="usersLoading" class="card-loading">加载中...</div>
<template v-else-if="users.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
<div class="empty-text">暂无案例数据</div>
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUser /></el-icon>
<div class="empty-text">暂无用户数据</div>
</div>
</template>
<template v-else>
<div class="stat-summary">
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
</div>
<el-table :data="paginatedCases" border stripe class="data-table" style="width:100%">
<el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="title" label="标题" min-width="80" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="field" label="领域" width="140"></el-table-column>
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="source" label="来源" width="140"></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<el-table-column prop="username" label="用户名" min-width="120"></el-table-column>
<el-table-column prop="role" label="角色" width="80"></el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="cases.length > casePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="cases.length" :page-size="casePageSize" :current-page="casePage" @current-change="casePage = $event"></el-pagination>
<div v-if="users.length > userPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="users.length" :page-size="userPageSize" :current-page="userPage" @current-change="userPage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile">
<div v-for="item in paginatedCases" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
<div v-for="item in paginatedUsers" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">用户名</span><span class="card-value">{{ item.username }}</span></div>
<div class="card-row"><span class="card-label">角色</span><span class="card-value">{{ item.role }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
<el-button size="small" type="danger" @click="deleteUser(item.id)">删除</el-button>
</div>
</div>
</div>
<el-dialog v-model="userDialogVisible" title="新增用户" width="450px" :close-on-click-modal="false">
<el-form :model="userForm" label-width="80px">
<el-form-item label="用户名"><el-input v-model="userForm.username" placeholder="至少2个字符"/></el-form-item>
<el-form-item label="密码"><el-input v-model="userForm.password" type="password" show-password placeholder="至少6位"/></el-form-item>
<el-form-item label="角色">
<el-select v-model="userForm.role" style="width:100%">
<el-option label="编辑" value="editor"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="userSubmitting">创建</el-button>
</template>
</el-dialog>
</div>
<div v-if="activeTab === 'tasklogs'">
@@ -173,43 +178,87 @@
</div>
<div v-if="activeTab === 'platformconfigs'">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconGlobe /></el-icon> 平台配置</h2>
<div class="toolbar">
<el-button type="primary" size="small" @click="showPlatformConfigDialog()">新增配置</el-button>
<el-button-group>
<el-button :type="pcShowActiveOnly ? 'primary' : ''" @click="pcShowActiveOnly = true; loadPlatformConfigs()">启用中</el-button>
<el-button :type="!pcShowActiveOnly ? 'primary' : ''" @click="pcShowActiveOnly = false; loadPlatformConfigs()">全部</el-button>
</el-button-group>
<el-button type="primary" size="small" @click="showPcDialogFn()">新增配置</el-button>
</div>
</div>
<div v-if="platformConfigsLoading" class="card-loading">加载中...</div>
<template v-else-if="platformConfigs.length === 0">
<template v-else-if="!platformConfigs || platformConfigs.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无平台配置</div>
</div>
</template>
<template v-else>
<el-table :data="platformConfigs" border stripe class="data-table" style="width:100%">
<el-table-column prop="platform" label="标识" width="80"></el-table-column>
<el-table-column prop="name" label="名称" width="80"></el-table-column>
<el-table v-else :data="platformConfigs" border stripe style="width:100%">
<el-table-column prop="platform" label="标识" width="120"></el-table-column>
<el-table-column prop="name" label="名称" width="120"></el-table-column>
<el-table-column prop="icon" label="图标" width="100">
<template #default="scope"><span>{{ scope.row.icon || '—' }}</span></template>
</el-table-column>
<el-table-column prop="website_url" label="平台网址" min-width="160">
<template #default="scope"><a :href="scope.row.website_url" target="_blank" style="color:#409eff;">{{ scope.row.website_url }}</a></template>
<template #default="scope"><a v-if="scope.row.website_url" :href="scope.row.website_url" target="_blank" style="color:#409eff;">{{ scope.row.website_url }}</a><span v-else></span></template>
</el-table-column>
<el-table-column prop="api_endpoint" label="API地址" min-width="160">
<template #default="scope"><span style="color:#606266;">{{ scope.row.api_endpoint || '—' }}</span></template>
</el-table-column>
<el-table-column label="字数范围" width="120">
<template #default="scope">{{ scope.row.min_words }} - {{ scope.row.max_words }}</template>
</el-table-column>
<el-table-column label="配图" width="100">
<el-table-column label="配图" width="120">
<template #default="scope">{{ scope.row.requires_image ? `${scope.row.image_count_min}-${scope.row.image_count_max}张` : '不需要' }}</template>
</el-table-column>
<el-table-column prop="api_endpoint" label="API地址" min-width="160"></el-table-column>
<el-table-column prop="default_format" label="默认格式" min-width="160">
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 30) }}{{ (scope.row.default_format || '').length > 30 ? '...' : '' }}</template>
<el-table-column prop="default_format" label="默认格式" min-width="180">
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 50) }}{{ (scope.row.default_format || '').length > 50 ? '...' : '' }}</template>
</el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope"><el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon><el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon></template>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope"><span>{{ scope.row.is_active ? '是' : '否' }}</span></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="操作" width="80" fixed="right">
<template #default="scope">
<el-button size="small" @click="showPlatformConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" @click="showPcDialogFn(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showPcDialog" :title="pcDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="pcForm" label-width="100px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="标识"><el-input v-model="pcForm.platform" placeholder="唯一标识,如 weixin" :disabled="!!editingPcPlatform"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="名称"><el-input v-model="pcForm.name" placeholder="微信公众号"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="图标"><el-input v-model="pcForm.icon" placeholder="IconWechat"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="官网"><el-input v-model="pcForm.website_url" placeholder="https://mp.weixin.qq.com"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="API地址"><el-input v-model="pcForm.api_endpoint" placeholder="https://..."/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最少字数"><el-input-number v-model="pcForm.min_words" :min="0" :max="99999" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最多字数"><el-input-number v-model="pcForm.max_words" :min="0" :max="99999" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="需要配图"><el-switch v-model="pcForm.requires_image"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="最少张数"><el-input-number v-model="pcForm.image_count_min" :min="0" :max="99" style="width:100%"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="最多张数"><el-input-number v-model="pcForm.image_count_max" :min="0" :max="99" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="默认格式"><el-input type="textarea" v-model="pcForm.default_format" :rows="4" placeholder="文章格式要求"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="pcForm.is_active"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="showPcDialog = false">取消</el-button>
<el-button type="primary" @click="savePcForm">确定</el-button>
</template>
</el-dialog>
</div>
<div v-if="activeTab === 'systemconfigs'">
<div class="toolbar">
@@ -253,121 +302,6 @@
</div>
</div>
<div v-if="activeTab === 'users'">
<div class="toolbar">
<el-button type="primary" size="small" @click="addUser">+ 新建用户</el-button>
<el-button size="small" @click="fetchUsers">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ users.length }} 个</span>
</div>
<div v-if="usersLoading" class="card-loading">加载中...</div>
<template v-else-if="users.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUser /></el-icon>
<div class="empty-text">暂无用户</div>
<el-button type="primary" size="small" @click="addUser">新增第一个用户</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="username" label="用户名"></el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
</el-table-column>
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)" :disabled="scope.row.role === 'admin'">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="users.length > userPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="users.length" :page-size="userPageSize" :current-page="userPage" @current-change="userPage = $event"></el-pagination>
</div>
<div class="card-list-mobile">
<div v-for="u in users" :key="u.id" class="card-item">
<div class="card-row"><span class="card-label">用户名</span><span class="card-value">{{ u.username }} <el-tag size="small" :type="u.role === 'admin' ? 'danger' : 'info'">{{ u.role === 'admin' ? '管理员' : '编辑' }}</el-tag></span></div>
<div class="card-row"><span class="card-label">组织</span><span class="card-value">{{ u.org_id || '-' }}</span></div>
<div class="card-row"><span class="card-label">创建时间</span><span class="card-value">{{ formatDate(u.created_at) }}</span></div>
<div class="card-actions">
<el-button size="small" type="danger" plain @click="deleteUser(u.id)" :disabled="u.role === 'admin'">删除</el-button>
</div>
</div>
</div>
</template>
<el-dialog v-model="userDialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
<el-form :model="userForm" label-width="80px">
<el-form-item label="用户名" required>
<el-input v-model="userForm.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="userForm.password" type="password" placeholder="至少6位" show-password></el-input>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="userForm.role" style="width:100%">
<el-option label="编辑" value="editor"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="userSubmitting">创建</el-button>
</template>
</el-dialog>
</div>
<div v-if="activeTab === 'categories'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
<el-button size="small" @click="loadCategories">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ categories.length }} 个</span>
</div>
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
<template v-else-if="categories.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
<div class="empty-text">暂无采集类别</div>
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedCategories" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="categories.length > categoryPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="categories.length" :page-size="categoryPageSize" :current-page="categoryPage" @current-change="categoryPage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="categories.length > 0">
<div v-for="item in paginatedCategories" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'orgs'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
@@ -460,90 +394,11 @@
</el-form>
</div>
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
<el-button size="small" @click="loadSources">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
</div>
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
<template v-else-if="sources.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
<div class="empty-text">暂无信息源</div>
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedSources" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="50"></el-table-column>
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
<el-table-column prop="source_type" label="类型" width="90">
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
</el-table-column>
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="sources.length > sourcePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="sources.length" :page-size="sourcePageSize" :current-page="sourcePage" @current-change="sourcePage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="sources.length > 0">
<div v-for="item in paginatedSources" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="700px" :close-on-click-modal="false">
<el-form :model="caseForm" label-width="80px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary" :rows="3"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="caseDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCase">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="llmConfigForm" label-width="110px">
<el-row :gutter="16">
@@ -580,39 +435,6 @@
</template>
</el-dialog>
<el-dialog v-model="platformConfigDialogVisible" :title="platformConfigDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="platformConfigForm" label-width="110px">
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="标识"><el-input v-model="platformConfigForm.platform" :disabled="!!editingPlatformConfigPlatform" placeholder="zhihu"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称"><el-input v-model="platformConfigForm.name" placeholder="知乎"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="图标"><el-input v-model="platformConfigForm.icon" placeholder="📕"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="平台网址"><el-input v-model="platformConfigForm.website_url" placeholder="https://www.zhihu.com"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="API地址"><el-input v-model="platformConfigForm.api_endpoint" placeholder="https://api.zhihu.com"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最少字数"><el-input-number v-model="platformConfigForm.min_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最多字数"><el-input-number v-model="platformConfigForm.max_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="需要配图"><el-switch v-model="platformConfigForm.requires_image"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="最少"><el-input-number v-model="platformConfigForm.image_count_min" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="最多"><el-input-number v-model="platformConfigForm.image_count_max" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="默认格式"><el-input type="textarea" v-model="platformConfigForm.default_format" :rows="2" placeholder="图文笔记,300-800字,emoji+标签"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="活跃"><el-switch v-model="platformConfigForm.is_active"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="platformConfigDialogVisible=false">取消</el-button>
<el-button type="primary" @click="savePlatformConfig">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="600px" :close-on-click-modal="false">
<el-form :model="systemConfigForm" label-width="80px">
<el-row :gutter="16">
@@ -629,48 +451,6 @@
</template>
</el-dialog>
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCategory">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
<el-form :model="sourceForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
<el-form-item label="类型">
<el-select v-model="sourceForm.source_type" style="width:100%">
<el-option label="RSS订阅" value="rss"></el-option>
<el-option label="搜索引擎" value="web_search"></el-option>
<el-option label="本地文件" value="local"></el-option>
</el-select>
</el-form-item>
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
<el-form-item label="可信度">
<el-select v-model="sourceForm.credibility" style="width:100%">
<el-option label="高" value="high"></el-option>
<el-option label="中" value="medium"></el-option>
<el-option label="低" value="low"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="sourceDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveSource">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="orgDialogVisible" :title="orgDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="orgForm" label-width="80px">
<el-form-item label="组织ID"><el-input v-model="orgForm.org_id" :disabled="!!editingOrgId" placeholder="唯一标识,如:org_a"/></el-form-item>
@@ -703,7 +483,7 @@
delete: (url) => fetch(apiBase + url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
};
const activeTab = ref('cases');
const activeTab = ref('users');
const currentUser = ref({ username: '' });
const isAdmin = ref(false);
@@ -719,38 +499,6 @@
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
const cases = ref([]);
const casesLoading = ref(false);
const caseDialogVisible = ref(false);
const caseDialogTitle = ref('新增案例');
const caseForm = reactive({ id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' });
const editingCaseId = ref(null);
const loadCases = async () => {
casesLoading.value = true;
try { cases.value = await api.get('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); }
finally { casesLoading.value = false; }
};
const showCaseDialog = (row = null) => {
if (row) { caseDialogTitle.value = '编辑案例'; editingCaseId.value = row.id; Object.assign(caseForm, row); }
else { caseDialogTitle.value = '新增案例'; editingCaseId.value = null; Object.keys(caseForm).forEach(k => { if (k === 'id') caseForm.id = null; else caseForm[k] = ''; }); }
caseDialogVisible.value = true;
};
const saveCase = async () => {
try {
if (editingCaseId.value) { await api.put(`/api/admin/cases/${editingCaseId.value}`, caseForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/cases', caseForm); ElMessage.success('创建成功'); }
caseDialogVisible.value = false; await loadCases();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteCase = async (id) => {
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/cases/${id}`); ElMessage.success('删除成功'); await loadCases(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const casePage = ref(1);
const casePageSize = 10;
const paginatedCases = computed(() => { const s = (casePage.value - 1) * casePageSize; return cases.value.slice(s, s + casePageSize); });
const taskLogs = ref([]);
const taskLogsLoading = ref(false);
const loadTaskLogs = async () => {
@@ -801,32 +549,41 @@
const platformConfigs = ref([]);
const platformConfigsLoading = ref(false);
const platformConfigDialogVisible = ref(false);
const platformConfigDialogTitle = ref('新增配置');
const platformConfigForm = reactive({ platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true });
const editingPlatformConfigPlatform = ref(null);
const platformConfigsRequested = ref(false);
const pcShowActiveOnly = ref(true);
const showPcDialog = ref(false);
const pcDialogTitle = ref('新增配置');
const pcForm = ref({ platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true });
const editingPcPlatform = ref(null);
const loadPlatformConfigs = async () => {
platformConfigsLoading.value = true;
try { platformConfigs.value = await api.get('/api/platform-config'); } catch (e) { ElMessage.error('加载平台配置失败: ' + e.message); }
platformConfigsRequested.value = true;
try {
const url = pcShowActiveOnly.value ? '/api/platform-config?active_only=true' : '/api/platform-config';
const data = await api.get(url);
platformConfigs.value = data;
} catch (e) {
ElMessage.error('加载平台配置失败: ' + e.message);
}
finally { platformConfigsLoading.value = false; }
};
const showPlatformConfigDialog = (row = null) => {
if (row) {
platformConfigDialogTitle.value = '编辑配置'; editingPlatformConfigPlatform.value = row.platform;
Object.assign(platformConfigForm, { ...row });
} else {
platformConfigDialogTitle.value = '新增配置'; editingPlatformConfigPlatform.value = null;
platformConfigForm.platform = ''; platformConfigForm.name = ''; platformConfigForm.icon = ''; platformConfigForm.website_url = ''; platformConfigForm.api_endpoint = '';
platformConfigForm.min_words = 300; platformConfigForm.max_words = 3000; platformConfigForm.requires_image = false;
platformConfigForm.image_count_min = 0; platformConfigForm.image_count_max = 0; platformConfigForm.default_format = ''; platformConfigForm.is_active = true;
}
platformConfigDialogVisible.value = true;
};
const savePlatformConfig = async () => {
const showPcDialogFn = (row = null) => {
try {
if (editingPlatformConfigPlatform.value) { await api.put(`/api/platform-config/${platformConfigForm.platform}`, platformConfigForm); ElMessage.success('更新成功'); }
else { await api.post('/api/platform-config', platformConfigForm); ElMessage.success('创建成功'); }
platformConfigDialogVisible.value = false; await loadPlatformConfigs();
if (row) {
pcDialogTitle.value = '编辑配置'; editingPcPlatform.value = row.platform;
pcForm.value = { ...row };
} else {
pcDialogTitle.value = '新增配置'; editingPcPlatform.value = null;
pcForm.value = { platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true };
}
showPcDialog.value = true;
} catch (e) { console.error('showPcDialogFn error:', e); }
};
const savePcForm = async () => {
try {
if (editingPcPlatform.value) { await api.put(`/api/platform-config/${pcForm.value.platform}`, pcForm.value); ElMessage.success('更新成功'); }
else { await api.post('/api/platform-config', pcForm.value); ElMessage.success('创建成功'); }
showPcDialog.value = false; await loadPlatformConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
@@ -837,20 +594,6 @@
const systemConfigForm = reactive({ key: '', value: '', description: '' });
const editingSystemConfigKey = ref(null);
const categories = ref([]);
const categoriesLoading = ref(false);
const categoryDialogVisible = ref(false);
const categoryDialogTitle = ref('新增类别');
const categoryForm = reactive({ name: '', search_query: '', description: '', sort_order: 0, is_active: true });
const editingCategoryId = ref(null);
const sources = ref([]);
const sourcesLoading = ref(false);
const sourceDialogVisible = ref(false);
const sourceDialogTitle = ref('新增信息源');
const sourceForm = reactive({ name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true });
const editingSourceId = ref(null);
const orgs = ref([]);
const orgsLoading = ref(false);
const orgDialogVisible = ref(false);
@@ -988,95 +731,39 @@
finally { assistantSaving.value = false; }
};
const loadCategories = async () => {
categoriesLoading.value = true;
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
finally { categoriesLoading.value = false; }
};
const showCategoryDialog = (row = null) => {
if (row) { categoryDialogTitle.value = '编辑类别'; editingCategoryId.value = row.id; Object.assign(categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { categoryDialogTitle.value = '新增类别'; editingCategoryId.value = null; categoryForm.name = ''; categoryForm.search_query = ''; categoryForm.description = ''; categoryForm.sort_order = 0; categoryForm.is_active = true; }
categoryDialogVisible.value = true;
};
const saveCategory = async () => {
try {
if (editingCategoryId.value) { await api.put(`/api/admin/collector/categories/${editingCategoryId.value}`, categoryForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/collector/categories', categoryForm); ElMessage.success('创建成功'); }
categoryDialogVisible.value = false; await loadCategories();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteCategory = async (id) => {
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/categories/${id}`); ElMessage.success('删除成功'); await loadCategories(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const categoryPage = ref(1);
const categoryPageSize = 10;
const paginatedCategories = computed(() => { const s = (categoryPage.value - 1) * categoryPageSize; return categories.value.slice(s, s + categoryPageSize); });
const loadSources = async () => {
sourcesLoading.value = true;
try { sources.value = await api.get('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
finally { sourcesLoading.value = false; }
};
const showSourceDialog = (row = null) => {
if (row) { sourceDialogTitle.value = '编辑信息源'; editingSourceId.value = row.id; Object.assign(sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { sourceDialogTitle.value = '新增信息源'; editingSourceId.value = null; sourceForm.name = ''; sourceForm.source_type = 'web_search'; sourceForm.query = ''; sourceForm.credibility = 'medium'; sourceForm.focus = ''; sourceForm.sort_order = 0; sourceForm.is_active = true; }
sourceDialogVisible.value = true;
};
const saveSource = async () => {
try {
if (editingSourceId.value) { await api.put(`/api/admin/collector/sources/${editingSourceId.value}`, sourceForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/collector/sources', sourceForm); ElMessage.success('创建成功'); }
sourceDialogVisible.value = false; await loadSources();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteSource = async (id) => {
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/sources/${id}`); ElMessage.success('删除成功'); await loadSources(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const sourcePage = ref(1);
const sourcePageSize = 10;
const paginatedSources = computed(() => { const s = (sourcePage.value - 1) * sourcePageSize; return sources.value.slice(s, s + sourcePageSize); });
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' }); };
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const tabLoaders = {
cases: loadCases, tasklogs: loadTaskLogs,
tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers,
categories: loadCategories, sources: loadSources,
orgs: loadOrgs, assistant: loadAssistantConfig,
};
const loadedTabs = new Set(['cases']);
const loadedTabs = new Set([]);
const switchTab = (name) => {
console.log('[switchTab]', name, 'loadedTabs has?', loadedTabs.has(name));
activeTab.value = name;
if (!loadedTabs.has(name)) {
loadedTabs.add(name);
tabLoaders[name]();
if (tabLoaders[name]) tabLoaders[name]();
else console.warn('[switchTab] no loader for', name);
}
};
onMounted(() => {
loadedTabs.add('cases');
loadCases();
const hash = window.location.hash.replace('#tab=', '');
if (hash && tabLoaders[hash]) { switchTab(hash); }
else { switchTab('users'); }
});
return {
activeTab, switchTab,
cases, casesLoading, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
casePage, casePageSize, paginatedCases,
taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
platformConfigs, platformConfigsLoading, platformConfigDialogVisible, platformConfigForm, platformConfigDialogTitle, showPlatformConfigDialog, savePlatformConfig, editingPlatformConfigPlatform,
platformConfigs, platformConfigsLoading, platformConfigsRequested, pcShowActiveOnly, showPcDialog, pcForm, pcDialogTitle, showPcDialogFn, savePcForm, editingPcPlatform,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
categoryPage, categoryPageSize, paginatedCategories,
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
sourcePage, sourcePageSize, paginatedSources,
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
orgPage, orgPageSize, paginatedOrgs,
logout, currentUser, isAdmin, redirectToPage,
+13 -7
View File
@@ -132,7 +132,7 @@
<template #footer>
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制 HTML</el-button>
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制正文</el-button>
</div>
</template>
</el-dialog>
@@ -215,12 +215,18 @@ const ArticlesApp = {
});
},
copyPreviewHtml() {
if (!this.previewArticleData || !this.previewArticleData.html_content) {
this.$message.warning('暂无 HTML 内容可复制');
return;
}
navigator.clipboard.writeText(this.previewArticleData.html_content)
.then(() => this.$message.success('HTML 已复制'))
const html = this.previewArticleData?.html_content;
if (!html) { this.$message.warning('暂无内容可复制'); return; }
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
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('复制失败'));
},
async deleteArticle(article) {
+609 -147
View File
@@ -3,12 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 创作任务</title>
<title>宇之然内容创作平台 - 任务管理</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
.toolbar { gap: 12px; }
.task-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; margin-bottom: 12px; transition: all 0.3s; background: #fff; }
.task-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.12); transform: translateY(-1px); }
.task-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
@@ -28,7 +27,6 @@
.status-failed { background: #fef0f0; color: #f56c6c; }
.status-cancelled { background: #f4f4f5; color: #c0c4cc; }
.error-box { margin-top: 8px; padding: 8px 12px; background: #fef0f0; border-radius: 6px; font-size: 13px; color: #f56c6c; border-left: 3px solid #f56c6c; }
.task-card-list-mobile { display: none; }
.schedule-row { display: flex; align-items: center; gap: 16px; padding: 14px 16px; border-radius: 10px; background: #f8faff; border: 1px solid #e8edf5; margin-bottom: 8px; transition: all 0.2s; }
.schedule-row:hover { background: #f0f4ff; }
@@ -44,7 +42,6 @@
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 80px; flex-shrink: 0; font-size: 13px; color: #909399; }
.detail-value { flex: 1; font-size: 13px; color: #303133; word-break: break-all; }
@media (max-width: 768px) {
.task-table { display: none; }
.task-card-list-mobile { display: block; }
@@ -61,40 +58,53 @@
.module-content > div { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px; }
.module-content > div > span:first-child { color: #909399; }
.module-content > div > span:last-child { color: #303133; font-weight: 500; }
.task-drawer .el-drawer__body { padding: 16px 24px; overflow: hidden; display: flex; flex-direction: column; }
.task-drawer .el-drawer__body { padding: 16px 24px; overflow-y: auto; display: flex; flex-direction: column; }
.data-table { display: block; }
@media (max-width: 768px) { .data-table { display: none; } }
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<uni-nav title="创作任务" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
<uni-nav title="任务管理" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 定时任务</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 任务管理</h2>
<div class="filter-bar">
<el-button size="default" :type="activeTab === 'scheduler' ? 'primary' : ''" @click="switchTab('scheduler')">定时任务</el-button>
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
<el-button size="default" :type="activeTab === 'tasks' ? 'primary' : ''" @click="switchTab('tasks')">创作任务</el-button>
</div>
</div>
<!-- 定时任务 -->
<div v-if="activeTab === 'scheduler'">
<div class="toolbar">
<el-tag v-if="schedulerRunning" type="success" size="small">调度器运行中</el-tag>
<el-tag v-else type="danger" size="small">调度器未启动</el-tag>
<el-button @click="loadModules"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div v-if="moduleLoading" style="text-align: center; padding: 20px; color: #909399;">加载中...</div>
<div v-else-if="modules.length === 0" style="text-align: center; padding: 30px 20px; color: #909399; font-size: 14px;">暂无定时任务</div>
<div v-else class="module-grid">
<div v-for="mod in modules" :key="mod.id" class="module-card" @click="openModuleDetail(mod)" style="cursor:pointer;">
<div v-for="mod in modules" :key="mod.module_id" class="module-card" @click="openModuleDetail(mod)" style="cursor:pointer;">
<div class="module-header">
<span class="module-title">{{ mod.title }}</span>
<span :class="['module-status', mod.status === 'running' ? 'running' : '']">{{ mod.status === 'running' ? '运行中' : '已停止' }}</span>
<el-tag v-if="mod.running > 0" type="success" size="small">运行中</el-tag>
<el-tag v-else-if="!mod.enabled" type="info" size="small">已禁用</el-tag>
<span v-else :class="['module-status', mod.last_status === 'success' ? 'completed' : mod.last_status === 'failed' ? 'failed' : '']">{{ mod.last_status === 'success' ? '正常' : mod.last_status === 'failed' ? '失败' : '空闲' }}</span>
</div>
<div class="module-content">
<div><span>最后运行</span><span>{{ mod.last_run }}</span></div>
<div><span>下次运行</span><span>{{ mod.next_run }}</span></div>
<div><span>今日任务</span><span>{{ mod.task_count }} </span></div>
<div><span>成功率</span><span>{{ mod.success_rate > 0 ? mod.success_rate + '%' : '暂无' }}</span></div>
<div><span>最后运行</span><span>{{ mod.last_run || '从未' }}</span></div>
<div><span>下次运行</span><span>{{ mod.next_run || '—' }}</span></div>
<div><span>累计运行</span><span>{{ mod.total_runs }} 次 <span style="color:#67c23a;">{{ mod.success_runs }} 成功</span> <span style="color:#f56c6c;">{{ mod.failed_runs }} 失败</span></span></div>
<div style="margin-top:10px; border-bottom:none;">
<el-button size="small" type="primary" @click.stop="triggerModule(mod.id)" :loading="runningModule === mod.id">立即运行</el-button>
<el-button size="small" type="primary" @click.stop="triggerModule(mod.module_id)" :loading="runningModule === mod.module_id" :disabled="!mod.enabled">立即运行</el-button>
<el-button size="small" @click.stop="openModuleDetail(mod)">查看详情</el-button>
</div>
</div>
@@ -102,120 +112,161 @@
</div>
</div>
<!-- 模块详情抽屉 -->
<el-drawer v-model="showDrawer" :title="drawerTitle" size="50%" direction="rtl" class="task-drawer">
<div v-if="drawerLoading" style="text-align:center;padding:40px;color:#909399;">加载中...</div>
<div v-else-if="drawerError" style="text-align:center;padding:40px;color:#f56c6c;">{{ drawerError }}</div>
<div v-else-if="drawerData" style="height:100%;display:flex;flex-direction:column;">
<div style="padding-bottom:16px;border-bottom:1px solid #ebeef5;margin-bottom:16px;flex-shrink:0;">
<el-tag v-if="drawerData.module_id" size="small" type="info" style="margin-bottom:8px;">{{ drawerData.module_id }}</el-tag>
<p style="color:#606266;font-size:14px;margin:0;">{{ drawerData.description }}</p>
<!-- 案例管理 -->
<div v-if="activeTab === 'cases'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCaseDialog()">新增案例</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ cases.length }} 个</span>
</div>
<el-tabs v-model="drawerTab" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<el-tab-pane label="📥 输入参数" name="inputs" style="overflow:auto;flex:1;">
<div v-if="Object.keys(drawerData.inputs || {}).length === 0" style="color:#909399;padding:20px;text-align:center;">无需手动输入,数据自动获取</div>
<div v-for="(val, key) in drawerData.inputs" :key="key" style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;display:flex;align-items:center;gap:8px;">
{{ key }}
<a v-if="key.includes('类别') || key.includes('信息源')" :href="'/admin.html#tab=' + (key.includes('信息源') ? 'sources' : 'categories')" target="_blank" style="font-size:12px;font-weight:400;color:#409eff;text-decoration:none;">前往编辑 →</a>
<div v-if="casesLoading" class="card-loading">加载中...</div>
<template v-else-if="cases.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
<div class="empty-text">暂无案例数据</div>
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
</div>
<div v-if="Array.isArray(val)">
<el-tag v-for="(item, i) in val" :key="i" style="margin:2px 4px 2px 0;" size="small">{{ item }}</el-tag>
</template>
<template v-else>
<div class="stat-summary">
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
</div>
<div v-else style="font-size:13px;color:#606266;">{{ val }}</div>
</div>
</el-tab-pane>
<el-tab-pane label="📤 产出结果" name="outputs" style="overflow:auto;flex:1;">
<div v-if="Object.keys(drawerData.outputs || {}).length === 0" style="color:#909399;padding:20px;text-align:center;">暂无产出数据,运行任务后将在此展示</div>
<div v-for="(val, key) in drawerData.outputs" :key="key" style="margin-bottom:16px;border-bottom:1px solid #f0f2f5;padding-bottom:12px;">
<div style="font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;">{{ key }}</div>
<div v-if="key === '热点列表' && Array.isArray(val)">
<el-table :data="val" size="small" max-height="400" style="width:100%;">
<el-table-column prop="topic" label="话题" min-width="180"></el-table-column>
<el-table-column prop="domain" label="领域" width="100"></el-table-column>
<el-table-column prop="source" label="来源" width="80"></el-table-column>
<el-table :data="paginatedCases" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="title" label="标题" min-width="80" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="field" label="领域" width="140"></el-table-column>
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="source" label="来源" width="140"></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="cases.length > casePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="cases.length" :page-size="casePageSize" :current-page="casePage" @current-change="casePage = $event"></el-pagination>
</div>
<div v-else-if="key === '最新选题' && Array.isArray(val)">
<el-table :data="val" size="small" max-height="300" style="width:100%;">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题" min-width="150"></el-table-column>
<el-table-column prop="field" label="领域" width="80"></el-table-column>
<el-table-column prop="status" label="状态" width="70"></el-table-column>
</el-table>
</div>
<div v-else-if="(key === '最新文章' || key === '最新指标') && Array.isArray(val)">
<el-table :data="val" size="small" max-height="300" style="width:100%;">
<el-table-column v-for="col in Object.keys(val[0]||{})" :key="col" :prop="col" :label="col" min-width="80"></el-table-column>
</el-table>
</div>
<div v-else-if="key === '各分类结果' && Array.isArray(val)">
<div v-for="item in val" :key="item.query" style="margin-bottom:8px;padding:8px;background:#f8faff;border-radius:6px;">
<div style="font-size:13px;font-weight:500;">{{ item.query }} <el-tag size="mini">{{ item.count }}条</el-tag></div>
<div style="font-size:12px;color:#909399;margin-top:4px;" v-for="s in item.samples" :key="s">● {{ s }}</div>
</template>
<div class="card-list-mobile">
<div v-for="item in paginatedCases" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
</div>
</div>
<div v-else-if="key === '文章评分' && Array.isArray(val)">
<div v-for="item in val" :key="item.title" style="margin-bottom:6px;display:flex;align-items:center;gap:8px;">
<span style="font-size:13px;">{{ item.title }}</span>
<el-rate :model-value="parseScore(item.score)" disabled show-score score-template="{value}分" size="small"></el-rate>
</div>
</div>
<div v-else-if="key === 'AI建议摘要'" style="background:#f0f9eb;padding:10px 14px;border-radius:8px;font-size:13px;color:#303133;">{{ val }}</div>
<div v-else-if="key === '建议新增类别' && Array.isArray(val)">
<div v-for="item in val" :key="item.name" style="padding:8px;background:#f0f9eb;border-radius:6px;margin-bottom:6px;">
<div style="font-weight:500;">{{ item.name }}</div>
<div style="font-size:12px;color:#606266;">{{ item.reason }}</div>
</div>
</div>
<div v-else-if="key === '类别评估' && Array.isArray(val)">
<div v-for="item in val" :key="item.name" style="margin-bottom:4px;font-size:13px;">
<el-tag :type="item.status==='保留'?'success':'warning'" size="mini" style="margin-right:6px;">{{ item.status }}</el-tag>
{{ item.name }} — {{ item.reason }}
</div>
</div>
<div v-else-if="key === '来源分布' && typeof val === 'object'">
<div v-for="(cnt, src) in val" :key="src" style="margin-bottom:4px;font-size:13px;">
{{ src }}: <el-tag size="mini">{{ cnt }}条</el-tag>
</div>
</div>
<div v-else-if="key === '高互动领域' && Array.isArray(val)">
<div v-for="item in val" :key="item[0]" style="margin-bottom:4px;font-size:13px;">
{{ item[0] }}: <el-tag size="mini" type="success">{{ item[1] }}分</el-tag>
</div>
</div>
<div v-else-if="typeof val === 'object' && !Array.isArray(val) && val !== null">
<div v-for="(v,k) in val" :key="k" style="margin-bottom:4px;font-size:13px;">
<span style="color:#909399;">{{ k }}:</span> {{ v }}
</div>
</div>
<div v-else style="font-size:13px;color:#606266;">{{ val }}</div>
</div>
</el-tab-pane>
<el-tab-pane label="📋 运行记录" name="history" style="overflow:auto;flex:1;">
<div v-if="drawerData.log_excerpt" style="margin-bottom:12px;">
<div style="font-size:13px;font-weight:600;margin-bottom:6px;">今日日志(最近15行)</div>
<pre style="background:#1a1a2e;color:#e0e0e0;padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:300px;white-space:pre-wrap;word-break:break-all;">{{ drawerData.log_excerpt }}</pre>
</div>
<div v-if="drawerData.history && drawerData.history.length > 0">
<el-timeline>
<el-timeline-item v-for="(h, i) in drawerData.history" :key="i" :timestamp="h.time" :color="h.status === 'success' ? '#67c23a' : '#f56c6c'">
<span :style="{color: h.status === 'success' ? '#67c23a' : '#f56c6c', fontSize:'13px'}">{{ h.msg }}</span>
</el-timeline-item>
</el-timeline>
</div>
<div v-if="!drawerData.log_excerpt && (!drawerData.history || drawerData.history.length === 0)" style="color:#909399;padding:20px;text-align:center;">暂无运行记录</div>
</el-tab-pane>
</el-tabs>
<div style="padding-top:16px;border-top:1px solid #ebeef5;flex-shrink:0;text-align:center;">
<el-button type="primary" @click="triggerModule(drawerData.module_id)" :loading="runningModule === drawerData.module_id" size="medium">立即运行此任务</el-button>
</div>
</div>
</el-drawer>
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 创作任务</h2>
<!-- 采集类别 -->
<div v-if="activeTab === 'categories'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
<el-button size="small" @click="loadCategories">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ categories.length }} 个</span>
</div>
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
<template v-else-if="categories.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
<div class="empty-text">暂无采集类别</div>
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedCategories" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="categories.length > categoryPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="categories.length" :page-size="categoryPageSize" :current-page="categoryPage" @current-change="categoryPage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="categories.length > 0">
<div v-for="item in paginatedCategories" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 信息源 -->
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
<el-button size="small" @click="loadSources">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
</div>
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
<template v-else-if="sources.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
<div class="empty-text">暂无信息源</div>
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedSources" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="50"></el-table-column>
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
<el-table-column prop="source_type" label="类型" width="90">
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
</el-table-column>
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="sources.length > sourcePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="sources.length" :page-size="sourcePageSize" :current-page="sourcePage" @current-change="sourcePage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="sources.length > 0">
<div v-for="item in paginatedSources" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 创作任务 -->
<div v-if="activeTab === 'tasks'">
<div class="toolbar">
<el-button-group>
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
@@ -226,7 +277,6 @@
</el-button-group>
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div v-if="loading" style="text-align: center; padding: 40px; color: #909399;">加载中...</div>
<div v-else-if="allTasks.length === 0" style="text-align: center; padding: 60px 20px; color: #909399;">
<el-icon style="font-size:56px;margin-bottom:16px;color:#c0c4cc;"><IconTopic /></el-icon>
@@ -270,8 +320,267 @@
</div>
</div>
</div>
</div>
<!-- 模块详情抽屉 -->
<el-drawer v-model="showDrawer" :title="drawerTitle" size="50%" direction="rtl" class="task-drawer">
<div v-if="drawerLoading" style="text-align:center;padding:40px;color:#909399;">加载中...</div>
<div v-else-if="drawerError" style="text-align:center;padding:40px;color:#f56c6c;">{{ drawerError }}</div>
<div v-else-if="drawerData" style="height:100%;display:flex;flex-direction:column;">
<div style="padding-bottom:16px;border-bottom:1px solid #ebeef5;margin-bottom:16px;flex-shrink:0;">
<el-tag v-if="drawerData.module_id" size="small" type="info" style="margin-bottom:8px;">{{ drawerData.title || drawerData.module_id }}</el-tag>
<el-tag v-if="drawerData.module_id && drawerData.title" size="small" type="" style="margin-bottom:8px;margin-left:6px;">{{ drawerData.module_id }}</el-tag>
<p style="color:#606266;font-size:14px;margin:0;">{{ drawerData.description }}</p>
</div>
<el-tabs v-model="drawerTab" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<el-tab-pane label="📥 配置" name="inputs" style="overflow:auto;flex:1;">
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">基础配置</div>
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;">
<span style="font-size:13px;color:#606266;width:60px;">启用</span>
<el-switch v-model="drawerData.enabled" @change="saveModuleConfig" :disabled="savingConfig"></el-switch>
<span style="font-size:12px;color:#909399;">{{ drawerData.enabled ? '定时执行' : '已禁用' }}</span>
</div>
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;">
<span style="font-size:13px;color:#606266;width:60px;">定时</span>
<el-input v-model="drawerData.schedule" size="small" style="width:120px;" placeholder="HH:MM" :disabled="savingConfig"></el-input>
<span style="font-size:12px;color:#909399;">每日执行时间(HH:MM</span>
</div>
</div>
<div v-if="Object.keys(drawerData.params || {}).length > 0">
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">参数配置</div>
<div v-for="(val, key) in drawerData.params" :key="key" style="margin-bottom:12px;display:flex;align-items:center;gap:12px;">
<span style="font-size:13px;color:#606266;width:120px;">{{ key }}</span>
<el-input v-if="typeof val === 'string'" v-model="drawerData.params[key]" size="small" style="flex:1;" @change="saveModuleConfig" :disabled="savingConfig"></el-input>
<el-input-number v-else-if="typeof val === 'number'" v-model="drawerData.params[key]" size="small" :disabled="savingConfig" @change="saveModuleConfig"></el-input-number>
<el-switch v-else-if="typeof val === 'boolean'" v-model="drawerData.params[key]" size="small" :disabled="savingConfig" @change="saveModuleConfig"></el-switch>
<span v-else style="font-size:13px;color:#909399;">{{ JSON.stringify(val) }}</span>
</div>
</div>
<div v-else style="color:#909399;font-size:13px;padding:8px 0;">无自定义参数</div>
<div style="margin-top:16px;padding-top:12px;border-top:1px solid #ebeef5;">
<el-button type="primary" size="small" @click="saveModuleConfig" :loading="savingConfig">保存配置</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="📤 产出结果" name="outputs" style="overflow:auto;flex:1;">
<div v-if="drawerData.last_result && Object.keys(drawerData.last_result).length > 0">
<div style="font-size:13px;font-weight:600;margin-bottom:10px;color:#303133;">最近运行结果</div>
<div v-for="(val, key) in drawerData.last_result" :key="key" style="margin-bottom:6px;font-size:13px;">
<span style="color:#909399;">{{ key }}:</span>
<span :style="{color: ['topics_count','articles_synced','processed','passed','topics_found'].includes(key) ? '#409eff' : '#606266', fontWeight: ['topics_count','articles_synced'].includes(key) ? '600' : '400'}">{{ val }}</span>
</div>
<el-divider v-if="Object.keys(drawerData.outputs || {}).length > 0" />
</div>
<div v-if="Object.keys(drawerData.outputs || {}).length === 0" style="color:#909399;padding:20px;text-align:center;">暂无产出数据</div>
<div v-else>
<div v-for="(val, key) in drawerData.outputs" :key="key" style="margin-bottom:16px;border-bottom:1px solid #f0f2f5;padding-bottom:12px;">
<div style="font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;">{{ key }}</div>
<div v-if="key === '热点列表' && Array.isArray(val)">
<el-table :data="val" size="small" max-height="400" style="width:100%;">
<el-table-column prop="topic" label="话题" min-width="180"></el-table-column>
<el-table-column prop="domain" label="领域" width="100"></el-table-column>
<el-table-column prop="platform" label="平台" width="100"></el-table-column>
<el-table-column prop="source" label="来源" width="80"></el-table-column>
</el-table>
</div>
<div v-else-if="key === '最新选题' && Array.isArray(val)">
<el-table :data="val" size="small" max-height="300" style="width:100%;">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题" min-width="150"></el-table-column>
<el-table-column prop="field" label="领域" width="80"></el-table-column>
<el-table-column prop="status" label="状态" width="70"></el-table-column>
<el-table-column prop="created" label="创建时间" min-width="150"></el-table-column>
</el-table>
</div>
<div v-else-if="(key === '最新文章' || key === '最新指标') && Array.isArray(val)">
<el-table :data="val" size="small" max-height="300" style="width:100%;">
<el-table-column v-for="col in Object.keys(val[0]||{})" :key="col" :prop="col" :label="col" min-width="120"></el-table-column>
</el-table>
</div>
<div v-else-if="key === '各分类结果' && Array.isArray(val)">
<div v-for="item in val" :key="item.query" style="margin-bottom:8px;padding:8px;background:#f8faff;border-radius:6px;">
<div style="font-size:13px;font-weight:500;">{{ item.query }} <el-tag size="mini">{{ item.count }}条</el-tag></div>
<div style="font-size:12px;color:#909399;margin-top:4px;" v-for="s in item.samples" :key="s">● {{ s }}</div>
</div>
</div>
<div v-else-if="key === '文章评分' && Array.isArray(val)">
<div v-for="item in val" :key="item.title" style="margin-bottom:6px;display:flex;align-items:center;gap:8px;">
<span style="font-size:13px;">{{ item.title }}</span>
<el-rate :model-value="parseScore(item.score)" disabled show-score score-template="{value}分" size="small"></el-rate>
</div>
</div>
<div v-else-if="key === 'AI建议摘要'" style="background:#f0f9eb;padding:10px 14px;border-radius:8px;font-size:13px;color:#303133;">{{ val }}</div>
<div v-else-if="key === '建议新增类别' && Array.isArray(val)">
<div v-for="item in val" :key="item.name" style="padding:8px;background:#f0f9eb;border-radius:6px;margin-bottom:6px;">
<div style="font-weight:500;">{{ item.name }}</div>
<div style="font-size:12px;color:#606266;">{{ item.reason }}</div>
</div>
</div>
<div v-else-if="key === '类别评估' && Array.isArray(val)">
<div v-for="item in val" :key="item.name" style="margin-bottom:4px;font-size:13px;">
<el-tag :type="item.status==='保留'?'success':'warning'" size="mini" style="margin-right:6px;">{{ item.status }}</el-tag>
{{ item.name }} — {{ item.reason }}
</div>
</div>
<div v-else-if="key === '来源分布' && typeof val === 'object'">
<div v-for="(cnt, src) in val" :key="src" style="margin-bottom:4px;font-size:13px;">
{{ src }}: <el-tag size="mini">{{ cnt }}条</el-tag>
</div>
</div>
<div v-else-if="key === '高互动领域' && Array.isArray(val)">
<div v-for="item in val" :key="item[0]" style="margin-bottom:4px;font-size:13px;">
{{ item[0] }}: <el-tag size="mini" type="success">{{ item[1] }}分</el-tag>
</div>
</div>
<div v-else style="font-size:13px;color:#606266;">{{ val }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="📋 运行记录" name="history" style="overflow:auto;flex:1;">
<div v-if="drawerHistory && drawerHistory.length > 0">
<el-timeline>
<el-timeline-item v-for="(h, i) in drawerHistory" :key="h.id || i"
:timestamp="formatDate(h.started_at)"
:color="h.status === 'success' ? '#67c23a' : h.status === 'running' ? '#e6a23c' : '#f56c6c'"
:hollow="h.status === 'running'">
<div style="font-size:13px;">
<el-tag :type="h.status === 'success' ? 'success' : h.status === 'running' ? 'warning' : 'danger'" size="small" style="margin-right:6px;">{{ h.status }}</el-tag>
<span v-if="h.message">{{ h.message }}</span>
<span v-else style="color:#909399;"></span>
</div>
<div v-if="h.error_trace" style="margin-top:4px;padding:6px 8px;background:#fef0f0;border-radius:4px;font-size:12px;color:#f56c6c;max-height:80px;overflow:auto;">{{ h.error_trace }}</div>
<div style="font-size:12px;color:#c0c4cc;margin-top:2px;">
耗时 {{ h.duration ? h.duration + 's' : '—' }} · 触发 {{ h.triggered_by }}
</div>
</el-timeline-item>
</el-timeline>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">暂无运行记录</div>
</el-tab-pane>
<el-tab-pane label="🤖 提示词" name="prompts" style="overflow:auto;flex:1;">
<div v-if="!drawerPrompts || drawerPrompts.length === 0" style="color:#909399;padding:20px;text-align:center;">此模块暂无提示词配置</div>
<div v-else style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span style="font-size:12px;color:#909399;">{{ drawerPrompts.length }} 个提示词 · 点击展开编辑</span>
<el-button size="small" @click="reloadPrompts">刷新</el-button>
</div>
<el-collapse v-if="drawerPrompts.length > 3" style="flex-shrink:0;">
<el-collapse-item v-for="p in drawerPrompts" :key="p.id" :title="p.key + (p.description ? '' + p.description + '' : '')" :name="p.id">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div style="font-size:12px;color:#909399;">{{ p.description }}</div>
<el-switch v-model="p.enabled" @change="savePrompt(p)" :disabled="promptSaving === p.id" size="small"></el-switch>
</div>
<div style="display:flex;gap:12px;margin-bottom:8px;font-size:12px;color:#606266;">
temp: <span style="color:#409eff;">{{ p.temperature ?? '-' }}</span>
max_tokens: <span style="color:#409eff;">{{ p.max_tokens ?? '-' }}</span>
<span v-for="v in (p.variables || [])" :key="v.name" style="background:#f4f4f5;padding:1px 6px;border-radius:4px;">{{ v.name }}</span>
</div>
<el-input type="textarea" v-model="p.content" :rows="6" style="font-family:monospace;font-size:12px;" @blur="savePrompt(p)"></el-input>
</el-collapse-item>
</el-collapse>
<div v-else>
<div v-for="p in drawerPrompts" :key="p.id" style="border:1px solid #ebeef5;border-radius:10px;padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<div>
<el-tag size="small" type="info" style="margin-right:6px;">{{ p.category }}</el-tag>
<span style="font-weight:600;font-size:14px;color:#303133;">{{ p.key }}</span>
<div style="font-size:12px;color:#909399;margin-top:2px;">{{ p.description }}</div>
</div>
<el-switch v-model="p.enabled" @change="savePrompt(p)" :disabled="promptSaving === p.id" size="small"></el-switch>
</div>
<div style="display:flex;gap:10px;margin-bottom:6px;font-size:12px;color:#606266;flex-wrap:wrap;">
<span>temp: <span style="color:#409eff;">{{ p.temperature ?? '-' }}</span></span>
<span>max: <span style="color:#409eff;">{{ p.max_tokens ?? '-' }}</span></span>
<span v-for="v in (p.variables || [])" :key="v.name" style="background:#f4f4f5;padding:1px 6px;border-radius:4px;">{{ v.name }}</span>
</div>
<el-input type="textarea" v-model="p.content" :rows="4" style="font-family:monospace;font-size:12px;" @blur="savePrompt(p)"></el-input>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div style="padding-top:16px;border-top:1px solid #ebeef5;flex-shrink:0;text-align:center;">
<el-button type="primary" @click="triggerModule(drawerData.module_id)" :loading="runningModule === drawerData.module_id" size="medium">立即运行此任务</el-button>
</div>
</div>
</el-drawer>
</main>
</div>
<!-- 案例对话框 -->
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="700px" :close-on-click-modal="false">
<el-form :model="caseForm" label-width="80px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary" :rows="3"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="caseDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCase">确定</el-button>
</template>
</el-dialog>
<!-- 类别对话框 -->
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCategory">确定</el-button>
</template>
</el-dialog>
<!-- 信息源对话框 -->
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
<el-form :model="sourceForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
<el-form-item label="类型">
<el-select v-model="sourceForm.source_type" style="width:100%">
<el-option label="RSS订阅" value="rss"></el-option>
<el-option label="搜索引擎" value="web_search"></el-option>
<el-option label="本地文件" value="local"></el-option>
</el-select>
</el-form-item>
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
<el-form-item label="可信度">
<el-select v-model="sourceForm.credibility" style="width:100%">
<el-option label="高" value="high"></el-option>
<el-option label="中" value="medium"></el-option>
<el-option label="低" value="low"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="sourceDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveSource">确定</el-button>
</template>
</el-dialog>
<!-- 任务详情对话框 -->
<el-dialog v-model="showDetailDialog" title="任务详情" width="600px" class="task-detail-dialog">
<div v-if="detailTask">
<div class="detail-row"><span class="detail-label">任务ID</span><span class="detail-value">{{ detailTask.task_id }}</span></div>
@@ -297,6 +606,9 @@
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue;
const { ElMessage, ElMessageBox } = ElementPlus;
const TasksApp = {
data() {
const SCHEDULER_JOBS = {
@@ -317,16 +629,32 @@ const TasksApp = {
scheduled_optimize_sources: '/api/system/optimize-sources/run',
scheduled_metrics_sync: '/api/system/metrics-sync/run',
};
const STAGE_NAMES = { 'creator': '创作', 'optimize': '审查', 'review': '审查', 'publish': '发布' };
const STATUS_NAMES = { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' };
return {
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
activeTab: 'scheduler',
// 模块 & 调度
modules: [], moduleLoading: false, schedulerRunning: false, runningModule: null,
showDrawer: false, drawerTitle: '', drawerData: null, drawerLoading: false, drawerError: '', drawerTab: 'inputs', drawerHistory: [], drawerPrompts: [], savingConfig: false, promptSaving: null, promptEditOrig: {},
// 创作任务
allTasks: [], loading: false, filterStatus: '',
showDetailDialog: false, detailTask: null,
modules: [], moduleLoading: false, schedulerRunning: false, runningModule: null,
currentPage: 1, pageSize: 10,
showDrawer: false, drawerTitle: '', drawerData: null, drawerLoading: false, drawerError: '', drawerTab: 'inputs',
pollTimer: null,
// 案例
cases: [], casesLoading: false,
caseDialogVisible: false, caseDialogTitle: '新增案例', editingCaseId: null,
caseForm: { id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' },
casePage: 1, casePageSize: 10,
// 类别
categories: [], categoriesLoading: false,
categoryDialogVisible: false, categoryDialogTitle: '新增类别', editingCategoryId: null,
categoryForm: { name: '', search_query: '', description: '', sort_order: 0, is_active: true },
categoryPage: 1, categoryPageSize: 10,
// 信息源
sources: [], sourcesLoading: false,
sourceDialogVisible: false, sourceDialogTitle: '新增信息源', editingSourceId: null,
sourceForm: { name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true },
sourcePage: 1, sourcePageSize: 10,
SCHEDULER_JOBS, MODULE_TRIGGER_ENDPOINTS,
}
},
@@ -334,13 +662,26 @@ const TasksApp = {
paginatedTasks() {
const start = (this.currentPage - 1) * this.pageSize;
return this.allTasks.slice(start, start + this.pageSize);
}
},
paginatedCases() {
const s = (this.casePage - 1) * this.casePageSize;
return this.cases.slice(s, s + this.casePageSize);
},
paginatedCategories() {
const s = (this.categoryPage - 1) * this.categoryPageSize;
return this.categories.slice(s, s + this.categoryPageSize);
},
paginatedSources() {
const s = (this.sourcePage - 1) * this.sourcePageSize;
return this.sources.slice(s, s + this.sourcePageSize);
},
},
methods: {
// === 通用 ===
getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) {
const token = this.getToken();
if (!token) { this.$message.error('请先登录'); return null; }
if (!token) { ElMessage.error('请先登录'); return null; }
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
return res.json();
@@ -352,9 +693,19 @@ const TasksApp = {
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.loadModules(); this.loadTasks(); })
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.switchTab('scheduler'); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
switchTab(name) {
this.activeTab = name;
this.showDrawer = false;
if (name === 'scheduler') { this.loadModules(); }
else if (name === 'cases') { this.loadCases(); }
else if (name === 'categories') { this.loadCategories(); }
else if (name === 'sources') { this.loadSources(); }
else if (name === 'tasks') { this.loadTasks(); }
},
// === 模块 ===
async loadModules() {
this.moduleLoading = true;
try {
@@ -368,32 +719,71 @@ const TasksApp = {
async openModuleDetail(mod) {
this.showDrawer = true;
this.drawerTitle = mod.title + ' 详情';
this.drawerData = null;
this.drawerData = { ...mod };
this.drawerError = '';
this.drawerLoading = true;
this.drawerTab = 'inputs';
this.drawerHistory = [];
this.drawerPrompts = [];
try {
const data = await this.api('/api/tasks/modules/' + mod.id + '/detail');
if (data) {
data.status = mod.status;
this.drawerData = data;
} else {
this.drawerError = '加载失败';
}
const [detail, history, prompts] = await Promise.all([
this.api('/api/admin/task-configs/' + mod.module_id),
this.api('/api/admin/task-configs/history/' + mod.module_id + '?limit=20'),
this.api('/api/admin/prompt-configs?module_id=' + mod.module_id),
]);
this.drawerData = { ...mod, ...detail };
this.drawerHistory = history || [];
this.drawerPrompts = prompts || [];
} catch (e) { this.drawerError = e.message; }
finally { this.drawerLoading = false; }
},
async savePrompt(p) {
this.promptSaving = p.id;
try {
await this.api('/api/admin/prompt-configs/' + p.id, {
method: 'PUT',
body: JSON.stringify({
content: p.content,
enabled: p.enabled,
description: p.description,
temperature: p.temperature,
max_tokens: p.max_tokens,
}),
});
ElMessage.success('已保存');
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
finally { this.promptSaving = null; }
},
async reloadPrompts() {
if (!this.drawerData || !this.drawerData.module_id) return;
try {
this.drawerPrompts = await this.api('/api/admin/prompt-configs?module_id=' + this.drawerData.module_id);
} catch (e) { ElMessage.error('刷新失败: ' + e.message); }
},
async triggerModule(modId) {
const endpoint = this.MODULE_TRIGGER_ENDPOINTS[modId];
if (!endpoint) { this.$message.error('未知模块'); return; }
if (!endpoint) { ElMessage.error('未知模块'); return; }
this.runningModule = modId;
try {
await this.api(endpoint, { method: 'POST' });
this.$message.success('任务已启动');
ElMessage.success('任务已启动');
setTimeout(() => this.loadModules(), 2000);
} catch (e) { this.$message.error('启动失败: ' + e.message); }
} catch (e) { ElMessage.error('启动失败: ' + e.message); }
finally { this.runningModule = null; }
},
async saveModuleConfig() {
if (!this.drawerData || !this.drawerData.module_id) return;
this.savingConfig = true;
try {
await this.api('/api/admin/task-configs/' + this.drawerData.module_id, {
method: 'PUT',
body: JSON.stringify({ enabled: this.drawerData.enabled, params: this.drawerData.params, schedule: this.drawerData.schedule, last_modified_by: this.currentUser.username }),
});
ElMessage.success('配置已保存');
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
finally { this.savingConfig = false; }
},
// === 创作任务 ===
parseScore(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; },
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
getStageLabel(stage) { return { 'creator': '创作', 'optimize': '审查', 'review': '审查', 'publish': '发布' }[stage] || stage; },
@@ -414,33 +804,105 @@ const TasksApp = {
let url = '/api/tasks?limit=200';
if (this.filterStatus) url += '&status=' + this.filterStatus;
const data = await this.api(url) || [];
const oldId = this.allTasks.length > 0 && this.allTasks[0] ? this.allTasks[0].task_id : null;
this.allTasks = data;
if (resetPage) this.currentPage = 1;
const hasRunning = data.some(t => t.status === 'running');
if (hasRunning) { this.startPolling(); } else { this.stopPolling(); }
} catch (e) { console.error(e); this.$message.error('加载任务列表失败: ' + e.message); }
} catch (e) { console.error(e); ElMessage.error('加载任务列表失败: ' + e.message); }
finally { this.loading = false; }
},
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
async cancelTask(taskId) {
try {
await this.$confirm('确定取消该任务?', '提示', { type: 'warning' });
await ElMessageBox.confirm('确定取消该任务?', '提示', { type: 'warning' });
await this.api('/api/tasks/' + taskId, { method: 'DELETE' });
this.$message.success('任务已取消');
ElMessage.success('任务已取消');
this.loadTasks();
} catch (e) { if (e !== 'cancel') this.$message.error(e.message || '操作失败'); }
} catch (e) { if (e !== 'cancel') ElMessage.error(e.message || '操作失败'); }
},
async retryTask(task) {
if (!task.topic_id) { this.$message.warning('该任务无关联选题,无法重试'); return; }
if (!task.topic_id) { ElMessage.warning('该任务无关联选题,无法重试'); return; }
try {
await this.api('/api/tasks/run-creator?topic_id=' + task.topic_id, { method: 'POST' });
this.$message.success('已重新提交创作任务');
ElMessage.success('已重新提交创作任务');
this.loadTasks();
} catch (e) { this.$message.error('重试失败: ' + e.message); }
} catch (e) { ElMessage.error('重试失败: ' + e.message); }
},
// === 案例 ===
async loadCases() {
this.casesLoading = true;
try { this.cases = await this.api('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); }
finally { this.casesLoading = false; }
},
showCaseDialog(row = null) {
if (row) { this.caseDialogTitle = '编辑案例'; this.editingCaseId = row.id; Object.assign(this.caseForm, row); }
else { this.caseDialogTitle = '新增案例'; this.editingCaseId = null; Object.keys(this.caseForm).forEach(k => { if (k === 'id') this.caseForm.id = null; else this.caseForm[k] = ''; }); }
this.caseDialogVisible = true;
},
async saveCase() {
try {
if (this.editingCaseId) { await this.api('/api/admin/cases/' + this.editingCaseId, { method: 'PUT', body: JSON.stringify(this.caseForm) }); ElMessage.success('更新成功'); }
else { await this.api('/api/admin/cases', { method: 'POST', body: JSON.stringify(this.caseForm) }); ElMessage.success('创建成功'); }
this.caseDialogVisible = false; await this.loadCases();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
},
async deleteCase(id) {
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await this.api('/api/admin/cases/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); await this.loadCases(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
},
// === 类别 ===
async loadCategories() {
this.categoriesLoading = true;
try { this.categories = await this.api('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
finally { this.categoriesLoading = false; }
},
showCategoryDialog(row = null) {
if (row) { this.categoryDialogTitle = '编辑类别'; this.editingCategoryId = row.id; Object.assign(this.categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { this.categoryDialogTitle = '新增类别'; this.editingCategoryId = null; this.categoryForm.name = ''; this.categoryForm.search_query = ''; this.categoryForm.description = ''; this.categoryForm.sort_order = 0; this.categoryForm.is_active = true; }
this.categoryDialogVisible = true;
},
async saveCategory() {
try {
if (this.editingCategoryId) { await this.api('/api/admin/collector/categories/' + this.editingCategoryId, { method: 'PUT', body: JSON.stringify(this.categoryForm) }); ElMessage.success('更新成功'); }
else { await this.api('/api/admin/collector/categories', { method: 'POST', body: JSON.stringify(this.categoryForm) }); ElMessage.success('创建成功'); }
this.categoryDialogVisible = false; await this.loadCategories();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
},
async deleteCategory(id) {
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await this.api('/api/admin/collector/categories/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); await this.loadCategories(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
},
// === 信息源 ===
async loadSources() {
this.sourcesLoading = true;
try { this.sources = await this.api('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
finally { this.sourcesLoading = false; }
},
showSourceDialog(row = null) {
if (row) { this.sourceDialogTitle = '编辑信息源'; this.editingSourceId = row.id; Object.assign(this.sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { this.sourceDialogTitle = '新增信息源'; this.editingSourceId = null; this.sourceForm.name = ''; this.sourceForm.source_type = 'web_search'; this.sourceForm.query = ''; this.sourceForm.credibility = 'medium'; this.sourceForm.focus = ''; this.sourceForm.sort_order = 0; this.sourceForm.is_active = true; }
this.sourceDialogVisible = true;
},
async saveSource() {
try {
if (this.editingSourceId) { await this.api('/api/admin/collector/sources/' + this.editingSourceId, { method: 'PUT', body: JSON.stringify(this.sourceForm) }); ElMessage.success('更新成功'); }
else { await this.api('/api/admin/collector/sources', { method: 'POST', body: JSON.stringify(this.sourceForm) }); ElMessage.success('创建成功'); }
this.sourceDialogVisible = false; await this.loadSources();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
},
async deleteSource(id) {
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await this.api('/api/admin/collector/sources/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); await this.loadSources(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
},
},
mounted() {
this.checkAuth();
// 从 URL hash 定位 tab
const hash = window.location.hash.replace('#tab=', '');
if (['scheduler','cases','categories','sources','tasks'].includes(hash)) {
this.activeTab = hash;
}
},
mounted() { this.checkAuth(); },
beforeUnmount() { this.stopPolling(); }
};
const app = Vue.createApp(TasksApp);
+3 -2
View File
@@ -356,9 +356,10 @@ const TopicsApp = {
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
const body = doc.body;
if (body) {
body.querySelectorAll('script, nav, .header, .tags, footer, .interaction').forEach(el => el.remove());
body.querySelectorAll('script, style, nav, .header, .tags, footer, .interaction, svg, img, button').forEach(el => el.remove());
}
const text = body ? body.textContent.trim() : html.replace(/<[^>]+>/g, '').trim();
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(`已复制内容,请前往${platform}粘贴发布`))
.catch(() => this.$message.error('复制失败,请手动复制'));
+1 -1
View File
@@ -134,7 +134,7 @@
{ key: 'calendar', label: '日历', page: 'calendar.html' },
{ key: 'assets', label: '素材', page: 'assets.html' },
{ key: 'tasks', label: '任务', page: 'tasks.html' },
{ key: 'platforms', label: '平台', page: 'platforms.html' },
];
if (isAdmin) {
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
+57 -37
View File
@@ -39,7 +39,48 @@ logging.basicConfig(
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
DEFAULT_CHINA_PAINS = {
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证"
}
_cached_china_pains = None
def _load_china_pains():
global _cached_china_pains
if _cached_china_pains is not None:
return _cached_china_pains
try:
from app.database import SessionLocal
from app.models import CollectorCategory
db = SessionLocal()
try:
cats = db.query(CollectorCategory).filter(
CollectorCategory.is_active == True,
CollectorCategory.pain_template.isnot(None),
CollectorCategory.pain_template != ""
).all()
if cats:
_cached_china_pains = {c.name: c.pain_template for c in cats}
logger.info(f"从DB加载 {len(_cached_china_pains)} 个类别的pain_template")
return _cached_china_pains
finally:
db.close()
except Exception as e:
logger.warning(f"从DB加载 china_pains 失败: {e}")
_cached_china_pains = DEFAULT_CHINA_PAINS
return _cached_china_pains
def _get_china_pain(category: str) -> str:
pains = _load_china_pains()
return pains.get(category, "中国相关数据不足,需本土化验证")
@dataclass
@@ -322,14 +363,8 @@ class SustainabilityCollector:
if not content:
content = title
# 关键词匹配(来源特定或全局)
keywords = source_keywords if source_keywords else [
'sustainable', 'green', 'eco', 'circular', 'climate', 'carbon',
'zero waste', 'renewable', 'recycle', '环保', '可持续', '碳中和',
'循环经济', '零浪费', '低碳', '生态'
]
search_text = (title + content).lower()
keywords = source_keywords if source_keywords else _load_rss_keywords()
if any(keyword.lower() in search_text for keyword in keywords):
articles.append({
'title': title,
@@ -445,6 +480,8 @@ class SustainabilityCollector:
logger.warning("LLM不可用,跳过AI选题生成")
return []
from prompt_loader import get_prompt, get_prompt_params
existing = self._get_existing_titles()
existing_hint = ""
if existing:
@@ -461,28 +498,21 @@ class SustainabilityCollector:
if cases:
case_lines = [f"- {c.title[:40]}({c.category})" for c in cases[:5]]
data_section += "\n采集案例:\n" + "\n".join(case_lines) + "\n"
if not data_section:
data_section = "(当前无实时采集数据,请基于你对中文互联网趋势的了解直接生成)"
trend_context = self._get_trend_context()
prompt = f"""你是一个内容策略师。基于以下信息,为「{target_category}」类别生成一个高质量选题。
{data_section if data_section else "(当前无实时采集数据,请基于你对中文互联网趋势的了解直接生成)"}
{existing_hint}
{trend_context}
输出一个选题格式JSON
{{{{
"title": "标题(20字内,含核心关键词)",
"core_concept": "核心观点(一句话)",
"audience_pain": "受众痛点",
"unique_angle": "差异化切入点",
"format": "内容形式(趋势洞察/实操指南/对比分析/案例解读)"
}}}}
只输出JSON"""
prompt = get_prompt("topic_generate",
target_category=target_category,
data_section=data_section,
existing_hint=existing_hint,
trend_context=trend_context,
)
try:
resp = call_llm(prompt, temperature=0.7)
params = get_prompt_params("topic_generate")
resp = call_llm(prompt, temperature=params.get("temperature", 0.6), max_tokens=params.get("max_tokens", 2000))
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
@@ -570,18 +600,8 @@ class SustainabilityCollector:
# 实际应用中可用AI提取,这里用前100字符
core_idea = content[:200] if len(content) > 200 else content
# 生成中国痛点(基于类别模板)
china_pains = {
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证"
}
china_pain = china_pains.get(category, "中国相关数据不足,需本土化验证")
# 生成中国痛点(基于类别模板,从DB读取pain_template)
china_pain = _get_china_pain(category)
# 生成案例
case = SustainabilityCase(
+70 -6
View File
@@ -7,15 +7,13 @@
import re
from typing import Dict, List, Tuple
# 敏感词库(示例,需要持续更新)
SENSITIVE_WORDS = {
"政治敏感": ["国家主席", "政治局", "常委", "军委", "统战部", "颠覆国家", "分裂主义", "台独", "疆独", "藏独"],
"违禁内容": ["赌博", "毒品", "迷药", "枪支", "炸药", "色情", "低俗", "反动", "邪教"],
"不实信息": [" guaranteed 赚钱", "一夜暴富", "100%有效", "包治百病", "绝对正确"],
"领导人相关": ["主席", "总理", "总书记", "国家领导人"] # 需上下文判断
"领导人相关": ["主席", "总理", "总书记", "国家领导人"]
}
# 平台规则限制
PLATFORM_RULES = {
"zhihu": {
"max_title_len": 100,
@@ -37,6 +35,70 @@ PLATFORM_RULES = {
}
}
_cached_sensitive_words = None
_cached_platform_rules = None
def _load_sensitive_words():
global _cached_sensitive_words
if _cached_sensitive_words is not None:
return _cached_sensitive_words
try:
from app.core.prompt_loader import _get_session
from app.models import SensitiveWord
session = _get_session()
try:
rows = session.query(SensitiveWord).filter(SensitiveWord.is_active == True).all()
if rows:
result = {}
for r in rows:
cat = r.category or "general"
if cat not in result:
result[cat] = []
result[cat].append(r.word)
_cached_sensitive_words = result
return _cached_sensitive_words
finally:
session.close()
except Exception:
pass
_cached_sensitive_words = SENSITIVE_WORDS
return _cached_sensitive_words
def _load_platform_rules():
global _cached_platform_rules
if _cached_platform_rules is not None:
return _cached_platform_rules
try:
from app.core.prompt_loader import _get_session
from app.models import PlatformConfig
import json
session = _get_session()
try:
rows = session.query(PlatformConfig).all()
if rows:
result = {}
for r in rows:
try:
cfg = json.loads(r.config_data) if r.config_data else {}
except:
cfg = {}
if cfg:
result[r.platform] = cfg
if result:
_cached_platform_rules = result
return _cached_platform_rules
finally:
session.close()
except Exception:
pass
_cached_platform_rules = PLATFORM_RULES
return _cached_platform_rules
class ComplianceChecker:
"""合规审查器"""
@@ -90,7 +152,8 @@ class ComplianceChecker:
def _check_sensitive_words(self, text: str):
"""检查敏感词"""
for category, words in SENSITIVE_WORDS.items():
words_map = _load_sensitive_words()
for category, words in words_map.items():
for word in words:
if word in text:
self.issues.append({
@@ -102,7 +165,8 @@ class ComplianceChecker:
def _check_platform_rules(self, text: str, platform: str):
"""检查平台特定规则"""
rules = PLATFORM_RULES.get(platform, {})
rules_map = _load_platform_rules()
rules = rules_map.get(platform, {})
# 标题长度(从HTML中提取)
max_title_len = self._get_platform_rule('max_title_len', rules.get("max_title_len"))
@@ -244,7 +308,7 @@ class ComplianceChecker:
"""检查文章最小字数(去除HTML标签)"""
plain = re.sub(r'<[^>]+>', '', text)
word_count = len(plain.strip())
min_words = self._get_platform_rule('min_word_count', PLATFORM_RULES.get(platform, {}).get("min_word_count", 1000))
min_words = self._get_platform_rule('min_word_count', 1000)
if word_count < min_words:
self.issues.append({
"type": "内容完整度",
+46 -29
View File
@@ -20,6 +20,8 @@ except ImportError:
HAVE_LLM = False
from db_helper import get_topic_by_id, update_topic_status, get_active_llm_config, get_articles_by_topic, save_article
from content_cleaner import strip_thinking, strip_ai_preface, strip_thinking_html, clean_html_content
from prompt_loader import get_prompt, get_prompt_params
DATA_DIR = PROJECT_ROOT / "automation" / "data"
DRAFTS_DIR = DATA_DIR / "drafts"
@@ -30,11 +32,42 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(
handlers=[logging.FileHandler(LOGS_DIR / f"optimizer_{TODAY}.log"), logging.StreamHandler()])
logger = logging.getLogger(__name__)
PLATFORM_TAGS = {
DEFAULT_PLATFORM_TAGS = {
"zhihu": ["科技", "职场"],
"xiaohongshu": ["AI", "可持续", "生活方式"]
}
_cached_platform_tags = None
def _load_platform_tags():
global _cached_platform_tags
if _cached_platform_tags is not None:
return _cached_platform_tags
try:
from app.database import SessionLocal
from app.models import PlatformConfig
db = SessionLocal()
try:
configs = db.query(PlatformConfig).filter(PlatformConfig.is_active == True).all()
if configs:
_cached_platform_tags = {}
for c in configs:
tags = c.to_dict().get("allowed_tags", [])
if tags:
_cached_platform_tags[c.platform] = tags
if _cached_platform_tags:
logger.info(f"从DB加载 {len(_cached_platform_tags)} 个平台的标签")
return _cached_platform_tags
finally:
db.close()
except Exception as e:
logger.warning(f"从DB加载 platform_tags 失败: {e}")
_cached_platform_tags = DEFAULT_PLATFORM_TAGS
return _cached_platform_tags
def get_platform_tags():
return _load_platform_tags()
_llm_config_cache = None
def get_llm_config():
@@ -110,13 +143,14 @@ def fix_wechat_title(html: str, title: str) -> str:
return html
def fix_tags(html: str, platform: str) -> str:
tags_map = get_platform_tags()
if platform == "zhihu":
tags_str = " ".join(f"#{t}" for t in PLATFORM_TAGS["zhihu"])
tags_str = " ".join(f"#{t}" for t in tags_map.get("zhihu", []))
if '<div class="tags">' in html:
old = html.split('<div class="tags">')[1].split('</div>')[0]
html = html.replace(f'<div class="tags">{old}</div>', f'<div class="tags">{tags_str}</div>')
elif platform == "xiaohongshu":
tags_str = " ".join(f"#{t}" for t in PLATFORM_TAGS["xiaohongshu"])
tags_str = " ".join(f"#{t}" for t in tags_map.get("xiaohongshu", []))
if '<div class="hashtags">' in html:
old = html.split('<div class="hashtags">')[1].split('</div>')[0]
html = html.replace(f'<div class="hashtags">{old}</div>', f'<div class="hashtags">{tags_str}</div>')
@@ -140,32 +174,13 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
f"- [{i['type']}] {i.get('category','')}: {i.get('detail','')} (建议: {i.get('suggestion','')})"
for i in remaining_issues
)
polish_prompt = f"""你是一个专业的内容合规优化助手。以下文章存在合规问题,请逐一修复并输出完整HTML。
需修复的问题
{issues_desc}
原文
{html}
要求
- 只修复上述问题不改变文章结构和核心内容
- 保持<h2>, <h3>, <p>等标签结构不变
- 修复后内容依然保持可读性和自然语感不要因为合规变成生硬的表达
- 替换敏感词时选择意思相近的替代词不删节重要信息"""
prompt = get_prompt("compliance_fix", issues_desc=issues_desc, html=html)
else:
polish_prompt = f"""你是一个专业的内容润色助手。请润色以下文章,提升表达的自然感和可读性。
原文
{html}
要求
- 保持原文事实数据章节结构不变
- 输出相同的HTML格式保留<h2>, <h3>, <p>标签
- 提升表达的自然感让它更像是人写的
- 避免AI常见表达模式首先其次最后总的来说值得注意的是
- 短句化读起来更流畅"""
polished = call_llm(polish_prompt, model=model, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
prompt = get_prompt("compliance_polish", html=html)
polished = call_llm(prompt, model=model, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
polished = clean_html_content(polished)
polished = strip_ai_preface(polished)
polished = strip_thinking_html(polished)
if '<h2' in polished or '<p>' in polished:
if len(polished) > len(html) * 0.3 and len(polished) > 100:
if not any(kw in polished[:100] for kw in ['保留', '建议', '可以', '应该', '推荐']):
@@ -178,6 +193,7 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
def optimize_article(html: str, platform: str, topic_data: Dict, remaining_issues: Optional[List[Dict]] = None) -> Tuple[str, List[str]]:
logs = []
html = strip_thinking_html(html)
if platform == "wechat":
html = fix_wechat_title(html, topic_data.get("title", ""))
logs.append("标题截断(含后缀)")
@@ -185,7 +201,8 @@ def optimize_article(html: str, platform: str, topic_data: Dict, remaining_issue
before = html
html = fix_tags(html, platform)
if html != before:
logs.append(f"标签标准化为{PLATFORM_TAGS[platform]}")
tags_map = get_platform_tags()
logs.append(f"标签标准化为{tags_map.get(platform, [])}")
polished, pol_log = polish_with_llm(html, platform, remaining_issues)
if pol_log:
html = polished
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
内容清洗工具集所有 AI 思考内容/噪音段落的清洗逻辑集中管理
各脚本writer/outline/compliance_optimizer统一引用此模块
"""
import re
from typing import List, Tuple
THINKING_PATTERNS: List[str] = [
r'^(好的|好的,|好[之,]|我来|让我|我将|我这就).*?(?=\n|$)',
r'^(以下|下面是|这是|为您|根据).*?(?=\n|$)',
r'^基于.*?(?=\n|$)',
r'^【.*?】',
r'^这里.*?(?=\n|$)',
r'\n+希望[这以].*?$',
r'\n+如果.*?$',
r'\n+若有.*?$',
r'\n+如有.*?$',
r'\n+\*\*免责.*?$',
r'^(这是按照要求|我已按|根据您的要求|^首先|^其次|^最后|^补充|^完成后).*?(?=\n|$)',
r'^(以下是|下面为|这是完整|已按要求|已完成|处理完成).*?(?=\n|$)',
]
AI_PREFACE_PATTERNS: List[str] = [
r'^(好的[,,]?|好的 |我来|让我|我将|我这就|以下|下面|这是|为您|基于)',
r'^(这是按照要求|我已按|根据您的要求|^首先|^其次|^最后|^补充|^完成后|以下是|下面为|这是完整|已按要求|已完成)',
]
AI_VERBAL_PATTERNS: List[str] = [
r'^(首先|其次|最后)(|,)?',
r'^总的来说',
r'^值得注意的是',
r'^换句话说',
r'^总而言之',
r'^简而言之',
r'^一言以蔽之',
r'^可以说',
r'^不难发现',
r'^由此可见',
r'^综上所述',
r'^通过以上',
]
_cached_clean_rules = None
def _load_clean_rules():
global _cached_clean_rules
if _cached_clean_rules is not None:
return _cached_clean_rules
try:
from app.core.prompt_loader import _get_session
from app.models import ContentCleanRule
session = _get_session()
try:
rows = session.query(ContentCleanRule).filter(ContentCleanRule.is_active == True).order_by(ContentCleanRule.sort_order).all()
if rows:
result = {"thinking": [], "preface": [], "verbosity": [], "html_thinking": []}
for r in rows:
rule_type = r.rule_type or "thinking"
if rule_type in result:
result[rule_type].append(r.pattern)
_cached_clean_rules = result
return _cached_clean_rules
finally:
session.close()
except Exception:
pass
_cached_clean_rules = {
"thinking": THINKING_PATTERNS,
"preface": AI_PREFACE_PATTERNS,
"verbosity": AI_VERBAL_PATTERNS,
"html_thinking": [],
}
return _cached_clean_rules
def _get_thinking_patterns() -> List[str]:
rules = _load_clean_rules()
return rules.get("thinking", THINKING_PATTERNS)
def _get_preface_patterns() -> List[str]:
rules = _load_clean_rules()
return rules.get("preface", AI_PREFACE_PATTERNS)
def _get_verbal_patterns() -> List[str]:
rules = _load_clean_rules()
return rules.get("verbosity", AI_VERBAL_PATTERNS)
def strip_thinking(text: str) -> str:
"""清洗 AI 思考前缀/后缀(正则替换,支持纯文本和 HTML 内联)"""
for pat in _get_thinking_patterns():
text = re.sub(pat, '', text, flags=re.MULTILINE)
return text.strip()
def strip_thinking_html(html: str) -> str:
"""清洗 HTML 中的 AI 思考段落(处理 <p>/<div> 包裹的情况)"""
rules = _load_clean_rules()
patterns = rules.get("html_thinking", [])
if not patterns:
patterns = [
r'<p[^>]*>(好的|好的,|好[的,]|我来|让我|我将|我这就|以下|下面|这是|为您|基于|这是按照要求|我已按|根据您的要求|^首先|^其次|^最后|^补充|^完成后|以下是|下面为|这是完整|已按要求|已完成).*?</p>',
r'<div[^>]*>(好的|好的,|好[的,]|我来|让我|我将|我这就|以下|下面|这是|为您|基于|这是按照要求|我已按|根据您的要求|^首先|^其次|^最后|^补充|^完成后|以下是|下面为|这是完整|已按要求|已完成).*?</div>',
r'<p[^>]*>首先.*?</p>',
r'<p[^>]*>其次.*?</p>',
r'<p[^>]*>最后.*?</p>',
r'<p[^>]*>(总的来说|值得注意的是|换句话说|总而言之|简而言之|一言以蔽之|可以说|不难发现|由此可见|综上所述).*?</p>',
r'<div[^>]*>(总的来说|值得注意的是|换句话说|总而言之|简而言之|一言以蔽之|可以说|不难发现|由此可见|综上所述).*?</div>',
]
for pat in patterns:
html = re.sub(pat, '', html, flags=re.IGNORECASE)
return html
def strip_ai_preface(text: str) -> str:
"""清洗以 AI 自述开头的整段说明文字(含代码围栏块)"""
lines = text.split('\n')
result = []
skip_mode = False
code_start = re.compile(r'^```')
for line in lines:
stripped = line.strip()
if skip_mode:
if code_start.match(stripped):
skip_mode = False
continue
should_skip = False
for pat in _get_preface_patterns():
if re.match(pat, stripped):
should_skip = True
break
if should_skip:
if code_start.match(stripped) or '```' in stripped:
skip_mode = True
continue
result.append(line)
return '\n'.join(result).strip()
def strip_ai_verbosity(text: str) -> str:
"""清洗正文中常见的 AI 套话段落"""
lines = text.split('\n')
result = []
for line in lines:
stripped = line.strip()
skip = False
for pat in _get_verbal_patterns():
if re.match(pat, stripped):
skip = True
break
if not skip:
result.append(line)
return '\n'.join(result).strip()
def clean_markdown_content(text: str) -> str:
"""清洗 markdown 正文:去思考内容 + 去 AI 套话 + 去格式噪音"""
text = strip_thinking(text)
text = strip_ai_preface(text)
text = strip_ai_verbosity(text)
lines = text.split('\n')
cleaned = []
in_code = False
for line in lines:
if line.strip().startswith('```'):
in_code = not in_code
continue
if in_code:
continue
line = re.sub(r'^#{1,6}\s+', '', line)
line = re.sub(r'^[\-\*\+]\s+', '', line)
line = re.sub(r'^\d+[\.\)]\s+', '', line)
line = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', line)
cleaned.append(line)
return '\n'.join(cleaned).strip()
def clean_html_content(html: str) -> str:
"""清洗 HTML 输出:去 markdown 代码围栏头尾 + 去 AI 思考注释"""
html = re.sub(r'^```+\w*\s*\n?', '', html)
html = html.strip()
html = re.sub(r'\n?```+\s*$', '', html)
html = strip_thinking_html(html)
return html
def clean_full_pipeline(text: str, output_format: str = 'markdown') -> str:
"""
完整清洗流程
- markdown 输入先去思考前缀 再去格式噪音 再转 HTML
- html 输入直接去代码围栏 + 思考注释
"""
if output_format == 'html':
return clean_html_content(text)
return clean_markdown_content(text)
def get_statistics(text: str) -> dict:
"""返回清洗前后的行数/字数统计(用于日志)"""
original_lines = len(text.split('\n'))
original_chars = len(text)
cleaned = strip_thinking(text)
cleaned = strip_ai_preface(cleaned)
cleaned = strip_ai_verbosity(cleaned)
cleaned_lines = len(cleaned.split('\n'))
cleaned_chars = len(cleaned)
return {
'original_lines': original_lines,
'cleaned_lines': cleaned_lines,
'original_chars': original_chars,
'cleaned_chars': cleaned_chars,
'dropped_lines': original_lines - cleaned_lines,
'dropped_chars': original_chars - cleaned_chars,
}
+3 -1
View File
@@ -16,8 +16,10 @@ sys.path.insert(0, str(PROJECT_ROOT))
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
LOG_FILE = LOGS_DIR / f"opencode_search_{TODAY}.log"
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler()])
logger = logging.getLogger(__name__)
SEARCH_CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "search_cache.json"
+13 -50
View File
@@ -12,6 +12,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
from db_helper import get_topic_by_id
from prompt_loader import get_prompt, get_prompt_params
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
@@ -52,57 +53,19 @@ class Outliner:
if HAVE_LLM:
_now = datetime.datetime.now()
prompt = f"""你是一个资深内容编辑,擅长设计读者爱看+搜索引擎友好+平台愿意推荐+有市场传播力的文章结构。
今天日期{_now.strftime('%Y年%m月%d')}当前年份{_now.year}
## 选题信息
标题{title}
领域{field}
核心观点{core}
受众痛点{pain}
独特视角{angle}
## 研究笔记
{cases_summary}
## 大纲设计要求
### 结构
- 5-8每章2-4个要点
- 结构要有递进要么认知升级型要么问题解决型
- 把独特视角和受众痛点融入各章不单独列
- 每章标题自带信息量+好奇心不要引言总结这类通用标题
- 开头要有"钩子"hook抓住读者结尾要有可转发/收藏的总结
### 数据与热点
- **全文必须使用{_now.year-1}-{_now.year}年最新数据**禁用一切过时数据
- 每个观点必须配最新的国内外热点事件/数据/政策来佐证
- 体现当前行业正在讨论的核心议题拒绝泛泛而谈
### 独特风格
- 有自己的判断和立场不是搬运观点
- 每章至少一个"反常识""很少有人提"的洞察
- 避免同质化表达老生常谈
### SEO
- H2/H3自然包含用户搜索时会用的短语
- 确保大纲覆盖2-3个高价值搜索词包含1个长尾词
- 每章标题对用户搜索意图有回应
### 市场价值
- 读完每章读者能拿走一个实际有用的东西方法/清单/思维框架/判断标准
- 避免信息增量为零的空洞章节
- 思考这篇文章对读者职业/生活/认知有什么用
### 平台推荐优化
- 知乎偏硬核数据分析和深度逻辑结构要有论证链
- 小红书偏实操步骤/清单/对比结构要一目了然
- 公众号偏故事化开头+情感共鸣+金句结尾段落节奏快
- 一个框架适应三平台各平台可裁剪侧重点
直接输出大纲不要输出思考过程"""
prompt = get_prompt("outline_generation",
date=_now.strftime('%Y年%m月%d'),
year=_now.year,
title=title,
field=field,
core=core,
pain=pain,
angle=angle,
cases_summary=cases_summary,
)
try:
outline = call_llm(prompt, temperature=0.6, system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
params = get_prompt_params("outline_generation")
outline = call_llm(prompt, temperature=params.get("temperature", 0.7), max_tokens=params.get("max_tokens", 4000), system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
return f"# 文章大纲:{title}\n\n{outline}\n\n---\n*大纲生成时间:{TODAY}*"
except Exception as e:
+136
View File
@@ -0,0 +1,136 @@
import os, sys
from pathlib import Path
from typing import Dict, Any, Optional
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
_PROMPT_DEFAULTS = {
"topics_trends": {
"content": "你是中文互联网趋势分析师。请列出今天({date})中文互联网上最值得创作的10个话题。\n\n要求:\n1. 覆盖领域:{domains}\n2. 从真实用户角度出发\n3. 每个话题需包含:\n - \"domain\": 领域\n - \"topic\": 话题名称\n - \"reason\": 为什么现在讨论这个(1句话,有具体事件/数据支撑)\n - \"hot_keywords\": 3-5个搜索词(含1-2个长尾词)\n - \"platform\": 最适合分发的平台(知乎/小红书/微信/多平台)\n - \"seo_angle\": 从什么角度切入能获得搜索流量(1句话)\n - \"engagement\": 高/中/低\n\n输出 JSON 数组。只输出 JSON,不要其他文字。",
"temperature": 0.7, "max_tokens": 3000,
"variables": ["date", "domains"],
},
"topic_generate": {
"content": "你是一个内容策略师。基于以下信息,为「{target_category}」类别生成一个高质量选题。\n\n{data_section}\n{existing_hint}\n{trend_context}\n\n输出一个选题,格式JSON\n{\n \"title\": \"标题(20字内,含核心关键词)\",\n \"core_concept\": \"核心观点(一句话)\",\n \"audience_pain\": \"受众痛点\",\n \"unique_angle\": \"差异化切入点\",\n \"format\": \"内容形式(趋势洞察/实操指南/对比分析/案例解读)\"\n}\n只输出JSON。",
"temperature": 0.6, "max_tokens": 2000,
"variables": ["target_category", "data_section", "existing_hint", "trend_context"],
},
"topic_selector_gaps": {
"content": "你是一个敏锐的内容策略师,擅长将热点转化为有价值、有传播力的选题。以下热点当前未覆盖,请为每个热点生成选题建议。\n\n{gaps}\n\n每个选题需包含:\n- \"title\": 标题(20字内,包含核心关键词,有吸引力)\n- \"field\": 所属领域\n- \"core_concept\": 核心观点(一句话说清独特价值)\n- \"audience_pain\": 受众痛点(真实用户的困惑/焦虑/需求)\n- \"unique_angle\": 独特视角(差异化切入点,含SEO关键词潜力)\n- \"target_platform\": 最适合发布平台(知乎/小红书/微信/多平台)\n- \"estimated_search_volume\": 预估搜索热度(高/中/低)\n\n只输出 JSON 数组,不要其他文字。",
"temperature": 0.6, "max_tokens": 2000,
"variables": ["gaps"],
},
"section_expansion": {
"content": "你是一个资深作者,正在写一篇关于「{topic_title}」的文章。请写「{section_title}」这一节。\n\n今天日期:{date}\n\n笔记要点:\n{content}\n\n【输出要求】\n输出3-6段纯粹、流畅的段落文字,每节内容根据平台需求控制在200-800字之间。\n\n格式:\n- 禁止任何标题/列表/格式标记(#、-、*、1.、**等)\n- 每段3-5句,段间空行分隔\n- 用「你」或「我们」视角,自然口语化\n\n内容要求(让文章在各平台能被推荐):\n- 开头直接切入痛点或反常识观点,抓住注意力\n- 每个观点配具体案例或数据(用「据统计」「调研显示」等),不要空泛说理\n- 有独特判断和立场,避免正确废话\n- 回答「所以呢」——读者看完能带走什么\n- 结尾有情绪感召力,让人想点赞/收藏/转发\n\n直接输出段落正文,不要任何附加说明。",
"temperature": 0.75, "max_tokens": 3000,
"variables": ["topic_title", "section_title", "date", "content"],
},
"title_optimize_zhihu": {
"content": "你是一个知乎内容专家。为以下文章起3个高点击率标题。\n\n标题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n要求:\n- 信息密度高,SEO关键词靠前\n- 偏好数字、对比、悬念、痛点类标题\n- 20字以内\n- 不要「如何...」开头\n- 有独特视角和差异化\n- 能引发讨论\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
"temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core", "pain", "field"],
},
"title_optimize_wechat": {
"content": "你是一个公众号资深作者。为以下文章起3个10万+潜力标题。\n\n标题:{title}\n核心观点:{core}\n\n要求:\n- 制造好奇心和话题感\n- 包含微信SEO关键词\n- 口语化,避免感叹号堆砌\n- 15-25字\n- 有情感共鸣或争议性\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
"temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core"],
},
"title_optimize_xhs": {
"content": "你是一个小红书爆款专家。为以下文章起3个热门标题。\n\n标题:{title}\n核心观点:{core}\n\n要求:\n- 20字以内\n- 爆款模式:数字+结果 / 痛点+方案 / 反常识\n- 包含小红书SEO关键词\n- 1个精确emoji\n- 有场景感、结果感、满足感\n- 不要「必看/收藏/码住」\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
"temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core"],
},
"research_summary": {
"content": "你是一个行业研究员+内容策略师,擅长从案例中发现真洞察+抢占热点的敏锐嗅觉,能判断什么内容对真实读者最有价值且正被市场热议。\n\n今天是{now}。当前年份:{year}年。\n\n基于以下选题和相关案例,写出能支撑文章核心观点、对读者真正有用的研究发现。\n\n## 选题\n标题:{title}\n领域:{field}\n核心观点:{core}\n受众痛点:{pain}\n独特视角:{angle}\n{search_section}\n## 相关案例({n}个)\n{cases_text}\n\n## 输出要求(按顺序):\n1. **国内外最新热点关联**:... **所有数据必须是{year-1}-{year}年最新数据,禁用一切过时数据**\n2. **核心发现**:2-3个真正有价值的洞察。每条需包含这个发现对读者意味着什么,以及支撑数据(附数据来源)\n3. **独特观点储备**:哪些角度别人没写过、可以讲出差异化?提供至少一个反向/冷门视角\n4. **SEO关键词建议**...\n5. **讨论点**:哪个观点最有争议或最可能引发讨论/转发/评论?\n6. **市场价值判断**...\n\n风格:说人话,直击要点,像资深编辑在给作者做 briefing。避免「首先其次最后」「综上所述」。直接输出内容,不要输出思考过程。",
"temperature": 0.7, "max_tokens": 4000,
"variables": ["now", "year", "title", "field", "core", "pain", "angle", "search_section", "n", "cases_text"],
},
"outline_generation": {
"content": "你是一个资深内容编辑,擅长设计读者爱看+搜索引擎友好+平台愿意推荐+有市场传播力的文章结构。\n\n今天是{date}。当前年份:{year}年。\n\n## 选题信息\n标题:{title}\n领域:{field}\n核心观点:{core}\n受众痛点:{pain}\n独特视角:{angle}\n\n## 研究笔记\n{cases_summary}\n\n## 大纲设计要求\n### 结构\n- 5-8章,每章2-4个要点\n- 结构要有递进:要么认知升级型,要么问题解决型\n- 把独特视角和受众痛点融入各章,不单独列\n- 每章标题自带信息量+好奇心,不要「引言」「总结」这类通用标题\n- 开头要有\"钩子\"(hook)抓住读者,结尾要有可转发/收藏的总结\n\n### 数据与热点\n- **全文必须使用{year-1}-{year}年最新数据**,禁用一切过时数据\n- 每个观点必须配最新的国内外热点事件/数据/政策来佐证\n- 体现当前行业正在讨论的核心议题,拒绝泛泛而谈\n\n### 独特风格\n- 有自己的判断和立场,不是搬运观点\n- 每章至少一个\"反常识\"\"很少有人提\"的洞察\n- 避免同质化表达、老生常谈\n\n### SEO\n- H2/H3自然包含用户搜索时会用的短语\n- 确保大纲覆盖2-3个高价值搜索词,包含1个长尾词\n- 每章标题对用户搜索意图有回应\n\n### 市场价值\n- 读完每章读者能拿走一个实际有用的东西(方法/清单/思维框架/判断标准)\n- 避免「信息增量为零」的空洞章节\n\n直接输出大纲,不要输出思考过程。",
"temperature": 0.7, "max_tokens": 4000,
"variables": ["date", "year", "title", "field", "core", "pain", "angle", "cases_summary"],
},
"compliance_fix": {
"content": "你是一个专业的内容合规优化助手。以下文章存在合规问题,请逐一修复并输出完整HTML。\n\n需修复的问题:\n{issues_desc}\n\n原文:\n{html}\n\n要求:\n- 只修复上述问题,不改变文章结构和核心内容\n- 保持<h2>, <h3>, <p>等标签结构不变\n- 修复后内容依然保持可读性和自然语感(不要因为合规变成生硬的表达)\n- 替换敏感词时选择意思相近的替代词,不删节重要信息",
"temperature": 0.3, "max_tokens": 8000,
"variables": ["issues_desc", "html"],
},
"compliance_polish": {
"content": "你是一个专业的内容润色助手。请润色以下文章,提升表达的自然感和可读性。\n\n原文:\n{html}\n\n要求:\n- 保持原文事实、数据、章节结构不变\n- 输出相同的HTML格式(保留<h2>, <h3>, <p>标签)\n- 提升表达的自然感,让它更像是人写的\n- 避免AI常见表达模式(「首先其次最后」「总的来说」「值得注意的是」等)\n- 短句化,读起来更流畅",
"temperature": 0.4, "max_tokens": 8000,
"variables": ["html"],
},
"sources_optimization": {
"content": "你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。\n\n当前配置的类别({n}个):\n<cat_names>\n\n当前配置的信息源({n2}个):\n<src_summary>\n\n请完成以下任务:\n1. 评估每个类别是否仍符合{year}年中国市场真实热点\n2. 评估每个信息源是否可能在中国正常访问\n3. 建议新增或删除的类别(最多2条)\n4. 建议新增的信息源搜索词(最多3条,包含具体搜索词)\n\n输出 JSON 格式:\n{\n \"category_assessment\": [{\"name\": \"...\", \"status\": \"保留/淘汰/合并\", \"reason\": \"...\"}],\n \"source_assessment\": [{\"name\": \"...\", \"status\": \"保留/淘汰/替换\", \"reason\": \"...\"}],\n \"suggested_new_categories\": [{\"name\": \"...\", \"search_query\": \"...\", \"reason\": \"...\"}],\n \"suggested_new_sources\": [{\"name\": \"...\", \"type\": \"web_search\", \"query\": \"...\", \"focus\": \"...\"}],\n \"summary\": \"一句话总结本次优化建议\"\n}\n\n只输出json,不要其他文字。",
"temperature": 0.5, "max_tokens": 3000,
"variables": ["n", "cat_names", "n2", "src_summary", "year"],
},
"tags_generation": {
"content": "为以下文章生成{platform}标签(3-5个)。\n\n标题:{title}\n领域:{field}\n核心观点:{core}\n\n要求:每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
"temperature": 0.3, "max_tokens": 500,
"variables": ["platform", "title", "field", "core"],
},
}
_DB_CACHE: Dict[str, Dict[str, Any]] = {}
_CACHE_LOADED = False
def _ensure_db_loaded():
global _DB_CACHE, _CACHE_LOADED
if _CACHE_LOADED:
return
try:
if os.getenv('USE_POSTGRES', 'true') == 'true':
from app.database import SessionLocal
from app.models import PromptConfig
db = SessionLocal()
try:
for p in db.query(PromptConfig).filter(PromptConfig.enabled == True).all():
_DB_CACHE[p.key] = {
"content": p.content,
"temperature": p.temperature,
"max_tokens": p.max_tokens,
}
finally:
db.close()
except Exception:
pass
_CACHE_LOADED = True
def get_prompt(key: str, **kwargs) -> str:
_ensure_db_loaded()
if key in _DB_CACHE:
content = _DB_CACHE[key]["content"]
elif key in _PROMPT_DEFAULTS:
content = _PROMPT_DEFAULTS[key]["content"]
else:
return ""
for k, v in kwargs.items():
content = content.replace("{" + k + "}", str(v))
return content
def get_prompt_params(key: str) -> Dict[str, Any]:
_ensure_db_loaded()
if key in _DB_CACHE:
return {
"temperature": _DB_CACHE[key].get("temperature"),
"max_tokens": _DB_CACHE[key].get("max_tokens"),
}
if key in _PROMPT_DEFAULTS:
return {
"temperature": _PROMPT_DEFAULTS[key].get("temperature"),
"max_tokens": _PROMPT_DEFAULTS[key].get("max_tokens"),
}
return {}
def reload_prompts():
global _CACHE_LOADED, _DB_CACHE
_CACHE_LOADED = False
_DB_CACHE = {}
_ensure_db_loaded()
+29 -2
View File
@@ -22,7 +22,7 @@ TODAY = __import__('datetime').datetime.now().strftime("%Y-%m-%d")
logger = logging.getLogger(__name__)
TREND_DOMAIN_MAP = {
DEFAULT_TREND_DOMAIN_MAP = {
"远程工作": "未来工作方式",
"AI工具": "AI与效率",
"可持续生活": "可持续生活系统",
@@ -37,11 +37,38 @@ TREND_DOMAIN_MAP = {
"家庭教育": "科技人文交叉",
}
_cached_trend_domain_map = None
def _load_trend_domain_map():
global _cached_trend_domain_map
if _cached_trend_domain_map is not None:
return _cached_trend_domain_map
try:
from app.core.prompt_loader import _get_session
from app.models import TrendFieldMapping
session = _get_session()
try:
rows = session.query(TrendFieldMapping).filter(TrendFieldMapping.is_active == True).order_by(TrendFieldMapping.sort_order).all()
if rows:
_cached_trend_domain_map = {r.trend_keyword: r.field_name for r in rows}
logger.info(f"从DB加载 {len(_cached_trend_domain_map)} 条 trend_domain_map")
return _cached_trend_domain_map
finally:
session.close()
except Exception as e:
logger.warning(f"从DB加载 trend_domain_map 失败: {e}")
_cached_trend_domain_map = DEFAULT_TREND_DOMAIN_MAP
return _cached_trend_domain_map
def get_trend_domain_map():
return _load_trend_domain_map()
def _topic_trend_score(topic: Dict, trend: Dict) -> float:
field = (topic.get("field") or "").lower()
title = (topic.get("title") or "").lower()
core = (topic.get("core_concept") or "").lower()
trend_domain = TREND_DOMAIN_MAP.get(trend.get("domain", ""), "")
trend_domain_map = get_trend_domain_map()
trend_domain = trend_domain_map.get(trend.get("domain", ""), "")
keywords = [trend.get("topic", "")] + trend.get("hot_keywords", [])
score = 0.0
if trend_domain and trend_domain in field:
+59 -21
View File
@@ -26,7 +26,34 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
DOMAINS = ["远程工作", "AI工具", "可持续生活", "知识管理", "数字生活", "科技人文"]
DEFAULT_DOMAINS = ["远程工作", "AI工具", "可持续生活", "知识管理", "数字生活", "科技人文"]
_cached_domains = None
def _load_domains():
global _cached_domains
if _cached_domains is not None:
return _cached_domains
try:
from app.core.prompt_loader import _get_session
from app.models import SystemConfig
session = _get_session()
try:
sc = session.query(SystemConfig).filter(SystemConfig.key == "trend_domains").first()
if sc and sc.value:
parsed = json.loads(sc.value)
if isinstance(parsed, list) and parsed:
_cached_domains = parsed
logger.info(f"从DB加载 {len(_cached_domains)} 个trend_domains")
return _cached_domains
finally:
session.close()
except Exception as e:
logger.warning(f"从DB加载 trend_domains 失败: {e}")
_cached_domains = DEFAULT_DOMAINS
return _cached_domains
def get_domains():
return _load_domains()
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
@@ -39,10 +66,35 @@ _KEYWORD_DOMAIN_MAP = [
(r"科技|人文|教育|心理|哲学|社会学", "科技人文"),
]
_cached_keyword_domain_map = None
def _load_keyword_domain_map():
global _cached_keyword_domain_map
if _cached_keyword_domain_map is not None:
return _cached_keyword_domain_map
try:
from app.core.prompt_loader import _get_session
from app.models import KeywordDomainMap
session = _get_session()
try:
rows = session.query(KeywordDomainMap).filter(KeywordDomainMap.is_active == True).order_by(KeywordDomainMap.sort_order).all()
if rows:
_cached_keyword_domain_map = [(r.pattern, r.domain) for r in rows]
logger.info(f"从DB加载 {len(rows)} 条 keyword_domain_map 规则")
return _cached_keyword_domain_map
finally:
session.close()
except Exception as e:
logger.warning(f"从DB加载 keyword_domain_map 失败: {e}")
_cached_keyword_domain_map = _KEYWORD_DOMAIN_MAP
return _cached_keyword_domain_map
def _guess_domain(topic: str, reason: str = "") -> str:
text = (topic + " " + reason).lower()
for pattern, domain in _KEYWORD_DOMAIN_MAP:
for pattern, domain in _load_keyword_domain_map():
if re.search(pattern, text, re.IGNORECASE):
return domain
return "科技人文"
@@ -186,26 +238,12 @@ def fetch_baidu_hot() -> List[Dict]:
def fetch_llm_trends() -> List[Dict]:
prompt = f"""你是中文互联网趋势分析师。请列出今天(2026年5月)中文互联网上最值得创作的10个话题。
要求
1. 覆盖领域{', '.join(DOMAINS)}
2. 从真实用户角度出发
3. 每个话题需包含
- "domain": 领域
- "topic": 话题名称
- "reason": 为什么现在讨论这个1句话有具体事件/数据支撑
- "hot_keywords": 3-5个搜索词含1-2个长尾词
- "platform": 最适合分发的平台知乎/小红书/微信/多平台
- "seo_angle": 从什么角度切入能获得搜索流量1句话
- "engagement": //
输出 JSON 数组
[{{"domain": "...", "topic": "...", "reason": "...", "hot_keywords": ["..."], "platform": "...", "seo_angle": "...", "engagement": "..."}}]
只输出 JSON不要其他文字"""
try:
resp = call_llm(prompt, temperature=0.4)
from prompt_loader import get_prompt, get_prompt_params
prompt = get_prompt("topics_trends", date=datetime.datetime.now().strftime("%Y年%m月%d"),
domains=", ".join(get_domains()))
params = get_prompt_params("topics_trends")
resp = call_llm(prompt, temperature=params.get("temperature", 0.4), max_tokens=params.get("max_tokens", 2000))
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
+43 -99
View File
@@ -12,6 +12,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from db_helper import get_topic_by_id, update_topic_status, save_article
from content_cleaner import strip_thinking, strip_ai_preface, clean_markdown_content, clean_html_content
from prompt_loader import get_prompt, get_prompt_params
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
@@ -116,21 +118,7 @@ class Writer:
@staticmethod
def _clean_markdown(text: str) -> str:
lines = text.split('\n')
cleaned = []
in_code_fence = False
for line in lines:
if line.strip().startswith('```'):
in_code_fence = not in_code_fence
continue
if in_code_fence:
continue
line = re.sub(r'^#{1,6}\s+', '', line)
line = re.sub(r'^[\-\*\+]\s+', '', line)
line = re.sub(r'^\d+[\.\)]\s+', '', line)
line = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', line)
cleaned.append(line)
return '\n'.join(cleaned).strip()
return clean_markdown_content(text)
@staticmethod
def _is_outline_noise(line: str) -> bool:
@@ -160,31 +148,15 @@ class Writer:
# 大纲要点格式(>40% 行以 -/*/** 开头)应始终由 LLM 展开为连贯段落
if HAVE_LLM and self._is_bullet_only(content):
logger.info(f"使用 LLM 扩写章节(要点→段落): {section['title']}")
prompt = f"""你是一个资深作者,正在写一篇关于「{self.topic['title']}」的文章。请写「{section['title']}」这一节。
今天日期{datetime.datetime.now().strftime('%Y年%m月%d')}
笔记要点
{content}
输出要求
输出3-6段纯粹流畅的段落文字每节内容根据平台需求控制在200-800字之间
格式
- 禁止任何标题/列表/格式标记#、-、*、1.、**等)
- 每段3-5段间空行分隔
- 我们视角自然口语化
内容要求让文章在各平台能被推荐
- 开头直接切入痛点或反常识观点抓住注意力
- 每个观点配具体案例或数据据统计调研显示不要空泛说理
- 有独特判断和立场避免正确废话
- 回答所以呢读者看完能带走什么
- 结尾有情绪感召力让人想点赞/收藏/转发
直接输出段落正文不要任何附加说明"""
prompt = get_prompt("section_expansion",
topic_title=self.topic['title'],
section_title=section['title'],
date=datetime.datetime.now().strftime('%Y年%m月%d'),
content=content,
)
try:
expanded = call_llm(prompt, temperature=0.6)
params = get_prompt_params("section_expansion")
expanded = call_llm(prompt, temperature=params.get("temperature", 0.75), max_tokens=params.get("max_tokens", 3000))
if expanded:
cleaned = self._clean_markdown(expanded.strip())
if cleaned:
@@ -284,15 +256,17 @@ class Writer:
core = self.topic.get('core_concept', '')
tag_prompts = {
"zhihu": f"为以下文章生成知乎标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
"wechat": f"为以下文章生成公众号标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
"xiaohongshu": f"为以下文章生成小红书标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
"zhihu": get_prompt("tags_generation", platform="知乎", title=title, field=field, core=core),
"wechat": get_prompt("tags_generation", platform="公众号", title=title, field=field, core=core),
"xiaohongshu": get_prompt("tags_generation", platform="小红书", title=title, field=field, core=core),
}
if HAVE_LLM:
prompt = tag_prompts.get(platform, f"根据文章信息生成适合{platform}的标签。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。")
prompt = tag_prompts.get(platform, get_prompt("tags_generation", platform=platform, title=title, field=field, core=core))
try:
tags_text = call_llm(prompt, temperature=0.2)
params = get_prompt_params("tags_generation")
tags_text = call_llm(prompt, temperature=params.get("temperature", 0.3), max_tokens=params.get("max_tokens", 500))
tags_text = strip_thinking(tags_text)
if tags_text:
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
if tags:
@@ -331,64 +305,33 @@ class Writer:
if not HAVE_LLM:
return original
title_templates = {
"zhihu": f"""你是一个知乎用户,在给自己的深度回答起高点击率标题。
if platform == "zhihu":
prompt = get_prompt("title_optimize_zhihu",
title=original,
core=self.topic.get('core_concept', ''),
pain=self.topic.get('audience_pain', ''),
field=self.topic.get('field', ''),
)
elif platform == "wechat":
prompt = get_prompt("title_optimize_wechat",
title=original,
core=self.topic.get('core_concept', ''),
)
elif platform == "xiaohongshu":
prompt = get_prompt("title_optimize_xhs",
title=original,
core=self.topic.get('core_concept', ''),
)
else:
prompt = f"给以下文章改个吸引人的{platform}标题:{original}"
原文标题{original}
领域{self.topic.get('field', '')}
要求
- 有信息量一看就知道能解决什么问题
- 含知乎搜索关键词SEO利用知乎搜索联想热词
- 带数字或对比最好3个方法
- 20字以内
- 参考知乎真实高赞标题风格不要套路句式
- 避免如何废句式XXX指南/手册/全攻略
- 有观点有态度不是中性描述
- 直击目标读者痛点或好奇心
- 直接输出3个标题选项每行一个不要输出思考过程
生成 3 个选项每行一个""",
"wechat": f"""你是一个公众号作者,在给可能10万+的文章起标题。
原文标题{original}
领域{self.topic.get('field', '')}
要求
- 制造好奇心和点击欲让人觉得不点开会错过
- 包含微信搜索关键词微信SEO利用搜一搜热门词
- 口语化不要书面腔
- 不要感叹号堆砌不要重磅/震惊/紧急
- 字数15-25字最佳
- 有情绪感召力共鸣/好奇/焦虑/期待
- 参考近期10万+标题的语气节奏
- 直接输出3个标题选项每行一个不要输出思考过程
生成 3 个选项每行一个""",
"xiaohongshu": f"""你是一个小红书用户,在给笔记起能上热门推荐的标题。
原文标题{original}
领域{self.topic.get('field', '')}
要求
- 20字以内
- 采用爆款模式数字+结果/痛点+方案/反常识观点/对比式
- 包含小红书搜索关键词SEO利用搜索下拉热词
- 带1个精准emoji点缀不要三个起堆
- 有场景感/结果感/获得感
- 不要必看/收藏/码住
- 像真实用户写的不是运营写的
- 参考小红书搜索热榜标题风格
- 直接输出3个标题选项每行一个不要输出思考过程
生成 3 个选项每行一个""",
}
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
try:
if platform in ("zhihu", "wechat", "xiaohongshu"):
params = get_prompt_params(f"title_optimize_{platform}")
resp = call_llm(prompt, temperature=params.get("temperature", 0.8), max_tokens=params.get("max_tokens", 1500))
else:
resp = call_llm(prompt, temperature=0.7)
resp = strip_thinking(resp)
titles = []
for line in resp.strip().split('\n'):
line = line.strip()
@@ -414,6 +357,7 @@ class Writer:
else:
template = "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>{{TITLE}}</title><meta name='viewport' content='width=device-width'><style>body{max-width:800px;margin:0 auto;padding:20px;font-family:-apple-system,sans-serif;line-height:1.8}</style></head><body><h1>{{TITLE}}</h1><!-- CONTENT --></body></html>"
adapted = strip_ai_preface(adapted)
html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME)
html_content = _md_parser(adapted)
+3 -1
View File
@@ -46,4 +46,6 @@ echo "========================================"
echo ""
cd backend
exec $PYTHON_CMD -m uvicorn app.main:app --host 0.0.0.0 --port $PORT --workers 2
LOG_FILE="$PLATFORM_DIR/backend/logs/uvicorn.log"
mkdir -p "$PLATFORM_DIR/backend/logs"
exec $PYTHON_CMD -m uvicorn app.main:app --host 0.0.0.0 --port $PORT --workers 2 >> "$LOG_FILE" 2>&1