chore: 清理未使用的代码文件和备份
- 删除 backend/main.py (已由 app/main.py 替代) - 删除 backend/api/ 和 backend/core/ (已由 app/api/ 和 app/core/ 替代) - 删除 backend/static 符号链接 - 删除前端测试/调试页面 - 删除未引用的 vendor 子目录 (element-plus/, vue/, axios/) - 删除所有 .bak 备份文件
This commit is contained in:
@@ -1,103 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import bcrypt
|
||||
|
||||
from core.security import get_current_admin_user
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/users", response_model=List[dict])
|
||||
async def get_users(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户列表(管理员功能)"""
|
||||
|
||||
users = db.query(User).all()
|
||||
result = []
|
||||
for user in users:
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||
})
|
||||
return result
|
||||
|
||||
@router.post("/users", response_model=dict)
|
||||
async def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = "user",
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建新用户(管理员功能)"""
|
||||
# 检查用户名是否已存在
|
||||
existing = db.query(User).filter(User.username == username).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
# 密码哈希
|
||||
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hashed_password,
|
||||
role=role or "user"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return {"message": "创建成功", "user_id": user.id}
|
||||
|
||||
@router.put("/users/{user_id}", response_model=dict)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
role: str = None,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新用户(管理员功能)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if username:
|
||||
existing = db.query(User).filter(User.username == username).first()
|
||||
if existing and existing.id != user_id:
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
user.username = username
|
||||
|
||||
if password:
|
||||
user.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
if role:
|
||||
user.role = role
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return {"message": "更新成功"}
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除用户(管理员功能)"""
|
||||
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="不能删除管理员用户")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
@@ -1,182 +0,0 @@
|
||||
# 宇之然内容创作平台 - 管理员API
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from core.security import get_current_admin_user
|
||||
from app.database import get_db
|
||||
from app.models import User, AuditLog
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/users", response_model=List[dict])
|
||||
async def get_users(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户列表(管理员功能)"""
|
||||
|
||||
users = db.query(User).all()
|
||||
result = []
|
||||
for user in users:
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@router.post("/users", response_model=dict)
|
||||
async def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = "user",
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建新用户(管理员功能)"""
|
||||
|
||||
# 检查用户名是否已存在
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户名已存在"
|
||||
)
|
||||
|
||||
# 验证角色
|
||||
if role not in ["admin", "user"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="角色必须是 admin 或 user"
|
||||
)
|
||||
|
||||
# 导入密码哈希函数
|
||||
from core.security import get_password_hash
|
||||
|
||||
# 创建新用户
|
||||
new_user = User(
|
||||
username=username,
|
||||
password_hash=get_password_hash(password),
|
||||
role=role
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# 记录审计日志
|
||||
from core.security import create_audit_log
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="create_user",
|
||||
resource_type="user",
|
||||
resource_id=new_user.id,
|
||||
details=f"角色: {role}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_user.id,
|
||||
"username": new_user.username,
|
||||
"role": new_user.role,
|
||||
"created_at": new_user.created_at.isoformat() if new_user.created_at else None
|
||||
}
|
||||
|
||||
@router.put("/users/{user_id}", response_model=dict)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
username: str = None,
|
||||
role: str = None,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新用户信息(管理员功能)"""
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
updates = {}
|
||||
|
||||
if username is not None:
|
||||
# 检查新用户名是否已被使用(除了当前用户)
|
||||
existing = db.query(User).filter(
|
||||
User.username == username,
|
||||
User.id != user_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户名已被使用"
|
||||
)
|
||||
user.username = username
|
||||
updates["username"] = username
|
||||
|
||||
if role is not None:
|
||||
if role not in ["admin", "user"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="角色必须是 admin 或 user"
|
||||
)
|
||||
user.role = role
|
||||
updates["role"] = role
|
||||
|
||||
if updates:
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
from core.security import create_audit_log
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="update_user",
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
details=f"更新字段: {', '.join(updates.keys())}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除用户(管理员功能)"""
|
||||
|
||||
# 不能删除自己
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="不能删除自己的账户"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
from core.security import create_audit_log
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="delete_user",
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
details="用户账户已删除"
|
||||
)
|
||||
|
||||
return {"message": "用户已成功删除"}
|
||||
@@ -1,63 +0,0 @@
|
||||
# 宇之然内容创作平台 - 文章预览API
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Topic
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/{topic_id}/preview")
|
||||
async def get_preview(
|
||||
topic_id: int,
|
||||
platform: str = "zhihu",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取文章预览HTML"""
|
||||
|
||||
# 检查选题是否存在
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="文章不存在")
|
||||
|
||||
# 生成预览HTML(简化实现)
|
||||
preview_html = generate_preview_html(topic.title, platform)
|
||||
|
||||
return {"html": preview_html}
|
||||
|
||||
def generate_preview_html(title: str, platform: str) -> str:
|
||||
"""根据平台和标题生成预览HTML"""
|
||||
|
||||
base_template = f"""
|
||||
<div class="article-preview">
|
||||
<header class="header">
|
||||
<h1>{title}</h1>
|
||||
<div class="meta">
|
||||
<span class="platform">{platform}</span>
|
||||
<span class="date">2026-04-26</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<p>这里是文章的正文内容...</p>
|
||||
<p>文章包含多个段落,展示不同的写作风格和结构。</p>
|
||||
<p>在实际生产环境中,这里应该是完整的文章内容。</p>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="tags">
|
||||
<span class="tag">#人工智能</span>
|
||||
<span class="tag">#科技</span>
|
||||
<span class="tag">#趋势</span>
|
||||
</div>
|
||||
<div class="interaction">
|
||||
<button class="like">👍 点赞</button>
|
||||
<button class="share">🔗 分享</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return base_template
|
||||
@@ -1,154 +0,0 @@
|
||||
# 宇之然内容创作平台 - 认证API
|
||||
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import secrets
|
||||
|
||||
from core.security import (
|
||||
verify_password, get_password_hash, create_access_token,
|
||||
create_audit_log, get_current_user
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
role: str
|
||||
user: dict
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user"
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""用户登录"""
|
||||
user = db.query(User).filter(User.username == login_data.username).first()
|
||||
|
||||
# 检查用户是否存在和密码是否正确
|
||||
if not user or not verify_password(login_data.password, user.password_hash):
|
||||
# 记录失败的登录尝试
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=0, # 未知用户
|
||||
action="login_failed",
|
||||
username=login_data.username,
|
||||
resource_type="user",
|
||||
resource_id=None,
|
||||
details=f"用户名: {login_data.username}",
|
||||
ip_address=request.client.host,
|
||||
user_agent=request.headers.get("User-Agent", "")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误"
|
||||
)
|
||||
|
||||
# 更新最后登录时间
|
||||
user.last_login = __import__('datetime').datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# 创建访问令牌
|
||||
access_token_expires = timedelta(minutes=10080) # 7天
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
# 记录成功的登录
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
action="login",
|
||||
username=user.username,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
details=f"登录成功",
|
||||
ip_address=request.client.host,
|
||||
user_agent=request.headers.get("User-Agent", "")
|
||||
)
|
||||
|
||||
return {
|
||||
"token": access_token,
|
||||
"role": user.role,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/me")
|
||||
async def read_users_me(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取当前用户信息"""
|
||||
return {
|
||||
"user": {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
"updated_at": current_user.updated_at.isoformat() if current_user.updated_at else None
|
||||
}
|
||||
}
|
||||
|
||||
@router.post("/register")
|
||||
async def register(
|
||||
user_data: RegisterRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""用户注册(管理员功能)"""
|
||||
# 检查用户名是否已存在
|
||||
existing_user = db.query(User).filter(User.username == user_data.username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户名已存在"
|
||||
)
|
||||
|
||||
# 创建新用户
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
username=user_data.username,
|
||||
password_hash=hashed_password,
|
||||
role=user_data.role
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# 记录审计日志
|
||||
from .auth import create_audit_log
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=new_user.id,
|
||||
action="create_user",
|
||||
username=new_user.username,
|
||||
resource_type="user",
|
||||
resource_id=new_user.id,
|
||||
details=f"角色: {user_data.role}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_user.id,
|
||||
"username": new_user.username,
|
||||
"role": new_user.role,
|
||||
"created_at": new_user.created_at.isoformat() if new_user.created_at else None
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
# 宇之然内容创作平台 - 文章生成API
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import asyncio
|
||||
|
||||
from core.security import get_current_user, create_audit_log
|
||||
from app.database import get_db
|
||||
from app.models import Topic, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class BatchGenerateRequest(BaseModel):
|
||||
topic_ids: Optional[List[str]] = None
|
||||
|
||||
class BatchOptimizeRequest(BaseModel):
|
||||
topic_ids: List[int]
|
||||
|
||||
@router.post("/run")
|
||||
async def batch_generate(
|
||||
request: BatchGenerateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""批量创建选题文章(简化版)"""
|
||||
# 如果没有指定topic_ids,则获取所有待处理的选题
|
||||
if not request.topic_ids:
|
||||
topics = db.query(Topic).filter(Topic.status != "已发布").all()
|
||||
topic_ids = [t.id for t in topics]
|
||||
else:
|
||||
topic_ids = request.topic_ids
|
||||
|
||||
if not topic_ids:
|
||||
return {"result": {"ok": True, "count": 0}}
|
||||
|
||||
# 验证选题是否存在且状态正确
|
||||
valid_topics = db.query(Topic).filter(
|
||||
Topic.id.in_(topic_ids),
|
||||
Topic.status != "已发布"
|
||||
).all()
|
||||
|
||||
if len(valid_topics) != len(topic_ids):
|
||||
invalid_count = len(topic_ids) - len(valid_topics)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"有{invalid_count}个选题状态不正确或不存在"
|
||||
)
|
||||
|
||||
# 更新选题状态为"待审查"
|
||||
for topic in valid_topics:
|
||||
topic.generated_at = datetime.utcnow()
|
||||
topic.status = "待审查"
|
||||
topic.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="batch_generate",
|
||||
resource_type="topic",
|
||||
resource_id=None,
|
||||
details=f"处理选题数量: {len(valid_topics)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"result": {
|
||||
"ok": True,
|
||||
"count": len(valid_topics)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/optimize/run")
|
||||
async def batch_optimize(
|
||||
request: BatchOptimizeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""批量优化选题文章"""
|
||||
|
||||
# 检查选题是否存在且状态正确(必须是待审查)
|
||||
valid_topics = db.query(Topic).filter(
|
||||
Topic.id.in_(request.topic_ids),
|
||||
Topic.status == "待审查"
|
||||
).all()
|
||||
|
||||
if len(valid_topics) != len(request.topic_ids):
|
||||
invalid_count = len(request.topic_ids) - len(valid_topics)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"有{invalid_count}个选题状态不正确或不存在"
|
||||
)
|
||||
|
||||
# 执行优化逻辑(简化实现)
|
||||
auto_passed = 0
|
||||
need_manual = 0
|
||||
|
||||
for topic in valid_topics:
|
||||
# 这里应该调用实际的合规性检查逻辑
|
||||
# 简化实现:随机决定通过或不通过
|
||||
import random
|
||||
if random.choice([True, False]):
|
||||
topic.status = "待发布"
|
||||
auto_passed += 1
|
||||
else:
|
||||
topic.status = "待审查"
|
||||
need_manual += 1
|
||||
|
||||
topic.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="batch_optimize",
|
||||
resource_type="topic",
|
||||
resource_id=None,
|
||||
details=f"自动通过: {auto_passed}, 需人工: {need_manual}"
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"passed_auto": auto_passed,
|
||||
"need_manual": need_manual,
|
||||
"total": len(valid_topics)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
# 宇之然内容创作平台 - 日志API
|
||||
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AuditLog
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/system/{date}")
|
||||
async def get_system_logs(
|
||||
date: str,
|
||||
log_type: str = "creator", # creator | collector
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取系统日志"""
|
||||
|
||||
try:
|
||||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式不正确,应为 YYYY-MM-DD")
|
||||
|
||||
# 查询指定日期的审计日志(简化实现)
|
||||
# 实际生产环境应从专门的日志系统中查询
|
||||
logs = [
|
||||
f"{target_date} 10:00:00 INFO 创建选题:人工智能发展趋势",
|
||||
f"{target_date} 10:05:00 INFO 选题状态更新为:待审查",
|
||||
f"{target_date} 10:10:00 INFO 批量生成文章任务已启动",
|
||||
f"{target_date} 10:15:00 INFO 文章优化完成,自动通过3篇",
|
||||
f"{target_date} 10:20:00 INFO 发布文章到知乎平台",
|
||||
f"{target_date} 10:25:00 INFO 用户登录成功",
|
||||
f"{target_date} 10:30:00 WARNING 选题合规性检查失败",
|
||||
f"{target_date} 10:35:00 ERROR 文章生成过程中出现异常"
|
||||
]
|
||||
|
||||
return {"content": logs}
|
||||
|
||||
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
async def get_logs(
|
||||
type: str = Query(..., description="日志类型: creator | collector"),
|
||||
date: str = Query(..., description="日期 YYYY-MM-DD"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取日志(支持查询参数)"""
|
||||
try:
|
||||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式不正确,应为 YYYY-MM-DD")
|
||||
|
||||
# 这里简化:仅返回系统日志,忽略type
|
||||
logs = [
|
||||
f"{target_date} 10:00:00 INFO 创建选题:人工智能发展趋势",
|
||||
f"{target_date} 10:05:00 INFO 选题状态更新为:待审查",
|
||||
f"{target_date} 10:10:00 INFO 批量生成文章任务已启动",
|
||||
f"{target_date} 10:15:00 INFO 文章优化完成,自动通过3篇",
|
||||
f"{target_date} 10:20:00 INFO 发布文章到知乎平台",
|
||||
f"{target_date} 10:25:00 INFO 用户登录成功",
|
||||
f"{target_date} 10:30:00 WARNING 选题合规性检查失败",
|
||||
f"{target_date} 10:35:00 ERROR 文章生成过程中出现异常"
|
||||
]
|
||||
|
||||
return {"type": type, "date": date, "content": logs}
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def get_audit_logs(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
action: str = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取审计日志(管理员功能)"""
|
||||
|
||||
# 这里应该实现真实的数据库查询
|
||||
# 简化实现返回空列表
|
||||
return []
|
||||
|
||||
@router.post("/clear")
|
||||
async def clear_old_logs(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""清理旧日志(管理员功能)"""
|
||||
|
||||
# 这里应该实现真实的数据库删除操作
|
||||
# 简化实现
|
||||
return {"message": "日志清理功能待实现"}
|
||||
@@ -1,93 +0,0 @@
|
||||
# 宇之然内容创作平台 - 文章发布API
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
import random
|
||||
|
||||
from core.security import get_current_user, create_audit_log
|
||||
from app.database import get_db
|
||||
from app.models import Topic, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
topic_id: str
|
||||
|
||||
@router.post("/create")
|
||||
async def create_publication(
|
||||
request: PublishRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""发布选题到各平台"""
|
||||
|
||||
# 检查选题是否存在
|
||||
topic = db.query(Topic).filter(Topic.id == request.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
# 检查选题状态是否正确(必须是待发布)
|
||||
if topic.status != "待发布":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"选题状态不正确,当前状态: {topic.status}"
|
||||
)
|
||||
|
||||
# 模拟发布到各个平台(实际生产环境应调用第三方API)
|
||||
urls = {}
|
||||
|
||||
# 知乎发布
|
||||
if publish_to_zhihu(topic.title):
|
||||
urls["zhihu"] = f"https://zhihu.com/article/{random.randint(100000, 999999)}"
|
||||
|
||||
# 微信公众号发布
|
||||
if publish_to_wechat(topic.title):
|
||||
urls["wechat"] = f"https://mp.weixin.qq.com/s/{random.randint(100000, 999999)}"
|
||||
|
||||
# 小红书发布
|
||||
if publish_to_xiaohongshu(topic.title):
|
||||
urls["xiaohongshu"] = f"https://www.xiaohongshu.com/discovery/item/{random.randint(100000, 999999)}"
|
||||
|
||||
# 更新选题状态和发布时间
|
||||
topic.published_at = datetime.utcnow()
|
||||
topic.platform_urls = urls
|
||||
topic.status = "已发布"
|
||||
topic.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="publish",
|
||||
resource_type="topic",
|
||||
resource_id=topic.id,
|
||||
details=f"发布到平台: {list(urls.keys())}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"urls": urls,
|
||||
"published_at": topic.published_at.isoformat() if topic.published_at else None
|
||||
}
|
||||
|
||||
def publish_to_zhihu(title: str) -> bool:
|
||||
"""模拟发布到知乎"""
|
||||
# 实际实现应调用知乎API
|
||||
import random
|
||||
return random.choice([True, False])
|
||||
|
||||
def publish_to_wechat(title: str) -> bool:
|
||||
"""模拟发布到微信公众号"""
|
||||
# 实际实现应调用微信公众号API
|
||||
import random
|
||||
return random.choice([True, False])
|
||||
|
||||
def publish_to_xiaohongshu(title: str) -> bool:
|
||||
"""模拟发布到小红书"""
|
||||
# 实际实现应调用小红书API
|
||||
import random
|
||||
return random.choice([True, False])
|
||||
@@ -1,89 +0,0 @@
|
||||
# 宇之然内容创作平台 - 系统管理API
|
||||
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import Topic
|
||||
import os
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/status")
|
||||
async def get_system_status(db: Session = Depends(get_db)):
|
||||
"""获取系统概览状态(格式匹配前端)"""
|
||||
today = date.today()
|
||||
|
||||
# 统计总数
|
||||
total = db.query(func.count(Topic.id)).scalar()
|
||||
|
||||
# 今日新选题数
|
||||
today_count = db.query(func.count(Topic.id)).filter(
|
||||
func.date(Topic.created_at) == today
|
||||
).scalar()
|
||||
|
||||
# 各状态选题数量(映射到前端字段)
|
||||
pending = db.query(func.count(Topic.id)).filter(Topic.status == "待处理").scalar() or 0
|
||||
review = db.query(func.count(Topic.id)).filter(Topic.status == "待审查").scalar() or 0
|
||||
ready = db.query(func.count(Topic.id)).filter(Topic.status == "待发布").scalar() or 0
|
||||
published = db.query(func.count(Topic.id)).filter(Topic.status == "已发布").scalar() or 0
|
||||
|
||||
return {
|
||||
"stats": {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"review": review,
|
||||
"ready": ready,
|
||||
"published": published,
|
||||
"today": today_count
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/pipeline/status")
|
||||
async def get_pipeline_status():
|
||||
"""获取流水线状态"""
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
# 检查各个模块的运行状态
|
||||
pipeline_modules = {
|
||||
"creator": {
|
||||
"exists": os.path.exists("modules/creator"),
|
||||
"has_error": False, # 简化实现,实际应检查日志或进程状态
|
||||
"last_run": get_last_run_time("creator"),
|
||||
"error": None
|
||||
},
|
||||
"collector": {
|
||||
"exists": os.path.exists("modules/collector"),
|
||||
"has_error": False,
|
||||
"last_run": get_last_run_time("collector"),
|
||||
"error": None
|
||||
}
|
||||
}
|
||||
|
||||
# 统计分布(这里应该从数据库查询,简化为静态数据)
|
||||
status_distribution = {
|
||||
"待处理": 0,
|
||||
"待审查": 0,
|
||||
"待发布": 0
|
||||
}
|
||||
|
||||
# 实际实现中应该从数据库查询真实数据
|
||||
# for status in ["待处理", "待审查", "待发布"]:
|
||||
# count = db.query(func.count(Topic.id)).filter(
|
||||
# Topic.status == status
|
||||
# ).scalar()
|
||||
# status_distribution[status] = count
|
||||
|
||||
return {
|
||||
"status_distribution": status_distribution,
|
||||
"pipeline_modules": pipeline_modules,
|
||||
"topics_count": 0 # 简化实现
|
||||
}
|
||||
|
||||
def get_last_run_time(module_name: str) -> str:
|
||||
"""获取模块最后运行时间(简化实现)"""
|
||||
# 实际实现应检查日志文件或数据库记录
|
||||
return "2026-04-26 15:30:00"
|
||||
@@ -1,189 +0,0 @@
|
||||
# 宇之然内容创作平台 - 选题管理API
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import json
|
||||
|
||||
from core.security import get_current_admin_user, create_audit_log
|
||||
from app.database import get_db
|
||||
from app.models import Topic, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
title: str
|
||||
field: Optional[str] = None
|
||||
priority_score: int = 0
|
||||
|
||||
class TopicUpdateRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
field: Optional[str] = None
|
||||
priority_score: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
@router.get("", response_model=List[dict])
|
||||
async def get_topics(
|
||||
status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取选题列表"""
|
||||
query = db.query(Topic)
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
query = query.filter(Topic.status == status)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * size
|
||||
topics = query.offset(offset).limit(size).all()
|
||||
|
||||
# 转换为字典格式
|
||||
result = []
|
||||
for topic in topics:
|
||||
result.append({
|
||||
"id": topic.id,
|
||||
"title": topic.title,
|
||||
"field": topic.field,
|
||||
"priority_score": topic.priority_score,
|
||||
"status": topic.status,
|
||||
"compliance_score": topic.compliance_score,
|
||||
"created_at": topic.created_at.isoformat() if topic.created_at else None,
|
||||
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None,
|
||||
"generated_at": topic.generated_at.isoformat() if topic.generated_at else None,
|
||||
"published_at": topic.published_at.isoformat() if topic.published_at else None,
|
||||
"platform_urls": topic.platform_urls
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_topic(
|
||||
topic_data: TopicCreateRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建新选题(管理员功能)"""
|
||||
new_topic = Topic(
|
||||
title=topic_data.title,
|
||||
field=topic_data.field,
|
||||
priority_score=topic_data.priority_score,
|
||||
status="待处理"
|
||||
)
|
||||
db.add(new_topic)
|
||||
db.commit()
|
||||
db.refresh(new_topic)
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="create_topic",
|
||||
resource_type="topic",
|
||||
resource_id=new_topic.id,
|
||||
details=f"标题: {topic_data.title}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_topic.id,
|
||||
"title": new_topic.title,
|
||||
"status": new_topic.status,
|
||||
"created_at": new_topic.created_at.isoformat() if new_topic.created_at else None
|
||||
}
|
||||
|
||||
@router.put("/{topic_id}", response_model=dict)
|
||||
async def update_topic(
|
||||
topic_id: int,
|
||||
topic_data: TopicUpdateRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新选题信息(管理员功能)"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
# 更新字段
|
||||
if topic_data.title is not None:
|
||||
topic.title = topic_data.title
|
||||
if topic_data.field is not None:
|
||||
topic.field = topic_data.field
|
||||
if topic_data.priority_score is not None:
|
||||
topic.priority_score = topic_data.priority_score
|
||||
if topic_data.status is not None:
|
||||
topic.status = topic_data.status
|
||||
|
||||
topic.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(topic)
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="update_topic",
|
||||
resource_type="topic",
|
||||
resource_id=topic_id,
|
||||
details=f"状态更新为: {topic_data.status}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": topic.id,
|
||||
"title": topic.title,
|
||||
"status": topic.status,
|
||||
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None
|
||||
}
|
||||
|
||||
@router.delete("/{topic_id}")
|
||||
async def delete_topic(
|
||||
topic_id: str,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除选题(管理员功能)"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
db.delete(topic)
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="delete_topic",
|
||||
resource_type="topic",
|
||||
resource_id=topic_id,
|
||||
details="选题已删除"
|
||||
)
|
||||
|
||||
return {"message": "选题已成功删除"}
|
||||
|
||||
@router.get("/{topic_id}", response_model=dict)
|
||||
async def get_topic(
|
||||
topic_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取单个选题详情"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
return {
|
||||
"id": topic.id,
|
||||
"title": topic.title,
|
||||
"field": topic.field,
|
||||
"priority_score": topic.priority_score,
|
||||
"status": topic.status,
|
||||
"compliance_score": topic.compliance_score,
|
||||
"created_at": topic.created_at.isoformat() if topic.created_at else None,
|
||||
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None,
|
||||
"generated_at": topic.generated_at.isoformat() if topic.generated_at else None,
|
||||
"published_at": topic.published_at.isoformat() if topic.published_at else None,
|
||||
"platform_urls": topic.platform_urls
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
# 宇之然内容创作平台 - 选题管理API
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import json
|
||||
|
||||
from core.security import get_current_admin_user, create_audit_log
|
||||
from app.database import get_db
|
||||
from app.models import Topic, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
title: str
|
||||
field: Optional[str] = None
|
||||
priority_score: int = 0
|
||||
|
||||
class TopicUpdateRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
field: Optional[str] = None
|
||||
priority_score: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def get_topics(
|
||||
status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取选题列表"""
|
||||
query = db.query(Topic)
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
query = query.filter(Topic.status == status)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * size
|
||||
topics = query.offset(offset).limit(size).all()
|
||||
|
||||
# 转换为字典格式
|
||||
result = []
|
||||
for topic in topics:
|
||||
result.append({
|
||||
"id": topic.id,
|
||||
"title": topic.title,
|
||||
"field": topic.field,
|
||||
"priority_score": topic.priority_score,
|
||||
"status": topic.status,
|
||||
"compliance_score": topic.compliance_score,
|
||||
"created_at": topic.created_at.isoformat() if topic.created_at else None,
|
||||
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None,
|
||||
"generated_at": topic.generated_at.isoformat() if topic.generated_at else None,
|
||||
"published_at": topic.published_at.isoformat() if topic.published_at else None,
|
||||
"platform_urls": topic.platform_urls
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_topic(
|
||||
topic_data: TopicCreateRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建新选题(管理员功能)"""
|
||||
new_topic = Topic(
|
||||
title=topic_data.title,
|
||||
field=topic_data.field,
|
||||
priority_score=topic_data.priority_score,
|
||||
status="待处理"
|
||||
)
|
||||
db.add(new_topic)
|
||||
db.commit()
|
||||
db.refresh(new_topic)
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="create_topic",
|
||||
resource_type="topic",
|
||||
resource_id=new_topic.id,
|
||||
details=f"标题: {topic_data.title}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_topic.id,
|
||||
"title": new_topic.title,
|
||||
"status": new_topic.status,
|
||||
"created_at": new_topic.created_at.isoformat() if new_topic.created_at else None
|
||||
}
|
||||
|
||||
@router.put("/{topic_id}", response_model=dict)
|
||||
async def update_topic(
|
||||
topic_id: int,
|
||||
topic_data: TopicUpdateRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新选题信息(管理员功能)"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
# 更新字段
|
||||
if topic_data.title is not None:
|
||||
topic.title = topic_data.title
|
||||
if topic_data.field is not None:
|
||||
topic.field = topic_data.field
|
||||
if topic_data.priority_score is not None:
|
||||
topic.priority_score = topic_data.priority_score
|
||||
if topic_data.status is not None:
|
||||
topic.status = topic_data.status
|
||||
|
||||
topic.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(topic)
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="update_topic",
|
||||
resource_type="topic",
|
||||
resource_id=topic_id,
|
||||
details=f"状态更新为: {topic_data.status}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": topic.id,
|
||||
"title": topic.title,
|
||||
"status": topic.status,
|
||||
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None
|
||||
}
|
||||
|
||||
@router.delete("/{topic_id}")
|
||||
async def delete_topic(
|
||||
topic_id: str,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除选题(管理员功能)"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
db.delete(topic)
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
create_audit_log(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
action="delete_topic",
|
||||
resource_type="topic",
|
||||
resource_id=topic_id,
|
||||
details="选题已删除"
|
||||
)
|
||||
|
||||
return {"message": "选题已成功删除"}
|
||||
|
||||
@router.get("/{topic_id}", response_model=dict)
|
||||
async def get_topic(
|
||||
topic_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取单个选题详情"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
return {
|
||||
"id": topic.id,
|
||||
"title": topic.title,
|
||||
"field": topic.field,
|
||||
"priority_score": topic.priority_score,
|
||||
"status": topic.status,
|
||||
"compliance_score": topic.compliance_score,
|
||||
"created_at": topic.created_at.isoformat() if topic.created_at else None,
|
||||
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None,
|
||||
"generated_at": topic.generated_at.isoformat() if topic.generated_at else None,
|
||||
"published_at": topic.published_at.isoformat() if topic.published_at else None,
|
||||
"platform_urls": topic.platform_urls
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"""
|
||||
NVIDIA 专用 LLM 客户端(fixed configuration)
|
||||
使用 OpenAI 兼容接口调用 stepfun-ai/step-3.5-flash
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
class LLMError(Exception):
|
||||
pass
|
||||
|
||||
# 固定配置(你的可用 key)
|
||||
CONFIG = {
|
||||
"base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key": "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",
|
||||
"model": "stepfun-ai/step-3.5-flash",
|
||||
}
|
||||
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
system_prompt: str = "你是一个专业的内容创作助手。",
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
stream: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
调用 NVIDIA LLM 生成文本
|
||||
"""
|
||||
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {CONFIG['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": CONFIG["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(endpoint, json=payload, headers=headers, timeout=120, stream=stream)
|
||||
if resp.status_code != 200:
|
||||
raise LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
if stream:
|
||||
full = []
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(b'data: '):
|
||||
data = line[6:]
|
||||
if data == b'[DONE]':
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
delta = chunk['choices'][0]['delta']
|
||||
# 支持 reasoning_content 或 reasoning 字段
|
||||
if 'reasoning_content' in delta and delta['reasoning_content']:
|
||||
full.append(delta['reasoning_content'])
|
||||
if 'content' in delta and delta['content']:
|
||||
full.append(delta['content'])
|
||||
except Exception:
|
||||
continue
|
||||
return "".join(full)
|
||||
else:
|
||||
data = resp.json()
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
|
||||
return content.strip() if content else ''
|
||||
except requests.RequestException as e:
|
||||
raise LLMError(f"Request failed: {e}")
|
||||
|
||||
def expand_content_with_llm(topic: dict, section_title: str, section_content: str, context: str = "") -> str:
|
||||
"""扩写大纲章节,返回包含 ## 标题的完整 Markdown"""
|
||||
prompt = f"""你是一个专业的内容创作者。请将以下大纲扩展为完整的文章章节。
|
||||
|
||||
# 选题信息
|
||||
- 标题:{topic.get('title')}
|
||||
- 领域:{topic.get('field')}
|
||||
- 核心观点:{topic.get('core_concept', '')}
|
||||
- 受众痛点:{topic.get('audience_pain', '')}
|
||||
- 独特视角:{topic.get('unique_angle', '')}
|
||||
|
||||
# 当前章节
|
||||
## {section_title}
|
||||
{section_content}
|
||||
|
||||
# 要求
|
||||
- 以 `## {section_title}` 作为章节标题开头
|
||||
- 字数:300-500字
|
||||
- 风格:客观、专业、易懂
|
||||
- 使用 Markdown 格式
|
||||
- 包含具体数据或案例(如果有)
|
||||
- 保持与整体文章调性一致
|
||||
|
||||
直接输出完整的 Markdown 章节(包括 ## 标题和正文段落)。"""
|
||||
if context:
|
||||
prompt = f"# 参考资料\n{context}\n\n{prompt}"
|
||||
|
||||
try:
|
||||
result = call_llm(prompt, temperature=0.8, max_tokens=2000)
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"## {section_title}\n\n(LLM 调用失败:{e},请手动补充)"
|
||||
|
||||
# 测试
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(f"[nvidia_client] 使用模型: {CONFIG['model']}")
|
||||
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
|
||||
print(f"[nvidia_client] 响应: {resp}")
|
||||
except Exception as e:
|
||||
print(f"[nvidia_client] 错误: {e}")
|
||||
@@ -1,130 +0,0 @@
|
||||
# 宇之然内容创作平台 - 安全模块
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import User
|
||||
from app.database import get_db
|
||||
|
||||
import bcrypt
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY = "your-secret-key-here" # 生产环境应从环境变量读取
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 10080 # 7天
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码(使用 bcrypt 直接比较)"""
|
||||
try:
|
||||
# 确保输入为字节
|
||||
if isinstance(plain_password, str):
|
||||
plain_password = plain_password.encode('utf-8')
|
||||
if isinstance(hashed_password, str):
|
||||
hashed_password = hashed_password.encode('utf-8')
|
||||
return bcrypt.checkpw(plain_password, hashed_password)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""生成密码哈希(使用 bcrypt)"""
|
||||
if isinstance(password, str):
|
||||
password = password.encode('utf-8')
|
||||
hashed = bcrypt.hashpw(password, bcrypt.gensalt())
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""创建JWT token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""获取当前用户"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭据",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||
"""获取活跃用户(简单检查)"""
|
||||
# 这里可以添加更多活跃性检查逻辑
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_admin_user(current_user: User = Depends(get_current_user)):
|
||||
"""获取管理员用户"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,需要管理员角色"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def create_audit_log(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
action: str,
|
||||
username: str = "",
|
||||
resource_type: str = "",
|
||||
resource_id: int = None,
|
||||
details: str = "",
|
||||
ip_address: str = "",
|
||||
user_agent: str = ""
|
||||
):
|
||||
"""创建审计日志"""
|
||||
from app.models import AuditLog, User
|
||||
|
||||
# 如果未提供 username,尝试从 user_id 查询
|
||||
if not username:
|
||||
if user_id and user_id != 0:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user:
|
||||
username = user.username
|
||||
if not username:
|
||||
username = "unknown"
|
||||
|
||||
audit_log = AuditLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
db.add(audit_log)
|
||||
db.commit()
|
||||
@@ -1,103 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from app.database import init_db
|
||||
from core.security import SECRET_KEY
|
||||
from api import auth, topics, system, publishing, articles, logs, admin, generate
|
||||
from app.api import cases, task_logs, llm_configs, system_configs
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
print("正在初始化数据库...")
|
||||
init_db()
|
||||
print("数据库初始化完成")
|
||||
yield
|
||||
print("应用关闭")
|
||||
|
||||
app = FastAPI(
|
||||
title="宇之然内容创作平台 API",
|
||||
description="企业级内容创作管理系统",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API 路由(必须先于静态文件注册)
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
|
||||
app.include_router(topics.router, prefix="/api/topics", tags=["选题管理"])
|
||||
app.include_router(system.router, prefix="/api/system", tags=["系统状态"])
|
||||
app.include_router(publishing.router, prefix="/api/publishing", tags=["文章发布"])
|
||||
app.include_router(articles.router, prefix="/api/articles", tags=["文章预览"])
|
||||
app.include_router(logs.router, prefix="/api/logs", tags=["日志系统"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["管理员"])
|
||||
app.include_router(generate.router, prefix="/api/generate", tags=["文章生成"])
|
||||
app.include_router(cases.router)
|
||||
app.include_router(task_logs.router)
|
||||
app.include_router(llm_configs.router)
|
||||
app.include_router(system_configs.router)
|
||||
|
||||
# 健康检查
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "timestamp": __import__('datetime').datetime.now().isoformat()}
|
||||
|
||||
# 独立页面路由(必须在 SPA catch-all 之前)
|
||||
@app.get("/topics.html")
|
||||
async def topics_page():
|
||||
return FileResponse("static/topics.html")
|
||||
|
||||
@app.get("/logs.html")
|
||||
async def logs_page():
|
||||
return FileResponse("static/logs.html")
|
||||
|
||||
@app.get("/users.html")
|
||||
async def users_page():
|
||||
return FileResponse("static/users.html")
|
||||
|
||||
|
||||
@app.get("/login.html")
|
||||
async def login_page():
|
||||
return FileResponse("static/login.html")
|
||||
|
||||
@app.get("/admin.html")
|
||||
async def admin_page():
|
||||
return FileResponse("static/admin.html")
|
||||
|
||||
# 静态文件(不干扰API)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# SPA:所有非 API 路径返回 index.html(最后注册)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
import traceback
|
||||
print(f"全局异常: {exc}")
|
||||
print(f"堆栈跟踪:\n{traceback.format_exc()}")
|
||||
return {
|
||||
"error": "服务器内部错误",
|
||||
"message": str(exc),
|
||||
"path": request.url.path
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True, log_level="info")
|
||||
@@ -1,97 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from app.database import init_db
|
||||
from core.security import SECRET_KEY
|
||||
from api import auth, topics, system, publishing, articles, logs, admin
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
print("正在初始化数据库...")
|
||||
init_db()
|
||||
print("数据库初始化完成")
|
||||
yield
|
||||
print("应用关闭")
|
||||
|
||||
app = FastAPI(
|
||||
title="宇之然内容创作平台 API",
|
||||
description="企业级内容创作管理系统",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API 路由(必须先于静态文件注册)
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
|
||||
app.include_router(topics.router, prefix="/api/topics", tags=["选题管理"])
|
||||
app.include_router(system.router, prefix="/api/system", tags=["系统状态"])
|
||||
app.include_router(publishing.router, prefix="/api/publishing", tags=["文章发布"])
|
||||
app.include_router(articles.router, prefix="/api/articles", tags=["文章预览"])
|
||||
app.include_router(logs.router, prefix="/api/logs", tags=["日志系统"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["管理员"])
|
||||
|
||||
# 健康检查
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "timestamp": __import__('datetime').datetime.now().isoformat()}
|
||||
|
||||
# 独立页面路由(必须在 SPA catch-all 之前)
|
||||
@app.get("/topics.html")
|
||||
async def topics_page():
|
||||
return FileResponse("static/topics.html")
|
||||
|
||||
@app.get("/logs.html")
|
||||
async def logs_page():
|
||||
return FileResponse("static/logs.html")
|
||||
|
||||
@app.get("/users.html")
|
||||
async def users_page():
|
||||
return FileResponse("static/users.html")
|
||||
|
||||
|
||||
@app.get("/login.html")
|
||||
async def login_page():
|
||||
return FileResponse("static/login.html")
|
||||
|
||||
@app.get("/admin.html")
|
||||
async def admin_page():
|
||||
return FileResponse("static/admin.html")
|
||||
|
||||
# 静态文件(不干扰API)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# SPA:所有非 API 路径返回 index.html(最后注册)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
import traceback
|
||||
print(f"全局异常: {exc}")
|
||||
print(f"堆栈跟踪:\n{traceback.format_exc()}")
|
||||
return {
|
||||
"error": "服务器内部错误",
|
||||
"message": str(exc),
|
||||
"path": request.url.path
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True, log_level="info")
|
||||
@@ -1 +0,0 @@
|
||||
../frontend
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
Redirecting to /axios@1.15.2/dist/axios.min.js
|
||||
@@ -1,39 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>导航组件调试</title>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navigation-component current-page="dashboard" :is-admin="true" @navigate="()=>{}"></navigation-component>
|
||||
</div>
|
||||
|
||||
<script src="navigation-component.js"></script>
|
||||
<script>
|
||||
// 手动检查
|
||||
console.log('NavigationComponent:', window.NavigationComponent);
|
||||
console.log('installNavigation:', window.installNavigation);
|
||||
|
||||
const App = { data() { return { isAdmin: true } } };
|
||||
const app = Vue.createApp(App);
|
||||
|
||||
if (window.installNavigation) {
|
||||
window.installNavigation(app);
|
||||
console.log('通过 installNavigation 注册');
|
||||
} else if (window.NavigationComponent) {
|
||||
app.component('navigation-component', window.NavigationComponent);
|
||||
console.log('直接注册组件');
|
||||
// 手动注入样式
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '.navigation-wrapper .sidebar { position: fixed; top: 0; left: 0; bottom: 0; width: 180px; background: #f0f0f0; z-index: 9999; }';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,601 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
/* 深色渐变背景主题 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
/* 深色渐变: #1a1a2e → #16213e → #0f3460 */
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
color: #e0e6ed;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(102, 126, 234, 0.2);
|
||||
padding: 16px 24px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.navbar-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.navbar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.navbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #a0aec0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
display: flex;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: rgba(26, 26, 46, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid rgba(102, 126, 234, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.sidebar-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: #a0aec0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
color: #667eea;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.sidebar-btn.active {
|
||||
background: linear-gradient(90deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.2));
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
.sidebar-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #667eea, #764ba2);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 页面切换 */
|
||||
.page { display: none; animation: fadeIn 0.5s ease-out; }
|
||||
.page.active { display: block; }
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 统计卡片网格 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(102, 126, 234, 0.1), transparent);
|
||||
transition: left 0.6s;
|
||||
}
|
||||
.stat-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
border-color: rgba(102, 126, 234, 0.4);
|
||||
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: #a0aec0;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-card.primary .stat-value { background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.success .stat-value { background: linear-gradient(135deg, #67c23a, #85e61d); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.warning .stat-value { background: linear-gradient(135deg, #e6a23c, #f5c543); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.danger .stat-value { background: linear-gradient(135deg, #f56c6c, #f79296); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.info .stat-value { background: linear-gradient(135deg, #409eff, #5cd0f3); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
/* 模块卡片 */
|
||||
.module-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.module-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
.module-card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.module-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.module-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e6ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.module-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
color: #67c23a;
|
||||
border: 1px solid rgba(103, 194, 58, 0.3);
|
||||
}
|
||||
.module-status.running {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(103, 194, 58, 0); }
|
||||
}
|
||||
.module-content {
|
||||
font-size: 14px;
|
||||
color: #a0aec0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.module-content div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.module-content div:last-child { border-bottom: none; }
|
||||
|
||||
/* 移动端导航 */
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(102, 126, 234, 0.2);
|
||||
padding: 8px 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.mobile-nav-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #a0aec0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.mobile-nav-btn.active {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mobile-nav-btn.active::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-nav { display: flex; }
|
||||
.content-area {
|
||||
padding: 16px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.stat-card { padding: 16px; }
|
||||
.stat-value { font-size: 24px; }
|
||||
.module-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body { background: #f5f7fa !important; color: #303133 !important; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important; border-bottom: none !important; backdrop-filter: none !important; }
|
||||
.navbar-title { background: none !important; -webkit-text-fill-color: white !important; color: white !important; }
|
||||
.user-info { color: white !important; }
|
||||
.avatar { background: rgba(255,255,255,0.2) !important; color: white !important; }
|
||||
.sidebar { background: white !important; border-right: 1px solid #ebeef5 !important; backdrop-filter: none !important; }
|
||||
.sidebar-btn { color: #606266 !important; }
|
||||
.sidebar-btn:hover, .sidebar-btn.active { background: #ecf5ff !important; color: #409eff !important; }
|
||||
.mobile-nav { background: white !important; border-top: 1px solid #ebeef5 !important; backdrop-filter: none !important; box-shadow: 0 -2px 8px rgba(0,0,0,0.1) !important; }
|
||||
.mobile-nav-btn { color: #606266 !important; }
|
||||
.mobile-nav-btn.active { color: #409eff !important; }
|
||||
.mobile-nav-btn.active::before { content: none !important; display: none !important; }
|
||||
#page-overview h2, #page-overview h3, #page-overview .module-title { color: #303133 !important; }
|
||||
#page-overview .module-content { color: #303133 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar" v-if="isLoggedIn">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div>
|
||||
<span>{{ currentUser.username }}</span>
|
||||
<el-tag v-if="isAdmin" size="small" type="danger" style="border: none;">管理员</el-tag>
|
||||
</div>
|
||||
<el-button size="small" type="danger" plain @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content" v-if="isLoggedIn">
|
||||
<aside class="sidebar">
|
||||
<button class="sidebar-btn" :class="{ active: currentPage === 'overview' }" @click="redirectToPage('/')">
|
||||
📊 系统概览
|
||||
</button>
|
||||
<button class="sidebar-btn" :class="{ active: currentPage === 'topics' }" @click="redirectToPage('topics.html')">
|
||||
📋 选题管理
|
||||
</button>
|
||||
<button class="sidebar-btn" :class="{ active: currentPage === 'logs' }" @click="redirectToPage('logs.html')">
|
||||
📄 系统日志
|
||||
</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" :class="{ active: currentPage === 'users' }" @click="redirectToPage('users.html')">
|
||||
👥 用户管理
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<!-- 系统概览页面 -->
|
||||
<div id="page-overview" class="page" :class="{ active: currentPage === 'overview' }">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #e0e6ed;">
|
||||
📊 系统概览
|
||||
</h2>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-title">选题总数</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning" @click="goToTopics('pending')">
|
||||
<div class="stat-title">待处理</div>
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
</div>
|
||||
<div class="stat-card danger" @click="goToTopics('review')">
|
||||
<div class="stat-title">待审查</div>
|
||||
<div class="stat-value">{{ stats.review }}</div>
|
||||
</div>
|
||||
<div class="stat-card success" @click="goToTopics('ready')">
|
||||
<div class="stat-title">待发布</div>
|
||||
<div class="stat-value">{{ stats.ready }}</div>
|
||||
</div>
|
||||
<div class="stat-card info" @click="goToTopics('published')">
|
||||
<div class="stat-title">已发布</div>
|
||||
<div class="stat-value">{{ stats.published }}</div>
|
||||
</div>
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-title">今日新增</div>
|
||||
<div class="stat-value">{{ stats.today }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块状态 -->
|
||||
<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 24px; color: #e0e6ed;">
|
||||
🔧 模块状态
|
||||
</h3>
|
||||
<div class="module-grid">
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">🤖 内容创作引擎</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 14:30</span></div>
|
||||
<div><span>今日任务</span><span>12 个</span></div>
|
||||
<div><span>成功率</span><span>95%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">🔍 内容优化器</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 14:45</span></div>
|
||||
<div><span>今日优化</span><span>8 个</span></div>
|
||||
<div><span>平均提升</span><span>+12 分</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">📡 内容收集器</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 14:00</span></div>
|
||||
<div><span>今日收集</span><span>24 个</span></div>
|
||||
<div><span>来源平台</span><span>8 个</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">📤 发布管理器</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 13:30</span></div>
|
||||
<div><span>今日发布</span><span>5 个</span></div>
|
||||
<div><span>成功率</span><span>100%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 移动端导航 -->
|
||||
<nav class="mobile-nav" v-if="isLoggedIn">
|
||||
<button class="mobile-nav-btn" :class="{ active: currentPage === 'overview' }" @click="redirectToPage('/')">
|
||||
📊 概览
|
||||
</button>
|
||||
<button class="mobile-nav-btn" :class="{ active: currentPage === 'topics' }" @click="redirectToPage('topics.html')">
|
||||
📋 选题
|
||||
</button>
|
||||
<button class="mobile-nav-btn" :class="{ active: currentPage === 'logs' }" @click="redirectToPage('logs.html')">
|
||||
📄 日志
|
||||
</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" :class="{ active: currentPage === 'users' }" @click="redirectToPage('users.html')">
|
||||
👥 用户
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const App = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
currentPage: 'overview',
|
||||
stats: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
review: 0,
|
||||
ready: 0,
|
||||
published: 0,
|
||||
today: 0
|
||||
}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async handleLogin() {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.loginForm)
|
||||
});
|
||||
if (!response.ok) throw new Error('登录失败');
|
||||
const data = await response.json();
|
||||
localStorage.setItem('authToken', data.token);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.currentPage = 'overview';
|
||||
this.fetchStats();
|
||||
} catch (error) {
|
||||
this.loginError = '用户名或密码错误';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
handleLogout() {
|
||||
localStorage.removeItem('authToken');
|
||||
this.isLoggedIn = false;
|
||||
this.currentUser = { username: '' };
|
||||
this.isAdmin = false;
|
||||
},
|
||||
async fetchStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/status', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// API返回格式: { stats: { total, pending, review, ready, published, today } }
|
||||
this.stats = {
|
||||
total: data.stats?.total || 0,
|
||||
pending: data.stats?.pending || 0,
|
||||
review: data.stats?.review || 0,
|
||||
ready: data.stats?.ready || 0,
|
||||
published: data.stats?.published || 0,
|
||||
today: data.stats?.today || 0
|
||||
};
|
||||
} else if (response.status === 401) {
|
||||
// Token无效,清除并跳转登录
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login.html';
|
||||
} else {
|
||||
console.error('获取统计信息失败:', response.status, response.statusText);
|
||||
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error);
|
||||
// 失败时设置为0,避免页面空白
|
||||
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
|
||||
}
|
||||
},
|
||||
goToTopics(filter) {
|
||||
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
|
||||
window.location.href = url;
|
||||
},
|
||||
redirectToPage(page) {
|
||||
window.location.href = page.startsWith('/') ? page : '/' + page;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.currentPage = 'overview';
|
||||
this.fetchStats();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const app = Vue.createApp(App);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="navigation-component.js"></script>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
.navbar { height: 60px; background: #333; color: white; display: flex; align-items: center; padding: 0 20px; }
|
||||
.main-content { padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="navbar">Test Navbar</div>
|
||||
<navigation-component current-page="dashboard" :is-admin="true" @navigate="()=>{}"></navigation-component>
|
||||
<div class="main-content">
|
||||
<h1>内容区域</h1>
|
||||
<p>侧边栏应该在左侧显示。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const app = Vue.createApp({
|
||||
data() { return { isAdmin: true } }
|
||||
});
|
||||
if (window.installNavigation) {
|
||||
window.installNavigation(app);
|
||||
console.log('已调用 installNavigation');
|
||||
} else {
|
||||
console.error('installNavigation 未定义');
|
||||
}
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,51 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>导航组件测试</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script src="navigation-component.js"></script>
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
.navbar { background: #2563eb; color: white; padding: 16px; }
|
||||
.main-content { padding: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<span>测试页面</span>
|
||||
</nav>
|
||||
|
||||
<navigation-component
|
||||
current-page="topics"
|
||||
:is-admin="true"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
|
||||
<div class="main-content">
|
||||
<h1>内容区域</h1>
|
||||
<p>如果看到左边栏,说明组件工作正常。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const App = {
|
||||
data() {
|
||||
return { isAdmin: true };
|
||||
},
|
||||
methods: {
|
||||
redirectToPage(page) {
|
||||
console.log('导航到:', page);
|
||||
}
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(App);
|
||||
if (window.installNavigation) { window.installNavigation(app); }
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,682 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 选题管理</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
.sidebar-btn { width: 100%; text-align: left; padding: 8px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; }
|
||||
.sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
|
||||
.sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600; }
|
||||
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.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; }
|
||||
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
|
||||
.mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 12px; text-align: center; font-size: 12px; color: #606266; cursor: pointer; }
|
||||
.mobile-nav-btn.active { color: #409eff; font-weight: 600; }
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-nav { display: flex; }
|
||||
.content-area { padding: 12px; padding-bottom: 80px; }
|
||||
}
|
||||
|
||||
.topic-card-list { display: none; }
|
||||
/* 移动端卡片布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-table { font-size: 12px; display: none; }
|
||||
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
|
||||
.el-table .cell { padding: 0 4px; }
|
||||
.el-table .el-table__cell { padding: 6px 0; }
|
||||
.topic-card-list { display: block; margin: 0 -16px; }
|
||||
.topic-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.topic-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.topic-card-title { font-size: 16px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
|
||||
.topic-card-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.topic-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.topic-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
|
||||
.mobile-nav { display: flex; }
|
||||
}
|
||||
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
/* 预览弹窗响应式高度 */
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
|
||||
/* 预览弹窗自定义高度(非全屏时) */
|
||||
.preview-dialog-custom.el-dialog {
|
||||
max-height: calc(100vh - 90px) !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main-content">
|
||||
<aside class="sidebar">
|
||||
<button class="sidebar-btn" @click="redirectToPage('/')">📊 系统概览</button>
|
||||
<button class="sidebar-btn active">📋 选题管理</button>
|
||||
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</aside>
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
||||
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
|
||||
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
|
||||
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="generated_at" label="创作时间" width="140"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="published_at" label="发布时间" width="140"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="optimizeTopic(scope.row)">审查</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
|
||||
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-meta">
|
||||
<div>创建: {{ formatDate(topic.created_at) }}</div>
|
||||
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
|
||||
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="topic-card-actions">
|
||||
<el-button size="small" @click="openPreview(topic)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="optimizeTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<nav class="mobile-nav">
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('/')">📊 概览</button>
|
||||
<button class="mobile-nav-btn active">📋 选题</button>
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</nav>
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen">
|
||||
<div v-if="previewTopic">
|
||||
<!-- 平台切换按钮 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||
<el-button-group>
|
||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
|
||||
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||
<el-button size="small" @click="togglePreviewFullscreen">
|
||||
{{ previewFullscreen ? '退出全屏' : '全屏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容使用 iframe 隔离样式 -->
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe :srcdoc="currentPreviewHtml"
|
||||
class="preview-iframe"
|
||||
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
|
||||
sandbox>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
|
||||
<div class="preview-info">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span style="margin: 0 8px;">|</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
|
||||
创作:{{ formatDate(previewTopic.generated_at) }}
|
||||
</span>
|
||||
<span v-if="previewTopic.published_at" style="margin-left:8px;">
|
||||
发布:{{ formatDate(previewTopic.published_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
data() {
|
||||
return {
|
||||
currentPage: 'topics',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
selectedTopicIds: [],
|
||||
filterStatus: '',
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [],
|
||||
previewVisible: false,
|
||||
previewTopic: null,
|
||||
previewFullscreen: false,
|
||||
previewPlatform: 'zhihu',
|
||||
platformContents: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) { return []; }
|
||||
if (!this.filterStatus) { return this.topics; }
|
||||
const statusMap = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
},
|
||||
statusStats() {
|
||||
const pending = ['pending', '待处理'];
|
||||
const review = ['review', '待审查'];
|
||||
const ready = ['ready', '待发布'];
|
||||
const published = ['published', '已发布'];
|
||||
return {
|
||||
total: this.topics.length,
|
||||
pending: this.topics.filter(t => pending.includes(t.status)).length,
|
||||
review: this.topics.filter(t => review.includes(t.status)).length,
|
||||
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
||||
published: this.topics.filter(t => published.includes(t.status)).length
|
||||
};
|
||||
},
|
||||
currentPreviewHtml() {
|
||||
const html = this.platformContents[this.previewPlatform];
|
||||
if (!html) return '';
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const body = doc.body;
|
||||
if (!body) return html;
|
||||
|
||||
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
|
||||
|
||||
const head = doc.querySelector('head');
|
||||
const headHtml = head ? head.innerHTML : '';
|
||||
const bodyHtml = body.innerHTML;
|
||||
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||
|
||||
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||
} catch (e) {
|
||||
console.error('生成预览 HTML 失败:', e);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
setTimeout(() => window.location.href = '/', 1500);
|
||||
this.loadingTable = false;
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/topics', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取选题失败:', error);
|
||||
this.$message.error(`获取选题失败: ${error.message}`);
|
||||
this.topics = [];
|
||||
} finally {
|
||||
this.loadingTable = false;
|
||||
}
|
||||
},
|
||||
refreshAll() { this.$message.info('执行批量刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量创作已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 取第一个选题(当前简单实现)
|
||||
const topicId = this.selectedTopicIds[0];
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topicId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
this.$message.success('批量创作已启动');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量创作失败:', error);
|
||||
this.$message.error(`批量创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async triggerOptimizeSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success('批量优化完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量优化失败:', error);
|
||||
this.$message.error(`批量优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async openPreview(topic) {
|
||||
this.previewTopic = topic;
|
||||
this.previewPlatform = 'zhihu';
|
||||
this.previewVisible = true;
|
||||
this.platformContents = {};
|
||||
// 并行加载所有平台内容
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const promises = platforms.map(p =>
|
||||
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (d && d.html) {
|
||||
this.platformContents[p] = d.html;
|
||||
}
|
||||
})
|
||||
.catch(e => console.error(`加载${p}预览失败:`, e))
|
||||
);
|
||||
await Promise.all(promises);
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
zhihu: '知乎',
|
||||
wechat: '微信公众号',
|
||||
xiaohongshu: '小红书'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
// 计算属性:当前平台预览的完整 HTML(响应式更新)
|
||||
copyContent(platform) {
|
||||
if (!this.previewTopic || !this.previewTopic.content) {
|
||||
this.$message.warning('暂无内容可复制');
|
||||
return;
|
||||
}
|
||||
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
|
||||
}).catch(err => {
|
||||
console.error('复制失败', err);
|
||||
this.$message.error('复制失败,请手动复制');
|
||||
});
|
||||
},
|
||||
async createTopic(topic) {
|
||||
console.log('createTopic clicked, topic:', topic);
|
||||
if (this.isStatus(topic, 'published')) {
|
||||
this.$message.info('已发布选题不可创作');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topic.id })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`创作完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('创作失败:', error);
|
||||
this.$message.error(`创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async optimizeTopic(topic) {
|
||||
if (!this.isStatus(topic, 'review')) {
|
||||
this.$message.info('仅待审查选题可优化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`优化完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('优化失败:', error);
|
||||
this.$message.error(`优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) {
|
||||
this.$message.info('仅待发布选题可发布');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
this.$message.error(`发布失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
this.$message.error(`删除失败: ${error.message}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'review': '待审查',
|
||||
'ready': '待发布',
|
||||
'published': '已发布'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
} catch (e) { return dateStr; }
|
||||
},
|
||||
getStatusType(status) {
|
||||
const map = {
|
||||
'pending': 'warning',
|
||||
'review': 'danger',
|
||||
'ready': 'success',
|
||||
'published': 'info',
|
||||
'待处理': 'warning',
|
||||
'待审查': 'danger',
|
||||
'待发布': 'success',
|
||||
'已发布': 'info'
|
||||
};
|
||||
return map[status] || 'primary';
|
||||
},
|
||||
isStatus(row, status) {
|
||||
const map = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
return map[status] ? map[status].includes(row.status) : row.status === status;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('[DEBUG] TopicsApp mounted');
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('[DEBUG] Token exists:', !!token);
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
// 解析 URL filter 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filter = urlParams.get('filter');
|
||||
console.log('[DEBUG] URL filter:', filter);
|
||||
if (filter) { this.filterStatus = filter; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
console.log('[DEBUG] Auth success, user:', data.user);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.fetchTopics();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,649 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 选题管理</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* 移动端卡片布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
|
||||
.el-table .cell { padding: 0 4px; }
|
||||
.el-table .el-table__cell { padding: 6px 0; }
|
||||
.topic-card-list { display: block; margin: 0 -16px; }
|
||||
.topic-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.topic-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.topic-card-title { font-size: 16px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
|
||||
.topic-card-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.topic-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.topic-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
|
||||
}
|
||||
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
/* 预览弹窗响应式高度 */
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
|
||||
/* 预览弹窗自定义高度(非全屏时) */
|
||||
.preview-dialog-custom.el-dialog {
|
||||
max-height: calc(100vh - 90px) !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script></head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
||||
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
|
||||
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
|
||||
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="generated_at" label="创作时间" width="140"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="published_at" label="发布时间" width="140"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="optimizeTopic(scope.row)">审查</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
|
||||
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-meta">
|
||||
<div>创建: {{ formatDate(topic.created_at) }}</div>
|
||||
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
|
||||
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="topic-card-actions">
|
||||
<el-button size="small" @click="openPreview(topic)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="optimizeTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen">
|
||||
<div v-if="previewTopic">
|
||||
<!-- 平台切换按钮 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||
<el-button-group>
|
||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
|
||||
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||
<el-button size="small" @click="togglePreviewFullscreen">
|
||||
{{ previewFullscreen ? '退出全屏' : '全屏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容使用 iframe 隔离样式 -->
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe :srcdoc="currentPreviewHtml"
|
||||
class="preview-iframe"
|
||||
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
|
||||
sandbox>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
|
||||
<div class="preview-info">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span style="margin: 0 8px;">|</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
|
||||
创作:{{ formatDate(previewTopic.generated_at) }}
|
||||
</span>
|
||||
<span v-if="previewTopic.published_at" style="margin-left:8px;">
|
||||
发布:{{ formatDate(previewTopic.published_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
data() {
|
||||
return {
|
||||
currentPage: 'topics',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
selectedTopicIds: [],
|
||||
filterStatus: '',
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [],
|
||||
previewVisible: false,
|
||||
previewTopic: null,
|
||||
previewFullscreen: false,
|
||||
previewPlatform: 'zhihu',
|
||||
platformContents: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) { return []; }
|
||||
if (!this.filterStatus) { return this.topics; }
|
||||
const statusMap = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
},
|
||||
statusStats() {
|
||||
const pending = ['pending', '待处理'];
|
||||
const review = ['review', '待审查'];
|
||||
const ready = ['ready', '待发布'];
|
||||
const published = ['published', '已发布'];
|
||||
return {
|
||||
total: this.topics.length,
|
||||
pending: this.topics.filter(t => pending.includes(t.status)).length,
|
||||
review: this.topics.filter(t => review.includes(t.status)).length,
|
||||
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
||||
published: this.topics.filter(t => published.includes(t.status)).length
|
||||
};
|
||||
},
|
||||
currentPreviewHtml() {
|
||||
const html = this.platformContents[this.previewPlatform];
|
||||
if (!html) return '';
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const body = doc.body;
|
||||
if (!body) return html;
|
||||
|
||||
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
|
||||
|
||||
const head = doc.querySelector('head');
|
||||
const headHtml = head ? head.innerHTML : '';
|
||||
const bodyHtml = body.innerHTML;
|
||||
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||
|
||||
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||
} catch (e) {
|
||||
console.error('生成预览 HTML 失败:', e);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
setTimeout(() => window.location.href = '/', 1500);
|
||||
this.loadingTable = false;
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/topics', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取选题失败:', error);
|
||||
this.$message.error(`获取选题失败: ${error.message}`);
|
||||
this.topics = [];
|
||||
} finally {
|
||||
this.loadingTable = false;
|
||||
}
|
||||
},
|
||||
refreshAll() { this.$message.info('执行批量刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量创作已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 取第一个选题(当前简单实现)
|
||||
const topicId = this.selectedTopicIds[0];
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topicId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
this.$message.success('批量创作已启动');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量创作失败:', error);
|
||||
this.$message.error(`批量创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async triggerOptimizeSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success('批量优化完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量优化失败:', error);
|
||||
this.$message.error(`批量优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async openPreview(topic) {
|
||||
this.previewTopic = topic;
|
||||
this.previewPlatform = 'zhihu';
|
||||
this.previewVisible = true;
|
||||
this.platformContents = {};
|
||||
// 并行加载所有平台内容
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const promises = platforms.map(p =>
|
||||
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (d && d.html) {
|
||||
this.platformContents[p] = d.html;
|
||||
}
|
||||
})
|
||||
.catch(e => console.error(`加载${p}预览失败:`, e))
|
||||
);
|
||||
await Promise.all(promises);
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
zhihu: '知乎',
|
||||
wechat: '微信公众号',
|
||||
xiaohongshu: '小红书'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
// 计算属性:当前平台预览的完整 HTML(响应式更新)
|
||||
copyContent(platform) {
|
||||
if (!this.previewTopic || !this.previewTopic.content) {
|
||||
this.$message.warning('暂无内容可复制');
|
||||
return;
|
||||
}
|
||||
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
|
||||
}).catch(err => {
|
||||
console.error('复制失败', err);
|
||||
this.$message.error('复制失败,请手动复制');
|
||||
});
|
||||
},
|
||||
async createTopic(topic) {
|
||||
console.log('createTopic clicked, topic:', topic);
|
||||
if (this.isStatus(topic, 'published')) {
|
||||
this.$message.info('已发布选题不可创作');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topic.id })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`创作完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('创作失败:', error);
|
||||
this.$message.error(`创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async optimizeTopic(topic) {
|
||||
if (!this.isStatus(topic, 'review')) {
|
||||
this.$message.info('仅待审查选题可优化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`优化完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('优化失败:', error);
|
||||
this.$message.error(`优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) {
|
||||
this.$message.info('仅待发布选题可发布');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
this.$message.error(`发布失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
this.$message.error(`删除失败: ${error.message}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'review': '待审查',
|
||||
'ready': '待发布',
|
||||
'published': '已发布'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
} catch (e) { return dateStr; }
|
||||
},
|
||||
getStatusType(status) {
|
||||
const map = {
|
||||
'pending': 'warning',
|
||||
'review': 'danger',
|
||||
'ready': 'success',
|
||||
'published': 'info',
|
||||
'待处理': 'warning',
|
||||
'待审查': 'danger',
|
||||
'待发布': 'success',
|
||||
'已发布': 'info'
|
||||
};
|
||||
return map[status] || 'primary';
|
||||
},
|
||||
isStatus(row, status) {
|
||||
const map = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
return map[status] ? map[status].includes(row.status) : row.status === status;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('[DEBUG] TopicsApp mounted');
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('[DEBUG] Token exists:', !!token);
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
// 解析 URL filter 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filter = urlParams.get('filter');
|
||||
console.log('[DEBUG] URL filter:', filter);
|
||||
if (filter) { this.filterStatus = filter; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
console.log('[DEBUG] Auth success, user:', data.user);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.fetchTopics();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,654 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 选题管理</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* 移动端卡片布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
|
||||
.el-table .cell { padding: 0 4px; }
|
||||
.el-table .el-table__cell { padding: 6px 0; }
|
||||
.topic-card-list { display: block; margin: 0 -16px; }
|
||||
.topic-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.topic-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.topic-card-title { font-size: 16px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
|
||||
.topic-card-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.topic-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.topic-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
|
||||
}
|
||||
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
/* 预览弹窗响应式高度 */
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
|
||||
/* 预览弹窗自定义高度(非全屏时) */
|
||||
.preview-dialog-custom.el-dialog {
|
||||
max-height: calc(100vh - 90px) !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script></head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<navigation-component
|
||||
current-page="topics"
|
||||
:is-admin="isAdmin"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
<div class="main-content">
|
||||
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
||||
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
|
||||
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
|
||||
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="generated_at" label="创作时间" width="140"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="published_at" label="发布时间" width="140"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="optimizeTopic(scope.row)">审查</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
|
||||
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-meta">
|
||||
<div>创建: {{ formatDate(topic.created_at) }}</div>
|
||||
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
|
||||
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="topic-card-actions">
|
||||
<el-button size="small" @click="openPreview(topic)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="optimizeTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen">
|
||||
<div v-if="previewTopic">
|
||||
<!-- 平台切换按钮 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||
<el-button-group>
|
||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
|
||||
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||
<el-button size="small" @click="togglePreviewFullscreen">
|
||||
{{ previewFullscreen ? '退出全屏' : '全屏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容使用 iframe 隔离样式 -->
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe :srcdoc="currentPreviewHtml"
|
||||
class="preview-iframe"
|
||||
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
|
||||
sandbox>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
|
||||
<div class="preview-info">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span style="margin: 0 8px;">|</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
|
||||
创作:{{ formatDate(previewTopic.generated_at) }}
|
||||
</span>
|
||||
<span v-if="previewTopic.published_at" style="margin-left:8px;">
|
||||
发布:{{ formatDate(previewTopic.published_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
data() {
|
||||
return {
|
||||
currentPage: 'topics',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
selectedTopicIds: [],
|
||||
filterStatus: '',
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [],
|
||||
previewVisible: false,
|
||||
previewTopic: null,
|
||||
previewFullscreen: false,
|
||||
previewPlatform: 'zhihu',
|
||||
platformContents: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) { return []; }
|
||||
if (!this.filterStatus) { return this.topics; }
|
||||
const statusMap = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
},
|
||||
statusStats() {
|
||||
const pending = ['pending', '待处理'];
|
||||
const review = ['review', '待审查'];
|
||||
const ready = ['ready', '待发布'];
|
||||
const published = ['published', '已发布'];
|
||||
return {
|
||||
total: this.topics.length,
|
||||
pending: this.topics.filter(t => pending.includes(t.status)).length,
|
||||
review: this.topics.filter(t => review.includes(t.status)).length,
|
||||
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
||||
published: this.topics.filter(t => published.includes(t.status)).length
|
||||
};
|
||||
},
|
||||
currentPreviewHtml() {
|
||||
const html = this.platformContents[this.previewPlatform];
|
||||
if (!html) return '';
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const body = doc.body;
|
||||
if (!body) return html;
|
||||
|
||||
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
|
||||
|
||||
const head = doc.querySelector('head');
|
||||
const headHtml = head ? head.innerHTML : '';
|
||||
const bodyHtml = body.innerHTML;
|
||||
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||
|
||||
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||
} catch (e) {
|
||||
console.error('生成预览 HTML 失败:', e);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
setTimeout(() => window.location.href = '/', 1500);
|
||||
this.loadingTable = false;
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/topics', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取选题失败:', error);
|
||||
this.$message.error(`获取选题失败: ${error.message}`);
|
||||
this.topics = [];
|
||||
} finally {
|
||||
this.loadingTable = false;
|
||||
}
|
||||
},
|
||||
refreshAll() { this.$message.info('执行批量刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量创作已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 取第一个选题(当前简单实现)
|
||||
const topicId = this.selectedTopicIds[0];
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topicId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
this.$message.success('批量创作已启动');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量创作失败:', error);
|
||||
this.$message.error(`批量创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async triggerOptimizeSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success('批量优化完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量优化失败:', error);
|
||||
this.$message.error(`批量优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async openPreview(topic) {
|
||||
this.previewTopic = topic;
|
||||
this.previewPlatform = 'zhihu';
|
||||
this.previewVisible = true;
|
||||
this.platformContents = {};
|
||||
// 并行加载所有平台内容
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const promises = platforms.map(p =>
|
||||
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (d && d.html) {
|
||||
this.platformContents[p] = d.html;
|
||||
}
|
||||
})
|
||||
.catch(e => console.error(`加载${p}预览失败:`, e))
|
||||
);
|
||||
await Promise.all(promises);
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
zhihu: '知乎',
|
||||
wechat: '微信公众号',
|
||||
xiaohongshu: '小红书'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
// 计算属性:当前平台预览的完整 HTML(响应式更新)
|
||||
copyContent(platform) {
|
||||
if (!this.previewTopic || !this.previewTopic.content) {
|
||||
this.$message.warning('暂无内容可复制');
|
||||
return;
|
||||
}
|
||||
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
|
||||
}).catch(err => {
|
||||
console.error('复制失败', err);
|
||||
this.$message.error('复制失败,请手动复制');
|
||||
});
|
||||
},
|
||||
async createTopic(topic) {
|
||||
console.log('createTopic clicked, topic:', topic);
|
||||
if (this.isStatus(topic, 'published')) {
|
||||
this.$message.info('已发布选题不可创作');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topic.id })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`创作完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('创作失败:', error);
|
||||
this.$message.error(`创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async optimizeTopic(topic) {
|
||||
if (!this.isStatus(topic, 'review')) {
|
||||
this.$message.info('仅待审查选题可优化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`优化完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('优化失败:', error);
|
||||
this.$message.error(`优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) {
|
||||
this.$message.info('仅待发布选题可发布');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
this.$message.error(`发布失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
this.$message.error(`删除失败: ${error.message}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'review': '待审查',
|
||||
'ready': '待发布',
|
||||
'published': '已发布'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
} catch (e) { return dateStr; }
|
||||
},
|
||||
getStatusType(status) {
|
||||
const map = {
|
||||
'pending': 'warning',
|
||||
'review': 'danger',
|
||||
'ready': 'success',
|
||||
'published': 'info',
|
||||
'待处理': 'warning',
|
||||
'待审查': 'danger',
|
||||
'待发布': 'success',
|
||||
'已发布': 'info'
|
||||
};
|
||||
return map[status] || 'primary';
|
||||
},
|
||||
isStatus(row, status) {
|
||||
const map = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
return map[status] ? map[status].includes(row.status) : row.status === status;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('[DEBUG] TopicsApp mounted');
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('[DEBUG] Token exists:', !!token);
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
// 解析 URL filter 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filter = urlParams.get('filter');
|
||||
console.log('[DEBUG] URL filter:', filter);
|
||||
if (filter) { this.filterStatus = filter; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
console.log('[DEBUG] Auth success, user:', data.user);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.fetchTopics();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user