Add platform config website_url, admin tab, fix writer DB config fallback, add trigger endpoints

This commit is contained in:
Yuzhiran Dev
2026-05-20 09:38:56 +08:00
parent 55e5f3d166
commit 498165440f
84 changed files with 1988 additions and 242 deletions
+1
View File
@@ -9,6 +9,7 @@ from ..database import get_db
from ..models import User, Topic, SystemConfig
from ..schemas import UserCreate, UserUpdate, UserResponse
from ..core.audit_logger import audit_log
from .auth import org_filter
import json
router = APIRouter(prefix="/api/admin", tags=["admin"])
+61
View File
@@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from ..core.nvidia_client import call_llm
from .auth import get_current_user
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
PAGE_CONTEXTS = {
"dashboard": "仪表盘页面:显示系统概览统计(选题总数、待处理、待审查、待发布、已发布、今日新增)、模块状态卡片(6个定时任务的运行状态)、近期计划列表",
"topics": "选题管理页面:创建和管理内容选题,支持筛选、排序、评分、编辑、预览等功能",
"metrics": "数据分析页面:展示各平台内容表现数据,包括浏览量、点赞、收藏、评论等指标的趋势图表",
"calendar": "内容日历页面:以日历形式展示内容发布计划,支持创建、编辑、拖拽调整日期",
"assets": "素材管理页面:管理图片、文件等媒体素材,支持上传、标签分类、关联选题",
"tasks": "任务管理页面:查看内容创作任务的执行状态,包括自动采集、同步、生成、优化等任务",
"platforms": "平台配置页面:管理各发布平台的配置,包括平台名称、API端点、发布格式等",
"admin": "系统管理页面:管理用户、LLM配置、系统配置、采集类别、信息源、组织、查看运行日志等",
}
class ChatRequest(BaseModel):
message: str
page: str = "dashboard"
history: List[dict] = []
@router.post("/chat")
def chat(request: ChatRequest, current_user=Depends(get_current_user)):
try:
from ..database import SessionLocal
from ..models import SystemConfig
db = SessionLocal()
sc = db.query(SystemConfig).filter(SystemConfig.key == "assistant_system_prompt").first()
base_prompt = sc.value if sc else None
db.close()
except Exception:
base_prompt = None
page_name = request.page
page_guide = PAGE_CONTEXTS.get(page_name, "未知页面")
username = current_user.username if hasattr(current_user, 'username') else '用户'
system_prompt = base_prompt or f"""你是一个智能内容创作平台助手,帮助用户解答平台使用问题。
当前用户 {username} 正在查看:{page_guide}
你可以帮助用户:
1. 解释各个页面的功能和操作方法
2. 解答平台使用中的疑问
3. 提供操作建议和最佳实践
4. 指导用户完成具体的操作步骤
请用友好、简洁的中文回答。如果不确定,建议用户参考相关页面或联系管理员。一次回答控制在200字以内。"""
messages = request.history[-20:]
conversation = "\n".join([f"{'用户' if m.get('role') == 'user' else '助手'}: {m.get('content', '')}" for m in messages])
prompt_text = f"{conversation}\n用户: {request.message}\n助手:"
try:
reply = call_llm(prompt_text, system_prompt=system_prompt, temperature=0.5, max_tokens=1000)
return {"reply": reply}
except Exception as e:
return {"reply": f"抱歉,AI助手暂时无法响应,请稍后重试。"}
+34 -2
View File
@@ -142,6 +142,30 @@ def run_sync():
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/optimize-sources/run")
def trigger_optimize_sources():
try:
from ..core.scheduler import scheduler
scheduler._run_optimize_sources()
log_file = LOGS_DIR / f"optimizer_sources_{date.today().isoformat()}.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
log_file.write_text(f"{datetime.now().isoformat()} - 信息源优化完成\n")
return {"message": "信息源优化已完成"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/metrics-sync/run")
def trigger_metrics_sync():
try:
from ..core.scheduler import scheduler
scheduler._run_metrics_sync()
log_file = LOGS_DIR / f"metrics_sync_{date.today().isoformat()}.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
log_file.write_text(f"{datetime.now().isoformat()} - 指标同步完成\n")
return {"message": "指标同步已完成"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/automation/topics")
def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
try:
@@ -187,7 +211,6 @@ def get_modules_status():
today_str = date.today().isoformat()
log_based: dict = {
"scheduled_collect": {"name": "📡 内容采集", "log": LOGS_DIR / f"collector_{today_str}.log"},
"scheduled_sync": {"name": "🔄 数据同步", "log": LOGS_DIR / f"sync_{today_str}.log"},
"scheduled_generate": {"name": "🤖 内容创作", "log": LOGS_DIR / f"creator_{today_str}.log"},
"scheduled_optimize": {"name": "🔍 内容优化", "log": LOGS_DIR / f"optimizer_{today_str}.log"},
"scheduled_optimize_sources": {"name": "📡 信息源优化", "log": LOGS_DIR / f"optimizer_sources_{today_str}.log"},
@@ -208,12 +231,21 @@ def get_modules_status():
total = task_count + content.count("失败") + content.count("failed") + content.count("ERROR")
success_rate = round(task_count / total * 100) if total > 0 else None
status = "running" if mod_id in jobs else "stopped"
job = jobs.get(mod_id)
next_run = None
if job and job.get("next_run_time"):
try:
next_dt = datetime.fromisoformat(job["next_run_time"])
next_run = next_dt.strftime("%Y-%m-%d %H:%M")
except Exception:
next_run = job["next_run_time"]
modules.append({
"id": mod_id,
"title": cfg["name"],
"status": status,
"last_run": last_run or "从未运行",
"next_run": next_run or "待计划",
"task_count": task_count,
"success_rate": success_rate,
"success_rate": success_rate if success_rate is not None else 0,
})
return {"modules": modules, "scheduler": {"running": scheduler._started, "jobs": scheduler.get_jobs()}}
+20 -3
View File
@@ -70,12 +70,26 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
"base_url": base_url or fb.get("base_url", ""),
}
def _get_db_defaults(provider: Optional[str] = None) -> dict:
try:
from ..database import SessionLocal
from ..models import LLMConfig
db = SessionLocal()
p = provider or _get_active_provider()
cfg = db.query(LLMConfig).filter(LLMConfig.provider == p).order_by(LLMConfig.is_active.desc()).first()
db.close()
if cfg:
return {"temperature": cfg.temperature, "max_tokens": cfg.max_tokens}
except Exception:
pass
return {"temperature": 0.20, "max_tokens": 2048}
def call_llm(
prompt: str,
model: Optional[str] = None,
system_prompt: str = "你是一个专业的内容创作助手。",
temperature: float = 0.20,
max_tokens: int = 2048,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
top_p: float = 0.70,
frequency_penalty: float = 0.00,
presence_penalty: float = 0.00,
@@ -83,6 +97,9 @@ def call_llm(
provider: Optional[str] = None,
additional_params: Optional[Dict[str, Any]] = None,
) -> str:
defaults = _get_db_defaults(provider)
temperature = temperature if temperature is not None else defaults["temperature"]
max_tokens = max_tokens if max_tokens is not None else defaults["max_tokens"]
cfg = _get_provider_config(provider)
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
headers = {
@@ -186,7 +203,7 @@ def expand_content_with_llm(
prompt = f"# 参考资料\n{context}\n\n{prompt}"
try:
result = call_llm(prompt, temperature=0.3, max_tokens=1500)
result = call_llm(prompt, temperature=0.3)
return result.strip()
except Exception as e:
return f"## {section_title}\n\nLLM 调用失败:{e},请手动补充)"
-20
View File
@@ -9,7 +9,6 @@ from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from .generator import run_creator
from .optimizer import run_optimizer
from .sync import sync_all_topics
from .collector import run_collector
logger = logging.getLogger(__name__)
@@ -28,17 +27,6 @@ class TaskScheduler:
self._run_collect,
CronTrigger(hour=1, minute=30),
id='scheduled_collect',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_sync,
CronTrigger(hour=2, minute=30),
id='scheduled_sync',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_generate,
@@ -176,14 +164,6 @@ class TaskScheduler:
except Exception as e:
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
def _run_sync(self):
try:
logger.info("[Scheduled] Starting data sync...")
sync_all_topics()
logger.info("[Scheduled] Sync completed")
except Exception as e:
logger.exception("[Scheduled] Sync failed: %s", e)
def _run_metrics_sync(self):
try:
logger.info("[Scheduled] Starting metrics sync...")
+1
View File
@@ -60,6 +60,7 @@ def init_db():
("platform_configs", "image_height", "INTEGER DEFAULT 0"),
("platform_configs", "min_words", "INTEGER DEFAULT 0"),
("platform_configs", "max_words", "INTEGER DEFAULT 0"),
("platform_configs", "website_url", "VARCHAR"),
]:
try:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
+5 -7
View File
@@ -38,22 +38,20 @@ def import_initial_data():
# 补充或更新 LLM 供应商配置(opencode-go 为主,nvidia 为备)
expected = {
"opencode-go": dict(provider="opencode-go", model="deepseek-v4-flash",
base_url="https://opencode.ai/zen/go/v1", temperature=0.7, max_tokens=2000, is_active=True,
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",
base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=2000, is_active=False,
base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=131072, is_active=False,
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
}
existing = {c.name: c for c in db.query(LLMConfig).all()}
# 删除完全无意义的旧残留
for name in list(existing.keys()):
if name not in expected:
db.delete(existing[name]); existing.pop(name)
for name, cfg in expected.items():
if name in existing:
c = existing[name]
# 仅补缺失字段,不覆写用户已修改的值
for k, v in cfg.items():
setattr(c, k, v)
if getattr(c, k, None) is None:
setattr(c, k, v)
else:
db.add(LLMConfig(name=name, **cfg))
db.commit()
+2 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db
from .models import Base
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant
from .initial_data import import_initial_data
from .core.scheduler import scheduler
@@ -93,6 +93,7 @@ app.include_router(assets.router)
app.include_router(tasks.router)
app.include_router(platform_config.router)
app.include_router(collector_mgmt.router)
app.include_router(assistant.router)
# 挂载前端
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
+2
View File
@@ -355,6 +355,7 @@ class PlatformConfig(Base):
platform = Column(String, unique=True, nullable=False) # zhihu/wechat/xiaohongshu
name = Column(String, nullable=False)
icon = Column(String, nullable=True)
website_url = Column(String, nullable=True)
api_endpoint = Column(String, nullable=True)
auth_config = Column(JSON, default=dict)
format_template = Column(JSON, default=dict)
@@ -377,6 +378,7 @@ class PlatformConfig(Base):
"platform": self.platform,
"name": self.name,
"icon": self.icon,
"website_url": self.website_url,
"api_endpoint": self.api_endpoint,
"format_template": self.format_template or {},
"compliance_rules": self.compliance_rules or {},
+3 -1
View File
@@ -257,6 +257,7 @@ class PlatformConfigBase(BaseModel):
platform: str
name: str
icon: Optional[str] = None
website_url: Optional[str] = None
api_endpoint: Optional[str] = None
auth_config: Dict[str, Any] = {}
format_template: Dict[str, Any] = {}
@@ -279,6 +280,7 @@ class PlatformConfigCreate(PlatformConfigBase):
class PlatformConfigUpdate(BaseModel):
name: Optional[str] = None
icon: Optional[str] = None
website_url: Optional[str] = None
api_endpoint: Optional[str] = None
auth_config: Optional[Dict[str, Any]] = None
format_template: Optional[Dict[str, Any]] = None
@@ -476,7 +478,7 @@ class LLMConfigBase(BaseModel):
system_prompt: Optional[str] = None
user_prompt_template: str
temperature: float = 0.7
max_tokens: int = 2000
max_tokens: int = 131072
model: Optional[str] = None
provider: str = "opencode-go"
base_url: Optional[str] = None
+168 -18
View File
@@ -25,12 +25,14 @@
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
<el-button size="default" :type="activeTab === 'platformconfigs' ? 'primary' : ''" @click="switchTab('platformconfigs')">平台配置</el-button>
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
<el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button>
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
</div>
</div>
@@ -53,10 +55,10 @@
</div>
<el-table :data="paginatedCases" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="title" label="标题" min-width="120" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="field" label="领域" width="100"></el-table-column>
<el-table-column prop="title" label="标题" min-width="80" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="field" label="领域" width="140"></el-table-column>
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="source" label="来源" width="100"></el-table-column>
<el-table-column prop="source" label="来源" width="140"></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
@@ -129,18 +131,25 @@
</template>
<template v-else>
<el-table :data="llmConfigs" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="model" label="模型" min-width="180"></el-table-column>
<el-table-column prop="temperature" label="温度" width="80"></el-table-column>
<el-table-column prop="max_tokens" label="最大Token" width="110"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="100"></el-table-column>
<el-table-column prop="provider" label="供应商" width="110"></el-table-column>
<el-table-column prop="model" label="模型" min-width="160"></el-table-column>
<el-table-column prop="base_url" label="接口地址" min-width="200">
<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="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<el-table-column label="系统提示词" min-width="150">
<template #default="scope">{{ (scope.row.system_prompt || '').slice(0, 30) }}{{ (scope.row.system_prompt || '').length > 30 ? '...' : '' }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="scope">
<el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button>
@@ -151,8 +160,9 @@
<div class="card-list-mobile">
<div v-for="item in llmConfigs" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">供应商</span><span class="card-value">{{ item.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">温度</span><span class="card-value">{{ item.temperature }}</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.is_active ? '是' : '否' }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
@@ -162,6 +172,45 @@
</div>
</div>
<div v-if="activeTab === 'platformconfigs'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showPlatformConfigDialog()">新增配置</el-button>
</div>
<div v-if="platformConfigsLoading" class="card-loading">加载中...</div>
<template v-else-if="platformConfigs.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无平台配置</div>
</div>
</template>
<template v-else>
<el-table :data="platformConfigs" border stripe class="data-table" style="width:100%">
<el-table-column prop="platform" label="标识" width="80"></el-table-column>
<el-table-column prop="name" label="名称" width="80"></el-table-column>
<el-table-column prop="website_url" label="平台网址" min-width="160">
<template #default="scope"><a :href="scope.row.website_url" target="_blank" style="color:#409eff;">{{ scope.row.website_url }}</a></template>
</el-table-column>
<el-table-column label="字数范围" width="120">
<template #default="scope">{{ scope.row.min_words }} - {{ scope.row.max_words }}</template>
</el-table-column>
<el-table-column label="配图" width="100">
<template #default="scope">{{ scope.row.requires_image ? `${scope.row.image_count_min}-${scope.row.image_count_max}张` : '不需要' }}</template>
</el-table-column>
<el-table-column prop="api_endpoint" label="API地址" min-width="160"></el-table-column>
<el-table-column prop="default_format" label="默认格式" min-width="160">
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 30) }}{{ (scope.row.default_format || '').length > 30 ? '...' : '' }}</template>
</el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope"><el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon><el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="scope">
<el-button size="small" @click="showPlatformConfigDialog(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
<div v-if="activeTab === 'systemconfigs'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button>
@@ -396,6 +445,21 @@
</div>
</div>
<div v-if="activeTab === 'assistant'">
<div class="toolbar">
<el-button type="primary" size="small" @click="saveAssistantPrompt" :loading="assistantSaving">保存配置</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">AI 助手在各页面右下角显示悬浮按钮,点击可打开对话窗口</span>
</div>
<el-form label-width="120px" style="max-width:700px;margin-top:16px;">
<el-form-item label="系统提示词">
<el-input type="textarea" v-model="assistantPrompt" :rows="12" placeholder="设置 AI 助手的角色和行为说明,留空则使用默认提示词"></el-input>
</el-form-item>
<el-form-item label="启用助手" v-if="false">
<el-switch v-model="assistantEnabled"></el-switch>
</el-form-item>
</el-form>
</div>
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
@@ -500,7 +564,7 @@
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="999999" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
@@ -516,6 +580,39 @@
</template>
</el-dialog>
<el-dialog v-model="platformConfigDialogVisible" :title="platformConfigDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="platformConfigForm" label-width="110px">
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="标识"><el-input v-model="platformConfigForm.platform" :disabled="!!editingPlatformConfigPlatform" placeholder="zhihu"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称"><el-input v-model="platformConfigForm.name" placeholder="知乎"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="图标"><el-input v-model="platformConfigForm.icon" placeholder="📕"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="平台网址"><el-input v-model="platformConfigForm.website_url" placeholder="https://www.zhihu.com"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="API地址"><el-input v-model="platformConfigForm.api_endpoint" placeholder="https://api.zhihu.com"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最少字数"><el-input-number v-model="platformConfigForm.min_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最多字数"><el-input-number v-model="platformConfigForm.max_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="需要配图"><el-switch v-model="platformConfigForm.requires_image"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="最少"><el-input-number v-model="platformConfigForm.image_count_min" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="最多"><el-input-number v-model="platformConfigForm.image_count_max" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="默认格式"><el-input type="textarea" v-model="platformConfigForm.default_format" :rows="2" placeholder="图文笔记,300-800字,emoji+标签"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="活跃"><el-switch v-model="platformConfigForm.is_active"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="platformConfigDialogVisible=false">取消</el-button>
<el-button type="primary" @click="savePlatformConfig">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="600px" :close-on-click-modal="false">
<el-form :model="systemConfigForm" label-width="80px">
<el-row :gutter="16">
@@ -666,7 +763,7 @@
const llmConfigsLoading = ref(false);
const llmConfigDialogVisible = ref(false);
const llmConfigDialogTitle = ref('新增配置');
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
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 });
const editingLLMConfigId = ref(null);
const loadLLMConfigs = async () => {
@@ -679,14 +776,14 @@
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go';
llmConfigForm.model = row.model || ''; llmConfigForm.base_url = row.base_url || ''; llmConfigForm.api_key = '';
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 2000;
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 131072;
llmConfigForm.system_prompt = row.system_prompt || ''; llmConfigForm.user_prompt_template = row.user_prompt_template || '';
llmConfigForm.is_active = row.is_active ?? true;
} else {
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null;
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
llmConfigForm.base_url = 'https://opencode.ai/zen/go/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.7;
llmConfigForm.max_tokens = 2000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
llmConfigForm.max_tokens = 131072; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
}
llmConfigDialogVisible.value = true;
};
@@ -702,6 +799,37 @@
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const platformConfigs = ref([]);
const platformConfigsLoading = ref(false);
const platformConfigDialogVisible = ref(false);
const platformConfigDialogTitle = ref('新增配置');
const platformConfigForm = reactive({ platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true });
const editingPlatformConfigPlatform = ref(null);
const loadPlatformConfigs = async () => {
platformConfigsLoading.value = true;
try { platformConfigs.value = await api.get('/api/platform-config'); } catch (e) { ElMessage.error('加载平台配置失败: ' + e.message); }
finally { platformConfigsLoading.value = false; }
};
const showPlatformConfigDialog = (row = null) => {
if (row) {
platformConfigDialogTitle.value = '编辑配置'; editingPlatformConfigPlatform.value = row.platform;
Object.assign(platformConfigForm, { ...row });
} else {
platformConfigDialogTitle.value = '新增配置'; editingPlatformConfigPlatform.value = null;
platformConfigForm.platform = ''; platformConfigForm.name = ''; platformConfigForm.icon = ''; platformConfigForm.website_url = ''; platformConfigForm.api_endpoint = '';
platformConfigForm.min_words = 300; platformConfigForm.max_words = 3000; platformConfigForm.requires_image = false;
platformConfigForm.image_count_min = 0; platformConfigForm.image_count_max = 0; platformConfigForm.default_format = ''; platformConfigForm.is_active = true;
}
platformConfigDialogVisible.value = true;
};
const savePlatformConfig = async () => {
try {
if (editingPlatformConfigPlatform.value) { await api.put(`/api/platform-config/${platformConfigForm.platform}`, platformConfigForm); ElMessage.success('更新成功'); }
else { await api.post('/api/platform-config', platformConfigForm); ElMessage.success('创建成功'); }
platformConfigDialogVisible.value = false; await loadPlatformConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const systemConfigs = ref([]);
const systemConfigsLoading = ref(false);
const systemConfigDialogVisible = ref(false);
@@ -841,6 +969,25 @@
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const assistantPrompt = ref('');
const assistantEnabled = ref(true);
const assistantSaving = ref(false);
const loadAssistantConfig = async () => {
try {
const configs = await api.get('/api/admin/systemconfigs');
const cfg = configs.find(c => c.key === 'assistant_system_prompt');
assistantPrompt.value = cfg ? (typeof cfg.value === 'string' ? cfg.value : '') : '';
} catch (e) { console.error('加载AI助手配置失败', e); }
};
const saveAssistantPrompt = async () => {
assistantSaving.value = true;
try {
await api.post('/api/admin/systemconfigs', { key: 'assistant_system_prompt', value: assistantPrompt.value, description: 'AI 助手系统提示词' });
ElementPlus.ElMessage.success('保存成功');
} catch (e) { ElementPlus.ElMessage.error('保存失败: ' + e.message); }
finally { assistantSaving.value = false; }
};
const loadCategories = async () => {
categoriesLoading.value = true;
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
@@ -896,10 +1043,10 @@
const tabLoaders = {
cases: loadCases, tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers,
categories: loadCategories, sources: loadSources,
orgs: loadOrgs,
orgs: loadOrgs, assistant: loadAssistantConfig,
};
const loadedTabs = new Set(['cases']);
@@ -924,6 +1071,7 @@
casePage, casePageSize, paginatedCases,
taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
platformConfigs, platformConfigsLoading, platformConfigDialogVisible, platformConfigForm, platformConfigDialogTitle, showPlatformConfigDialog, savePlatformConfig, editingPlatformConfigPlatform,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
categoryPage, categoryPageSize, paginatedCategories,
@@ -934,7 +1082,8 @@
logout, currentUser, isAdmin, redirectToPage,
users, usersLoading, userDialogVisible, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
fetchUsers, addUser, submitUser, deleteUser,
logType, logDate, logContent, logsLoading, fetchLogs
logType, logDate, logContent, logsLoading, fetchLogs,
assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt,
};
}
});
@@ -944,5 +1093,6 @@
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+361
View File
@@ -0,0 +1,361 @@
(function () {
if (document.getElementById('ai-assistant-styles')) return;
var style = document.createElement('style');
style.id = 'ai-assistant-styles';
style.textContent = '\
.ai-assistant-btn {\
position: fixed; bottom: 24px; right: 24px;\
z-index: 99999;\
width: 52px; height: 52px;\
border-radius: 50%;\
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
color: #fff;\
border: none;\
cursor: pointer;\
box-shadow: 0 4px 16px rgba(37,99,235,0.35);\
display: flex; align-items: center; justify-content: center;\
font-size: 22px;\
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\
}\
.ai-assistant-btn:hover { transform: scale(1.1); box-shadow: 0 6px 24px rgba(37,99,235,0.45); }\
.ai-assistant-btn.open { transform: rotate(45deg); background: #f56c6c; box-shadow: 0 4px 16px rgba(245,108,108,0.35); }\
.ai-assistant-panel {\
position: fixed; bottom: 88px; right: 24px;\
z-index: 99998;\
width: 380px; height: 560px;\
background: #fff;\
border-radius: 16px;\
box-shadow: 0 8px 40px rgba(0,0,0,0.15);\
display: none;\
flex-direction: column;\
overflow: hidden;\
animation: aiSlideUp 0.3s ease-out;\
border: 1px solid #e5e7eb;\
}\
.ai-assistant-panel.open { display: flex; }\
@keyframes aiSlideUp {\
from { opacity: 0; transform: translateY(20px) scale(0.95); }\
to { opacity: 1; transform: translateY(0) scale(1); }\
}\
.ai-assistant-header {\
padding: 14px 16px;\
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
color: #fff;\
font-weight: 600;\
font-size: 15px;\
display: flex; align-items: center; gap: 8px;\
flex-shrink: 0;\
}\
.ai-assistant-header .ai-badge {\
background: rgba(255,255,255,0.2);\
padding: 2px 8px; border-radius: 10px;\
font-size: 10px; font-weight: 400;\
}\
.ai-assistant-messages {\
flex: 1; overflow-y: auto;\
padding: 12px 16px;\
background: #f8f9fb;\
display: flex; flex-direction: column; gap: 10px;\
}\
.ai-assistant-msg {\
max-width: 85%;\
padding: 10px 14px;\
border-radius: 12px;\
font-size: 13px;\
line-height: 1.6;\
word-break: break-word;\
white-space: pre-wrap;\
}\
.ai-assistant-msg.ai {\
background: #fff;\
border: 1px solid #e5e7eb;\
color: #303133;\
align-self: flex-start;\
border-bottom-left-radius: 4px;\
}\
.ai-assistant-msg.user {\
background: #2563eb;\
color: #fff;\
align-self: flex-end;\
border-bottom-right-radius: 4px;\
}\
.ai-assistant-msg .msg-time {\
font-size: 10px; opacity: 0.6;\
margin-top: 4px; display: block;\
}\
.ai-assistant-typing {\
align-self: flex-start;\
display: flex; gap: 4px;\
padding: 12px 16px;\
background: #fff;\
border: 1px solid #e5e7eb;\
border-radius: 12px;\
border-bottom-left-radius: 4px;\
}\
.ai-assistant-typing span {\
width: 6px; height: 6px;\
background: #909399;\
border-radius: 50%;\
animation: aiBounce 1.4s ease-in-out infinite;\
}\
.ai-assistant-typing span:nth-child(2) { animation-delay: 0.2s; }\
.ai-assistant-typing span:nth-child(3) { animation-delay: 0.4s; }\
@keyframes aiBounce {\
0%, 60%, 100% { transform: translateY(0); }\
30% { transform: translateY(-6px); }\
}\
.ai-assistant-input {\
display: flex; align-items: center;\
padding: 10px 12px;\
border-top: 1px solid #e5e7eb;\
background: #fff;\
flex-shrink: 0;\
gap: 8px;\
}\
.ai-assistant-input textarea {\
flex: 1;\
border: 1px solid #d1d5db;\
border-radius: 8px;\
padding: 8px 12px;\
font-size: 13px;\
outline: none;\
resize: none;\
height: 36px;\
line-height: 20px;\
font-family: inherit;\
transition: border-color 0.2s;\
}\
.ai-assistant-input textarea:focus { border-color: #2563eb; }\
.ai-assistant-send {\
width: 36px; height: 36px;\
border-radius: 8px;\
background: #2563eb;\
color: #fff;\
border: none;\
cursor: pointer;\
font-size: 16px;\
display: flex; align-items: center; justify-content: center;\
transition: all 0.2s;\
flex-shrink: 0;\
}\
.ai-assistant-send:hover { background: #1d4ed8; }\
.ai-assistant-send:disabled { background: #c0c4cc; cursor: not-allowed; }\
.ai-assistant-greeting {\
text-align: center;\
padding: 20px 16px;\
color: #909399;\
font-size: 13px;\
}\
.ai-assistant-greeting .greeting-icon {\
font-size: 36px; margin-bottom: 8px;\
}\
.ai-assistant-greeting h4 {\
color: #303133; font-size: 15px;\
margin: 0 0 6px;\
}\
@media (max-width: 480px) {\
.ai-assistant-panel {\
right: 12px; left: 12px;\
width: auto;\
height: 480px;\
bottom: 80px;\
}\
.ai-assistant-btn { right: 16px; bottom: 16px; }\
}\
';
document.head.appendChild(style);
var token = localStorage.getItem('authToken');
if (!token) return;
var pageMap = {
'/': 'dashboard', '/index.html': 'dashboard',
'topics.html': 'topics', 'metrics.html': 'metrics',
'calendar.html': 'calendar', 'assets.html': 'assets',
'tasks.html': 'tasks', 'platforms.html': 'platforms',
'admin.html': 'admin'
};
var path = window.location.pathname;
var currentPage = pageMap[path] || pageMap['/'];
var greettings = {
'dashboard': '👋 欢迎回来!我可以帮你了解仪表盘上的统计数据、模块状态的含义,或指导你如何快速定位待处理选题。',
'topics': '📝 这里是选题管理。我可以帮你了解如何创建选题、设置评分、编辑内容,或如何使用预览功能查看各平台文章效果。',
'metrics': '📊 数据分析页展示内容表现。我可以帮你解读图表数据、分析各平台效果差异,或建议如何提升内容互动率。',
'calendar': '📅 内容日历帮你规划发布节奏。我可以教你如何创建计划、调整日期,或优化内容排期策略。',
'assets': '🖼️ 素材库管理图片和文件。我可以指导你如何上传素材、添加标签分类,或关联到相关选题。',
'tasks': '⚡ 任务页面跟踪所有创作任务。我可以解释各任务的含义、查看执行进度,或排查任务失败原因。',
'platforms': '🔗 平台配置管理各发布渠道。我可以帮你了解如何配置平台参数、设置发布格式。',
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
};
var HISTORY_KEY = 'ai_assistant_history_' + currentPage;
var saved = [];
try { saved = JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch(e) {}
var container = document.createElement('div');
container.innerHTML = '\
<button class="ai-assistant-btn" id="aiAssistantBtn" title="AI 助手">\
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>\
<path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/>\
<circle cx="9" cy="10" r="1" fill="currentColor"/>\
<circle cx="15" cy="10" r="1" fill="currentColor"/>\
<path d="M12 14v2"/>\
</svg>\
</button>\
<div class="ai-assistant-panel" id="aiAssistantPanel">\
<div class="ai-assistant-header">\
<span>🤖 AI 助手</span>\
<span class="ai-badge">' + currentPage + '</span>\
</div>\
<div class="ai-assistant-messages" id="aiMessages">\
</div>\
<div class="ai-assistant-input">\
<textarea id="aiInput" rows="1" placeholder="输入你的问题..." maxlength="500"></textarea>\
<button class="ai-assistant-send" id="aiSendBtn">\
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\
<line x1="22" y1="2" x2="11" y2="13"/>\
<polygon points="22 2 15 22 11 13 2 9 22 2"/>\
</svg>\
</button>\
</div>\
</div>\
';
document.body.appendChild(container);
var btn = document.getElementById('aiAssistantBtn');
var panel = document.getElementById('aiAssistantPanel');
var messagesEl = document.getElementById('aiMessages');
var input = document.getElementById('aiInput');
var sendBtn = document.getElementById('aiSendBtn');
function addMessage(role, content) {
var div = document.createElement('div');
div.className = 'ai-assistant-msg ' + role;
div.textContent = content;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function showGreeting() {
messagesEl.innerHTML = '\
<div class="ai-assistant-greeting">\
<div class="greeting-icon">🤖</div>\
<h4>宇之然 AI 助手</h4>\
<p>' + (greettings[currentPage] || '👋 你好!有什么可以帮助你的?') + '</p>\
</div>';
}
function showTyping() {
var div = document.createElement('div');
div.className = 'ai-assistant-typing';
div.id = 'aiTyping';
div.innerHTML = '<span></span><span></span><span></span>';
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function removeTyping() {
var t = document.getElementById('aiTyping');
if (t) t.remove();
}
function saveHistory(history) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-50)));
} catch(e) {}
}
async function sendMessage(text) {
if (!text.trim()) return;
input.value = '';
input.style.height = '36px';
addMessage('user', text);
if (messagesEl.querySelector('.ai-assistant-greeting')) {
messagesEl.querySelector('.ai-assistant-greeting').remove();
}
sendBtn.disabled = true;
var history = [];
var msgs = messagesEl.querySelectorAll('.ai-assistant-msg');
msgs.forEach(function(m) {
history.push({ role: m.classList.contains('user') ? 'user' : 'assistant', content: m.textContent });
});
showTyping();
try {
var resp = await fetch('/api/assistant/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ message: text, page: currentPage, history: history })
});
removeTyping();
if (resp.ok) {
var data = await resp.json();
addMessage('assistant', data.reply || '已收到');
saveHistory(history.concat([
{ role: 'user', content: text },
{ role: 'assistant', content: data.reply || '已收到' }
]));
} else {
addMessage('assistant', '抱歉,请求失败,请稍后重试。');
}
} catch(e) {
removeTyping();
addMessage('assistant', '网络异常,请检查连接后重试。');
}
sendBtn.disabled = false;
input.focus();
}
// Open/close
var isOpen = false;
btn.addEventListener('click', function() {
isOpen = !isOpen;
btn.classList.toggle('open', isOpen);
panel.classList.toggle('open', isOpen);
if (isOpen) {
var msgs = messagesEl.querySelectorAll('.ai-assistant-msg');
if (msgs.length === 0) {
// Load saved or show greeting
if (saved.length > 0) {
saved.forEach(function(m) { addMessage(m.role, m.content); });
} else {
showGreeting();
}
}
input.focus();
}
});
// Send on click
sendBtn.addEventListener('click', function() { sendMessage(input.value); });
// Send on Enter (Shift+Enter for newline)
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(input.value);
}
});
// Auto-resize textarea
input.addEventListener('input', function() {
this.style.height = '36px';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen) {
isOpen = false;
btn.classList.remove('open');
panel.classList.remove('open');
}
});
window.aiAssistantOpen = function() { btn.click(); };
window.aiAssistantClose = function() { if (isOpen) btn.click(); };
})();
+1
View File
@@ -257,5 +257,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -246,5 +246,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+3 -2
View File
@@ -96,9 +96,9 @@
<div class="calendar-header">
<h2 class="calendar-title"><el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 内容日历</h2>
<div class="calendar-nav">
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon></el-button>
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon> 上月</el-button>
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
<el-button @click="nextMonth" size="large"><el-icon><IconArrowRight /></el-icon></el-button>
<el-button @click="nextMonth" size="large">下月 <el-icon><IconArrowRight /></el-icon></el-button>
<el-button type="primary" @click="goToday">今天</el-button>
</div>
</div>
@@ -490,5 +490,6 @@ function getDayMeta(year, month, day) {
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+27 -2
View File
@@ -102,8 +102,13 @@
</div>
<div class="module-content">
<div><span>最后运行</span><span>{{ mod.last_run }}</span></div>
<div><span>下次运行</span><span>{{ mod.next_run }}</span></div>
<div><span>今日任务</span><span>{{ mod.task_count }} 个</span></div>
<div v-if="mod.success_rate !== null"><span>成功率</span><span>{{ mod.success_rate }}%</span></div>
<div><span>成功率</span><span>{{ mod.success_rate > 0 ? mod.success_rate + '%' : '暂无' }}</span></div>
<div style="margin-top:10px; border-bottom:none;">
<el-button v-if="mod.id === 'scheduled_optimize_sources'" size="small" type="primary" @click="triggerModule(mod.id)" :loading="runningModule === mod.id">立即运行</el-button>
<el-button v-if="mod.id === 'scheduled_metrics_sync'" size="small" type="primary" @click="triggerModule(mod.id)" :loading="runningModule === mod.id">立即运行</el-button>
</div>
</div>
</div>
</div>
@@ -158,7 +163,8 @@
schedulerStatus: '',
schedulerRunning: false,
upcomingEntries: [],
loadingPlan: false
loadingPlan: false,
runningModule: null
};
},
methods: {
@@ -276,6 +282,24 @@
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
window.location.href = url;
},
async triggerModule(modId) {
this.runningModule = modId;
try {
const token = localStorage.getItem('authToken');
const endpoint = modId === 'scheduled_optimize_sources' ? '/api/system/optimize-sources/run' : '/api/system/metrics-sync/run';
const resp = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } });
if (resp.ok) {
const data = await resp.json();
this.$message.success(data.message || '执行成功');
this.fetchModules();
} else {
const err = await resp.json().catch(() => ({}));
this.$message.error(err.detail || '执行失败');
}
} catch (e) {
this.$message.error('执行失败: ' + e.message);
} finally { this.runningModule = null; }
},
redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + page;
}
@@ -312,5 +336,6 @@
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -369,5 +369,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -246,5 +246,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -292,5 +292,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+26 -11
View File
@@ -8,6 +8,9 @@
<link rel="stylesheet" href="theme-modern.css">
<style>
.selected-count { color: var(--color-text-secondary); font-size: var(--font-size-body); margin-left: auto; }
.preview-dialog-custom .el-dialog__body { overflow-y: auto; max-height: calc(90vh - 120px); padding: 16px 20px; }
.preview-dialog-custom .preview-body { max-height: none !important; min-height: 300px; overflow: visible; }
.preview-dialog-custom .preview-body img { max-width: 100%; }
.topic-card-list { display: none; }
@media (max-width: 768px) {
.el-table { display: none; }
@@ -50,7 +53,7 @@
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
</div>
<el-table :data="paginatedTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
<el-table ref="topicTable" :data="paginatedTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
@@ -160,7 +163,7 @@
</div>
</div>
<div style="width:100%;">
<div v-if="!editing" class="preview-body" style="min-height:300px; height:68vh; border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:20px; overflow-y:auto; width:100%; line-height:1.8;" v-html="currentPreviewBody"></div>
<div v-if="!editing" class="preview-body" style="border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:20px; width:100%; line-height:1.8;" v-html="currentPreviewBody"></div>
<div v-else ref="editor" contenteditable="true" style="min-height:300px; height:68vh; border:1px solid #409eff; border-radius:8px; background:#fff; padding:20px; overflow-y:auto; width:100%; line-height:1.8; outline:none; box-shadow:0 0 0 2px rgba(64,158,255,0.2);" @input="editContent = $event.target.innerHTML"></div>
</div>
</div>
@@ -237,11 +240,13 @@ const TopicsApp = {
currentPreviewBody() {
const html = this.platformContents[this.previewPlatform];
if (!html) return '';
if (html.length < 50) return '<div style="padding:40px;text-align:center;color:#909399;"><p style="font-size:36px;margin:0 0 12px;">📝</p><p>该平台内容暂未生成或内容为空</p><p style="font-size:13px;margin-top:8px;">可点击上方「批量创作」重新生成,或在编辑模式下手动添加内容</p></div>';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const body = doc.body;
if (!body) return html;
if (body.innerHTML.length < 30) return '<div style="padding:40px;text-align:center;color:#909399;"><p style="font-size:36px;margin:0 0 12px;">📝</p><p>内容为空,请编辑或重新生成</p></div>';
body.querySelectorAll('script, nav, .header, .tags, footer, .interaction').forEach(el => el.remove());
return body.innerHTML;
} catch (e) { console.error('解析 HTML 失败:', e); return html; }
@@ -340,8 +345,18 @@ const TopicsApp = {
},
platformName(platform) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[platform] || platform; },
copyContent(platform) {
if (!this.previewTopic || !this.previewTopic.content) { this.$message.warning('暂无内容可复制'); return; }
navigator.clipboard.writeText(`标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`)
const html = this.platformContents[platform];
if (!html) { this.$message.warning('暂无内容可复制'); return; }
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const titleEl = doc.querySelector('h1');
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
const body = doc.body;
if (body) {
body.querySelectorAll('script, nav, .header, .tags, footer, .interaction').forEach(el => el.remove());
}
const text = body ? body.textContent.trim() : html.replace(/<[^>]+>/g, '').trim();
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
.then(() => this.$message.success(`已复制内容,请前往${platform}粘贴发布`))
.catch(() => this.$message.error('复制失败,请手动复制'));
},
@@ -433,13 +448,12 @@ const TopicsApp = {
}
},
toggleSelectAll() {
const visible = this.filteredTopics.map(t => t.id);
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
if (allChecked) {
this.selectedTopicIds = this.selectedTopicIds.filter(id => !visible.includes(id));
} else {
const existing = new Set(this.selectedTopicIds);
for (const id of visible) { if (!existing.has(id)) { this.selectedTopicIds.push(id); } }
const table = this.$refs.topicTable;
if (!table) return;
const visible = this.filteredTopics;
const allChecked = visible.every(t => this.selectedTopicIds.includes(t.id));
for (const row of visible) {
table.toggleRowSelection(row, !allChecked);
}
},
formatDate(dateStr) {
@@ -467,5 +481,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>