配置全面迁移数据库: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:
@@ -4,7 +4,8 @@
|
|||||||
- **Backend**: FastAPI 0.104 + SQLAlchemy 2.0 + PostgreSQL 15 (`yzr_nr`)
|
- **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
|
- **Frontend**: Vue 3 (CDN, no build step) + Element Plus — static HTML served by FastAPI
|
||||||
- **Auth**: JWT (`python-jose` + bcrypt), default admin `admin/admin123`
|
- **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.
|
- **LLM**: Multi-provider (opencode-go primary, nvidia backup). API keys only in `.env`, not DB.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|||||||
+11
-3
@@ -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 (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 |
|
| 前端页面 (仪表盘/选题/日历/数据/素材/任务/平台/系统管理/用户/日志/文章) | ✅ 完成 | Vue 3 + Element Plus SPA |
|
||||||
| 数据库 (PostgreSQL 15) | ✅ 运行中 | `yzr_nr` 库 |
|
| 数据库 (PostgreSQL 15) | ✅ 运行中 | `yzr_nr` 库 |
|
||||||
| 服务 | ✅ 运行中 | 端口 8001 |
|
| 服务 | ✅ 运行中 | 端口 8001 |
|
||||||
@@ -137,14 +137,22 @@
|
|||||||
| metrics 冗余统计移除 | 2026-05-18 | 删除与仪表盘重复的 4 个概览卡片 |
|
| metrics 冗余统计移除 | 2026-05-18 | 删除与仪表盘重复的 4 个概览卡片 |
|
||||||
| writer max_tokens 提升 | 2026-05-19 | 标题/标签 max_tokens 500→1000,修复推理模型思考链占用导致输出截断 |
|
| writer max_tokens 提升 | 2026-05-19 | 标题/标签 max_tokens 500→1000,修复推理模型思考链占用导致输出截断 |
|
||||||
| 预览滚动条修复 | 2026-05-19 | 改用 height:68vh 替代 flex+calc,避免 Element Plus 对话框内滚动冲突 |
|
| 预览滚动条修复 | 2026-05-19 | 改用 height:68vh 替代 flex+calc,避免 Element Plus 对话框内滚动冲突 |
|
||||||
|
| 定时任务 DB 化 | 2026-05-22 | TaskLog 新增 module_id/error_trace/triggered_by/result_data;TaskConfig 模型新建含 params/schedule/enabled;scheduler.py 全 7 个任务执行前后写 TaskRun;modules/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 任务管理 tab(TaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI |
|
||||||
| M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 |
|
| M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 |
|
||||||
|
| 现有文章重新创作(清除 AI 思考内容) | 中 | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py |
|
||||||
| 数据追踪接入 (阅读量/互动) | 中 | 需要对接平台 API |
|
| 数据追踪接入 (阅读量/互动) | 中 | 需要对接平台 API |
|
||||||
| 选题库数据库同步 | 低 | 将选题从 markdown 同步至 PG |
|
|
||||||
| 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 |
|
| 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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",
|
"id": "SUS-0A6B894E",
|
||||||
"country": "US",
|
"country": "US",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -9,7 +9,7 @@ from typing import Dict, Any, List, Optional
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from ..database import get_db
|
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.generator import run_creator, get_generator_status
|
||||||
from ..core.optimizer import run_optimizer, get_optimizer_status
|
from ..core.optimizer import run_optimizer, get_optimizer_status
|
||||||
from ..core.collector import run_collector, get_collector_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)])
|
@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}")
|
logger.info(f"Generation triggered by {current_user.username}, topic_id={topic_id}")
|
||||||
try:
|
try:
|
||||||
result = run_creator(topic_id)
|
result = run_creator(topic_id)
|
||||||
@@ -93,7 +93,7 @@ def collection_status():
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
@router.post("/review/run", dependencies=[Depends(get_current_user)])
|
@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:
|
try:
|
||||||
result = run_optimizer(topic_ids)
|
result = run_optimizer(topic_ids)
|
||||||
return {"message": "合规审查已后台启动", "pid": result.get("pid")}
|
return {"message": "合规审查已后台启动", "pid": result.get("pid")}
|
||||||
@@ -247,47 +247,61 @@ def get_scheduler_status():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/modules/status", dependencies=[Depends(get_current_user)])
|
@router.get("/modules/status", dependencies=[Depends(get_current_user)])
|
||||||
def get_modules_status():
|
def get_modules_status(db: Session = Depends(get_db)):
|
||||||
today_str = date.today().isoformat()
|
configs = db.query(TaskConfig).all()
|
||||||
log_based: dict = {
|
config_map = {c.module_id: c for c in configs}
|
||||||
"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"},
|
MODULE_META = {
|
||||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "log": LOGS_DIR / f"trends_{today_str}.log"},
|
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "cron": "01:00", "params_desc": {"refresh_queries": "搜索关键词列表"}},
|
||||||
"scheduled_generate": {"name": "🤖 内容创作", "log": LOGS_DIR / f"creator_{today_str}.log"},
|
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10", "params_desc": {}},
|
||||||
"scheduled_optimize": {"name": "🔍 合规审查", "log": LOGS_DIR / f"optimizer_{today_str}.log"},
|
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30", "params_desc": {"max_topics": "最大选题数", "categories": "采集类别"}},
|
||||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "log": LOGS_DIR / f"optimizer_sources_{today_str}.log"},
|
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00", "params_desc": {"auto_review": "自动合规审查"}},
|
||||||
"scheduled_metrics_sync": {"name": "📊 指标同步", "log": LOGS_DIR / f"metrics_sync_{today_str}.log"},
|
"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 = []
|
modules = []
|
||||||
for mod_id, cfg in log_based.items():
|
for mod_id, meta in MODULE_META.items():
|
||||||
log_file = cfg["log"]
|
cfg = config_map.get(mod_id)
|
||||||
last_run = None
|
latest = db.query(TaskLog).filter(TaskLog.module_id == mod_id).order_by(TaskLog.started_at.desc()).first()
|
||||||
task_count = 0
|
next_run = _get_next_run(mod_id)
|
||||||
success_rate = None
|
|
||||||
if log_file.exists():
|
total = db.query(TaskLog).filter(TaskLog.module_id == mod_id).count()
|
||||||
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
success = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "success").count()
|
||||||
last_run = mtime.strftime("%Y-%m-%d %H:%M")
|
failed = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "failed").count()
|
||||||
content = log_file.read_text(encoding="utf-8", errors="ignore")
|
running = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "running").count()
|
||||||
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"]
|
|
||||||
modules.append({
|
modules.append({
|
||||||
"id": mod_id,
|
"module_id": mod_id,
|
||||||
"title": cfg["name"],
|
"title": meta["name"],
|
||||||
"status": status,
|
"enabled": cfg.enabled if cfg else True,
|
||||||
"last_run": last_run or "从未运行",
|
"params": cfg.params if cfg else {},
|
||||||
"next_run": next_run or "待计划",
|
"params_desc": meta["params_desc"],
|
||||||
"task_count": task_count,
|
"schedule": cfg.schedule if cfg else meta["cron"],
|
||||||
"success_rate": success_rate if success_rate is not None else 0,
|
"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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,55 +1,82 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import or_
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import TaskLog
|
from ..models import TaskLog
|
||||||
from ..schemas import TaskLogBase, TaskLogResponse
|
from ..schemas import TaskLogBase, TaskLogResponse
|
||||||
from .auth import get_current_admin
|
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])
|
@router.get("", response_model=List[TaskLogResponse])
|
||||||
def list_task_logs(
|
def list_task_logs(
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin_user=Depends(get_current_admin),
|
admin_user=Depends(get_current_admin),
|
||||||
topic_id: Optional[str] = None,
|
module_id: Optional[str] = None,
|
||||||
task_name: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
status: Optional[str] = None
|
date: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
):
|
):
|
||||||
"""获取任务日志列表(可过滤)"""
|
|
||||||
query = db.query(TaskLog)
|
query = db.query(TaskLog)
|
||||||
if topic_id:
|
if module_id:
|
||||||
query = query.filter(TaskLog.topic_id == topic_id)
|
query = query.filter(TaskLog.module_id == module_id)
|
||||||
if task_name:
|
|
||||||
query = query.filter(TaskLog.task_name == task_name)
|
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(TaskLog.status == 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]
|
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)
|
@router.get("/{log_id}", response_model=TaskLogResponse)
|
||||||
def get_task_log(
|
def get_task_log(log_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||||
log_id: int,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin_user = Depends(get_current_admin)
|
|
||||||
):
|
|
||||||
"""获取单个任务日志详情"""
|
|
||||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(status_code=404, detail="日志不存在")
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
return log
|
return log
|
||||||
|
|
||||||
@router.post("", response_model=TaskLogResponse)
|
@router.post("", response_model=TaskLogResponse)
|
||||||
def create_task_log(
|
def create_task_log(log_data: TaskLogBase, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||||
log_data: TaskLogBase,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin_user = Depends(get_current_admin)
|
|
||||||
):
|
|
||||||
"""创建任务日志(用于手动记录)"""
|
|
||||||
log = TaskLog(**log_data.model_dump())
|
log = TaskLog(**log_data.model_dump())
|
||||||
db.add(log)
|
db.add(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -57,35 +84,22 @@ def create_task_log(
|
|||||||
return log
|
return log
|
||||||
|
|
||||||
@router.put("/{log_id}", response_model=TaskLogResponse)
|
@router.put("/{log_id}", response_model=TaskLogResponse)
|
||||||
def update_task_log(
|
def update_task_log(log_id: int, log_update: TaskLogBase, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||||
log_id: int,
|
|
||||||
log_update: TaskLogBase,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin_user = Depends(get_current_admin)
|
|
||||||
):
|
|
||||||
"""更新任务日志"""
|
|
||||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(status_code=404, detail="日志不存在")
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
update_data = log_update.model_dump(exclude_unset=True)
|
data = log_update.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in data.items():
|
||||||
setattr(log, field, value)
|
setattr(log, field, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(log)
|
db.refresh(log)
|
||||||
return log
|
return log
|
||||||
|
|
||||||
@router.delete("/{log_id}")
|
@router.delete("/{log_id}")
|
||||||
def delete_task_log(
|
def delete_task_log(log_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||||
log_id: int,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin_user = Depends(get_current_admin)
|
|
||||||
):
|
|
||||||
"""删除任务日志"""
|
|
||||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(status_code=404, detail="日志不存在")
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
db.delete(log)
|
db.delete(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "删除成功"}
|
return {"message": "删除成功"}
|
||||||
@@ -213,6 +213,25 @@ def get_module_detail(module_id: str, db: Session = Depends(get_db), current_use
|
|||||||
LOGS_DIR = ROOT / "automation" / "logs"
|
LOGS_DIR = ROOT / "automation" / "logs"
|
||||||
today_str = date_mod.today().isoformat()
|
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 = {
|
MODULE_META = {
|
||||||
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "description": "通过 opencode webfetch 联网搜索,刷新 8 个分类的搜索缓存,供内容采集器使用"},
|
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "description": "通过 opencode webfetch 联网搜索,刷新 8 个分类的搜索缓存,供内容采集器使用"},
|
||||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "description": "从百度、微博、知乎实时热搜 API 抓取当天热点,LLM 补充,存入 trends.json"},
|
"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
|
pass
|
||||||
|
|
||||||
elif module_id == "scheduled_collect":
|
elif module_id == "scheduled_collect":
|
||||||
from ..models import Topic, CollectorCategory
|
from ..models import CollectorCategory
|
||||||
pending = db.query(Topic).filter(Topic.status.in_(["pending", "待处理"])).count()
|
pending = db.query(Topic).filter(Topic.status.in_(["pending", "待处理"])).count()
|
||||||
total_topics = db.query(Topic).count()
|
total_topics = db.query(Topic).count()
|
||||||
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
|
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()
|
pending_t = db.query(Topic).filter(Topic.status.in_(["pending", "待处理"])).count()
|
||||||
inputs["待创作选题"] = pending_t
|
inputs["待创作选题"] = pending_t
|
||||||
outputs["待审查"] = review
|
outputs["待审查"] = review
|
||||||
|
try:
|
||||||
from ..models import Article
|
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()
|
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["最新文章"] = []
|
outputs["最新文章"] = []
|
||||||
seen_articles = set()
|
seen_articles = set()
|
||||||
for a in recent_articles:
|
for a in recent_articles:
|
||||||
art = a.Article if hasattr(a, 'Article') else a[0]
|
art = a[0]
|
||||||
tid = a.topic_title if hasattr(a, 'topic_title') else (a[1] if len(a) > 1 else "")
|
tid = a[1] if len(a) > 1 else ""
|
||||||
if art.id not in seen_articles:
|
if art.id not in seen_articles:
|
||||||
seen_articles.add(art.id)
|
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 ""})
|
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":
|
elif module_id == "scheduled_optimize":
|
||||||
outputs["待审查选题"] = db.query(Topic).filter(Topic.status.in_(["review", "待审查"])).count()
|
outputs["待审查选题"] = db.query(Topic).filter(Topic.status.in_(["review", "待审查"])).count()
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -2,11 +2,15 @@
|
|||||||
定时任务调度器
|
定时任务调度器
|
||||||
基于 APScheduler,支持在 FastAPI 生命周期内运行定时任务
|
基于 APScheduler,支持在 FastAPI 生命周期内运行定时任务
|
||||||
"""
|
"""
|
||||||
import os
|
import os, sys, logging, json
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
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.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from .generator import run_creator_blocking
|
from .generator import run_creator_blocking
|
||||||
@@ -15,6 +19,69 @@ from .collector import run_collector_blocking
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class TaskScheduler:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.scheduler = BackgroundScheduler()
|
self.scheduler = BackgroundScheduler()
|
||||||
@@ -24,64 +91,47 @@ class TaskScheduler:
|
|||||||
if self._started:
|
if self._started:
|
||||||
logger.warning("Scheduler already started")
|
logger.warning("Scheduler already started")
|
||||||
return
|
return
|
||||||
# 使用 CronTrigger 设置每日固定时间点
|
from ..database import SessionLocal
|
||||||
# 顺序: 搜索缓存(01:00)→趋势(01:10)→采集(01:30)→创作(02:00)→审查(03:00)→源优化(05:00)→指标(06:00)
|
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.scheduler.add_job(
|
||||||
self._run_refresh_search_cache,
|
fn,
|
||||||
CronTrigger(hour=1, minute=0),
|
CronTrigger(hour=hour, minute=minute),
|
||||||
id='scheduled_refresh_search_cache',
|
id=module_id,
|
||||||
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',
|
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True
|
coalesce=True
|
||||||
)
|
)
|
||||||
|
logger.info(f"调度任务: {module_id} -> {schedule}")
|
||||||
|
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
self._started = True
|
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):
|
def shutdown(self):
|
||||||
if self.scheduler.running:
|
if self.scheduler.running:
|
||||||
self.scheduler.shutdown()
|
self.scheduler.shutdown()
|
||||||
@@ -89,6 +139,8 @@ class TaskScheduler:
|
|||||||
|
|
||||||
def _run_fetch_trends(self):
|
def _run_fetch_trends(self):
|
||||||
"""定时刷新热点趋势(百度/微博/知乎实时热搜 + LLM补充)"""
|
"""定时刷新热点趋势(百度/微博/知乎实时热搜 + LLM补充)"""
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
_log_task("scheduled_fetch_trends", "running", started_at=started)
|
||||||
try:
|
try:
|
||||||
logger.info("[Scheduled] Fetching hot trends...")
|
logger.info("[Scheduled] Fetching hot trends...")
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -100,14 +152,26 @@ class TaskScheduler:
|
|||||||
for line in result.stdout.strip().split("\n"):
|
for line in result.stdout.strip().split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
logger.info("[Trends] %s", 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:
|
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:
|
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)
|
logger.exception("[Scheduled] Trends refresh error: %s", e)
|
||||||
|
|
||||||
def _run_refresh_search_cache(self):
|
def _run_refresh_search_cache(self):
|
||||||
"""定时刷新搜索缓存(通过 opencode webfetch)"""
|
"""定时刷新搜索缓存(通过 opencode webfetch)"""
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
_log_task("scheduled_refresh_search_cache", "running", started_at=started)
|
||||||
try:
|
try:
|
||||||
logger.info("[Scheduled] Refreshing search cache via opencode...")
|
logger.info("[Scheduled] Refreshing search cache via opencode...")
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -122,48 +186,95 @@ class TaskScheduler:
|
|||||||
if line.strip():
|
if line.strip():
|
||||||
logger.warning("[SearchCache] %s", line.strip())
|
logger.warning("[SearchCache] %s", line.strip())
|
||||||
if result.returncode == 0:
|
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")
|
logger.info("[Scheduled] Search cache refreshed")
|
||||||
else:
|
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")
|
logger.warning("[Scheduled] Search cache refresh may have partial failures")
|
||||||
except subprocess.TimeoutExpired:
|
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")
|
logger.warning("[Scheduled] Search cache refresh timed out")
|
||||||
except Exception as e:
|
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)
|
logger.exception("[Scheduled] Search cache refresh error: %s", e)
|
||||||
|
|
||||||
def _run_generate(self):
|
def _run_generate(self):
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
_log_task("scheduled_generate", "running", started_at=started)
|
||||||
try:
|
try:
|
||||||
logger.info("[Scheduled] Starting content generation...")
|
logger.info("[Scheduled] Starting content generation...")
|
||||||
result = run_creator_blocking()
|
result = run_creator_blocking()
|
||||||
logger.info("[Scheduled] Generation completed: %s", result)
|
logger.info("[Scheduled] Generation completed: %s", result)
|
||||||
created_id = result.get("topic_id") if isinstance(result, dict) else None
|
created_id = result.get("topic_id") if isinstance(result, dict) else None
|
||||||
|
review_result = None
|
||||||
if created_id:
|
if created_id:
|
||||||
logger.info("[Scheduled] Running compliance review on %s...", created_id)
|
logger.info("[Scheduled] Running compliance review on %s...", created_id)
|
||||||
review_result = run_optimizer_blocking([created_id])
|
review_result = run_optimizer_blocking([created_id])
|
||||||
if review_result.get("ok"):
|
if review_result.get("ok"):
|
||||||
logger.info("[Scheduled] Review completed for %s", created_id)
|
logger.info("[Scheduled] Review completed for %s", created_id)
|
||||||
else:
|
_log_task("scheduled_generate", "success",
|
||||||
logger.warning("[Scheduled] Review failed: %s", review_result.get("error"))
|
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:
|
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)
|
logger.exception("[Scheduled] Generation pipeline failed: %s", e)
|
||||||
|
|
||||||
def _run_optimize(self):
|
def _run_optimize(self):
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
_log_task("scheduled_optimize", "running", started_at=started)
|
||||||
try:
|
try:
|
||||||
logger.info("[Scheduled] Starting compliance review...")
|
logger.info("[Scheduled] Starting compliance review...")
|
||||||
result = run_optimizer_blocking()
|
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)
|
logger.info("[Scheduled] Review completed: %s", result)
|
||||||
except Exception as e:
|
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)
|
logger.exception("[Scheduled] Review failed: %s", e)
|
||||||
|
|
||||||
def _run_collect(self):
|
def _run_collect(self):
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
_log_task("scheduled_collect", "running", started_at=started)
|
||||||
try:
|
try:
|
||||||
logger.info("[Scheduled] Starting topic collection...")
|
logger.info("[Scheduled] Starting topic collection...")
|
||||||
result = run_collector_blocking()
|
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:])
|
logger.info("[Scheduled] Collection completed: %s", result.get("output", "")[-200:])
|
||||||
except Exception as e:
|
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)
|
logger.exception("[Scheduled] Collection failed: %s", e)
|
||||||
|
|
||||||
def _run_optimize_sources(self):
|
def _run_optimize_sources(self):
|
||||||
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
|
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
_log_task("scheduled_optimize_sources", "running", started_at=started)
|
||||||
try:
|
try:
|
||||||
logger.info("[Scheduled] Starting source optimization with AI...")
|
logger.info("[Scheduled] Starting source optimization with AI...")
|
||||||
from .nvidia_client import call_llm
|
from .nvidia_client import call_llm
|
||||||
@@ -183,32 +294,16 @@ class TaskScheduler:
|
|||||||
cat_names = [c.name for c in cats]
|
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)
|
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)}个):
|
params = get_prompt_params("sources_optimization")
|
||||||
{chr(10).join(f'- {n}' for n in cat_names)}
|
resp = call_llm(prompt, temperature=params.get("temperature", 0.5), max_tokens=params.get("max_tokens", 3000))
|
||||||
|
|
||||||
当前配置的信息源({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)
|
|
||||||
if resp.startswith("```"):
|
if resp.startswith("```"):
|
||||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||||
result = json.loads(resp)
|
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.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
|
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()
|
db.close()
|
||||||
except Exception as e:
|
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)
|
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
|
||||||
|
|
||||||
def _run_metrics_sync(self):
|
def _run_metrics_sync(self):
|
||||||
"""定时从各平台公开API获取发布文章的效果数据(当前仅支持知乎)"""
|
"""定时从各平台公开API获取发布文章的效果数据(当前仅支持知乎)"""
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
_log_task("scheduled_metrics_sync", "running", started_at=started)
|
||||||
try:
|
try:
|
||||||
logger.info("[Scheduled] Starting metrics sync (zhihu auto-fetch)...")
|
logger.info("[Scheduled] Starting metrics sync (zhihu auto-fetch)...")
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
@@ -286,6 +394,10 @@ class TaskScheduler:
|
|||||||
if count:
|
if count:
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
|
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 读取
|
# 生成指标反馈:按 field 聚合表现,写入 metrics_feedback.json 供 collector 读取
|
||||||
try:
|
try:
|
||||||
import json as json_mod
|
import json as json_mod
|
||||||
@@ -317,9 +429,16 @@ class TaskScheduler:
|
|||||||
except Exception as e_fb:
|
except Exception as e_fb:
|
||||||
logger.warning("[Scheduled] Metrics feedback generation failed: %s", e_fb)
|
logger.warning("[Scheduled] Metrics feedback generation failed: %s", e_fb)
|
||||||
else:
|
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")
|
logger.info("[Scheduled] Metrics sync: no zhihu articles to sync")
|
||||||
db.close()
|
db.close()
|
||||||
except Exception as e:
|
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)
|
logger.exception("[Scheduled] Metrics sync failed: %s", e)
|
||||||
|
|
||||||
def get_jobs(self):
|
def get_jobs(self):
|
||||||
|
|||||||
@@ -61,6 +61,49 @@ def init_db():
|
|||||||
("platform_configs", "min_words", "INTEGER DEFAULT 0"),
|
("platform_configs", "min_words", "INTEGER DEFAULT 0"),
|
||||||
("platform_configs", "max_words", "INTEGER DEFAULT 0"),
|
("platform_configs", "max_words", "INTEGER DEFAULT 0"),
|
||||||
("platform_configs", "website_url", "VARCHAR"),
|
("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:
|
try:
|
||||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .database import engine, get_db, init_db
|
from .database import engine, get_db, init_db
|
||||||
from .models import Base
|
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 .initial_data import import_initial_data
|
||||||
from .core.scheduler import scheduler
|
from .core.scheduler import scheduler
|
||||||
|
|
||||||
@@ -83,6 +83,8 @@ app.include_router(admin.router)
|
|||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(optimizer_logs.router)
|
app.include_router(optimizer_logs.router)
|
||||||
app.include_router(cases.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(task_logs.router)
|
||||||
app.include_router(llm_configs.router)
|
app.include_router(llm_configs.router)
|
||||||
app.include_router(system_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(platform_config.router)
|
||||||
app.include_router(collector_mgmt.router)
|
app.include_router(collector_mgmt.router)
|
||||||
app.include_router(assistant.router)
|
app.include_router(assistant.router)
|
||||||
|
app.include_router(config_items.router)
|
||||||
|
|
||||||
# 挂载自动生成的图片(必须先于前端根挂载)
|
# 挂载自动生成的图片(必须先于前端根挂载)
|
||||||
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
|
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
|
||||||
|
|||||||
@@ -471,24 +471,59 @@ class TaskLog(Base):
|
|||||||
__tablename__ = "task_logs"
|
__tablename__ = "task_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
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)
|
task_name = Column(String, nullable=False)
|
||||||
topic_id = Column(String, nullable=True)
|
topic_id = Column(String, nullable=True, index=True)
|
||||||
status = Column(String, nullable=False)
|
status = Column(String, nullable=False) # pending / running / success / failed / cancelled
|
||||||
message = Column(Text, nullable=True)
|
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())
|
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
|
"module_id": self.module_id,
|
||||||
"task_name": self.task_name,
|
"task_name": self.task_name,
|
||||||
"topic_id": self.topic_id,
|
"topic_id": self.topic_id,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"message": self.message,
|
"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,
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||||
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
||||||
"duration": self.duration,
|
"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):
|
class SystemConfig(Base):
|
||||||
__tablename__ = "system_configs"
|
__tablename__ = "system_configs"
|
||||||
|
|
||||||
@@ -557,6 +687,7 @@ class CollectorCategory(Base):
|
|||||||
name = Column(String, unique=True, nullable=False)
|
name = Column(String, unique=True, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
search_query = Column(String, nullable=True)
|
search_query = Column(String, nullable=True)
|
||||||
|
pain_template = Column(Text, nullable=True)
|
||||||
sort_order = Column(Integer, default=0)
|
sort_order = Column(Integer, default=0)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -570,6 +701,7 @@ class CollectorCategory(Base):
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"search_query": self.search_query,
|
"search_query": self.search_query,
|
||||||
|
"pain_template": self.pain_template,
|
||||||
"sort_order": self.sort_order,
|
"sort_order": self.sort_order,
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"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):
|
class CollectorSource(Base):
|
||||||
"""采集信息源(可在运营管理中动态编辑)"""
|
"""采集信息源(可在运营管理中动态编辑)"""
|
||||||
__tablename__ = "collector_sources"
|
__tablename__ = "collector_sources"
|
||||||
|
|||||||
@@ -458,13 +458,18 @@ class CaseResponse(CaseBase):
|
|||||||
|
|
||||||
|
|
||||||
class TaskLogBase(BaseModel):
|
class TaskLogBase(BaseModel):
|
||||||
|
module_id: str
|
||||||
task_name: str
|
task_name: str
|
||||||
topic_id: Optional[str] = None
|
topic_id: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
|
error_trace: Optional[str] = None
|
||||||
|
triggered_by: str = "scheduler"
|
||||||
|
result_data: Dict[str, Any] = {}
|
||||||
started_at: Optional[datetime] = None
|
started_at: Optional[datetime] = None
|
||||||
finished_at: Optional[datetime] = None
|
finished_at: Optional[datetime] = None
|
||||||
duration: Optional[int] = None
|
duration: Optional[int] = None
|
||||||
|
next_run_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskLogResponse(TaskLogBase):
|
class TaskLogResponse(TaskLogBase):
|
||||||
@@ -473,6 +478,29 @@ class TaskLogResponse(TaskLogBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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):
|
class LLMConfigBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
system_prompt: Optional[str] = None
|
system_prompt: Optional[str] = None
|
||||||
|
|||||||
+133
-446
@@ -22,65 +22,70 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
|
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
|
||||||
<div class="filter-bar">
|
<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 === '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 === '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 === '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 === '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 === '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 === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
||||||
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'cases'">
|
<div v-if="activeTab === 'users'">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" size="small" @click="showCaseDialog()">新增案例</el-button>
|
<el-button type="primary" size="small" @click="addUser">新增用户</el-button>
|
||||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ cases.length }} 个</span>
|
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ users.length }} 个</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="casesLoading" class="card-loading">加载中...</div>
|
<div v-if="usersLoading" class="card-loading">加载中...</div>
|
||||||
<template v-else-if="cases.length === 0">
|
<template v-else-if="users.length === 0">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
|
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUser /></el-icon>
|
||||||
<div class="empty-text">暂无案例数据</div>
|
<div class="empty-text">暂无用户数据</div>
|
||||||
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="stat-summary">
|
<el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%">
|
||||||
<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-column prop="id" label="ID" width="70"></el-table-column>
|
<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="username" label="用户名" min-width="120"></el-table-column>
|
||||||
<el-table-column prop="field" label="领域" width="140"></el-table-column>
|
<el-table-column prop="role" label="角色" width="80"></el-table-column>
|
||||||
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
<el-table-column prop="source" label="来源" width="140"></el-table-column>
|
|
||||||
<el-table-column label="操作" width="140" fixed="right">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
|
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)">删除</el-button>
|
||||||
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div v-if="cases.length > casePageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
<div v-if="users.length > userPageSize" 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>
|
<el-pagination background layout="prev, pager, next" :total="users.length" :page-size="userPageSize" :current-page="userPage" @current-change="userPage = $event"></el-pagination>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="card-list-mobile">
|
<div class="card-list-mobile">
|
||||||
<div v-for="item in paginatedCases" :key="item.id" class="card-item">
|
<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.title }}</span></div>
|
<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.field }}</span></div>
|
<div class="card-row"><span class="card-label">角色</span><span class="card-value">{{ item.role }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
|
<el-button size="small" type="danger" @click="deleteUser(item.id)">删除</el-button>
|
||||||
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-if="activeTab === 'tasklogs'">
|
<div v-if="activeTab === 'tasklogs'">
|
||||||
@@ -173,43 +178,87 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'platformconfigs'">
|
<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">
|
<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>
|
||||||
<div v-if="platformConfigsLoading" class="card-loading">加载中...</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">
|
<div class="empty-state">
|
||||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
|
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
|
||||||
<div class="empty-text">暂无平台配置</div>
|
<div class="empty-text">暂无平台配置</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<el-table v-else :data="platformConfigs" border stripe style="width:100%">
|
||||||
<el-table :data="platformConfigs" border stripe class="data-table" style="width:100%">
|
<el-table-column prop="platform" label="标识" width="120"></el-table-column>
|
||||||
<el-table-column prop="platform" label="标识" width="80"></el-table-column>
|
<el-table-column prop="name" label="名称" width="120"></el-table-column>
|
||||||
<el-table-column prop="name" label="名称" width="80"></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">
|
<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>
|
||||||
<el-table-column label="字数范围" width="120">
|
<el-table-column label="字数范围" width="120">
|
||||||
<template #default="scope">{{ scope.row.min_words }} - {{ scope.row.max_words }}</template>
|
<template #default="scope">{{ scope.row.min_words }} - {{ scope.row.max_words }}</template>
|
||||||
</el-table-column>
|
</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>
|
<template #default="scope">{{ scope.row.requires_image ? `${scope.row.image_count_min}-${scope.row.image_count_max}张` : '不需要' }}</template>
|
||||||
</el-table-column>
|
</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="180">
|
||||||
<el-table-column prop="default_format" label="默认格式" min-width="160">
|
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 50) }}{{ (scope.row.default_format || '').length > 50 ? '...' : '' }}</template>
|
||||||
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 30) }}{{ (scope.row.default_format || '').length > 30 ? '...' : '' }}</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="is_active" label="激活" width="60">
|
<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>
|
<template #default="scope"><span>{{ scope.row.is_active ? '是' : '否' }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100" fixed="right">
|
<el-table-column label="操作" width="80" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button size="small" @click="showPlatformConfigDialog(scope.row)">编辑</el-button>
|
<el-button size="small" @click="showPcDialogFn(scope.row)">编辑</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeTab === 'systemconfigs'">
|
<div v-if="activeTab === 'systemconfigs'">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -253,121 +302,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 v-if="activeTab === 'orgs'">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
|
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
|
||||||
@@ -460,90 +394,11 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
|
||||||
<el-form :model="llmConfigForm" label-width="110px">
|
<el-form :model="llmConfigForm" label-width="110px">
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
@@ -580,39 +435,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="600px" :close-on-click-modal="false">
|
||||||
<el-form :model="systemConfigForm" label-width="80px">
|
<el-form :model="systemConfigForm" label-width="80px">
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
@@ -629,48 +451,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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-dialog v-model="orgDialogVisible" :title="orgDialogTitle" width="500px" :close-on-click-modal="false">
|
||||||
<el-form :model="orgForm" label-width="80px">
|
<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>
|
<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(); }),
|
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 currentUser = ref({ username: '' });
|
||||||
const isAdmin = ref(false);
|
const isAdmin = ref(false);
|
||||||
|
|
||||||
@@ -719,38 +499,6 @@
|
|||||||
|
|
||||||
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
|
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 taskLogs = ref([]);
|
||||||
const taskLogsLoading = ref(false);
|
const taskLogsLoading = ref(false);
|
||||||
const loadTaskLogs = async () => {
|
const loadTaskLogs = async () => {
|
||||||
@@ -801,32 +549,41 @@
|
|||||||
|
|
||||||
const platformConfigs = ref([]);
|
const platformConfigs = ref([]);
|
||||||
const platformConfigsLoading = ref(false);
|
const platformConfigsLoading = ref(false);
|
||||||
const platformConfigDialogVisible = ref(false);
|
const platformConfigsRequested = ref(false);
|
||||||
const platformConfigDialogTitle = ref('新增配置');
|
const pcShowActiveOnly = ref(true);
|
||||||
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 showPcDialog = ref(false);
|
||||||
const editingPlatformConfigPlatform = ref(null);
|
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 () => {
|
const loadPlatformConfigs = async () => {
|
||||||
platformConfigsLoading.value = true;
|
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; }
|
finally { platformConfigsLoading.value = false; }
|
||||||
};
|
};
|
||||||
const showPlatformConfigDialog = (row = null) => {
|
const showPcDialogFn = (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 () => {
|
|
||||||
try {
|
try {
|
||||||
if (editingPlatformConfigPlatform.value) { await api.put(`/api/platform-config/${platformConfigForm.platform}`, platformConfigForm); ElMessage.success('更新成功'); }
|
if (row) {
|
||||||
else { await api.post('/api/platform-config', platformConfigForm); ElMessage.success('创建成功'); }
|
pcDialogTitle.value = '编辑配置'; editingPcPlatform.value = row.platform;
|
||||||
platformConfigDialogVisible.value = false; await loadPlatformConfigs();
|
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); }
|
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -837,20 +594,6 @@
|
|||||||
const systemConfigForm = reactive({ key: '', value: '', description: '' });
|
const systemConfigForm = reactive({ key: '', value: '', description: '' });
|
||||||
const editingSystemConfigKey = ref(null);
|
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 orgs = ref([]);
|
||||||
const orgsLoading = ref(false);
|
const orgsLoading = ref(false);
|
||||||
const orgDialogVisible = ref(false);
|
const orgDialogVisible = ref(false);
|
||||||
@@ -988,95 +731,39 @@
|
|||||||
finally { assistantSaving.value = false; }
|
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 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 logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||||
|
|
||||||
const tabLoaders = {
|
const tabLoaders = {
|
||||||
cases: loadCases, tasklogs: loadTaskLogs,
|
tasklogs: loadTaskLogs,
|
||||||
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
|
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
|
||||||
users: fetchUsers,
|
users: fetchUsers,
|
||||||
categories: loadCategories, sources: loadSources,
|
|
||||||
orgs: loadOrgs, assistant: loadAssistantConfig,
|
orgs: loadOrgs, assistant: loadAssistantConfig,
|
||||||
};
|
};
|
||||||
const loadedTabs = new Set(['cases']);
|
const loadedTabs = new Set([]);
|
||||||
|
|
||||||
const switchTab = (name) => {
|
const switchTab = (name) => {
|
||||||
|
console.log('[switchTab]', name, 'loadedTabs has?', loadedTabs.has(name));
|
||||||
activeTab.value = name;
|
activeTab.value = name;
|
||||||
if (!loadedTabs.has(name)) {
|
if (!loadedTabs.has(name)) {
|
||||||
loadedTabs.add(name);
|
loadedTabs.add(name);
|
||||||
tabLoaders[name]();
|
if (tabLoaders[name]) tabLoaders[name]();
|
||||||
|
else console.warn('[switchTab] no loader for', name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadedTabs.add('cases');
|
|
||||||
loadCases();
|
|
||||||
const hash = window.location.hash.replace('#tab=', '');
|
const hash = window.location.hash.replace('#tab=', '');
|
||||||
if (hash && tabLoaders[hash]) { switchTab(hash); }
|
if (hash && tabLoaders[hash]) { switchTab(hash); }
|
||||||
|
else { switchTab('users'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTab, switchTab,
|
activeTab, switchTab,
|
||||||
cases, casesLoading, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
|
|
||||||
casePage, casePageSize, paginatedCases,
|
|
||||||
taskLogs, taskLogsLoading, loadTaskLogs,
|
taskLogs, taskLogsLoading, loadTaskLogs,
|
||||||
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
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,
|
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,
|
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
|
||||||
orgPage, orgPageSize, paginatedOrgs,
|
orgPage, orgPageSize, paginatedOrgs,
|
||||||
logout, currentUser, isAdmin, redirectToPage,
|
logout, currentUser, isAdmin, redirectToPage,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
|
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
|
||||||
<el-button @click="previewVisible = false">关闭</el-button>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -215,12 +215,18 @@ const ArticlesApp = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
copyPreviewHtml() {
|
copyPreviewHtml() {
|
||||||
if (!this.previewArticleData || !this.previewArticleData.html_content) {
|
const html = this.previewArticleData?.html_content;
|
||||||
this.$message.warning('暂无 HTML 内容可复制');
|
if (!html) { this.$message.warning('暂无内容可复制'); return; }
|
||||||
return;
|
const parser = new DOMParser();
|
||||||
}
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
navigator.clipboard.writeText(this.previewArticleData.html_content)
|
const titleEl = doc.querySelector('h1');
|
||||||
.then(() => this.$message.success('HTML 已复制'))
|
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('复制失败'));
|
.catch(() => this.$message.error('复制失败'));
|
||||||
},
|
},
|
||||||
async deleteArticle(article) {
|
async deleteArticle(article) {
|
||||||
|
|||||||
+609
-147
@@ -3,12 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="element-plus.css">
|
||||||
<link rel="stylesheet" href="theme-modern.css">
|
<link rel="stylesheet" href="theme-modern.css">
|
||||||
<style>
|
<style>
|
||||||
.toolbar { gap: 12px; }
|
.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 { 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-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; }
|
.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-failed { background: #fef0f0; color: #f56c6c; }
|
||||||
.status-cancelled { background: #f4f4f5; color: #c0c4cc; }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.schedule-row:hover { background: #f0f4ff; }
|
||||||
@@ -44,7 +42,6 @@
|
|||||||
.detail-row:last-child { border-bottom: none; }
|
.detail-row:last-child { border-bottom: none; }
|
||||||
.detail-label { width: 80px; flex-shrink: 0; font-size: 13px; color: #909399; }
|
.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; }
|
.detail-value { flex: 1; font-size: 13px; color: #303133; word-break: break-all; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.task-table { display: none; }
|
.task-table { display: none; }
|
||||||
.task-card-list-mobile { display: block; }
|
.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 { 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:first-child { color: #909399; }
|
||||||
.module-content > div > span:last-child { color: #303133; font-weight: 500; }
|
.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>
|
</style>
|
||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<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>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<main class="content-area">
|
<main class="content-area">
|
||||||
<div class="card page-fade">
|
<div class="card page-fade">
|
||||||
<div class="page-header">
|
<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">
|
<div class="toolbar">
|
||||||
<el-tag v-if="schedulerRunning" type="success" size="small">调度器运行中</el-tag>
|
<el-tag v-if="schedulerRunning" type="success" size="small">调度器运行中</el-tag>
|
||||||
<el-tag v-else type="danger" 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>
|
<el-button @click="loadModules"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-if="moduleLoading" style="text-align: center; padding: 20px; color: #909399;">加载中...</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-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-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">
|
<div class="module-header">
|
||||||
<span class="module-title">{{ mod.title }}</span>
|
<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>
|
||||||
<div class="module-content">
|
<div class="module-content">
|
||||||
<div><span>最后运行</span><span>{{ mod.last_run }}</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.next_run || '—' }}</span></div>
|
||||||
<div><span>今日任务</span><span>{{ mod.task_count }} 个</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><span>成功率</span><span>{{ mod.success_rate > 0 ? mod.success_rate + '%' : '暂无' }}</span></div>
|
|
||||||
<div style="margin-top:10px; border-bottom:none;">
|
<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>
|
<el-button size="small" @click.stop="openModuleDetail(mod)">查看详情</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,120 +112,161 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模块详情抽屉 -->
|
<!-- 案例管理 -->
|
||||||
<el-drawer v-model="showDrawer" :title="drawerTitle" size="50%" direction="rtl" class="task-drawer">
|
<div v-if="activeTab === 'cases'">
|
||||||
<div v-if="drawerLoading" style="text-align:center;padding:40px;color:#909399;">加载中...</div>
|
<div class="toolbar">
|
||||||
<div v-else-if="drawerError" style="text-align:center;padding:40px;color:#f56c6c;">{{ drawerError }}</div>
|
<el-button type="primary" size="small" @click="showCaseDialog()">新增案例</el-button>
|
||||||
<div v-else-if="drawerData" style="height:100%;display:flex;flex-direction:column;">
|
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ cases.length }} 个</span>
|
||||||
<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>
|
</div>
|
||||||
<el-tabs v-model="drawerTab" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
<div v-if="casesLoading" class="card-loading">加载中...</div>
|
||||||
<el-tab-pane label="📥 输入参数" name="inputs" style="overflow:auto;flex:1;">
|
<template v-else-if="cases.length === 0">
|
||||||
<div v-if="Object.keys(drawerData.inputs || {}).length === 0" style="color:#909399;padding:20px;text-align:center;">无需手动输入,数据自动获取</div>
|
<div class="empty-state">
|
||||||
<div v-for="(val, key) in drawerData.inputs" :key="key" style="margin-bottom:16px;">
|
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
|
||||||
<div style="font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;display:flex;align-items:center;gap:8px;">
|
<div class="empty-text">暂无案例数据</div>
|
||||||
{{ key }}
|
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
|
||||||
<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>
|
</div>
|
||||||
<div v-if="Array.isArray(val)">
|
</template>
|
||||||
<el-tag v-for="(item, i) in val" :key="i" style="margin:2px 4px 2px 0;" size="small">{{ item }}</el-tag>
|
<template v-else>
|
||||||
|
<div class="stat-summary">
|
||||||
|
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else style="font-size:13px;color:#606266;">{{ val }}</div>
|
<el-table :data="paginatedCases" border stripe class="data-table" style="width:100%">
|
||||||
</div>
|
<el-table-column prop="id" label="ID" width="70"></el-table-column>
|
||||||
</el-tab-pane>
|
<el-table-column prop="title" label="标题" min-width="80" :show-overflow-tooltip="true"></el-table-column>
|
||||||
<el-tab-pane label="📤 产出结果" name="outputs" style="overflow:auto;flex:1;">
|
<el-table-column prop="field" label="领域" width="140"></el-table-column>
|
||||||
<div v-if="Object.keys(drawerData.outputs || {}).length === 0" style="color:#909399;padding:20px;text-align:center;">暂无产出数据,运行任务后将在此展示</div>
|
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
|
||||||
<div v-for="(val, key) in drawerData.outputs" :key="key" style="margin-bottom:16px;border-bottom:1px solid #f0f2f5;padding-bottom:12px;">
|
<el-table-column prop="source" label="来源" width="140"></el-table-column>
|
||||||
<div style="font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;">{{ key }}</div>
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
<div v-if="key === '热点列表' && Array.isArray(val)">
|
<template #default="scope">
|
||||||
<el-table :data="val" size="small" max-height="400" style="width:100%;">
|
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
|
||||||
<el-table-column prop="topic" label="话题" min-width="180"></el-table-column>
|
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
|
||||||
<el-table-column prop="domain" label="领域" width="100"></el-table-column>
|
</template>
|
||||||
<el-table-column prop="source" label="来源" width="80"></el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
||||||
<div v-else-if="key === '最新选题' && Array.isArray(val)">
|
</template>
|
||||||
<el-table :data="val" size="small" max-height="300" style="width:100%;">
|
<div class="card-list-mobile">
|
||||||
<el-table-column prop="id" label="ID" width="80"></el-table-column>
|
<div v-for="item in paginatedCases" :key="item.id" class="card-item">
|
||||||
<el-table-column prop="title" label="标题" min-width="150"></el-table-column>
|
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
|
||||||
<el-table-column prop="field" label="领域" width="80"></el-table-column>
|
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
|
||||||
<el-table-column prop="status" label="状态" width="70"></el-table-column>
|
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
|
||||||
</el-table>
|
<div class="card-actions">
|
||||||
</div>
|
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
|
||||||
<div v-else-if="(key === '最新文章' || key === '最新指标') && Array.isArray(val)">
|
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</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">
|
<div v-if="activeTab === 'categories'">
|
||||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 创作任务</h2>
|
<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">
|
<div class="toolbar">
|
||||||
<el-button-group>
|
<el-button-group>
|
||||||
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
|
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
|
||||||
@@ -226,7 +277,6 @@
|
|||||||
</el-button-group>
|
</el-button-group>
|
||||||
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-if="loading" style="text-align: center; padding: 40px; color: #909399;">加载中...</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;">
|
<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>
|
<el-icon style="font-size:56px;margin-bottom:16px;color:#c0c4cc;"><IconTopic /></el-icon>
|
||||||
@@ -270,8 +320,267 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>
|
</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">
|
<el-dialog v-model="showDetailDialog" title="任务详情" width="600px" class="task-detail-dialog">
|
||||||
<div v-if="detailTask">
|
<div v-if="detailTask">
|
||||||
<div class="detail-row"><span class="detail-label">任务ID</span><span class="detail-value">{{ detailTask.task_id }}</span></div>
|
<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="icon-components.js"></script>
|
||||||
<script src="element-plus.full.js"></script>
|
<script src="element-plus.full.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
||||||
|
const { ElMessage, ElMessageBox } = ElementPlus;
|
||||||
|
|
||||||
const TasksApp = {
|
const TasksApp = {
|
||||||
data() {
|
data() {
|
||||||
const SCHEDULER_JOBS = {
|
const SCHEDULER_JOBS = {
|
||||||
@@ -317,16 +629,32 @@ const TasksApp = {
|
|||||||
scheduled_optimize_sources: '/api/system/optimize-sources/run',
|
scheduled_optimize_sources: '/api/system/optimize-sources/run',
|
||||||
scheduled_metrics_sync: '/api/system/metrics-sync/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 {
|
return {
|
||||||
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
|
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: '',
|
allTasks: [], loading: false, filterStatus: '',
|
||||||
showDetailDialog: false, detailTask: null,
|
showDetailDialog: false, detailTask: null,
|
||||||
modules: [], moduleLoading: false, schedulerRunning: false, runningModule: null,
|
|
||||||
currentPage: 1, pageSize: 10,
|
currentPage: 1, pageSize: 10,
|
||||||
showDrawer: false, drawerTitle: '', drawerData: null, drawerLoading: false, drawerError: '', drawerTab: 'inputs',
|
|
||||||
pollTimer: null,
|
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,
|
SCHEDULER_JOBS, MODULE_TRIGGER_ENDPOINTS,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -334,13 +662,26 @@ const TasksApp = {
|
|||||||
paginatedTasks() {
|
paginatedTasks() {
|
||||||
const start = (this.currentPage - 1) * this.pageSize;
|
const start = (this.currentPage - 1) * this.pageSize;
|
||||||
return this.allTasks.slice(start, start + 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: {
|
methods: {
|
||||||
|
// === 通用 ===
|
||||||
getToken() { return localStorage.getItem('authToken'); },
|
getToken() { return localStorage.getItem('authToken'); },
|
||||||
async api(url, opts = {}) {
|
async api(url, opts = {}) {
|
||||||
const token = this.getToken();
|
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 });
|
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}`); }
|
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -352,9 +693,19 @@ const TasksApp = {
|
|||||||
if (!token) { window.location.href = '/login.html'; return; }
|
if (!token) { window.location.href = '/login.html'; return; }
|
||||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(r => r.ok ? r.json() : Promise.reject())
|
.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'; });
|
.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() {
|
async loadModules() {
|
||||||
this.moduleLoading = true;
|
this.moduleLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -368,32 +719,71 @@ const TasksApp = {
|
|||||||
async openModuleDetail(mod) {
|
async openModuleDetail(mod) {
|
||||||
this.showDrawer = true;
|
this.showDrawer = true;
|
||||||
this.drawerTitle = mod.title + ' 详情';
|
this.drawerTitle = mod.title + ' 详情';
|
||||||
this.drawerData = null;
|
this.drawerData = { ...mod };
|
||||||
this.drawerError = '';
|
this.drawerError = '';
|
||||||
this.drawerLoading = true;
|
this.drawerLoading = true;
|
||||||
this.drawerTab = 'inputs';
|
this.drawerTab = 'inputs';
|
||||||
|
this.drawerHistory = [];
|
||||||
|
this.drawerPrompts = [];
|
||||||
try {
|
try {
|
||||||
const data = await this.api('/api/tasks/modules/' + mod.id + '/detail');
|
const [detail, history, prompts] = await Promise.all([
|
||||||
if (data) {
|
this.api('/api/admin/task-configs/' + mod.module_id),
|
||||||
data.status = mod.status;
|
this.api('/api/admin/task-configs/history/' + mod.module_id + '?limit=20'),
|
||||||
this.drawerData = data;
|
this.api('/api/admin/prompt-configs?module_id=' + mod.module_id),
|
||||||
} else {
|
]);
|
||||||
this.drawerError = '加载失败';
|
this.drawerData = { ...mod, ...detail };
|
||||||
}
|
this.drawerHistory = history || [];
|
||||||
|
this.drawerPrompts = prompts || [];
|
||||||
} catch (e) { this.drawerError = e.message; }
|
} catch (e) { this.drawerError = e.message; }
|
||||||
finally { this.drawerLoading = false; }
|
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) {
|
async triggerModule(modId) {
|
||||||
const endpoint = this.MODULE_TRIGGER_ENDPOINTS[modId];
|
const endpoint = this.MODULE_TRIGGER_ENDPOINTS[modId];
|
||||||
if (!endpoint) { this.$message.error('未知模块'); return; }
|
if (!endpoint) { ElMessage.error('未知模块'); return; }
|
||||||
this.runningModule = modId;
|
this.runningModule = modId;
|
||||||
try {
|
try {
|
||||||
await this.api(endpoint, { method: 'POST' });
|
await this.api(endpoint, { method: 'POST' });
|
||||||
this.$message.success('任务已启动');
|
ElMessage.success('任务已启动');
|
||||||
setTimeout(() => this.loadModules(), 2000);
|
setTimeout(() => this.loadModules(), 2000);
|
||||||
} catch (e) { this.$message.error('启动失败: ' + e.message); }
|
} catch (e) { ElMessage.error('启动失败: ' + e.message); }
|
||||||
finally { this.runningModule = null; }
|
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; },
|
parseScore(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; },
|
||||||
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
|
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
|
||||||
getStageLabel(stage) { return { 'creator': '创作', 'optimize': '审查', 'review': '审查', 'publish': '发布' }[stage] || stage; },
|
getStageLabel(stage) { return { 'creator': '创作', 'optimize': '审查', 'review': '审查', 'publish': '发布' }[stage] || stage; },
|
||||||
@@ -414,33 +804,105 @@ const TasksApp = {
|
|||||||
let url = '/api/tasks?limit=200';
|
let url = '/api/tasks?limit=200';
|
||||||
if (this.filterStatus) url += '&status=' + this.filterStatus;
|
if (this.filterStatus) url += '&status=' + this.filterStatus;
|
||||||
const data = await this.api(url) || [];
|
const data = await this.api(url) || [];
|
||||||
const oldId = this.allTasks.length > 0 && this.allTasks[0] ? this.allTasks[0].task_id : null;
|
|
||||||
this.allTasks = data;
|
this.allTasks = data;
|
||||||
if (resetPage) this.currentPage = 1;
|
if (resetPage) this.currentPage = 1;
|
||||||
const hasRunning = data.some(t => t.status === 'running');
|
const hasRunning = data.some(t => t.status === 'running');
|
||||||
if (hasRunning) { this.startPolling(); } else { this.stopPolling(); }
|
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; }
|
finally { this.loading = false; }
|
||||||
},
|
},
|
||||||
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
|
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
|
||||||
async cancelTask(taskId) {
|
async cancelTask(taskId) {
|
||||||
try {
|
try {
|
||||||
await this.$confirm('确定取消该任务?', '提示', { type: 'warning' });
|
await ElMessageBox.confirm('确定取消该任务?', '提示', { type: 'warning' });
|
||||||
await this.api('/api/tasks/' + taskId, { method: 'DELETE' });
|
await this.api('/api/tasks/' + taskId, { method: 'DELETE' });
|
||||||
this.$message.success('任务已取消');
|
ElMessage.success('任务已取消');
|
||||||
this.loadTasks();
|
this.loadTasks();
|
||||||
} catch (e) { if (e !== 'cancel') this.$message.error(e.message || '操作失败'); }
|
} catch (e) { if (e !== 'cancel') ElMessage.error(e.message || '操作失败'); }
|
||||||
},
|
},
|
||||||
async retryTask(task) {
|
async retryTask(task) {
|
||||||
if (!task.topic_id) { this.$message.warning('该任务无关联选题,无法重试'); return; }
|
if (!task.topic_id) { ElMessage.warning('该任务无关联选题,无法重试'); return; }
|
||||||
try {
|
try {
|
||||||
await this.api('/api/tasks/run-creator?topic_id=' + task.topic_id, { method: 'POST' });
|
await this.api('/api/tasks/run-creator?topic_id=' + task.topic_id, { method: 'POST' });
|
||||||
this.$message.success('已重新提交创作任务');
|
ElMessage.success('已重新提交创作任务');
|
||||||
this.loadTasks();
|
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(); }
|
beforeUnmount() { this.stopPolling(); }
|
||||||
};
|
};
|
||||||
const app = Vue.createApp(TasksApp);
|
const app = Vue.createApp(TasksApp);
|
||||||
|
|||||||
@@ -356,9 +356,10 @@ const TopicsApp = {
|
|||||||
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
|
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
|
||||||
const body = doc.body;
|
const body = doc.body;
|
||||||
if (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}`)
|
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
|
||||||
.then(() => this.$message.success(`已复制内容,请前往${platform}粘贴发布`))
|
.then(() => this.$message.success(`已复制内容,请前往${platform}粘贴发布`))
|
||||||
.catch(() => this.$message.error('复制失败,请手动复制'));
|
.catch(() => this.$message.error('复制失败,请手动复制'));
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
||||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
{ key: 'assets', label: '素材', page: 'assets.html' },
|
||||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
||||||
{ key: 'platforms', label: '平台', page: 'platforms.html' },
|
|
||||||
];
|
];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
||||||
|
|||||||
+57
-37
@@ -39,7 +39,48 @@ logging.basicConfig(
|
|||||||
logging.StreamHandler()
|
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
|
@dataclass
|
||||||
@@ -322,14 +363,8 @@ class SustainabilityCollector:
|
|||||||
if not content:
|
if not content:
|
||||||
content = title
|
content = title
|
||||||
|
|
||||||
# 关键词匹配(来源特定或全局)
|
|
||||||
keywords = source_keywords if source_keywords else [
|
|
||||||
'sustainable', 'green', 'eco', 'circular', 'climate', 'carbon',
|
|
||||||
'zero waste', 'renewable', 'recycle', '环保', '可持续', '碳中和',
|
|
||||||
'循环经济', '零浪费', '低碳', '生态'
|
|
||||||
]
|
|
||||||
|
|
||||||
search_text = (title + content).lower()
|
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):
|
if any(keyword.lower() in search_text for keyword in keywords):
|
||||||
articles.append({
|
articles.append({
|
||||||
'title': title,
|
'title': title,
|
||||||
@@ -445,6 +480,8 @@ class SustainabilityCollector:
|
|||||||
logger.warning("LLM不可用,跳过AI选题生成")
|
logger.warning("LLM不可用,跳过AI选题生成")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
from prompt_loader import get_prompt, get_prompt_params
|
||||||
|
|
||||||
existing = self._get_existing_titles()
|
existing = self._get_existing_titles()
|
||||||
existing_hint = ""
|
existing_hint = ""
|
||||||
if existing:
|
if existing:
|
||||||
@@ -461,28 +498,21 @@ class SustainabilityCollector:
|
|||||||
if cases:
|
if cases:
|
||||||
case_lines = [f"- {c.title[:40]}({c.category})" for c in cases[:5]]
|
case_lines = [f"- {c.title[:40]}({c.category})" for c in cases[:5]]
|
||||||
data_section += "\n采集案例:\n" + "\n".join(case_lines) + "\n"
|
data_section += "\n采集案例:\n" + "\n".join(case_lines) + "\n"
|
||||||
|
if not data_section:
|
||||||
|
data_section = "(当前无实时采集数据,请基于你对中文互联网趋势的了解直接生成)"
|
||||||
|
|
||||||
trend_context = self._get_trend_context()
|
trend_context = self._get_trend_context()
|
||||||
|
|
||||||
prompt = f"""你是一个内容策略师。基于以下信息,为「{target_category}」类别生成一个高质量选题。
|
prompt = get_prompt("topic_generate",
|
||||||
|
target_category=target_category,
|
||||||
{data_section if data_section else "(当前无实时采集数据,请基于你对中文互联网趋势的了解直接生成)"}
|
data_section=data_section,
|
||||||
{existing_hint}
|
existing_hint=existing_hint,
|
||||||
|
trend_context=trend_context,
|
||||||
{trend_context}
|
)
|
||||||
|
|
||||||
输出一个选题,格式JSON:
|
|
||||||
{{{{
|
|
||||||
"title": "标题(20字内,含核心关键词)",
|
|
||||||
"core_concept": "核心观点(一句话)",
|
|
||||||
"audience_pain": "受众痛点",
|
|
||||||
"unique_angle": "差异化切入点",
|
|
||||||
"format": "内容形式(趋势洞察/实操指南/对比分析/案例解读)"
|
|
||||||
}}}}
|
|
||||||
只输出JSON。"""
|
|
||||||
|
|
||||||
try:
|
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()
|
resp = resp.strip()
|
||||||
if resp.startswith("```"):
|
if resp.startswith("```"):
|
||||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||||
@@ -570,18 +600,8 @@ class SustainabilityCollector:
|
|||||||
# 实际应用中可用AI提取,这里用前100字符
|
# 实际应用中可用AI提取,这里用前100字符
|
||||||
core_idea = content[:200] if len(content) > 200 else content
|
core_idea = content[:200] if len(content) > 200 else content
|
||||||
|
|
||||||
# 生成中国痛点(基于类别模板)
|
# 生成中国痛点(基于类别模板,从DB读取pain_template)
|
||||||
china_pains = {
|
china_pain = _get_china_pain(category)
|
||||||
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
|
|
||||||
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
|
|
||||||
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
|
|
||||||
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
|
|
||||||
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
|
|
||||||
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
|
|
||||||
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
|
|
||||||
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证"
|
|
||||||
}
|
|
||||||
china_pain = china_pains.get(category, "中国相关数据不足,需本土化验证")
|
|
||||||
|
|
||||||
# 生成案例
|
# 生成案例
|
||||||
case = SustainabilityCase(
|
case = SustainabilityCase(
|
||||||
|
|||||||
@@ -7,15 +7,13 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
# 敏感词库(示例,需要持续更新)
|
|
||||||
SENSITIVE_WORDS = {
|
SENSITIVE_WORDS = {
|
||||||
"政治敏感": ["国家主席", "政治局", "常委", "军委", "统战部", "颠覆国家", "分裂主义", "台独", "疆独", "藏独"],
|
"政治敏感": ["国家主席", "政治局", "常委", "军委", "统战部", "颠覆国家", "分裂主义", "台独", "疆独", "藏独"],
|
||||||
"违禁内容": ["赌博", "毒品", "迷药", "枪支", "炸药", "色情", "低俗", "反动", "邪教"],
|
"违禁内容": ["赌博", "毒品", "迷药", "枪支", "炸药", "色情", "低俗", "反动", "邪教"],
|
||||||
"不实信息": [" guaranteed 赚钱", "一夜暴富", "100%有效", "包治百病", "绝对正确"],
|
"不实信息": [" guaranteed 赚钱", "一夜暴富", "100%有效", "包治百病", "绝对正确"],
|
||||||
"领导人相关": ["主席", "总理", "总书记", "国家领导人"] # 需上下文判断
|
"领导人相关": ["主席", "总理", "总书记", "国家领导人"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# 平台规则限制
|
|
||||||
PLATFORM_RULES = {
|
PLATFORM_RULES = {
|
||||||
"zhihu": {
|
"zhihu": {
|
||||||
"max_title_len": 100,
|
"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:
|
class ComplianceChecker:
|
||||||
"""合规审查器"""
|
"""合规审查器"""
|
||||||
|
|
||||||
@@ -90,7 +152,8 @@ class ComplianceChecker:
|
|||||||
|
|
||||||
def _check_sensitive_words(self, text: str):
|
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:
|
for word in words:
|
||||||
if word in text:
|
if word in text:
|
||||||
self.issues.append({
|
self.issues.append({
|
||||||
@@ -102,7 +165,8 @@ class ComplianceChecker:
|
|||||||
|
|
||||||
def _check_platform_rules(self, text: str, platform: str):
|
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中提取)
|
# 标题长度(从HTML中提取)
|
||||||
max_title_len = self._get_platform_rule('max_title_len', rules.get("max_title_len"))
|
max_title_len = self._get_platform_rule('max_title_len', rules.get("max_title_len"))
|
||||||
@@ -244,7 +308,7 @@ class ComplianceChecker:
|
|||||||
"""检查文章最小字数(去除HTML标签)"""
|
"""检查文章最小字数(去除HTML标签)"""
|
||||||
plain = re.sub(r'<[^>]+>', '', text)
|
plain = re.sub(r'<[^>]+>', '', text)
|
||||||
word_count = len(plain.strip())
|
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:
|
if word_count < min_words:
|
||||||
self.issues.append({
|
self.issues.append({
|
||||||
"type": "内容完整度",
|
"type": "内容完整度",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ except ImportError:
|
|||||||
HAVE_LLM = False
|
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 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"
|
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||||
DRAFTS_DIR = DATA_DIR / "drafts"
|
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()])
|
handlers=[logging.FileHandler(LOGS_DIR / f"optimizer_{TODAY}.log"), logging.StreamHandler()])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORM_TAGS = {
|
DEFAULT_PLATFORM_TAGS = {
|
||||||
"zhihu": ["科技", "职场"],
|
"zhihu": ["科技", "职场"],
|
||||||
"xiaohongshu": ["AI", "可持续", "生活方式"]
|
"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
|
_llm_config_cache = None
|
||||||
|
|
||||||
def get_llm_config():
|
def get_llm_config():
|
||||||
@@ -110,13 +143,14 @@ def fix_wechat_title(html: str, title: str) -> str:
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
def fix_tags(html: str, platform: str) -> str:
|
def fix_tags(html: str, platform: str) -> str:
|
||||||
|
tags_map = get_platform_tags()
|
||||||
if platform == "zhihu":
|
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:
|
if '<div class="tags">' in html:
|
||||||
old = html.split('<div class="tags">')[1].split('</div>')[0]
|
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>')
|
html = html.replace(f'<div class="tags">{old}</div>', f'<div class="tags">{tags_str}</div>')
|
||||||
elif platform == "xiaohongshu":
|
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:
|
if '<div class="hashtags">' in html:
|
||||||
old = html.split('<div class="hashtags">')[1].split('</div>')[0]
|
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>')
|
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','')})"
|
f"- [{i['type']}] {i.get('category','')}: {i.get('detail','')} (建议: {i.get('suggestion','')})"
|
||||||
for i in remaining_issues
|
for i in remaining_issues
|
||||||
)
|
)
|
||||||
polish_prompt = f"""你是一个专业的内容合规优化助手。以下文章存在合规问题,请逐一修复并输出完整HTML。
|
prompt = get_prompt("compliance_fix", issues_desc=issues_desc, html=html)
|
||||||
|
|
||||||
需修复的问题:
|
|
||||||
{issues_desc}
|
|
||||||
|
|
||||||
原文:
|
|
||||||
{html}
|
|
||||||
|
|
||||||
要求:
|
|
||||||
- 只修复上述问题,不改变文章结构和核心内容
|
|
||||||
- 保持<h2>, <h3>, <p>等标签结构不变
|
|
||||||
- 修复后内容依然保持可读性和自然语感(不要因为合规变成生硬的表达)
|
|
||||||
- 替换敏感词时选择意思相近的替代词,不删节重要信息"""
|
|
||||||
else:
|
else:
|
||||||
polish_prompt = f"""你是一个专业的内容润色助手。请润色以下文章,提升表达的自然感和可读性。
|
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)
|
||||||
{html}
|
polished = strip_ai_preface(polished)
|
||||||
|
polished = strip_thinking_html(polished)
|
||||||
要求:
|
|
||||||
- 保持原文事实、数据、章节结构不变
|
|
||||||
- 输出相同的HTML格式(保留<h2>, <h3>, <p>标签)
|
|
||||||
- 提升表达的自然感,让它更像是人写的
|
|
||||||
- 避免AI常见表达模式(「首先其次最后」「总的来说」「值得注意的是」等)
|
|
||||||
- 短句化,读起来更流畅"""
|
|
||||||
polished = call_llm(polish_prompt, model=model, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
|
|
||||||
if '<h2' in polished or '<p>' in polished:
|
if '<h2' in polished or '<p>' in polished:
|
||||||
if len(polished) > len(html) * 0.3 and len(polished) > 100:
|
if len(polished) > len(html) * 0.3 and len(polished) > 100:
|
||||||
if not any(kw in polished[:100] for kw in ['保留', '建议', '可以', '应该', '推荐']):
|
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]]:
|
def optimize_article(html: str, platform: str, topic_data: Dict, remaining_issues: Optional[List[Dict]] = None) -> Tuple[str, List[str]]:
|
||||||
logs = []
|
logs = []
|
||||||
|
html = strip_thinking_html(html)
|
||||||
if platform == "wechat":
|
if platform == "wechat":
|
||||||
html = fix_wechat_title(html, topic_data.get("title", ""))
|
html = fix_wechat_title(html, topic_data.get("title", ""))
|
||||||
logs.append("标题截断(含后缀)")
|
logs.append("标题截断(含后缀)")
|
||||||
@@ -185,7 +201,8 @@ def optimize_article(html: str, platform: str, topic_data: Dict, remaining_issue
|
|||||||
before = html
|
before = html
|
||||||
html = fix_tags(html, platform)
|
html = fix_tags(html, platform)
|
||||||
if html != before:
|
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)
|
polished, pol_log = polish_with_llm(html, platform, remaining_issues)
|
||||||
if pol_log:
|
if pol_log:
|
||||||
html = polished
|
html = polished
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
|
|
||||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SEARCH_CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "search_cache.json"
|
SEARCH_CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "search_cache.json"
|
||||||
|
|||||||
+13
-50
@@ -12,6 +12,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
|
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
|
||||||
|
|
||||||
from db_helper import get_topic_by_id
|
from db_helper import get_topic_by_id
|
||||||
|
from prompt_loader import get_prompt, get_prompt_params
|
||||||
try:
|
try:
|
||||||
from app.core.nvidia_client import call_llm
|
from app.core.nvidia_client import call_llm
|
||||||
HAVE_LLM = True
|
HAVE_LLM = True
|
||||||
@@ -52,57 +53,19 @@ class Outliner:
|
|||||||
|
|
||||||
if HAVE_LLM:
|
if HAVE_LLM:
|
||||||
_now = datetime.datetime.now()
|
_now = datetime.datetime.now()
|
||||||
prompt = f"""你是一个资深内容编辑,擅长设计读者爱看+搜索引擎友好+平台愿意推荐+有市场传播力的文章结构。
|
prompt = get_prompt("outline_generation",
|
||||||
|
date=_now.strftime('%Y年%m月%d日'),
|
||||||
⚠️ 今天日期:{_now.strftime('%Y年%m月%d日')}。当前年份:{_now.year}年。
|
year=_now.year,
|
||||||
|
title=title,
|
||||||
## 选题信息
|
field=field,
|
||||||
标题:{title}
|
core=core,
|
||||||
领域:{field}
|
pain=pain,
|
||||||
核心观点:{core}
|
angle=angle,
|
||||||
受众痛点:{pain}
|
cases_summary=cases_summary,
|
||||||
独特视角:{angle}
|
)
|
||||||
|
|
||||||
## 研究笔记
|
|
||||||
{cases_summary}
|
|
||||||
|
|
||||||
## 大纲设计要求
|
|
||||||
### 结构
|
|
||||||
- 5-8章,每章2-4个要点
|
|
||||||
- 结构要有递进:要么认知升级型,要么问题解决型
|
|
||||||
- 把独特视角和受众痛点融入各章,不单独列
|
|
||||||
- 每章标题自带信息量+好奇心,不要「引言」「总结」这类通用标题
|
|
||||||
- 开头要有"钩子"(hook)抓住读者,结尾要有可转发/收藏的总结
|
|
||||||
|
|
||||||
### 数据与热点
|
|
||||||
- **全文必须使用{_now.year-1}-{_now.year}年最新数据**,禁用一切过时数据
|
|
||||||
- 每个观点必须配最新的国内外热点事件/数据/政策来佐证
|
|
||||||
- 体现当前行业正在讨论的核心议题,拒绝泛泛而谈
|
|
||||||
|
|
||||||
### 独特风格
|
|
||||||
- 有自己的判断和立场,不是搬运观点
|
|
||||||
- 每章至少一个"反常识"或"很少有人提"的洞察
|
|
||||||
- 避免同质化表达、老生常谈
|
|
||||||
|
|
||||||
### SEO
|
|
||||||
- H2/H3自然包含用户搜索时会用的短语
|
|
||||||
- 确保大纲覆盖2-3个高价值搜索词,包含1个长尾词
|
|
||||||
- 每章标题对用户搜索意图有回应
|
|
||||||
|
|
||||||
### 市场价值
|
|
||||||
- 读完每章读者能拿走一个实际有用的东西(方法/清单/思维框架/判断标准)
|
|
||||||
- 避免「信息增量为零」的空洞章节
|
|
||||||
- 思考:这篇文章对读者职业/生活/认知有什么用?
|
|
||||||
|
|
||||||
### 平台推荐优化
|
|
||||||
- 知乎:偏硬核数据分析和深度逻辑,结构要有论证链
|
|
||||||
- 小红书:偏实操步骤/清单/对比,结构要一目了然
|
|
||||||
- 公众号:偏故事化开头+情感共鸣+金句结尾,段落节奏快
|
|
||||||
- 一个框架适应三平台,各平台可裁剪侧重点
|
|
||||||
|
|
||||||
直接输出大纲,不要输出思考过程。"""
|
|
||||||
try:
|
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)}")
|
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
|
||||||
return f"# 文章大纲:{title}\n\n{outline}\n\n---\n*大纲生成时间:{TODAY}*"
|
return f"# 文章大纲:{title}\n\n{outline}\n\n---\n*大纲生成时间:{TODAY}*"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -22,7 +22,7 @@ TODAY = __import__('datetime').datetime.now().strftime("%Y-%m-%d")
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TREND_DOMAIN_MAP = {
|
DEFAULT_TREND_DOMAIN_MAP = {
|
||||||
"远程工作": "未来工作方式",
|
"远程工作": "未来工作方式",
|
||||||
"AI工具": "AI与效率",
|
"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:
|
def _topic_trend_score(topic: Dict, trend: Dict) -> float:
|
||||||
field = (topic.get("field") or "").lower()
|
field = (topic.get("field") or "").lower()
|
||||||
title = (topic.get("title") or "").lower()
|
title = (topic.get("title") or "").lower()
|
||||||
core = (topic.get("core_concept") 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", [])
|
keywords = [trend.get("topic", "")] + trend.get("hot_keywords", [])
|
||||||
score = 0.0
|
score = 0.0
|
||||||
if trend_domain and trend_domain in field:
|
if trend_domain and trend_domain in field:
|
||||||
|
|||||||
+59
-21
@@ -26,7 +26,34 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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"
|
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"科技|人文|教育|心理|哲学|社会学", "科技人文"),
|
(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:
|
def _guess_domain(topic: str, reason: str = "") -> str:
|
||||||
text = (topic + " " + reason).lower()
|
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):
|
if re.search(pattern, text, re.IGNORECASE):
|
||||||
return domain
|
return domain
|
||||||
return "科技人文"
|
return "科技人文"
|
||||||
@@ -186,26 +238,12 @@ def fetch_baidu_hot() -> List[Dict]:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_llm_trends() -> 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:
|
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()
|
resp = resp.strip()
|
||||||
if resp.startswith("```"):
|
if resp.startswith("```"):
|
||||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||||
|
|||||||
+43
-99
@@ -12,6 +12,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
||||||
|
|
||||||
from db_helper import get_topic_by_id, update_topic_status, save_article
|
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:
|
try:
|
||||||
from app.core.nvidia_client import call_llm
|
from app.core.nvidia_client import call_llm
|
||||||
HAVE_LLM = True
|
HAVE_LLM = True
|
||||||
@@ -116,21 +118,7 @@ class Writer:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clean_markdown(text: str) -> str:
|
def _clean_markdown(text: str) -> str:
|
||||||
lines = text.split('\n')
|
return clean_markdown_content(text)
|
||||||
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()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_outline_noise(line: str) -> bool:
|
def _is_outline_noise(line: str) -> bool:
|
||||||
@@ -160,31 +148,15 @@ class Writer:
|
|||||||
# 大纲要点格式(>40% 行以 -/*/** 开头)应始终由 LLM 展开为连贯段落
|
# 大纲要点格式(>40% 行以 -/*/** 开头)应始终由 LLM 展开为连贯段落
|
||||||
if HAVE_LLM and self._is_bullet_only(content):
|
if HAVE_LLM and self._is_bullet_only(content):
|
||||||
logger.info(f"使用 LLM 扩写章节(要点→段落): {section['title']}")
|
logger.info(f"使用 LLM 扩写章节(要点→段落): {section['title']}")
|
||||||
prompt = f"""你是一个资深作者,正在写一篇关于「{self.topic['title']}」的文章。请写「{section['title']}」这一节。
|
prompt = get_prompt("section_expansion",
|
||||||
|
topic_title=self.topic['title'],
|
||||||
今天日期:{datetime.datetime.now().strftime('%Y年%m月%d日')}。
|
section_title=section['title'],
|
||||||
|
date=datetime.datetime.now().strftime('%Y年%m月%d日'),
|
||||||
笔记要点:
|
content=content,
|
||||||
{content}
|
)
|
||||||
|
|
||||||
【输出要求】
|
|
||||||
输出3-6段纯粹、流畅的段落文字,每节内容根据平台需求控制在200-800字之间。
|
|
||||||
|
|
||||||
格式:
|
|
||||||
- 禁止任何标题/列表/格式标记(#、-、*、1.、**等)
|
|
||||||
- 每段3-5句,段间空行分隔
|
|
||||||
- 用「你」或「我们」视角,自然口语化
|
|
||||||
|
|
||||||
内容要求(让文章在各平台能被推荐):
|
|
||||||
- 开头直接切入痛点或反常识观点,抓住注意力
|
|
||||||
- 每个观点配具体案例或数据(用「据统计」「调研显示」等),不要空泛说理
|
|
||||||
- 有独特判断和立场,避免正确废话
|
|
||||||
- 回答「所以呢」——读者看完能带走什么
|
|
||||||
- 结尾有情绪感召力,让人想点赞/收藏/转发
|
|
||||||
|
|
||||||
直接输出段落正文,不要任何附加说明。"""
|
|
||||||
try:
|
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:
|
if expanded:
|
||||||
cleaned = self._clean_markdown(expanded.strip())
|
cleaned = self._clean_markdown(expanded.strip())
|
||||||
if cleaned:
|
if cleaned:
|
||||||
@@ -284,15 +256,17 @@ class Writer:
|
|||||||
core = self.topic.get('core_concept', '')
|
core = self.topic.get('core_concept', '')
|
||||||
|
|
||||||
tag_prompts = {
|
tag_prompts = {
|
||||||
"zhihu": f"为以下文章生成知乎标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
"zhihu": get_prompt("tags_generation", platform="知乎", title=title, field=field, core=core),
|
||||||
"wechat": f"为以下文章生成公众号标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
"wechat": get_prompt("tags_generation", platform="公众号", title=title, field=field, core=core),
|
||||||
"xiaohongshu": f"为以下文章生成小红书标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
"xiaohongshu": get_prompt("tags_generation", platform="小红书", title=title, field=field, core=core),
|
||||||
}
|
}
|
||||||
|
|
||||||
if HAVE_LLM:
|
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:
|
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:
|
if tags_text:
|
||||||
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
|
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
|
||||||
if tags:
|
if tags:
|
||||||
@@ -331,64 +305,33 @@ class Writer:
|
|||||||
if not HAVE_LLM:
|
if not HAVE_LLM:
|
||||||
return original
|
return original
|
||||||
|
|
||||||
title_templates = {
|
if platform == "zhihu":
|
||||||
"zhihu": f"""你是一个知乎用户,在给自己的深度回答起高点击率标题。
|
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:
|
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 = call_llm(prompt, temperature=0.7)
|
||||||
|
resp = strip_thinking(resp)
|
||||||
titles = []
|
titles = []
|
||||||
for line in resp.strip().split('\n'):
|
for line in resp.strip().split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -414,6 +357,7 @@ class Writer:
|
|||||||
else:
|
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>"
|
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 = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME)
|
||||||
html_content = _md_parser(adapted)
|
html_content = _md_parser(adapted)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -46,4 +46,6 @@ echo "========================================"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd backend
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user