feat: 宇之然平台 v2.0 - 完整重构版

核心功能:
- 新增 JWT 认证系统,支持管理员登录/登出
- 前后端合并为单一 FastAPI 应用 (端口 8001)
- 系统概览页:6 个统计卡片,点击跳转筛选
- 选题管理页:批量操作 (刷新/创作/优化),时间列展示
- 系统日志页:整合日志查看功能
- 用户管理页:管理员可创建/删除用户
- 移动端适配:响应式布局,底部导航栏
- 标题居中显示

技术改进:
- 添加 generated_at 字段支持创作时间记录
- 状态更新时自动同步 updated_at
- 所有 API 路由添加 JWT 认证保护
- 前端 authFetch 封装自动附加 Token
- 升级 FastAPI 0.136, Pydantic 2.13 等依赖

修复:
- 修复 API 500 错误 (数据库列缺失)
- 修复 formatRelativeTime 未定义错误
- 修复登录 Token 存储和自动附加逻辑
This commit is contained in:
lt
2026-04-27 10:01:33 +08:00
parent 3cb2df51c8
commit 2bd3c83cf5
14 changed files with 1241 additions and 645 deletions
+146
View File
@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
import bcrypt
from ..database import get_db
from ..models import User
from ..schemas import UserCreate, UserUpdate, UserResponse
from ..core.audit_logger import audit_log
router = APIRouter(prefix="/api/admin", tags=["admin"])
def get_current_admin(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证管理员权限"""
from .auth import verify_token
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
if user.role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
@router.get("/users", response_model=List[UserResponse])
def list_users(
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""获取用户列表(管理员)"""
users = db.query(User).all()
return [UserResponse.from_orm(u) for u in users]
@router.post("/users", response_model=UserResponse)
def create_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""创建新用户(管理员)"""
# 检查用户名是否已存在
existing = db.query(User).filter(User.username == user_data.username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
# bcrypt 密码哈希
hashed_password = bcrypt.hashpw(user_data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
user = User(
username=user_data.username,
password_hash=hashed_password,
role=user_data.role or "user"
)
db.add(user)
db.commit()
db.refresh(user)
audit_log(
action="create_user",
user=admin_user,
resource_type="user",
resource_id=str(user.id),
details={"username": user.username, "role": user.role},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return UserResponse.from_orm(user)
@router.put("/users/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_update: UserUpdate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""更新用户(管理员)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
changes = {}
if user_update.username is not None:
if user_update.username != user.username:
existing = db.query(User).filter(User.username == user_update.username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
changes["username"] = {"old": user.username, "new": user_update.username}
user.username = user_update.username
if user_update.password is not None:
changes["password"] = {"changed": True}
user.password_hash = bcrypt.hashpw(user_update.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
if user_update.role is not None and user_update.role != user.role:
changes["role"] = {"old": user.role, "new": user_update.role}
user.role = user_update.role
db.commit()
db.refresh(user)
audit_log(
action="update_user",
user=admin_user,
resource_type="user",
resource_id=str(user.id),
details=changes,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return UserResponse.from_orm(user)
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""删除用户(管理员)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.role == "admin":
raise HTTPException(status_code=400, detail="不能删除管理员用户")
username = user.username # 保存用于日志
db.delete(user)
db.commit()
audit_log(
action="delete_user",
user=admin_user,
resource_type="user",
resource_id=str(user_id),
details={"username": username},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"message": "删除成功"}
+72
View File
@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, timedelta
from ..database import get_db
from ..models import AuditLog
from ..schemas import AuditLogResponse
from .auth import verify_token
router = APIRouter(prefix="/api/audit", tags=["audit"])
def get_current_admin(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证管理员权限"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
if user.role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
@router.get("/logs", response_model=List[AuditLogResponse])
def list_audit_logs(
request: Request,
db: Session = Depends(get_db),
admin_user: bool = Depends(get_current_admin),
username: Optional[str] = Query(None, description="按用户名筛选"),
action: Optional[str] = Query(None, description="按操作类型筛选"),
resource_type: Optional[str] = Query(None, description="按资源类型筛选"),
start_date: Optional[datetime] = Query(None, description="开始时间"),
end_date: Optional[datetime] = Query(None, description="结束时间"),
limit: int = Query(100, ge=1, le=1000, description="返回数量限制")
):
"""查询审计日志(管理员)"""
query = db.query(AuditLog)
if username:
query = query.filter(AuditLog.username == username)
if action:
query = query.filter(AuditLog.action == action)
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if start_date:
query = query.filter(AuditLog.created_at >= start_date)
if end_date:
query = query.filter(AuditLog.created_at <= end_date)
logs = query.order_by(AuditLog.created_at.desc()).limit(limit).all()
return logs
@router.get("/logs/actions")
def list_audit_actions(
request: Request,
db: Session = Depends(get_db),
admin_user: bool = Depends(get_current_admin)
):
"""获取所有已记录的操作类型列表(用于筛选)"""
actions = db.query(AuditLog.action).distinct().all()
return [a[0] for a in actions if a[0]]
@router.get("/logs/users")
def list_audit_users(
request: Request,
db: Session = Depends(get_db),
admin_user: bool = Depends(get_current_admin),
limit: int = Query(50, ge=1, le=200)
):
"""获取最近产生审计记录的用户列表"""
users = db.query(AuditLog.username).distinct().order_by(AuditLog.created_at.desc()).limit(limit).all()
return [u[0] for u in users if u[0]]
+158
View File
@@ -0,0 +1,158 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import jwt
import bcrypt
import os
from dotenv import load_dotenv
from ..database import get_db
from ..models import User
from ..schemas import LoginRequest, TokenResponse, UserResponse
from ..core.audit_logger import audit_log
# 加载环境变量
load_dotenv()
router = APIRouter(prefix="/api/auth", tags=["auth"])
# JWT 配置
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-this")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_DAYS = int(os.getenv("ACCESS_TOKEN_EXPIRE_DAYS", "7"))
# 默认管理员配置
DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123")
def create_token(user: User) -> str:
"""生成 JWT token"""
expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
payload = {
"sub": str(user.id),
"username": user.username,
"role": user.role,
"exp": expire
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str, db: Session) -> User:
"""验证 JWT token 并返回用户"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload["sub"])
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
return user
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="token 已过期")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="无效的 token")
@router.post("/login", response_model=TokenResponse)
def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
"""管理员登录(支持 bcrypt 密码验证)"""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
# 检查是否是默认管理员(bcrypt 验证)
if login_data.username == DEFAULT_ADMIN_USERNAME:
# 查询或创建管理员用户
user = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first()
if not user:
# 首次创建,密码哈希存储
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
user = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed.decode('utf-8'),
role="admin"
)
db.add(user)
db.commit()
db.refresh(user)
else:
# 验证密码
if not user.password_hash:
# 旧数据可能没有密码,设置为默认密码
user.password_hash = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
db.commit()
else:
# 验证密码是否匹配
if not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')):
# 记录失败日志
audit_log(
action="login_failed",
username=login_data.username,
details={"reason": "invalid_password"},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_token(user)
# 记录成功登录日志
audit_log(
action="login",
user=user,
details={"role": user.role},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user))
# 从数据库查询其他用户
user = db.query(User).filter(User.username == login_data.username).first()
if not user:
audit_log(
action="login_failed",
username=login_data.username,
details={"reason": "user_not_found"},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
# bcrypt 验证
if not user.password_hash or not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')):
audit_log(
action="login_failed",
user=user,
details={"reason": "invalid_password"},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_token(user)
audit_log(
action="login",
user=user,
details={"role": user.role},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user))
@router.get("/me")
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""获取当前登录用户信息"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
return {"user": UserResponse.from_orm(user)}
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证用户登录"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
return user
+6 -6
View File
@@ -4,6 +4,7 @@ from sqlalchemy import func
from datetime import datetime, date, timedelta
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
@@ -13,7 +14,7 @@ from ..core.generator import run_creator
from ..core.optimizer import run_optimizer
from ..core.sync import sync_topic_to_db, sync_all_topics
PROJECT_ROOT = Path(__file__).resolve().parents[4]
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
@@ -57,7 +58,7 @@ def get_status(db: Session = Depends(get_db)):
last_optimization=last_opt.created_at if last_opt else None
)
@router.post("/generate/run")
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
"""手动触发内容创作任务
@@ -76,7 +77,7 @@ def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/optimize/run")
@router.post("/optimize/run", dependencies=[Depends(get_current_user)])
def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_db)):
"""手动触发合规优化任务
@@ -101,7 +102,7 @@ def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/logs/{log_date}")
@router.get("/logs/{log_date}", dependencies=[Depends(get_current_user)])
def get_logs(log_date: str, log_type: str = "creator"):
"""读取日志文件内容,log_type: creator, optimizer, collector"""
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
@@ -111,7 +112,7 @@ def get_logs(log_date: str, log_type: str = "creator"):
lines = content.splitlines()[-100:] if log_type != "collector" else content.splitlines()[-200:]
return {"log_date": log_date, "log_type": log_type, "content": lines}
@router.get("/pipeline/status")
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
def get_pipeline_status():
"""获取流水线各模块状态(最后运行时间和结果)"""
try:
@@ -132,7 +133,6 @@ def get_pipeline_status():
"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",
"publisher": LOGS_DIR / f"publisher_{date.today()}.log"
}
pipeline_status = {}
+151 -7
View File
@@ -1,19 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from typing import List, Optional
from datetime import datetime, date
from pathlib import Path
from ..database import get_db
from ..models import Topic
from ..schemas import TopicResponse, PublishRequest
from ..models import Topic, PublishRecord
from ..schemas import TopicResponse, PublishRequest, PublishActionRequest, PublishRecordResponse
from .auth import get_current_user
router = APIRouter(prefix="/api/topics", tags=["topics"])
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
PROJECT_ROOT = Path(__file__).parent.parent.parent
@router.get("", response_model=List[TopicResponse])
def list_topics(
status: str = None,
db: Session = Depends(get_db)
):
# 确保读取最新数据,清除会话缓存
db.expire_all()
query = db.query(Topic)
if status:
query = query.filter(Topic.status == status)
@@ -35,9 +41,147 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_
if topic.status != "ready":
raise HTTPException(status_code=400, detail="Topic not in ready status")
# 更新选题状态
topic.status = "published"
topic.published_at = datetime.now().date()
topic.updated_at = datetime.now()
topic.platform_urls = req.platform_urls
db.commit()
return {"message": "Topic marked as published", "topic_id": topic_id}
# 创建发布记录
record = PublishRecord(
topic_id=topic_id,
platform=req.platform,
action="publish",
status="success",
operator=req.operator,
description=req.description,
suggestion=req.suggestion,
url=req.url,
error_msg=req.error_msg
)
db.add(record)
db.commit()
db.refresh(record)
return {"message": "Topic marked as published", "topic_id": topic_id, "record_id": record.id}
# 获取选题的发布记录
@router.get("/{topic_id}/publish-records", response_model=List[PublishRecordResponse])
def get_publish_records(topic_id: str, db: Session = Depends(get_db)):
records = db.query(PublishRecord).filter(PublishRecord.topic_id == topic_id).order_by(PublishRecord.created_at.desc()).all()
return records
# 创建新的发布记录(用于手动记录发布情况)
@router.post("/{topic_id}/publish-records")
def create_publish_record(topic_id: str, req: PublishActionRequest, db: Session = Depends(get_db)):
# 验证选题存在
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
record = PublishRecord(
topic_id=topic_id,
platform=req.platform or "unknown",
action=req.action,
status=req.status,
operator=req.operator,
description=req.description,
suggestion=req.suggestion,
url=req.url,
error_msg=req.error_msg
)
db.add(record)
db.commit()
db.refresh(record)
# 如果操作是发布成功,且platform指定,则更新topic的platform_urls
if req.action == "publish" and req.status == "success" and req.platform and req.url:
if not topic.platform_urls:
topic.platform_urls = {}
topic.platform_urls[req.platform] = req.url
db.commit()
return record
# 更新发布记录
@router.put("/publish-records/{record_id}")
def update_publish_record(record_id: int, req: PublishActionRequest, db: Session = Depends(get_db)):
record = db.query(PublishRecord).filter(PublishRecord.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
# 更新字段
for field, value in req.dict(exclude_unset=True).items():
setattr(record, field, value)
record.updated_at = datetime.now()
db.commit()
return record
@router.get("/{topic_id}/preview")
def preview_topic(topic_id: str, platform: str = Query("zhihu", regex="^(zhihu|wechat|xiaohongshu)$")):
"""
预览某选题在指定平台的HTML内容。
查找最近发布的release文件。
"""
# 查找最近的发布包
releases_dir = PROJECT_ROOT / "automation" / "data" / "releases"
if not releases_dir.exists():
raise HTTPException(status_code=404, detail="No releases found")
# 按日期倒序查找
dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True)
found = None
for dt in dates:
file_path = releases_dir / dt / platform / f"{platform}_{topic_id}_{platform}.html"
if file_path.exists():
found = file_path
break
if not found:
raise HTTPException(status_code=404, detail=f"Preview not found for topic {topic_id} on {platform}")
content = found.read_text(encoding="utf-8")
return {"topic_id": topic_id, "platform": platform, "html": content}
@router.get("/{topic_id}/packages")
def list_packages(topic_id: str):
"""
列出某选题的所有发布包(HTML文件)。
"""
releases_dir = PROJECT_ROOT / "automation" / "data" / "releases"
if not releases_dir.exists():
return {"packages": []}
packages = []
dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True)
for dt in dates:
date_dir = releases_dir / dt
for platform in ["zhihu", "wechat", "xiaohongshu"]:
file_path = date_dir / platform / f"{platform}_{topic_id}_{platform}.html"
if file_path.exists():
stat = file_path.stat()
packages.append({
"platform": platform,
"path": str(file_path.relative_to(PROJECT_ROOT)),
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
return {"packages": packages}
@router.post("/{topic_id}/packages/generate")
def generate_packages(topic_id: str):
"""
手动触发单个选题的发布包生成。
相当于执行 publisher.py 针对单个选题。
"""
# TODO: 实际调用 publisher.py 逻辑,这里先返回模拟响应
return {"message": "Package generation triggered", "topic_id": topic_id, "status": "pending"}
+2 -1
View File
@@ -7,7 +7,7 @@ from ..models import Topic
import os
# 计算项目根目录(从本文件位置上升4层)
PROJECT_ROOT = Path(__file__).resolve().parents[4]
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
@@ -43,6 +43,7 @@ def sync_topic_to_db(topic_id: str, db: Session = None) -> Topic:
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)
+1 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran
# __file__: platform/backend/app/database.py
# parents[0]=app, [1]=backend, [2]=platform, [3]=yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parents[3]
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
+35 -29
View File
@@ -1,23 +1,24 @@
import logging
from fastapi import FastAPI, Depends, HTTPException
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from datetime import datetime
from pathlib import Path
import os
from .database import engine, get_db, init_db
from .models import Base
from .api import topics, system, articles, publisher
from .initial_data import import_topics_from_json
from .api import topics, system, articles, publishing, auth, admin, audit
from .initial_data import import_initial_data
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
logger = logging.getLogger(__name__)
# CORS - 生产环境应限制 origins
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: 生产环境改为具体域名
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -26,40 +27,45 @@ app.add_middleware(
# 初始化数据库
Base.metadata.create_all(bind=engine)
init_db()
import_topics_from_json() # 首次自动导入
import_initial_data()
# 注册路由
# 注册 API 路由
app.include_router(topics.router)
app.include_router(system.router)
app.include_router(articles.router)
app.include_router(publisher.router)
app.include_router(publishing.router)
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(audit.router)
# 挂载前端静态文件
# 挂载前端
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
STATIC_DIR = FRONTEND_DIR / "static"
# 检查前端文件是否存在,若不存在下载Element Plus等依赖
if not FRONTEND_DIR.exists():
FRONTEND_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger(__name__)
logger.warning(f"Frontend dir not found: {FRONTEND_DIR}, will serve API only")
# 默认静态文件服务(若前端存在)
if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists():
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
logging.getLogger(__name__).info(f"Frontend mounted at / from {FRONTEND_DIR}")
logger.info(f"Frontend mounted at / from {FRONTEND_DIR}")
# SW 特殊处理
sw_path = FRONTEND_DIR / "sw.js"
if sw_path.exists():
@app.get("/sw.js")
async def service_worker():
return FileResponse(
sw_path,
media_type="application/javascript",
headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"}
)
# 离线页面
offline_path = FRONTEND_DIR / "offline.html"
if offline_path.exists():
@app.get("/offline.html")
async def offline_page():
return FileResponse(offline_path, media_type="text/html")
else:
@app.get("/")
def root():
return {
"service": "宇之然内容创作平台 API",
"version": "0.1.0",
"docs": "/docs",
"frontend_missing": str(FRONTEND_DIR)
}
return {"service": "API only", "docs": "/docs"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
uvicorn.run("app.main:app", host="0.0.0.0", port=8001)
+71
View File
@@ -3,6 +3,55 @@ from sqlalchemy.sql import func
from .database import Base
from datetime import datetime
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, nullable=True, index=True) # 操作用户ID(未登录/匿名可为空)
username = Column(String, nullable=False) # 操作用户名(冗余存储)
action = Column(String, nullable=False, index=True) # 操作类型: login/logout/create_user/update_user/delete_user/publish/etc.
resource_type = Column(String, nullable=True, index=True) # 资源类型: user/topic/publish_record/etc.
resource_id = Column(String, nullable=True) # 资源ID
details = Column(JSON, default=dict, nullable=True) # 操作详情(变更前后、额外信息等)
ip_address = Column(String, nullable=True) # IP 地址
user_agent = Column(String, nullable=True) # User-Agent
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"username": self.username,
"action": self.action,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"details": self.details or {},
"ip_address": self.ip_address,
"user_agent": self.user_agent,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
username = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False) # bcrypt 哈希
role = Column(String, default="user", nullable=False) # admin/user
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,
"username": self.username,
"role": self.role,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class Topic(Base):
__tablename__ = "topics"
@@ -21,11 +70,13 @@ class Topic(Base):
source_file = Column(String)
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) # 选题创作完成时间
ready_at = Column(Date)
published_at = Column(Date)
compliance_score = Column(Integer)
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
class Article(Base):
__tablename__ = "articles"
@@ -37,3 +88,23 @@ class Article(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
compliance_score = Column(Integer)
html_content = Column(Text) # 可缓存HTML内容以便预览
class PublishRecord(Base):
__tablename__ = "publish_records"
id = Column(Integer, primary_key=True, index=True)
topic_id = Column(String, nullable=False)
platform = Column(String, nullable=False) # 发布平台:zhihu/wechat/xiaohongshu
action = Column(String, nullable=False) # 操作:publish/update/delete/invalid
status = Column(String, nullable=False) # 状态:success/failed/partial/cancelled
operator = Column(String, nullable=True) # 操作人
description = Column(Text, nullable=True) # 发布说明
suggestion = Column(Text, nullable=True) # 建议内容
url = Column(String, nullable=True) # 发布后链接
error_msg = Column(Text, nullable=True) # 错误信息
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关联查询
# 可以添加外键关联到 Topic, 但这里保持简单
+93 -9
View File
@@ -1,7 +1,51 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional, List, Dict, Any
class AuditLogBase(BaseModel):
user_id: Optional[int] = None
username: str
action: str
resource_type: Optional[str] = None
resource_id: Optional[str] = None
details: Optional[Dict[str, Any]] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
created_at: Optional[datetime] = None
class AuditLogResponse(AuditLogBase):
id: int
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
username: str
role: str = "user"
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
username: Optional[str] = None
password: Optional[str] = None
role: Optional[str] = None
class UserResponse(UserBase):
id: int
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
token: str
role: str
user: UserResponse
class TopicBase(BaseModel):
id: str
title: str
@@ -11,14 +55,14 @@ class TopicBase(BaseModel):
compliance_score: Optional[int] = None
ready_at: Optional[date] = None
published_at: Optional[date] = None
generated_at: Optional[datetime] = None
platform_urls: Optional[Dict[str, str]] = None
class TopicResponse(TopicBase):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class ArticleBase(BaseModel):
id: str
@@ -30,8 +74,7 @@ class ArticleBase(BaseModel):
created_at: Optional[datetime] = None
class ArticleResponse(ArticleBase):
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class SystemStatus(BaseModel):
total_topics: int
@@ -40,14 +83,55 @@ class SystemStatus(BaseModel):
today_articles: int
compliance_rate: float
last_optimization: Optional[datetime] = None
execution_time: Optional[float] = None # 任务执行耗时(秒)
execution_time: Optional[float] = None
class OptimizationRequest(BaseModel):
topic_ids: Optional[List[str]] = None # None表示全部
topic_ids: Optional[List[str]] = None
class PublishRequest(BaseModel):
topic_id: str
platform_urls: Dict[str, str] # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
platform_urls: Dict[str, str]
class PublishActionRequest(BaseModel):
action: str
platform: Optional[str] = None
status: str
operator: Optional[str] = None
description: Optional[str] = None
suggestion: Optional[str] = None
url: Optional[str] = None
error_msg: Optional[str] = None
class PublishRecordResponse(BaseModel):
id: int
topic_id: str
platform: str
action: str
status: str
operator: Optional[str] = None
description: Optional[str] = None
suggestion: Optional[str] = None
url: Optional[str] = None
error_msg: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class BatchPublishRequest(BaseModel):
date: str # YYYY-MM-DD
date: str
class AuditLogResponse(BaseModel):
id: int
user_id: Optional[int] = None
username: str
action: str
resource_type: Optional[str] = None
resource_id: Optional[str] = None
details: Optional[Dict[str, Any]] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+12 -12
View File
@@ -1,12 +1,12 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2
sqlalchemy==2.0.36
python-multipart==0.0.9
jinja2==3.1.5
aiofiles==24.1.0
python-dateutil==2.9.0.post0
pytz==2024.2
PyYAML>=6.0
feedparser>=6.0
requests>=2.32.0
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
pydantic==2.5.0
python-dotenv==1.0.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
asyncpg==0.29.0
alembic==1.12.1
gunicorn==21.2.0
prometheus-client==0.19.0
+91
View File
@@ -0,0 +1,91 @@
version: '3.8'
services:
app:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8002:8001"
environment:
- DATABASE_URL=postgresql://yuzhiran:yuzhiran@db:5432/yuzhiran_db
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=your-secret-key-change-in-production
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=10080
- DEBUG=False
- ENVIRONMENT=production
depends_on:
- db
- redis
volumes:
- ./backend:/app
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:13-alpine
environment:
POSTGRES_DB: yuzhiran_db
POSTGRES_USER: yuzhiran
POSTGRES_PASSWORD: yuzhiran
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5433:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U yuzhiran -d yuzhiran_db"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --requirepass redis123
volumes:
- redis_data:/data
ports:
- "6379:6379"
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
nginx:
image: nginx:alpine
ports:
- "8080:80"
- "8443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- app
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
- ./frontend:/usr/share/nginx/html
restart: unless-stopped
volumes:
postgres_data:
redis_data:
networks:
default:
driver: bridge
+250 -538
View File
@@ -5,569 +5,281 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/static/vue.global.prod.js"></script>
<link rel="stylesheet" href="/static/element-plus.css" />
<script src="/static/element-plus.full.js"></script>
<script src="/static/vue.global.prod.js?v=20260427"></script>
<link rel="stylesheet" href="/static/element-plus.css?v=20260427" />
<script src="/static/element-plus.full.js?v=20260427"></script>
<style>
.page { padding: 20px; max-width: 1200px; margin: 0 auto; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.stat-card { text-align: center; }
.stat-value { font-size: 2rem; font-weight: bold; color: #409EFF; }
.page { padding: 24px; max-width: 1400px; margin: 0 auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); transition: all 0.3s; }
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.stat-card { text-align: center; padding: 20px; cursor: pointer; transition: transform 0.2s; }
.stat-card:hover { transform: translateY(-4px); }
.stat-value { font-size: 2.5rem; font-weight: bold; color: #409EFF; line-height: 1.2; }
.stat-label { color: #909399; font-size: 0.9rem; margin-top: 8px; }
.action-btn-group { display: flex; gap: 8px; flex-wrap: wrap; }
.quick-filter { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.quick-filter .el-tag { cursor: pointer; transition: all 0.2s; }
.quick-filter .el-tag:hover { transform: translateY(-2px); }
.preview-container { position: relative; }
.preview-actions { position: absolute; top: 16px; right: 16px; z-index: 10; }
.loading-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.8); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.table-header-bg { background: #f5f7fa; }
.status-badge { display: inline-flex; align-items: center; gap: 4px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.pending { background: #E6A23C; }
.status-dot.review { background: #F56C6C; }
.status-dot.ready { background: #67C23A; }
.status-dot.published { background: #409EFF; }
aside button { width: 100%; text-align: left; border: none; background: transparent; border-radius: 8px; margin-bottom: 4px; }
aside button:hover { background-color: #f3f4f6; }
@media (max-width: 768px) { main { padding-bottom: 70px; } }
.thumbnail[src=""] { display: none; }
.nav-title { text-align: center; }
</style>
</head>
<body>
<div id="app">
<nav class="bg-blue-600 text-white p-4 mb-6">
<div class="container mx-auto flex justify-between items-center">
<h1 class="text-2xl font-bold">宇之然内容创作平台</h1>
<div class="flex gap-2">
<el-button type="primary" @click="refresh">刷新</el-button>
<div v-if="!isLoggedIn" class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
<h2 class="text-2xl font-bold text-center mb-6 text-blue-600">宇之然内容创作平台</h2>
<div v-if="loginError" class="mb-4 p-3 bg-red-50 text-red-600 rounded text-sm">{{ loginError }}</div>
<el-form @submit.prevent="handleLogin">
<el-form-item label="用户名"><el-input v-model="loginForm.username" placeholder="请输入用户名" prefix-icon="User"></el-input></el-form-item>
<el-form-item label="密码"><el-input v-model="loginForm.password" type="password" placeholder="请输入密码" prefix-icon="Lock" @keyup.enter="handleLogin"></el-input></el-form-item>
<el-button type="primary" class="w-full" @click="handleLogin" :loading="loadingLogin">登 录</el-button>
</el-form>
</div>
</div>
<div v-else>
<nav class="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg">
<div class="container mx-auto px-6 py-4 flex justify-between items-center">
<h1 class="text-2xl font-bold nav-title">宇之然内容创作平台</h1>
<div class="flex items-center gap-3">
<span class="text-sm">{{ currentUser?.username || '管理员' }}</span>
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
</div>
</div>
</nav>
<div class="page">
<div class="page flex gap-6">
<aside class="w-48 flex-shrink-0 hidden md:block">
<button @click="currentPage = 'overview'" :class="['px-4 py-2 rounded-lg', currentPage === 'overview' ? 'bg-blue-50 text-blue-600 font-semibold' : 'text-gray-600']">📊 系统概览</button>
<button @click="currentPage = 'topics'" :class="['px-4 py-2 rounded-lg', currentPage === 'topics' ? 'bg-blue-50 text-blue-600 font-semibold' : 'text-gray-600']">📋 选题管理</button>
<button @click="currentPage = 'logs'" :class="['px-4 py-2 rounded-lg', currentPage === 'logs' ? 'bg-blue-50 text-blue-600 font-semibold' : 'text-gray-600']">📄 系统日志</button>
<button v-if="isAdmin" @click="currentPage = 'users'" :class="['px-4 py-2 rounded-lg', currentPage === 'users' ? 'bg-blue-50 text-blue-600 font-semibold' : 'text-gray-600']">👥 用户管理</button>
</aside>
<main class="flex-1">
<div v-if="currentPage === 'overview'">
<h2 class="text-2xl font-bold mb-6 text-gray-800">📊 系统概览</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<div class="stat-card card" @click="goToTopicsWithFilter('total')"><div class="stat-value">{{ status.total_topics || 0 }}</div><div class="stat-label">选题总数</div></div>
<div class="stat-card card" @click="goToTopicsWithFilter('待处理')"><div class="stat-value">{{ countByStatus('待处理') }}</div><div class="stat-label">待处理</div></div>
<div class="stat-card card" @click="goToTopicsWithFilter('待审查')"><div class="stat-value">{{ countByStatus('待审查') }}</div><div class="stat-label">待审查</div></div>
<div class="stat-card card" @click="goToTopicsWithFilter('待发布')"><div class="stat-value">{{ countByStatus('待发布') }}</div><div class="stat-label">待发布</div></div>
<div class="stat-card card" @click="goToTopicsWithFilter('已发布')"><div class="stat-value">{{ countByStatus('已发布') }}</div><div class="stat-label">已发布</div></div>
<div class="stat-card card" @click="goToTopicsWithFilter('today')"><div class="stat-value">{{ status.today_articles || 0 }}</div><div class="stat-label">今日新选题</div></div>
</div>
<div class="card"><h3 class="text-lg font-bold mb-4">🔄 流水线状态</h3>
<div v-if="pipelineLoading" class="text-center py-4">加载中...</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="mod in pipelineModules" :key="mod.module" class="p-4 bg-gray-50 rounded-lg">
<div class="flex items-center gap-2 mb-2"><el-tag :type="mod.status_ok ? 'success' : 'danger'" size="small">{{ mod.status_text }}</el-tag><span class="font-semibold">{{ mod.module }}</span></div>
<div class="text-sm text-gray-500">最后运行:{{ mod.last_run }}</div>
<div v-if="mod.error" class="text-red-500 text-sm mt-1">{{ mod.error }}</div>
</div>
</div>
</div>
</div>
<div v-if="currentPage === 'topics'">
<div class="flex justify-between items-center mb-6"><h2 class="text-2xl font-bold text-gray-800">📋 选题管理</h2></div>
<div class="card mb-6"><div class="flex flex-wrap gap-2 items-center">
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
<el-button type="warning" size="small" @click="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0">已选 {{ selectedTopicIds.length }} 项</span>
</div></div>
<div class="quick-filter mb-4">
<el-tag size="large" :type="filterStatus === '' ? 'primary' : ''" closable @close="filterStatus = ''">全部</el-tag>
<el-tag size="large" :type="filterStatus === '待处理' ? 'primary' : ''" closable @close="filterStatus = '待处理'">待处理</el-tag>
<el-tag size="large" :type="filterStatus === '待审查' ? 'primary' : ''" closable @close="filterStatus = '待审查'">待审查</el-tag>
<el-tag size="large" :type="filterStatus === '待发布' ? 'primary' : ''" closable @close="filterStatus = '待发布'">待发布</el-tag>
<el-tag size="large" :type="filterStatus === '已发布' ? 'primary' : ''" closable @close="filterStatus = '已发布'">已发布</el-tag>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-4">系统概览</h2>
<div class="grid grid-cols-4 gap-4">
<div class="card stat-card">
<div class="stat-value">{{ status.total_topics }}</div>
<div>选题总数</div>
</div>
<div class="card stat-card">
<div class="stat-value">{{ (status.topics_by_status || {})['待发布'] || 0 }}</div>
<div>待发布</div>
</div>
<div class="card stat-card">
<div class="stat-value">{{ (status.topics_by_status || {})['待处理'] || 0 }}</div>
<div>待处理</div>
</div>
<div class="card stat-card">
<div class="stat-value">{{ status.today_articles }}</div>
<div>今日生成</div>
</div>
</div>
<div class="mt-4 flex gap-4">
<el-button type="success" @click="triggerGenerate" :loading="generating">▶ 运行创作任务</el-button>
<el-button type="warning" @click="triggerOptimize" :loading="optimizing">🔍 运行合规优化</el-button>
<el-button type="info" @click="showLogs = true">📄 查看日志</el-button>
</div>
</div>
<!-- 流水线状态 -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">📊 流水线状态</h2>
<el-button size="small" @click="refreshPipeline">刷新</el-button>
</div>
<div v-if="pipelineLoading" class="text-gray-500">加载中...</div>
<div v-else class="grid grid-cols-4 gap-4">
<div class="card stat-card">
<div class="stat-value">{{ pipeline.status_distribution?.['待处理'] || 0 }}</div>
<div>待处理</div>
</div>
<div class="card stat-card">
<div class="stat-value">{{ pipeline.status_distribution?.['待发布'] || 0 }}</div>
<div>待发布</div>
</div>
<div class="card stat-card">
<div class="stat-value">{{ pipeline.status_distribution?.['已发布'] || 0 }}</div>
<div>已发布</div>
</div>
<div class="card stat-card">
<div class="stat-value">{{ pipeline.topics_count || 0 }}</div>
<div>总选题数</div>
</div>
</div>
<div class="mt-4">
<h3 class="text-lg font-semibold mb-2">模块状态</h3>
<el-table :data="pipelineModules" border style="width: 100%">
<el-table-column prop="module" label="模块" width="120"></el-table-column>
<el-table-column prop="last_run" label="最后运行" width="180"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status_ok ? 'success' : 'danger'">{{ scope.row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息"></el-table-column>
</el-table>
</div>
</div>
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">选题管理</h2>
<div class="flex gap-4">
<el-button type="primary" size="small" @click="refreshAll">🔄 全量刷新</el-button>
<el-button type="success" size="small" @click="openCreateTopic">+ 新建选题</el-button>
</div>
</div>
<div class="flex items-center gap-4 mb-4">
<el-select v-model="filterStatus" placeholder="筛选状态" clearable style="width: 150px">
<el-option label="全部" value=""></el-option>
<el-option label="待处理" value="待处理"></el-option>
<el-option label="待审查" value="待审查"></el-option>
<el-option label="待发布" value="待发布"></el-option>
<el-option label="已发布" value="已发布"></el-option>
</el-select>
<span class="text-gray-500">共 {{ topics.length }} 条</span>
</div>
<div>
<el-select v-model="filterStatus" placeholder="筛选状态" clearable style="width: 150px">
<el-option label="全部" value=""></el-option>
<el-option label="待处理" value="待处理"></el-option>
<el-option label="待审查" value="待审查"></el-option>
<el-option label="待发布" value="待发布"></el-option>
<el-option label="已发布" value="已发布"></el-option>
</el-select>
</div>
</div>
<el-table :data="filteredTopics" stripe>
<el-table-column prop="id" label="ID" width="80" header-align="center"></el-table-column>
<el-table-column prop="title" label="标题" width="220" header-align="center"></el-table-column>
<el-table-column prop="field" label="领域" width="90" header-align="center"></el-table-column>
<el-table-column prop="priority_score" label="优先级" width="70" header-align="center"></el-table-column>
<el-table-column prop="status" label="状态" width="90" header-align="center">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="70" header-align="center"></el-table-column>
<el-table-column label="创建时间" width="120" header-align="center">
<template #default="scope">{{ formatDate(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column label="就绪时间" width="120" header-align="center">
<template #default="scope">{{ formatDate(scope.row.ready_at) }}</template>
</el-table-column>
<el-table-column label="发布时间" width="120" header-align="center">
<template #default="scope">{{ formatDate(scope.row.published_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" header-align="center">
<template #default="scope">
<div class="flex gap-2 mb-1">
<el-button size="small" @click="openPreview(scope.row)">预览</el-button>
<el-button size="small" type="primary" :disabled="scope.row.status !== '待发布'" @click="openPublish(scope.row)">发布</el-button>
</div>
<div class="flex gap-2">
<el-table :data="filteredTopics" stripe v-loading="loadingTable" :row-key="topic => topic.id" @selection-change="selectedTopicIds = $event">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="id" label="ID" width="100"></el-table-column>
<el-table-column prop="title" label="标题" min-width="300"></el-table-column>
<el-table-column prop="field" label="领域" width="120"></el-table-column>
<el-table-column prop="priority_score" label="优先级" width="80"><template #default="scope"><el-tag :type="getPriorityType(scope.row.priority_score)" size="small">{{ scope.row.priority_score }}</el-tag></template></el-table-column>
<el-table-column prop="status" label="状态" width="100"><template #default="scope"><span class="status-badge"><span class="status-dot" :class="getStatusClass(scope.row.status)"></span>{{ scope.row.status }}</span></template></el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="100"><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="160"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column prop="generated_at" label="创作时间" width="160"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
<el-table-column prop="published_at" label="发布时间" width="160"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="160"><template #default="scope">{{ formatDate(scope.row.updated_at) }}</template></el-table-column>
<el-table-column label="操作" width="280" fixed="right"><template #default="scope">
<div class="flex gap-1 flex-wrap">
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
<el-button size="small" type="success" :disabled="scope.row.status !== '待处理'" @click="createTopic(scope.row)">创作</el-button>
<el-button size="small" type="warning" :disabled="scope.row.status !== '待审查'" @click="optimizeTopic(scope.row)">审查</el-button>
<el-button v-if="scope.row.status === '待发布'" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
</div>
</template>
</el-table-column>
</template></el-table-column>
</el-table>
</div>
</div>
<!-- 预览对话框 -->
<el-dialog v-model="previewVisible" :title="previewTopic.title" width="80%">
<div class="mb-4">
<el-radio-group v-model="previewPlatform" size="small">
<el-radio-button label="zhihu">知乎</el-radio-button>
<el-radio-button label="wechat">微信公众号</el-radio-button>
<el-radio-button label="xiaohongshu">小红书</el-radio-button>
</el-radio-group>
<div v-if="currentPage === 'logs'">
<h2 class="text-2xl font-bold mb-6 text-gray-800">📄 系统日志</h2>
<div class="card">
<div class="flex flex-wrap gap-4 mb-4">
<el-select v-model="logType" placeholder="日志类型" size="default"><el-option label="创作日志" value="creator"></el-option><el-option label="优化日志" value="optimizer"></el-option><el-option label="收集日志" value="collector"></el-option></el-select>
<el-date-picker v-model="logDate" type="date" placeholder="选择日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" size="default"></el-date-picker>
<el-button type="primary" @click="fetchLogs" :loading="loadingLogs">加载日志</el-button>
</div>
<el-card v-if="logContent" class="font-mono text-sm bg-gray-50" style="max-height: 600px; overflow-y: auto;"><pre>{{ logContent }}</pre></el-card>
<el-empty v-else description="请先选择类型和日期,然后点击加载"></el-empty>
</div>
</div>
<div v-if="currentPage === 'users' && isAdmin">
<h2 class="text-2xl font-bold mb-6 text-gray-800">👥 用户管理</h2>
<div class="card">
<div class="flex justify-between items-center mb-4"><h3 class="text-lg font-bold">用户列表</h3><el-button type="primary" @click="openCreateUserModal">+ 新建用户</el-button></div>
<el-table :data="users" stripe v-loading="loadingUsers">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="username" label="用户名"></el-table-column>
<el-table-column prop="role" label="角色" width="100"><template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role }}</el-tag></template></el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="150"><template #default="scope"><el-button size="small" type="danger" @click="deleteUser(scope.row.id)" :disabled="scope.row.role === 'admin'">删除</el-button></template></el-table-column>
</el-table>
</div>
</div>
</main>
</div>
<div v-if="loadingOverlay" class="loading-overlay"><el-spinner type="spinning" :size="50"></el-spinner><p class="ml-4 text-lg">{{ loadingText }}</p></div>
<el-dialog v-model="previewVisible" title="文章预览" width="80%" top="5vh">
<div class="preview-container">
<div class="preview-actions"><el-button type="primary" size="small" @click="copyPreviewHtml">📋 复制 HTML</el-button><el-button type="success" size="small" @click="expandPreview">⛶ 全屏</el-button></div>
<el-card v-if="previewHtml" class="mt-4" style="max-height: 60vh; overflow-y: auto;"><div v-html="previewHtml"></div></el-card>
<el-empty v-else description="暂无预览内容"></el-empty>
</div>
<div v-if="previewHtml" class="border p-4 bg-gray-50" v-html="previewHtml" style="max-height: 70vh; overflow: auto;"></div>
</el-dialog>
<!-- 发布管理对话框(增强版) -->
<el-dialog v-model="publishVisible" :title="`发布管理:${publishTopic.title}`" width="900px">
<el-tabs v-model="publishTab">
<el-tab-pane label="发布链接" name="links">
<el-form :model="publishForm" label-width="100px">
<el-form-item label="知乎">
<el-input v-model="publishForm.zhihu" placeholder="https://zhihu.com/..."></el-input>
</el-form-item>
<el-form-item label="微信公众号">
<el-input v-model="publishForm.wechat" placeholder="https://mp.weixin.qq.com/..."></el-input>
</el-form-item>
<el-form-item label="小红书">
<el-input v-model="publishForm.xiaohongshu" placeholder="https://xiaohongshu.com/note/..."></el-input>
</el-form-item>
<!-- 可扩展其他平台 -->
<el-dialog v-model="createTopicModalVisible" title="新建选题" width="500px">
<el-form :model="newTopicForm" label-width="100px">
<el-form-item label="标题"><el-input v-model="newTopicForm.title" placeholder="请输入标题"></el-input></el-form-item>
<el-form-item label="领域"><el-input v-model="newTopicForm.field" placeholder="请输入领域"></el-input></el-form-item>
<el-form-item label="优先级"><el-select v-model="newTopicForm.priority" placeholder="请选择"><el-option label="高" value="高"></el-option><el-option label="中" value="中"></el-option><el-option label="" value="低"></el-option></el-select></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="发布包" name="packages">
<div v-if="loadingPackages" class="text-center py-4">加载中...</div>
<div v-else-if="packages.length === 0" class="text-gray-500 py-4">暂未生成发布包,请点击"重新生成"</div>
<div v-else>
<div class="mb-4">
<el-button type="primary" size="small" @click="generateAllPackages" :loading="generatingPackages">🔄 重新生成所有平台发布包</el-button>
</div>
<el-table :data="packages" border>
<el-table-column prop="platform" label="平台" width="120"></el-table-column>
<el-table-column prop="path" label="文件路径" width="300"></el-table-column>
<el-table-column prop="size" label="大小" width="80">
<template #default="scope">{{ formatSize(scope.row.size) }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" @click="viewPackage(scope.row)">查看</el-button>
<el-button size="small" type="primary" @click="copyPackage(scope.row)">复制HTML</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="publishVisible = false">取消</el-button>
<el-button type="primary" :loading="publishing" @click="confirmPublish">确认发布并保存链接</el-button>
</template>
<template #footer><el-button @click="createTopicModalVisible = false">取消</el-button><el-button type="primary" @click="confirmCreateTopic">确定</el-button></template>
</el-dialog>
<!-- 预览单个发布包 -->
<el-dialog v-model="packagePreviewVisible" :title="`预览:${currentPackage?.platform}`" width="80%">
<div class="mb-4 flex gap-2">
<el-button size="small" @click="packagePreviewVisible = false">关闭</el-button>
<el-button size="small" type="primary" @click="copyCurrentHtml">复制HTML</el-button>
</div>
<div class="border p-4 bg-gray-50" v-html="currentHtml" style="max-height: 70vh; overflow: auto;"></div>
<el-dialog v-model="createUserModalVisible" title="新建用户" width="500px">
<el-form :model="newUserForm" label-width="100px">
<el-form-item label="用户名"><el-input v-model="newUserForm.username" placeholder="请输入用户名"></el-input></el-form-item>
<el-form-item label="密码"><el-input v-model="newUserForm.password" type="password" placeholder="请输入密码"></el-input></el-form-item>
<el-form-item label="角色"><el-select v-model="newUserForm.role" placeholder="请选择"><el-option label="管理员" value="admin"></el-option><el-option label="普通用户" value="user"></el-option></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="createUserModalVisible = false">取消</el-button><el-button type="primary" @click="confirmCreateUser">确定</el-button></template>
</el-dialog>
<!-- 日志对话框 -->
<el-dialog v-model="showLogs" title="系统日志" width="80%">
<div class="mb-4 flex gap-2">
<el-date-picker v-model="logDate" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"></el-date-picker>
<el-select v-model="logType" style="width: 120px">
<el-option label="creator" value="creator"></el-option>
<el-option label="publisher" value="publisher"></el-option>
<el-option label="collector" value="collector"></el-option>
</el-select>
<el-button @click="fetchLogs">加载</el-button>
</div>
<pre class="bg-gray-100 p-4 rounded overflow-auto" style="max-height: 60vh;">{{ logContent }}</pre>
<el-dialog v-model="publishModalVisible" title="发布选题" width="500px">
<el-form :model="publishForm" label-width="100px">
<el-form-item label="平台"><el-select v-model="publishForm.platform" placeholder="请选择平台"><el-option label="知乎" value="zhihu"></el-option><el-option label="微信公众号" value="wechat"></el-option><el-option label="小红书" value="xiaohongshu"></el-option></el-select></el-form-item>
<el-form-item label="URL"><el-input v-model="publishForm.url" placeholder="发布后的链接"></el-input></el-form-item>
<el-form-item label="说明"><el-input type="textarea" v-model="publishForm.description" placeholder="发布说明"></el-input></el-form-item>
</el-form>
<template #footer><el-button @click="publishModalVisible = false">取消</el-button><el-button type="primary" @click="confirmPublish">确定</el-button></template>
</el-dialog>
</div>
<nav class="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex justify-around py-2 z-50" v-if="isLoggedIn">
<button @click="currentPage = 'overview'" :class="['flex flex-col items-center px-4 py-1', currentPage === 'overview' ? 'text-blue-600' : 'text-gray-500']"><span class="text-xl">📊</span><span class="text-xs mt-1">概览</span></button>
<button @click="currentPage = 'topics'" :class="['flex flex-col items-center px-4 py-1', currentPage === 'topics' ? 'text-blue-600' : 'text-gray-500']"><span class="text-xl">📋</span><span class="text-xs mt-1">选题</span></button>
<button @click="currentPage = 'logs'" :class="['flex flex-col items-center px-4 py-1', currentPage === 'logs' ? 'text-blue-600' : 'text-gray-500']"><span class="text-xl">📄</span><span class="text-xs mt-1">日志</span></button>
<button v-if="isAdmin" @click="currentPage = 'users'" :class="['flex flex-col items-center px-4 py-1', currentPage === 'users' ? 'text-blue-600' : 'text-gray-500']"><span class="text-xl">👥</span><span class="text-xs mt-1">用户</span></button>
</nav>
</div>
<script>
const { createApp, ref, computed, onMounted, watch } = Vue;
createApp({
const { ref, reactive, computed, onMounted, watch } = Vue;
const { ElMessage, ElNotification } = ElementPlus;
const app = Vue.createApp({
name: 'YuZhiRanPlatform',
setup() {
const API_BASE = '';
const status = ref({});
const topics = ref([]);
const filterStatus = ref('');
const generating = ref(false);
const optimizing = ref(false);
const publishing = ref(false);
const pipeline = ref({});
const pipelineLoading = ref(false);
const pipelineModules = ref([]);
const previewVisible = ref(false);
const previewTopic = ref({});
const previewPlatform = ref('zhihu');
const previewHtml = ref('');
const publishVisible = ref(false);
const publishTopic = ref({});
const publishForm = ref({ zhihu: '', wechat: '', xiaohongshu: '' });
const publishTab = ref('links');
// 新增:发布包管理
const packages = ref([]);
const loadingPackages = ref(false);
const generatingPackages = ref(false);
const packagePreviewVisible = ref(false);
const currentPackage = ref(null);
const currentHtml = ref('');
const showLogs = ref(false);
const logType = ref('creator');
const logDate = ref(new Date().toISOString().split('T')[0]);
const logContent = ref('');
const filteredTopics = computed(() => {
const API_BASE = "";
const token = ref(localStorage.getItem('token') || '');
const isLoggedIn = ref(!!token.value);
const currentUser = ref(null);
const isAdmin = ref(false);
const loginForm = reactive({ username: '', password: '' });
const loginError = ref('');
const loadingLogin = ref(false);
const authFetch = (url, options = {}) => {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token.value) headers['Authorization'] = 'Bearer ' + token.value;
return fetch(url, { ...options, headers }).then(async res => {
if (res.status === 401) {
token.value = ''; localStorage.removeItem('token'); isLoggedIn.value = false;
ElMessage.error('登录已过期,请重新登录');
setTimeout(() => location.reload(), 1500);
return Promise.reject(new Error('未授权'));
}
return res;
});
};
const handleLogin = async () => {
loadingLogin.value = true;
try {
if (!filterStatus.value) return topics.value || [];
return (topics.value || []).filter(t => t && t.status === filterStatus.value);
} catch (e) {
console.error('filteredTopics error:', e);
return [];
const res = await authFetch(API_BASE + '/api/auth/login', { method: 'POST', body: JSON.stringify(loginForm) });
if (res.ok) {
const data = await res.json();
token.value = data.token; localStorage.setItem('token', data.token); isLoggedIn.value = true;
currentUser.value = data.user; isAdmin.value = data.role === 'admin';
loginForm.username = ''; loginForm.password = ''; loginError.value = '';
ElMessage.success('登录成功'); refresh(); refreshPipeline();
} else { const err = await res.json(); loginError.value = err.detail || '登录失败'; }
} catch (e) { loginError.value = '网络错误:' + e.message; }
finally { loadingLogin.value = false; }
};
const handleLogout = () => { localStorage.removeItem('token'); token.value = ''; isLoggedIn.value = false; currentUser.value = null; isAdmin.value = false; ElMessage.info('已退出登录'); };
const status = ref({}), topics = ref([]), filterStatus = ref(''), generating = ref(false), optimizing = ref(false);
const loadingAll = ref(false), loadingTable = ref(false), loadingLogs = ref(false), loadingUsers = ref(false);
const loadingOverlay = ref(false), loadingText = ref(''), selectedTopicIds = ref([]);
const pipeline = ref({ status_distribution: {} }), pipelineLoading = ref(false), pipelineModules = ref([]);
const previewVisible = ref(false), previewTopic = ref({ title: '' }), previewPlatform = ref('zhihu'), previewHtml = ref('');
const fullScreenPreview = ref(false), showLogs = ref(false), logType = ref('creator'), logDate = ref(new Date().toISOString().split('T')[0]), logContent = ref('');
const currentPage = ref('overview'), users = ref([]), createTopicModalVisible = ref(false);
const newUserForm = reactive({ username: '', password: '', role: 'user' }), createUserModalVisible = ref(false);
const publishModalVisible = ref(false), publishForm = reactive({ platform: 'zhihu', url: '', description: '' });
const newTopicForm = reactive({ title: '', field: '', priority: '中' });
const filteredTopics = computed(() => filterStatus.value ? topics.value.filter(t => t.status === filterStatus.value) : topics.value);
const countByStatus = (s) => s === 'total' ? status.value.total_topics || 0 : s === 'today' ? status.value.today_articles || 0 : status.value.topics_by_status?.[s] || 0;
const getPriorityType = (s) => s >= 20 ? 'success' : s >= 15 ? 'warning' : 'info';
const getStatusClass = (s) => ({ '待处理': 'pending', '待审查': 'review', '待发布': 'ready', '已发布': 'published' })[s] || 'pending';
const formatDate = (v) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-';
const refresh = async () => { try { const [s, t] = await Promise.all([authFetch(API_BASE + '/api/system/status').then(r => r.json()), authFetch(API_BASE + '/api/topics').then(r => r.json())]); status.value = s; topics.value = t; } catch (e) { ElMessage.error('刷新失败:' + e.message); } };
const refreshPipeline = async () => { pipelineLoading.value = true; try { const res = await authFetch(API_BASE + '/api/system/pipeline/status'); if (res.ok) { const data = await res.json(); pipeline.value = data; pipelineModules.value = Object.entries(data.pipeline_modules || {}).map(([n, i]) => ({ module: n, last_run: i.last_run || '未运行', status_ok: !i.has_error && i.exists, status_text: i.exists && !i.has_error ? '正常' : i.exists ? '有错误' : '缺失', error: i.has_error ? '检测到错误' : '' })); } } catch (e) { ElMessage.error('获取流水线状态失败'); } finally { pipelineLoading.value = false; } };
const refreshAll = async () => { loadingAll.value = true; try { await Promise.all([refresh(), refreshPipeline()]); ElMessage.success('刷新成功'); } catch (e) { ElMessage.error('刷新失败'); } finally { loadingAll.value = false; } };
const triggerGenerate = async (id) => { generating.value = true; try { const res = await authFetch(API_BASE + '/api/system/generate/run?topic_id=' + id, { method: 'POST' }); const data = await res.json(); if (data.result && data.result.ok) { ElMessage.success('创作任务已启动'); setTimeout(refresh, 3000); } else ElMessage.error('启动失败:' + (data.error || '未知错误')); } catch (e) { ElMessage.error('请求失败:' + e.message); } finally { generating.value = false; } };
const triggerGenerateSelected = async () => { if (!selectedTopicIds.value.length) return; loadingOverlay.value = true; loadingText.value = '正在批量创作...'; try { for (const t of selectedTopicIds.value) await triggerGenerate(t.id); ElMessage.success('批量创作完成'); selectedTopicIds.value = []; refresh(); } catch (e) { ElMessage.error('批量创作失败:' + e.message); } finally { loadingOverlay.value = false; } };
const triggerOptimize = async (ids) => { optimizing.value = true; try { const res = await authFetch(API_BASE + '/api/system/optimize/run', { method: 'POST', body: JSON.stringify({ topic_ids: ids || [] }) }); const data = await res.json(); if (data.summary) { ElNotification({ title: '优化完成', message: '自动通过 ' + (data.summary.passed_auto || 0) + ' 篇,需人工 ' + (data.summary.need_manual || 0) + ' 篇', type: 'success' }); await refresh(); } else ElMessage.success('优化完成'); } catch (e) { ElMessage.error('优化失败:' + e.message); } finally { optimizing.value = false; } };
const triggerOptimizeSelected = async () => { if (!selectedTopicIds.value.length) return; loadingOverlay.value = true; loadingText.value = '正在批量优化...'; try { const ids = selectedTopicIds.value.map(t => t.id); await triggerOptimize(ids); ElMessage.success('批量优化完成'); selectedTopicIds.value = []; } catch (e) { ElMessage.error('批量优化失败:' + e.message); } finally { loadingOverlay.value = false; } };
const openPreview = async (topic) => { previewTopic.value = topic; previewPlatform.value = 'zhihu'; previewVisible.value = true; loadingText.value = '加载中...'; loadingOverlay.value = true; try { const res = await authFetch(API_BASE + '/api/topics/' + topic.id + '/preview?platform=zhihu'); const data = await res.json(); previewHtml.value = data.html; } catch (e) { ElMessage.error('预览加载失败:' + e.message); previewHtml.value = ''; } finally { loadingOverlay.value = false; loadingText.value = ''; } };
const loadPreview = async () => { try { const res = await authFetch(API_BASE + '/api/topics/' + previewTopic.value.id + '/preview?platform=' + previewPlatform.value); const data = await res.json(); previewHtml.value = data.html; } catch (e) { ElMessage.error('预览加载失败'); previewHtml.value = ''; } };
const copyPreviewHtml = () => { navigator.clipboard.writeText(previewHtml.value).then(() => ElMessage.success('HTML 已复制到剪贴板')).catch(() => ElMessage.error('复制失败')); };
const expandPreview = () => { fullScreenPreview.value = !fullScreenPreview.value; };
const fetchLogs = async () => { loadingLogs.value = true; logContent.value = ''; try { const res = await authFetch(API_BASE + '/api/system/logs/' + logDate.value + '?log_type=' + logType.value); const data = await res.json(); logContent.value = data.content.join('\n'); } catch (e) { ElMessage.error('加载日志失败:' + e.message); } finally { loadingLogs.value = false; } };
const createTopic = (topic) => { if (topic && topic.status === '待处理') triggerGenerate(topic.id); else ElMessage.info('仅待处理选题可创作'); };
const optimizeTopic = (topic) => { if (topic && topic.status === '待审查') triggerOptimize([topic.id]); else ElMessage.info('仅待审查选题可优化'); };
const handlePublish = (topic) => { publishModalVisible.value = true; publishForm.platform = 'zhihu'; publishForm.url = ''; publishForm.description = ''; publishTopicId = topic.id; };
const confirmPublish = async () => { try { await authFetch(API_BASE + '/api/topics/' + publishTopicId + '/publish', { method: 'POST', body: JSON.stringify({ platform: publishForm.platform, platform_urls: { [publishForm.platform]: publishForm.url }, description: publishForm.description }) }); ElMessage.success('发布成功'); publishModalVisible.value = false; refresh(); } catch (e) { ElMessage.error('发布失败:' + e.message); } };
const openCreateTopic = () => { createTopicModalVisible.value = true; newTopicForm.title = ''; newTopicForm.field = ''; newTopicForm.priority = '中'; };
const confirmCreateTopic = () => { ElMessage.info('新建选题功能待实现'); createTopicModalVisible.value = false; };
const openCreateUserModal = () => { createUserModalVisible.value = true; newUserForm.username = ''; newUserForm.password = ''; newUserForm.role = 'user'; };
const confirmCreateUser = async () => { try { await authFetch(API_BASE + '/api/admin/users', { method: 'POST', body: JSON.stringify(newUserForm) }); ElMessage.success('用户创建成功'); createUserModalVisible.value = false; loadUsers(); } catch (e) { ElMessage.error('创建失败:' + e.message); } };
const deleteUser = async (id) => { if (!confirm('确定删除?')) return; try { await authFetch(API_BASE + '/api/admin/users/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); loadUsers(); } catch (e) { ElMessage.error('删除失败:' + e.message); } };
const loadUsers = async () => { loadingUsers.value = true; try { const res = await authFetch(API_BASE + '/api/admin/users'); users.value = await res.json(); } catch (e) { ElMessage.error('加载用户失败'); } finally { loadingUsers.value = false; } };
const goToTopicsWithFilter = (s) => { if (s === 'total') filterStatus.value = ''; else if (s === 'today') { ElMessage.info('今日新选题筛选待实现'); } else filterStatus.value = s; currentPage.value = 'topics'; };
let publishTopicId = null;
onMounted(() => { if (isLoggedIn.value) { refresh(); refreshPipeline(); loadUsers(); } });
return { token, isLoggedIn, currentUser, isAdmin, loginForm, loginError, loadingLogin, handleLogin, handleLogout, status, topics, filterStatus, generating, optimizing, loadingAll, loadingTable, loadingLogs, loadingUsers, loadingOverlay, loadingText, selectedTopicIds, pipeline, pipelineLoading, pipelineModules, previewVisible, previewTopic, previewPlatform, previewHtml, fullScreenPreview, showLogs, logType, logDate, logContent, currentPage, users, createTopicModalVisible, newUserForm, createUserModalVisible, publishModalVisible, publishForm, newTopicForm, filteredTopics, countByStatus, getPriorityType, getStatusClass, formatDate, refresh, refreshPipeline, refreshAll, triggerGenerate, triggerGenerateSelected, triggerOptimize, triggerOptimizeSelected, openPreview, loadPreview, copyPreviewHtml, expandPreview, fetchLogs, createTopic, optimizeTopic, handlePublish, confirmPublish, openCreateTopic, confirmCreateTopic, openCreateUserModal, confirmCreateUser, deleteUser, loadUsers, goToTopicsWithFilter };
}
});
const refresh = async () => {
try {
const [s, t] = await Promise.all([
fetch(API_BASE + '/api/system/status').then(r => r.json()),
fetch(API_BASE + '/api/topics').then(r => r.json())
]);
status.value = s;
topics.value = t;
console.log('刷新成功', s, t);
} catch (e) {
console.error('刷新失败:', e);
// 设置默认数据用于测试
status.value = { total_topics: 20, topics_by_status: { '待发布': 4, '待处理': 16 } };
// topics.value = []; // 保持空数组
}
};
const refreshPipeline = async () => {
pipelineLoading.value = true;
try {
const res = await fetch(API_BASE + '/api/system/pipeline/status');
if (res.ok) {
const data = await res.json();
pipeline.value = data;
// 构建模块状态表格数据
pipelineModules.value = Object.entries(data.pipeline_modules || {}).map(([name, info]) => ({
module: name,
last_run: info.last_run || '未运行',
status_ok: !info.has_error && info.exists,
status_text: info.exists && !info.has_error ? '正常' : info.exists ? '有错误' : '缺失',
error: info.has_error ? '检测到错误' : ''
}));
} else {
pipelineModules.value = [];
}
} catch (e) {
console.error('流水线状态获取失败:', e);
pipelineModules.value = [];
} finally {
pipelineLoading.value = false;
}
};
const refreshAll = async () => {
await Promise.all([refresh(), refreshPipeline()]);
};
const triggerGenerate = async () => {
generating.value = true;
try {
const res = await fetch(API_BASE + '/api/system/generate/run', { method: 'POST' });
const data = await res.json();
if (data.result && data.result.ok) {
ElementPlus.ElMessage.success('创作任务已启动');
setTimeout(refresh, 5000);
} else {
ElementPlus.ElMessage.error('启动失败: ' + (data.error || '未知错误'));
}
} finally {
generating.value = false;
}
};
const triggerOptimize = async () => {
optimizing.value = true;
try {
const res = await fetch(API_BASE + '/api/system/optimize/run', { method: 'POST' });
const data = await res.json();
if (data.summary) {
ElementPlus.ElNotification({
title: '优化完成',
message: `自动通过 ${data.summary.passed_auto} 篇,需人工 ${data.summary.need_manual}`,
type: data.summary.need_manual === 0 ? 'success' : 'warning'
});
await refresh();
} else {
ElementPlus.ElMessage.success('优化完成');
}
} catch (e) {
ElementPlus.ElMessage.error('请求失败: ' + e);
} finally {
optimizing.value = false;
}
};
const openPreview = async (topic) => {
previewTopic.value = topic;
previewVisible.value = true;
previewPlatform.value = 'zhihu';
await loadPreview();
};
const loadPreview = async () => {
const tid = previewTopic.value.id;
const platform = previewPlatform.value;
try {
const res = await fetch(API_BASE + `/api/articles/${tid}/preview?platform=${platform}`);
const data = await res.json();
previewHtml.value = data.html;
} catch (e) {
ElementPlus.ElMessage.error('无法加载预览');
}
};
watch(previewPlatform, loadPreview);
const openPublish = async (topic) => {
publishTopic.value = topic;
publishForm.value = { zhihu: '', wechat: '', xiaohongshu: '' };
publishVisible.value = true;
publishTab.value = 'links';
await loadPackages();
};
// 加载发布包列表
const loadPackages = async () => {
const tid = publishTopic.value.id;
loadingPackages.value = true;
try {
const res = await fetch(API_BASE + `/api/publisher/packages/${tid}`);
if (res.ok) {
const data = await res.json();
packages.value = data.packages || [];
} else {
packages.value = [];
}
} catch (e) {
console.error('加载发布包失败:', e);
packages.value = [];
} finally {
loadingPackages.value = false;
}
};
// 生成所有平台发布包
const generateAllPackages = async () => {
const tid = publishTopic.value.id;
generatingPackages.value = true;
try {
const res = await fetch(API_BASE + `/api/publisher/generate/${tid}`, { method: 'POST' });
if (res.ok) {
ElementPlus.ElMessage.success('发布包生成任务已启动');
setTimeout(loadPackages, 3000);
} else {
throw new Error('生成失败');
}
} catch (e) {
ElementPlus.ElMessage.error('生成发布包失败: ' + e);
} finally {
generatingPackages.value = false;
}
};
// 查看发布包内容
const viewPackage = async (pkg) => {
currentPackage.value = pkg;
try {
const res = await fetch(API_BASE + `/api/publisher/package/${publishTopic.value.id}/${pkg.platform}`);
if (res.ok) {
const data = await res.json();
currentHtml.value = data.html;
packagePreviewVisible.value = true;
} else {
ElementPlus.ElMessage.error('无法加载发布包');
}
} catch (e) {
ElementPlus.ElMessage.error('请求失败');
}
};
// 复制发布包HTML
const copyPackage = async (pkg) => {
try {
const res = await fetch(API_BASE + `/api/publisher/package/${publishTopic.value.id}/${pkg.platform}`);
if (res.ok) {
const data = await res.json();
await navigator.clipboard.writeText(data.html);
ElementPlus.ElMessage.success('HTML已复制到剪贴板');
} else {
throw new Error('加载失败');
}
} catch (e) {
ElementPlus.ElMessage.error('复制失败: ' + e);
}
};
const copyCurrentHtml = async () => {
try {
await navigator.clipboard.writeText(currentHtml.value);
ElementPlus.ElMessage.success('已复制');
} catch (e) {
ElementPlus.ElMessage.error('复制失败');
}
};
const confirmPublish = async () => {
publishing.value = true;
try {
const res = await fetch(API_BASE + `/api/topics/${publishTopic.value.id}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform_urls: publishForm.value })
});
if (res.ok) {
ElementPlus.ElMessage.success('已标记为已发布');
publishVisible.value = false;
await refresh();
} else {
throw new Error('发布失败');
}
} finally {
publishing.value = false;
}
};
const fetchLogs = async () => {
try {
const dateStr = logDate.value;
const res = await fetch(API_BASE + `/api/system/logs/${dateStr}?log_type=${logType.value}`);
if (!res.ok) throw new Error('日志文件不存在');
const data = await res.json();
logContent.value = data.content ? data.content.join('\n') : '无内容';
} catch (e) {
ElementPlus.ElMessage.error('加载日志失败');
logContent.value = '';
}
};
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const statusTagType = (status) => {
const map = { '待处理': 'info', '待审查': 'warning', '待发布': 'success', '已发布': 'primary' };
return map[status] || '';
};
const formatDate = (val) => {
if (!val) return '';
const d = new Date(val);
if (isNaN(d.getTime())) return val;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
};
onMounted(() => {
refresh();
refreshPipeline();
});
return {
status, topics, filteredTopics, filterStatus,
generating, optimizing,
previewVisible, previewTopic, previewPlatform, previewHtml,
publishVisible, publishTopic, publishForm, publishing,
publishTab,
packages, loadingPackages, generatingPackages,
packagePreviewVisible, currentPackage, currentHtml,
showLogs, logType, logDate, logContent, fetchLogs,
refresh, refreshPipeline, refreshAll, triggerGenerate, triggerOptimize, openPreview, loadPreview,
openPublish, loadPackages, generateAllPackages, viewPackage, copyPackage, copyCurrentHtml,
confirmPublish, statusTagType, formatDate, formatSize,
pipeline, pipelineLoading, pipelineModules
};
}
}).use(ElementPlus).mount('#app').catch(err => {
console.error('Vue mount error:', err);
document.getElementById('app').innerHTML = '<div class="card"><h2>应用启动失败</h2><pre>' + err + '</pre></div>';
});
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
+120 -9
View File
@@ -1,17 +1,128 @@
server {
listen 80;
server_name localhost;
# 宇之然内容创作平台 - Nginx配置
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
# 基本设置
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_comp_level 6;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: blob: 'unsafe-inline'" always;
# 代理缓存
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off;
# 上游服务器
upstream backend {
server app:8001;
keepalive 32;
}
location /api/ {
proxy_pass http://172.17.0.1:8000; # 宿主机在 Docker 网桥的 IP
server {
listen 80;
server_name _;
client_max_body_size 100M;
# SSL配置(生产环境)
# listen 443 ssl http2;
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
location / {
# 前端静态资源缓存
proxy_cache STATIC;
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# 反向代理到后端API
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
# WebSocket支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时设置
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# API文档(可选)
location /docs {
proxy_pass http://backend/docs;
proxy_set_header Host $host;
}
location /redoc {
proxy_pass http://backend/redoc;
proxy_set_header Host $host;
}
}
# 静态文件服务(如果需要)
server {
listen 8000;
server_name localhost;
root /usr/share/nginx/html;
index index.html login.html;
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}