fix: content quality, image format, task monitor, calendar data source, search UI & sort
This commit is contained in:
@@ -321,6 +321,27 @@ def _extract_zhihu_post_id(url: str) -> str:
|
||||
raise ValueError("无法从URL中提取知乎文章/回答ID")
|
||||
|
||||
|
||||
def _fetch_zhihu_page(post_id: str) -> dict:
|
||||
"""多种方式尝试获取知乎文章数据,返回原始 JSON"""
|
||||
UA = _ZHIHU_UA
|
||||
# 方式1:专栏 API
|
||||
urls = [
|
||||
f"https://zhuanlan.zhihu.com/api/posts/{post_id}",
|
||||
f"https://www.zhihu.com/api/v4/posts/{post_id}",
|
||||
f"https://www.zhihu.com/api/v4/answers/{post_id}",
|
||||
]
|
||||
exceptions = []
|
||||
for url in urls:
|
||||
try:
|
||||
resp = http_requests.get(url, headers={"User-Agent": UA}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
exceptions.append(f"{url} → {resp.status_code}")
|
||||
except Exception as e:
|
||||
exceptions.append(f"{url} → {e}")
|
||||
raise RuntimeError(f"知乎API已封禁,无法获取数据({'; '.join(exceptions)})")
|
||||
|
||||
|
||||
class ZhihuFetchRequest(BaseModel):
|
||||
topic_id: str
|
||||
zhihu_url: str
|
||||
@@ -338,17 +359,9 @@ def fetch_zhihu_metrics(
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
post_id = _extract_zhihu_post_id(data.zhihu_url)
|
||||
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
|
||||
|
||||
try:
|
||||
resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15)
|
||||
if resp.status_code == 404:
|
||||
api_url = f"https://www.zhihu.com/api/v4/answers/{post_id}"
|
||||
resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"知乎API返回 {resp.status_code}")
|
||||
|
||||
raw = resp.json()
|
||||
raw = _fetch_zhihu_page(post_id)
|
||||
platform = "zhihu"
|
||||
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""发布管理 API"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
from ..database import get_db
|
||||
@@ -86,8 +86,9 @@ async def create_publish_record(
|
||||
))
|
||||
|
||||
topic.status = '已发布'
|
||||
topic.updated_at = datetime.now()
|
||||
topic.published_at = datetime.now().date()
|
||||
_now = datetime.now(timezone(timedelta(hours=8)))
|
||||
topic.updated_at = _now
|
||||
topic.published_at = _now.date()
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
db.refresh(topic)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import logging
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import SearchProvider
|
||||
from .auth import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/search-providers", tags=["search_providers"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_providers(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
providers = db.query(SearchProvider).order_by(SearchProvider.priority).all()
|
||||
return [p.to_dict() for p in providers]
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_provider(data: dict, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
p = SearchProvider(
|
||||
name=data.get("name", ""),
|
||||
provider_type=data.get("provider_type", ""),
|
||||
api_key=data.get("api_key", ""),
|
||||
api_url=data.get("api_url", ""),
|
||||
console_url=data.get("console_url", ""),
|
||||
priority=data.get("priority", 99),
|
||||
enabled=data.get("enabled", True),
|
||||
daily_limit=data.get("daily_limit", 1500),
|
||||
extra_config=data.get("extra_config", {}),
|
||||
)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p.to_dict()
|
||||
|
||||
|
||||
@router.put("/{provider_id}")
|
||||
def update_provider(provider_id: int, data: dict, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
for key in ("name", "provider_type", "api_key", "api_url", "console_url", "priority", "enabled", "daily_limit"):
|
||||
if key in data:
|
||||
setattr(p, key, data[key])
|
||||
if "extra_config" in data:
|
||||
p.extra_config = data["extra_config"]
|
||||
p.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{provider_id}")
|
||||
def delete_provider(provider_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{provider_id}/test")
|
||||
def test_provider(provider_id: int, data: dict = {}, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
query = data.get("query", "测试搜索")
|
||||
try:
|
||||
test_key = data.get("api_key") or p.api_key
|
||||
test_url = data.get("api_url") or p.api_url
|
||||
if p.provider_type == "baidu":
|
||||
import requests
|
||||
resp = requests.post(
|
||||
test_url,
|
||||
headers={"Authorization": f"Bearer {test_key}", "Content-Type": "application/json"},
|
||||
json={"messages": [{"role": "user", "content": query}], "search_source": "baidu_search_v2", "resource_type_filter": [{"type": "web", "top_k": 3}]},
|
||||
timeout=15
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
return {"ok": True, "results": resp.json().get("results", [])[:3]}
|
||||
elif p.provider_type == "qiniu":
|
||||
import requests
|
||||
resp = requests.post(
|
||||
test_url,
|
||||
headers={"Authorization": f"Bearer {test_key}", "Content-Type": "application/json"},
|
||||
json={"query": query, "max_results": 3, "search_type": "web"},
|
||||
timeout=15
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
return {"ok": True, "results": resp.json().get("results", resp.json().get("data", []))[:3]}
|
||||
elif p.provider_type == "tinyfish":
|
||||
import requests
|
||||
resp = requests.get(
|
||||
test_url,
|
||||
params={"query": query, "max_results": 3},
|
||||
headers={"X-API-Key": test_key},
|
||||
timeout=15
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
return {"ok": True, "results": resp.json().get("results", resp.json().get("data", []))[:3]}
|
||||
elif p.provider_type == "bing":
|
||||
import requests
|
||||
resp = requests.get(
|
||||
test_url,
|
||||
params={"q": query, "count": 3, "mkt": "zh-CN"},
|
||||
headers={"Ocp-Apim-Subscription-Key": test_key},
|
||||
timeout=15
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
return {"ok": True, "results": resp.json().get("webPages", {}).get("value", [])[:3]}
|
||||
elif p.provider_type == "mcp":
|
||||
import subprocess, json as _json
|
||||
mcp_script = Path(__file__).resolve().parent.parent.parent.parent.parent / "scripts" / "mcp_search_server.py"
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(mcp_script), "--query", query],
|
||||
capture_output=True, text=True, timeout=90,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": f"子进程失败: {r.stderr[:200]}"}
|
||||
return {"ok": True, "results": _json.loads(r.stdout)[:3]}
|
||||
return {"ok": False, "error": f"Unknown provider_type: {p.provider_type}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/reset-usage")
|
||||
def reset_usage(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
db.query(SearchProvider).update({SearchProvider.usage_today: 0})
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -3,16 +3,17 @@ import subprocess
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, date
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
import os
|
||||
import os
|
||||
import json
|
||||
from ..database import get_db
|
||||
from ..models import Topic, Article, TaskConfig, TaskLog
|
||||
from ..core.generator import run_creator, get_generator_status
|
||||
from ..core.optimizer import run_optimizer, get_optimizer_status
|
||||
from ..core.collector import run_collector, get_collector_status
|
||||
from ..core.generator import run_creator, get_generator_status, _running_processes as _generator_running
|
||||
from ..core.optimizer import run_optimizer, get_optimizer_status, _running_processes as _optimizer_running
|
||||
from ..core.collector import run_collector, get_collector_status, _running_processes as _collector_running
|
||||
import threading
|
||||
from ..core.sync import sync_all_topics
|
||||
from ..core.scheduler import scheduler
|
||||
@@ -26,6 +27,32 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
_active_monitors: Dict[int, dict] = {}
|
||||
|
||||
def _monitor_subprocess(log_id: int, proc, module_id: str, task_name: str, db_session_factory):
|
||||
"""阻塞等待子进程退出(最长 1800s),完成后更新 task_logs"""
|
||||
try:
|
||||
returncode = proc.wait(timeout=1800)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
returncode = -1
|
||||
logger.warning("Subprocess %s (pid=%s) killed after 1800s timeout", module_id, proc.pid)
|
||||
finished_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
db = db_session_factory()
|
||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||
if log:
|
||||
log.status = "success" if returncode == 0 else "failed"
|
||||
log.finished_at = finished_at
|
||||
if log.started_at:
|
||||
log.duration = int((finished_at - log.started_at).total_seconds())
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to update task log %s: %s", log_id, e)
|
||||
finally:
|
||||
_active_monitors.pop(log_id, None)
|
||||
|
||||
def _aggregate_status_counts(q):
|
||||
"""聚合状态计数,兼容中英文状态值"""
|
||||
raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
@@ -60,12 +87,22 @@ def get_status(db: Session = Depends(get_db)):
|
||||
}
|
||||
}
|
||||
|
||||
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
|
||||
@router.post("/generate/run")
|
||||
def trigger_generation(topic_id: Optional[str] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
logger.info(f"Generation triggered by {current_user.username}, topic_id={topic_id}")
|
||||
try:
|
||||
result = run_creator(topic_id)
|
||||
return {"message": "内容创作已后台启动", "pid": result.get("pid")}
|
||||
from ..database import SessionLocal
|
||||
log = TaskLog(module_id="scheduled_generate", task_name="🤖 内容创作", status="running", message="内容创作已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
|
||||
db.add(log)
|
||||
db.commit()
|
||||
log_id = log.id
|
||||
proc_info = result.get("proc") or result
|
||||
proc = _generator_running.get("generator", {}).get("process") if "pid" in result else None
|
||||
if proc:
|
||||
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_generate", "🤖 内容创作", SessionLocal), daemon=True)
|
||||
t.start()
|
||||
return {"message": "内容创作已后台启动", "pid": result.get("pid"), "log_id": log_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -76,12 +113,21 @@ def generation_status():
|
||||
return {"status": "idle", "message": "当前无运行中的创作任务"}
|
||||
return status
|
||||
|
||||
@router.post("/collect/run", dependencies=[Depends(get_current_user)])
|
||||
@router.post("/collect/run")
|
||||
def trigger_collection(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
logger.info(f"Manual collection triggered by {current_user.username}")
|
||||
try:
|
||||
result = run_collector()
|
||||
return {"message": "内容采集已后台启动", "result": result}
|
||||
from ..database import SessionLocal
|
||||
log = TaskLog(module_id="scheduled_collect", task_name="📡 内容采集", status="running", message="内容采集已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
|
||||
db.add(log)
|
||||
db.commit()
|
||||
log_id = log.id
|
||||
proc = _collector_running.get("collector", {}).get("process") if "pid" in result else None
|
||||
if proc:
|
||||
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_collect", "📡 内容采集", SessionLocal), daemon=True)
|
||||
t.start()
|
||||
return {"message": "内容采集已后台启动", "result": result, "log_id": log_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -92,11 +138,20 @@ def collection_status():
|
||||
return {"status": "idle", "message": "当前无运行中的采集任务"}
|
||||
return status
|
||||
|
||||
@router.post("/review/run", dependencies=[Depends(get_current_user)])
|
||||
@router.post("/review/run")
|
||||
def trigger_review(topic_ids: Optional[List[str]] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
try:
|
||||
result = run_optimizer(topic_ids)
|
||||
return {"message": "合规审查已后台启动", "pid": result.get("pid")}
|
||||
from ..database import SessionLocal
|
||||
log = TaskLog(module_id="scheduled_optimize", task_name="🔍 合规审查", status="running", message="合规审查已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
|
||||
db.add(log)
|
||||
db.commit()
|
||||
log_id = log.id
|
||||
proc = _optimizer_running.get("optimizer", {}).get("process") if "pid" in result else None
|
||||
if proc:
|
||||
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_optimize", "🔍 合规审查", SessionLocal), daemon=True)
|
||||
t.start()
|
||||
return {"message": "合规审查已后台启动", "pid": result.get("pid"), "log_id": log_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -177,17 +232,24 @@ def trigger_metrics_sync():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/refresh-search-cache/run")
|
||||
def trigger_refresh_search_cache():
|
||||
def trigger_refresh_search_cache(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
try:
|
||||
import sys as sys_mod
|
||||
scripts_dir = Path(__file__).parent.parent.parent.parent / "scripts"
|
||||
from ..database import SessionLocal as _ss
|
||||
proc = subprocess.Popen(
|
||||
[sys_mod.executable, str(scripts_dir / "opencode_search.py"), "--refresh-cache"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
cwd=scripts_dir.parent.parent
|
||||
)
|
||||
logger.info("Search cache refresh started (pid=%s)", proc.pid)
|
||||
return {"message": "搜索缓存刷新已后台启动", "pid": proc.pid}
|
||||
log = TaskLog(module_id="scheduled_refresh_search_cache", task_name="🔍 搜索缓存", status="running", message="搜索缓存刷新已启动", triggered_by="manual", started_at=datetime.now(timezone.utc), result_data={"pid": proc.pid})
|
||||
db.add(log)
|
||||
db.commit()
|
||||
log_id = log.id
|
||||
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_refresh_search_cache", "🔍 搜索缓存", _ss), daemon=True)
|
||||
t.start()
|
||||
return {"message": "搜索缓存刷新已后台启动", "pid": proc.pid, "log_id": log_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -259,6 +321,7 @@ def get_modules_status(db: Session = Depends(get_db)):
|
||||
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00", "params_desc": {"auto_pass_threshold": "自动通过分数阈值"}},
|
||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00", "params_desc": {}},
|
||||
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00", "params_desc": {}},
|
||||
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*", "params_desc": {}},
|
||||
}
|
||||
|
||||
modules = []
|
||||
|
||||
@@ -19,6 +19,7 @@ MODULES = {
|
||||
"scheduled_optimize": "🔍 合规审查",
|
||||
"scheduled_optimize_sources": "📡 信息源优化",
|
||||
"scheduled_metrics_sync": "📊 指标同步",
|
||||
"scheduled_task_monitor": "⏰ 任务监控",
|
||||
}
|
||||
|
||||
@router.get("", response_model=List[TaskLogResponse])
|
||||
@@ -68,6 +69,29 @@ def list_modules(db: Session = Depends(get_db), admin_user=Depends(get_current_a
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/log-types")
|
||||
def list_log_types(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||
used = db.query(TaskLog.module_id).distinct().all()
|
||||
used_ids = [r[0] for r in used]
|
||||
result = []
|
||||
for mid, name in MODULES.items():
|
||||
if mid in used_ids or True:
|
||||
log_file_map = {
|
||||
"scheduled_refresh_search_cache": "opencode_search",
|
||||
"scheduled_fetch_trends": "trends",
|
||||
"scheduled_collect": "collector",
|
||||
"scheduled_generate": "creator",
|
||||
"scheduled_optimize": "optimizer",
|
||||
"scheduled_optimize_sources": "collector",
|
||||
"scheduled_metrics_sync": "sync",
|
||||
}
|
||||
result.append({"module_id": mid, "name": name, "log_file": log_file_map.get(mid, mid)})
|
||||
for mid in used_ids:
|
||||
if mid not in MODULES:
|
||||
result.append({"module_id": mid, "name": mid, "log_file": mid})
|
||||
return result
|
||||
|
||||
@router.get("/{log_id}", response_model=TaskLogResponse)
|
||||
def get_task_log(log_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
|
||||
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import threading
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
@@ -8,6 +9,8 @@ from ..models import ContentTask, Topic
|
||||
from ..schemas import ContentTaskCreate, ContentTaskResponse
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
_creator_semaphore = threading.Semaphore(3)
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@@ -240,6 +243,7 @@ def _get_module_detail_data(module_id: str, db, ROOT, DATA_DIR, LOGS_DIR, today_
|
||||
"scheduled_optimize": {"name": "🔍 合规审查", "description": "LLM 审查已创作文章,检查合规、打分数、优化建议"},
|
||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "description": "AI 分析当前类别和信息源的市场匹配度,给出调整建议"},
|
||||
"scheduled_metrics_sync": {"name": "📊 指标同步", "description": "从各平台公开 API 获取已发布文章的互动数据(点赞、阅读、评论等)"},
|
||||
"scheduled_task_monitor": {"name": "⏰ 任务监控", "description": "每小时自动检查卡死/中断任务,标记为失败以便重新执行"},
|
||||
}
|
||||
|
||||
meta = MODULE_META.get(module_id, {"name": module_id, "description": ""})
|
||||
@@ -430,7 +434,6 @@ def run_creator_task(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -448,44 +451,51 @@ def run_creator_task(
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
from ..core.generator import run_creator
|
||||
|
||||
def _run():
|
||||
from ..database import SessionLocal
|
||||
from ..core.generator import run_creator_blocking
|
||||
from datetime import datetime, timezone
|
||||
new_db = SessionLocal()
|
||||
import traceback
|
||||
_creator_semaphore.acquire()
|
||||
try:
|
||||
new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if new_task:
|
||||
new_task.message = "创作脚本运行中..."
|
||||
new_task.progress = 30
|
||||
new_db.commit()
|
||||
result = run_creator(topic_id)
|
||||
new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if new_task:
|
||||
finished = datetime.now(timezone.utc)
|
||||
new_task.status = "completed"
|
||||
new_task.finished_at = finished
|
||||
new_task.progress = 100
|
||||
new_task.message = "创作完成"
|
||||
new_task.result_data = result or {}
|
||||
if new_task.started_at:
|
||||
new_task.duration = int((finished - new_task.started_at).total_seconds())
|
||||
new_db.commit()
|
||||
except Exception as e:
|
||||
new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if new_task:
|
||||
finished = datetime.now(timezone.utc)
|
||||
new_task.status = "failed"
|
||||
new_task.finished_at = finished
|
||||
new_task.error_msg = str(e)
|
||||
if new_task.started_at:
|
||||
new_task.duration = int((finished - new_task.started_at).total_seconds())
|
||||
new_db.commit()
|
||||
finally:
|
||||
new_db.close()
|
||||
new_db = SessionLocal()
|
||||
try:
|
||||
new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if new_task:
|
||||
new_task.message = "创作脚本运行中..."
|
||||
new_task.progress = 30
|
||||
new_db.commit()
|
||||
|
||||
thread = threading.Thread(target=_run)
|
||||
result = run_creator_blocking(topic_id)
|
||||
|
||||
new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if new_task:
|
||||
finished = datetime.now(timezone.utc)
|
||||
new_task.status = "completed"
|
||||
new_task.finished_at = finished
|
||||
new_task.progress = 100
|
||||
new_task.message = "创作完成"
|
||||
new_task.result_data = {"stdout": (result or {}).get("stdout", "")[:2000]} if isinstance(result, dict) else {"raw": str(result)[:2000]}
|
||||
if new_task.started_at:
|
||||
new_task.duration = int((finished - new_task.started_at).total_seconds())
|
||||
new_db.commit()
|
||||
except Exception as e:
|
||||
new_db.rollback()
|
||||
new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if new_task:
|
||||
finished = datetime.now(timezone.utc)
|
||||
new_task.status = "failed"
|
||||
new_task.finished_at = finished
|
||||
new_task.error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
|
||||
if new_task.started_at:
|
||||
new_task.duration = int((finished - new_task.started_at).total_seconds())
|
||||
new_db.commit()
|
||||
finally:
|
||||
new_db.close()
|
||||
finally:
|
||||
_creator_semaphore.release()
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return task
|
||||
@@ -27,6 +27,8 @@ MODULES = {
|
||||
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00"},
|
||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00"},
|
||||
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"},
|
||||
"scheduled_reset_search_usage": {"name": "🔁 搜索用量重置", "cron": "00:05"},
|
||||
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
|
||||
}
|
||||
|
||||
def _log_task(module_id: str, status: str, message: str = None,
|
||||
@@ -107,6 +109,7 @@ class TaskScheduler:
|
||||
("scheduled_optimize", self._run_optimize, "合规审查"),
|
||||
("scheduled_optimize_sources", self._run_optimize_sources, "信息源优化"),
|
||||
("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"),
|
||||
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
|
||||
]
|
||||
|
||||
for module_id, fn, name in MODULE_JOBS:
|
||||
@@ -129,6 +132,17 @@ class TaskScheduler:
|
||||
)
|
||||
logger.info(f"调度任务: {module_id} -> {schedule}")
|
||||
|
||||
# 每小时运行的任务监控:检测卡死/中断任务
|
||||
self.scheduler.add_job(
|
||||
self._run_task_monitor,
|
||||
CronTrigger(hour='*/1'),
|
||||
id='scheduled_task_monitor',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
logger.info("调度任务: scheduled_task_monitor -> 每小时")
|
||||
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
logger.info("Scheduler started with dynamic schedule from TaskConfig")
|
||||
@@ -441,6 +455,95 @@ class TaskScheduler:
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Metrics sync failed: %s", e)
|
||||
|
||||
def _run_reset_search_usage(self):
|
||||
"""每日凌晨重置搜索 API 提供商用量计数"""
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_reset_search_usage", "running", started_at=started)
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import SearchProvider
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total = db.query(SearchProvider).update({SearchProvider.usage_today: 0, SearchProvider.last_used_at: None})
|
||||
db.commit()
|
||||
_log_task("scheduled_reset_search_usage", "success",
|
||||
message=f"已重置 {total} 个提供商用量",
|
||||
result_data={"reset_count": total},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.info("[Scheduled] Reset %d search providers usage", total)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
_log_task("scheduled_reset_search_usage", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[Scheduled] Reset search usage failed: %s", e)
|
||||
|
||||
def _run_task_monitor(self):
|
||||
"""每小时检查卡死/中断的任务,标记为失败"""
|
||||
started = datetime.now(timezone.utc)
|
||||
_log_task("scheduled_task_monitor", "running", started_at=started)
|
||||
stuck_tasklog_timeout = 7200 # 超过2小时视为卡死
|
||||
stuck_contenttask_timeout = 10800 # 超过3小时视为卡死
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import TaskLog, ContentTask
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff_tasklog = now.timestamp() - stuck_tasklog_timeout
|
||||
cutoff_content = now.timestamp() - stuck_contenttask_timeout
|
||||
marked = 0
|
||||
|
||||
# 检查 TaskLog 中卡死的 running 记录
|
||||
stuck_logs = db.query(TaskLog).filter(
|
||||
TaskLog.status == "running",
|
||||
TaskLog.started_at.isnot(None)
|
||||
).all()
|
||||
for log in stuck_logs:
|
||||
if log.started_at.timestamp() < cutoff_tasklog:
|
||||
log.status = "failed"
|
||||
log.finished_at = now
|
||||
log.error_trace = "系统监控:任务运行超时(超过2小时)或进程中断,已自动标记为失败"
|
||||
if log.started_at:
|
||||
log.duration = int((now - log.started_at).total_seconds())
|
||||
marked += 1
|
||||
logger.warning("[TaskMonitor] 标记 TaskLog %d (%s) 为失败(超时)", log.id, log.module_id)
|
||||
|
||||
# 检查 ContentTask 中卡死的 running 记录
|
||||
stuck_tasks = db.query(ContentTask).filter(
|
||||
ContentTask.status == "running",
|
||||
ContentTask.started_at.isnot(None)
|
||||
).all()
|
||||
for task in stuck_tasks:
|
||||
if task.started_at.timestamp() < cutoff_content:
|
||||
task.status = "failed"
|
||||
task.finished_at = now
|
||||
task.error_msg = "系统监控:任务运行超时(超过3小时)或进程中断,已自动标记为失败"
|
||||
if task.started_at:
|
||||
task.duration = int((now - task.started_at).total_seconds())
|
||||
marked += 1
|
||||
logger.warning("[TaskMonitor] 标记 ContentTask %s (%s) 为失败(超时)", task.task_id, task.stage)
|
||||
|
||||
if marked:
|
||||
db.commit()
|
||||
logger.info("[TaskMonitor] 已标记 %d 个卡死任务为失败", marked)
|
||||
|
||||
_log_task("scheduled_task_monitor", "success",
|
||||
message=f"检查完成,标记 {marked} 个卡死任务",
|
||||
result_data={"marked_failed": marked},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_log_task("scheduled_task_monitor", "failed",
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[TaskMonitor] 监控检查失败: %s", e)
|
||||
|
||||
def get_jobs(self):
|
||||
"""返回当前所有定时任务的状态"""
|
||||
jobs = []
|
||||
|
||||
@@ -53,6 +53,7 @@ def init_db():
|
||||
for table, col, typ in [
|
||||
("users", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
("topics", "reviewed_at", "TIMESTAMP"),
|
||||
("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"),
|
||||
("platform_configs", "image_count_min", "INTEGER DEFAULT 0"),
|
||||
("platform_configs", "image_count_max", "INTEGER DEFAULT 0"),
|
||||
@@ -82,6 +83,7 @@ def init_db():
|
||||
("prompt_configs", "temperature", "FLOAT"),
|
||||
("prompt_configs", "max_tokens", "INTEGER"),
|
||||
("prompt_configs", "created_by", "VARCHAR"),
|
||||
("search_providers", "console_url", "VARCHAR"),
|
||||
("keyword_domain_map", "id", "INTEGER PRIMARY KEY"),
|
||||
("keyword_domain_map", "pattern", "VARCHAR"),
|
||||
("keyword_domain_map", "domain", "VARCHAR"),
|
||||
@@ -124,10 +126,11 @@ def init_db():
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}"))
|
||||
except Exception:
|
||||
pass
|
||||
# Create roles and menus tables if they don't exist
|
||||
# Create roles, menus, and search_providers tables if they don't exist
|
||||
for tbl_sql in [
|
||||
"CREATE TABLE IF NOT EXISTS roles (id SERIAL PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, description VARCHAR DEFAULT '', is_system BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
|
||||
"CREATE TABLE IF NOT EXISTS menus (id SERIAL PRIMARY KEY, parent_id INTEGER REFERENCES menus(id), name VARCHAR NOT NULL, path VARCHAR NOT NULL, icon VARCHAR DEFAULT '', sort_order INTEGER DEFAULT 0, roles JSON DEFAULT '[]'::json, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
|
||||
"CREATE TABLE IF NOT EXISTS search_providers (id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, provider_type VARCHAR NOT NULL, api_key VARCHAR, api_url VARCHAR, priority INTEGER DEFAULT 1, enabled BOOLEAN DEFAULT TRUE, daily_limit INTEGER DEFAULT 1500, usage_today INTEGER DEFAULT 0, extra_config JSON DEFAULT '{}'::json, last_used_at TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
|
||||
]:
|
||||
try:
|
||||
conn.execute(text(tbl_sql))
|
||||
|
||||
@@ -6,7 +6,7 @@ from .database import SessionLocal, init_db
|
||||
from .models import (
|
||||
Topic, TopicField, TopicConfigField, TopicStatusConfig,
|
||||
User, Case, LLMConfig, SystemConfig, PlatformConfig,
|
||||
CollectorCategory, CollectorSource, Role, Menu
|
||||
CollectorCategory, CollectorSource, Role, Menu, SearchProvider
|
||||
)
|
||||
import bcrypt
|
||||
|
||||
@@ -71,6 +71,17 @@ def import_initial_data():
|
||||
db.commit()
|
||||
print("✅ 插入默认系统配置")
|
||||
|
||||
# 初始化默认搜索 API 提供商
|
||||
if db.query(SearchProvider).count() == 0:
|
||||
providers = [
|
||||
SearchProvider(name="百度千帆", provider_type="baidu", api_key="", api_url="https://qianfan.baidubce.com/v2/ai_search/web_search", console_url="https://console.bce.baidu.com/qianfan/ais/console/onlineService", priority=1, enabled=True, daily_limit=50),
|
||||
SearchProvider(name="opencode云搜索", provider_type="mcp", api_key="", api_url="", console_url="https://opencode.ai", priority=2, enabled=True, daily_limit=99999),
|
||||
]
|
||||
for p in providers:
|
||||
db.add(p)
|
||||
db.commit()
|
||||
print("✅ 插入默认搜索 API 提供商")
|
||||
|
||||
if db.query(PlatformConfig).count() == 0:
|
||||
platforms = [
|
||||
{
|
||||
|
||||
@@ -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, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers
|
||||
from .initial_data import import_initial_data
|
||||
from .core.scheduler import scheduler
|
||||
|
||||
@@ -103,6 +103,7 @@ app.include_router(config_items.router)
|
||||
app.include_router(role_configs.router)
|
||||
app.include_router(menu_configs.router)
|
||||
app.include_router(menu_configs.public_router)
|
||||
app.include_router(search_providers.router)
|
||||
|
||||
# 挂载自动生成的图片(必须先于前端根挂载)
|
||||
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
|
||||
|
||||
@@ -2,7 +2,44 @@ from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boo
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from .database import Base
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class SearchProvider(Base):
|
||||
__tablename__ = "search_providers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String, nullable=False, comment="显示名称")
|
||||
provider_type = Column(String, nullable=False, comment="baidu / qiniu / tinyfish")
|
||||
api_key = Column(String, nullable=True, comment="API密钥")
|
||||
api_url = Column(String, nullable=True, comment="API地址")
|
||||
priority = Column(Integer, default=1, comment="优先级,越小越优先")
|
||||
enabled = Column(Boolean, default=True)
|
||||
daily_limit = Column(Integer, default=1500, comment="每日调用上限")
|
||||
usage_today = Column(Integer, default=0, comment="当日已用次数")
|
||||
console_url = Column(String, nullable=True, comment="官网控制台地址")
|
||||
extra_config = Column(JSON, default=dict, comment="额外配置")
|
||||
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"provider_type": self.provider_type,
|
||||
"api_key": self.api_key,
|
||||
"api_url": self.api_url,
|
||||
"console_url": self.console_url,
|
||||
"priority": self.priority,
|
||||
"enabled": self.enabled,
|
||||
"daily_limit": self.daily_limit,
|
||||
"usage_today": self.usage_today,
|
||||
"extra_config": self.extra_config or {},
|
||||
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
@@ -221,6 +258,7 @@ class Topic(Base):
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
generated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
ready_at = Column(Date)
|
||||
published_at = Column(Date)
|
||||
compliance_score = Column(Integer)
|
||||
|
||||
@@ -112,6 +112,7 @@ class TopicResponse(TopicBase):
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
generated_at: Optional[datetime] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
ready_at: Optional[date] = None
|
||||
published_at: Optional[date] = None
|
||||
compliance_score: Optional[int] = None
|
||||
|
||||
+221
-18
@@ -31,6 +31,7 @@
|
||||
<el-button size="default" :type="activeTab === 'menus' ? 'primary' : ''" @click="switchTab('menus')">菜单管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
||||
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
||||
<el-button size="default" :type="activeTab === 'searchproviders' ? 'primary' : ''" @click="switchTab('searchproviders')">搜索API</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -441,16 +442,8 @@
|
||||
|
||||
<div v-if="activeTab === 'logs'">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="logType" placeholder="日志类型" style="width:200px;">
|
||||
<el-option label="创作日志" value="creator"></el-option>
|
||||
<el-option label="审查日志" value="optimizer"></el-option>
|
||||
<el-option label="研究日志" value="research"></el-option>
|
||||
<el-option label="大纲日志" value="outline"></el-option>
|
||||
<el-option label="写作日志" value="writer"></el-option>
|
||||
<el-option label="发布日志" value="publisher"></el-option>
|
||||
<el-option label="收集日志" value="collector"></el-option>
|
||||
<el-option label="通知日志" value="notifier"></el-option>
|
||||
<el-option label="趋势日志" value="trends"></el-option>
|
||||
<el-select v-model="logType" placeholder="日志类型" style="width:200px;" @focus="loadLogTypes">
|
||||
<el-option v-for="t in logTypes" :key="t.module_id" :label="t.name" :value="t.module_id"></el-option>
|
||||
</el-select>
|
||||
<el-date-picker v-model="logDate" type="date" placeholder="选择日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" style="width:200px;"></el-date-picker>
|
||||
<el-button type="primary" @click="fetchLogs" :loading="logsLoading">加载日志</el-button>
|
||||
@@ -480,7 +473,119 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'searchproviders'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="addSearchProvider">新增提供商</el-button>
|
||||
<el-button size="small" @click="testAllSearchProviders">测试全部</el-button>
|
||||
<el-button size="small" @click="resetSearchUsage">重置用量</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ searchProviders.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="searchProvidersLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="searchProviders.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSearch /></el-icon>
|
||||
<div class="empty-text">暂无搜索API提供商</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="searchProviders" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="50"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" width="120"></el-table-column>
|
||||
<el-table-column prop="provider_type" label="类型" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.provider_type === 'baidu' ? 'primary' : scope.row.provider_type === 'bing' ? 'success' : 'warning'" size="small">{{ scope.row.provider_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" label="启用" width="60">
|
||||
<template #default="scope">
|
||||
<el-switch v-model="scope.row.enabled" @change="updateSearchProvider(scope.row)"></el-switch>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="70"></el-table-column>
|
||||
<el-table-column prop="daily_limit" label="日限" width="70"></el-table-column>
|
||||
<el-table-column prop="usage_today" label="已用/日限" width="120">
|
||||
<template #default="scope">
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<el-progress :percentage="Math.round(scope.row.usage_today / scope.row.daily_limit * 100)" :stroke-width="12" :status="scope.row.usage_today >= scope.row.daily_limit ? 'exception' : scope.row.usage_today / scope.row.daily_limit > 0.8 ? 'warning' : 'success'" style="flex:1;min-width:80px;"></el-progress>
|
||||
<span :style="{fontSize:'11px',color: scope.row.usage_today >= scope.row.daily_limit ? '#f56c6c' : scope.row.usage_today / scope.row.daily_limit > 0.8 ? '#e6a23c' : '#67c23a'}">{{ scope.row.usage_today }}/{{ scope.row.daily_limit }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="api_url" label="API地址" min-width="200">
|
||||
<template #default="scope">
|
||||
<span style="font-size:12px;word-break:break-all;">{{ scope.row.api_url || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="console_url" label="控制台" min-width="160">
|
||||
<template #default="scope">
|
||||
<a v-if="scope.row.console_url" :href="scope.row.console_url" target="_blank" style="font-size:12px;color:#409eff;">打开</a>
|
||||
<span v-else style="color:#c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="editSearchProvider(scope.row)">编辑</el-button>
|
||||
<el-button size="small" @click="testSearchProvider(scope.row)">测试</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSearchProvider(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in searchProviders" :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_type }}</span></div>
|
||||
<div class="card-row"><span class="card-label">用量</span><span class="card-value">{{ item.usage_today }}/{{ item.daily_limit }}</span></div>
|
||||
<div class="card-actions"><el-button size="small" @click="editSearchProvider(item)">编辑</el-button><el-button size="small" type="danger" @click="deleteSearchProvider(item.id)">删除</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="searchProviderDialogVisible" :title="searchProviderDialogTitle" width="720px" :close-on-click-modal="false">
|
||||
<el-form :model="searchProviderForm" label-width="110px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="searchProviderForm.name" placeholder="如:百度千帆"/></el-form-item></el-col>
|
||||
<el-col :span="12"> <el-form-item label="类型">
|
||||
<el-select v-model="searchProviderForm.provider_type" style="width:100%">
|
||||
<el-option label="百度千帆" value="baidu"></el-option>
|
||||
<el-option label="opencode云搜索" value="mcp"></el-option>
|
||||
</el-select>
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="API Key"><el-input v-model="searchProviderForm.api_key" type="password" show-password placeholder="sk-..."/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="优先级"><el-input-number v-model="searchProviderForm.priority" :min="1" :max="99" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="14"><el-form-item label="API地址"><el-input v-model="searchProviderForm.api_url" placeholder="https://..."/></el-form-item></el-col>
|
||||
<el-col :span="10"><el-form-item label="日限额"><el-input-number v-model="searchProviderForm.daily_limit" :min="1" :max="999999" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="控制台地址"><el-input v-model="searchProviderForm.console_url" placeholder="https://console.xxx.com/..."/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="searchProviderDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSearchProvider" :loading="searchProviderSaving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="searchProviderTestVisible" title="测试结果" width="600px">
|
||||
<div v-if="searchProviderTesting" style="text-align:center;padding:20px;">测试中...</div>
|
||||
<div v-else>
|
||||
<div :style="{padding:'8px',marginBottom:'8px',borderRadius:'4px',background:searchProviderTestResult.ok ? '#f0f9eb' : '#fef0f0',color:searchProviderTestResult.ok ? '#67c23a' : '#f56c6c'}">
|
||||
{{ searchProviderTestResult.ok ? '✅ 连接成功' : '❌ 连接失败' }}
|
||||
</div>
|
||||
<div v-if="searchProviderTestResult.error" style="color:#f56c6c;font-size:13px;margin-bottom:8px;">{{ searchProviderTestResult.error }}</div>
|
||||
<div v-if="searchProviderTestResult.results && searchProviderTestResult.results.length">
|
||||
<div v-for="r in searchProviderTestResult.results" :key="r.url" style="padding:8px;border-bottom:1px solid #eee;">
|
||||
<div style="font-weight:bold;">{{ r.title }}</div>
|
||||
<div style="font-size:12px;color:#909399;">{{ r.url }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer><el-button @click="searchProviderTestVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -761,6 +866,7 @@ const llmConfigs = ref([]);
|
||||
};
|
||||
|
||||
const logType = ref('creator');
|
||||
const logTypes = ref([]);
|
||||
const users = ref([]);
|
||||
const usersLoading = ref(false);
|
||||
const userDialogVisible = ref(false);
|
||||
@@ -810,10 +916,25 @@ const llmConfigs = ref([]);
|
||||
const logDate = ref(new Date().toISOString().slice(0, 10));
|
||||
const logContent = ref('');
|
||||
const logsLoading = ref(false);
|
||||
const loadLogTypes = async () => {
|
||||
try {
|
||||
logTypes.value = await api.get('/api/admin/task-logs/log-types');
|
||||
logTypes.value.unshift({ module_id: 'creator', name: '创作日志', log_file: 'creator' });
|
||||
logTypes.value.unshift({ module_id: 'optimizer', name: '审查日志', log_file: 'optimizer' });
|
||||
logTypes.value.unshift({ module_id: 'research', name: '研究日志', log_file: 'research' });
|
||||
logTypes.value.unshift({ module_id: 'outline', name: '大纲日志', log_file: 'outline' });
|
||||
logTypes.value.unshift({ module_id: 'writer', name: '写作日志', log_file: 'writer' });
|
||||
logTypes.value.unshift({ module_id: 'publisher', name: '发布日志', log_file: 'publisher' });
|
||||
logTypes.value.unshift({ module_id: 'collector', name: '收集日志', log_file: 'collector' });
|
||||
logTypes.value.unshift({ module_id: 'trends', name: '趋势日志', log_file: 'trends' });
|
||||
} catch (e) { console.error('加载日志类型失败:', e); }
|
||||
};
|
||||
const fetchLogs = async () => {
|
||||
logsLoading.value = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/system/logs/${logDate.value}?log_type=${logType.value}`, {
|
||||
const module = logTypes.value.find(t => t.name === logType.value);
|
||||
const logFile = module ? module.log_file : logType.value;
|
||||
const resp = await fetch(`/api/system/logs/${logDate.value}?log_type=${logFile}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}` }
|
||||
});
|
||||
if (resp.ok) {
|
||||
@@ -863,12 +984,89 @@ const llmConfigs = ref([]);
|
||||
} 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 configs = await api.get('/api/admin/systemconfigs');
|
||||
const cfg = configs.find(c => c.key === 'assistant_system_prompt');
|
||||
if (cfg) { await api.put(`/api/admin/systemconfigs/${cfg.id}`, { value: assistantPrompt.value, description: 'AI 助手系统提示词' }); }
|
||||
else { await api.post('/api/admin/systemconfigs', { key: 'assistant_system_prompt', value: assistantPrompt.value, description: 'AI 助手系统提示词' }); }
|
||||
ElMessage.success('保存成功');
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const searchProviders = ref([]);
|
||||
const searchProvidersLoading = ref(false);
|
||||
const searchProviderDialogVisible = ref(false);
|
||||
const searchProviderDialogTitle = ref('新增搜索API');
|
||||
const searchProviderSaving = ref(false);
|
||||
const searchProviderForm = reactive({ id: null, name: '', provider_type: 'baidu', api_key: '', api_url: '', console_url: '', priority: 1, daily_limit: 1500, enabled: true });
|
||||
const searchProviderTestVisible = ref(false);
|
||||
const searchProviderTesting = ref(false);
|
||||
const searchProviderTestResult = ref({ ok: false, error: '', results: [] });
|
||||
|
||||
const loadSearchProviders = async () => {
|
||||
searchProvidersLoading.value = true;
|
||||
try { searchProviders.value = await api.get('/api/search-providers'); }
|
||||
catch (e) { ElMessage.error('加载搜索API失败: ' + e.message); }
|
||||
finally { searchProvidersLoading.value = false; }
|
||||
};
|
||||
const addSearchProvider = () => {
|
||||
searchProviderDialogTitle.value = '新增搜索API';
|
||||
searchProviderForm.id = null;
|
||||
searchProviderForm.name = '';
|
||||
searchProviderForm.provider_type = 'baidu';
|
||||
searchProviderForm.api_key = '';
|
||||
searchProviderForm.api_url = '';
|
||||
searchProviderForm.console_url = '';
|
||||
searchProviderForm.priority = 1;
|
||||
searchProviderForm.daily_limit = 1500;
|
||||
searchProviderForm.enabled = true;
|
||||
searchProviderDialogVisible.value = true;
|
||||
};
|
||||
const editSearchProvider = (row) => {
|
||||
searchProviderDialogTitle.value = '编辑搜索API';
|
||||
Object.assign(searchProviderForm, { id: row.id, name: row.name, provider_type: row.provider_type, api_key: row.api_key, api_url: row.api_url, console_url: row.console_url || '', priority: row.priority, daily_limit: row.daily_limit, enabled: row.enabled });
|
||||
searchProviderDialogVisible.value = true;
|
||||
};
|
||||
const saveSearchProvider = async () => {
|
||||
searchProviderSaving.value = true;
|
||||
try {
|
||||
const body = { name: searchProviderForm.name, provider_type: searchProviderForm.provider_type, api_key: searchProviderForm.api_key, api_url: searchProviderForm.api_url, console_url: searchProviderForm.console_url, priority: searchProviderForm.priority, daily_limit: searchProviderForm.daily_limit, enabled: searchProviderForm.enabled };
|
||||
if (searchProviderForm.id) { await api.put(`/api/search-providers/${searchProviderForm.id}`, body); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/search-providers', body); ElMessage.success('创建成功'); }
|
||||
searchProviderDialogVisible.value = false;
|
||||
await loadSearchProviders();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
finally { searchProviderSaving.value = false; }
|
||||
};
|
||||
const deleteSearchProvider = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该提供商吗?', '提示', { type: 'warning' }); await api.delete(`/api/search-providers/${id}`); ElMessage.success('删除成功'); await loadSearchProviders(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const updateSearchProvider = async (row) => {
|
||||
try { await api.put(`/api/search-providers/${row.id}`, { enabled: row.enabled }); }
|
||||
catch (e) { ElMessage.error('更新失败: ' + e.message); }
|
||||
};
|
||||
const testSearchProvider = async (row) => {
|
||||
searchProviderTesting.value = true;
|
||||
searchProviderTestVisible.value = true;
|
||||
searchProviderTestResult.value = { ok: false, error: '', results: [] };
|
||||
try {
|
||||
const r = await api.post(`/api/search-providers/${row.id}/test`, { query: '测试搜索' });
|
||||
searchProviderTestResult.value = r;
|
||||
} catch (e) { searchProviderTestResult.value = { ok: false, error: e.message, results: [] }; }
|
||||
finally { searchProviderTesting.value = false; }
|
||||
};
|
||||
const testAllSearchProviders = async () => {
|
||||
for (const p of searchProviders.value) {
|
||||
try {
|
||||
const r = await api.post(`/api/search-providers/${p.id}/test`, { query: '测试搜索' });
|
||||
ElMessage({ type: r.ok ? 'success' : 'error', message: `${p.name}: ${r.ok ? 'OK' : '失败 ' + (r.error || '')}` });
|
||||
} catch (e) { ElMessage.error(`${p.name} 测试失败: ${e.message}`); }
|
||||
}
|
||||
};
|
||||
const resetSearchUsage = async () => {
|
||||
try { await api.post('/api/search-providers/reset-usage'); ElMessage.success('用量已重置'); await loadSearchProviders(); }
|
||||
catch (e) { 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' }); };
|
||||
@@ -877,7 +1075,8 @@ const llmConfigs = ref([]);
|
||||
const tabLoaders = {
|
||||
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
|
||||
users: fetchUsers,
|
||||
orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig, logs: fetchLogs,
|
||||
logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
|
||||
searchproviders: loadSearchProviders,
|
||||
};
|
||||
const loadedTabs = new Set([]);
|
||||
|
||||
@@ -909,8 +1108,12 @@ const llmConfigs = ref([]);
|
||||
logout, currentUser, isAdmin, redirectToPage,
|
||||
users, usersLoading, userDialogVisible, userDialogTitle, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
|
||||
fetchUsers, addUser, showEditUserDialog, submitUser, deleteUser,
|
||||
logType, logDate, logContent, logsLoading, fetchLogs,
|
||||
logType, logTypes, logDate, logContent, logsLoading, fetchLogs,
|
||||
assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt,
|
||||
searchProviders, searchProvidersLoading, searchProviderDialogVisible, searchProviderDialogTitle, searchProviderSaving, searchProviderForm,
|
||||
searchProviderTestVisible, searchProviderTesting, searchProviderTestResult,
|
||||
loadSearchProviders, addSearchProvider, editSearchProvider, saveSearchProvider, deleteSearchProvider, updateSearchProvider,
|
||||
testSearchProvider, testAllSearchProviders, resetSearchUsage,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,12 +222,17 @@ const ArticlesApp = {
|
||||
const titleEl = doc.querySelector('h1');
|
||||
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
|
||||
const body = doc.body;
|
||||
if (body) body.querySelectorAll('script, style, svg, img, nav, footer, .interaction').forEach(el => el.remove());
|
||||
const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : [];
|
||||
const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n');
|
||||
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
|
||||
.then(() => this.$message.success('已复制到剪贴板'))
|
||||
.catch(() => this.$message.error('复制失败'));
|
||||
if (body) body.querySelectorAll('script, style, nav, footer, .interaction, .ad, aside, .comment').forEach(el => el.remove());
|
||||
const container = doc.createElement('div');
|
||||
if (titleEl) { const h1 = doc.createElement('h1'); h1.textContent = title; container.appendChild(h1); }
|
||||
body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => container.appendChild(el.cloneNode(true)));
|
||||
const cleanHtml = container.innerHTML;
|
||||
const blob = new Blob([cleanHtml], { type: 'text/html' });
|
||||
const plainText = container.textContent;
|
||||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([plainText], { type: 'text/plain' }) });
|
||||
navigator.clipboard.write([item]).then(() => this.$message.success('✅ 已复制(含格式和配图),Ctrl+V 粘贴')).catch(() => {
|
||||
navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
|
||||
});
|
||||
},
|
||||
async deleteArticle(article) {
|
||||
try {
|
||||
|
||||
@@ -189,14 +189,15 @@
|
||||
<el-table v-else :data="selectedDayEntries" stripe>
|
||||
<el-table-column prop="title" label="标题" min-width="120"></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="100">
|
||||
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
|
||||
<template #default="scope">{{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="scope"><el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="openEntryDialog(scope.row)">编辑</el-button>
|
||||
<el-button v-if="scope.row._source === 'topic'" size="small" type="primary" @click="openEntryDialog(scope.row)">查看</el-button>
|
||||
<el-button v-else size="small" @click="openEntryDialog(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -407,11 +408,51 @@ function getDayMeta(year, month, day) {
|
||||
loadingCalendar.value = true;
|
||||
calendarError.value = '';
|
||||
errorMsg.value = '';
|
||||
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; }
|
||||
try {
|
||||
const [calEntries, topicList] = await Promise.all([
|
||||
api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`),
|
||||
api('/api/topics?limit=200'),
|
||||
]);
|
||||
topics.value = topicList;
|
||||
|
||||
const virtualEntries = [];
|
||||
(topicList || []).forEach(t => {
|
||||
const s = t.status;
|
||||
if ((s === 'ready' || s === '待发布') && t.ready_at) {
|
||||
const d = new Date(t.ready_at);
|
||||
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) {
|
||||
virtualEntries.push({ id: 'topic_ready_' + t.id, title: t.title, planned_date: t.ready_at, platform: '', status: 'planned', platform_icon: '📋', topic_status: 'ready', _source: 'topic' });
|
||||
}
|
||||
} else if ((s === 'published' || s === '已发布') && t.published_at) {
|
||||
const d = new Date(t.published_at);
|
||||
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) {
|
||||
virtualEntries.push({ id: 'topic_pub_' + t.id, title: t.title, planned_date: t.published_at, platform: '', status: 'published', platform_icon: '📄', topic_status: 'published', _source: 'topic' });
|
||||
}
|
||||
}
|
||||
});
|
||||
entries.value = [...(calEntries || []), ...virtualEntries];
|
||||
} catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; }
|
||||
finally { loadingCalendar.value = false; }
|
||||
};
|
||||
const fetchStats = async () => {
|
||||
try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); }
|
||||
try {
|
||||
const [calStats, topicList] = await Promise.all([
|
||||
api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`),
|
||||
api('/api/topics?limit=200').catch(() => []),
|
||||
]);
|
||||
const s = { planned: calStats.planned || 0, published: calStats.published || 0, delayed: calStats.delayed || 0, cancelled: calStats.cancelled || 0 };
|
||||
(topicList || []).forEach(t => {
|
||||
const st = t.status;
|
||||
if ((st === 'ready' || st === '待发布') && t.ready_at) {
|
||||
const d = new Date(t.ready_at);
|
||||
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.planned++;
|
||||
} else if ((st === 'published' || st === '已发布') && t.published_at) {
|
||||
const d = new Date(t.published_at);
|
||||
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.published++;
|
||||
}
|
||||
});
|
||||
stats.value = s;
|
||||
} catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); }
|
||||
};
|
||||
const fetchTopics = async () => {
|
||||
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); ElMessage.error('加载选题失败: ' + e.message); }
|
||||
@@ -423,7 +464,14 @@ function getDayMeta(year, month, day) {
|
||||
|
||||
const openDayDialog = (day) => { selectedDay.value = day; selectedDayEntries.value = entries.value.filter(e => { const pd = new Date(e.planned_date); return pd.getFullYear() === day.year && pd.getMonth() + 1 === day.month && pd.getDate() === day.day; }); dayDialogVisible.value = true; };
|
||||
const openCreateDialog = () => { isEdit.value = false; entryForm.value = { id: null, title: '', planned_date: `${selectedDay.value.year}-${selectedDay.value.month}-${selectedDay.value.day}`, platform: 'zhihu', topic_id: null, status: 'planned', notes: '' }; entryDialogVisible.value = true; };
|
||||
const openEntryDialog = (entry) => { isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true; };
|
||||
const openEntryDialog = (entry) => {
|
||||
if (entry._source === 'topic') {
|
||||
const topicId = entry.id.replace(/^topic_(ready|pub)_/, '');
|
||||
window.location.href = '/topics.html?topic_id=' + topicId;
|
||||
return;
|
||||
}
|
||||
isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveEntry = async () => {
|
||||
saving.value = true;
|
||||
@@ -463,7 +511,6 @@ function getDayMeta(year, month, day) {
|
||||
isAdmin.value = d.user.role === 'admin';
|
||||
fetchEntries();
|
||||
fetchStats();
|
||||
fetchTopics();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('authToken');
|
||||
|
||||
@@ -327,22 +327,51 @@
|
||||
this.loadingPlan = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const resp = await fetch(`/api/calendar?year=${year}&month=${month}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const end = new Date(today);
|
||||
end.setDate(end.getDate() + 7);
|
||||
|
||||
const [entries, topics] = await Promise.all([
|
||||
fetch(`/api/calendar?year=${today.getFullYear()}&month=${today.getMonth() + 1}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(r => r.ok ? r.json() : []),
|
||||
fetch('/api/topics?limit=200', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(r => r.ok ? r.json() : [])
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
(entries || []).forEach(e => {
|
||||
events.push({
|
||||
id: 'cal_' + e.id,
|
||||
date: e.planned_date,
|
||||
title: e.title,
|
||||
status: e.status === 'published' ? 'published' : 'planned'
|
||||
});
|
||||
});
|
||||
if (resp.ok) {
|
||||
const all = await resp.json();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const end = new Date(today);
|
||||
end.setDate(end.getDate() + 7);
|
||||
this.upcomingEntries = (all || [])
|
||||
.filter(e => { const d = new Date(e.planned_date); return d >= today && d < end; })
|
||||
.sort((a, b) => a.planned_date.localeCompare(b.planned_date));
|
||||
}
|
||||
(topics || []).forEach(t => {
|
||||
const s = t.status;
|
||||
if ((s === 'ready' || s === '待发布') && t.ready_at) {
|
||||
events.push({
|
||||
id: 'topic_ready_' + t.id,
|
||||
date: t.ready_at,
|
||||
title: t.title,
|
||||
status: 'planned'
|
||||
});
|
||||
} else if ((s === 'published' || s === '已发布') && t.published_at) {
|
||||
events.push({
|
||||
id: 'topic_pub_' + t.id,
|
||||
date: t.published_at,
|
||||
title: t.title,
|
||||
status: 'published'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.upcomingEntries = events
|
||||
.filter(e => { const d = new Date(e.date); return d >= today && d < end; })
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
} catch (e) {
|
||||
console.error('获取近期计划失败:', e);
|
||||
} finally { this.loadingPlan = false; }
|
||||
|
||||
@@ -100,9 +100,9 @@
|
||||
<span v-else :class="['module-status', mod.last_status === 'success' ? 'completed' : mod.last_status === 'failed' ? 'failed' : '']">{{ mod.last_status === 'success' ? '正常' : mod.last_status === 'failed' ? '失败' : '空闲' }}</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>{{ mod.last_run || '从未' }}</span></div>
|
||||
<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></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 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>
|
||||
@@ -619,6 +619,7 @@ const TasksApp = {
|
||||
'scheduled_metrics_sync': { icon: 'IconDashboard', name: '指标同步', defaultTime: '06:00' },
|
||||
'scheduled_refresh_search_cache': { icon: 'IconRefresh', name: '搜索缓存', defaultTime: '01:00' },
|
||||
'scheduled_fetch_trends': { icon: 'IconRefresh', name: '热点趋势', defaultTime: '01:10' },
|
||||
'scheduled_task_monitor': { icon: 'IconRefresh', name: '任务监控', defaultTime: '*' },
|
||||
};
|
||||
const MODULE_TRIGGER_ENDPOINTS = {
|
||||
scheduled_collect: '/api/system/collect/run',
|
||||
@@ -762,7 +763,7 @@ const TasksApp = {
|
||||
},
|
||||
async triggerModule(modId) {
|
||||
const endpoint = this.MODULE_TRIGGER_ENDPOINTS[modId];
|
||||
if (!endpoint) { ElMessage.error('未知模块'); return; }
|
||||
if (!endpoint) { ElMessage.info('此模块自动运行,无需手动触发'); return; }
|
||||
this.runningModule = modId;
|
||||
try {
|
||||
await this.api(endpoint, { method: 'POST' });
|
||||
|
||||
+161
-20
@@ -22,8 +22,11 @@
|
||||
.topic-card-meta { display: flex; flex-direction: column; gap: 3px; font-size: var(--font-size-caption); color: var(--color-text-regular); margin-bottom: 10px; }
|
||||
.topic-card-actions { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border); }
|
||||
.topic-card-actions .el-button { margin: 0; width: 100%; justify-content: center; padding: 8px 4px !important; }
|
||||
.topic-card.is-checked { border-color: var(--color-primary); box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
|
||||
.topic-card-actions .el-button--danger { grid-column: 1 / -1; }
|
||||
.topic-card.is-checked { border-color: var(--color-primary); box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
|
||||
.topic-card-actions .el-button--danger { grid-column: 1 / -1; }
|
||||
.search-area { background:#f8faff; border:1px solid #e8edf5; border-radius:8px; padding:12px 16px; margin-bottom:12px; }
|
||||
.search-area .el-form-item { margin-bottom:6px; }
|
||||
.search-toggle { cursor:pointer; user-select:none; font-size:14px; color:#409eff; }
|
||||
}
|
||||
</style>
|
||||
<script src="uni-nav.js"></script>
|
||||
@@ -53,9 +56,52 @@
|
||||
<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 ref="topicTable" :data="paginatedTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
|
||||
<div style="margin-bottom:10px;">
|
||||
<span class="search-toggle" @click="showSearch = !showSearch">🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}</span>
|
||||
<span v-if="searchActive" style="margin-left:8px;font-size:12px;color:#909399;">筛选条件已生效,共 {{ filteredTopics.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="showSearch" class="search-area">
|
||||
<el-form :model="searchForm" size="small" label-width="70px" @submit.prevent>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6"><el-form-item label="选题ID"><el-input v-model="searchForm.id" placeholder="如 A07" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :span="10"><el-form-item label="标题"><el-input v-model="searchForm.title" placeholder="关键词" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="领域"><el-input v-model="searchForm.field" placeholder="如 科技前沿" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12"><el-form-item label="创建时间">
|
||||
<el-date-picker v-model="searchForm.createdStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.createdEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
</el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="创作时间">
|
||||
<el-date-picker v-model="searchForm.generatedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.generatedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12"><el-form-item label="审查时间">
|
||||
<el-date-picker v-model="searchForm.reviewedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.reviewedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
</el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="发布时间">
|
||||
<el-date-picker v-model="searchForm.publishedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.publishedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="24" style="text-align:right;">
|
||||
<el-button type="primary" @click="doSearch" size="small">查询</el-button>
|
||||
<el-button @click="resetSearch" size="small">重置</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table ref="topicTable" :data="paginatedTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)" @sort-change="handleSortChange" :default-sort="{ prop: 'created_at', order: 'ascending' }">
|
||||
<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="id" label="ID" width="70" fixed sortable="custom"></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
@@ -64,9 +110,10 @@
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="generated_at" label="创作时间" width="140"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="published_at" label="发布时间" width="140"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140" sortable="custom"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="generated_at" label="创作时间" width="140" sortable="custom"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="reviewed_at" label="审查时间" width="140" sortable="custom"><template #default="scope">{{ scope.row.reviewed_at ? formatDate(scope.row.reviewed_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="published_at" label="发布时间" width="140" sortable="custom"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px; white-space: nowrap;">
|
||||
@@ -100,6 +147,7 @@
|
||||
<div class="topic-card-meta">
|
||||
<div>创建: {{ formatDate(topic.created_at) }}</div>
|
||||
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
|
||||
<div>审查: {{ topic.reviewed_at ? formatDate(topic.reviewed_at) : '-' }}</div>
|
||||
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="topic-card-actions">
|
||||
@@ -177,6 +225,7 @@
|
||||
<span style="margin: 0 8px;">|</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at" style="margin-left:8px;">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||
<span v-if="previewTopic.reviewed_at" style="margin-left:8px;">审查:{{ formatDate(previewTopic.reviewed_at) }}</span>
|
||||
<span v-if="previewTopic.published_at" style="margin-left:8px;">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -204,16 +253,66 @@ const TopicsApp = {
|
||||
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
|
||||
publishing: false,
|
||||
savingContent: false,
|
||||
currentPage: 1, pageSize: 10
|
||||
currentPage: 1, pageSize: 10,
|
||||
sortField: 'created_at', sortOrder: 'ascending',
|
||||
showSearch: false,
|
||||
searchForm: { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
searchActive() {
|
||||
const f = this.searchForm;
|
||||
return !!(f.id || f.title || f.field || f.createdStart || f.createdEnd || f.generatedStart || f.generatedEnd || f.reviewedStart || f.reviewedEnd || f.publishedStart || f.publishedEnd);
|
||||
},
|
||||
filteredTopics() {
|
||||
if (this.filterStatus === 'today') return this.todayTopics;
|
||||
if (!this.filterStatus) return this.topics;
|
||||
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
|
||||
const allowed = map[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
let list;
|
||||
if (this.filterStatus === 'today') list = this.todayTopics;
|
||||
else if (!this.filterStatus) list = this.topics;
|
||||
else {
|
||||
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
|
||||
const allowed = map[this.filterStatus] || [this.filterStatus];
|
||||
list = this.topics.filter(t => allowed.includes(t.status));
|
||||
}
|
||||
// 搜索过滤
|
||||
const sf = this.searchForm;
|
||||
if (sf.id || sf.title || sf.field || sf.createdStart || sf.createdEnd || sf.generatedStart || sf.generatedEnd || sf.reviewedStart || sf.reviewedEnd || sf.publishedStart || sf.publishedEnd) {
|
||||
list = list.filter(t => {
|
||||
if (sf.id && !t.id.toLowerCase().includes(sf.id.toLowerCase())) return false;
|
||||
if (sf.title && !t.title.toLowerCase().includes(sf.title.toLowerCase())) return false;
|
||||
if (sf.field && !(t.field || '').toLowerCase().includes(sf.field.toLowerCase())) return false;
|
||||
const inRange = (val, start, end) => {
|
||||
if (!val) return !start && !end;
|
||||
const d = val.slice(0, 10);
|
||||
if (start && d < start) return false;
|
||||
if (end && d > end) return false;
|
||||
return true;
|
||||
};
|
||||
return inRange(t.created_at, sf.createdStart, sf.createdEnd)
|
||||
&& inRange(t.generated_at, sf.generatedStart, sf.generatedEnd)
|
||||
&& inRange(t.reviewed_at, sf.reviewedStart, sf.reviewedEnd)
|
||||
&& inRange(t.published_at, sf.publishedStart, sf.publishedEnd);
|
||||
});
|
||||
}
|
||||
if (this.sortField) {
|
||||
const field = this.sortField;
|
||||
const isDateField = ['created_at','generated_at','reviewed_at','published_at','updated_at'].includes(field);
|
||||
list = [...list].sort((a, b) => {
|
||||
const va = a[field], vb = b[field];
|
||||
if (!va && !vb) return 0;
|
||||
if (!va) return 1;
|
||||
if (!vb) return -1;
|
||||
let cmp;
|
||||
if (isDateField) {
|
||||
const ta = new Date(va.replace(' ', 'T')).getTime();
|
||||
const tb = new Date(vb.replace(' ', 'T')).getTime();
|
||||
cmp = ta - tb;
|
||||
} else {
|
||||
cmp = String(va).localeCompare(String(vb), 'zh', { numeric: true });
|
||||
}
|
||||
return this.sortOrder === 'descending' ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
return list;
|
||||
},
|
||||
paginatedTopics() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
@@ -356,13 +455,42 @@ const TopicsApp = {
|
||||
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
|
||||
const body = doc.body;
|
||||
if (body) {
|
||||
body.querySelectorAll('script, style, nav, .header, .tags, footer, .interaction, svg, img, button').forEach(el => el.remove());
|
||||
body.querySelectorAll('script, style, nav, .header, .tags, footer, .interaction, button, .ad, aside, .comment').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
if (platform === 'xiaohongshu') {
|
||||
const lines = [];
|
||||
body.querySelectorAll('h1,h2,h3,h4,p,li').forEach(el => {
|
||||
const text = el.textContent.trim();
|
||||
if (!text || text.length < 2) return;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const prefix = tag.startsWith('h') ? '\n### ' : '- ';
|
||||
const clean = text.replace(/\n/g, ' ').replace(/^[\s#]+|[\s#]+$/g, '');
|
||||
if (clean) lines.push(prefix + clean);
|
||||
});
|
||||
const md = `**${title}**\n\n${lines.join('\n')}`;
|
||||
navigator.clipboard.writeText(md).then(() => this.$message.success('✅ 已复制 Markdown(小红书格式)')).catch(() => this.$message.error('❌ 复制失败'));
|
||||
} else {
|
||||
const container = doc.createElement('div');
|
||||
if (titleEl) {
|
||||
const h1 = doc.createElement('h1');
|
||||
h1.textContent = title;
|
||||
container.appendChild(h1);
|
||||
}
|
||||
body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => {
|
||||
const clone = el.cloneNode(true);
|
||||
container.appendChild(clone);
|
||||
});
|
||||
const cleanHtml = container.innerHTML;
|
||||
const blob = new Blob([cleanHtml], { type: 'text/html' });
|
||||
const richText = new Blob([cleanHtml], { type: 'text/plain' });
|
||||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': richText });
|
||||
navigator.clipboard.write([item]).then(() => {
|
||||
this.$message.success(`✅ 已复制(含格式和配图),Ctrl+V 粘贴到${platform === 'zhihu' ? '知乎' : '微信公众号'}`);
|
||||
}).catch(() => {
|
||||
navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
|
||||
});
|
||||
}
|
||||
const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : [];
|
||||
const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n');
|
||||
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
|
||||
.then(() => this.$message.success(`已复制内容,请前往${platform}粘贴发布`))
|
||||
.catch(() => this.$message.error('复制失败,请手动复制'));
|
||||
},
|
||||
async createTopic(topic) {
|
||||
if (this.isStatus(topic, 'published')) { this.$message.info('已发布选题不可创作'); return; }
|
||||
@@ -451,6 +579,11 @@ const TopicsApp = {
|
||||
} catch (e) { this.$message.error('保存失败: ' + e.message); }
|
||||
finally { this.savingContent = false; }
|
||||
},
|
||||
doSearch() { this.currentPage = 1; },
|
||||
resetSearch() {
|
||||
this.searchForm = { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null };
|
||||
this.currentPage = 1;
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
|
||||
@@ -471,6 +604,11 @@ const TopicsApp = {
|
||||
table.toggleRowSelection(row, !allChecked);
|
||||
}
|
||||
},
|
||||
handleSortChange({ prop, order }) {
|
||||
this.sortField = prop || 'created_at';
|
||||
this.sortOrder = order || 'ascending';
|
||||
this.currentPage = 1;
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
@@ -482,8 +620,11 @@ const TopicsApp = {
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
const urlFilter = new URLSearchParams(window.location.search).get('filter');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlFilter = params.get('filter');
|
||||
if (urlFilter) this.filterStatus = urlFilter;
|
||||
const topicId = params.get('topic_id');
|
||||
if (topicId) { this.searchForm.id = topicId; this.showSearch = true; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchTopics(); this.fetchTodayCount(); })
|
||||
|
||||
Reference in New Issue
Block a user