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:
@@ -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": "删除成功"}
|
||||||
@@ -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]]
|
||||||
@@ -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
|
||||||
@@ -4,6 +4,7 @@ from sqlalchemy import func
|
|||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from .auth import get_current_user
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -13,7 +14,7 @@ from ..core.generator import run_creator
|
|||||||
from ..core.optimizer import run_optimizer
|
from ..core.optimizer import run_optimizer
|
||||||
from ..core.sync import sync_topic_to_db, sync_all_topics
|
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'):
|
if os.getenv('PROJECT_ROOT'):
|
||||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
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
|
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)):
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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)):
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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"):
|
def get_logs(log_date: str, log_type: str = "creator"):
|
||||||
"""读取日志文件内容,log_type: creator, optimizer, collector"""
|
"""读取日志文件内容,log_type: creator, optimizer, collector"""
|
||||||
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
|
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:]
|
lines = content.splitlines()[-100:] if log_type != "collector" else content.splitlines()[-200:]
|
||||||
return {"log_date": log_date, "log_type": log_type, "content": lines}
|
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():
|
def get_pipeline_status():
|
||||||
"""获取流水线各模块状态(最后运行时间和结果)"""
|
"""获取流水线各模块状态(最后运行时间和结果)"""
|
||||||
try:
|
try:
|
||||||
@@ -132,7 +133,6 @@ def get_pipeline_status():
|
|||||||
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
|
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
|
||||||
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
|
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
|
||||||
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
|
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
|
||||||
"publisher": LOGS_DIR / f"publisher_{date.today()}.log"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pipeline_status = {}
|
pipeline_status = {}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Topic
|
from ..models import Topic, PublishRecord
|
||||||
from ..schemas import TopicResponse, PublishRequest
|
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])
|
@router.get("", response_model=List[TopicResponse])
|
||||||
def list_topics(
|
def list_topics(
|
||||||
status: str = None,
|
status: str = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
# 确保读取最新数据,清除会话缓存
|
||||||
|
db.expire_all()
|
||||||
query = db.query(Topic)
|
query = db.query(Topic)
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Topic.status == 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":
|
if topic.status != "ready":
|
||||||
raise HTTPException(status_code=400, detail="Topic not in ready status")
|
raise HTTPException(status_code=400, detail="Topic not in ready status")
|
||||||
|
|
||||||
|
# 更新选题状态
|
||||||
topic.status = "published"
|
topic.status = "published"
|
||||||
topic.published_at = datetime.now().date()
|
topic.published_at = datetime.now().date()
|
||||||
|
topic.updated_at = datetime.now()
|
||||||
topic.platform_urls = req.platform_urls
|
topic.platform_urls = req.platform_urls
|
||||||
db.commit()
|
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"}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from ..models import Topic
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
# 计算项目根目录(从本文件位置上升4层)
|
# 计算项目根目录(从本文件位置上升4层)
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
if os.getenv('PROJECT_ROOT'):
|
if os.getenv('PROJECT_ROOT'):
|
||||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||||
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
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.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.compliance_score = topic_data.get('compliance_score', db_topic.compliance_score)
|
||||||
db_topic.platform_urls = topic_data.get('platform_urls', {})
|
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_topic.updated_at = datetime.now()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_topic)
|
db.refresh(db_topic)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran)
|
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran)
|
||||||
# __file__: platform/backend/app/database.py
|
# __file__: platform/backend/app/database.py
|
||||||
# parents[0]=app, [1]=backend, [2]=platform, [3]=yu-zhi-ran
|
# 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'))
|
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
|
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import FastAPI, Depends, HTTPException
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
|
||||||
from .database import engine, get_db, init_db
|
from .database import engine, get_db, init_db
|
||||||
from .models import Base
|
from .models import Base
|
||||||
from .api import topics, system, articles, publisher
|
from .api import topics, system, articles, publishing, auth, admin, audit
|
||||||
from .initial_data import import_topics_from_json
|
from .initial_data import import_initial_data
|
||||||
|
|
||||||
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
|
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# CORS - 生产环境应限制 origins
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # TODO: 生产环境改为具体域名
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -26,40 +27,45 @@ app.add_middleware(
|
|||||||
# 初始化数据库
|
# 初始化数据库
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
init_db()
|
init_db()
|
||||||
import_topics_from_json() # 首次自动导入
|
import_initial_data()
|
||||||
|
|
||||||
# 注册路由
|
# 注册 API 路由
|
||||||
app.include_router(topics.router)
|
app.include_router(topics.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(articles.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"
|
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():
|
if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists():
|
||||||
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|
||||||
if STATIC_DIR.exists():
|
logger.info(f"Frontend mounted at / from {FRONTEND_DIR}")
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
||||||
logging.getLogger(__name__).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:
|
else:
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {
|
return {"service": "API only", "docs": "/docs"}
|
||||||
"service": "宇之然内容创作平台 API",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"docs": "/docs",
|
|
||||||
"frontend_missing": str(FRONTEND_DIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -3,6 +3,55 @@ from sqlalchemy.sql import func
|
|||||||
from .database import Base
|
from .database import Base
|
||||||
from datetime import datetime
|
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):
|
class Topic(Base):
|
||||||
__tablename__ = "topics"
|
__tablename__ = "topics"
|
||||||
|
|
||||||
@@ -21,11 +70,13 @@ class Topic(Base):
|
|||||||
source_file = Column(String)
|
source_file = Column(String)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
generated_at = Column(DateTime(timezone=True), nullable=True) # 选题创作完成时间
|
||||||
ready_at = Column(Date)
|
ready_at = Column(Date)
|
||||||
published_at = Column(Date)
|
published_at = Column(Date)
|
||||||
compliance_score = Column(Integer)
|
compliance_score = Column(Integer)
|
||||||
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
|
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
|
||||||
|
|
||||||
|
|
||||||
class Article(Base):
|
class Article(Base):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
@@ -37,3 +88,23 @@ class Article(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
compliance_score = Column(Integer)
|
compliance_score = Column(Integer)
|
||||||
html_content = Column(Text) # 可缓存HTML内容以便预览
|
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, 但这里保持简单
|
||||||
|
|||||||
@@ -1,7 +1,51 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Optional, List, Dict, Any
|
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):
|
class TopicBase(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
@@ -11,14 +55,14 @@ class TopicBase(BaseModel):
|
|||||||
compliance_score: Optional[int] = None
|
compliance_score: Optional[int] = None
|
||||||
ready_at: Optional[date] = None
|
ready_at: Optional[date] = None
|
||||||
published_at: Optional[date] = None
|
published_at: Optional[date] = None
|
||||||
|
generated_at: Optional[datetime] = None
|
||||||
platform_urls: Optional[Dict[str, str]] = None
|
platform_urls: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
class TopicResponse(TopicBase):
|
class TopicResponse(TopicBase):
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class ArticleBase(BaseModel):
|
class ArticleBase(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -30,8 +74,7 @@ class ArticleBase(BaseModel):
|
|||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
class ArticleResponse(ArticleBase):
|
class ArticleResponse(ArticleBase):
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class SystemStatus(BaseModel):
|
class SystemStatus(BaseModel):
|
||||||
total_topics: int
|
total_topics: int
|
||||||
@@ -40,14 +83,55 @@ class SystemStatus(BaseModel):
|
|||||||
today_articles: int
|
today_articles: int
|
||||||
compliance_rate: float
|
compliance_rate: float
|
||||||
last_optimization: Optional[datetime] = None
|
last_optimization: Optional[datetime] = None
|
||||||
execution_time: Optional[float] = None # 任务执行耗时(秒)
|
execution_time: Optional[float] = None
|
||||||
|
|
||||||
class OptimizationRequest(BaseModel):
|
class OptimizationRequest(BaseModel):
|
||||||
topic_ids: Optional[List[str]] = None # None表示全部
|
topic_ids: Optional[List[str]] = None
|
||||||
|
|
||||||
class PublishRequest(BaseModel):
|
class PublishRequest(BaseModel):
|
||||||
topic_id: str
|
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):
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
fastapi==0.115.0
|
fastapi==0.104.1
|
||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.24.0
|
||||||
pydantic==2.9.2
|
sqlalchemy==2.0.23
|
||||||
sqlalchemy==2.0.36
|
psycopg2-binary==2.9.9
|
||||||
python-multipart==0.0.9
|
pydantic==2.5.0
|
||||||
jinja2==3.1.5
|
python-dotenv==1.0.0
|
||||||
aiofiles==24.1.0
|
python-jose[cryptography]==3.3.0
|
||||||
python-dateutil==2.9.0.post0
|
passlib[bcrypt]==1.7.4
|
||||||
pytz==2024.2
|
asyncpg==0.29.0
|
||||||
PyYAML>=6.0
|
alembic==1.12.1
|
||||||
feedparser>=6.0
|
gunicorn==21.2.0
|
||||||
requests>=2.32.0
|
prometheus-client==0.19.0
|
||||||
@@ -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
|
||||||
+278
-566
@@ -1,573 +1,285 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>宇之然内容创作平台</title>
|
<title>宇之然内容创作平台</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="/static/vue.global.prod.js"></script>
|
<script src="/static/vue.global.prod.js?v=20260427"></script>
|
||||||
<link rel="stylesheet" href="/static/element-plus.css" />
|
<link rel="stylesheet" href="/static/element-plus.css?v=20260427" />
|
||||||
<script src="/static/element-plus.full.js"></script>
|
<script src="/static/element-plus.full.js?v=20260427"></script>
|
||||||
<style>
|
<style>
|
||||||
.page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
.page { padding: 24px; max-width: 1400px; 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); }
|
.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; }
|
||||||
.stat-card { text-align: center; }
|
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
|
||||||
.stat-value { font-size: 2rem; font-weight: bold; color: #409EFF; }
|
.stat-card { text-align: center; padding: 20px; cursor: pointer; transition: transform 0.2s; }
|
||||||
</style>
|
.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<nav class="bg-blue-600 text-white p-4 mb-6">
|
<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="container mx-auto flex justify-between items-center">
|
<div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
|
||||||
<h1 class="text-2xl font-bold">宇之然内容创作平台</h1>
|
<h2 class="text-2xl font-bold text-center mb-6 text-blue-600">宇之然内容创作平台</h2>
|
||||||
<div class="flex gap-2">
|
<div v-if="loginError" class="mb-4 p-3 bg-red-50 text-red-600 rounded text-sm">{{ loginError }}</div>
|
||||||
<el-button type="primary" @click="refresh">刷新</el-button>
|
<el-form @submit.prevent="handleLogin">
|
||||||
</div>
|
<el-form-item label="用户名"><el-input v-model="loginForm.username" placeholder="请输入用户名" prefix-icon="User"></el-input></el-form-item>
|
||||||
</div>
|
<el-form-item label="密码"><el-input v-model="loginForm.password" type="password" placeholder="请输入密码" prefix-icon="Lock" @keyup.enter="handleLogin"></el-input></el-form-item>
|
||||||
</nav>
|
<el-button type="primary" class="w-full" @click="handleLogin" :loading="loadingLogin">登 录</el-button>
|
||||||
|
</el-form>
|
||||||
<div class="page">
|
</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-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>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
<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-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>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- 日志对话框 -->
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else>
|
||||||
<script>
|
<nav class="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg">
|
||||||
const { createApp, ref, computed, onMounted, watch } = Vue;
|
<div class="container mx-auto px-6 py-4 flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold nav-title">宇之然内容创作平台</h1>
|
||||||
createApp({
|
<div class="flex items-center gap-3">
|
||||||
setup() {
|
<span class="text-sm">{{ currentUser?.username || '管理员' }}</span>
|
||||||
const API_BASE = '';
|
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||||
|
</div>
|
||||||
const status = ref({});
|
</div>
|
||||||
const topics = ref([]);
|
</nav>
|
||||||
const filterStatus = ref('');
|
<div class="page flex gap-6">
|
||||||
const generating = ref(false);
|
<aside class="w-48 flex-shrink-0 hidden md:block">
|
||||||
const optimizing = ref(false);
|
<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>
|
||||||
const publishing = ref(false);
|
<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>
|
||||||
const pipeline = ref({});
|
<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>
|
||||||
const pipelineLoading = ref(false);
|
<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>
|
||||||
const pipelineModules = ref([]);
|
</aside>
|
||||||
|
<main class="flex-1">
|
||||||
const previewVisible = ref(false);
|
<div v-if="currentPage === 'overview'">
|
||||||
const previewTopic = ref({});
|
<h2 class="text-2xl font-bold mb-6 text-gray-800">📊 系统概览</h2>
|
||||||
const previewPlatform = ref('zhihu');
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||||
const previewHtml = ref('');
|
<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>
|
||||||
const publishVisible = ref(false);
|
<div class="stat-card card" @click="goToTopicsWithFilter('待审查')"><div class="stat-value">{{ countByStatus('待审查') }}</div><div class="stat-label">待审查</div></div>
|
||||||
const publishTopic = ref({});
|
<div class="stat-card card" @click="goToTopicsWithFilter('待发布')"><div class="stat-value">{{ countByStatus('待发布') }}</div><div class="stat-label">待发布</div></div>
|
||||||
const publishForm = ref({ zhihu: '', wechat: '', xiaohongshu: '' });
|
<div class="stat-card card" @click="goToTopicsWithFilter('已发布')"><div class="stat-value">{{ countByStatus('已发布') }}</div><div class="stat-label">已发布</div></div>
|
||||||
const publishTab = ref('links');
|
<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>
|
||||||
const packages = ref([]);
|
<div v-if="pipelineLoading" class="text-center py-4">加载中...</div>
|
||||||
const loadingPackages = ref(false);
|
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
const generatingPackages = ref(false);
|
<div v-for="mod in pipelineModules" :key="mod.module" class="p-4 bg-gray-50 rounded-lg">
|
||||||
const packagePreviewVisible = ref(false);
|
<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>
|
||||||
const currentPackage = ref(null);
|
<div class="text-sm text-gray-500">最后运行:{{ mod.last_run }}</div>
|
||||||
const currentHtml = ref('');
|
<div v-if="mod.error" class="text-red-500 text-sm mt-1">{{ mod.error }}</div>
|
||||||
|
</div>
|
||||||
const showLogs = ref(false);
|
</div>
|
||||||
const logType = ref('creator');
|
</div>
|
||||||
const logDate = ref(new Date().toISOString().split('T')[0]);
|
</div>
|
||||||
const logContent = ref('');
|
<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>
|
||||||
const filteredTopics = computed(() => {
|
<div class="card mb-6"><div class="flex flex-wrap gap-2 items-center">
|
||||||
try {
|
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
|
||||||
if (!filterStatus.value) return topics.value || [];
|
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
|
||||||
return (topics.value || []).filter(t => t && t.status === filterStatus.value);
|
<el-button type="warning" size="small" @click="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
|
||||||
} catch (e) {
|
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0">已选 {{ selectedTopicIds.length }} 项</span>
|
||||||
console.error('filteredTopics error:', e);
|
</div></div>
|
||||||
return [];
|
<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>
|
||||||
const refresh = async () => {
|
<el-tag size="large" :type="filterStatus === '待发布' ? 'primary' : ''" closable @close="filterStatus = '待发布'">待发布</el-tag>
|
||||||
try {
|
<el-tag size="large" :type="filterStatus === '已发布' ? 'primary' : ''" closable @close="filterStatus = '已发布'">已发布</el-tag>
|
||||||
const [s, t] = await Promise.all([
|
</div>
|
||||||
fetch(API_BASE + '/api/system/status').then(r => r.json()),
|
<div class="card">
|
||||||
fetch(API_BASE + '/api/topics').then(r => r.json())
|
<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>
|
||||||
status.value = s;
|
<el-table-column prop="id" label="ID" width="100"></el-table-column>
|
||||||
topics.value = t;
|
<el-table-column prop="title" label="标题" min-width="300"></el-table-column>
|
||||||
console.log('刷新成功', s, t);
|
<el-table-column prop="field" label="领域" width="120"></el-table-column>
|
||||||
} catch (e) {
|
<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>
|
||||||
console.error('刷新失败:', e);
|
<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>
|
||||||
status.value = { total_topics: 20, topics_by_status: { '待发布': 4, '待处理': 16 } };
|
<el-table-column prop="created_at" label="创建时间" width="160"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||||
// topics.value = []; // 保持空数组
|
<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">
|
||||||
const refreshPipeline = async () => {
|
<div class="flex gap-1 flex-wrap">
|
||||||
pipelineLoading.value = true;
|
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
|
||||||
try {
|
<el-button size="small" type="success" :disabled="scope.row.status !== '待处理'" @click="createTopic(scope.row)">创作</el-button>
|
||||||
const res = await fetch(API_BASE + '/api/system/pipeline/status');
|
<el-button size="small" type="warning" :disabled="scope.row.status !== '待审查'" @click="optimizeTopic(scope.row)">审查</el-button>
|
||||||
if (res.ok) {
|
<el-button v-if="scope.row.status === '待发布'" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
|
||||||
const data = await res.json();
|
</div>
|
||||||
pipeline.value = data;
|
</template></el-table-column>
|
||||||
// 构建模块状态表格数据
|
</el-table>
|
||||||
pipelineModules.value = Object.entries(data.pipeline_modules || {}).map(([name, info]) => ({
|
</div>
|
||||||
module: name,
|
</div>
|
||||||
last_run: info.last_run || '未运行',
|
<div v-if="currentPage === 'logs'">
|
||||||
status_ok: !info.has_error && info.exists,
|
<h2 class="text-2xl font-bold mb-6 text-gray-800">📄 系统日志</h2>
|
||||||
status_text: info.exists && !info.has_error ? '正常' : info.exists ? '有错误' : '缺失',
|
<div class="card">
|
||||||
error: info.has_error ? '检测到错误' : ''
|
<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>
|
||||||
} else {
|
<el-date-picker v-model="logDate" type="date" placeholder="选择日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" size="default"></el-date-picker>
|
||||||
pipelineModules.value = [];
|
<el-button type="primary" @click="fetchLogs" :loading="loadingLogs">加载日志</el-button>
|
||||||
}
|
</div>
|
||||||
} catch (e) {
|
<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>
|
||||||
console.error('流水线状态获取失败:', e);
|
<el-empty v-else description="请先选择类型和日期,然后点击加载"></el-empty>
|
||||||
pipelineModules.value = [];
|
</div>
|
||||||
} finally {
|
</div>
|
||||||
pipelineLoading.value = false;
|
<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>
|
||||||
const refreshAll = async () => {
|
<el-table :data="users" stripe v-loading="loadingUsers">
|
||||||
await Promise.all([refresh(), refreshPipeline()]);
|
<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>
|
||||||
const triggerGenerate = async () => {
|
<el-table-column prop="created_at" label="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||||
generating.value = true;
|
<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>
|
||||||
try {
|
</el-table>
|
||||||
const res = await fetch(API_BASE + '/api/system/generate/run', { method: 'POST' });
|
</div>
|
||||||
const data = await res.json();
|
</div>
|
||||||
if (data.result && data.result.ok) {
|
</main>
|
||||||
ElementPlus.ElMessage.success('创作任务已启动');
|
</div>
|
||||||
setTimeout(refresh, 5000);
|
<div v-if="loadingOverlay" class="loading-overlay"><el-spinner type="spinning" :size="50"></el-spinner><p class="ml-4 text-lg">{{ loadingText }}</p></div>
|
||||||
} else {
|
<el-dialog v-model="previewVisible" title="文章预览" width="80%" top="5vh">
|
||||||
ElementPlus.ElMessage.error('启动失败: ' + (data.error || '未知错误'));
|
<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>
|
||||||
} finally {
|
<el-card v-if="previewHtml" class="mt-4" style="max-height: 60vh; overflow-y: auto;"><div v-html="previewHtml"></div></el-card>
|
||||||
generating.value = false;
|
<el-empty v-else description="暂无预览内容"></el-empty>
|
||||||
}
|
</div>
|
||||||
};
|
</el-dialog>
|
||||||
|
<el-dialog v-model="createTopicModalVisible" title="新建选题" width="500px">
|
||||||
const triggerOptimize = async () => {
|
<el-form :model="newTopicForm" label-width="100px">
|
||||||
optimizing.value = true;
|
<el-form-item label="标题"><el-input v-model="newTopicForm.title" placeholder="请输入标题"></el-input></el-form-item>
|
||||||
try {
|
<el-form-item label="领域"><el-input v-model="newTopicForm.field" placeholder="请输入领域"></el-input></el-form-item>
|
||||||
const res = await fetch(API_BASE + '/api/system/optimize/run', { method: 'POST' });
|
<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>
|
||||||
const data = await res.json();
|
</el-form>
|
||||||
if (data.summary) {
|
<template #footer><el-button @click="createTopicModalVisible = false">取消</el-button><el-button type="primary" @click="confirmCreateTopic">确定</el-button></template>
|
||||||
ElementPlus.ElNotification({
|
</el-dialog>
|
||||||
title: '优化完成',
|
<el-dialog v-model="createUserModalVisible" title="新建用户" width="500px">
|
||||||
message: `自动通过 ${data.summary.passed_auto} 篇,需人工 ${data.summary.need_manual} 篇`,
|
<el-form :model="newUserForm" label-width="100px">
|
||||||
type: data.summary.need_manual === 0 ? 'success' : 'warning'
|
<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>
|
||||||
await refresh();
|
<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>
|
||||||
} else {
|
</el-form>
|
||||||
ElementPlus.ElMessage.success('优化完成');
|
<template #footer><el-button @click="createUserModalVisible = false">取消</el-button><el-button type="primary" @click="confirmCreateUser">确定</el-button></template>
|
||||||
}
|
</el-dialog>
|
||||||
} catch (e) {
|
<el-dialog v-model="publishModalVisible" title="发布选题" width="500px">
|
||||||
ElementPlus.ElMessage.error('请求失败: ' + e);
|
<el-form :model="publishForm" label-width="100px">
|
||||||
} finally {
|
<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>
|
||||||
optimizing.value = false;
|
<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>
|
||||||
const openPreview = async (topic) => {
|
</el-dialog>
|
||||||
previewTopic.value = topic;
|
</div>
|
||||||
previewVisible.value = true;
|
<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">
|
||||||
previewPlatform.value = 'zhihu';
|
<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>
|
||||||
await loadPreview();
|
<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>
|
||||||
const loadPreview = async () => {
|
</nav>
|
||||||
const tid = previewTopic.value.id;
|
</div>
|
||||||
const platform = previewPlatform.value;
|
<script>
|
||||||
try {
|
const { ref, reactive, computed, onMounted, watch } = Vue;
|
||||||
const res = await fetch(API_BASE + `/api/articles/${tid}/preview?platform=${platform}`);
|
const { ElMessage, ElNotification } = ElementPlus;
|
||||||
const data = await res.json();
|
const app = Vue.createApp({
|
||||||
previewHtml.value = data.html;
|
name: 'YuZhiRanPlatform',
|
||||||
} catch (e) {
|
setup() {
|
||||||
ElementPlus.ElMessage.error('无法加载预览');
|
const API_BASE = "";
|
||||||
}
|
const token = ref(localStorage.getItem('token') || '');
|
||||||
};
|
const isLoggedIn = ref(!!token.value);
|
||||||
|
const currentUser = ref(null);
|
||||||
watch(previewPlatform, loadPreview);
|
const isAdmin = ref(false);
|
||||||
|
const loginForm = reactive({ username: '', password: '' });
|
||||||
const openPublish = async (topic) => {
|
const loginError = ref('');
|
||||||
publishTopic.value = topic;
|
const loadingLogin = ref(false);
|
||||||
publishForm.value = { zhihu: '', wechat: '', xiaohongshu: '' };
|
const authFetch = (url, options = {}) => {
|
||||||
publishVisible.value = true;
|
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||||
publishTab.value = 'links';
|
if (token.value) headers['Authorization'] = 'Bearer ' + token.value;
|
||||||
await loadPackages();
|
return fetch(url, { ...options, headers }).then(async res => {
|
||||||
};
|
if (res.status === 401) {
|
||||||
|
token.value = ''; localStorage.removeItem('token'); isLoggedIn.value = false;
|
||||||
// 加载发布包列表
|
ElMessage.error('登录已过期,请重新登录');
|
||||||
const loadPackages = async () => {
|
setTimeout(() => location.reload(), 1500);
|
||||||
const tid = publishTopic.value.id;
|
return Promise.reject(new Error('未授权'));
|
||||||
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 => {
|
return res;
|
||||||
console.error('Vue mount error:', err);
|
});
|
||||||
document.getElementById('app').innerHTML = '<div class="card"><h2>应用启动失败</h2><pre>' + err + '</pre></div>';
|
};
|
||||||
});
|
const handleLogin = async () => {
|
||||||
</script>
|
loadingLogin.value = true;
|
||||||
</body>
|
try {
|
||||||
</html>
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.use(ElementPlus);
|
||||||
|
app.mount('#app');
|
||||||
|
</script>
|
||||||
|
|||||||
+125
-14
@@ -1,17 +1,128 @@
|
|||||||
server {
|
# 宇之然内容创作平台 - Nginx配置
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
location / {
|
user nginx;
|
||||||
root /usr/share/nginx/html;
|
worker_processes auto;
|
||||||
index index.html;
|
error_log /var/log/nginx/error.log warn;
|
||||||
try_files $uri $uri/ /index.html;
|
pid /var/run/nginx.pid;
|
||||||
}
|
|
||||||
|
|
||||||
location /api/ {
|
events {
|
||||||
proxy_pass http://172.17.0.1:8000; # 宿主机在 Docker 网桥的 IP
|
worker_connections 1024;
|
||||||
proxy_set_header Host $host;
|
use epoll;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
multi_accept on;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user