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:
Yuzhiran Dev
2026-06-08 13:51:35 +08:00
parent d0896ef10e
commit 23ff63baa9
26 changed files with 582 additions and 93 deletions
-1
View File
@@ -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}
+37 -3
View File
@@ -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
+11 -4
View File
@@ -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}
+1
View File
@@ -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()
+42 -3
View File
@@ -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=TrueDB 不可用时回退环境变量"""
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):
+12 -2
View File
@@ -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"},
+3
View File
@@ -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"),
+38 -11
View File
@@ -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 = [
+6 -1
View File
@@ -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,
}
+2
View File
@@ -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):
+245 -7
View File
@@ -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,
};
}
});
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+28 -8
View File
@@ -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 + ' 详情';
+5 -2
View File
@@ -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; }
+1 -1
View File
@@ -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">