fix: 三平台内容差异化 + admin敏感词管理表格化
- writer.py: _expand_section() 去除 <100字阈值,始终调用 LLM 平台专属扩写
- prompt_loader.py: 新增 section_expansion_zhihu/wechat/xiaohongshu 三个独立 prompt
- admin.html: 配置管理标签页 + 敏感词/清理规则子标签 + 敏感词表格化管理(编辑/删除)
- config_items.py: PUT /sensitive-words/{id} 支持更新 word/category
- compliance_checker.py: AI 套话从 DB 加载 + 人称规则修正
- initial_data.py: PlatformConfig 字数迁移 + 新种子
- 各前端页面: LLM 配置 rate_limit 字段 + 供应商列表排序
This commit is contained in:
@@ -161,7 +161,6 @@ def update_article_content(
|
||||
)
|
||||
db.add(article)
|
||||
article.html_content = html_content
|
||||
article.updated_at = datetime.now()
|
||||
db.commit()
|
||||
return {"ok": True, "id": article_id}
|
||||
|
||||
|
||||
@@ -100,6 +100,32 @@ DEFAULT_CONTENT_CLEAN_RULES = [
|
||||
{"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},
|
||||
{"rule_type": "ai_telltale", "pattern": r"值得注意的是", "description": "AI套话—值得注意的是", "sort_order": 20},
|
||||
{"rule_type": "ai_telltale", "pattern": r"首先.*其次.*最后", "description": "AI套话—首先其次最后", "sort_order": 21},
|
||||
{"rule_type": "ai_telltale", "pattern": r"综上所述", "description": "AI套话—综上所述", "sort_order": 22},
|
||||
{"rule_type": "ai_telltale", "pattern": r"总的来说", "description": "AI套话—总的来说", "sort_order": 23},
|
||||
{"rule_type": "ai_telltale", "pattern": r"总而言之", "description": "AI套话—总而言之", "sort_order": 24},
|
||||
{"rule_type": "ai_telltale", "pattern": r"无可否认", "description": "AI套话—无可否认", "sort_order": 25},
|
||||
{"rule_type": "ai_telltale", "pattern": r"毋庸置疑", "description": "AI套话—毋庸置疑", "sort_order": 26},
|
||||
{"rule_type": "ai_telltale", "pattern": r"众所周知", "description": "AI套话—众所周知", "sort_order": 27},
|
||||
{"rule_type": "ai_telltale", "pattern": r"不可否认", "description": "AI套话—不可否认", "sort_order": 28},
|
||||
{"rule_type": "ai_telltale", "pattern": r"从某种意义上", "description": "AI套话—从某种意义上", "sort_order": 29},
|
||||
{"rule_type": "ai_telltale", "pattern": r"从某种程度上", "description": "AI套话—从某种程度上", "sort_order": 30},
|
||||
{"rule_type": "ai_telltale", "pattern": r"在一定程度上", "description": "AI套话—在一定程度上", "sort_order": 31},
|
||||
{"rule_type": "ai_telltale", "pattern": r"换而言之", "description": "AI套话—换而言之", "sort_order": 32},
|
||||
{"rule_type": "ai_telltale", "pattern": r"换言之", "description": "AI套话—换言之", "sort_order": 33},
|
||||
{"rule_type": "ai_telltale", "pattern": r"归根结底", "description": "AI套话—归根结底", "sort_order": 34},
|
||||
{"rule_type": "ai_telltale", "pattern": r"说到底", "description": "AI套话—说到底", "sort_order": 35},
|
||||
{"rule_type": "ai_telltale", "pattern": r"这为我们提供了", "description": "AI套话—这为我们提供了", "sort_order": 36},
|
||||
{"rule_type": "ai_telltale", "pattern": r"引人深思", "description": "AI套话—引人深思", "sort_order": 37},
|
||||
{"rule_type": "ai_telltale", "pattern": r"毫无悬念", "description": "AI套话—毫无悬念", "sort_order": 38},
|
||||
{"rule_type": "ai_telltale", "pattern": r"不知大家", "description": "AI套话—不知大家", "sort_order": 39},
|
||||
{"rule_type": "ai_telltale", "pattern": r"说回到", "description": "AI套话—说回到", "sort_order": 40},
|
||||
{"rule_type": "ai_telltale", "pattern": r"如果你也", "description": "AI套话—如果你也", "sort_order": 41},
|
||||
{"rule_type": "ai_telltale", "pattern": r"我们不难发现", "description": "AI套话—我们不难发现", "sort_order": 42},
|
||||
{"rule_type": "ai_telltale", "pattern": r"我们可以看出", "description": "AI套话—我们可以看出", "sort_order": 43},
|
||||
{"rule_type": "ai_telltale", "pattern": r"从以上分析可以看出", "description": "AI套话—从以上分析可以看出", "sort_order": 44},
|
||||
{"rule_type": "ai_telltale", "pattern": r"来说说到底", "description": "AI套话—来说说到底", "sort_order": 45},
|
||||
]
|
||||
|
||||
class KeywordDomainMapResponse(BaseModel):
|
||||
@@ -193,6 +219,14 @@ def _ensure_defaults(db: Session):
|
||||
if db.query(ContentCleanRule).count() == 0:
|
||||
for item in DEFAULT_CONTENT_CLEAN_RULES:
|
||||
db.add(ContentCleanRule(**item))
|
||||
else:
|
||||
# 补入缺失的 ai_telltale 规则(不覆盖已存在的内容)
|
||||
existing_patterns = {r.pattern for r in db.query(ContentCleanRule).filter(ContentCleanRule.rule_type == 'ai_telltale').all()}
|
||||
for item in DEFAULT_CONTENT_CLEAN_RULES:
|
||||
if item['rule_type'] == 'ai_telltale' and item['pattern'] not in existing_patterns:
|
||||
db.add(ContentCleanRule(**item))
|
||||
if any(item['rule_type'] == 'ai_telltale' and item['pattern'] not in existing_patterns for item in DEFAULT_CONTENT_CLEAN_RULES):
|
||||
db.flush()
|
||||
if db.query(TrendFieldMapping).count() == 0:
|
||||
for item in DEFAULT_TREND_FIELD_MAP:
|
||||
db.add(TrendFieldMapping(**item))
|
||||
@@ -253,12 +287,12 @@ def create_sensitive_word(data: SensitiveWordCreate, db: Session = Depends(get_d
|
||||
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)):
|
||||
def update_sensitive_word(item_id: int, body: SensitiveWordCreate, 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
|
||||
item.word = body.word
|
||||
item.category = body.category
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
@@ -304,10 +304,17 @@ def batch_update_status(
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
updated = q.update(
|
||||
{Topic.status: status, Topic.updated_at: datetime.now()},
|
||||
synchronize_session=False
|
||||
)
|
||||
now = datetime.now()
|
||||
update_dict = {Topic.status: status, Topic.updated_at: now}
|
||||
if status in ('review', '待审查'):
|
||||
update_dict[Topic.generated_at] = now
|
||||
update_dict[Topic.reviewed_at] = now
|
||||
elif status in ('ready', '待发布'):
|
||||
update_dict[Topic.ready_at] = now.date()
|
||||
update_dict[Topic.reviewed_at] = now
|
||||
elif status in ('published', '已发布'):
|
||||
update_dict[Topic.published_at] = now.date()
|
||||
updated = q.update(update_dict, synchronize_session=False)
|
||||
db.commit()
|
||||
return {"ok": True, "updated": updated}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ def run_creator_blocking(topic_id: str = None, timeout: int = 1800):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic:
|
||||
topic.generated_at = datetime.now(timezone.utc)
|
||||
topic.reviewed_at = datetime.now(timezone.utc)
|
||||
if topic.status in ('pending', '待处理'):
|
||||
topic.status = 'review'
|
||||
db.commit()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Unified LLM Client
|
||||
支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
支持 NVIDIA / sensenova / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
支持模型级 rate limit 和任务级模型选择(LLM_TASK_MODEL)
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
@@ -30,10 +32,28 @@ _API_KEYS = {
|
||||
# 代码级回退默认值(实际配置优先从 DB 读取)
|
||||
_FALLBACK = {
|
||||
"opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"},
|
||||
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.7-flash"},
|
||||
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"},
|
||||
"sensenova": {"base_url": "https://token.sensenova.cn/v1", "model": "deepseek-v4-flash"},
|
||||
}
|
||||
|
||||
# 模型级 rate limiter(进程内,重启重置)
|
||||
_RATE_LIMITER: Dict[str, List[float]] = {}
|
||||
|
||||
def _check_rate_limit(provider: str, model: str, limit: int, window_minutes: int) -> bool:
|
||||
if limit <= 0:
|
||||
return True
|
||||
key = f"{provider}/{model}"
|
||||
now = time.time()
|
||||
window_sec = window_minutes * 60
|
||||
timestamps = _RATE_LIMITER.get(key, [])
|
||||
timestamps = [t for t in timestamps if now - t < window_sec]
|
||||
_RATE_LIMITER[key] = timestamps
|
||||
return len(timestamps) < limit
|
||||
|
||||
def _record_usage(provider: str, model: str):
|
||||
key = f"{provider}/{model}"
|
||||
_RATE_LIMITER.setdefault(key, []).append(time.time())
|
||||
|
||||
def _get_active_provider() -> str:
|
||||
"""从 DB 读取活跃供应商,优先取 is_default=True;DB 不可用时回退环境变量"""
|
||||
try:
|
||||
@@ -61,6 +81,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
|
||||
db_model = None
|
||||
db_base_url = None
|
||||
db_api_key = None
|
||||
db_rate_limit = 0
|
||||
db_rate_window = 300
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import LLMConfig
|
||||
@@ -70,6 +92,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
|
||||
db_model = cfg.model
|
||||
db_base_url = cfg.base_url
|
||||
db_api_key = cfg.api_key
|
||||
db_rate_limit = cfg.rate_limit or 0
|
||||
db_rate_window = cfg.rate_limit_window_minutes or 300
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -82,6 +106,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
|
||||
"api_key": api_key,
|
||||
"model": db_model or fb.get("model", ""),
|
||||
"base_url": db_base_url or fb.get("base_url", ""),
|
||||
"rate_limit": db_rate_limit,
|
||||
"rate_limit_window_minutes": db_rate_window,
|
||||
}
|
||||
|
||||
def _get_db_defaults(provider: Optional[str] = None) -> dict:
|
||||
@@ -139,14 +165,25 @@ def call_llm(
|
||||
if not provider and os.getenv("LLM_TASK_PROVIDER"):
|
||||
task_provider = os.getenv("LLM_TASK_PROVIDER")
|
||||
providers_to_try = [task_provider] + [p for p in providers_to_try if p != task_provider]
|
||||
# LLM_TASK_MODEL 环境变量可覆盖任务级别的模型选择
|
||||
if not model and os.getenv("LLM_TASK_MODEL"):
|
||||
model = os.getenv("LLM_TASK_MODEL")
|
||||
last_error = None
|
||||
for p in providers_to_try:
|
||||
try:
|
||||
cfg = _get_provider_config(p)
|
||||
actual_model = model or cfg["model"]
|
||||
# Rate limit check
|
||||
rl = cfg.get("rate_limit", 0)
|
||||
rw = cfg.get("rate_limit_window_minutes", 300)
|
||||
if not _check_rate_limit(p, actual_model, rl, rw):
|
||||
logger.warning(f"[LLM] {p}/{actual_model} rate limit reached ({rl}/{rw}min) → 尝试下一个")
|
||||
last_error = LLMError(f"{p}/{actual_model} rate limit reached")
|
||||
continue
|
||||
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"model": model or cfg["model"],
|
||||
"model": actual_model,
|
||||
"messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}],
|
||||
"temperature": temperature, "max_tokens": max_tokens, "top_p": top_p,
|
||||
"frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty,
|
||||
@@ -176,6 +213,7 @@ def call_llm(
|
||||
if delta.get('content'): content_parts.append(delta['content'])
|
||||
if delta.get('reasoning_content'): reasoning_parts.append(delta['reasoning_content'])
|
||||
except Exception: continue
|
||||
_record_usage(p, actual_model)
|
||||
return "".join(content_parts) or "".join(reasoning_parts)
|
||||
else:
|
||||
data = resp.json()
|
||||
@@ -186,6 +224,7 @@ def call_llm(
|
||||
if rc:
|
||||
parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()]
|
||||
content = parts[-1] if parts else rc
|
||||
_record_usage(p, actual_model)
|
||||
return content.strip() or ''
|
||||
except LLMError as e:
|
||||
if '429' in str(e):
|
||||
|
||||
@@ -20,7 +20,7 @@ from .collector import run_collector_blocking
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _set_task_llm_provider(module_id: str):
|
||||
"""从 TaskConfig 读取 llm_provider 并设为环境变量,供子进程和 call_llm 读取"""
|
||||
"""从 TaskConfig 读取 llm_provider 和 llm_model 并设为环境变量,供子进程和 call_llm 读取"""
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import TaskConfig
|
||||
@@ -32,10 +32,20 @@ def _set_task_llm_provider(module_id: str):
|
||||
if provider:
|
||||
os.environ["LLM_TASK_PROVIDER"] = provider
|
||||
logger.debug("[%s] LLM provider set to %s", module_id, provider)
|
||||
return
|
||||
else:
|
||||
os.environ.pop("LLM_TASK_PROVIDER", None)
|
||||
|
||||
model = cfg.params.get("llm_model")
|
||||
if model:
|
||||
os.environ["LLM_TASK_MODEL"] = model
|
||||
logger.debug("[%s] LLM model set to %s", module_id, model)
|
||||
else:
|
||||
os.environ.pop("LLM_TASK_MODEL", None)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
os.environ.pop("LLM_TASK_PROVIDER", None)
|
||||
os.environ.pop("LLM_TASK_MODEL", None)
|
||||
|
||||
MODULES = {
|
||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"},
|
||||
|
||||
@@ -48,12 +48,15 @@ def init_db():
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS base_url VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS api_key VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS is_default BOOLEAN DEFAULT FALSE"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS rate_limit INTEGER DEFAULT 0"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS rate_limit_window_minutes INTEGER DEFAULT 300"))
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json"))
|
||||
except Exception:
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'"))
|
||||
for table, col, typ in [
|
||||
("users", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
("articles", "updated_at", "TIMESTAMP"),
|
||||
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
("topics", "reviewed_at", "TIMESTAMP"),
|
||||
("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"),
|
||||
|
||||
@@ -35,14 +35,26 @@ def import_initial_data():
|
||||
db.commit()
|
||||
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
|
||||
|
||||
# 补充或更新 LLM 供应商配置(nvidia 为主,opencode-go 为备)
|
||||
# 补充或更新 LLM 供应商配置(nvidia 为主,sensenova 为备,含多模型)
|
||||
expected = {
|
||||
"opencode-go": dict(provider="opencode-go", model="deepseek-v4-flash",
|
||||
base_url="https://opencode.ai/zen/go/v1", temperature=0.7, max_tokens=131072, is_active=True,
|
||||
user_prompt_template="将以下内容扩展为完整文章:\n{topic_title}\n{core_concept}"),
|
||||
"nvidia": dict(provider="nvidia", model="stepfun-ai/step-3.5-flash",
|
||||
"nvidia": dict(provider="nvidia", model="qwen/qwen3.5-397b-a17b",
|
||||
base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=131072, is_active=False,
|
||||
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
|
||||
"sensenova-6.7-flash-lite": dict(provider="sensenova", model="sensenova-6.7-flash-lite",
|
||||
base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True,
|
||||
rate_limit=1500, rate_limit_window_minutes=300,
|
||||
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
|
||||
"sensenova-u1-fast": dict(provider="sensenova", model="sensenova-u1-fast",
|
||||
base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True,
|
||||
rate_limit=1500, rate_limit_window_minutes=300,
|
||||
user_prompt_template="根据以下内容生成信息图:\n{section_content}"),
|
||||
"sensenova-deepseek": dict(provider="sensenova", model="deepseek-v4-flash",
|
||||
base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True,
|
||||
rate_limit=500, rate_limit_window_minutes=300,
|
||||
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
|
||||
}
|
||||
existing = {c.name: c for c in db.query(LLMConfig).all()}
|
||||
for name, cfg in expected.items():
|
||||
@@ -90,7 +102,7 @@ def import_initial_data():
|
||||
"platform": "zhihu",
|
||||
"name": "知乎",
|
||||
"icon": "🔍",
|
||||
"default_format": "长文深度分析,1500-3000字,有数据支撑",
|
||||
"default_format": "深度分析 3000-8000字,数据驱动",
|
||||
"compliance_rules": {
|
||||
"max_title_len": 100,
|
||||
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
|
||||
@@ -102,14 +114,14 @@ def import_initial_data():
|
||||
"image_count_max": 0,
|
||||
"image_width": 0,
|
||||
"image_height": 0,
|
||||
"min_words": 1500,
|
||||
"max_words": 3000
|
||||
"min_words": 3000,
|
||||
"max_words": 8000
|
||||
},
|
||||
{
|
||||
"platform": "wechat",
|
||||
"name": "微信公众号",
|
||||
"icon": "💚",
|
||||
"default_format": "公众号图文,800-1500字,亲切口语化",
|
||||
"default_format": "个人叙事 2000-4000字,对话感",
|
||||
"compliance_rules": {
|
||||
"max_title_len": 32,
|
||||
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
|
||||
@@ -121,14 +133,14 @@ def import_initial_data():
|
||||
"image_count_max": 3,
|
||||
"image_width": 1080,
|
||||
"image_height": 1080,
|
||||
"min_words": 800,
|
||||
"max_words": 1500
|
||||
"min_words": 2000,
|
||||
"max_words": 4000
|
||||
},
|
||||
{
|
||||
"platform": "xiaohongshu",
|
||||
"name": "小红书",
|
||||
"icon": "📕",
|
||||
"default_format": "图文笔记,300-800字,emoji+标签",
|
||||
"default_format": "精炼干货 400-1000字,实用优先",
|
||||
"compliance_rules": {
|
||||
"max_title_len": 50,
|
||||
"allowed_tags": ["生活方式", "可持续", "AI", "个人成长", "极简", "环保"],
|
||||
@@ -140,14 +152,29 @@ def import_initial_data():
|
||||
"image_count_max": 6,
|
||||
"image_width": 1080,
|
||||
"image_height": 1440,
|
||||
"min_words": 300,
|
||||
"max_words": 800
|
||||
"min_words": 400,
|
||||
"max_words": 1000
|
||||
}
|
||||
]
|
||||
for p in platforms:
|
||||
db.add(PlatformConfig(**p))
|
||||
db.commit()
|
||||
print("✅ 插入平台配置")
|
||||
else:
|
||||
# 更新已有平台配置的字数要求(迁移:2026-06 内容质量升级)
|
||||
platform_updates = {
|
||||
"zhihu": {"min_words": 3000, "max_words": 8000, "default_format": "深度分析 3000-8000字,数据驱动"},
|
||||
"wechat": {"min_words": 2000, "max_words": 4000, "default_format": "个人叙事 2000-4000字,对话感"},
|
||||
"xiaohongshu": {"min_words": 400, "max_words": 1000, "default_format": "精炼干货 400-1000字,实用优先"},
|
||||
}
|
||||
for p in db.query(PlatformConfig).all():
|
||||
if p.platform in platform_updates:
|
||||
up = platform_updates[p.platform]
|
||||
p.min_words = up["min_words"]
|
||||
p.max_words = up["max_words"]
|
||||
p.default_format = up["default_format"]
|
||||
db.commit()
|
||||
print("✅ 平台配置已更新(字数/格式)")
|
||||
|
||||
if db.query(TopicField).count() == 0:
|
||||
fields = [
|
||||
|
||||
@@ -288,7 +288,8 @@ class Article(Base):
|
||||
html_content = Column(Text)
|
||||
word_count = Column(Integer, nullable=True)
|
||||
outline = Column(Text, nullable=True)
|
||||
images = Column(JSON, default=dict) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"}
|
||||
images = Column(JSON, default=dict)
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"}
|
||||
|
||||
|
||||
class PublishRecord(Base):
|
||||
@@ -628,6 +629,8 @@ class LLMConfig(Base):
|
||||
api_key = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
rate_limit = Column(Integer, default=0, comment="每个时间窗口的调用上限,0=不限")
|
||||
rate_limit_window_minutes = Column(Integer, default=300, comment="时间窗口(分钟),默认5小时")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
@@ -645,6 +648,8 @@ class LLMConfig(Base):
|
||||
"api_key": f"{self.api_key[:8]}..." if self.api_key else None,
|
||||
"is_active": self.is_active,
|
||||
"is_default": self.is_default,
|
||||
"rate_limit": self.rate_limit,
|
||||
"rate_limit_window_minutes": self.rate_limit_window_minutes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -523,6 +523,8 @@ class LLMConfigBase(BaseModel):
|
||||
api_key: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
rate_limit: int = 0
|
||||
rate_limit_window_minutes: int = 300
|
||||
|
||||
|
||||
class LLMConfigResponse(LLMConfigBase):
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="系统管理" :username="currentUser.username" :is-admin="isAdmin" current-page="admin" @navigate="redirectToPage" @logout="logout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
@@ -32,6 +32,7 @@
|
||||
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
||||
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
||||
<el-button size="default" :type="activeTab === 'searchproviders' ? 'primary' : ''" @click="switchTab('searchproviders')">搜索API</el-button>
|
||||
<el-button size="default" :type="activeTab === 'configitems' ? 'primary' : ''" @click="switchTab('configitems')">配置管理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,8 +136,11 @@
|
||||
<template #default="scope">{{ scope.row.base_url ? scope.row.base_url.replace(/^https?:\/\//, '') : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="temperature" label="温度" width="70"></el-table-column>
|
||||
<el-table-column prop="max_tokens" label="最大Token" width="100"></el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80">
|
||||
<el-table-column prop="max_tokens" label="最大Token" width="100"></el-table-column>
|
||||
<el-table-column label="调用上限" width="110">
|
||||
<template #default="scope">{{ scope.row.rate_limit ? scope.row.rate_limit + '次/' + (scope.row.rate_limit_window_minutes || 300) + '分' : '不限' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.is_active" type="success" size="small" effect="dark">启用</el-tag>
|
||||
<el-tag v-else type="info" size="small">停用</el-tag>
|
||||
@@ -164,6 +168,7 @@
|
||||
<div class="card-row"><span class="card-label">供应商</span><span class="card-value">{{ item.provider }}</span></div>
|
||||
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
|
||||
<div class="card-row"><span class="card-label">温度/最大Token</span><span class="card-value">{{ item.temperature }} / {{ item.max_tokens }}</span></div>
|
||||
<div class="card-row"><span class="card-label">调用上限</span><span class="card-value">{{ item.rate_limit ? item.rate_limit + '次/' + (item.rate_limit_window_minutes || 300) + '分' : '不限' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag v-if="item.is_active" type="success" size="small" effect="dark">当前使用</el-tag><el-tag v-else type="info" size="small">未启用</el-tag></span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
|
||||
@@ -601,19 +606,160 @@
|
||||
</div>
|
||||
<template #footer><el-button @click="searchProviderTestVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<div v-if="activeTab === 'configitems'">
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="configSubTab === 'sensitive' ? 'primary' : ''" @click="switchConfigSubTab('sensitive')">🔒 敏感词</el-button>
|
||||
<el-button size="default" :type="configSubTab === 'cleanrules' ? 'primary' : ''" @click="switchConfigSubTab('cleanrules')">🧹 清理规则</el-button>
|
||||
</div>
|
||||
|
||||
<template v-if="configSubTab === 'sensitive'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showAddSensitiveWord">新增敏感词</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sensitiveWords.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="swLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="sensitiveWords.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconWarning /></el-icon>
|
||||
<div class="empty-text">暂无敏感词</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="sensitiveWords" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column type="index" label="#" width="50"></el-table-column>
|
||||
<el-table-column prop="word" label="敏感词" min-width="160"></el-table-column>
|
||||
<el-table-column prop="category" label="类别" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.category || '未分类' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editSensitiveWord(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSensitiveWord(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in sensitiveWords" :key="item.id" class="card-item">
|
||||
<div><el-tag size="small">{{ item.category || '未分类' }}</el-tag> <strong>{{ item.word }}</strong></div>
|
||||
<div style="margin-top:4px;">
|
||||
<el-button size="small" @click="editSensitiveWord(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSensitiveWord(item)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="configSubTab === 'cleanrules'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showAddCleanRule">新增清理规则</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ contentCleanRules.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="ccrLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="contentCleanRules.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconWarning /></el-icon>
|
||||
<div class="empty-text">暂无清理规则</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="contentCleanRules" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column type="index" label="#" width="50"></el-table-column>
|
||||
<el-table-column prop="rule_type" label="类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.rule_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="140"></el-table-column>
|
||||
<el-table-column prop="pattern" label="模式" min-width="220">
|
||||
<template #default="{ row }"><code style="font-size:12px;word-break:break-all;">{{ row.pattern }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editCleanRule(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCleanRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in contentCleanRules" :key="item.id" class="card-item">
|
||||
<div><el-tag size="small">{{ item.rule_type }}</el-tag> <code style="font-size:12px;">{{ item.pattern }}</code></div>
|
||||
<div style="margin-top:4px;">{{ item.description }}</div>
|
||||
<div style="margin-top:4px;">
|
||||
<el-tag :type="item.is_active ? 'success' : 'info'" size="small">{{ item.is_active ? '启用' : '停用' }}</el-tag>
|
||||
<el-button size="small" style="margin-left:8px;" @click="editCleanRule(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCleanRule(item)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="swDialogVisible" :title="editingSwId ? '编辑敏感词' : '新增敏感词'" width="450px" :close-on-click-modal="false">
|
||||
<el-form :model="swForm" label-width="80px">
|
||||
<el-form-item label="敏感词"><el-input v-model="swForm.word" placeholder="输入敏感词"/></el-form-item>
|
||||
<el-form-item label="类别">
|
||||
<el-select v-model="swForm.category" style="width:100%">
|
||||
<el-option label="general" value="general" />
|
||||
<el-option label="政治敏感" value="政治敏感" />
|
||||
<el-option label="违禁内容" value="违禁内容" />
|
||||
<el-option label="不实信息" value="不实信息" />
|
||||
<el-option label="porn" value="porn" />
|
||||
<el-option label="ad" value="ad" />
|
||||
<el-option label="violence" value="violence" />
|
||||
<el-option label="other" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="swDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSensitiveWord" :loading="swSaving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="ccrDialogVisible" :title="ccrDialogTitle" width="650px" :close-on-click-modal="false">
|
||||
<el-form :model="ccrForm" label-width="100px">
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="ccrForm.rule_type" style="width:100%">
|
||||
<el-option label="thinking (AI思考)" value="thinking" />
|
||||
<el-option label="preface (AI前缀)" value="preface" />
|
||||
<el-option label="verbosity (AI废话)" value="verbosity" />
|
||||
<el-option label="ai_telltale (AI套话)" value="ai_telltale" />
|
||||
<el-option label="html_thinking" value="html_thinking" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模式"><el-input v-model="ccrForm.pattern" placeholder="正则表达式"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="ccrForm.description" placeholder="简短说明"/></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="ccrForm.sort_order" :min="0" :max="999" style="width:100%"/></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="ccrForm.is_active"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ccrDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCleanRule" :loading="ccrSaving">保存</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">
|
||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name" placeholder="如:default_expand"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="供应商">
|
||||
<el-select v-model="llmConfigForm.provider" style="width:100%">
|
||||
<el-option label="opencode-go (deepseek-v4-flash)" value="opencode-go"></el-option>
|
||||
<el-option label="nvidia (step-3.7-flash)" value="nvidia"></el-option>
|
||||
<el-option label="sensenova (deepseek-v4-flash)" value="sensenova"></el-option>
|
||||
<el-option label="sensenova" value="sensenova"></el-option>
|
||||
<el-option label="nvidia" value="nvidia"></el-option>
|
||||
<el-option label="opencode-go" value="opencode-go"></el-option>
|
||||
</el-select>
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
@@ -630,6 +776,10 @@
|
||||
<el-col :span="6"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="默认"><el-switch v-model="llmConfigForm.is_default"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="调用上限"><el-input-number v-model="llmConfigForm.rate_limit" :min="0" :max="999999" style="width:100%"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="时间窗口(分)"><el-input-number v-model="llmConfigForm.rate_limit_window_minutes" :min="1" :max="43200" 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="llmConfigForm.system_prompt" :rows="3" placeholder="你是一个专业的内容创作助手。"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
@@ -692,6 +842,7 @@
|
||||
};
|
||||
|
||||
const activeTab = ref('users');
|
||||
const configSubTab = ref('sensitive');
|
||||
const currentUser = ref({ username: '' });
|
||||
const isAdmin = ref(false);
|
||||
|
||||
@@ -711,7 +862,7 @@ const llmConfigs = ref([]);
|
||||
const llmConfigsLoading = ref(false);
|
||||
const llmConfigDialogVisible = ref(false);
|
||||
const llmConfigDialogTitle = ref('新增 LLM 配置');
|
||||
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 131072, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true, is_default: false });
|
||||
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 131072, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true, is_default: false, rate_limit: 0, rate_limit_window_minutes: 300 });
|
||||
const editingLLMConfigId = ref(null);
|
||||
const loadLLMConfigs = async () => {
|
||||
llmConfigsLoading.value = true;
|
||||
@@ -727,6 +878,7 @@ const llmConfigs = ref([]);
|
||||
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'sensenova'; llmConfigForm.model = 'deepseek-v4-flash';
|
||||
llmConfigForm.base_url = 'https://token.sensenova.cn/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.3;
|
||||
llmConfigForm.max_tokens = 4000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true; llmConfigForm.is_default = false;
|
||||
llmConfigForm.rate_limit = 0; llmConfigForm.rate_limit_window_minutes = 300;
|
||||
}
|
||||
llmConfigDialogVisible.value = true;
|
||||
};
|
||||
@@ -1078,6 +1230,87 @@ const llmConfigs = ref([]);
|
||||
catch (e) { ElMessage.error('重置失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const sensitiveWords = ref([]);
|
||||
const swLoading = ref(false);
|
||||
const swDialogVisible = ref(false);
|
||||
const swSaving = ref(false);
|
||||
const swForm = reactive({ word: '', category: 'general' });
|
||||
const editingSwId = ref(null);
|
||||
const loadSensitiveWords = async () => {
|
||||
swLoading.value = true;
|
||||
try { sensitiveWords.value = await api.get('/api/admin/config/sensitive-words'); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { swLoading.value = false; }
|
||||
};
|
||||
const showAddSensitiveWord = () => { swForm.word = ''; swForm.category = 'general'; editingSwId.value = null; swDialogVisible.value = true; };
|
||||
const editSensitiveWord = (row) => { swForm.word = row.word; swForm.category = row.category || 'general'; editingSwId.value = row.id; swDialogVisible.value = true; };
|
||||
const saveSensitiveWord = async () => {
|
||||
swSaving.value = true;
|
||||
try {
|
||||
const body = { word: swForm.word, category: swForm.category };
|
||||
if (editingSwId.value) { await api.put('/api/admin/config/sensitive-words/' + editingSwId.value, body); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/config/sensitive-words', body); ElMessage.success('添加成功'); }
|
||||
swDialogVisible.value = false;
|
||||
await loadSensitiveWords();
|
||||
}
|
||||
catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
finally { swSaving.value = false; }
|
||||
};
|
||||
const deleteSensitiveWord = async (row) => {
|
||||
try { await ElMessageBox.confirm('确定删除「' + row.word + '」吗?', '提示', { type: 'warning' }); await api.delete('/api/admin/config/sensitive-words/' + row.id); ElMessage.success('删除成功'); await loadSensitiveWords(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const sensitiveWordsByCategory = computed(() => {
|
||||
const map = {};
|
||||
for (const w of sensitiveWords.value) {
|
||||
const cat = w.category || '未分类';
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(w);
|
||||
}
|
||||
return Object.entries(map).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
});
|
||||
|
||||
const contentCleanRules = ref([]);
|
||||
const ccrLoading = ref(false);
|
||||
const ccrDialogVisible = ref(false);
|
||||
const ccrDialogTitle = ref('新增清理规则');
|
||||
const ccrSaving = ref(false);
|
||||
const ccrForm = reactive({ id: null, rule_type: 'ai_telltale', pattern: '', description: '', sort_order: 0, is_active: true });
|
||||
const editingCcrId = ref(null);
|
||||
const loadContentCleanRules = async () => {
|
||||
ccrLoading.value = true;
|
||||
try { contentCleanRules.value = await api.get('/api/admin/config/content-clean-rules'); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { ccrLoading.value = false; }
|
||||
};
|
||||
const showAddCleanRule = () => {
|
||||
ccrDialogTitle.value = '新增清理规则';
|
||||
editingCcrId.value = null;
|
||||
ccrForm.id = null; ccrForm.rule_type = 'ai_telltale'; ccrForm.pattern = ''; ccrForm.description = ''; ccrForm.sort_order = 0; ccrForm.is_active = true;
|
||||
ccrDialogVisible.value = true;
|
||||
};
|
||||
const editCleanRule = (row) => {
|
||||
ccrDialogTitle.value = '编辑清理规则';
|
||||
editingCcrId.value = row.id;
|
||||
Object.assign(ccrForm, { id: row.id, rule_type: row.rule_type, pattern: row.pattern, description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active });
|
||||
ccrDialogVisible.value = true;
|
||||
};
|
||||
const saveCleanRule = async () => {
|
||||
ccrSaving.value = true;
|
||||
try {
|
||||
const body = { rule_type: ccrForm.rule_type, pattern: ccrForm.pattern, description: ccrForm.description, sort_order: ccrForm.sort_order, is_active: ccrForm.is_active };
|
||||
if (editingCcrId.value) { await api.put('/api/admin/config/content-clean-rules/' + editingCcrId.value, body); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/config/content-clean-rules', body); ElMessage.success('创建成功'); }
|
||||
ccrDialogVisible.value = false;
|
||||
await loadContentCleanRules();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
finally { ccrSaving.value = false; }
|
||||
};
|
||||
const deleteCleanRule = async (row) => {
|
||||
try { await ElMessageBox.confirm('确定删除该规则吗?', '提示', { type: 'warning' }); await api.delete('/api/admin/config/content-clean-rules/' + row.id); ElMessage.success('删除成功'); await loadContentCleanRules(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
||||
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||
|
||||
@@ -1086,6 +1319,7 @@ const llmConfigs = ref([]);
|
||||
users: fetchUsers,
|
||||
logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
|
||||
searchproviders: loadSearchProviders,
|
||||
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
|
||||
};
|
||||
const loadedTabs = new Set([]);
|
||||
|
||||
@@ -1098,6 +1332,7 @@ const llmConfigs = ref([]);
|
||||
else console.warn('[switchTab] no loader for', name);
|
||||
}
|
||||
};
|
||||
const switchConfigSubTab = (name) => { configSubTab.value = name; };
|
||||
|
||||
onMounted(() => {
|
||||
const hash = window.location.hash.replace('#tab=', '');
|
||||
@@ -1140,6 +1375,9 @@ const llmConfigs = ref([]);
|
||||
searchProviderTestVisible, searchProviderTesting, searchProviderTestResult,
|
||||
loadSearchProviders, addSearchProvider, editSearchProvider, saveSearchProvider, deleteSearchProvider, updateSearchProvider,
|
||||
testSearchProvider, testAllSearchProviders, resetSearchUsage,
|
||||
sensitiveWords, swLoading, swDialogVisible, swSaving, swForm, editingSwId, loadSensitiveWords, showAddSensitiveWord, editSensitiveWord, saveSensitiveWord, deleteSensitiveWord, configSubTab, switchConfigSubTab,
|
||||
contentCleanRules, ccrLoading, ccrDialogVisible, ccrDialogTitle, ccrSaving, ccrForm, editingCcrId,
|
||||
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="素材库" :username="currentUser.username" :is-admin="isAdmin" current-page="assets" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="内容日历" :username="currentUser.username" :is-admin="isAdmin" current-page="calendar" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content" v-if="isLoggedIn">
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<script src="chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="数据分析" :username="currentUser.username" :is-admin="isAdmin" current-page="metrics" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="平台配置" :username="currentUser.username" :is-admin="isAdmin" current-page="platforms" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="任务管理" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
@@ -103,7 +103,7 @@
|
||||
<div><span>最后运行</span><span>{{ mod.last_run || '从未' }}<span v-if="mod.last_status" :style="{marginLeft:'6px',padding:'1px 6px',borderRadius:'8px',fontSize:'11px',fontWeight:500}"><span v-if="mod.last_status==='success'" style="color:#67c23a;">✅成功</span><span v-else-if="mod.last_status==='failed'" style="color:#f56c6c;">❌失败</span><span v-else-if="mod.last_status==='running'" style="color:#e6a23c;">⏳运行中</span></span></span></div>
|
||||
<div><span>下次运行</span><span>{{ mod.next_run || '—' }}</span></div>
|
||||
<div><span>累计运行</span><span>{{ mod.total_runs }} 次 <span style="color:#67c23a;">{{ mod.success_runs }} 成功</span> <span style="color:#f56c6c;">{{ mod.failed_runs }} 失败</span><span v-if="mod.running > 0" style="color:#e6a23c;"> {{ mod.running }} 运行中</span></span></div>
|
||||
<div><span>LLM 模型</span><span>{{ (mod.params && mod.params.llm_provider) || (defaultLlmLabel || '默认') }}</span></div>
|
||||
<div><span>LLM 模型</span><span>{{ llmLabel(mod) }}</span></div>
|
||||
<div style="margin-top:10px; border-bottom:none;">
|
||||
<el-button size="small" type="primary" @click.stop="triggerModule(mod.module_id)" :loading="runningModule === mod.module_id" :disabled="!mod.enabled">立即运行</el-button>
|
||||
<el-button size="small" @click.stop="openModuleDetail(mod)">查看详情</el-button>
|
||||
@@ -349,15 +349,15 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;">
|
||||
<span style="font-size:13px;color:#606266;width:60px;">LLM</span>
|
||||
<el-select v-model="drawerData.params.llm_provider" size="small" style="width:200px;" clearable placeholder="系统默认" @change="saveModuleConfig" :disabled="savingConfig">
|
||||
<el-option v-for="c in llmConfigs.filter(x=>x.is_active)" :key="c.id" :label="c.provider + ' (' + c.model + ')' + (c.is_default ? ' (默认)' : '')" :value="c.provider"/>
|
||||
<el-select v-model="drawerData.params.llm_provider" size="small" style="width:280px;" clearable placeholder="系统默认" @change="onLLMProviderChange" :disabled="savingConfig">
|
||||
<el-option v-for="c in llmConfigs.filter(x=>x.is_active)" :key="c.id" :label="c.provider + '/' + c.model + (c.rate_limit ? ' (' + c.rate_limit + '次/' + (c.rate_limit_window_minutes||300) + '分)' : '') + (c.is_default ? ' (默认)' : '')" :value="c.provider"/>
|
||||
</el-select>
|
||||
<span style="font-size:12px;color:#909399;">留空则使用系统默认模型</span>
|
||||
<span style="font-size:12px;color:#909399;">留空则使用系统默认</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="Object.keys(drawerData.params || {}).filter(k => k !== 'llm_provider').length > 0">
|
||||
<div v-if="Object.keys(drawerData.params || {}).filter(k => k !== 'llm_provider' && k !== 'llm_model').length > 0">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">参数配置</div>
|
||||
<div v-for="(val, key) in drawerData.params" v-if="key !== 'llm_provider'" :key="key" style="margin-bottom:12px;display:flex;align-items:center;gap:12px;">
|
||||
<div v-for="(val, key) in drawerData.params" v-if="key !== 'llm_provider' && key !== 'llm_model'" :key="key" style="margin-bottom:12px;display:flex;align-items:center;gap:12px;">
|
||||
<span style="font-size:13px;color:#606266;width:120px;">{{ key }}</span>
|
||||
<el-input v-if="typeof val === 'string'" v-model="drawerData.params[key]" size="small" style="flex:1;" @change="saveModuleConfig" :disabled="savingConfig"></el-input>
|
||||
<el-input-number v-else-if="typeof val === 'number'" v-model="drawerData.params[key]" size="small" :disabled="savingConfig" @change="saveModuleConfig"></el-input-number>
|
||||
@@ -672,9 +672,29 @@ const TasksApp = {
|
||||
const configs = await this.api('/api/admin/llmconfigs');
|
||||
this.llmConfigs = configs || [];
|
||||
const def = (configs || []).find(c => c.is_default);
|
||||
this.defaultLlmLabel = def ? def.provider + ' (' + def.model + ')' : '';
|
||||
this.defaultLlmLabel = def ? def.provider + '/' + def.model : '';
|
||||
} catch (e) { console.error('loadLLMConfigs error:', e); this.llmConfigs = []; }
|
||||
},
|
||||
llmLabel(mod) {
|
||||
const p = (mod.params && mod.params.llm_provider) || '';
|
||||
const m = (mod.params && mod.params.llm_model) || '';
|
||||
if (p && m) return p + '/' + m;
|
||||
if (p) {
|
||||
const match = (this.llmConfigs || []).find(c => c.provider === p && c.is_active);
|
||||
if (match) return p + '/' + match.model;
|
||||
}
|
||||
return this.defaultLlmLabel || '默认';
|
||||
},
|
||||
onLLMProviderChange(val) {
|
||||
if (!val) {
|
||||
delete this.drawerData.params.llm_provider;
|
||||
delete this.drawerData.params.llm_model;
|
||||
} else {
|
||||
const match = (this.llmConfigs || []).find(c => c.provider === val && c.is_active);
|
||||
if (match) this.drawerData.params.llm_model = match.model;
|
||||
}
|
||||
this.saveModuleConfig();
|
||||
},
|
||||
async openModuleDetail(mod) {
|
||||
this.showDrawer = true;
|
||||
this.drawerTitle = mod.title + ' 详情';
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Inspired by opencode.ai — clean, modern, responsive
|
||||
============================================ */
|
||||
|
||||
/* ========== v-cloak (hide raw Vue templates) ========== */
|
||||
[v-cloak] { display: none !important; }
|
||||
|
||||
/* ========== Design Tokens ========== */
|
||||
:root {
|
||||
/* Colors */
|
||||
@@ -116,7 +119,7 @@ body {
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - var(--nav-height));
|
||||
}
|
||||
.content-area { flex: 1; padding: var(--spacing-lg); overflow-y: auto; }
|
||||
.content-area { flex: 1; padding: var(--spacing-md); overflow-y: auto; }
|
||||
|
||||
/* ========== Card ========== */
|
||||
.card {
|
||||
@@ -373,7 +376,7 @@ body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index
|
||||
|
||||
/* ========== Responsive ========== */
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: var(--spacing-md) !important; padding-bottom: 80px; }
|
||||
.content-area { padding: var(--spacing-sm) !important; padding-bottom: 80px; }
|
||||
.card { padding: var(--spacing-md) !important; }
|
||||
.page-header { flex-direction: column; align-items: flex-start !important; gap: var(--spacing-sm) !important; }
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
|
||||
Reference in New Issue
Block a user