feat: 创作工作台统一 + AI味检测/白标 + 移动端补全 + 合规发布闭环

- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式)
- 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级)
- 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包)
- 发布闭环:publishing 放宽至 editor + records/mark-published 接口
- 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底)
- 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立)
- 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
This commit is contained in:
yuzhiran
2026-07-11 12:36:01 +08:00
parent 3d596275d5
commit 7e953afbd2
39 changed files with 2418 additions and 311 deletions
+206
View File
@@ -0,0 +1,206 @@
import sys
from pathlib import Path
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends, Body
from pydantic import BaseModel
from ..database import get_db
from ..models import User, Topic, Article
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/ai-slop", tags=["ai-slop"])
PROJECT_ROOT = Path(__file__).resolve().parents[4]
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
if not SCRIPTS_DIR.exists():
SCRIPTS_DIR = PROJECT_ROOT.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
check_article = None
polish_with_llm = None
clean_html_content = None
strip_ai_preface = None
strip_thinking_html = None
try:
from compliance_checker import check_article
except Exception:
check_article = None
try:
from compliance_optimizer import polish_with_llm
except Exception:
polish_with_llm = None
try:
from content_cleaner import clean_html_content, strip_ai_preface, strip_thinking_html
except Exception:
clean_html_content = None
strip_ai_preface = None
strip_thinking_html = None
HARD_ISSUE_TYPES = ("敏感词", "法律法规", "平台规则", "品牌规范", "资源合规")
class IssueItem(BaseModel):
type: str
category: str = ""
detail: str = ""
suggestion: str = ""
severity: str = "medium"
class PlatformReport(BaseModel):
platform: str
score: int
passed: bool
issues: List[IssueItem]
html_preview: str = ""
class ReportResponse(BaseModel):
topic_id: str
platforms: List[PlatformReport]
class PurifyRequest(BaseModel):
topic_id: str
platform: str
class PurifyResponse(BaseModel):
ok: bool
platform: str
score_before: Optional[int] = None
score_after: Optional[int] = None
issues_before: List[IssueItem] = []
issues_after: List[IssueItem] = []
preview: str = ""
message: str = ""
def _normalize_issues(issues: list) -> List[IssueItem]:
result = []
for i in issues or []:
itype = i.get("type", "")
severity = "high" if itype in HARD_ISSUE_TYPES else "medium"
detail = i.get("detail") or i.get("suggestion") or i.get("word") or i.get("tag") or i.get("pattern") or ""
result.append(IssueItem(
type=itype,
category=i.get("category", ""),
detail=detail,
suggestion=i.get("suggestion", ""),
severity=severity,
))
return result
def _extract_title(html: str) -> str:
import re
m = re.search(r"<title>\s*([^<]+?)\s*</title>", html, re.IGNORECASE)
if not m:
m = re.search(r"<h1[^>]*>\s*([^<]+?)\s*</h1>", html, re.IGNORECASE)
return m.group(1).strip() if m else ""
def _extract_content(html: str) -> str:
import re
text = re.sub(r"<style.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<script.*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
return re.sub(r"\s+", " ", text).strip()
def _load_topic_data(db, topic_id: str) -> dict:
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
return {}
return {
"topic": {
"title": getattr(topic, "title", "") or "",
"field": getattr(topic, "field", "") or "",
"core_concept": getattr(topic, "core_concept", "") or "",
}
}
@router.get("/report", response_model=ReportResponse)
def get_report(topic_id: str, current_user: User = Depends(get_current_user), db=Depends(get_db)):
if check_article is None:
raise HTTPException(status_code=503, detail="合规检测模块不可用")
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
of = org_filter(current_user, Topic)
if of is not True and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
topic_data = _load_topic_data(db, topic_id)
from db_helper import get_articles_by_topic
articles = get_articles_by_topic(topic_id)
platforms = []
for art in articles:
platform = art.get("platform")
html = art.get("html_content") or ""
if not html:
continue
res = check_article(html, platform, topic_data=topic_data)
platforms.append(PlatformReport(
platform=platform,
score=res.get("score", 0),
passed=res.get("passed", False),
issues=_normalize_issues(res.get("issues", [])),
html_preview=html[:600],
))
return ReportResponse(topic_id=topic_id, platforms=platforms)
@router.post("/purify", response_model=PurifyResponse)
def purify(req: PurifyRequest, current_user: User = Depends(get_current_user), db=Depends(get_db)):
if check_article is None:
raise HTTPException(status_code=503, detail="合规检测模块不可用")
topic = db.query(Topic).filter(Topic.id == req.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
of = org_filter(current_user, Topic)
if of is not True and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
from db_helper import get_articles_by_topic, save_article
articles = get_articles_by_topic(req.topic_id)
target = next((a for a in articles if a.get("platform") == req.platform), None)
if not target or not target.get("html_content"):
raise HTTPException(status_code=404, detail=f"未找到 {req.platform} 平台的文章")
html = target["html_content"]
topic_data = _load_topic_data(db, req.topic_id)
before = check_article(html, req.platform, topic_data=topic_data)
issues_before = _normalize_issues(before.get("issues", []))
raw_issues = before.get("issues", [])
polished_html, log_msg = (html, None)
if polish_with_llm is not None:
polished_html, log_msg = polish_with_llm(html, req.platform, remaining_issues=raw_issues)
cleaned = polished_html
if clean_html_content is not None:
cleaned = clean_html_content(cleaned)
if strip_ai_preface is not None:
cleaned = strip_ai_preface(cleaned)
if strip_thinking_html is not None:
cleaned = strip_thinking_html(cleaned)
title = _extract_title(cleaned)
content = _extract_content(cleaned)
save_article(req.topic_id, req.platform, cleaned, title=title, content=content, db=db)
after = check_article(cleaned, req.platform, topic_data=topic_data)
issues_after = _normalize_issues(after.get("issues", []))
message = "净化完成" + (f"{log_msg}" if log_msg else "(仅执行清洗,未调用 LLM")
return PurifyResponse(
ok=True,
platform=req.platform,
score_before=before.get("score"),
score_after=after.get("score"),
issues_before=issues_before,
issues_after=issues_after,
preview=cleaned[:600],
message=message,
)
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Optional
from pydantic import BaseModel
from ..database import get_db
from ..models import SystemConfig
from .auth import get_current_admin
router = APIRouter(prefix="/api/branding", tags=["branding"])
BRAND_KEYS = {
"brand_name": "宇之然内容创作平台",
"brand_logo": "",
"brand_primary_color": "#2563eb",
"brand_support_email": "",
}
PUBLIC_RESP_DESC = "白标实例公开信息(供前端运行时应用品牌)"
class BrandingResponse(BaseModel):
brand_name: str
brand_logo: str
brand_primary_color: str
brand_support_email: str
version: str
class BrandingUpdate(BaseModel):
brand_name: Optional[str] = None
brand_logo: Optional[str] = None
brand_primary_color: Optional[str] = None
brand_support_email: Optional[str] = None
def _get(db: Session, key: str) -> str:
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if not cfg or cfg.value is None:
return BRAND_KEYS.get(key, "")
return cfg.value
@router.get("", response_model=BrandingResponse)
def get_branding(db: Session = Depends(get_db)):
"""公开接口:返回当前实例的白标配置(无需登录),用于前端运行时渲染品牌"""
try:
from ..main import app
version = getattr(app, "version", "0.1.0")
except Exception:
version = "0.1.0"
return BrandingResponse(
brand_name=_get(db, "brand_name"),
brand_logo=_get(db, "brand_logo"),
brand_primary_color=_get(db, "brand_primary_color"),
brand_support_email=_get(db, "brand_support_email"),
version=version,
)
@router.put("", response_model=BrandingResponse)
def update_branding(
payload: BrandingUpdate,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""管理员接口:更新白标配置(品牌名/Logo/主色/支持邮箱)"""
data = payload.model_dump(exclude_unset=True)
for key, value in data.items():
if key not in BRAND_KEYS:
continue
if value is None:
continue
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if cfg:
cfg.value = str(value)
else:
db.add(SystemConfig(key=key, value=str(value), description="白标配置"))
db.commit()
return get_branding(db)
+56 -1
View File
@@ -1,4 +1,10 @@
import os
import sys
import subprocess
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import Optional
@@ -6,10 +12,18 @@ from datetime import datetime, timezone
from ..database import get_db
from ..models import ExternalProduct, PromotionCampaign, CampaignKeyword, SEOAudit, KeywordRanking, OptimizationTask, User
from .auth import get_current_user, org_filter
from .auth import get_current_user, get_current_admin, org_filter
router = APIRouter(prefix="/api/external", tags=["external_promotion"])
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
SEO_AUDITOR_PATH = PROJECT_ROOT / "scripts" / "seo_auditor.py"
class AuditRunRequest(BaseModel):
product_id: Optional[str] = None
url: Optional[str] = None
def _org_filtered_query(db, model, current_user):
query = db.query(model)
@@ -467,3 +481,44 @@ def external_overview(
"type_breakdown": type_breakdown,
}
}
# ===== Run SEO Audit (delegate to scripts/seo_auditor.py) =====
@router.post("/audit/run")
def run_audit(
body: AuditRunRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
cmd = [sys.executable, str(SEO_AUDITOR_PATH)]
if body.product_id:
cmd += ["--product-id", str(body.product_id)]
elif body.url:
cmd += ["--url", body.url]
else:
raise HTTPException(status_code=400, detail="product_id 或 url 至少提供一个")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=408, detail="SEO 审计超时")
except Exception as e:
raise HTTPException(status_code=500, detail=f"审计执行失败: {str(e)}")
if result.returncode != 0:
raise HTTPException(
status_code=502,
detail={
"message": "SEO 审计脚本返回错误",
"stderr": result.stderr,
"stdout": result.stdout,
},
)
return {
"ok": True,
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
}
+104 -14
View File
@@ -2,13 +2,13 @@
"""发布管理 API"""
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone, timedelta, date
from typing import Optional, List
from ..database import get_db
from ..models import Topic, PublishRecord, User
from sqlalchemy.orm import Session
from .auth import get_current_admin, org_filter
from .auth import get_current_user, org_filter
from ..core.audit_logger import audit_log
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
@@ -40,9 +40,9 @@ async def create_publish_record(
req: MultiPublishRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
current_user: User = Depends(get_current_user)
):
"""多平台发布选题(管理员"""
"""多平台发布选题(运营/管理员,按组织隔离"""
try:
q = db.query(Topic).filter(Topic.id == req.topic_id)
of = org_filter(current_user, Topic)
@@ -66,17 +66,17 @@ async def create_publish_record(
record = PublishRecord(
topic_id=req.topic_id,
platform=platform,
action='publish',
status='success',
action='stage',
status='pending_manual',
operator=operator,
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}",
description=f"选题 {req.topic_id} 已生成 {PLATFORM_LABELS.get(platform, platform)} 发布稿,待人工校验后发布",
org_id=topic.org_id
)
db.add(record)
results.append(PublishResult(
platform=platform,
platform_label=PLATFORM_LABELS.get(platform, platform),
status='success'
status='pending_manual'
))
except Exception as e:
results.append(PublishResult(
@@ -86,16 +86,17 @@ async def create_publish_record(
error_msg=str(e)
))
topic.status = '已发布'
# 诚实状态流转:本项目无真实自动发布器(且按《AI生成内容标识办法》AI 稿需真人把关),
# 故仅将选题置为「待人工发布」,待运营在对应平台校验声明后手动发布。
topic.status = 'pending_publish'
_now = datetime.now(timezone(timedelta(hours=8)))
topic.updated_at = _now
topic.published_at = _now.date()
db.commit()
db.expire_all()
db.refresh(topic)
audit_log(
action="publish",
action="publish_stage",
user=current_user,
resource_type="topic",
resource_id=req.topic_id,
@@ -105,15 +106,104 @@ async def create_publish_record(
db=db
)
success_count = sum(1 for r in results if r.status == 'success')
staged = sum(1 for r in results if r.status == 'pending_manual')
return MultiPublishResponse(
ok=success_count > 0,
ok=staged == len(results),
topic_id=req.topic_id,
results=results,
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)"
message=(
f"选题 {req.topic_id} 已生成 {staged}/{len(results)} 个平台的发布稿并置为「待人工发布」。"
f"AI 生成内容须由真人校验声明后手动发布,系统不代发。"
)
)
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/records")
def list_publish_records(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""列出发布记录(按组织隔离),用于运营查看「待人工发布 / 已发布」状态"""
q = db.query(PublishRecord)
of = org_filter(current_user, PublishRecord)
if of is not True:
q = q.filter(of)
recs = q.order_by(PublishRecord.created_at.desc()).all()
result = []
for r in recs:
topic = db.query(Topic).filter(Topic.id == r.topic_id).first()
result.append({
"id": r.id,
"topic_id": r.topic_id,
"topic_title": topic.title if topic else None,
"platform": r.platform,
"action": r.action,
"status": r.status,
"operator": r.operator,
"description": r.description,
"url": r.url,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
})
return {"records": result}
class MarkPublishedResponse(BaseModel):
ok: bool
topic_id: str
message: str
@router.post("/{topic_id}/mark-published", response_model=MarkPublishedResponse)
def mark_published(
topic_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""运营在对应平台手动发布后,回填「已发布」闭环状态"""
q = db.query(Topic).filter(Topic.id == topic_id)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
topic = q.first()
if not topic:
raise HTTPException(status_code=404, detail=f"选题 {topic_id} 不存在")
if topic.status != 'pending_publish':
raise HTTPException(status_code=400, detail=f"选题 {topic_id} 当前状态为「{topic.status}」,无需标记发布")
_now = datetime.now(timezone(timedelta(hours=8)))
topic.status = 'published'
topic.published_at = date.today()
topic.updated_at = _now
recs = db.query(PublishRecord).filter(
PublishRecord.topic_id == topic_id,
PublishRecord.status == 'pending_manual'
)
for r in recs:
r.status = 'published'
r.updated_at = _now
db.commit()
audit_log(
action="publish_confirm",
user=current_user,
resource_type="topic",
resource_id=topic_id,
details={"operator": current_user.username},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return MarkPublishedResponse(
ok=True,
topic_id=topic_id,
message=f"选题 {topic_id} 已标记为「已发布」,发布记录同步更新。"
)
+1 -2
View File
@@ -25,8 +25,7 @@ def _get_cmd(topic_ids: List[str] = None):
cmd = [str(venv_python), str(script_path)] if venv_python.exists() else ["python3", str(script_path)]
if topic_ids:
cmd.extend(["--topic-ids", ','.join(topic_ids)])
else:
cmd.append("--today-only")
# 不再使用 --today-only,避免三平台串行撰写时小红书文章错过审查窗口
return cmd
def run_optimizer(topic_ids: List[str] = None):
+83 -33
View File
@@ -58,6 +58,7 @@ MODULES = {
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
"scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"},
"scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"},
"scheduled_shortvideo": {"name": "🎬 短视频拆条", "cron": "08:00"},
}
LOG_FILE_MAP = {
@@ -71,6 +72,7 @@ LOG_FILE_MAP = {
"scheduled_task_monitor": "task_monitor",
"scheduled_rank_tracker": "rank_tracker",
"scheduled_geo_tracker": "geo_tracker",
"scheduled_shortvideo": "shortvideo",
}
def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None):
@@ -188,6 +190,7 @@ class TaskScheduler:
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"),
("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"),
("scheduled_shortvideo", self._run_shortvideo, "短视频拆条"),
]
for module_id, fn, name in MODULE_JOBS:
@@ -418,41 +421,48 @@ class TaskScheduler:
count = 0
for topic in topics:
platform_urls = topic.platform_urls or {}
zhihu_url = platform_urls.get("zhihu", "")
if not zhihu_url:
continue
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', zhihu_url)
if not m:
continue
post_id = m.group(1)
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
try:
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
if resp.status_code != 200:
for platform, url in platform_urls.items():
if not url:
continue
if platform == "zhihu":
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', url)
if not m:
continue
post_id = m.group(1)
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
try:
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
if resp.status_code != 200:
continue
raw = resp.json()
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic.id,
ContentMetrics.platform == "zhihu"
).first()
metric_data = {
"views": raw.get("voteup_count", raw.get("views_count", 0)),
"likes": raw.get("voteup_count", 0),
"favorites": raw.get("favorite_count", 0),
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0),
"last_fetched": datetime.now(),
"publish_url": url,
"data_snapshot": raw,
}
if existing:
for k, v in metric_data.items():
setattr(existing, k, v)
else:
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
count += 1
except Exception:
continue
elif platform in ("wechat_mp", "xiaohongshu"):
logger.info("平台 %s 无公开指标 API,跳过", platform)
continue
raw = resp.json()
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic.id,
ContentMetrics.platform == "zhihu"
).first()
metric_data = {
"views": raw.get("voteup_count", raw.get("views_count", 0)),
"likes": raw.get("voteup_count", 0),
"favorites": raw.get("favorite_count", 0),
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0),
"last_fetched": datetime.now(),
"publish_url": zhihu_url,
"data_snapshot": raw,
}
if existing:
for k, v in metric_data.items():
setattr(existing, k, v)
else:
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
count += 1
except Exception:
continue
logger.info("平台 %s 暂无指标同步支持,跳过", platform)
continue
if count:
db.commit()
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
@@ -661,6 +671,46 @@ class TaskScheduler:
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[GeoTracker] AI 搜索引用追踪失败: %s", e)
def _run_shortvideo(self):
"""每日将前一日「待发布」选题的长文拆条为短视频脚本"""
started = datetime.now(timezone.utc)
log_id = _log_task("scheduled_shortvideo", "running", started_at=started)
try:
from ..database import SessionLocal
from ..models import Topic
db = SessionLocal()
try:
topics = db.query(Topic).filter(
Topic.status.in_(["ready", "待发布", "pending_publish"])
).order_by(Topic.ready_at.desc().nullslast()).limit(3).all()
finally:
db.close()
if not topics:
_log_task("scheduled_shortvideo", "success", log_id=log_id,
message="无待拆条选题", started_at=started, finished_at=datetime.now(timezone.utc))
return
import subprocess
total = 0
for t in topics:
res = subprocess.run(
[sys.executable, str(PROJECT_ROOT / "scripts" / "shortvideo.py"), "--topic-id", t.id, "--count", "3"],
capture_output=True, text=True, timeout=600
)
if res.returncode == 0:
total += 1
_log_task("scheduled_shortvideo", "success", log_id=log_id,
message=f"拆条完成: {total}/{len(topics)} 选题",
result_data={"topics": len(topics), "done": total},
started_at=started, finished_at=datetime.now(timezone.utc))
logger.info("[ShortVideo] 拆条完成: %d/%d", total, len(topics))
except Exception as e:
import traceback
_log_task("scheduled_shortvideo", "failed", log_id=log_id,
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[ShortVideo] 拆条失败: %s", e)
def get_jobs(self):
"""返回当前所有定时任务的状态"""
jobs = []
+51 -14
View File
@@ -76,6 +76,17 @@ def import_initial_data():
for cfg in default_system_configs:
if db.query(SystemConfig).filter(SystemConfig.key == cfg["key"]).first() is None:
db.add(SystemConfig(**cfg))
# 白标默认值(私有化部署/卖给他人时可改)
default_branding = [
{"key": "brand_name", "value": "宇之然内容创作平台", "description": "白标:平台名称"},
{"key": "brand_logo", "value": "", "description": "白标:Logo URL(留空用文字)"},
{"key": "brand_primary_color", "value": "#2563eb", "description": "白标:主色(导航/按钮)"},
{"key": "brand_support_email", "value": "", "description": "白标:支持邮箱"},
]
for b in default_branding:
if db.query(SystemConfig).filter(SystemConfig.key == b["key"]).first() is None:
db.add(SystemConfig(**b))
if db.query(SystemConfig).filter(SystemConfig.key == "review_llm_id").first() is None:
first_llm = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if first_llm:
@@ -294,21 +305,47 @@ def import_initial_data():
db.commit()
print("✅ 插入默认角色")
# 初始化默认菜单(与 uni-nav.js 对齐)
if db.query(Menu).count() == 0:
default_menus = [
{"name": "仪表盘", "path": "/", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
{"name": "选题", "path": "topics.html", "icon": "IconTopic", "sort_order": 1, "roles": ["admin", "editor"]},
{"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]},
{"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]},
{"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]},
{"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]},
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 6, "roles": ["admin"]},
]
for m in default_menus:
db.add(Menu(**m))
# 归一化首页菜单路径,避免 index.html 与 /index.html 产生重复入口
idx_plain = db.query(Menu).filter(Menu.path == "index.html").first()
idx_slash = db.query(Menu).filter(Menu.path == "/index.html").first()
if idx_plain and not idx_slash:
idx_plain.path = "/index.html"
db.commit()
print("✅ 插入默认菜单")
elif idx_plain and idx_slash:
db.delete(idx_plain)
db.commit()
# 初始化默认菜单:幂等按 path 对齐(已有时更新排序/角色,缺失时插入)。
# 运行时会用 DB 菜单覆盖 uni-nav.js 的 fallback,因此这里必须保证文章管理/内容工厂/白标设置等入口存在。
default_menus = [
{"name": "工作台", "path": "/index.html", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
{"name": "创作工作台", "path": "studio.html", "icon": "IconEdit", "sort_order": 1, "roles": ["admin", "editor"]},
{"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]},
{"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]},
{"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]},
{"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]},
{"name": "外推营销", "path": "campaigns.html", "icon": "IconStar", "sort_order": 6, "roles": ["admin", "editor"]},
{"name": "AI味检测", "path": "ai-slop.html", "icon": "IconSearch", "sort_order": 7, "roles": ["admin", "editor"]},
{"name": "白标设置", "path": "branding.html", "icon": "IconSetting", "sort_order": 8, "roles": ["admin"]},
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 9, "roles": ["admin"]},
]
for m in default_menus:
found = db.query(Menu).filter(Menu.path == m["path"]).first()
if found:
found.sort_order = m["sort_order"]
found.roles = m["roles"]
found.icon = m["icon"]
found.is_active = True
# 保留原名称,避免意外重命名
else:
db.add(Menu(**m))
# 原独立的选题/内容工厂/文章管理已合并进「创作工作台」,从主导航隐藏(页面仍可被 studio 内嵌与直接访问)
for legacy in ["topics.html", "factory.html", "articles.html"]:
legacy_menu = db.query(Menu).filter(Menu.path == legacy).first()
if legacy_menu:
legacy_menu.is_active = False
db.commit()
print("✅ 菜单已对齐(创作工作台合并选题/内容工厂/文章管理)")
# 同步 PostgreSQL 自增序列
if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
+3 -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, 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, search_rankings, external_promotion
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, search_rankings, external_promotion, ai_slop, branding
from .initial_data import import_initial_data
from .core.scheduler import scheduler
@@ -106,6 +106,8 @@ app.include_router(menu_configs.public_router)
app.include_router(search_providers.router)
app.include_router(search_rankings.router)
app.include_router(external_promotion.router)
app.include_router(ai_slop.router)
app.include_router(branding.router)
# 挂载自动生成的图片(必须先于前端根挂载)
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent