配置全面迁移数据库: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:
@@ -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 json
|
||||
from ..database import get_db
|
||||
from ..models import Topic, Article
|
||||
from ..models import Topic, Article, TaskConfig, TaskLog
|
||||
from ..core.generator import run_creator, get_generator_status
|
||||
from ..core.optimizer import run_optimizer, get_optimizer_status
|
||||
from ..core.collector import run_collector, get_collector_status
|
||||
@@ -61,7 +61,7 @@ def get_status(db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
def trigger_generation(topic_id: Optional[str] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
logger.info(f"Generation triggered by {current_user.username}, topic_id={topic_id}")
|
||||
try:
|
||||
result = run_creator(topic_id)
|
||||
@@ -93,7 +93,7 @@ def collection_status():
|
||||
return status
|
||||
|
||||
@router.post("/review/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
def trigger_review(topic_ids: Optional[List[str]] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
try:
|
||||
result = run_optimizer(topic_ids)
|
||||
return {"message": "合规审查已后台启动", "pid": result.get("pid")}
|
||||
@@ -247,47 +247,61 @@ def get_scheduler_status():
|
||||
|
||||
|
||||
@router.get("/modules/status", dependencies=[Depends(get_current_user)])
|
||||
def get_modules_status():
|
||||
today_str = date.today().isoformat()
|
||||
log_based: dict = {
|
||||
"scheduled_collect": {"name": "📡 内容采集", "log": LOGS_DIR / f"collector_{today_str}.log"},
|
||||
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "log": LOGS_DIR / f"opencode_search_{today_str}.log"},
|
||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "log": LOGS_DIR / f"trends_{today_str}.log"},
|
||||
"scheduled_generate": {"name": "🤖 内容创作", "log": LOGS_DIR / f"creator_{today_str}.log"},
|
||||
"scheduled_optimize": {"name": "🔍 合规审查", "log": LOGS_DIR / f"optimizer_{today_str}.log"},
|
||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "log": LOGS_DIR / f"optimizer_sources_{today_str}.log"},
|
||||
"scheduled_metrics_sync": {"name": "📊 指标同步", "log": LOGS_DIR / f"metrics_sync_{today_str}.log"},
|
||||
def get_modules_status(db: Session = Depends(get_db)):
|
||||
configs = db.query(TaskConfig).all()
|
||||
config_map = {c.module_id: c for c in configs}
|
||||
|
||||
MODULE_META = {
|
||||
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "cron": "01:00", "params_desc": {"refresh_queries": "搜索关键词列表"}},
|
||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10", "params_desc": {}},
|
||||
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30", "params_desc": {"max_topics": "最大选题数", "categories": "采集类别"}},
|
||||
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00", "params_desc": {"auto_review": "自动合规审查"}},
|
||||
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00", "params_desc": {"auto_pass_threshold": "自动通过分数阈值"}},
|
||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00", "params_desc": {}},
|
||||
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00", "params_desc": {}},
|
||||
}
|
||||
jobs = {j['id']: j for j in scheduler.get_jobs()}
|
||||
|
||||
modules = []
|
||||
for mod_id, cfg in log_based.items():
|
||||
log_file = cfg["log"]
|
||||
last_run = None
|
||||
task_count = 0
|
||||
success_rate = None
|
||||
if log_file.exists():
|
||||
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
||||
last_run = mtime.strftime("%Y-%m-%d %H:%M")
|
||||
content = log_file.read_text(encoding="utf-8", errors="ignore")
|
||||
task_count = content.count("完成") + content.count("success") + content.count("SUCCESS")
|
||||
total = task_count + content.count("失败") + content.count("failed") + content.count("ERROR")
|
||||
success_rate = round(task_count / total * 100) if total > 0 else None
|
||||
status = "running" if mod_id in jobs else "stopped"
|
||||
job = jobs.get(mod_id)
|
||||
next_run = None
|
||||
if job and job.get("next_run_time"):
|
||||
try:
|
||||
next_dt = datetime.fromisoformat(job["next_run_time"])
|
||||
next_run = next_dt.strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
next_run = job["next_run_time"]
|
||||
for mod_id, meta in MODULE_META.items():
|
||||
cfg = config_map.get(mod_id)
|
||||
latest = db.query(TaskLog).filter(TaskLog.module_id == mod_id).order_by(TaskLog.started_at.desc()).first()
|
||||
next_run = _get_next_run(mod_id)
|
||||
|
||||
total = db.query(TaskLog).filter(TaskLog.module_id == mod_id).count()
|
||||
success = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "success").count()
|
||||
failed = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "failed").count()
|
||||
running = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "running").count()
|
||||
|
||||
modules.append({
|
||||
"id": mod_id,
|
||||
"title": cfg["name"],
|
||||
"status": status,
|
||||
"last_run": last_run or "从未运行",
|
||||
"next_run": next_run or "待计划",
|
||||
"task_count": task_count,
|
||||
"success_rate": success_rate if success_rate is not None else 0,
|
||||
"module_id": mod_id,
|
||||
"title": meta["name"],
|
||||
"enabled": cfg.enabled if cfg else True,
|
||||
"params": cfg.params if cfg else {},
|
||||
"params_desc": meta["params_desc"],
|
||||
"schedule": cfg.schedule if cfg else meta["cron"],
|
||||
"cron_default": meta["cron"],
|
||||
"status": "running" if running else ("stopped" if not (cfg and cfg.enabled) else "idle"),
|
||||
"last_run": latest.started_at.strftime("%Y-%m-%d %H:%M") if latest and latest.started_at else None,
|
||||
"last_status": latest.status if latest else None,
|
||||
"last_message": latest.message if latest else None,
|
||||
"last_result": latest.result_data if latest else None,
|
||||
"next_run": next_run,
|
||||
"total_runs": total,
|
||||
"success_runs": success,
|
||||
"failed_runs": failed,
|
||||
"running": running,
|
||||
})
|
||||
return {"modules": modules, "scheduler": {"running": scheduler._started, "jobs": scheduler.get_jobs()}}
|
||||
|
||||
jobs = scheduler.get_jobs()
|
||||
return {"modules": modules, "scheduler": {"running": scheduler._started, "jobs": jobs}}
|
||||
|
||||
|
||||
def _get_next_run(mod_id: str) -> Optional[str]:
|
||||
for job in scheduler.get_jobs():
|
||||
if job["id"] == mod_id and job["next_run_time"]:
|
||||
try:
|
||||
dt = datetime.fromisoformat(job["next_run_time"])
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
return job["next_run_time"]
|
||||
return None
|
||||
|
||||
@@ -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 import or_
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import TaskLog
|
||||
from ..schemas import TaskLogBase, TaskLogResponse
|
||||
from .auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/api/admin/tasklogs", tags=["admin"])
|
||||
router = APIRouter(prefix="/api/admin/task-logs", tags=["admin"])
|
||||
|
||||
MODULES = {
|
||||
"scheduled_refresh_search_cache": "🔍 搜索缓存",
|
||||
"scheduled_fetch_trends": "🔥 热点趋势",
|
||||
"scheduled_collect": "📡 内容采集",
|
||||
"scheduled_generate": "🤖 内容创作",
|
||||
"scheduled_optimize": "🔍 合规审查",
|
||||
"scheduled_optimize_sources": "📡 信息源优化",
|
||||
"scheduled_metrics_sync": "📊 指标同步",
|
||||
}
|
||||
|
||||
@router.get("", response_model=List[TaskLogResponse])
|
||||
def list_task_logs(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user = Depends(get_current_admin),
|
||||
topic_id: Optional[str] = None,
|
||||
task_name: Optional[str] = None,
|
||||
status: Optional[str] = None
|
||||
admin_user=Depends(get_current_admin),
|
||||
module_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
date: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""获取任务日志列表(可过滤)"""
|
||||
query = db.query(TaskLog)
|
||||
if topic_id:
|
||||
query = query.filter(TaskLog.topic_id == topic_id)
|
||||
if task_name:
|
||||
query = query.filter(TaskLog.task_name == task_name)
|
||||
if module_id:
|
||||
query = query.filter(TaskLog.module_id == module_id)
|
||||
if status:
|
||||
query = query.filter(TaskLog.status == status)
|
||||
logs = query.order_by(TaskLog.started_at.desc()).all()
|
||||
if date:
|
||||
try:
|
||||
dt = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
next_day = datetime(dt.year, dt.month, dt.day + 1, tzinfo=timezone.utc)
|
||||
query = query.filter(TaskLog.started_at >= dt, TaskLog.started_at < next_day)
|
||||
except ValueError:
|
||||
pass
|
||||
logs = query.order_by(TaskLog.started_at.desc()).limit(limit).all()
|
||||
return [TaskLogResponse.model_validate(l) for l in logs]
|
||||
|
||||
@router.get("/modules")
|
||||
def list_modules(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
result = []
|
||||
for mid, name in MODULES.items():
|
||||
latest = db.query(TaskLog).filter(TaskLog.module_id == mid).order_by(TaskLog.started_at.desc()).first()
|
||||
total = db.query(TaskLog).filter(TaskLog.module_id == mid).count()
|
||||
success = db.query(TaskLog).filter(TaskLog.module_id == mid, TaskLog.status == "success").count()
|
||||
failed = db.query(TaskLog).filter(TaskLog.module_id == mid, TaskLog.status == "failed").count()
|
||||
running = db.query(TaskLog).filter(TaskLog.module_id == mid, TaskLog.status == "running").count()
|
||||
result.append({
|
||||
"module_id": mid,
|
||||
"name": name,
|
||||
"last_run": latest.started_at.isoformat() if latest and latest.started_at else None,
|
||||
"last_status": latest.status if latest else None,
|
||||
"last_message": latest.message if latest else None,
|
||||
"total_runs": total,
|
||||
"success_runs": success,
|
||||
"failed_runs": failed,
|
||||
"running": running,
|
||||
})
|
||||
return result
|
||||
|
||||
@router.get("/{log_id}", response_model=TaskLogResponse)
|
||||
def get_task_log(
|
||||
log_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user = Depends(get_current_admin)
|
||||
):
|
||||
"""获取单个任务日志详情"""
|
||||
def get_task_log(log_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
return log
|
||||
|
||||
@router.post("", response_model=TaskLogResponse)
|
||||
def create_task_log(
|
||||
log_data: TaskLogBase,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user = Depends(get_current_admin)
|
||||
):
|
||||
"""创建任务日志(用于手动记录)"""
|
||||
def create_task_log(log_data: TaskLogBase, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||
log = TaskLog(**log_data.model_dump())
|
||||
db.add(log)
|
||||
db.commit()
|
||||
@@ -57,35 +84,22 @@ def create_task_log(
|
||||
return log
|
||||
|
||||
@router.put("/{log_id}", response_model=TaskLogResponse)
|
||||
def update_task_log(
|
||||
log_id: int,
|
||||
log_update: TaskLogBase,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user = Depends(get_current_admin)
|
||||
):
|
||||
"""更新任务日志"""
|
||||
def update_task_log(log_id: int, log_update: TaskLogBase, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
update_data = log_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
data = log_update.model_dump(exclude_unset=True)
|
||||
for field, value in data.items():
|
||||
setattr(log, field, value)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
return log
|
||||
|
||||
@router.delete("/{log_id}")
|
||||
def delete_task_log(
|
||||
log_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user = Depends(get_current_admin)
|
||||
):
|
||||
"""删除任务日志"""
|
||||
def delete_task_log(log_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
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"
|
||||
today_str = date_mod.today().isoformat()
|
||||
|
||||
try:
|
||||
return _get_module_detail_data(module_id, db, ROOT, DATA_DIR, LOGS_DIR, today_str)
|
||||
except Exception as e:
|
||||
return {
|
||||
"module_id": module_id,
|
||||
"title": module_id,
|
||||
"description": "",
|
||||
"status": "stopped",
|
||||
"inputs": {},
|
||||
"outputs": {"error": str(e)},
|
||||
"history": [],
|
||||
"log_excerpt": "",
|
||||
}
|
||||
|
||||
def _get_module_detail_data(module_id: str, db, ROOT, DATA_DIR, LOGS_DIR, today_str):
|
||||
from datetime import datetime as dt_mod, date as date_mod
|
||||
from pathlib import Path as PathMod
|
||||
import json as json_mod
|
||||
import re as re_mod
|
||||
MODULE_META = {
|
||||
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "description": "通过 opencode webfetch 联网搜索,刷新 8 个分类的搜索缓存,供内容采集器使用"},
|
||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "description": "从百度、微博、知乎实时热搜 API 抓取当天热点,LLM 补充,存入 trends.json"},
|
||||
@@ -279,7 +298,7 @@ def get_module_detail(module_id: str, db: Session = Depends(get_db), current_use
|
||||
pass
|
||||
|
||||
elif module_id == "scheduled_collect":
|
||||
from ..models import Topic, CollectorCategory
|
||||
from ..models import CollectorCategory
|
||||
pending = db.query(Topic).filter(Topic.status.in_(["pending", "待处理"])).count()
|
||||
total_topics = db.query(Topic).count()
|
||||
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
|
||||
@@ -296,16 +315,19 @@ def get_module_detail(module_id: str, db: Session = Depends(get_db), current_use
|
||||
pending_t = db.query(Topic).filter(Topic.status.in_(["pending", "待处理"])).count()
|
||||
inputs["待创作选题"] = pending_t
|
||||
outputs["待审查"] = review
|
||||
from ..models import Article
|
||||
recent_articles = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id, isouter=True).order_by(Article.created_at.desc()).limit(5).all()
|
||||
outputs["最新文章"] = []
|
||||
seen_articles = set()
|
||||
for a in recent_articles:
|
||||
art = a.Article if hasattr(a, 'Article') else a[0]
|
||||
tid = a.topic_title if hasattr(a, 'topic_title') else (a[1] if len(a) > 1 else "")
|
||||
if art.id not in seen_articles:
|
||||
seen_articles.add(art.id)
|
||||
outputs["最新文章"].append({"id": art.id, "platform": art.platform, "topic": tid, "status": art.status, "created": art.created_at.isoformat() if art.created_at else ""})
|
||||
try:
|
||||
from ..models import Article
|
||||
recent_articles = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id, isouter=True).order_by(Article.created_at.desc()).limit(5).all()
|
||||
outputs["最新文章"] = []
|
||||
seen_articles = set()
|
||||
for a in recent_articles:
|
||||
art = a[0]
|
||||
tid = a[1] if len(a) > 1 else ""
|
||||
if art.id not in seen_articles:
|
||||
seen_articles.add(art.id)
|
||||
outputs["最新文章"].append({"id": art.id, "platform": art.platform, "topic": tid, "status": art.status, "created": art.created_at.isoformat() if art.created_at else ""})
|
||||
except Exception as e:
|
||||
outputs["最新文章_错误"] = str(e)
|
||||
|
||||
elif module_id == "scheduled_optimize":
|
||||
outputs["待审查选题"] = db.query(Topic).filter(Topic.status.in_(["review", "待审查"])).count()
|
||||
|
||||
@@ -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 生命周期内运行定时任务
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import os, sys, logging, json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT / 'scripts'))
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
from prompt_loader import get_prompt, get_prompt_params
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from .generator import run_creator_blocking
|
||||
@@ -15,6 +19,69 @@ from .collector import run_collector_blocking
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODULES = {
|
||||
"scheduled_refresh_search_cache": {"name": "🔍 搜索缓存", "cron": "01:00"},
|
||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"},
|
||||
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30"},
|
||||
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00"},
|
||||
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00"},
|
||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00"},
|
||||
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"},
|
||||
}
|
||||
|
||||
def _log_task(module_id: str, status: str, message: str = None,
|
||||
error_trace: str = None, result_data: dict = None,
|
||||
started_at: datetime = None, finished_at: datetime = None,
|
||||
triggered_by: str = "scheduler", next_run_time: datetime = None):
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import TaskLog
|
||||
db = SessionLocal()
|
||||
try:
|
||||
duration = None
|
||||
if started_at and finished_at:
|
||||
duration = int((finished_at - started_at).total_seconds())
|
||||
log = TaskLog(
|
||||
module_id=module_id,
|
||||
task_name=MODULES.get(module_id, {}).get("name", module_id),
|
||||
status=status,
|
||||
message=message,
|
||||
error_trace=error_trace,
|
||||
triggered_by=triggered_by,
|
||||
result_data=result_data or {},
|
||||
started_at=started_at or datetime.now(timezone.utc),
|
||||
finished_at=finished_at,
|
||||
duration=duration,
|
||||
next_run_time=next_run_time,
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _wrap_task(module_id: str, target_fn, *args, **kwargs):
|
||||
started = datetime.now(timezone.utc)
|
||||
status = "running"
|
||||
error_trace = None
|
||||
result_data = None
|
||||
try:
|
||||
result = target_fn(*args, **kwargs)
|
||||
status = "success"
|
||||
if isinstance(result, dict):
|
||||
result_data = {k: v for k, v in result.items() if isinstance(v, (str, int, float, bool, list, dict)) and k not in ("stdout", "stderr")}
|
||||
return result
|
||||
except Exception as e:
|
||||
status = "failed"
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
raise
|
||||
finally:
|
||||
_log_task(module_id, status=status, message=None, error_trace=error_trace,
|
||||
result_data=result_data, started_at=started,
|
||||
finished_at=datetime.now(timezone.utc))
|
||||
|
||||
class TaskScheduler:
|
||||
def __init__(self):
|
||||
self.scheduler = BackgroundScheduler()
|
||||
@@ -24,64 +91,47 @@ class TaskScheduler:
|
||||
if self._started:
|
||||
logger.warning("Scheduler already started")
|
||||
return
|
||||
# 使用 CronTrigger 设置每日固定时间点
|
||||
# 顺序: 搜索缓存(01:00)→趋势(01:10)→采集(01:30)→创作(02:00)→审查(03:00)→源优化(05:00)→指标(06:00)
|
||||
self.scheduler.add_job(
|
||||
self._run_refresh_search_cache,
|
||||
CronTrigger(hour=1, minute=0),
|
||||
id='scheduled_refresh_search_cache',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_fetch_trends,
|
||||
CronTrigger(hour=1, minute=10),
|
||||
id='scheduled_fetch_trends',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_collect,
|
||||
CronTrigger(hour=1, minute=30),
|
||||
id='scheduled_collect',
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_generate,
|
||||
CronTrigger(hour=2, minute=0),
|
||||
id='scheduled_generate',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_optimize,
|
||||
CronTrigger(hour=3, minute=0),
|
||||
id='scheduled_optimize',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_optimize_sources,
|
||||
CronTrigger(hour=5, minute=0),
|
||||
id='scheduled_optimize_sources',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_metrics_sync,
|
||||
CronTrigger(hour=6, minute=0),
|
||||
id='scheduled_metrics_sync',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
from ..database import SessionLocal
|
||||
from ..models import TaskConfig
|
||||
db = SessionLocal()
|
||||
try:
|
||||
configs = {c.module_id: c for c in db.query(TaskConfig).all()}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
MODULE_JOBS = [
|
||||
("scheduled_refresh_search_cache", self._run_refresh_search_cache, "搜索缓存"),
|
||||
("scheduled_fetch_trends", self._run_fetch_trends, "热点趋势"),
|
||||
("scheduled_collect", self._run_collect, "内容采集"),
|
||||
("scheduled_generate", self._run_generate, "内容创作"),
|
||||
("scheduled_optimize", self._run_optimize, "合规审查"),
|
||||
("scheduled_optimize_sources", self._run_optimize_sources, "信息源优化"),
|
||||
("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"),
|
||||
]
|
||||
|
||||
for module_id, fn, name in MODULE_JOBS:
|
||||
cfg = configs.get(module_id)
|
||||
if cfg and not cfg.enabled:
|
||||
logger.info(f"跳过禁用任务: {module_id}")
|
||||
continue
|
||||
schedule = (cfg.schedule if cfg else None) or MODULES.get(module_id, {}).get("cron", "01:00")
|
||||
try:
|
||||
hour, minute = map(int, schedule.split(":"))
|
||||
except (ValueError, AttributeError):
|
||||
hour, minute = 1, 0
|
||||
self.scheduler.add_job(
|
||||
fn,
|
||||
CronTrigger(hour=hour, minute=minute),
|
||||
id=module_id,
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
logger.info(f"调度任务: {module_id} -> {schedule}")
|
||||
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
logger.info("Scheduler started: 01:00 search 01:10 trends 01:30 collect 02:00 create 03:00 review 05:00 sources 06:00 metrics")
|
||||
logger.info("Scheduler started with dynamic schedule from TaskConfig")
|
||||
def shutdown(self):
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
@@ -89,6 +139,8 @@ class TaskScheduler:
|
||||
|
||||
def _run_fetch_trends(self):
|
||||
"""定时刷新热点趋势(百度/微博/知乎实时热搜 + LLM补充)"""
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_fetch_trends", "running", started_at=started)
|
||||
try:
|
||||
logger.info("[Scheduled] Fetching hot trends...")
|
||||
import subprocess
|
||||
@@ -100,14 +152,26 @@ class TaskScheduler:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
logger.info("[Trends] %s", line.strip())
|
||||
logger.info("[Scheduled] Trends refreshed successfully")
|
||||
_log_task("scheduled_fetch_trends", "success",
|
||||
message="趋势刷新成功",
|
||||
result_data={"output_lines": len(result.stdout.splitlines())},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
else:
|
||||
logger.warning("[Scheduled] Trends refresh failed: %s", result.stderr[-500:])
|
||||
_log_task("scheduled_fetch_trends", "failed",
|
||||
message=f"返回码 {result.returncode}",
|
||||
error_trace=result.stderr[-500:],
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
except Exception as e:
|
||||
_log_task("scheduled_fetch_trends", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Trends refresh error: %s", e)
|
||||
|
||||
def _run_refresh_search_cache(self):
|
||||
"""定时刷新搜索缓存(通过 opencode webfetch)"""
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_refresh_search_cache", "running", started_at=started)
|
||||
try:
|
||||
logger.info("[Scheduled] Refreshing search cache via opencode...")
|
||||
import subprocess
|
||||
@@ -122,48 +186,95 @@ class TaskScheduler:
|
||||
if line.strip():
|
||||
logger.warning("[SearchCache] %s", line.strip())
|
||||
if result.returncode == 0:
|
||||
_log_task("scheduled_refresh_search_cache", "success",
|
||||
message="搜索缓存刷新成功",
|
||||
result_data={"output_lines": len(result.stdout.splitlines())},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.info("[Scheduled] Search cache refreshed")
|
||||
else:
|
||||
_log_task("scheduled_refresh_search_cache", "failed",
|
||||
message="部分失败",
|
||||
error_trace=result.stderr[-500:],
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.warning("[Scheduled] Search cache refresh may have partial failures")
|
||||
except subprocess.TimeoutExpired:
|
||||
_log_task("scheduled_refresh_search_cache", "failed",
|
||||
message="超时",
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.warning("[Scheduled] Search cache refresh timed out")
|
||||
except Exception as e:
|
||||
_log_task("scheduled_refresh_search_cache", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Search cache refresh error: %s", e)
|
||||
|
||||
def _run_generate(self):
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_generate", "running", started_at=started)
|
||||
try:
|
||||
logger.info("[Scheduled] Starting content generation...")
|
||||
result = run_creator_blocking()
|
||||
logger.info("[Scheduled] Generation completed: %s", result)
|
||||
created_id = result.get("topic_id") if isinstance(result, dict) else None
|
||||
review_result = None
|
||||
if created_id:
|
||||
logger.info("[Scheduled] Running compliance review on %s...", created_id)
|
||||
review_result = run_optimizer_blocking([created_id])
|
||||
if review_result.get("ok"):
|
||||
logger.info("[Scheduled] Review completed for %s", created_id)
|
||||
else:
|
||||
logger.warning("[Scheduled] Review failed: %s", review_result.get("error"))
|
||||
_log_task("scheduled_generate", "success",
|
||||
message=f"创作完成" + (f", 选题 {created_id}" if created_id else ""),
|
||||
result_data={"topic_id": created_id, "review_ok": review_result.get("ok") if review_result else None},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
except Exception as e:
|
||||
_log_task("scheduled_generate", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Generation pipeline failed: %s", e)
|
||||
|
||||
def _run_optimize(self):
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_optimize", "running", started_at=started)
|
||||
try:
|
||||
logger.info("[Scheduled] Starting compliance review...")
|
||||
result = run_optimizer_blocking()
|
||||
_log_task("scheduled_optimize", "success",
|
||||
message="合规审查完成",
|
||||
result_data={"processed": result.get("processed", 0), "passed": result.get("passed", 0)},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.info("[Scheduled] Review completed: %s", result)
|
||||
except Exception as e:
|
||||
_log_task("scheduled_optimize", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Review failed: %s", e)
|
||||
|
||||
def _run_collect(self):
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_collect", "running", started_at=started)
|
||||
try:
|
||||
logger.info("[Scheduled] Starting topic collection...")
|
||||
result = run_collector_blocking()
|
||||
topics_count = result.get("topics_count", 0)
|
||||
_log_task("scheduled_collect", "success",
|
||||
message=f"采集完成,找到 {topics_count} 个选题",
|
||||
result_data={"topics_count": topics_count, "output": str(result.get("output", ""))[:200]},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.info("[Scheduled] Collection completed: %s", result.get("output", "")[-200:])
|
||||
except Exception as e:
|
||||
_log_task("scheduled_collect", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Collection failed: %s", e)
|
||||
|
||||
def _run_optimize_sources(self):
|
||||
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_optimize_sources", "running", started_at=started)
|
||||
try:
|
||||
logger.info("[Scheduled] Starting source optimization with AI...")
|
||||
from .nvidia_client import call_llm
|
||||
@@ -183,32 +294,16 @@ class TaskScheduler:
|
||||
cat_names = [c.name for c in cats]
|
||||
src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources)
|
||||
|
||||
prompt = f"""你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。
|
||||
prompt = get_prompt("sources_optimization",
|
||||
n=len(cat_names),
|
||||
cat_names="\n".join(f"- {n}" for n in cat_names),
|
||||
n2=len(sources),
|
||||
src_summary=src_summary,
|
||||
year=datetime.now().year,
|
||||
)
|
||||
|
||||
当前配置的类别({len(cat_names)}个):
|
||||
{chr(10).join(f'- {n}' for n in cat_names)}
|
||||
|
||||
当前配置的信息源({len(sources)}个):
|
||||
{src_summary}
|
||||
|
||||
请完成以下任务:
|
||||
1. 评估每个类别是否仍符合2026年中国市场真实热点(基于你的知识)
|
||||
2. 评估每个信息源是否可能在中国正常访问
|
||||
3. 建议新增或删除的类别(最多2条)
|
||||
4. 建议新增的信息源搜索词(最多3条,包含具体搜索词)
|
||||
|
||||
输出 JSON 格式:
|
||||
{{
|
||||
"category_assessment": [{{"name": "类别名", "status": "保留/淘汰/合并", "reason": "原因"}}],
|
||||
"source_assessment": [{{"name": "源名", "status": "保留/淘汰/替换", "reason": "原因"}}],
|
||||
"suggested_new_categories": [{{"name": "类别名", "search_query": "搜索词", "reason": "推荐原因"}}],
|
||||
"suggested_new_sources": [{{"name": "源名", "type": "web_search", "query": "搜索词", "focus": "聚焦领域"}}],
|
||||
"summary": "一句话总结本次优化建议"
|
||||
}}
|
||||
|
||||
只输出JSON,不要其他文字。"""
|
||||
|
||||
resp = call_llm(prompt, temperature=0.5, max_tokens=2000)
|
||||
params = get_prompt_params("sources_optimization")
|
||||
resp = call_llm(prompt, temperature=params.get("temperature", 0.5), max_tokens=params.get("max_tokens", 3000))
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
result = json.loads(resp)
|
||||
@@ -222,12 +317,25 @@ class TaskScheduler:
|
||||
db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
|
||||
db.commit()
|
||||
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
|
||||
_log_task("scheduled_optimize_sources", "success",
|
||||
message=result.get("summary", "优化完成"),
|
||||
result_data={"categories_assessed": len(result.get("category_assessment", [])),
|
||||
"sources_assessed": len(result.get("source_assessment", [])),
|
||||
"suggested_cats": len(result.get("suggested_new_categories", [])),
|
||||
"suggested_srcs": len(result.get("suggested_new_sources", []))},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
db.close()
|
||||
except Exception as e:
|
||||
_log_task("scheduled_optimize_sources", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
|
||||
|
||||
def _run_metrics_sync(self):
|
||||
"""定时从各平台公开API获取发布文章的效果数据(当前仅支持知乎)"""
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_metrics_sync", "running", started_at=started)
|
||||
try:
|
||||
logger.info("[Scheduled] Starting metrics sync (zhihu auto-fetch)...")
|
||||
from ..database import SessionLocal
|
||||
@@ -286,6 +394,10 @@ class TaskScheduler:
|
||||
if count:
|
||||
db.commit()
|
||||
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
|
||||
_log_task("scheduled_metrics_sync", "success",
|
||||
message=f"同步完成,{count} 篇知乎文章",
|
||||
result_data={"articles_synced": count},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
# 生成指标反馈:按 field 聚合表现,写入 metrics_feedback.json 供 collector 读取
|
||||
try:
|
||||
import json as json_mod
|
||||
@@ -317,9 +429,16 @@ class TaskScheduler:
|
||||
except Exception as e_fb:
|
||||
logger.warning("[Scheduled] Metrics feedback generation failed: %s", e_fb)
|
||||
else:
|
||||
_log_task("scheduled_metrics_sync", "success",
|
||||
message="无已发布的知乎文章",
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.info("[Scheduled] Metrics sync: no zhihu articles to sync")
|
||||
db.close()
|
||||
except Exception as e:
|
||||
_log_task("scheduled_metrics_sync", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Metrics sync failed: %s", e)
|
||||
|
||||
def get_jobs(self):
|
||||
|
||||
@@ -61,6 +61,49 @@ def init_db():
|
||||
("platform_configs", "min_words", "INTEGER DEFAULT 0"),
|
||||
("platform_configs", "max_words", "INTEGER DEFAULT 0"),
|
||||
("platform_configs", "website_url", "VARCHAR"),
|
||||
("task_logs", "module_id", "VARCHAR"),
|
||||
("task_logs", "error_trace", "TEXT"),
|
||||
("task_logs", "triggered_by", "VARCHAR DEFAULT 'scheduler'"),
|
||||
("task_logs", "result_data", "JSON DEFAULT '{}'::json"),
|
||||
("task_logs", "next_run_time", "TIMESTAMP"),
|
||||
("task_configs", "module_id", "VARCHAR UNIQUE"),
|
||||
("task_configs", "enabled", "BOOLEAN DEFAULT TRUE"),
|
||||
("task_configs", "params", "JSON DEFAULT '{}'::json"),
|
||||
("task_configs", "schedule", "VARCHAR"),
|
||||
("task_configs", "last_modified_by", "VARCHAR"),
|
||||
("prompt_configs", "key", "VARCHAR UNIQUE"),
|
||||
("prompt_configs", "module_id", "VARCHAR"),
|
||||
("prompt_configs", "category", "VARCHAR DEFAULT 'prompt'"),
|
||||
("prompt_configs", "version", "VARCHAR DEFAULT 'v1'"),
|
||||
("prompt_configs", "content", "TEXT"),
|
||||
("prompt_configs", "variables", "JSON DEFAULT '[]'::json"),
|
||||
("prompt_configs", "description", "VARCHAR"),
|
||||
("prompt_configs", "enabled", "BOOLEAN DEFAULT TRUE"),
|
||||
("prompt_configs", "temperature", "FLOAT"),
|
||||
("prompt_configs", "max_tokens", "INTEGER"),
|
||||
("prompt_configs", "created_by", "VARCHAR"),
|
||||
("keyword_domain_map", "id", "INTEGER PRIMARY KEY"),
|
||||
("keyword_domain_map", "pattern", "VARCHAR"),
|
||||
("keyword_domain_map", "domain", "VARCHAR"),
|
||||
("keyword_domain_map", "sort_order", "INTEGER DEFAULT 0"),
|
||||
("keyword_domain_map", "is_active", "BOOLEAN DEFAULT TRUE"),
|
||||
("sensitive_words", "id", "INTEGER PRIMARY KEY"),
|
||||
("sensitive_words", "word", "VARCHAR"),
|
||||
("sensitive_words", "category", "VARCHAR DEFAULT 'general'"),
|
||||
("sensitive_words", "is_active", "BOOLEAN DEFAULT TRUE"),
|
||||
("sensitive_words", "added_by", "VARCHAR"),
|
||||
("content_clean_rules", "id", "INTEGER PRIMARY KEY"),
|
||||
("content_clean_rules", "rule_type", "VARCHAR"),
|
||||
("content_clean_rules", "pattern", "TEXT"),
|
||||
("content_clean_rules", "description", "VARCHAR"),
|
||||
("content_clean_rules", "is_active", "BOOLEAN DEFAULT TRUE"),
|
||||
("content_clean_rules", "sort_order", "INTEGER DEFAULT 0"),
|
||||
("collector_categories", "pain_template", "TEXT"),
|
||||
("trend_field_mappings", "id", "INTEGER PRIMARY KEY"),
|
||||
("trend_field_mappings", "trend_keyword", "VARCHAR"),
|
||||
("trend_field_mappings", "field_name", "VARCHAR"),
|
||||
("trend_field_mappings", "sort_order", "INTEGER DEFAULT 0"),
|
||||
("trend_field_mappings", "is_active", "BOOLEAN DEFAULT TRUE"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
from .database import engine, get_db, init_db
|
||||
from .models import Base
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items
|
||||
from .initial_data import import_initial_data
|
||||
from .core.scheduler import scheduler
|
||||
|
||||
@@ -83,6 +83,8 @@ app.include_router(admin.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(optimizer_logs.router)
|
||||
app.include_router(cases.router)
|
||||
app.include_router(task_configs.router)
|
||||
app.include_router(prompt_configs.router)
|
||||
app.include_router(task_logs.router)
|
||||
app.include_router(llm_configs.router)
|
||||
app.include_router(system_configs.router)
|
||||
@@ -94,6 +96,7 @@ app.include_router(tasks.router)
|
||||
app.include_router(platform_config.router)
|
||||
app.include_router(collector_mgmt.router)
|
||||
app.include_router(assistant.router)
|
||||
app.include_router(config_items.router)
|
||||
|
||||
# 挂载自动生成的图片(必须先于前端根挂载)
|
||||
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
|
||||
|
||||
@@ -471,24 +471,59 @@ class TaskLog(Base):
|
||||
__tablename__ = "task_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
module_id = Column(String, nullable=False, index=True) # scheduled_collect / scheduled_generate 等
|
||||
task_name = Column(String, nullable=False)
|
||||
topic_id = Column(String, nullable=True)
|
||||
status = Column(String, nullable=False)
|
||||
topic_id = Column(String, nullable=True, index=True)
|
||||
status = Column(String, nullable=False) # pending / running / success / failed / cancelled
|
||||
message = Column(Text, nullable=True)
|
||||
error_trace = Column(Text, nullable=True)
|
||||
triggered_by = Column(String, default="scheduler") # scheduler / manual / api
|
||||
result_data = Column(JSON, default=dict) # 产出摘要:{topics_found, articles_created, issues_fixed, ...}
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||
duration = Column(Integer, nullable=True)
|
||||
duration = Column(Integer, nullable=True) # seconds
|
||||
next_run_time = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"module_id": self.module_id,
|
||||
"task_name": self.task_name,
|
||||
"topic_id": self.topic_id,
|
||||
"status": self.status,
|
||||
"message": self.message,
|
||||
"error_trace": self.error_trace,
|
||||
"triggered_by": self.triggered_by,
|
||||
"result_data": self.result_data or {},
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
||||
"duration": self.duration,
|
||||
"next_run_time": self.next_run_time.isoformat() if self.next_run_time else None,
|
||||
}
|
||||
|
||||
|
||||
class TaskConfig(Base):
|
||||
__tablename__ = "task_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
module_id = Column(String, unique=True, nullable=False)
|
||||
enabled = Column(Boolean, default=True)
|
||||
params = Column(JSON, default=dict) # 各任务自定义参数,JSON 格式
|
||||
schedule = Column(String, nullable=True) # cron 表达式,覆盖默认
|
||||
last_modified_by = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"module_id": self.module_id,
|
||||
"enabled": self.enabled,
|
||||
"params": self.params or {},
|
||||
"schedule": self.schedule,
|
||||
"last_modified_by": self.last_modified_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -527,6 +562,101 @@ class LLMConfig(Base):
|
||||
}
|
||||
|
||||
|
||||
class PromptConfig(Base):
|
||||
__tablename__ = "prompt_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
key = Column(String, unique=True, nullable=False, index=True)
|
||||
module_id = Column(String, nullable=True, index=True)
|
||||
category = Column(String, default="prompt") # prompt / rule / template
|
||||
version = Column(String, default="v1")
|
||||
content = Column(Text, nullable=False)
|
||||
variables = Column(JSON, default=[]) # [{name, description, default_value}]
|
||||
description = Column(String, nullable=True)
|
||||
enabled = Column(Boolean, default=True)
|
||||
temperature = Column(Float, nullable=True)
|
||||
max_tokens = Column(Integer, nullable=True)
|
||||
created_by = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"key": self.key,
|
||||
"module_id": self.module_id,
|
||||
"category": self.category,
|
||||
"version": self.version,
|
||||
"content": self.content,
|
||||
"variables": self.variables or [],
|
||||
"description": self.description,
|
||||
"enabled": self.enabled,
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"created_by": self.created_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class KeywordDomainMap(Base):
|
||||
__tablename__ = "keyword_domain_map"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
pattern = Column(String, nullable=False)
|
||||
domain = Column(String, nullable=False)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "pattern": self.pattern, "domain": self.domain,
|
||||
"sort_order": self.sort_order, "is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class SensitiveWord(Base):
|
||||
__tablename__ = "sensitive_words"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
word = Column(String, nullable=False)
|
||||
category = Column(String, default="general")
|
||||
is_active = Column(Boolean, default=True)
|
||||
added_by = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "word": self.word, "category": self.category,
|
||||
"is_active": self.is_active, "added_by": self.added_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ContentCleanRule(Base):
|
||||
__tablename__ = "content_clean_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
rule_type = Column(String, nullable=False) # thinking / preface / verbosity / html_thinking
|
||||
pattern = Column(Text, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "rule_type": self.rule_type, "pattern": self.pattern,
|
||||
"description": self.description, "is_active": self.is_active,
|
||||
"sort_order": self.sort_order,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_configs"
|
||||
|
||||
@@ -557,6 +687,7 @@ class CollectorCategory(Base):
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
search_query = Column(String, nullable=True)
|
||||
pain_template = Column(Text, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -570,6 +701,7 @@ class CollectorCategory(Base):
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"search_query": self.search_query,
|
||||
"pain_template": self.pain_template,
|
||||
"sort_order": self.sort_order,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
@@ -577,6 +709,24 @@ class CollectorCategory(Base):
|
||||
}
|
||||
|
||||
|
||||
class TrendFieldMapping(Base):
|
||||
__tablename__ = "trend_field_mappings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
trend_keyword = Column(String, nullable=False)
|
||||
field_name = Column(String, nullable=False)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "trend_keyword": self.trend_keyword, "field_name": self.field_name,
|
||||
"sort_order": self.sort_order, "is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CollectorSource(Base):
|
||||
"""采集信息源(可在运营管理中动态编辑)"""
|
||||
__tablename__ = "collector_sources"
|
||||
|
||||
@@ -458,13 +458,18 @@ class CaseResponse(CaseBase):
|
||||
|
||||
|
||||
class TaskLogBase(BaseModel):
|
||||
module_id: str
|
||||
task_name: str
|
||||
topic_id: Optional[str] = None
|
||||
status: str
|
||||
message: Optional[str] = None
|
||||
error_trace: Optional[str] = None
|
||||
triggered_by: str = "scheduler"
|
||||
result_data: Dict[str, Any] = {}
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
duration: Optional[int] = None
|
||||
next_run_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class TaskLogResponse(TaskLogBase):
|
||||
@@ -473,6 +478,29 @@ class TaskLogResponse(TaskLogBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TaskConfigBase(BaseModel):
|
||||
module_id: str
|
||||
enabled: bool = True
|
||||
params: Dict[str, Any] = {}
|
||||
schedule: Optional[str] = None
|
||||
|
||||
|
||||
class TaskConfigUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
schedule: Optional[str] = None
|
||||
last_modified_by: Optional[str] = None
|
||||
|
||||
|
||||
class TaskConfigResponse(TaskConfigBase):
|
||||
id: int
|
||||
last_modified_by: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class LLMConfigBase(BaseModel):
|
||||
name: str
|
||||
system_prompt: Optional[str] = None
|
||||
|
||||
+148
-461
@@ -22,65 +22,70 @@
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
|
||||
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
|
||||
<el-button size="default" :type="activeTab === 'platformconfigs' ? 'primary' : ''" @click="switchTab('platformconfigs')">平台配置</el-button>
|
||||
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
|
||||
<el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
|
||||
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
|
||||
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
||||
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'cases'">
|
||||
<div v-if="activeTab === 'users'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showCaseDialog()">新增案例</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ cases.length }} 个</span>
|
||||
<el-button type="primary" size="small" @click="addUser">新增用户</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ users.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="casesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="cases.length === 0">
|
||||
<div v-if="usersLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="users.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
|
||||
<div class="empty-text">暂无案例数据</div>
|
||||
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUser /></el-icon>
|
||||
<div class="empty-text">暂无用户数据</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="stat-summary">
|
||||
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
|
||||
</div>
|
||||
<el-table :data="paginatedCases" border stripe class="data-table" style="width:100%">
|
||||
<el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="70"></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="80" :show-overflow-tooltip="true"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="140"></el-table-column>
|
||||
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="140"></el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<el-table-column prop="username" label="用户名" min-width="120"></el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="80"></el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="cases.length > casePageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="cases.length" :page-size="casePageSize" :current-page="casePage" @current-change="casePage = $event"></el-pagination>
|
||||
<div v-if="users.length > userPageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="users.length" :page-size="userPageSize" :current-page="userPage" @current-change="userPage = $event"></el-pagination>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in paginatedCases" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
|
||||
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
|
||||
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
|
||||
<div v-for="item in paginatedUsers" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">用户名</span><span class="card-value">{{ item.username }}</span></div>
|
||||
<div class="card-row"><span class="card-label">角色</span><span class="card-value">{{ item.role }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteUser(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="userDialogVisible" title="新增用户" width="450px" :close-on-click-modal="false">
|
||||
<el-form :model="userForm" label-width="80px">
|
||||
<el-form-item label="用户名"><el-input v-model="userForm.username" placeholder="至少2个字符"/></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="userForm.password" type="password" show-password placeholder="至少6位"/></el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="userForm.role" style="width:100%">
|
||||
<el-option label="编辑" value="editor"></el-option>
|
||||
<el-option label="管理员" value="admin"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="userDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUser" :loading="userSubmitting">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'tasklogs'">
|
||||
@@ -173,43 +178,87 @@
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'platformconfigs'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showPlatformConfigDialog()">新增配置</el-button>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconGlobe /></el-icon> 平台配置</h2>
|
||||
<div class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button :type="pcShowActiveOnly ? 'primary' : ''" @click="pcShowActiveOnly = true; loadPlatformConfigs()">启用中</el-button>
|
||||
<el-button :type="!pcShowActiveOnly ? 'primary' : ''" @click="pcShowActiveOnly = false; loadPlatformConfigs()">全部</el-button>
|
||||
</el-button-group>
|
||||
<el-button type="primary" size="small" @click="showPcDialogFn()">新增配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="platformConfigsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="platformConfigs.length === 0">
|
||||
<template v-else-if="!platformConfigs || platformConfigs.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
|
||||
<div class="empty-text">暂无平台配置</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="platformConfigs" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="platform" label="标识" width="80"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" width="80"></el-table-column>
|
||||
<el-table-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>
|
||||
</el-table-column>
|
||||
<el-table-column label="字数范围" width="120">
|
||||
<template #default="scope">{{ scope.row.min_words }} - {{ scope.row.max_words }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配图" width="100">
|
||||
<template #default="scope">{{ scope.row.requires_image ? `${scope.row.image_count_min}-${scope.row.image_count_max}张` : '不需要' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="api_endpoint" label="API地址" min-width="160"></el-table-column>
|
||||
<el-table-column prop="default_format" label="默认格式" min-width="160">
|
||||
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 30) }}{{ (scope.row.default_format || '').length > 30 ? '...' : '' }}</template>
|
||||
</el-table-column>
|
||||
<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="100" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showPlatformConfigDialog(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-table v-else :data="platformConfigs" border stripe style="width:100%">
|
||||
<el-table-column prop="platform" label="标识" width="120"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" width="120"></el-table-column>
|
||||
<el-table-column prop="icon" label="图标" width="100">
|
||||
<template #default="scope"><span>{{ scope.row.icon || '—' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="website_url" label="平台网址" min-width="160">
|
||||
<template #default="scope"><a v-if="scope.row.website_url" :href="scope.row.website_url" target="_blank" style="color:#409eff;">{{ scope.row.website_url }}</a><span v-else>—</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="api_endpoint" label="API地址" min-width="160">
|
||||
<template #default="scope"><span style="color:#606266;">{{ scope.row.api_endpoint || '—' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字数范围" width="120">
|
||||
<template #default="scope">{{ scope.row.min_words }} - {{ scope.row.max_words }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配图" width="120">
|
||||
<template #default="scope">{{ scope.row.requires_image ? `${scope.row.image_count_min}-${scope.row.image_count_max}张` : '不需要' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="default_format" label="默认格式" min-width="180">
|
||||
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 50) }}{{ (scope.row.default_format || '').length > 50 ? '...' : '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="70">
|
||||
<template #default="scope"><span>{{ scope.row.is_active ? '是' : '否' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showPcDialogFn(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-dialog v-model="showPcDialog" :title="pcDialogTitle" width="750px" :close-on-click-modal="false">
|
||||
<el-form :model="pcForm" label-width="100px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="标识"><el-input v-model="pcForm.platform" placeholder="唯一标识,如 weixin" :disabled="!!editingPcPlatform"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="pcForm.name" placeholder="微信公众号"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="图标"><el-input v-model="pcForm.icon" placeholder="IconWechat"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="官网"><el-input v-model="pcForm.website_url" placeholder="https://mp.weixin.qq.com"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="API地址"><el-input v-model="pcForm.api_endpoint" placeholder="https://..."/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="最少字数"><el-input-number v-model="pcForm.min_words" :min="0" :max="99999" style="width:100%"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="最多字数"><el-input-number v-model="pcForm.max_words" :min="0" :max="99999" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="需要配图"><el-switch v-model="pcForm.requires_image"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="最少张数"><el-input-number v-model="pcForm.image_count_min" :min="0" :max="99" style="width:100%"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="最多张数"><el-input-number v-model="pcForm.image_count_max" :min="0" :max="99" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="默认格式"><el-input type="textarea" v-model="pcForm.default_format" :rows="4" placeholder="文章格式要求"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="pcForm.is_active"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPcDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePcForm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<div v-if="activeTab === 'systemconfigs'">
|
||||
<div class="toolbar">
|
||||
@@ -253,121 +302,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'users'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="addUser">+ 新建用户</el-button>
|
||||
<el-button size="small" @click="fetchUsers">刷新</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ users.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="usersLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="users.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUser /></el-icon>
|
||||
<div class="empty-text">暂无用户</div>
|
||||
<el-button type="primary" size="small" @click="addUser">新增第一个用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="70"></el-table-column>
|
||||
<el-table-column prop="username" label="用户名"></el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)" :disabled="scope.row.role === 'admin'">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="users.length > userPageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="users.length" :page-size="userPageSize" :current-page="userPage" @current-change="userPage = $event"></el-pagination>
|
||||
</div>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="u in users" :key="u.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">用户名</span><span class="card-value">{{ u.username }} <el-tag size="small" :type="u.role === 'admin' ? 'danger' : 'info'">{{ u.role === 'admin' ? '管理员' : '编辑' }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">组织</span><span class="card-value">{{ u.org_id || '-' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">创建时间</span><span class="card-value">{{ formatDate(u.created_at) }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" type="danger" plain @click="deleteUser(u.id)" :disabled="u.role === 'admin'">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-dialog v-model="userDialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
|
||||
<el-form :model="userForm" label-width="80px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="userForm.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" required>
|
||||
<el-input v-model="userForm.password" type="password" placeholder="至少6位" show-password></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="userForm.role" style="width:100%">
|
||||
<el-option label="编辑" value="editor"></el-option>
|
||||
<el-option label="管理员" value="admin"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="userDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUser" :loading="userSubmitting">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'categories'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
||||
<el-button size="small" @click="loadCategories">刷新</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ categories.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="categories.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
|
||||
<div class="empty-text">暂无采集类别</div>
|
||||
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="paginatedCategories" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="60"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
|
||||
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
|
||||
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="70">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="categories.length > categoryPageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="categories.length" :page-size="categoryPageSize" :current-page="categoryPage" @current-change="categoryPage = $event"></el-pagination>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="categories.length > 0">
|
||||
<div v-for="item in paginatedCategories" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
|
||||
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'orgs'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
|
||||
@@ -460,90 +394,11 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'sources'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
||||
<el-button size="small" @click="loadSources">刷新</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="sources.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
|
||||
<div class="empty-text">暂无信息源</div>
|
||||
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="paginatedSources" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="50"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
|
||||
<el-table-column prop="source_type" label="类型" width="90">
|
||||
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
|
||||
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="60">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="sources.length > sourcePageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="sources.length" :page-size="sourcePageSize" :current-page="sourcePage" @current-change="sourcePage = $event"></el-pagination>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="sources.length > 0">
|
||||
<div v-for="item in paginatedSources" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
|
||||
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="700px" :close-on-click-modal="false">
|
||||
<el-form :model="caseForm" label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary" :rows="3"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="caseDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCase">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
|
||||
<el-form :model="llmConfigForm" label-width="110px">
|
||||
<el-row :gutter="16">
|
||||
@@ -580,39 +435,6 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="platformConfigDialogVisible" :title="platformConfigDialogTitle" width="750px" :close-on-click-modal="false">
|
||||
<el-form :model="platformConfigForm" label-width="110px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="标识"><el-input v-model="platformConfigForm.platform" :disabled="!!editingPlatformConfigPlatform" placeholder="zhihu"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="platformConfigForm.name" placeholder="知乎"/></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="图标"><el-input v-model="platformConfigForm.icon" placeholder="📕"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="平台网址"><el-input v-model="platformConfigForm.website_url" placeholder="https://www.zhihu.com"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="API地址"><el-input v-model="platformConfigForm.api_endpoint" placeholder="https://api.zhihu.com"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="最少字数"><el-input-number v-model="platformConfigForm.min_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="最多字数"><el-input-number v-model="platformConfigForm.max_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="需要配图"><el-switch v-model="platformConfigForm.requires_image"/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="最少"><el-input-number v-model="platformConfigForm.image_count_min" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="最多"><el-input-number v-model="platformConfigForm.image_count_max" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="默认格式"><el-input type="textarea" v-model="platformConfigForm.default_format" :rows="2" placeholder="图文笔记,300-800字,emoji+标签"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="活跃"><el-switch v-model="platformConfigForm.is_active"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="platformConfigDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="savePlatformConfig">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="600px" :close-on-click-modal="false">
|
||||
<el-form :model="systemConfigForm" label-width="80px">
|
||||
<el-row :gutter="16">
|
||||
@@ -629,48 +451,6 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
|
||||
<el-form :model="categoryForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
|
||||
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
|
||||
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
|
||||
<el-form :model="sourceForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="sourceForm.source_type" style="width:100%">
|
||||
<el-option label="RSS订阅" value="rss"></el-option>
|
||||
<el-option label="搜索引擎" value="web_search"></el-option>
|
||||
<el-option label="本地文件" value="local"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
|
||||
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
|
||||
<el-form-item label="可信度">
|
||||
<el-select v-model="sourceForm.credibility" style="width:100%">
|
||||
<el-option label="高" value="high"></el-option>
|
||||
<el-option label="中" value="medium"></el-option>
|
||||
<el-option label="低" value="low"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
|
||||
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="sourceDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSource">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="orgDialogVisible" :title="orgDialogTitle" width="500px" :close-on-click-modal="false">
|
||||
<el-form :model="orgForm" label-width="80px">
|
||||
<el-form-item label="组织ID"><el-input v-model="orgForm.org_id" :disabled="!!editingOrgId" placeholder="唯一标识,如:org_a"/></el-form-item>
|
||||
@@ -703,7 +483,7 @@
|
||||
delete: (url) => fetch(apiBase + url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
};
|
||||
|
||||
const activeTab = ref('cases');
|
||||
const activeTab = ref('users');
|
||||
const currentUser = ref({ username: '' });
|
||||
const isAdmin = ref(false);
|
||||
|
||||
@@ -719,38 +499,6 @@
|
||||
|
||||
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
|
||||
|
||||
const cases = ref([]);
|
||||
const casesLoading = ref(false);
|
||||
const caseDialogVisible = ref(false);
|
||||
const caseDialogTitle = ref('新增案例');
|
||||
const caseForm = reactive({ id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' });
|
||||
const editingCaseId = ref(null);
|
||||
|
||||
const loadCases = async () => {
|
||||
casesLoading.value = true;
|
||||
try { cases.value = await api.get('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); }
|
||||
finally { casesLoading.value = false; }
|
||||
};
|
||||
const showCaseDialog = (row = null) => {
|
||||
if (row) { caseDialogTitle.value = '编辑案例'; editingCaseId.value = row.id; Object.assign(caseForm, row); }
|
||||
else { caseDialogTitle.value = '新增案例'; editingCaseId.value = null; Object.keys(caseForm).forEach(k => { if (k === 'id') caseForm.id = null; else caseForm[k] = ''; }); }
|
||||
caseDialogVisible.value = true;
|
||||
};
|
||||
const saveCase = async () => {
|
||||
try {
|
||||
if (editingCaseId.value) { await api.put(`/api/admin/cases/${editingCaseId.value}`, caseForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/cases', caseForm); ElMessage.success('创建成功'); }
|
||||
caseDialogVisible.value = false; await loadCases();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteCase = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/cases/${id}`); ElMessage.success('删除成功'); await loadCases(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const casePage = ref(1);
|
||||
const casePageSize = 10;
|
||||
const paginatedCases = computed(() => { const s = (casePage.value - 1) * casePageSize; return cases.value.slice(s, s + casePageSize); });
|
||||
|
||||
const taskLogs = ref([]);
|
||||
const taskLogsLoading = ref(false);
|
||||
const loadTaskLogs = async () => {
|
||||
@@ -801,32 +549,41 @@
|
||||
|
||||
const platformConfigs = ref([]);
|
||||
const platformConfigsLoading = ref(false);
|
||||
const platformConfigDialogVisible = ref(false);
|
||||
const platformConfigDialogTitle = ref('新增配置');
|
||||
const platformConfigForm = reactive({ platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true });
|
||||
const editingPlatformConfigPlatform = ref(null);
|
||||
const platformConfigsRequested = ref(false);
|
||||
const pcShowActiveOnly = ref(true);
|
||||
const showPcDialog = ref(false);
|
||||
const pcDialogTitle = ref('新增配置');
|
||||
const pcForm = ref({ platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true });
|
||||
const editingPcPlatform = ref(null);
|
||||
const loadPlatformConfigs = async () => {
|
||||
platformConfigsLoading.value = true;
|
||||
try { platformConfigs.value = await api.get('/api/platform-config'); } catch (e) { ElMessage.error('加载平台配置失败: ' + e.message); }
|
||||
platformConfigsRequested.value = true;
|
||||
try {
|
||||
const url = pcShowActiveOnly.value ? '/api/platform-config?active_only=true' : '/api/platform-config';
|
||||
const data = await api.get(url);
|
||||
platformConfigs.value = data;
|
||||
} catch (e) {
|
||||
ElMessage.error('加载平台配置失败: ' + e.message);
|
||||
}
|
||||
finally { platformConfigsLoading.value = false; }
|
||||
};
|
||||
const showPlatformConfigDialog = (row = null) => {
|
||||
if (row) {
|
||||
platformConfigDialogTitle.value = '编辑配置'; editingPlatformConfigPlatform.value = row.platform;
|
||||
Object.assign(platformConfigForm, { ...row });
|
||||
} else {
|
||||
platformConfigDialogTitle.value = '新增配置'; editingPlatformConfigPlatform.value = null;
|
||||
platformConfigForm.platform = ''; platformConfigForm.name = ''; platformConfigForm.icon = ''; platformConfigForm.website_url = ''; platformConfigForm.api_endpoint = '';
|
||||
platformConfigForm.min_words = 300; platformConfigForm.max_words = 3000; platformConfigForm.requires_image = false;
|
||||
platformConfigForm.image_count_min = 0; platformConfigForm.image_count_max = 0; platformConfigForm.default_format = ''; platformConfigForm.is_active = true;
|
||||
}
|
||||
platformConfigDialogVisible.value = true;
|
||||
};
|
||||
const savePlatformConfig = async () => {
|
||||
const showPcDialogFn = (row = null) => {
|
||||
try {
|
||||
if (editingPlatformConfigPlatform.value) { await api.put(`/api/platform-config/${platformConfigForm.platform}`, platformConfigForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/platform-config', platformConfigForm); ElMessage.success('创建成功'); }
|
||||
platformConfigDialogVisible.value = false; await loadPlatformConfigs();
|
||||
if (row) {
|
||||
pcDialogTitle.value = '编辑配置'; editingPcPlatform.value = row.platform;
|
||||
pcForm.value = { ...row };
|
||||
} else {
|
||||
pcDialogTitle.value = '新增配置'; editingPcPlatform.value = null;
|
||||
pcForm.value = { platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true };
|
||||
}
|
||||
showPcDialog.value = true;
|
||||
} catch (e) { console.error('showPcDialogFn error:', e); }
|
||||
};
|
||||
const savePcForm = async () => {
|
||||
try {
|
||||
if (editingPcPlatform.value) { await api.put(`/api/platform-config/${pcForm.value.platform}`, pcForm.value); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/platform-config', pcForm.value); ElMessage.success('创建成功'); }
|
||||
showPcDialog.value = false; await loadPlatformConfigs();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
|
||||
@@ -837,20 +594,6 @@
|
||||
const systemConfigForm = reactive({ key: '', value: '', description: '' });
|
||||
const editingSystemConfigKey = ref(null);
|
||||
|
||||
const categories = ref([]);
|
||||
const categoriesLoading = ref(false);
|
||||
const categoryDialogVisible = ref(false);
|
||||
const categoryDialogTitle = ref('新增类别');
|
||||
const categoryForm = reactive({ name: '', search_query: '', description: '', sort_order: 0, is_active: true });
|
||||
const editingCategoryId = ref(null);
|
||||
|
||||
const sources = ref([]);
|
||||
const sourcesLoading = ref(false);
|
||||
const sourceDialogVisible = ref(false);
|
||||
const sourceDialogTitle = ref('新增信息源');
|
||||
const sourceForm = reactive({ name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true });
|
||||
const editingSourceId = ref(null);
|
||||
|
||||
const orgs = ref([]);
|
||||
const orgsLoading = ref(false);
|
||||
const orgDialogVisible = ref(false);
|
||||
@@ -988,95 +731,39 @@
|
||||
finally { assistantSaving.value = false; }
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
categoriesLoading.value = true;
|
||||
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
|
||||
finally { categoriesLoading.value = false; }
|
||||
};
|
||||
const showCategoryDialog = (row = null) => {
|
||||
if (row) { categoryDialogTitle.value = '编辑类别'; editingCategoryId.value = row.id; Object.assign(categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
|
||||
else { categoryDialogTitle.value = '新增类别'; editingCategoryId.value = null; categoryForm.name = ''; categoryForm.search_query = ''; categoryForm.description = ''; categoryForm.sort_order = 0; categoryForm.is_active = true; }
|
||||
categoryDialogVisible.value = true;
|
||||
};
|
||||
const saveCategory = async () => {
|
||||
try {
|
||||
if (editingCategoryId.value) { await api.put(`/api/admin/collector/categories/${editingCategoryId.value}`, categoryForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/collector/categories', categoryForm); ElMessage.success('创建成功'); }
|
||||
categoryDialogVisible.value = false; await loadCategories();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteCategory = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/categories/${id}`); ElMessage.success('删除成功'); await loadCategories(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const categoryPage = ref(1);
|
||||
const categoryPageSize = 10;
|
||||
const paginatedCategories = computed(() => { const s = (categoryPage.value - 1) * categoryPageSize; return categories.value.slice(s, s + categoryPageSize); });
|
||||
|
||||
const loadSources = async () => {
|
||||
sourcesLoading.value = true;
|
||||
try { sources.value = await api.get('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
|
||||
finally { sourcesLoading.value = false; }
|
||||
};
|
||||
const showSourceDialog = (row = null) => {
|
||||
if (row) { sourceDialogTitle.value = '编辑信息源'; editingSourceId.value = row.id; Object.assign(sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
|
||||
else { sourceDialogTitle.value = '新增信息源'; editingSourceId.value = null; sourceForm.name = ''; sourceForm.source_type = 'web_search'; sourceForm.query = ''; sourceForm.credibility = 'medium'; sourceForm.focus = ''; sourceForm.sort_order = 0; sourceForm.is_active = true; }
|
||||
sourceDialogVisible.value = true;
|
||||
};
|
||||
const saveSource = async () => {
|
||||
try {
|
||||
if (editingSourceId.value) { await api.put(`/api/admin/collector/sources/${editingSourceId.value}`, sourceForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/collector/sources', sourceForm); ElMessage.success('创建成功'); }
|
||||
sourceDialogVisible.value = false; await loadSources();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteSource = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/sources/${id}`); ElMessage.success('删除成功'); await loadSources(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const sourcePage = ref(1);
|
||||
const sourcePageSize = 10;
|
||||
const paginatedSources = computed(() => { const s = (sourcePage.value - 1) * sourcePageSize; return sources.value.slice(s, s + sourcePageSize); });
|
||||
|
||||
const formatDate = (dateStr) => { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
||||
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||
|
||||
const tabLoaders = {
|
||||
cases: loadCases, tasklogs: loadTaskLogs,
|
||||
tasklogs: loadTaskLogs,
|
||||
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
|
||||
users: fetchUsers,
|
||||
categories: loadCategories, sources: loadSources,
|
||||
orgs: loadOrgs, assistant: loadAssistantConfig,
|
||||
};
|
||||
const loadedTabs = new Set(['cases']);
|
||||
const loadedTabs = new Set([]);
|
||||
|
||||
const switchTab = (name) => {
|
||||
console.log('[switchTab]', name, 'loadedTabs has?', loadedTabs.has(name));
|
||||
activeTab.value = name;
|
||||
if (!loadedTabs.has(name)) {
|
||||
loadedTabs.add(name);
|
||||
tabLoaders[name]();
|
||||
if (tabLoaders[name]) tabLoaders[name]();
|
||||
else console.warn('[switchTab] no loader for', name);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadedTabs.add('cases');
|
||||
loadCases();
|
||||
const hash = window.location.hash.replace('#tab=', '');
|
||||
if (hash && tabLoaders[hash]) { switchTab(hash); }
|
||||
else { switchTab('users'); }
|
||||
});
|
||||
|
||||
return {
|
||||
activeTab, switchTab,
|
||||
cases, casesLoading, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
|
||||
casePage, casePageSize, paginatedCases,
|
||||
taskLogs, taskLogsLoading, loadTaskLogs,
|
||||
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
||||
platformConfigs, platformConfigsLoading, platformConfigDialogVisible, platformConfigForm, platformConfigDialogTitle, showPlatformConfigDialog, savePlatformConfig, editingPlatformConfigPlatform,
|
||||
platformConfigs, platformConfigsLoading, platformConfigsRequested, pcShowActiveOnly, showPcDialog, pcForm, pcDialogTitle, showPcDialogFn, savePcForm, editingPcPlatform,
|
||||
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
|
||||
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
|
||||
categoryPage, categoryPageSize, paginatedCategories,
|
||||
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
|
||||
sourcePage, sourcePageSize, paginatedSources,
|
||||
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
|
||||
orgPage, orgPageSize, paginatedOrgs,
|
||||
logout, currentUser, isAdmin, redirectToPage,
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制 HTML</el-button>
|
||||
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制正文</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -215,12 +215,18 @@ const ArticlesApp = {
|
||||
});
|
||||
},
|
||||
copyPreviewHtml() {
|
||||
if (!this.previewArticleData || !this.previewArticleData.html_content) {
|
||||
this.$message.warning('暂无 HTML 内容可复制');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(this.previewArticleData.html_content)
|
||||
.then(() => this.$message.success('HTML 已复制'))
|
||||
const html = this.previewArticleData?.html_content;
|
||||
if (!html) { this.$message.warning('暂无内容可复制'); return; }
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const titleEl = doc.querySelector('h1');
|
||||
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
|
||||
const body = doc.body;
|
||||
if (body) body.querySelectorAll('script, style, svg, img, nav, footer, .interaction').forEach(el => el.remove());
|
||||
const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : [];
|
||||
const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n');
|
||||
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
|
||||
.then(() => this.$message.success('已复制到剪贴板'))
|
||||
.catch(() => this.$message.error('复制失败'));
|
||||
},
|
||||
async deleteArticle(article) {
|
||||
|
||||
+672
-210
File diff suppressed because it is too large
Load Diff
@@ -356,9 +356,10 @@ const TopicsApp = {
|
||||
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
|
||||
const body = doc.body;
|
||||
if (body) {
|
||||
body.querySelectorAll('script, nav, .header, .tags, footer, .interaction').forEach(el => el.remove());
|
||||
body.querySelectorAll('script, style, nav, .header, .tags, footer, .interaction, svg, img, button').forEach(el => el.remove());
|
||||
}
|
||||
const text = body ? body.textContent.trim() : html.replace(/<[^>]+>/g, '').trim();
|
||||
const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : [];
|
||||
const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n');
|
||||
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
|
||||
.then(() => this.$message.success(`已复制内容,请前往${platform}粘贴发布`))
|
||||
.catch(() => this.$message.error('复制失败,请手动复制'));
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
||||
{ key: 'platforms', label: '平台', page: 'platforms.html' },
|
||||
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
||||
|
||||
Reference in New Issue
Block a user