feat: 数据源统一与前端预览修复

=== 后端核心 ===
- db_helper: 统一数据库访问抽象层
- system.py API:
  * 参数绑定修复: 使用 Body(embed=True) 接收 JSON
  * 添加请求日志记录
- sync.py: 仅导出 DB→JSON(备份)

=== 合规与流水线 ===
- compliance_checker: 标签检测优化(仅检查容器,避免正文误判)
- 所有脚本(creator/collector/writer/outline/research等)统一使用数据库

=== 前端改版 ===
- topics.html:
  * 创作/优化 API 路径修正
  * 预览弹窗重设计:多平台并行加载、富文本显示、单复制按钮
  * 状态中文映射(getStatusLabel)
  * 认证检查
- 所有 HTML 静态资源路径修复(移除 /static 前缀)

=== 数据一致性 ===
- 数据库状态统一为英文(pending/review/ready/published)
- 前端显示中文化映射

已测试 A03 流水线完整通过。
This commit is contained in:
lt
2026-05-07 11:25:42 +08:00
parent 8dd19a2179
commit 31d6306e3b
24 changed files with 1018 additions and 530 deletions
+66 -70
View File
@@ -1,63 +1,65 @@
from fastapi import APIRouter, HTTPException, Depends
import logging
from fastapi import APIRouter, HTTPException, Depends, Body
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, date, timedelta
from datetime import datetime, date
from typing import Dict, Any, List, Optional
from pathlib import Path
from .auth import get_current_user
import os
import json
from ..database import get_db
from ..models import Topic, Article
from ..schemas import SystemStatus
from ..core.generator import run_creator
from ..core.optimizer import run_optimizer
from ..core.sync import sync_all_topics
from ..core.scheduler import scheduler
from .auth import get_current_user
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
DATA_DIR = PROJECT_ROOT / "automation" / "data"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system", tags=["system"])
def _aggregate_status_counts(db: Session):
"""聚合状态计数,兼容中英文状态值"""
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
mapping = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
'ready': ['ready', '待发布'],
'published': ['published', '已发布']
}
counts = {'pending': 0, 'review': 0, 'ready': 0, 'published': 0}
for status_val, cnt in raw:
for key, aliases in mapping.items():
if status_val in aliases:
counts[key] += cnt
break
return counts
@router.get("/status")
def get_status(db: Session = Depends(get_db)):
"""系统状态概览 - 返回前端兼容格式"""
total = db.query(Topic).count()
by_status_result = db.query(Topic.status, func.count()).group_by(Topic.status).all()
by_status = {status: count for status, count in by_status_result}
# 确保返回所有状态(数据库存中文,返回前端需要英文)
status_map = {
'pending': by_status.get('待处理', 0),
'review': by_status.get('待审查', 0),
'ready': by_status.get('待发布', 0),
'published': by_status.get('已发布', 0)
}
# 计算今日新增
counts = _aggregate_status_counts(db)
today = date.today()
today_count = db.query(Topic).filter(
func.date(Topic.created_at) == today
).count()
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
return {
"stats": {
"total": total,
"pending": status_map['pending'],
"review": status_map['review'],
"ready": status_map['ready'],
"published": status_map['published'],
"pending": counts['pending'],
"review": counts['review'],
"ready": counts['ready'],
"published": counts['published'],
"today": today_count
}
}
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
"""手动触发内容创作任务"""
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db)):
logger.info(f"Received topic_id={topic_id}")
try:
result = run_creator(topic_id)
if not result["ok"]:
@@ -68,8 +70,7 @@ def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/optimize/run", dependencies=[Depends(get_current_user)])
def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_db)):
"""手动触发合规优化任务"""
def trigger_optimization(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db)):
try:
result = run_optimizer(topic_ids)
if not result["ok"]:
@@ -85,7 +86,6 @@ def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_
@router.get("/logs/{log_date}", dependencies=[Depends(get_current_user)])
def get_logs(log_date: str, log_type: str = "creator"):
"""读取日志文件内容"""
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
if not log_file.exists():
raise HTTPException(status_code=404, detail=f"Log file not found: {log_file}")
@@ -94,59 +94,56 @@ def get_logs(log_date: str, log_type: str = "creator"):
return {"log_date": log_date, "log_type": log_type, "content": lines}
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
def get_pipeline_status():
"""获取流水线各模块状态"""
try:
topics_file = DATA_DIR / "sustainability_topics.json"
topics = []
if topics_file.exists():
topics = json.loads(topics_file.read_text(encoding='utf-8'))
status_counts = {}
for t in topics:
s = t.get('status', 'unknown')
status_counts[s] = status_counts.get(s, 0) + 1
log_files = {
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
}
pipeline_status = {}
for name, log_file in log_files.items():
if log_file.exists():
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
pipeline_status[name] = {"last_run": mtime.isoformat(), "exists": True}
else:
pipeline_status[name] = {"exists": False, "last_run": None}
return {"topics_count": len(topics), "status_distribution": status_counts, "pipeline_modules": pipeline_status}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def get_pipeline_status(db: Session = Depends(get_db)):
total = db.query(Topic).count()
counts = _aggregate_status_counts(db)
log_files = {
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
}
pipeline_status = {}
for name, log_file in log_files.items():
if log_file.exists():
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
pipeline_status[name] = {"last_run": mtime.isoformat(), "exists": True}
else:
pipeline_status[name] = {"exists": False, "last_run": None}
return {"topics_count": total, "status_distribution": counts, "pipeline_modules": pipeline_status}
@router.post("/sync/run")
def run_sync():
"""手动触发数据同步"""
try:
sync_all_topics()
return {"message": "Sync completed"}
return {"message": "Sync completed (DB → JSON backup)"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/automation/topics")
def list_automation_topics():
"""直接读取自动化流水线的选题 JSON"""
def list_automation_topics(db: Session = Depends(get_db)):
try:
topics_file = DATA_DIR / "sustainability_topics.json"
if not topics_file.exists():
raise HTTPException(status_code=404, detail="Topics JSON not found")
topics = json.loads(topics_file.read_text(encoding='utf-8'))
return {"count": len(topics), "topics": topics[-50:]}
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"JSON parse error: {e}")
topics = db.query(Topic).order_by(Topic.created_at.desc()).limit(100).all()
result = []
for t in topics:
result.append({
"id": t.id,
"title": t.title,
"field": t.field,
"status": t.status,
"priority": t.priority,
"priority_score": t.priority_score,
"total_score": t.total_score,
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
"ready_at": t.ready_at.isoformat() if t.ready_at else None,
"compliance_score": t.compliance_score
})
return {"count": len(result), "topics": result[:50]}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/refresh")
def refresh_all():
"""刷新所有数据"""
try:
sync_all_topics()
return {"message": "Refresh completed"}
@@ -155,5 +152,4 @@ def refresh_all():
@router.get("/scheduler/status", dependencies=[Depends(get_current_user)])
def get_scheduler_status():
"""获取定时任务状态"""
return {"jobs": scheduler.get_jobs()}
+77 -33
View File
@@ -13,52 +13,96 @@ if os.getenv('PROJECT_ROOT'):
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
def sync_topic_to_db(topic_id: str, db: Session = None) -> Topic:
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
topic_data = next((t for t in topics if t['id'] == topic_id), None)
if not topic_data:
raise ValueError(f"Topic {topic_id} not found in file")
"""注意:此函数原用于将JSON单个选题同步到数据库。现已不需要,保留用于兼容。当前方向相反(DB为主),此处仅从数据库导出到JSON(如果需要)"""
# 为了不破坏旧调用,我们改为从数据库读取并写入 JSON 文件(单条更新)
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
db_topic = db.query(Topic).filter(Topic.id == topic_id).first()
if db_topic is None:
db_topic = Topic(
id=topic_data['id'],
title=topic_data['title'],
field=topic_data['field'],
format=topic_data.get('format'),
core_concept=topic_data.get('core_concept'),
audience_pain=topic_data.get('audience_pain'),
unique_angle=topic_data.get('unique_angle'),
priority=topic_data.get('priority'),
priority_score=topic_data.get('priority_score', 0),
total_score=topic_data.get('total_score')
)
db.add(db_topic)
db_topic.status = topic_data.get('status', db_topic.status)
db_topic.ready_at = datetime.strptime(topic_data['ready_at'], '%Y-%m-%d').date() if topic_data.get('ready_at') else None
db_topic.published_at = datetime.strptime(topic_data['published_at'], '%Y-%m-%d').date() if topic_data.get('published_at') else None
db_topic.compliance_score = topic_data.get('compliance_score', db_topic.compliance_score)
db_topic.platform_urls = topic_data.get('platform_urls', {})
db_topic.generated_at = datetime.now() if db_topic.generated_at is None and topic_data.get("status") in ["ready", "published"] else db_topic.generated_at
db_topic.updated_at = datetime.now()
db.commit()
db.refresh(db_topic)
return db_topic
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise ValueError(f"Topic {topic_id} not found in DB")
# 写入 JSON 文件(作为备份)
try:
if TOPICS_FILE.exists():
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
topics = json.load(f)
else:
topics = []
# 转为字典
tdict = {
'id': topic.id,
'title': topic.title,
'field': topic.field,
'format': topic.format,
'core_concept': topic.core_concept,
'audience_pain': topic.audience_pain,
'unique_angle': topic.unique_angle,
'priority': topic.priority,
'priority_score': topic.priority_score,
'total_score': topic.total_score,
'status': topic.status,
'cases': topic.cases or [],
'source_file': topic.source_file,
'created_at': topic.created_at.isoformat() if topic.created_at else None,
'updated_at': topic.updated_at.isoformat() if topic.updated_at else None,
'ready_at': topic.ready_at.isoformat() if topic.ready_at else None,
'published_at': topic.published_at.isoformat() if topic.published_at else None,
'compliance_score': topic.compliance_score,
'platform_urls': topic.platform_urls or {}
}
# 更新或追加
found = False
for i, t in enumerate(topics):
if t['id'] == topic_id:
topics[i] = tdict
found = True
break
if not found:
topics.append(tdict)
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[Warning] JSON backup failed: {e}")
return topic
finally:
if close_db:
db.close()
def sync_all_topics():
"""导出所有选题到 JSON 文件(用于备份或兼容)"""
db = SessionLocal()
try:
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
topics = db.query(Topic).order_by(Topic.created_at).all()
topic_list = []
for t in topics:
sync_topic_to_db(t['id'], db)
print(f"✅ 同步 {len(topics)} 个选题到数据库")
tdict = {
'id': t.id,
'title': t.title,
'field': t.field,
'format': t.format,
'core_concept': t.core_concept,
'audience_pain': t.audience_pain,
'unique_angle': t.unique_angle,
'priority': t.priority,
'priority_score': t.priority_score,
'total_score': t.total_score,
'status': t.status,
'cases': t.cases or [],
'source_file': t.source_file,
'created_at': t.created_at.isoformat() if t.created_at else None,
'updated_at': t.updated_at.isoformat() if t.updated_at else None,
'ready_at': t.ready_at.isoformat() if t.ready_at else None,
'published_at': t.published_at.isoformat() if t.published_at else None,
'compliance_score': t.compliance_score,
'platform_urls': t.platform_urls or {}
}
topic_list.append(tdict)
TOPICS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
json.dump(topic_list, f, ensure_ascii=False, indent=2)
print(f"✅ 导出 {len(topic_list)} 个选题到 JSON (兼容模式)")
finally:
db.close()
+6 -6
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 用户管理</title>
<link rel="stylesheet" href="/static/element-plus/index.css">
<link rel="stylesheet" href="element-plus.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
@@ -71,7 +71,7 @@
<div id="app">
<nav class="navbar">
<div class="navbar-content">
<h1 class="navbar-title">宇之然内容创作平台 - 系统管理</h1>
<h1 class="navbar-title">宇之然内容创作平台 - 系统</h1>
<div class="navbar-user">
<div class="user-info"><div class="avatar">{{ currentUser.username.charAt(0).toUpperCase() }}</div><span>{{ currentUser.username }}</span></div>
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
@@ -83,7 +83,7 @@
<button class="sidebar-btn" @click="redirectToPage('topics.html')">📋 选题管理</button>
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
<button class="sidebar-btn active">⚙️ 系统管理</button>
<button class="sidebar-btn active">⚙️ 系统</button>
</aside><main class="content-area">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="案例管理" name="cases"></el-tab-pane>
@@ -231,10 +231,10 @@
<button class="mobile-nav-btn" @click="redirectToPage('topics.html')">📋 选题</button>
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
<button class="mobile-nav-btn active">⚙️ 系统管理</button>
<button class="mobile-nav-btn active">⚙️ 系统</button>
</nav></div>
<script src="/static/vue/vue.global.js"></script>
<script src="/static/element-plus/index.full.min.js"></script>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { createApp, ref, reactive, onMounted, watch } = Vue;
const { ElMessage, ElMessageBox } = ElementPlus;
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台</title>
<link rel="stylesheet" href="/static/element-plus/index.css">
<link rel="stylesheet" href="element-plus.css">
<style>
/* 深色渐变背景主题 */
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -476,8 +476,8 @@
</nav>
</div>
<script src="/static/vue/vue.global.js"></script>
<script src="/static/element-plus/index.full.min.js"></script>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const App = {
data() {
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 登录</title>
<link rel="stylesheet" href="/static/element-plus/index.css">
<link rel="stylesheet" href="element-plus.css">
<style>
/* 重置与基础样式 */
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -269,8 +269,8 @@
</p>
</div>
<script src="/static/vue/vue.global.js"></script>
<script src="/static/element-plus/index.full.min.js"></script>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { ref } = Vue;
const { ElMessage } = ElementPlus;
+5 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 系统日志</title>
<link rel="stylesheet" href="/static/element-plus/index.css">
<link rel="stylesheet" href="element-plus.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
@@ -57,7 +57,7 @@
<button class="sidebar-btn" @click="redirectToPage('topics.html')">📋 选题管理</button>
<button class="sidebar-btn active">📄 系统日志</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统管理</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</aside>
<main class="content-area">
<div class="card">
@@ -81,11 +81,11 @@
<button class="mobile-nav-btn" @click="redirectToPage('topics.html')">📋 选题</button>
<button class="mobile-nav-btn active">📄 日志</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
<button v-if="isAdmin" class="mobile-nav-btn">⚙️ 系统管理</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</nav>
</div>
<script src="/static/vue/vue.global.js"></script>
<script src="/static/element-plus/index.full.min.js"></script>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const LogsApp = {
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, logType: 'creator', logDate: '', logContent: '', loadingLogs: false } },
+8
View File
@@ -62,6 +62,14 @@ http {
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# 静态文件直接服务
location /static/ {
alias /root/openclaw-workspace/projects/yu-zhi-ran/platform/frontend/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
# 前端静态资源缓存
proxy_cache STATIC;
+128 -24
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 选题管理</title>
<link rel="stylesheet" href="/static/element-plus/index.css">
<link rel="stylesheet" href="element-plus.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
@@ -97,7 +97,7 @@
<button class="sidebar-btn active">📋 选题管理</button>
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统管理</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</aside>
<main class="content-area">
<div class="card">
@@ -124,7 +124,7 @@
<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">
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ scope.row.status }}</span></template>
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
</el-table-column>
<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>
@@ -149,7 +149,7 @@
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
<div class="topic-card-header">
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
<el-tag :type="getStatusType(topic.status)" size="small">{{ topic.status }}</el-tag>
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
</div>
<div class="topic-card-tags">
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
@@ -179,18 +179,30 @@
<button class="mobile-nav-btn active">📋 选题</button>
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
<button v-if="isAdmin" class="mobile-nav-btn">⚙️ 系统管理</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</nav>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" title="选题预览" width="80%" :before-close="() => previewVisible = false">
<div v-if="previewTopic">
<h2 style="margin-top: 0;">{{ previewTopic.title }}</h2>
<div class="preview-content" style="max-height: 60vh; overflow-y: auto; margin: 16px 0; padding: 16px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa;">
{{ previewTopic.content || '暂无内容' }}
<!-- 平台切换按钮 -->
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
<el-button-group>
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
</el-button-group>
</div>
<h2 style="margin-top: 0;">{{ previewTopic.title }}</h2>
<!-- 富文本内容预览(v-html 渲染) -->
<div class="preview-content" v-html="getPreviewHtml(previewPlatform)"
style="max-height: 60vh; overflow-y: auto; margin: 16px 0; padding: 16px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa;">
</div>
<div class="preview-footer" style="font-size: 14px; color: #909399;">
<div>创建时间:{{ formatDate(previewTopic.created_at) }}</div>
<div>状态:{{ previewTopic.status }}</div>
<div>状态:{{ getStatusLabel(previewTopic.status) }}</div>
<div v-if="previewTopic.generated_at">创作时间:{{ formatDate(previewTopic.generated_at) }}</div>
<div v-if="previewTopic.published_at">发布时间:{{ formatDate(previewTopic.published_at) }}</div>
</div>
@@ -198,15 +210,13 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="copyContent('微信公众号')">复制并发布到微信公众号</el-button>
<el-button type="primary" @click="copyContent('知乎')">复制并发布到知乎</el-button>
<el-button type="primary" @click="copyContent('小红书')">复制并发布到小红书</el-button>
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
</div>
</template>
</el-dialog>
</div>
<script src="/static/vue/vue.global.js"></script>
<script src="/static/element-plus/index.full.min.js"></script>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TopicsApp = {
@@ -222,7 +232,9 @@ const TopicsApp = {
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [],
previewVisible: false,
previewTopic: null
previewTopic: null,
previewPlatform: 'zhihu',
platformContents: {}
}
},
computed: {
@@ -285,18 +297,100 @@ const TopicsApp = {
async triggerGenerateSelected() {
if (!this.selectedTopicIds.length) return;
this.$message.success('批量创作已启动');
this.selectedTopicIds = [];
await this.fetchTopics();
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
// 取第一个选题(当前简单实现)
const topicId = this.selectedTopicIds[0];
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_id: topicId })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
await response.json();
this.$message.success('批量创作已启动');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量创作失败:', error);
this.$message.error(`批量创作失败: ${error.message}`);
}
},
async triggerOptimizeSelected() {
if (!this.selectedTopicIds.length) return;
this.$message.success('批量优化已启动');
this.selectedTopicIds = [];
await this.fetchTopics();
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success('批量优化完成');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量优化失败:', error);
this.$message.error(`批量优化失败: ${error.message}`);
}
},
openPreview(topic) {
async openPreview(topic) {
this.previewTopic = topic;
this.previewPlatform = 'zhihu';
this.previewVisible = true;
this.platformContents = {};
// 并行加载所有平台内容
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.warning('请先登录');
return;
}
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
const promises = platforms.map(p =>
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d && d.html) {
this.platformContents[p] = d.html;
}
})
.catch(e => console.error(`加载${p}预览失败:`, e))
);
await Promise.all(promises);
},
getPreviewHtml(platform) {
return this.platformContents[platform] || '暂无内容';
},
platformName(platform) {
const names = {
zhihu: '知乎',
wechat: '微信公众号',
xiaohongshu: '小红书'
};
return names[platform] || platform;
},
copyContent(platform) {
if (!this.previewTopic || !this.previewTopic.content) {
@@ -312,6 +406,7 @@ const TopicsApp = {
});
},
async createTopic(topic) {
console.log('createTopic clicked, topic:', topic);
if (this.isStatus(topic, 'published')) {
this.$message.info('已发布选题不可创作');
return;
@@ -322,13 +417,13 @@ const TopicsApp = {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/generate/run', {
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: [topic.id] })
body: JSON.stringify({ topic_id: topic.id })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@@ -353,7 +448,7 @@ const TopicsApp = {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/optimizer/run', {
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
@@ -433,6 +528,15 @@ const TopicsApp = {
},
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) {
const statusMap = {
'pending': '待处理',
'review': '待审查',
'ready': '待发布',
'published': '已发布'
};
return statusMap[status] || status;
},
formatDate(dateStr) {
if (!dateStr) return '-';
try {
+5 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 用户管理</title>
<link rel="stylesheet" href="/static/element-plus/index.css">
<link rel="stylesheet" href="element-plus.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
@@ -84,7 +84,7 @@
<button class="sidebar-btn" @click="redirectToPage('topics.html')">📋 选题管理</button>
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
<button v-if="isAdmin" class="sidebar-btn active">👥 用户管理</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统管理</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</aside>
<main class="content-area">
<div class="card">
@@ -114,11 +114,11 @@
<button class="mobile-nav-btn" @click="redirectToPage('topics.html')">📋 选题</button>
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
<button v-if="isAdmin" class="mobile-nav-btn active">👥 用户</button>
<button v-if="isAdmin" class="mobile-nav-btn">⚙️ 系统管理</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</nav>
</div>
<script src="/static/vue/vue.global.js"></script>
<script src="/static/element-plus/index.full.min.js"></script>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const UsersApp = {
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, users: [] } },