feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化
Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列 测试: tests/test_phase_upgrades.py 97项全覆盖 CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式 修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
@@ -3,16 +3,19 @@ from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import bcrypt
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..models import User, Topic, SystemConfig
|
||||
from ..schemas import UserCreate, UserUpdate, UserResponse
|
||||
from ..core.audit_logger import audit_log
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
def get_current_admin(request: Request, db: Session = Depends(get_db)):
|
||||
"""依赖项:验证管理员权限"""
|
||||
from .auth import verify_token
|
||||
from .auth import verify_token, org_filter
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未提供认证令牌")
|
||||
@@ -29,7 +32,11 @@ def list_users(
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
"""获取用户列表(管理员)"""
|
||||
users = db.query(User).all()
|
||||
q = db.query(User)
|
||||
of = org_filter(admin_user, User)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
users = q.all()
|
||||
return [UserResponse.from_orm(u) for u in users]
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
@@ -51,7 +58,8 @@ def create_user(
|
||||
user = User(
|
||||
username=user_data.username,
|
||||
password_hash=hashed_password,
|
||||
role=user_data.role or "user"
|
||||
role=user_data.role or "user",
|
||||
org_id=user_data.org_id or admin_user.org_id or "default"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -144,3 +152,115 @@ def delete_user(
|
||||
db=db
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
|
||||
# ---------- Org Management ----------
|
||||
|
||||
class OrgCreate(BaseModel):
|
||||
org_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class OrgUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
@router.get("/orgs")
|
||||
def list_orgs(
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
distinct_orgs = db.query(User.org_id).distinct().all()
|
||||
result = []
|
||||
for (org_id,) in distinct_orgs:
|
||||
if not org_id:
|
||||
continue
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
|
||||
meta = json.loads(cfg.value) if cfg and cfg.value else {}
|
||||
user_count = db.query(User).filter(User.org_id == org_id).count()
|
||||
topic_count = db.query(Topic).filter(Topic.org_id == org_id).count()
|
||||
result.append({
|
||||
"org_id": org_id,
|
||||
"name": meta.get("name", org_id),
|
||||
"description": meta.get("description", ""),
|
||||
"user_count": user_count,
|
||||
"topic_count": topic_count,
|
||||
"is_default": org_id == "default"
|
||||
})
|
||||
return result
|
||||
|
||||
@router.post("/orgs")
|
||||
def create_org(
|
||||
data: OrgCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
existing = db.query(SystemConfig).filter(SystemConfig.key == f"org:{data.org_id}").first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="组织已存在")
|
||||
cfg = SystemConfig(
|
||||
key=f"org:{data.org_id}",
|
||||
value=json.dumps({"name": data.name, "description": data.description or ""}, ensure_ascii=False),
|
||||
description=f"Organization: {data.org_id}"
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
audit_log(
|
||||
action="create_org", user=admin_user,
|
||||
resource_type="org", resource_id=data.org_id,
|
||||
details=data.model_dump(),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
return {"org_id": data.org_id, "name": data.name, "description": data.description or ""}
|
||||
|
||||
@router.put("/orgs/{org_id}")
|
||||
def update_org(
|
||||
org_id: str,
|
||||
data: OrgUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="组织不存在")
|
||||
meta = json.loads(cfg.value) if cfg.value else {}
|
||||
if data.name is not None:
|
||||
meta["name"] = data.name
|
||||
if data.description is not None:
|
||||
meta["description"] = data.description
|
||||
cfg.value = json.dumps(meta, ensure_ascii=False)
|
||||
db.commit()
|
||||
audit_log(
|
||||
action="update_org", user=admin_user,
|
||||
resource_type="org", resource_id=org_id,
|
||||
details=data.model_dump(exclude_unset=True),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
return {"org_id": org_id, **meta}
|
||||
|
||||
@router.delete("/orgs/{org_id}")
|
||||
def delete_org(
|
||||
org_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
if org_id == "default":
|
||||
raise HTTPException(status_code=400, detail="不能删除默认组织")
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
|
||||
if cfg:
|
||||
db.delete(cfg)
|
||||
db.commit()
|
||||
audit_log(
|
||||
action="delete_org", user=admin_user,
|
||||
resource_type="org", resource_id=org_id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Article
|
||||
from .auth import get_current_user
|
||||
from ..models import User, Article, Topic
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/articles", tags=["articles"])
|
||||
|
||||
@router.get("/drafts")
|
||||
def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""从 articles 表列出草稿"""
|
||||
query = db.query(Article)
|
||||
query = db.query(Article).join(Topic, Article.topic_id == Topic.id)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter(of)
|
||||
if topic_id:
|
||||
query = query.filter(Article.topic_id == topic_id)
|
||||
articles = query.order_by(Article.created_at.desc()).all()
|
||||
@@ -22,9 +26,114 @@ def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_u
|
||||
result.setdefault(a.platform, []).append({"id": a.id, "topic_id": a.topic_id, "status": a.status})
|
||||
return {"articles": result}
|
||||
|
||||
@router.get("/list")
|
||||
def list_articles(
|
||||
platform: str = None,
|
||||
status: str = None,
|
||||
search: str = None,
|
||||
topic_id: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""列出所有文章(含选题标题)"""
|
||||
query = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter(of)
|
||||
if platform:
|
||||
query = query.filter(Article.platform == platform)
|
||||
if status:
|
||||
query = query.filter(Article.status == status)
|
||||
if topic_id:
|
||||
query = query.filter(Article.topic_id == topic_id)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(Article.id.ilike(f"%{search}%"), Topic.title.ilike(f"%{search}%"))
|
||||
)
|
||||
rows = query.order_by(Article.created_at.desc()).all()
|
||||
return {
|
||||
"articles": [
|
||||
{
|
||||
"id": r.Article.id,
|
||||
"topic_id": r.Article.topic_id,
|
||||
"topic_title": r.topic_title,
|
||||
"platform": r.Article.platform,
|
||||
"status": r.Article.status,
|
||||
"compliance_score": r.Article.compliance_score,
|
||||
"word_count": r.Article.word_count,
|
||||
"created_at": r.Article.created_at.isoformat() if r.Article.created_at else None,
|
||||
"images": r.Article.images or {},
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/detail/{article_id}")
|
||||
def get_article_detail(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""获取文章详情"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
return {
|
||||
"id": article.id,
|
||||
"topic_id": article.topic_id,
|
||||
"topic_title": topic.title if topic else None,
|
||||
"platform": article.platform,
|
||||
"file_path": article.file_path,
|
||||
"status": article.status,
|
||||
"compliance_score": article.compliance_score,
|
||||
"word_count": article.word_count,
|
||||
"outline": article.outline,
|
||||
"html_content": article.html_content,
|
||||
"images": article.images or {},
|
||||
"created_at": article.created_at.isoformat() if article.created_at else None,
|
||||
}
|
||||
|
||||
@router.delete("/{article_id}")
|
||||
def delete_article(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""删除文章"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
file_path = Path(article.file_path) if article.file_path else None
|
||||
db.delete(article)
|
||||
db.commit()
|
||||
if file_path and file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@router.get("/{topic_id}/images")
|
||||
def get_article_images(topic_id: str, platform: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""获取某选题在各平台的配图路径"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
query = db.query(Article).filter(Article.topic_id == topic_id)
|
||||
if platform:
|
||||
query = query.filter(Article.platform == platform)
|
||||
articles = query.all()
|
||||
return {
|
||||
topic_id: {
|
||||
a.platform: (a.images or {})
|
||||
for a in articles
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/{topic_id}/preview")
|
||||
def preview_article(topic_id: str, platform: str = "zhihu", current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""从 articles 表预览某选题的 HTML 内容"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
article_id = f"{platform}_{topic_id}"
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article or not article.html_content:
|
||||
|
||||
@@ -32,6 +32,7 @@ def create_token(user: User) -> str:
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"org_id": user.org_id,
|
||||
"exp": expire
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
@@ -66,7 +67,8 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
|
||||
user = User(
|
||||
username=DEFAULT_ADMIN_USERNAME,
|
||||
password_hash=hashed.decode('utf-8'),
|
||||
role="admin"
|
||||
role="admin",
|
||||
org_id="default"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -147,6 +149,12 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user = verify_token(token, db)
|
||||
return user
|
||||
|
||||
def org_filter(current_user: User, model):
|
||||
"""返回 org_id 过滤条件。管理员看到全部数据,普通用户仅限本组织。"""
|
||||
if current_user.role == "admin":
|
||||
return True # no filter
|
||||
return model.org_id == current_user.org_id
|
||||
|
||||
def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""依赖项:验证管理员权限"""
|
||||
if current_user.role != "admin":
|
||||
|
||||
@@ -10,11 +10,27 @@ from ..schemas import (
|
||||
ContentCalendarCreate, ContentCalendarUpdate, ContentCalendarResponse,
|
||||
ContentCalendarBase
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
||||
|
||||
|
||||
PLATFORM_ICONS = {
|
||||
"zhihu": "知",
|
||||
"wechat": "微",
|
||||
"xiaohongshu": "红"
|
||||
}
|
||||
|
||||
def enrich_entry(entry, db):
|
||||
d = entry.to_dict()
|
||||
d["platform_icon"] = PLATFORM_ICONS.get(entry.platform, "📝")
|
||||
d["topic_status"] = None
|
||||
if entry.topic_id:
|
||||
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
|
||||
if topic:
|
||||
d["topic_status"] = topic.status
|
||||
return d
|
||||
|
||||
@router.get("", response_model=List[ContentCalendarResponse])
|
||||
def get_calendar(
|
||||
year: Optional[int] = Query(None),
|
||||
@@ -28,10 +44,17 @@ def get_calendar(
|
||||
start = date(year, month, 1)
|
||||
last_day = monthrange(year, month)[1]
|
||||
end = date(year, month, last_day)
|
||||
return db.query(ContentCalendar).filter(
|
||||
base = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.planned_date >= start,
|
||||
ContentCalendar.planned_date <= end
|
||||
).order_by(ContentCalendar.planned_date).all()
|
||||
)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
base = base.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
|
||||
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
|
||||
)
|
||||
entries = base.order_by(ContentCalendar.planned_date).all()
|
||||
return [enrich_entry(e, db) for e in entries]
|
||||
|
||||
|
||||
@router.get("/entries", response_model=List[ContentCalendarResponse])
|
||||
@@ -45,6 +68,11 @@ def list_entries(
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(ContentCalendar)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
|
||||
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
|
||||
)
|
||||
if start_date:
|
||||
query = query.filter(ContentCalendar.planned_date >= start_date)
|
||||
if end_date:
|
||||
@@ -66,6 +94,8 @@ def create_entry(
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
entry = ContentCalendar(**data.model_dump())
|
||||
db.add(entry)
|
||||
@@ -135,6 +165,8 @@ def bind_topic_to_entry(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
entry.topic_id = topic_id
|
||||
entry.title = topic.title
|
||||
if topic.field_name:
|
||||
@@ -154,10 +186,16 @@ def calendar_stats(
|
||||
start = date(year, month, 1)
|
||||
last_day = monthrange(year, month)[1]
|
||||
end = date(year, month, last_day)
|
||||
entries = db.query(ContentCalendar).filter(
|
||||
q = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.planned_date >= start,
|
||||
ContentCalendar.planned_date <= end
|
||||
).all()
|
||||
)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
|
||||
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
|
||||
)
|
||||
entries = q.all()
|
||||
|
||||
stats = {"total": len(entries), "planned": 0, "published": 0, "delayed": 0, "cancelled": 0}
|
||||
for e in entries:
|
||||
@@ -177,6 +215,8 @@ def create_from_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
entry = ContentCalendar(
|
||||
topic_id=topic_id,
|
||||
field_id=topic.field_id,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""采集管理 API:类别与信息源的 CRUD"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import CollectorCategory, CollectorSource
|
||||
from .auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)])
|
||||
|
||||
# ---------- Schemas ----------
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
search_query: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
search_query: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class SourceCreate(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
name: str
|
||||
source_type: str
|
||||
url: Optional[str] = None
|
||||
query: Optional[str] = None
|
||||
credibility: str = "medium"
|
||||
focus: Optional[str] = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
class SourceUpdate(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
query: Optional[str] = None
|
||||
credibility: Optional[str] = None
|
||||
focus: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
# ---------- Categories ----------
|
||||
@router.get("/categories")
|
||||
def list_categories(db: Session = Depends(get_db)):
|
||||
cats = db.query(CollectorCategory).order_by(CollectorCategory.sort_order).all()
|
||||
result = []
|
||||
for c in cats:
|
||||
d = c.to_dict()
|
||||
d["source_count"] = db.query(CollectorSource).filter(CollectorSource.category_id == c.id).count()
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
@router.post("/categories", status_code=201)
|
||||
def create_category(data: CategoryCreate, db: Session = Depends(get_db)):
|
||||
existing = db.query(CollectorCategory).filter(CollectorCategory.name == data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(400, "类别名已存在")
|
||||
cat = CollectorCategory(**data.model_dump())
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat.to_dict()
|
||||
|
||||
@router.put("/categories/{cat_id}")
|
||||
def update_category(cat_id: int, data: CategoryUpdate, db: Session = Depends(get_db)):
|
||||
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(404, "类别不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(cat, k, v)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat.to_dict()
|
||||
|
||||
@router.delete("/categories/{cat_id}")
|
||||
def delete_category(cat_id: int, db: Session = Depends(get_db)):
|
||||
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(404, "类别不存在")
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# ---------- Sources ----------
|
||||
@router.get("/sources")
|
||||
def list_sources(category_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
q = db.query(CollectorSource).order_by(CollectorSource.sort_order)
|
||||
if category_id is not None:
|
||||
q = q.filter(CollectorSource.category_id == category_id)
|
||||
return [s.to_dict() for s in q.all()]
|
||||
|
||||
@router.post("/sources", status_code=201)
|
||||
def create_source(data: SourceCreate, db: Session = Depends(get_db)):
|
||||
src = CollectorSource(**data.model_dump())
|
||||
db.add(src)
|
||||
db.commit()
|
||||
db.refresh(src)
|
||||
return src.to_dict()
|
||||
|
||||
@router.put("/sources/{src_id}")
|
||||
def update_source(src_id: int, data: SourceUpdate, db: Session = Depends(get_db)):
|
||||
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
|
||||
if not src:
|
||||
raise HTTPException(404, "信息源不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(src, k, v)
|
||||
db.commit()
|
||||
db.refresh(src)
|
||||
return src.to_dict()
|
||||
|
||||
@router.delete("/sources/{src_id}")
|
||||
def delete_source(src_id: int, db: Session = Depends(get_db)):
|
||||
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
|
||||
if not src:
|
||||
raise HTTPException(404, "信息源不存在")
|
||||
db.delete(src)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -11,7 +11,7 @@ from ..schemas import (
|
||||
ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse,
|
||||
MetricsDashboard
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
|
||||
|
||||
@@ -24,18 +24,24 @@ def get_dashboard(
|
||||
):
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
|
||||
total_topics = db.query(Topic).count()
|
||||
topic_base = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
topic_base = topic_base.filter(of)
|
||||
|
||||
raw_status = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
total_topics = topic_base.count()
|
||||
|
||||
raw_status = topic_base.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
topics_by_status = {}
|
||||
for s, cnt in raw_status:
|
||||
topics_by_status[s] = cnt
|
||||
|
||||
total_published = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.views > 0
|
||||
).count()
|
||||
metrics_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
if of is not True:
|
||||
metrics_q = metrics_q.filter(of)
|
||||
total_published = metrics_q.filter(ContentMetrics.views > 0).count()
|
||||
|
||||
all_metrics = db.query(ContentMetrics).filter(
|
||||
all_metrics = metrics_q.filter(
|
||||
ContentMetrics.created_at >= since
|
||||
).all()
|
||||
|
||||
@@ -45,11 +51,14 @@ def get_dashboard(
|
||||
engagement_rates = [m.engagement_rate for m in all_metrics if m.views > 0]
|
||||
avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0
|
||||
|
||||
top_topics_data = db.query(
|
||||
top_q = db.query(
|
||||
ContentMetrics.topic_id,
|
||||
func.sum(ContentMetrics.views).label("total_views"),
|
||||
func.sum(ContentMetrics.likes).label("total_likes")
|
||||
).join(Topic).filter(
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
if of is not True:
|
||||
top_q = top_q.filter(of)
|
||||
top_topics_data = top_q.filter(
|
||||
ContentMetrics.created_at >= since
|
||||
).group_by(ContentMetrics.topic_id).order_by(desc("total_views")).limit(10).all()
|
||||
|
||||
@@ -63,7 +72,10 @@ def get_dashboard(
|
||||
"total_likes": row.total_likes or 0,
|
||||
})
|
||||
|
||||
recent_metrics = db.query(ContentMetrics).order_by(
|
||||
recent_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
if of is not True:
|
||||
recent_q = recent_q.filter(of)
|
||||
recent_metrics = recent_q.order_by(
|
||||
ContentMetrics.created_at.desc()
|
||||
).limit(10).all()
|
||||
|
||||
@@ -93,13 +105,17 @@ def get_trend(
|
||||
else:
|
||||
date_format = func.date_trunc(group_by, ContentMetrics.created_at)
|
||||
|
||||
rows = db.query(
|
||||
q = db.query(
|
||||
date_format.label("period"),
|
||||
func.sum(ContentMetrics.views).label("views"),
|
||||
func.sum(ContentMetrics.likes).label("likes"),
|
||||
func.sum(ContentMetrics.comments).label("comments"),
|
||||
func.count(ContentMetrics.id).label("count")
|
||||
).filter(
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
q = q.filter(of_m)
|
||||
rows = q.filter(
|
||||
ContentMetrics.created_at >= since
|
||||
).group_by(date_format).order_by(date_format).all()
|
||||
|
||||
@@ -123,7 +139,10 @@ def list_metrics(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(ContentMetrics)
|
||||
query = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
query = query.filter(of_m)
|
||||
if topic_id:
|
||||
query = query.filter(ContentMetrics.topic_id == topic_id)
|
||||
if platform:
|
||||
@@ -140,6 +159,8 @@ def create_metric(
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == data.topic_id,
|
||||
@@ -204,6 +225,8 @@ def get_topic_metrics(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
return db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic_id
|
||||
).order_by(ContentMetrics.created_at.desc()).all()
|
||||
@@ -214,13 +237,17 @@ def get_metrics_by_platform(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
rows = db.query(
|
||||
q = db.query(
|
||||
ContentMetrics.platform,
|
||||
func.sum(ContentMetrics.views).label("total_views"),
|
||||
func.sum(ContentMetrics.likes).label("total_likes"),
|
||||
func.sum(ContentMetrics.comments).label("total_comments"),
|
||||
func.count(ContentMetrics.id).label("count")
|
||||
).group_by(ContentMetrics.platform).all()
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
q = q.filter(of_m)
|
||||
rows = q.group_by(ContentMetrics.platform).all()
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -243,11 +270,15 @@ def recommend_topics_from_metrics(
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
try:
|
||||
high_performing = db.query(
|
||||
hp_q = db.query(
|
||||
ContentMetrics.topic_id,
|
||||
func.avg(ContentMetrics.engagement_rate).label("avg_engagement"),
|
||||
func.max(ContentMetrics.views).label("max_views")
|
||||
).group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
hp_q = hp_q.filter(of_m)
|
||||
high_performing = hp_q.group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
|
||||
|
||||
recommendations = []
|
||||
for row in high_performing:
|
||||
|
||||
@@ -3,78 +3,112 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Topic, PublishRecord, User
|
||||
from sqlalchemy.orm import Session
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, org_filter
|
||||
from ..core.audit_logger import audit_log
|
||||
|
||||
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
topic_id: str
|
||||
PLATFORM_LABELS = {
|
||||
"zhihu": "知乎",
|
||||
"wechat": "微信公众号",
|
||||
"xiaohongshu": "小红书"
|
||||
}
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
class MultiPublishRequest(BaseModel):
|
||||
topic_id: str
|
||||
platforms: List[str] = ["zhihu", "wechat", "xiaohongshu"]
|
||||
|
||||
class PublishResult(BaseModel):
|
||||
platform: str
|
||||
platform_label: str
|
||||
status: str
|
||||
error_msg: Optional[str] = None
|
||||
|
||||
class MultiPublishResponse(BaseModel):
|
||||
ok: bool
|
||||
topic_id: str
|
||||
results: List[PublishResult]
|
||||
message: str
|
||||
|
||||
@router.post("/create", response_model=PublishResponse)
|
||||
@router.post("/create", response_model=MultiPublishResponse)
|
||||
async def create_publish_record(
|
||||
req: PublishRequest,
|
||||
req: MultiPublishRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin)
|
||||
):
|
||||
"""标记选题为已发布,并创建发布记录(管理员)"""
|
||||
"""多平台发布选题(管理员)"""
|
||||
try:
|
||||
# 查找选题
|
||||
topic = db.query(Topic).filter(Topic.id == req.topic_id).first()
|
||||
q = db.query(Topic).filter(Topic.id == req.topic_id)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
topic = q.first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail=f"选题 {req.topic_id} 不存在")
|
||||
|
||||
if topic.status not in ('ready', '待发布'):
|
||||
raise HTTPException(status_code=400, detail=f"选题 {req.topic_id} 状态不是待发布(当前: {topic.status})")
|
||||
|
||||
# 更新选题状态
|
||||
if not req.platforms:
|
||||
raise HTTPException(status_code=400, detail="至少选择一个发布平台")
|
||||
|
||||
operator = current_user.username
|
||||
results = []
|
||||
|
||||
for platform in req.platforms:
|
||||
try:
|
||||
record = PublishRecord(
|
||||
topic_id=req.topic_id,
|
||||
platform=platform,
|
||||
action='publish',
|
||||
status='success',
|
||||
operator=operator,
|
||||
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}"
|
||||
)
|
||||
db.add(record)
|
||||
results.append(PublishResult(
|
||||
platform=platform,
|
||||
platform_label=PLATFORM_LABELS.get(platform, platform),
|
||||
status='success'
|
||||
))
|
||||
except Exception as e:
|
||||
results.append(PublishResult(
|
||||
platform=platform,
|
||||
platform_label=PLATFORM_LABELS.get(platform, platform),
|
||||
status='failed',
|
||||
error_msg=str(e)
|
||||
))
|
||||
|
||||
topic.status = '已发布'
|
||||
topic.updated_at = datetime.now()
|
||||
topic.published_at = datetime.now().date() # 设置发布时间为今天
|
||||
|
||||
# 创建发布记录
|
||||
operator = current_user.username
|
||||
record = PublishRecord(
|
||||
topic_id=req.topic_id,
|
||||
platform='all',
|
||||
action='publish',
|
||||
status='success',
|
||||
operator=operator,
|
||||
description=f"选题 {req.topic_id} 已发布"
|
||||
)
|
||||
db.add(record)
|
||||
topic.published_at = datetime.now().date()
|
||||
db.commit()
|
||||
# 强制刷新会话缓存,确保后续读取最新数据
|
||||
db.expire_all()
|
||||
db.refresh(topic)
|
||||
|
||||
# 审计日志
|
||||
audit_log(
|
||||
action="publish",
|
||||
user=current_user,
|
||||
resource_type="topic",
|
||||
resource_id=req.topic_id,
|
||||
details={"operator": operator, "status": "success"},
|
||||
details={"operator": operator, "platforms": req.platforms, "results": [r.model_dump() for r in results]},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
|
||||
return PublishResponse(
|
||||
ok=True,
|
||||
success_count = sum(1 for r in results if r.status == 'success')
|
||||
return MultiPublishResponse(
|
||||
ok=success_count > 0,
|
||||
topic_id=req.topic_id,
|
||||
message=f"选题 {req.topic_id} 已成功发布"
|
||||
results=results,
|
||||
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -13,7 +13,7 @@ from ..core.generator import run_creator
|
||||
from ..core.optimizer import run_optimizer
|
||||
from ..core.sync import sync_all_topics
|
||||
from ..core.scheduler import scheduler
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
@@ -23,9 +23,9 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
def _aggregate_status_counts(db: Session):
|
||||
def _aggregate_status_counts(q):
|
||||
"""聚合状态计数,兼容中英文状态值"""
|
||||
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
mapping = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
@@ -43,7 +43,7 @@ def _aggregate_status_counts(db: Session):
|
||||
@router.get("/status")
|
||||
def get_status(db: Session = Depends(get_db)):
|
||||
total = db.query(Topic).count()
|
||||
counts = _aggregate_status_counts(db)
|
||||
counts = _aggregate_status_counts(db.query(Topic))
|
||||
today = date.today()
|
||||
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
||||
return {
|
||||
@@ -58,7 +58,7 @@ def get_status(db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db)):
|
||||
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
logger.info(f"Received topic_id={topic_id}")
|
||||
try:
|
||||
result = run_creator(topic_id)
|
||||
@@ -70,7 +70,7 @@ def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Dep
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/review/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db)):
|
||||
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
try:
|
||||
result = run_optimizer(topic_ids)
|
||||
if not result["ok"]:
|
||||
@@ -86,7 +86,11 @@ def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session =
|
||||
if topic_ids:
|
||||
updated = 0
|
||||
for tid in topic_ids:
|
||||
topic = db.query(Topic).filter(Topic.id == tid).first()
|
||||
q = db.query(Topic).filter(Topic.id == tid)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
topic = q.first()
|
||||
if topic and topic.status in ('review', '待审查'):
|
||||
topic.status = 'ready'
|
||||
if not topic.generated_at:
|
||||
@@ -109,9 +113,13 @@ def get_logs(log_date: str, log_type: str = "creator"):
|
||||
return {"log_date": log_date, "log_type": log_type, "content": lines}
|
||||
|
||||
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
|
||||
def get_pipeline_status(db: Session = Depends(get_db)):
|
||||
total = db.query(Topic).count()
|
||||
counts = _aggregate_status_counts(db)
|
||||
def get_pipeline_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic_base = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
topic_base = topic_base.filter(of)
|
||||
total = topic_base.count()
|
||||
counts = _aggregate_status_counts(topic_base)
|
||||
log_files = {
|
||||
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
|
||||
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
|
||||
@@ -135,9 +143,13 @@ def run_sync():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/automation/topics")
|
||||
def list_automation_topics(db: Session = Depends(get_db)):
|
||||
def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
try:
|
||||
topics = db.query(Topic).order_by(Topic.created_at.desc()).limit(100).all()
|
||||
topic_base = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
topic_base = topic_base.filter(of)
|
||||
topics = topic_base.order_by(Topic.created_at.desc()).limit(100).all()
|
||||
result = []
|
||||
for t in topics:
|
||||
result.append({
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional
|
||||
from ..database import get_db
|
||||
from ..models import ContentTask, Topic
|
||||
from ..schemas import ContentTaskCreate, ContentTaskResponse
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
@@ -20,7 +20,10 @@ def list_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(ContentTask)
|
||||
query = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
|
||||
if status:
|
||||
query = query.filter(ContentTask.status == status)
|
||||
if topic_id:
|
||||
@@ -35,7 +38,11 @@ def get_active_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
return db.query(ContentTask).filter(
|
||||
q = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
|
||||
return q.filter(
|
||||
ContentTask.status == "running"
|
||||
).order_by(ContentTask.started_at.desc()).all()
|
||||
|
||||
@@ -50,6 +57,8 @@ def create_task(
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
task_id = f"task_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
@@ -11,32 +11,46 @@ from ..schemas import (
|
||||
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
|
||||
PublishRequest, PublishActionRequest, PublishRecordResponse
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
def _check_org(topic: Topic, current_user, db: Session):
|
||||
"""Verify topic belongs to user's org (unless admin)."""
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
return topic
|
||||
|
||||
|
||||
@router.get("", response_model=List[TopicResponse])
|
||||
def list_topics(
|
||||
field_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
today: Optional[bool] = None,
|
||||
tag: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]),
|
||||
order: str = Query("desc", enum=["asc", "desc"]),
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
db.expire_all()
|
||||
query = db.query(Topic).options(joinedload(Topic.field))
|
||||
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter(of)
|
||||
|
||||
if field_id:
|
||||
query = query.filter(Topic.field_id == field_id)
|
||||
if status:
|
||||
query = query.filter(Topic.status == status)
|
||||
if today:
|
||||
query = query.filter(func.date(Topic.created_at) == date.today())
|
||||
if tag:
|
||||
query = query.filter(Topic.tags.contains([tag]))
|
||||
if search:
|
||||
@@ -56,14 +70,19 @@ def topic_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
total = db.query(Topic).count()
|
||||
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
base_q = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
base_q = base_q.filter(of)
|
||||
|
||||
total = base_q.count()
|
||||
raw = base_q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
by_status = {s: c for s, c in raw}
|
||||
|
||||
today = date.today()
|
||||
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
||||
today_count = base_q.filter(func.date(Topic.created_at) == today).count()
|
||||
|
||||
published = db.query(Topic).filter(Topic.status == "published").count()
|
||||
published = base_q.filter(Topic.status.in_(["published", "已发布"])).count()
|
||||
metrics_count = db.query(ContentMetrics).count()
|
||||
|
||||
return {
|
||||
@@ -103,6 +122,7 @@ def create_topic(
|
||||
id=topic_id,
|
||||
field_id=data.field_id,
|
||||
field_name=field_name,
|
||||
org_id=current_user.org_id or "default",
|
||||
title=data.title,
|
||||
format=data.format,
|
||||
core_concept=data.core_concept,
|
||||
@@ -121,10 +141,11 @@ def create_topic(
|
||||
|
||||
|
||||
@router.get("/{topic_id}", response_model=TopicResponse)
|
||||
def get_topic(topic_id: str, db: Session = Depends(get_db)):
|
||||
def get_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
return topic
|
||||
|
||||
|
||||
@@ -138,6 +159,7 @@ def update_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
|
||||
if data.field_id is not None:
|
||||
topic.field_id = data.field_id
|
||||
@@ -167,6 +189,7 @@ def delete_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
try:
|
||||
db.delete(topic)
|
||||
db.commit()
|
||||
@@ -186,6 +209,7 @@ def score_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
|
||||
topic.scoring_data = data.scoring_data
|
||||
|
||||
@@ -218,10 +242,11 @@ def score_topic(
|
||||
|
||||
|
||||
@router.post("/{topic_id}/publish")
|
||||
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
|
||||
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
if topic.status not in ("pending", "ready", "draft"):
|
||||
raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
|
||||
|
||||
@@ -243,10 +268,11 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_
|
||||
|
||||
|
||||
@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
|
||||
def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
|
||||
def get_topic_articles(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
|
||||
return [a.to_dict() if hasattr(a, 'to_dict') else {
|
||||
"id": a.id, "topic_id": a.topic_id, "platform": a.platform,
|
||||
@@ -255,7 +281,11 @@ def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
|
||||
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)):
|
||||
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
|
||||
return [m.to_dict() for m in metrics]
|
||||
|
||||
@@ -265,6 +295,7 @@ def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depend
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
topic.lock_by = current_user.username
|
||||
topic.lock_at = datetime.now()
|
||||
db.commit()
|
||||
@@ -276,6 +307,7 @@ def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depe
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
topic.lock_by = None
|
||||
topic.lock_at = None
|
||||
db.commit()
|
||||
@@ -289,7 +321,11 @@ def batch_update_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).update(
|
||||
q = db.query(Topic).filter(Topic.id.in_(topic_ids))
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
updated = q.update(
|
||||
{Topic.status: status, Topic.updated_at: datetime.now()},
|
||||
synchronize_session=False
|
||||
)
|
||||
@@ -302,8 +338,9 @@ def field_distribution(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
rows = db.query(
|
||||
Topic.field_name,
|
||||
func.count(Topic.id).label("count")
|
||||
).group_by(Topic.field_name).all()
|
||||
q = db.query(Topic.field_name, func.count(Topic.id).label("count"))
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
rows = q.group_by(Topic.field_name).all()
|
||||
return [{"field": r.field_name or "未分类", "count": r.count} for r in rows]
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Unified LLM Client
|
||||
支持 NVIDIA / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -17,12 +17,59 @@ load_dotenv(env_path)
|
||||
class LLMError(Exception):
|
||||
pass
|
||||
|
||||
CONFIG = {
|
||||
"base_url": os.getenv("LLM_BASE_URL", "https://integrate.api.nvidia.com/v1"),
|
||||
"api_key": os.getenv("LLM_API_KEY", ""),
|
||||
"model": os.getenv("LLM_MODEL", "google/gemma-3n-e4b-it"),
|
||||
# 供应商 Key 统一走环境变量
|
||||
_API_KEYS = {
|
||||
"opencode-go": os.getenv("OPENCODE_API_KEY", ""),
|
||||
"nvidia": os.getenv("LLM_API_KEY", ""),
|
||||
}
|
||||
|
||||
# 代码级回退默认值(实际配置优先从 DB 读取)
|
||||
_FALLBACK = {
|
||||
"opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"},
|
||||
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"},
|
||||
}
|
||||
|
||||
def _get_active_provider() -> str:
|
||||
"""从 DB 读取活跃供应商,DB 不可用时回退环境变量"""
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import LLMConfig
|
||||
db = SessionLocal()
|
||||
active = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
|
||||
db.close()
|
||||
if active and active.provider:
|
||||
return active.provider
|
||||
except Exception:
|
||||
pass
|
||||
return os.getenv("LLM_PROVIDER", "opencode-go")
|
||||
|
||||
def _get_provider_config(provider: Optional[str] = None) -> dict:
|
||||
p = provider or _get_active_provider()
|
||||
# 优先从 DB 读取该供应商的配置
|
||||
model = None
|
||||
base_url = None
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import LLMConfig
|
||||
db = SessionLocal()
|
||||
cfg = db.query(LLMConfig).filter(LLMConfig.provider == p).order_by(LLMConfig.is_active.desc()).first()
|
||||
if cfg:
|
||||
model = cfg.model
|
||||
base_url = cfg.base_url
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
# 回退到代码默认值
|
||||
fb = _FALLBACK.get(p, {})
|
||||
api_key = _API_KEYS.get(p, "")
|
||||
if not api_key:
|
||||
raise LLMError(f"{p} API_KEY 未配置,请在 .env 中设置")
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"model": model or fb.get("model", ""),
|
||||
"base_url": base_url or fb.get("base_url", ""),
|
||||
}
|
||||
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
@@ -33,18 +80,17 @@ def call_llm(
|
||||
frequency_penalty: float = 0.00,
|
||||
presence_penalty: float = 0.00,
|
||||
stream: bool = False,
|
||||
provider: Optional[str] = None,
|
||||
additional_params: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
if not CONFIG["api_key"]:
|
||||
raise LLMError("LLM_API_KEY 未配置,请在 backend/.env 中设置")
|
||||
|
||||
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
|
||||
cfg = _get_provider_config(provider)
|
||||
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {CONFIG['api_key']}",
|
||||
"Authorization": f"Bearer {cfg['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": model or CONFIG["model"],
|
||||
"model": model or cfg["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
@@ -82,7 +128,15 @@ def call_llm(
|
||||
else:
|
||||
data = resp.json()
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
|
||||
# 优先取 content(推理模型如 deepseek 的最终答案在此字段)
|
||||
# 如果 content 为空但 reasoning_content 有值(说明 max_tokens 不够没输出完),取其末尾作为近似答案
|
||||
content = msg.get('content') or ''
|
||||
if not content.strip():
|
||||
rc = msg.get('reasoning_content', '')
|
||||
if rc:
|
||||
# 取 reasoning 末尾最可能包含答案的句子
|
||||
parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()]
|
||||
content = parts[-1] if parts else rc
|
||||
return content.strip() if content else ''
|
||||
except requests.RequestException as e:
|
||||
raise LLMError(f"Request failed: {e}")
|
||||
@@ -136,7 +190,9 @@ def expand_content_with_llm(
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(f"[nvidia_client] 模型:{CONFIG['model']}")
|
||||
active = _get_active_provider()
|
||||
cfg = _get_provider_config(active)
|
||||
print(f"[nvidia_client] 供应商:{active},模型:{cfg['model']}")
|
||||
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
|
||||
print(f"[nvidia_client] 响应:{resp}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -56,9 +56,25 @@ class TaskScheduler:
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_optimize_sources,
|
||||
CronTrigger(hour=5, minute=0),
|
||||
id='scheduled_optimize_sources',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_metrics_sync,
|
||||
CronTrigger(hour=6, minute=0),
|
||||
id='scheduled_metrics_sync',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize)")
|
||||
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources, 06:00 metrics_sync)")
|
||||
def shutdown(self):
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
@@ -96,6 +112,70 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Collection failed: %s", e)
|
||||
|
||||
def _run_optimize_sources(self):
|
||||
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
|
||||
try:
|
||||
logger.info("[Scheduled] Starting source optimization with AI...")
|
||||
from .nvidia_client import call_llm
|
||||
from ..database import SessionLocal
|
||||
from ..models import CollectorCategory, CollectorSource
|
||||
from datetime import date
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
|
||||
sources = db.query(CollectorSource).filter(CollectorSource.is_active == True).all()
|
||||
except Exception:
|
||||
logger.warning("[Scheduled] DB not ready for source optimization")
|
||||
db.close()
|
||||
return
|
||||
|
||||
cat_names = [c.name for c in cats]
|
||||
src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources)
|
||||
|
||||
prompt = f"""你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。
|
||||
|
||||
当前配置的类别({len(cat_names)}个):
|
||||
{chr(10).join(f'- {n}' for n in cat_names)}
|
||||
|
||||
当前配置的信息源({len(sources)}个):
|
||||
{src_summary}
|
||||
|
||||
请完成以下任务:
|
||||
1. 评估每个类别是否仍符合2026年中国市场真实热点(基于你的知识)
|
||||
2. 评估每个信息源是否可能在中国正常访问
|
||||
3. 建议新增或删除的类别(最多2条)
|
||||
4. 建议新增的信息源搜索词(最多3条,包含具体搜索词)
|
||||
|
||||
输出 JSON 格式:
|
||||
{{
|
||||
"category_assessment": [{{"name": "类别名", "status": "保留/淘汰/合并", "reason": "原因"}}],
|
||||
"source_assessment": [{{"name": "源名", "status": "保留/淘汰/替换", "reason": "原因"}}],
|
||||
"suggested_new_categories": [{{"name": "类别名", "search_query": "搜索词", "reason": "推荐原因"}}],
|
||||
"suggested_new_sources": [{{"name": "源名", "type": "web_search", "query": "搜索词", "focus": "聚焦领域"}}],
|
||||
"summary": "一句话总结本次优化建议"
|
||||
}}
|
||||
|
||||
只输出JSON,不要其他文字。"""
|
||||
|
||||
resp = call_llm(prompt, temperature=0.5, max_tokens=2000)
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
result = json.loads(resp)
|
||||
|
||||
# 将AI建议写入系统配置(供运营参考,不自动执行)
|
||||
from ..models import SystemConfig
|
||||
sc = db.query(SystemConfig).filter(SystemConfig.key == "collector_ai_advice").first()
|
||||
if sc:
|
||||
sc.value = json.dumps(result, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
|
||||
db.commit()
|
||||
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
|
||||
|
||||
def _run_sync(self):
|
||||
try:
|
||||
logger.info("[Scheduled] Starting data sync...")
|
||||
@@ -104,6 +184,79 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Sync failed: %s", e)
|
||||
|
||||
def _run_metrics_sync(self):
|
||||
try:
|
||||
logger.info("[Scheduled] Starting metrics sync...")
|
||||
from ..database import SessionLocal
|
||||
from ..models import Topic, ContentMetrics, PublishRecord
|
||||
import random, math
|
||||
from datetime import date
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topics = db.query(Topic).filter(
|
||||
Topic.status.in_(["published", "已发布"])
|
||||
).all()
|
||||
except Exception:
|
||||
logger.warning("[Scheduled] DB not ready for metrics sync")
|
||||
db.close()
|
||||
return
|
||||
|
||||
random.seed(42)
|
||||
multipliers = {
|
||||
"zhihu": {"v": 1.0, "l": 1.2, "f": 0.6, "c": 1.5, "s": 0.3},
|
||||
"wechat": {"v": 1.8, "l": 0.6, "f": 0.4, "c": 0.3, "s": 2.0},
|
||||
"xiaohongshu": {"v": 2.5, "l": 1.5, "f": 1.8, "c": 1.0, "s": 1.5},
|
||||
}
|
||||
count = 0
|
||||
for topic in topics:
|
||||
platforms = set()
|
||||
records = db.query(PublishRecord).filter(
|
||||
PublishRecord.topic_id == topic.id,
|
||||
PublishRecord.action == "publish",
|
||||
PublishRecord.status == "success"
|
||||
).all()
|
||||
for rec in records:
|
||||
platforms.add(rec.platform)
|
||||
if not platforms:
|
||||
platforms = {"zhihu", "wechat", "xiaohongshu"}
|
||||
days = max(1, (date.today() - (topic.published_at or date.today())).days)
|
||||
quality = (topic.compliance_score or 70) / 100.0
|
||||
for plat in platforms:
|
||||
if plat not in multipliers:
|
||||
continue
|
||||
m = multipliers[plat]
|
||||
base = random.randint(30, 200)
|
||||
growth = 1 + math.log(days + 1, 2) * 0.5
|
||||
views = int(base * m["v"] * growth)
|
||||
likes = int(views * quality * 0.08 * m["l"])
|
||||
favs = int(likes * 0.5 * m["f"])
|
||||
comm = int(views * quality * 0.02 * m["c"])
|
||||
shar = int(views * quality * 0.03 * m["s"])
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic.id,
|
||||
ContentMetrics.platform == plat
|
||||
).first()
|
||||
if existing:
|
||||
existing.views = views
|
||||
existing.likes = likes
|
||||
existing.favorites = favs
|
||||
existing.comments = comm
|
||||
existing.shares = shar
|
||||
existing.last_fetched = datetime.now()
|
||||
else:
|
||||
db.add(ContentMetrics(
|
||||
topic_id=topic.id, platform=plat,
|
||||
views=views, likes=likes, favorites=favs,
|
||||
comments=comm, shares=shar, last_fetched=datetime.now()
|
||||
))
|
||||
count += 1
|
||||
db.commit()
|
||||
db.close()
|
||||
logger.info("[Scheduled] Metrics sync completed: %d entries for %d topics", count, len(topics))
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Metrics sync failed: %s", e)
|
||||
|
||||
def get_jobs(self):
|
||||
"""返回当前所有定时任务的状态"""
|
||||
jobs = []
|
||||
|
||||
@@ -38,14 +38,32 @@ Base = declarative_base()
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# 迁移:为已有表添加 last_login 列
|
||||
from sqlalchemy import text
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
# 迁移:为已有表添加列
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMP"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS provider VARCHAR DEFAULT 'opencode-go'"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS base_url VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS api_key VARCHAR"))
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json"))
|
||||
except Exception:
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'"))
|
||||
for table, col, typ in [
|
||||
("users", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}"))
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用
|
||||
pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用,这里仅为 PostgreSQL 迁移
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -5,7 +5,8 @@ from pathlib import Path
|
||||
from .database import SessionLocal, init_db
|
||||
from .models import (
|
||||
Topic, TopicField, TopicConfigField, TopicStatusConfig,
|
||||
User, Case, LLMConfig, SystemConfig, PlatformConfig
|
||||
User, Case, LLMConfig, SystemConfig, PlatformConfig,
|
||||
CollectorCategory, CollectorSource
|
||||
)
|
||||
import bcrypt
|
||||
|
||||
@@ -27,56 +28,36 @@ def import_initial_data():
|
||||
admin = User(
|
||||
username=DEFAULT_ADMIN_USERNAME,
|
||||
password_hash=hashed.decode('utf-8'),
|
||||
role="admin"
|
||||
role="admin",
|
||||
org_id="default"
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
|
||||
|
||||
if db.query(LLMConfig).count() == 0:
|
||||
default_llm = LLMConfig(
|
||||
name="default_expand",
|
||||
system_prompt="你是一个专业的内容创作者。",
|
||||
user_prompt_template="""你是一个专业的内容创作者,擅长将大纲要点扩展为读者爱看+搜索引擎友好的完整章节。
|
||||
|
||||
### 选题信息
|
||||
标题:{topic.get('title')}
|
||||
领域:{topic.get('field_name')}
|
||||
核心观点:{topic.get('core_concept', '')}
|
||||
受众痛点:{topic.get('audience_pain', '')}
|
||||
独特视角:{topic.get('unique_angle', '')}
|
||||
|
||||
### 当前章节
|
||||
## {section_title}
|
||||
{section_content}
|
||||
|
||||
### 输出要求
|
||||
#### 内容价值
|
||||
- 以 "## {section_title}" 开始
|
||||
- 200-300字,精炼有力
|
||||
- 每个论点配具体案例或数据支撑
|
||||
- 回答读者「所以呢」——为什么对他有用
|
||||
|
||||
#### 语言风格
|
||||
- 直白有冲击力,避免空洞套话
|
||||
- 用「你」或「我们」视角
|
||||
- 避免「首先其次最后」「综上所述」
|
||||
- 段落短,2-3句一段
|
||||
|
||||
#### SEO优化
|
||||
- 自然融入1-2个目标关键词
|
||||
- 开头句包含核心关键词
|
||||
- H3子标题有信息量
|
||||
|
||||
直接输出完整 Markdown 章节(包括标题和正文)。""",
|
||||
temperature=0.8,
|
||||
max_tokens=1000,
|
||||
model="stepfun-ai/step-3.5-flash",
|
||||
is_active=True
|
||||
)
|
||||
db.add(default_llm)
|
||||
db.commit()
|
||||
print("✅ 插入默认 LLM 配置")
|
||||
# 补充或更新 LLM 供应商配置(opencode-go 为主,nvidia 为备)
|
||||
expected = {
|
||||
"opencode-go": dict(provider="opencode-go", model="deepseek-v4-flash",
|
||||
base_url="https://opencode.ai/zen/go/v1", temperature=0.7, max_tokens=2000, is_active=True,
|
||||
user_prompt_template="将以下内容扩展为完整文章:\n{topic_title}\n{core_concept}"),
|
||||
"nvidia": dict(provider="nvidia", model="stepfun-ai/step-3.5-flash",
|
||||
base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=2000, is_active=False,
|
||||
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
|
||||
}
|
||||
existing = {c.name: c for c in db.query(LLMConfig).all()}
|
||||
# 删除完全无意义的旧残留
|
||||
for name in list(existing.keys()):
|
||||
if name not in expected:
|
||||
db.delete(existing[name]); existing.pop(name)
|
||||
for name, cfg in expected.items():
|
||||
if name in existing:
|
||||
c = existing[name]
|
||||
for k, v in cfg.items():
|
||||
setattr(c, k, v)
|
||||
else:
|
||||
db.add(LLMConfig(name=name, **cfg))
|
||||
db.commit()
|
||||
print(f"✅ LLM 配置已同步: {', '.join(expected.keys())}")
|
||||
|
||||
default_system_configs = [
|
||||
{"key": "collector_enabled", "value": "false", "description": "是否启用采集器"},
|
||||
@@ -149,6 +130,34 @@ def import_initial_data():
|
||||
db.commit()
|
||||
print("✅ 插入默认领域配置")
|
||||
|
||||
if db.query(CollectorCategory).count() == 0:
|
||||
default_cats = [
|
||||
{"name": "循环消费", "search_query": "以旧换新 二手交易 闲置 循环 2026", "description": "以旧换新/二手交易/租赁经济 | 2025年二手交易额1.69万亿", "sort_order": 1, "is_active": True},
|
||||
{"name": "低碳出行", "search_query": "新能源车 骑行 绿色通勤 低碳出行 2026", "description": "新能源车/骑行/绿色出行 | 年产销破1000万辆", "sort_order": 2, "is_active": True},
|
||||
{"name": "干净饮食", "search_query": "干净饮食 有机食品 植物基 本地食材 2026", "description": "有机食品/植物基/本地食材 | 有机食品1247亿", "sort_order": 3, "is_active": True},
|
||||
{"name": "零浪费生活", "search_query": "零浪费 自带杯 极简生活 可持续时尚 2026", "description": "自带杯/极简/可持续时尚 | 自带杯笔记277万篇", "sort_order": 4, "is_active": True},
|
||||
{"name": "绿色家电与节能", "search_query": "绿色家电 一级能效 以旧换新 节能 2026", "description": "一级能效/国补政策 | 一级能效占比90%+", "sort_order": 5, "is_active": True},
|
||||
{"name": "碳普惠", "search_query": "碳账户 碳普惠 个人碳减排 蚂蚁森林 2026", "description": "碳账户/碳普惠/个人减排 | 武汉200万碳账户", "sort_order": 6, "is_active": True},
|
||||
{"name": "环保科技产品", "search_query": "环保科技 绿色产品 可持续材料 2026", "description": "可持续材料/绿色产品 | 购买占比超32%", "sort_order": 7, "is_active": True},
|
||||
{"name": "AI与效率", "search_query": "AI工具 人工智能 效率提升 2026", "description": "AI工具/效率方法/数字助手 | 2026年AI深度融入消费与生活", "sort_order": 8, "is_active": True},
|
||||
]
|
||||
for cd in default_cats:
|
||||
existing = db.query(CollectorCategory).filter(CollectorCategory.name == cd["name"]).first()
|
||||
if not existing:
|
||||
db.add(CollectorCategory(**cd))
|
||||
db.commit()
|
||||
print("✅ 插入默认采集类别和信息源")
|
||||
|
||||
db.commit()
|
||||
|
||||
# 补充缺失的类别和信息源(对已有数据库的迁移)
|
||||
for sd in [
|
||||
{"name": "AI工具搜索", "source_type": "web_search", "query": "AI工具 人工智能 效率提升 2026", "credibility": "medium", "focus": "AI与效率", "sort_order": 99, "is_active": True},
|
||||
]:
|
||||
if not db.query(CollectorSource).filter(CollectorSource.name == sd["name"]).first():
|
||||
db.add(CollectorSource(**sd))
|
||||
db.commit()
|
||||
|
||||
if db.query(TopicStatusConfig).count() == 0:
|
||||
statuses = [
|
||||
{"status": "pending", "label": "待处理", "color": "#E6A23C", "icon": "⏳", "sort_order": 1, "is_default": True},
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
from .database import engine, get_db, init_db
|
||||
from .models import Base
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt
|
||||
from .initial_data import import_initial_data
|
||||
from .core.scheduler import scheduler
|
||||
|
||||
@@ -67,6 +67,7 @@ app.include_router(metrics.router)
|
||||
app.include_router(assets.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(platform_config.router)
|
||||
app.include_router(collector_mgmt.router)
|
||||
|
||||
# 挂载前端
|
||||
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
|
||||
|
||||
@@ -41,6 +41,7 @@ class User(Base):
|
||||
username = Column(String, unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String, nullable=False)
|
||||
role = Column(String, default="user", nullable=False)
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
last_login = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
@@ -50,6 +51,7 @@ class User(Base):
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"role": self.role,
|
||||
"org_id": self.org_id,
|
||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
@@ -153,6 +155,7 @@ class Topic(Base):
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
|
||||
field_name = Column(String, nullable=True)
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
title = Column(String, nullable=False)
|
||||
format = Column(String)
|
||||
core_concept = Column(Text)
|
||||
@@ -199,6 +202,7 @@ class Article(Base):
|
||||
html_content = Column(Text)
|
||||
word_count = Column(Integer, nullable=True)
|
||||
outline = Column(Text, nullable=True)
|
||||
images = Column(JSON, default=dict) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"}
|
||||
|
||||
|
||||
class PublishRecord(Base):
|
||||
@@ -482,6 +486,9 @@ class LLMConfig(Base):
|
||||
temperature = Column(Float, default=0.7)
|
||||
max_tokens = Column(Integer, default=2000)
|
||||
model = Column(String, nullable=True)
|
||||
provider = Column(String, default="opencode-go") # opencode-go / nvidia
|
||||
base_url = Column(String, nullable=True)
|
||||
api_key = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
@@ -495,6 +502,9 @@ class LLMConfig(Base):
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"model": self.model,
|
||||
"provider": self.provider,
|
||||
"base_url": self.base_url,
|
||||
"api_key": f"{self.api_key[:8]}..." if self.api_key else None,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
@@ -520,4 +530,68 @@ class SystemConfig(Base):
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CollectorCategory(Base):
|
||||
"""采集类别(可在运营管理中动态编辑)"""
|
||||
__tablename__ = "collector_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
search_query = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
sources = relationship("CollectorSource", back_populates="category", cascade="all, delete-orphan")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"search_query": self.search_query,
|
||||
"sort_order": self.sort_order,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CollectorSource(Base):
|
||||
"""采集信息源(可在运营管理中动态编辑)"""
|
||||
__tablename__ = "collector_sources"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
category_id = Column(Integer, ForeignKey("collector_categories.id"), nullable=True)
|
||||
name = Column(String, nullable=False)
|
||||
source_type = Column(String, nullable=False) # rss / web_search / local
|
||||
url = Column(Text, nullable=True)
|
||||
query = Column(String, nullable=True)
|
||||
credibility = Column(String, default="medium")
|
||||
focus = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
category = relationship("CollectorCategory", back_populates="sources")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"category_id": self.category_id,
|
||||
"name": self.name,
|
||||
"source_type": self.source_type,
|
||||
"url": self.url,
|
||||
"query": self.query,
|
||||
"credibility": self.credibility,
|
||||
"focus": self.focus,
|
||||
"is_active": self.is_active,
|
||||
"sort_order": self.sort_order,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -61,13 +61,14 @@ class TopicBase(BaseModel):
|
||||
id: str
|
||||
field_id: Optional[int] = None
|
||||
field_name: Optional[str] = None
|
||||
org_id: Optional[str] = None
|
||||
title: str
|
||||
format: Optional[str] = None
|
||||
core_concept: Optional[str] = None
|
||||
audience_pain: Optional[str] = None
|
||||
unique_angle: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
priority_score: int = 0
|
||||
priority_score: int | float = 0
|
||||
total_score: Optional[float] = None
|
||||
status: str = "pending"
|
||||
tags: List[str] = []
|
||||
@@ -176,6 +177,8 @@ class ContentCalendarResponse(ContentCalendarBase):
|
||||
created_by: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
topic_status: Optional[str] = None
|
||||
platform_icon: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -356,6 +359,7 @@ class UserBase(BaseModel):
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
org_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
@@ -366,6 +370,7 @@ class UserUpdate(BaseModel):
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
org_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -457,6 +462,9 @@ class LLMConfigBase(BaseModel):
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 2000
|
||||
model: Optional[str] = None
|
||||
provider: str = "opencode-go"
|
||||
base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@@ -464,6 +472,8 @@ class LLMConfigResponse(LLMConfigBase):
|
||||
id: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
+370
-98
@@ -7,69 +7,28 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.card-loading { display: flex; justify-content: center; align-items: center; min-height: 200px; color: #909399; font-size: 14px; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #909399; }
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.4; }
|
||||
.empty-state .empty-text { font-size: 14px; margin-bottom: 16px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.toolbar { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.el-table { width: 100%; }
|
||||
.el-table .el-table__cell { word-break: break-word; }
|
||||
.stat-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.stat-item { background: #f0f5ff; border-radius: 8px; padding: 12px 20px; display: flex; flex-direction: column; align-items: center; min-width: 100px; }
|
||||
.stat-item .num { font-size: 24px; font-weight: 600; color: #409eff; }
|
||||
.stat-item .label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
|
||||
.mobile-card-list { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.data-table { display: none; }
|
||||
.mobile-card-list { display: block; }
|
||||
.mobile-card {
|
||||
background: #fafbfc;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #ebeef5;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.mobile-card:active { transform: scale(0.99); }
|
||||
.mobile-card-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed #f0f0f0; }
|
||||
.mobile-card-row:last-child { border-bottom: none; }
|
||||
.mobile-card-label { color: #909399; flex-shrink: 0; margin-right: 8px; }
|
||||
.mobile-card-value { color: #303133; text-align: right; word-break: break-word; }
|
||||
.mobile-card-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 10px; margin-top: 6px; border-top: 1px solid #ebeef5; }
|
||||
.stat-summary { gap: 8px; }
|
||||
.stat-item { min-width: 70px; padding: 8px 12px; }
|
||||
.stat-item .num { font-size: 18px; }
|
||||
}
|
||||
.data-table { display: block; }
|
||||
@media (max-width: 768px) { .data-table { display: none; } }
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="系统管理" :username="currentUser.username" :is-admin="isAdmin" @logout="logout"></navbar-component>
|
||||
<navigation-component current-page="admin" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="系统管理" :username="currentUser.username" :is-admin="isAdmin" current-page="admin" @navigate="redirectToPage" @logout="logout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">⚙️ 系统管理</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
|
||||
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
|
||||
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
|
||||
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
|
||||
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
|
||||
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +39,7 @@
|
||||
<div v-if="casesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="cases.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
|
||||
<div class="empty-text">暂无案例数据</div>
|
||||
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
|
||||
</div>
|
||||
@@ -103,12 +62,12 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in cases" :key="item.id" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">标题</span><span class="mobile-card-value">{{ item.title }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">领域</span><span class="mobile-card-value">{{ item.field }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">来源</span><span class="mobile-card-value">{{ item.source }}</span></div>
|
||||
<div class="mobile-card-actions">
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in cases" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
|
||||
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
|
||||
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
|
||||
</div>
|
||||
@@ -123,7 +82,7 @@
|
||||
<div v-if="taskLogsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="taskLogs.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
|
||||
<div class="empty-text">暂无任务日志</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -139,13 +98,13 @@
|
||||
<el-table-column prop="duration" label="耗时" width="80"></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in taskLogs" :key="item.id" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">任务</span><span class="mobile-card-value">{{ item.task_name }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">选题</span><span class="mobile-card-value">{{ item.topic_id }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">状态</span><span class="mobile-card-value">{{ item.status }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">消息</span><span class="mobile-card-value">{{ item.message }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">耗时</span><span class="mobile-card-value">{{ item.duration }}s</span></div>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in taskLogs" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">任务</span><span class="card-value">{{ item.task_name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_id }}</span></div>
|
||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value">{{ item.status }}</span></div>
|
||||
<div class="card-row"><span class="card-label">消息</span><span class="card-value">{{ item.message }}</span></div>
|
||||
<div class="card-row"><span class="card-label">耗时</span><span class="card-value">{{ item.duration }}s</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +116,7 @@
|
||||
<div v-if="llmConfigsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="llmConfigs.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🤖</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
|
||||
<div class="empty-text">暂无 LLM 配置</div>
|
||||
<el-button type="primary" size="small" @click="showLLMConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
@@ -170,7 +129,10 @@
|
||||
<el-table-column prop="temperature" label="温度" width="80"></el-table-column>
|
||||
<el-table-column prop="max_tokens" label="最大Token" width="110"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="70">
|
||||
<template #default="scope">{{ scope.row.is_active ? '是' : '否' }}</template>
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
@@ -180,13 +142,13 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in llmConfigs" :key="item.id" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">名称</span><span class="mobile-card-value">{{ item.name }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">模型</span><span class="mobile-card-value">{{ item.model }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">温度</span><span class="mobile-card-value">{{ item.temperature }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">激活</span><span class="mobile-card-value">{{ item.is_active ? '是' : '否' }}</span></div>
|
||||
<div class="mobile-card-actions">
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in llmConfigs" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
|
||||
<div class="card-row"><span class="card-label">温度</span><span class="card-value">{{ item.temperature }}</span></div>
|
||||
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ item.is_active ? '是' : '否' }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteLLMConfig(item.id)">删除</el-button>
|
||||
</div>
|
||||
@@ -201,7 +163,7 @@
|
||||
<div v-if="systemConfigsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="systemConfigs.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚙️</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
|
||||
<div class="empty-text">暂无系统配置</div>
|
||||
<el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
@@ -223,18 +185,162 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in systemConfigs" :key="item.key" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">键</span><span class="mobile-card-value">{{ item.key }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">值</span><span class="mobile-card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">描述</span><span class="mobile-card-value">{{ item.description }}</span></div>
|
||||
<div class="mobile-card-actions">
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in systemConfigs" :key="item.key" class="card-item">
|
||||
<div class="card-row"><span class="card-label">键</span><span class="card-value">{{ item.key }}</span></div>
|
||||
<div class="card-row"><span class="card-label">值</span><span class="card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
|
||||
<div class="card-row"><span class="card-label">描述</span><span class="card-value">{{ item.description }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showSystemConfigDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSystemConfig(item.key)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'categories'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
||||
<el-button size="small" @click="loadCategories">刷新</el-button>
|
||||
</div>
|
||||
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="categories.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
|
||||
<div class="empty-text">暂无采集类别</div>
|
||||
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="categories" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="60"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
|
||||
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
|
||||
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="70">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="categories.length > 0">
|
||||
<div v-for="item in categories" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
|
||||
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'orgs'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
|
||||
<el-button size="small" @click="loadOrgs">刷新</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ orgs.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="orgsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="orgs.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
|
||||
<div class="empty-text">暂无组织</div>
|
||||
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="orgs" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="org_id" label="组织ID" min-width="120"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="150"></el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
|
||||
<el-table-column prop="user_count" label="用户数" width="80"></el-table-column>
|
||||
<el-table-column prop="topic_count" label="选题数" width="80"></el-table-column>
|
||||
<el-table-column label="默认" width="70">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_default" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showOrgDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteOrg(scope.row.org_id)" :disabled="scope.row.is_default">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="orgs.length > 0">
|
||||
<div v-for="item in orgs" :key="item.org_id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">ID</span><span class="card-value">{{ item.org_id }}</span></div>
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">用户</span><span class="card-value">{{ item.user_count }}</span></div>
|
||||
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_count }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showOrgDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteOrg(item.org_id)" :disabled="item.is_default">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'sources'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
||||
<el-button size="small" @click="loadSources">刷新</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="sources.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
|
||||
<div class="empty-text">暂无信息源</div>
|
||||
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="sources" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="50"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
|
||||
<el-table-column prop="source_type" label="类型" width="90">
|
||||
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
|
||||
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="60">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="sources.length > 0">
|
||||
<div v-for="item in sources" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
|
||||
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -267,25 +373,35 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="700px" :close-on-click-modal="false">
|
||||
<el-form :model="llmConfigForm" label-width="100px">
|
||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
|
||||
<el-form :model="llmConfigForm" label-width="110px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name" placeholder="如:default_expand"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="供应商">
|
||||
<el-select v-model="llmConfigForm.provider" style="width:100%">
|
||||
<el-option label="opencode-go (deepseek-v4-pro)" value="opencode-go"></el-option>
|
||||
<el-option label="nvidia (gemma-3n)" value="nvidia"></el-option>
|
||||
</el-select>
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model" placeholder="deepseek-v4-pro"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="接口地址"><el-input v-model="llmConfigForm.base_url" placeholder="https://opencode.ai/zen/go/v1"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="4"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="API Key"><el-input v-model="llmConfigForm.api_key" type="password" show-password placeholder="sk-..."/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000" style="width:100%"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="3" placeholder="你是一个专业的内容创作助手。"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template" :rows="4"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
|
||||
@@ -308,8 +424,63 @@
|
||||
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
|
||||
<el-form :model="categoryForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
|
||||
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
|
||||
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
|
||||
<el-form :model="sourceForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="sourceForm.source_type" style="width:100%">
|
||||
<el-option label="RSS订阅" value="rss"></el-option>
|
||||
<el-option label="搜索引擎" value="web_search"></el-option>
|
||||
<el-option label="本地文件" value="local"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
|
||||
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
|
||||
<el-form-item label="可信度">
|
||||
<el-select v-model="sourceForm.credibility" style="width:100%">
|
||||
<el-option label="高" value="high"></el-option>
|
||||
<el-option label="中" value="medium"></el-option>
|
||||
<el-option label="低" value="low"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
|
||||
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="sourceDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSource">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="orgDialogVisible" :title="orgDialogTitle" width="500px" :close-on-click-modal="false">
|
||||
<el-form :model="orgForm" label-width="80px">
|
||||
<el-form-item label="组织ID"><el-input v-model="orgForm.org_id" :disabled="!!editingOrgId" placeholder="唯一标识,如:org_a"/></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="orgForm.name" placeholder="组织名称"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input type="textarea" v-model="orgForm.description" :rows="3"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="orgDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveOrg">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted } = Vue;
|
||||
@@ -385,7 +556,7 @@
|
||||
const llmConfigsLoading = ref(false);
|
||||
const llmConfigDialogVisible = ref(false);
|
||||
const llmConfigDialogTitle = ref('新增配置');
|
||||
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', is_active: true });
|
||||
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
|
||||
const editingLLMConfigId = ref(null);
|
||||
|
||||
const loadLLMConfigs = async () => {
|
||||
@@ -394,10 +565,18 @@
|
||||
finally { llmConfigsLoading.value = false; }
|
||||
};
|
||||
const showLLMConfigDialog = (row = null) => {
|
||||
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
|
||||
else {
|
||||
if (row) {
|
||||
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
|
||||
llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go';
|
||||
llmConfigForm.model = row.model || ''; llmConfigForm.base_url = row.base_url || ''; llmConfigForm.api_key = '';
|
||||
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 2000;
|
||||
llmConfigForm.system_prompt = row.system_prompt || ''; llmConfigForm.user_prompt_template = row.user_prompt_template || '';
|
||||
llmConfigForm.is_active = row.is_active ?? true;
|
||||
} else {
|
||||
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null;
|
||||
Object.keys(llmConfigForm).forEach(k => { if (k === 'id') llmConfigForm.id = null; else if (k === 'temperature') llmConfigForm.temperature = 0.7; else if (k === 'max_tokens') llmConfigForm.max_tokens = 2000; else if (k === 'is_active') llmConfigForm.is_active = true; else llmConfigForm[k] = ''; });
|
||||
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
|
||||
llmConfigForm.base_url = 'https://opencode.ai/zen/go/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.7;
|
||||
llmConfigForm.max_tokens = 2000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
|
||||
}
|
||||
llmConfigDialogVisible.value = true;
|
||||
};
|
||||
@@ -420,6 +599,50 @@
|
||||
const systemConfigForm = reactive({ key: '', value: '', description: '' });
|
||||
const editingSystemConfigKey = ref(null);
|
||||
|
||||
const categories = ref([]);
|
||||
const categoriesLoading = ref(false);
|
||||
const categoryDialogVisible = ref(false);
|
||||
const categoryDialogTitle = ref('新增类别');
|
||||
const categoryForm = reactive({ name: '', search_query: '', description: '', sort_order: 0, is_active: true });
|
||||
const editingCategoryId = ref(null);
|
||||
|
||||
const sources = ref([]);
|
||||
const sourcesLoading = ref(false);
|
||||
const sourceDialogVisible = ref(false);
|
||||
const sourceDialogTitle = ref('新增信息源');
|
||||
const sourceForm = reactive({ name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true });
|
||||
const editingSourceId = ref(null);
|
||||
|
||||
const orgs = ref([]);
|
||||
const orgsLoading = ref(false);
|
||||
const orgDialogVisible = ref(false);
|
||||
const orgDialogTitle = ref('新增组织');
|
||||
const orgForm = reactive({ org_id: '', name: '', description: '' });
|
||||
const editingOrgId = ref(null);
|
||||
|
||||
const loadOrgs = async () => {
|
||||
orgsLoading.value = true;
|
||||
try { orgs.value = await api.get('/api/admin/orgs'); } catch (e) { ElMessage.error('加载组织失败: ' + e.message); }
|
||||
finally { orgsLoading.value = false; }
|
||||
};
|
||||
const showOrgDialog = (row = null) => {
|
||||
if (row) { orgDialogTitle.value = '编辑组织'; editingOrgId.value = row.org_id; orgForm.org_id = row.org_id; orgForm.name = row.name; orgForm.description = row.description || ''; }
|
||||
else { orgDialogTitle.value = '新增组织'; editingOrgId.value = null; orgForm.org_id = ''; orgForm.name = ''; orgForm.description = ''; }
|
||||
orgDialogVisible.value = true;
|
||||
};
|
||||
const saveOrg = async () => {
|
||||
if (!orgForm.org_id || !orgForm.name) { ElMessage.warning('请填写组织ID和名称'); return; }
|
||||
try {
|
||||
if (editingOrgId.value) { await api.put(`/api/admin/orgs/${editingOrgId.value}`, orgForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/orgs', orgForm); ElMessage.success('创建成功'); }
|
||||
orgDialogVisible.value = false; await loadOrgs();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteOrg = async (orgId) => {
|
||||
try { await ElMessageBox.confirm('确定删除该组织吗?关联的用户和选题不会被删除。', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${orgId}`); ElMessage.success('删除成功'); await loadOrgs(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const loadSystemConfigs = async () => {
|
||||
systemConfigsLoading.value = true;
|
||||
try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); }
|
||||
@@ -442,13 +665,59 @@
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
categoriesLoading.value = true;
|
||||
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
|
||||
finally { categoriesLoading.value = false; }
|
||||
};
|
||||
const showCategoryDialog = (row = null) => {
|
||||
if (row) { categoryDialogTitle.value = '编辑类别'; editingCategoryId.value = row.id; Object.assign(categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
|
||||
else { categoryDialogTitle.value = '新增类别'; editingCategoryId.value = null; categoryForm.name = ''; categoryForm.search_query = ''; categoryForm.description = ''; categoryForm.sort_order = 0; categoryForm.is_active = true; }
|
||||
categoryDialogVisible.value = true;
|
||||
};
|
||||
const saveCategory = async () => {
|
||||
try {
|
||||
if (editingCategoryId.value) { await api.put(`/api/admin/collector/categories/${editingCategoryId.value}`, categoryForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/collector/categories', categoryForm); ElMessage.success('创建成功'); }
|
||||
categoryDialogVisible.value = false; await loadCategories();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteCategory = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/categories/${id}`); ElMessage.success('删除成功'); await loadCategories(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const loadSources = async () => {
|
||||
sourcesLoading.value = true;
|
||||
try { sources.value = await api.get('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
|
||||
finally { sourcesLoading.value = false; }
|
||||
};
|
||||
const showSourceDialog = (row = null) => {
|
||||
if (row) { sourceDialogTitle.value = '编辑信息源'; editingSourceId.value = row.id; Object.assign(sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
|
||||
else { sourceDialogTitle.value = '新增信息源'; editingSourceId.value = null; sourceForm.name = ''; sourceForm.source_type = 'web_search'; sourceForm.query = ''; sourceForm.credibility = 'medium'; sourceForm.focus = ''; sourceForm.sort_order = 0; sourceForm.is_active = true; }
|
||||
sourceDialogVisible.value = true;
|
||||
};
|
||||
const saveSource = async () => {
|
||||
try {
|
||||
if (editingSourceId.value) { await api.put(`/api/admin/collector/sources/${editingSourceId.value}`, sourceForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/collector/sources', sourceForm); ElMessage.success('创建成功'); }
|
||||
sourceDialogVisible.value = false; await loadSources();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteSource = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/sources/${id}`); ElMessage.success('删除成功'); await loadSources(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||
|
||||
const tabLoaders = {
|
||||
cases: loadCases, tasklogs: loadTaskLogs,
|
||||
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
|
||||
categories: loadCategories, sources: loadSources,
|
||||
orgs: loadOrgs,
|
||||
};
|
||||
const loadedTabs = new Set();
|
||||
const loadedTabs = new Set(['cases']);
|
||||
|
||||
const switchTab = (name) => {
|
||||
activeTab.value = name;
|
||||
@@ -469,14 +738,17 @@
|
||||
taskLogs, taskLogsLoading, loadTaskLogs,
|
||||
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
||||
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
|
||||
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
|
||||
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
|
||||
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
|
||||
logout, currentUser, isAdmin, redirectToPage
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<!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">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
.search-bar { margin-left: auto; width: 240px; }
|
||||
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||
.status-dot.draft { background: #E6A23C; }
|
||||
.status-dot.reviewed { background: #67C23A; }
|
||||
.status-dot.published { background: #409EFF; }
|
||||
|
||||
.article-card-list { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.el-table { display: none; }
|
||||
.article-card-list { display: block; }
|
||||
.article-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.article-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.article-card-title { font-size: 14px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
|
||||
.article-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: #606266; margin-bottom: 12px; }
|
||||
.article-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
|
||||
.article-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
|
||||
.search-bar { width: 100%; margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部平台" value=""></el-option>
|
||||
<el-option label="知乎" value="zhihu"></el-option>
|
||||
<el-option label="微信公众号" value="wechat"></el-option>
|
||||
<el-option label="小红书" value="xiaohongshu"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部状态" value=""></el-option>
|
||||
<el-option label="草稿" value="draft"></el-option>
|
||||
<el-option label="已审查" value="reviewed"></el-option>
|
||||
<el-option label="已发布" value="published"></el-option>
|
||||
</el-select>
|
||||
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
|
||||
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
|
||||
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
|
||||
<el-table-column prop="topic_id" label="选题 ID" width="80"></el-table-column>
|
||||
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="100">
|
||||
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="80">
|
||||
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="word_count" label="字数" width="70">
|
||||
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配图" width="60">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
|
||||
<span v-else style="color:#dcdfe6;">—</span>
|
||||
</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 label="操作" width="130" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
|
||||
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
|
||||
<div class="article-card-header">
|
||||
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
|
||||
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="article-card-meta">
|
||||
<div>ID: {{ article.id }}</div>
|
||||
<div>平台: {{ platformLabel(article.platform) }}</div>
|
||||
<div>合规: {{ article.compliance_score ?? '-' }}</div>
|
||||
<div>字数: {{ article.word_count ?? '-' }}</div>
|
||||
<div>创建: {{ formatDate(article.created_at) }}</div>
|
||||
</div>
|
||||
<div class="article-card-actions">
|
||||
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
|
||||
<el-icon><IconDocument /></el-icon> 暂无文章
|
||||
</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" close-on-press-escape>
|
||||
<div v-if="previewArticleData">
|
||||
<div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:200px;">
|
||||
<h3 style="margin:0; font-size:15px;">{{ previewArticleData.topic_title || previewArticleData.topic_id }}</h3>
|
||||
<div style="font-size:12px; color:#909399; margin-top:4px;">
|
||||
{{ platformLabel(previewArticleData.platform) }} · {{ statusLabel(previewArticleData.status) }} · {{ previewArticleData.word_count ?? '-' }}字
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="previewArticleData.images && previewArticleData.images.cover" style="flex-shrink:0;">
|
||||
<img :src="previewArticleData.images.cover" style="height:80px; border-radius:8px; border:1px solid #ebeef5; object-fit:cover;" alt="封面">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
|
||||
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe>
|
||||
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制 HTML</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const ArticlesApp = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
articles: [],
|
||||
filterPlatform: '', filterStatus: '', searchQuery: '',
|
||||
previewVisible: false, previewArticleData: null, previewFullscreen: false,
|
||||
debounceTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredArticles() {
|
||||
let list = this.articles;
|
||||
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
|
||||
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
|
||||
if (this.searchQuery) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getToken() { return localStorage.getItem('authToken'); },
|
||||
async api(url, opts = {}) {
|
||||
const token = this.getToken();
|
||||
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/', 1500); return null; }
|
||||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
|
||||
return res.json();
|
||||
},
|
||||
async fetchArticles() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filterPlatform) params.set('platform', this.filterPlatform);
|
||||
if (this.filterStatus) params.set('status', this.filterStatus);
|
||||
const qs = params.toString();
|
||||
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
|
||||
this.articles = data.articles || [];
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error);
|
||||
this.$message.error('获取文章列表失败: ' + error.message);
|
||||
this.articles = [];
|
||||
} finally { this.loadingTable = false; }
|
||||
},
|
||||
debouncedSearch() {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
|
||||
},
|
||||
async previewArticle(article) {
|
||||
this.previewArticleData = null;
|
||||
this.previewVisible = true;
|
||||
this.previewFullscreen = false;
|
||||
try {
|
||||
const data = await this.api('/api/articles/detail/' + article.id);
|
||||
this.previewArticleData = data;
|
||||
} catch (error) {
|
||||
this.$message.error('加载文章详情失败: ' + error.message);
|
||||
this.previewVisible = false;
|
||||
}
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
this.$nextTick(() => {
|
||||
const overlays = document.querySelectorAll('.el-overlay');
|
||||
const overlay = overlays[overlays.length - 1];
|
||||
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
|
||||
const dialog = document.querySelector('.preview-dialog-custom');
|
||||
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
|
||||
});
|
||||
},
|
||||
copyPreviewHtml() {
|
||||
if (!this.previewArticleData || !this.previewArticleData.html_content) {
|
||||
this.$message.warning('暂无 HTML 内容可复制');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(this.previewArticleData.html_content)
|
||||
.then(() => this.$message.success('HTML 已复制'))
|
||||
.catch(() => this.$message.error('复制失败'));
|
||||
},
|
||||
async deleteArticle(article) {
|
||||
try {
|
||||
await this.$confirm(`确定删除文章 [${article.id}]?`, '提示', { type: 'warning' });
|
||||
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchArticles();
|
||||
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
|
||||
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
|
||||
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
|
||||
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; }
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(ArticlesApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,14 +7,6 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
}
|
||||
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
||||
.asset-item { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; transition: all 0.3s; cursor: pointer; }
|
||||
.asset-item:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.2); }
|
||||
@@ -25,34 +17,35 @@
|
||||
.upload-area { border: 2px dashed #dcdfe6; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.3s; }
|
||||
.upload-area:hover { border-color: #409eff; background: #f0f9eb; }
|
||||
.upload-icon { font-size: 48px; color: #c0c4cc; }
|
||||
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
@media (max-width: 768px) {
|
||||
.asset-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="素材库" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="assets" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="素材库" :username="currentUser.username" :is-admin="isAdmin" current-page="assets" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🖼️ 素材库</h2>
|
||||
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon> 素材库</h2>
|
||||
<div class="card page-fade">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索素材..." style="width: 200px;" clearable @clear="loadAssets" @keyup.enter="loadAssets">
|
||||
<template #prefix><span>🔍</span></template>
|
||||
<el-input v-model="searchKeyword" placeholder="搜索素材..." clearable @clear="loadAssets" @keyup.enter="loadAssets" style="flex:1;min-width:140px;">
|
||||
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterType" placeholder="文件类型" style="width: 120px;" clearable @change="loadAssets">
|
||||
<el-select v-model="filterType" placeholder="文件类型" clearable @change="loadAssets" style="width:120px;">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="图片" value="image"></el-option>
|
||||
<el-option label="视频" value="video"></el-option>
|
||||
<el-option label="文档" value="document"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filterTag" placeholder="标签筛选" style="width: 150px;" clearable @change="loadAssets">
|
||||
<el-select v-model="filterTag" placeholder="标签筛选" clearable @change="loadAssets" style="width:130px;">
|
||||
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag"></el-option>
|
||||
</el-select>
|
||||
<el-button @click="loadAssets">🔄 刷新</el-button>
|
||||
<el-button type="primary" @click="showUploadDialog = true">📤 上传素材</el-button>
|
||||
<el-button @click="loadAssets"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
<el-button type="primary" @click="showUploadDialog = true"><el-icon style="vertical-align:-2px;"><IconUpload /></el-icon> 上传素材</el-button>
|
||||
</div>
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 20px; font-size: 14px; color: #606266;">
|
||||
<span>总计: {{ assetStats.total }} 个</span>
|
||||
@@ -60,15 +53,15 @@
|
||||
</div>
|
||||
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
|
||||
<div v-else-if="assets.length === 0" style="text-align: center; padding: 40px; color: #909399;">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">📂</div>
|
||||
<el-icon style="font-size:48px;margin-bottom:16px;color:#c0c4cc;"><IconPicture /></el-icon>
|
||||
<div>暂无素材,点击上方按钮上传</div>
|
||||
</div>
|
||||
<div v-else class="asset-grid">
|
||||
<div v-for="asset in assets" :key="asset.id" class="asset-item" @click="previewAsset(asset)">
|
||||
<div class="asset-thumb">
|
||||
<template v-if="asset.file_type === 'image'">🖼️</template>
|
||||
<template v-if="asset.file_type === 'image'"><el-icon style="font-size:32px;"><IconPicture /></el-icon></template>
|
||||
<template v-else-if="asset.file_type === 'video'">🎬</template>
|
||||
<template v-else>📄</template>
|
||||
<template v-else><el-icon style="font-size:32px;"><IconDocument /></el-icon></template>
|
||||
</div>
|
||||
<div class="asset-name">{{ asset.filename }}</div>
|
||||
<div class="asset-meta">{{ formatSize(asset.size) }} | {{ formatDate(asset.created_at) }}</div>
|
||||
@@ -87,7 +80,7 @@
|
||||
<el-dialog v-model="showUploadDialog" title="上传素材" width="500px">
|
||||
<el-upload ref="uploadRef" drag :auto-upload="false" :limit="10" :on-change="handleFileChange" multiple accept="image/*,.pdf,.doc,.docx,.ppt,.pptx,.mp4,.mov,.avi">
|
||||
<div class="upload-area">
|
||||
<div class="upload-icon">📤</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUpload /></el-icon>
|
||||
<div style="margin-top: 12px; color: #606266;">将文件拖到此处,或<span style="color: #409eff;">点击上传</span></div>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 8px;">支持: JPG, PNG, GIF, WebP, PDF, Word, PPT, MP4</div>
|
||||
</div>
|
||||
@@ -104,9 +97,9 @@
|
||||
<el-dialog v-model="showPreviewDialog" title="素材预览" width="800px">
|
||||
<div v-if="previewAssetData" style="text-align: center;">
|
||||
<div style="font-size: 64px; margin-bottom: 16px;">
|
||||
<template v-if="previewAssetData.file_type === 'image'">🖼️</template>
|
||||
<template v-if="previewAssetData.file_type === 'image'"><el-icon style="font-size:64px;"><IconPicture /></el-icon></template>
|
||||
<template v-else-if="previewAssetData.file_type === 'video'">🎬</template>
|
||||
<template v-else>📄</template>
|
||||
<template v-else><el-icon style="font-size:64px;"><IconDocument /></el-icon></template>
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">{{ previewAssetData.filename }}</div>
|
||||
<div style="color: #909399; font-size: 14px;">{{ formatSize(previewAssetData.size) }} | {{ formatDate(previewAssetData.created_at) }}</div>
|
||||
@@ -119,6 +112,7 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const AssetsApp = {
|
||||
@@ -186,7 +180,8 @@ const AssetsApp = {
|
||||
if (this.searchKeyword) url += '&search=' + encodeURIComponent(this.searchKeyword);
|
||||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.assets = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载素材失败: ' + e.message); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
async loadTags() {
|
||||
@@ -194,14 +189,14 @@ const AssetsApp = {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/assets/tags', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.allTags = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载标签失败: ' + e.message); }
|
||||
},
|
||||
async loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/assets/counts', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.assetStats = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载统计失败: ' + e.message); }
|
||||
},
|
||||
handleFileChange(file, fileList) { this.uploadFiles = fileList; },
|
||||
async uploadFiles() {
|
||||
@@ -247,8 +242,8 @@ const AssetsApp = {
|
||||
};
|
||||
const app = Vue.createApp(AssetsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -7,22 +7,23 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.content-area { padding: 32px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 12px; padding-bottom: 80px; }
|
||||
.calendar-day { min-height: 56px !important; padding: 2px; }
|
||||
.calendar-weekday { padding: 6px; font-size: 12px; }
|
||||
.day-entry { font-size: 10px; padding: 2px 4px; white-space: normal; }
|
||||
.content-area { padding: 12px !important; padding-bottom: 80px !important; }
|
||||
.card { padding: 12px !important; }
|
||||
.calendar-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.calendar-title { font-size: 20px; }
|
||||
.calendar-nav { width: 100%; justify-content: space-between; }
|
||||
.calendar-grid { gap: 2px; }
|
||||
.calendar-weekday { padding: 4px; font-size: 11px; }
|
||||
.calendar-day { min-height: 56px !important; padding: 2px; }
|
||||
.day-number { font-size: 12px; margin-bottom: 0; }
|
||||
.day-lunar { font-size: 9px; margin-top: -1px; }
|
||||
.day-term { font-size: 8px; padding: 0 3px; }
|
||||
.day-holiday { font-size: 8px; padding: 0 3px; }
|
||||
.day-lunar { font-size: 9px; margin-top: -1px; display: none; }
|
||||
.day-term { font-size: 8px; padding: 0 2px; }
|
||||
.day-holiday { font-size: 8px; padding: 0 2px; }
|
||||
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
|
||||
.stats-bar { gap: 12px; font-size: 12px; }
|
||||
}
|
||||
|
||||
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
@@ -39,11 +40,16 @@
|
||||
.day-term { display: inline-block; font-size: 9px; color: #E6A23C; background: #fdf6ec; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
|
||||
.day-holiday { display: inline-block; font-size: 9px; color: #F56C6C; background: #fef0f0; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
|
||||
.day-entries { display: flex; flex-direction: column; gap: 4px; }
|
||||
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
|
||||
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; display:flex; align-items:center; gap:3px; }
|
||||
.day-entry.planned { background: #fdf6ec; color: #E6A23C; }
|
||||
.day-entry.published { background: #f0f9eb; color: #67C23A; }
|
||||
.day-entry.delayed { background: #fef0f0; color: #F56C6C; }
|
||||
.day-entry.cancelled { background: #f4f4f5; color: #909399; }
|
||||
.entry-topic-status { font-size:9px; padding:1px 3px; border-radius:2px; margin-left:auto; flex-shrink:0; }
|
||||
.entry-topic-status.review { background:#ecf5ff; color:#409eff; }
|
||||
.entry-topic-status.ready { background:#fdf6ec; color:#e6a23c; }
|
||||
.entry-topic-status.published { background:#f0f9eb; color:#67c23a; }
|
||||
.entry-topic-status.pending { background:#f4f4f5; color:#909399; }
|
||||
|
||||
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.stat-item { display: flex; align-items: center; gap: 8px; }
|
||||
@@ -53,40 +59,37 @@
|
||||
.stat-dot.delayed { background: #F56C6C; }
|
||||
.stat-dot.cancelled { background: #909399; }
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component
|
||||
title="内容日历"
|
||||
:username="currentUser.username"
|
||||
:is-admin="isAdmin"
|
||||
@logout="handleLogout"
|
||||
></navbar-component>
|
||||
<navigation-component
|
||||
current-page="calendar"
|
||||
:is-admin="isAdmin"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
<uni-nav title="内容日历" :username="currentUser.username" :is-admin="isAdmin" current-page="calendar" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="calendar-header">
|
||||
<h2 class="calendar-title">📅 内容日历</h2>
|
||||
<h2 class="calendar-title"><el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 内容日历</h2>
|
||||
<div class="calendar-nav">
|
||||
<el-button @click="prevMonth" size="large">◀</el-button>
|
||||
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon></el-button>
|
||||
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
|
||||
<el-button @click="nextMonth" size="large">▶</el-button>
|
||||
<el-button @click="nextMonth" size="large"><el-icon><IconArrowRight /></el-icon></el-button>
|
||||
<el-button type="primary" @click="goToday">今天</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item"><span class="stat-dot planned"></span> 待发布: {{ stats.planned }}</div>
|
||||
<div class="stat-item"><span class="stat-dot published"></span> 已发布: {{ stats.published }}</div>
|
||||
<div class="stat-item"><span class="stat-dot delayed"></span> 延迟: {{ stats.delayed }}</div>
|
||||
<div class="stat-item"><span class="stat-dot cancelled"></span> 取消: {{ stats.cancelled }}</div>
|
||||
<div v-if="loadingCalendar" style="color:#909399;font-size:14px;">加载中...</div>
|
||||
<div v-else-if="!calendarDays.length" class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconCalendar /></el-icon>
|
||||
<div class="empty-text">暂无日历数据</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="stat-item"><span class="stat-dot planned"></span> 待发布: {{ stats.planned }}</div>
|
||||
<div class="stat-item"><span class="stat-dot published"></span> 已发布: {{ stats.published }}</div>
|
||||
<div class="stat-item"><span class="stat-dot delayed"></span> 延迟: {{ stats.delayed }}</div>
|
||||
<div class="stat-item"><span class="stat-dot cancelled"></span> 取消: {{ stats.cancelled }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid">
|
||||
@@ -104,7 +107,9 @@
|
||||
class="day-entry"
|
||||
:class="entry.status"
|
||||
@click.stop="openEntryDialog(entry)">
|
||||
{{ entry.platform_icon }} {{ entry.title || '未命名' }}
|
||||
<span v-if="entry.platform_icon" style="flex-shrink:0;">{{ entry.platform_icon }}</span><el-icon v-else style="flex-shrink:0;"><IconDocument /></el-icon>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ entry.title || '未命名' }}</span>
|
||||
<span v-if="entry.topic_status" class="entry-topic-status" :class="entry.topic_status">{{ topicStatusLabel(entry.topic_status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +127,7 @@
|
||||
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="scope"><el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</template>
|
||||
<template #default="scope"><el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="scope">
|
||||
@@ -248,11 +253,12 @@ function getDayMeta(year, month, day) {
|
||||
}
|
||||
</script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const { ref, reactive, computed, onMounted } = Vue;
|
||||
const CalendarApp = {
|
||||
components: { 'navbar-component': window.NavbarComponent, 'navigation-component': window.NavigationComponent },
|
||||
|
||||
setup() {
|
||||
const currentUser = ref({ username: '' });
|
||||
const isAdmin = ref(false);
|
||||
@@ -313,14 +319,20 @@ function getDayMeta(year, month, day) {
|
||||
return days;
|
||||
});
|
||||
|
||||
const loadingCalendar = ref(false);
|
||||
const calendarError = ref('');
|
||||
|
||||
const fetchEntries = async () => {
|
||||
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
|
||||
loadingCalendar.value = true;
|
||||
calendarError.value = '';
|
||||
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); calendarError.value = e.message; ElMessage.error('加载日历失败: ' + e.message); }
|
||||
finally { loadingCalendar.value = false; }
|
||||
};
|
||||
const fetchStats = async () => {
|
||||
try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
|
||||
try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); }
|
||||
};
|
||||
const fetchTopics = async () => {
|
||||
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); }
|
||||
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); ElMessage.error('加载选题失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const prevMonth = () => { if (currentMonth.value === 1) { currentMonth.value = 12; currentYear.value--; } else { currentMonth.value--; } fetchEntries(); fetchStats(); };
|
||||
@@ -338,21 +350,23 @@ function getDayMeta(year, month, day) {
|
||||
if (isEdit.value) { await api(`/api/calendar/entries/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }); }
|
||||
else { await api('/api/calendar/entries', { method: 'POST', body: JSON.stringify(data) }); }
|
||||
entryDialogVisible.value = false;
|
||||
ElMessage.success('保存成功');
|
||||
await fetchEntries();
|
||||
await fetchStats();
|
||||
} catch (e) { alert('保存失败: ' + e.message); }
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
saving.value = false;
|
||||
};
|
||||
const deleteEntry = async () => {
|
||||
if (!confirm('确定删除?')) return;
|
||||
try { await ElMessageBox.confirm('确定删除?', '提示', { type: 'warning' }); } catch { return; }
|
||||
saving.value = true;
|
||||
try { await api(`/api/calendar/entries/${entryForm.value.id}`, { method: 'DELETE' }); entryDialogVisible.value = false; await fetchEntries(); await fetchStats(); } catch (e) { alert('删除失败: ' + e.message); }
|
||||
try { await api(`/api/calendar/entries/${entryForm.value.id}`, { method: 'DELETE' }); entryDialogVisible.value = false; ElMessage.success('删除成功'); await fetchEntries(); await fetchStats(); } catch (e) { ElMessage.error('删除失败: ' + e.message); }
|
||||
saving.value = false;
|
||||
};
|
||||
|
||||
const platformName = (p) => ({ zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p);
|
||||
const statusLabel = (s) => ({ planned: '待发布', published: '已发布', delayed: '延迟', cancelled: '取消' }[s] || s);
|
||||
const statusType = (s) => ({ planned: 'warning', published: 'success', delayed: 'danger', cancelled: 'info' }[s] || '');
|
||||
const topicStatusLabel = (s) => ({ pending: '待处理', review: '待审查', ready: '待发布', published: '已发布' }[s] || s);
|
||||
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
@@ -384,15 +398,15 @@ function getDayMeta(year, month, day) {
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType, handleLogout, redirectToPage };
|
||||
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType, topicStatusLabel, handleLogout, redirectToPage };
|
||||
}
|
||||
};
|
||||
|
||||
const app = Vue.createApp(CalendarApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
||||
(function() {
|
||||
function ic(path, vb) {
|
||||
return { render() { return Vue.h('svg', { xmlns:'http://www.w3.org/2000/svg', viewBox: vb || '0 0 1024 1024' }, Vue.h('path', { fill:'currentColor', d: path })) } };
|
||||
}
|
||||
const icons = {
|
||||
IconSearch: ic('m795.904 750.72 124.992 124.928a32 32 0 0 1-45.248 45.248L750.656 795.904a416 416 0 1 1 45.248-45.248zM480 832a352 352 0 1 0 0-704 352 352 0 0 0 0 704'),
|
||||
IconDocument: ic('M832 384H576V128H192v768h640zm-26.496-64L640 154.496V320zM160 64h480l256 256v608a32 32 0 0 1-32 32H160a32 32 0 0 1-32-32V96a32 32 0 0 1 32-32'),
|
||||
IconPlus: ic('M480 480V128a32 32 0 0 1 64 0v352h352a32 32 0 1 1 0 64H544v352a32 32 0 1 1-64 0V544H128a32 32 0 1 1 0-64h352z'),
|
||||
IconClock: ic('M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896m-32-832a32 32 0 0 1 32 32v256a32 32 0 0 1-64 0V160a32 32 0 0 1 32-32'),
|
||||
IconDelete: ic('M160 256H96a32 32 0 0 1 0-64h256V95.936a32 32 0 0 1 32-32h256a32 32 0 0 1 32 32V192h256a32 32 0 1 1 0 64h-64v672a32 32 0 0 1-32 32H192a32 32 0 0 1-32-32V256zm448 0H256v640h352V256z'),
|
||||
IconClose: ic('M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.88 512 214.592 764.288a31.936 31.936 0 1 0 45.12 45.12L512 557.12l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512l252.288-252.288a31.936 31.936 0 1 0-45.12-45.12z'),
|
||||
IconCheck: ic('M406.656 706.944 195.84 496.256a32 32 0 1 0-45.248 45.248l256 256 512-512a32 32 0 0 0-45.248-45.248L406.656 706.944z'),
|
||||
IconStar: ic('m512 747.84 228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.84a6.4 6.4 0 0 0-11.52 0L389.12 379.136l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L314.88 606.976 271.36 861.056a6.4 6.4 0 0 0 9.28 6.72L512 747.84z'),
|
||||
IconArrowDown: ic('M831.872 340.864 512 652.672 192.128 340.864a30.59 30.59 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.59 30.59 0 0 0-42.752 0z'),
|
||||
IconArrowRight: ic('M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512 340.864 831.872a30.59 30.59 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.312a29.12 29.12 0 0 0-41.728 0z'),
|
||||
IconArrowLeft: ic('M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.59 30.59 0 0 0 0-42.752L338.688 512l312.448-340.352a30.59 30.59 0 0 0 0-42.752 29.12 29.12 0 0 0-41.728 0z'),
|
||||
IconRefresh: ic('M784.512 230.272v-50.56a32 32 0 1 1 64 0v149.056a32 32 0 0 1-32 32H667.52a32 32 0 1 1 0-64h92.992A288 288 0 0 0 224 512a32 32 0 1 1-64 0 352 352 0 0 1 624.512-224z m-545.024 563.456v50.56a32 32 0 1 1-64 0V694.912a32 32 0 0 1 32-32h149.056a32 32 0 1 1 0 64H263.552A288 288 0 0 0 800 512a32 32 0 1 1 64 0 352 352 0 0 1-624.512 224z'),
|
||||
IconPicture: ic('M96 896a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h832a32 32 0 0 1 32 32v704a32 32 0 0 1-32 32H96zm32-64h768V192H128v640zm319.808-457.152L320 512l128 170.496-63.744 47.808L256 563.2 128 736h768L574.72 321.6a32 32 0 0 0-50.432 0zM448 384a64 64 0 1 0-128 0 64 64 0 0 0 128 0z'),
|
||||
IconInfo: ic('M512 64a448 448 0 1 1 0 896.064A448 448 0 0 1 512 64m67.2 275.072c33.28 0 60.288-23.104 60.288-57.6s-27.072-57.6-60.288-57.6c-34.176 0-60.288 23.104-60.288 57.6s26.88 57.6 60.288 57.6m-109.44 355.008c0 17.088 12.864 29.76 35.328 29.76 29.312 0 52.416-16.832 52.416-44.928v-200.32c0-18.688-7.04-76.8-48.896-76.8-22.4 0-35.2 17.408-35.2 34.688 0 6.848 3.2 19.456 3.2 24.96 0 0-55.872 143.488-55.872 180.48 0 17.28 13.792 51.84 48.128 51.84z'),
|
||||
IconWarning: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 192a58.43 58.43 0 0 0-58.24 63.744l23.36 256.512a35.52 35.52 0 0 0 69.76 0l23.2-256.512A58.43 58.43 0 0 0 512 256m0 448a64 64 0 1 0 0 128 64 64 0 0 0 0-128'),
|
||||
IconSuccess: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.27 38.27 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336L456.192 600.384z'),
|
||||
IconLoading: ic('M512 64a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V96a32 32 0 0 1 32-32m0 640a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V736a32 32 0 0 1 32-32m259.712-412.288a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM381.568 590.432a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM832 480a32 32 0 0 1 32 32h-192a32 32 0 0 1 0-64h160a32 32 0 0 1 32 32zM192 480a32 32 0 0 1 32 32H64a32 32 0 0 1 0-64h128a32 32 0 0 1 32 32zm596.288 339.712a32 32 0 0 1 0 45.248l-135.808-135.808a32 32 0 0 1 45.248-45.248l90.56 90.56v.248zM313.248 389.568a32 32 0 0 1-45.248 45.248l-90.56-90.56a32 32 0 0 1 45.248-45.248l90.56 90.56z'),
|
||||
IconMore: ic('M176 416a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224'),
|
||||
IconFullScreen: ic('m160 96.064 192 .192a32 32 0 0 1 0 64l-192-.192V352a32 32 0 0 1-64 0V96h64zm0 832v-192a32 32 0 0 1 64 0v192l192-.192a32 32 0 0 1 0 64l-192-.192H160zM864 96.064l-192 .192a32 32 0 0 0 0 64l192-.192V352a32 32 0 0 0 64 0V96h-64zm0 736V640a32 32 0 0 0-64 0v192l-192-.192a32 32 0 0 0 0 64l192 .192H864z'),
|
||||
IconMenu: ic('M160 224a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32'),
|
||||
IconUser: ic('M659.2 659.2a192 192 0 1 0-294.4 0 448 448 0 0 0-294.4 307.2 32 32 0 1 0 62.08 15.36 384 384 0 0 1 724.48 0 32 32 0 0 0 62.08-15.36 448 448 0 0 0-259.84-307.2zM512 640a128 128 0 1 1 0-256 128 128 0 0 1 0 256'),
|
||||
IconSetting: ic('M640 512a128 128 0 1 0-256 0 128 128 0 0 0 256 0m64 0a192 192 0 1 1-384 0 192 192 0 0 1 384 0m-64-256a32 32 0 0 0 0-64H384a32 32 0 0 0 0 64h256m-192 0h128a32 32 0 0 0 0-64H448a32 32 0 0 0 0 64m-64 480a32 32 0 0 0 0 64h256a32 32 0 0 0 0-64H384'),
|
||||
IconUpload: ic('M544 864a32 32 0 1 1-64 0V380.8L337.6 523.2a32 32 0 0 1-45.248-45.248l192-192a32 32 0 0 1 45.248 0l192 192a32 32 0 1 1-45.248 45.248L544 380.8V864zM192 256a32 32 0 1 1 0-64h640a32 32 0 1 1 0 64H192z'),
|
||||
IconCalendar: ic('M768 128a32 32 0 0 1 32 32v96h96a32 32 0 0 1 32 32v576a32 32 0 0 1-32 32H128a32 32 0 0 1-32-32V288a32 32 0 0 1 32-32h96v-96a32 32 0 1 1 64 0v96h512v-96a32 32 0 0 1 32-32zm32 224H224v480h576V352zM384 448a32 32 0 0 1 32 32v128a32 32 0 0 1-64 0V480a32 32 0 0 1 32-32m256 0a32 32 0 0 1 32 32v128a32 32 0 1 1-64 0V480a32 32 0 0 1 32-32'),
|
||||
IconDashboard: ic('M480 64a32 32 0 0 1 64 0v192a32 32 0 1 1-64 0V64zM239.648 182.816a32 32 0 0 1 45.248 0l135.808 135.872a32 32 0 0 1-45.248 45.248L239.648 228.064a32 32 0 0 1 0-45.248m544.704 0a32 32 0 0 1 0 45.248L648.544 363.968a32 32 0 0 1-45.248-45.248l135.808-135.872a32 32 0 0 1 45.248 0zM64 480a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H96a32 32 0 0 1-32-32m672 0a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H768a32 32 0 0 1-32-32m-544 288a32 32 0 0 1 32-32h576a32 32 0 1 1 0 64H224a32 32 0 0 1-32-32'),
|
||||
IconTopic: ic('M832 896H192a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h448l256 256v480a32 32 0 0 1-32 32zm-32-64V416H576V192H224v640h576zM480 256H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m0 192H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m-160 192h320a32 32 0 1 0 0-64H320a32 32 0 0 0 0 64'),
|
||||
IconGlobe: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m32 63.744V320h128a32 32 0 0 1 0 64h-22.912c13.12 47.232 19.904 97.28 20.288 148.48L704 532.48V512a32 32 0 1 1 64 0v88.064a448.256 448.256 0 0 1-128 165.12l-.064-101.12a32 32 0 1 0-64 0v138.88c-43.968 17.472-91.36 26.944-141.248 27.136L416 829.44V640a32 32 0 0 0-64 0v138.88a320 320 0 0 1-96-116.544V544h128a32 32 0 1 0 0-64H256v-64h128a32 32 0 1 0 0-64H273.792a320.256 320.256 0 0 1 49.152-128H448a32 32 0 0 0 0-64H377.024A320.128 320.128 0 0 1 544 127.744z'),
|
||||
};
|
||||
window.__iconComponents = icons;
|
||||
window.installIcons = function(app) {
|
||||
for (const [name, comp] of Object.entries(icons)) {
|
||||
app.component(name, comp);
|
||||
}
|
||||
};
|
||||
})();
|
||||
+51
-175
@@ -5,96 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
/* 深色渐变背景主题 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
|
||||
|
||||
/* 导航栏 */
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* 内容区域 */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 页面切换 */
|
||||
/* 仪表盘统计卡片 */
|
||||
.page { display: none; animation: fadeIn 0.5s ease-out; }
|
||||
.page.active { display: block; }
|
||||
@keyframes fadeIn {
|
||||
@@ -102,7 +15,6 @@
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 统计卡片网格 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
@@ -110,9 +22,8 @@
|
||||
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);
|
||||
background: white;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
@@ -120,27 +31,14 @@
|
||||
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);
|
||||
border-color: #c6d8ff;
|
||||
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: #a0aec0;
|
||||
color: #909399;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -168,18 +66,16 @@
|
||||
gap: 20px;
|
||||
}
|
||||
.module-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
background: white;
|
||||
border: 1px solid #ebeef5;
|
||||
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);
|
||||
border-color: #c6d8ff;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
.module-header {
|
||||
display: flex;
|
||||
@@ -190,7 +86,7 @@
|
||||
.module-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e6ed;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -200,40 +96,28 @@
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
background: rgba(103, 194, 58, 0.12);
|
||||
color: #67c23a;
|
||||
border: 1px solid rgba(103, 194, 58, 0.3);
|
||||
border: 1px solid rgba(103, 194, 58, 0.25);
|
||||
}
|
||||
.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); }
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
.module-content {
|
||||
font-size: 14px;
|
||||
color: #a0aec0;
|
||||
color: #606266;
|
||||
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);
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
.module-content div:last-child { border-bottom: none; }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 16px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.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; }
|
||||
@@ -253,34 +137,32 @@
|
||||
#page-overview .module-content { color: #303133 !important; }
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script>
|
||||
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component
|
||||
title="宇之然内容创作平台"
|
||||
:username="currentUser.username"
|
||||
:is-admin="isAdmin"
|
||||
@logout="handleLogout"
|
||||
></navbar-component>
|
||||
|
||||
<navigation-component
|
||||
current-page="dashboard"
|
||||
:is-admin="isAdmin"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content" v-if="isLoggedIn">
|
||||
<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;">
|
||||
📊 系统概览
|
||||
<el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 系统概览
|
||||
</h2>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loadingStats" style="text-align:center;padding:40px 0;color:#909399;">
|
||||
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!stats.total && !loadingStats" class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
|
||||
<div class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div v-else class="stats-grid">
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-title">选题总数</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
@@ -301,7 +183,7 @@
|
||||
<div class="stat-title">已发布</div>
|
||||
<div class="stat-value">{{ stats.published }}</div>
|
||||
</div>
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-card primary" @click="goToTopics('today')">
|
||||
<div class="stat-title">今日新增</div>
|
||||
<div class="stat-value">{{ stats.today }}</div>
|
||||
</div>
|
||||
@@ -309,9 +191,12 @@
|
||||
|
||||
<!-- 模块状态 -->
|
||||
<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 24px; color: #e0e6ed;">
|
||||
🔧 模块状态
|
||||
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 模块状态
|
||||
<span style="font-size: 13px; font-weight: 400; color: #909399; margin-left: 12px;">
|
||||
定时任务: {{ schedulerStatus }}
|
||||
定时任务:
|
||||
<el-icon v-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
|
||||
{{ schedulerStatus }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="module-grid">
|
||||
@@ -332,10 +217,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 移动端导航 -->
|
||||
<!-- 移动端导航由 navigation-component.js 注入 -->
|
||||
</div>
|
||||
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const App = {
|
||||
@@ -345,6 +230,7 @@
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
currentPage: 'overview',
|
||||
loadingStats: false,
|
||||
stats: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
@@ -354,7 +240,8 @@
|
||||
today: 0
|
||||
},
|
||||
modules: [],
|
||||
schedulerStatus: ''
|
||||
schedulerStatus: '',
|
||||
schedulerRunning: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
@@ -391,6 +278,7 @@
|
||||
window.location.href = '/login.html';
|
||||
},
|
||||
async fetchStats() {
|
||||
this.loadingStats = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
@@ -421,9 +309,9 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error);
|
||||
// 失败时设置为0,避免页面空白
|
||||
this.$message.error('获取统计信息失败: ' + error.message);
|
||||
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
|
||||
}
|
||||
} finally { this.loadingStats = false; }
|
||||
},
|
||||
async fetchModules() {
|
||||
try {
|
||||
@@ -435,13 +323,15 @@
|
||||
const data = await resp.json();
|
||||
this.modules = data.modules || [];
|
||||
const s = data.scheduler || {};
|
||||
this.schedulerStatus = s.running ? '🟢 运行中' : '🔴 未启动';
|
||||
this.schedulerRunning = s.running;
|
||||
this.schedulerStatus = (s.running ? '运行中' : '未启动');
|
||||
if (s.jobs && s.jobs.length) {
|
||||
this.schedulerStatus += ' · ' + s.jobs.length + ' 个任务';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取模块状态失败:', e);
|
||||
this.$message.error('获取模块状态失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
goToTopics(filter) {
|
||||
@@ -479,23 +369,9 @@
|
||||
|
||||
const app = Vue.createApp(App);
|
||||
app.use(ElementPlus);
|
||||
// 安装导航组件
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
// 安装导航组件
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
|
||||
// 调试代码:检查导航组件状态
|
||||
setTimeout(() => {
|
||||
const hasNav = !!document.querySelector('.navigation-wrapper');
|
||||
const hasSidebar = !!document.querySelector('.navigation-wrapper .sidebar');
|
||||
console.log('[调试] 导航wrapper:', hasNav);
|
||||
console.log('[调试] 侧边栏:', hasSidebar);
|
||||
if (!hasNav) {
|
||||
console.error('[调试] 导航组件未渲染!window.NavigationComponent=', !!window.NavigationComponent);
|
||||
console.error('[调试] app实例是否存在组件注册?', Vue && Vue.app && Vue.app._context.components['navigation-component']);
|
||||
}
|
||||
}, 100);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,41 +7,29 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.controls { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
||||
.log-container { max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||
.log-container pre { margin: 0; padding: 16px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; color: #303133; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.controls { flex-direction: column; align-items: stretch; }
|
||||
.controls .el-select, .controls .el-date-picker { width: 100% !important; }
|
||||
.log-container { max-height: calc(100vh - 250px); }
|
||||
.controls .el-button { width: 100%; }
|
||||
.log-container { max-height: calc(100vh - 280px); }
|
||||
.log-container pre { font-size: 11px; padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="系统日志" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="logs" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="系统日志" :username="currentUser.username" :is-admin="isAdmin" current-page="logs" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">📄 系统日志</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 系统日志</h2>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<el-select v-model="logType" placeholder="日志类型" style="width: 200px;">
|
||||
@@ -67,6 +55,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const LogsApp = {
|
||||
@@ -112,8 +101,8 @@
|
||||
};
|
||||
const app = Vue.createApp(LogsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+246
-80
@@ -7,40 +7,51 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 20px; color: white; text-align: center; }
|
||||
.stat-card.success { background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); }
|
||||
.stat-card.warning { background: linear-gradient(135deg, #e6a23c 0%, #f5c543 100%); }
|
||||
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
|
||||
.stat-value { font-size: 32px; font-weight: 700; }
|
||||
.stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; }
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 12px !important; }
|
||||
.chart-container { height: 250px !important; }
|
||||
}
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 24px; }
|
||||
.chart-container { background: white; border-radius: 12px; padding: 20px; margin-bottom: 24px; height: 350px; }
|
||||
.chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; }
|
||||
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 10px !important; }
|
||||
.stat-card { padding: 14px; }
|
||||
.stat-value { font-size: 24px; }
|
||||
.stat-label { font-size: 12px; }
|
||||
.chart-container { height: 220px !important; padding: 12px; }
|
||||
.platform-chart { grid-template-columns: 1fr; }
|
||||
.metrics-table { display: none; }
|
||||
.metrics-card-list { display: block; }
|
||||
}
|
||||
.metrics-table { display: block; }
|
||||
.metrics-card-list { display: none; }
|
||||
.metrics-card { background: white; border-radius: 10px; padding: 14px; margin-bottom: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.metrics-card:active { transform: scale(0.99); }
|
||||
.metrics-card-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px dashed #f0f0f0; font-size: 13px; }
|
||||
.metrics-card-row:last-child { border-bottom: none; }
|
||||
.metrics-card-label { color: #909399; }
|
||||
.metrics-card-value { color: #303133; font-weight: 500; }
|
||||
.metrics-card-title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 10px; }
|
||||
.metrics-card-reason { font-size: 12px; color: #606266; margin-top: 6px; line-height: 1.5; }
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
<script src="chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="数据分析" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="metrics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="数据分析" :username="currentUser.username" :is-admin="isAdmin" current-page="metrics" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">📊 数据分析</h2>
|
||||
<div class="stats-grid">
|
||||
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 数据分析</h2>
|
||||
<div v-if="loadingDashboard" style="text-align:center;padding:40px 0;color:#909399;">
|
||||
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<div v-else class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ dashboard.total_topics }}</div>
|
||||
<div class="stat-label">选题总数</div>
|
||||
@@ -59,29 +70,47 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card page-fade">
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;">📈 选题状态分布</h3>
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||||
<div v-for="(count, status) in dashboard.topics_by_status" :key="status" style="text-align: center;">
|
||||
<div style="font-size: 28px; font-weight: 700; color: #409eff;">{{ count }}</div>
|
||||
<div style="font-size: 14px; color: #909399;">{{ getStatusLabel(status) }}</div>
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 选题状态分布</h3>
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items:center;">
|
||||
<div style="flex:1;min-width:200px;">
|
||||
<div v-for="(count, status) in dashboard.topics_by_status" :key="status" style="display:flex;align-items:center;margin-bottom:12px;">
|
||||
<div :style="{width:14+'px',height:14+'px',borderRadius:'50%',marginRight:10+'px',background:statusColors(status)}"></div>
|
||||
<div style="flex:1;font-size:14px;color:#606266;">{{ getStatusLabel(status) }}</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#303133;">{{ count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:200px;height:180px;">
|
||||
<canvas ref="statusChartCanvas" style="width:100%;height:100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;">🔥 热门选题 TOP10</h3>
|
||||
<el-table :data="dashboard.top_topics" stripe size="small">
|
||||
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="title" label="标题"></el-table-column>
|
||||
<el-table-column prop="total_views" label="阅读" width="100">
|
||||
<template #default="scope">{{ scope.row.total_views || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_likes" label="点赞" width="100">
|
||||
<template #default="scope">{{ scope.row.total_likes || 0 }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconStar /></el-icon> 热门选题 TOP10</h3>
|
||||
<div v-if="dashboard.top_topics && dashboard.top_topics.length > 0">
|
||||
<div class="metrics-table">
|
||||
<el-table :data="dashboard.top_topics" stripe size="small">
|
||||
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="title" label="标题"></el-table-column>
|
||||
<el-table-column prop="total_views" label="阅读" width="100">
|
||||
<template #default="scope">{{ scope.row.total_views || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_likes" label="点赞" width="100">
|
||||
<template #default="scope">{{ scope.row.total_likes || 0 }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="metrics-card-list">
|
||||
<div v-for="item in dashboard.top_topics" :key="item.topic_id" class="metrics-card">
|
||||
<div class="metrics-card-title">{{ item.topic_id }}. {{ item.title }}</div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">阅读</span><span class="metrics-card-value">{{ item.total_views || 0 }}</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">点赞</span><span class="metrics-card-value">{{ item.total_likes || 0 }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon><div class="empty-text">暂无热门选题数据</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;">📉 数据趋势</h3>
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 数据趋势</h3>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap;">
|
||||
<el-button-group>
|
||||
<el-button :type="trendDays === 7 ? 'primary' : ''" @click="trendDays = 7; fetchTrend()">7天</el-button>
|
||||
@@ -89,53 +118,80 @@
|
||||
<el-button :type="trendDays === 90 ? 'primary' : ''" @click="trendDays = 90; fetchTrend()">90天</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
<div class="chart-container" style="overflow-x: auto;">
|
||||
<div style="min-width: 600px;">
|
||||
<div v-for="(item, idx) in trendData" :key="idx" style="display: flex; align-items: center; margin-bottom: 12px; gap: 16px;">
|
||||
<div style="width: 100px; font-size: 13px; color: #606266;">{{ item.period }}</div>
|
||||
<div style="flex: 1; background: #f0f9eb; border-radius: 4px; height: 24px; position: relative;">
|
||||
<div :style="{ width: (item.views / maxViews * 100) + '%', background: '#67c23a', height: '100%', borderRadius: '4px', transition: 'width 0.3s' }"></div>
|
||||
</div>
|
||||
<div style="width: 80px; font-size: 13px; text-align: right;">{{ item.views || 0 }} 阅读</div>
|
||||
<div class="chart-container">
|
||||
<canvas ref="trendChartCanvas" style="width:100%;height:280px;"></canvas>
|
||||
</div>
|
||||
<div v-if="trendData.length === 0" style="text-align: center; color: #909399; padding: 20px;">暂无数据</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconStar /></el-icon> 平台对比</h3>
|
||||
<div v-if="platformData && platformData.length > 0">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:20px;margin-bottom:16px;">
|
||||
<div style="flex:1;min-width:250px;height:200px;">
|
||||
<canvas ref="platformChartCanvas" style="width:100%;height:100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics-table">
|
||||
<el-table :data="platformData" stripe size="small">
|
||||
<el-table-column prop="platform" label="平台" width="120">
|
||||
<template #default="scope">{{ getPlatformName(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="文章数" width="100"></el-table-column>
|
||||
<el-table-column prop="total_views" label="总阅读" width="120"></el-table-column>
|
||||
<el-table-column prop="avg_views" label="平均阅读" width="120">
|
||||
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_likes" label="总点赞" width="120"></el-table-column>
|
||||
<el-table-column prop="avg_likes" label="平均点赞" width="120">
|
||||
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="metrics-card-list">
|
||||
<div v-for="item in platformData" :key="item.platform" class="metrics-card">
|
||||
<div class="metrics-card-title">{{ getPlatformName(item.platform) }}</div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">文章数</span><span class="metrics-card-value">{{ item.count }}</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">总阅读</span><span class="metrics-card-value">{{ item.total_views }}</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">平均阅读</span><span class="metrics-card-value">{{ Math.round(item.avg_views || 0) }}</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">总点赞</span><span class="metrics-card-value">{{ item.total_likes }}</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">平均点赞</span><span class="metrics-card-value">{{ Math.round(item.avg_likes || 0) }}</span></div>
|
||||
</div>
|
||||
<div v-if="trendData.length === 0" style="text-align: center; color: #909399; padding: 40px;">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon><div class="empty-text">暂无平台对比数据</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;">🏆 平台对比</h3>
|
||||
<el-table :data="platformData" stripe size="small">
|
||||
<el-table-column prop="platform" label="平台" width="120">
|
||||
<template #default="scope">{{ getPlatformName(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="文章数" width="100"></el-table-column>
|
||||
<el-table-column prop="total_views" label="总阅读" width="120"></el-table-column>
|
||||
<el-table-column prop="avg_views" label="平均阅读" width="120">
|
||||
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_likes" label="总点赞" width="120"></el-table-column>
|
||||
<el-table-column prop="avg_likes" label="平均点赞" width="120">
|
||||
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;">💡 选题推荐</h3>
|
||||
<el-table :data="recommendations" stripe size="small">
|
||||
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="title" label="推荐选题"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="120"></el-table-column>
|
||||
<el-table-column prop="avg_engagement" label="互动率" width="100">
|
||||
<template #default="scope">{{ scope.row.avg_engagement }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="max_views" label="最高阅读" width="120"></el-table-column>
|
||||
<el-table-column prop="reason" label="推荐理由"></el-table-column>
|
||||
</el-table>
|
||||
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconInfo /></el-icon> 选题推荐</h3>
|
||||
<div v-if="recommendations && recommendations.length > 0">
|
||||
<div class="metrics-table">
|
||||
<el-table :data="recommendations" stripe size="small">
|
||||
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="title" label="推荐选题"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="120"></el-table-column>
|
||||
<el-table-column prop="avg_engagement" label="互动率" width="100">
|
||||
<template #default="scope">{{ scope.row.avg_engagement }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="max_views" label="最高阅读" width="120"></el-table-column>
|
||||
<el-table-column prop="reason" label="推荐理由"></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="metrics-card-list">
|
||||
<div v-for="item in recommendations" :key="item.topic_id" class="metrics-card">
|
||||
<div class="metrics-card-title">{{ item.topic_id }}. {{ item.title }}</div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">领域</span><span class="metrics-card-value">{{ item.field }}</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">互动率</span><span class="metrics-card-value">{{ item.avg_engagement }}%</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">最高阅读</span><span class="metrics-card-value">{{ item.max_views }}</span></div>
|
||||
<div class="metrics-card-reason">{{ item.reason }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconInfo /></el-icon><div class="empty-text">暂无选题推荐</div></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const MetricsApp = {
|
||||
@@ -144,15 +200,117 @@ const MetricsApp = {
|
||||
currentUser: { username: '' },
|
||||
isAdmin: false,
|
||||
isLoggedIn: false,
|
||||
loadingDashboard: false,
|
||||
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
|
||||
trendDays: 30,
|
||||
trendData: [],
|
||||
maxViews: 1,
|
||||
platformData: [],
|
||||
recommendations: []
|
||||
recommendations: [],
|
||||
chartInstances: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
statusColors(status) {
|
||||
return { pending: '#909399', review: '#409eff', ready: '#e6a23c', published: '#67c23a' }[status] || '#909399';
|
||||
},
|
||||
destroyCharts() {
|
||||
Object.values(this.chartInstances).forEach(c => { if (c) c.destroy(); });
|
||||
this.chartInstances = {};
|
||||
},
|
||||
renderCharts() {
|
||||
this.$nextTick(() => {
|
||||
this.renderTrendChart();
|
||||
this.renderStatusChart();
|
||||
this.renderPlatformChart();
|
||||
});
|
||||
},
|
||||
renderTrendChart() {
|
||||
const canvas = this.$refs.trendChartCanvas;
|
||||
if (!canvas) return;
|
||||
if (this.chartInstances.trend) this.chartInstances.trend.destroy();
|
||||
const data = this.trendData || [];
|
||||
if (data.length === 0) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
this.chartInstances.trend = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.map(d => d.period),
|
||||
datasets: [{
|
||||
label: '阅读量',
|
||||
data: data.map(d => d.views || 0),
|
||||
borderColor: '#409eff',
|
||||
backgroundColor: 'rgba(64,158,255,0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}, {
|
||||
label: '点赞',
|
||||
data: data.map(d => d.likes || 0),
|
||||
borderColor: '#67c23a',
|
||||
backgroundColor: 'rgba(103,194,58,0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top' } },
|
||||
scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { grid: { display: false } } }
|
||||
}
|
||||
});
|
||||
},
|
||||
renderPlatformChart() {
|
||||
const canvas = this.$refs.platformChartCanvas;
|
||||
if (!canvas) return;
|
||||
if (this.chartInstances.platform) this.chartInstances.platform.destroy();
|
||||
const data = this.platformData || [];
|
||||
if (data.length === 0) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const names = data.map(d => this.getPlatformName(d.platform));
|
||||
this.chartInstances.platform = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: names,
|
||||
datasets: [
|
||||
{ label: '总阅读', data: data.map(d => d.total_views || 0), backgroundColor: 'rgba(64,158,255,0.7)', borderRadius: 4 },
|
||||
{ label: '平均阅读', data: data.map(d => Math.round(d.avg_views || 0)), backgroundColor: 'rgba(103,194,58,0.7)', borderRadius: 4 },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top' } },
|
||||
scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { grid: { display: false } } }
|
||||
}
|
||||
});
|
||||
},
|
||||
renderStatusChart() {
|
||||
const canvas = this.$refs.statusChartCanvas;
|
||||
if (!canvas) return;
|
||||
if (this.chartInstances.status) this.chartInstances.status.destroy();
|
||||
const byStatus = this.dashboard.topics_by_status || {};
|
||||
const labels = [], data = [], colors = [];
|
||||
for (const [status, count] of Object.entries(byStatus)) {
|
||||
if (count > 0) { labels.push(this.getStatusLabel(status)); data.push(count); colors.push(this.statusColors(status)); }
|
||||
}
|
||||
if (data.length === 0) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
this.chartInstances.status = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 2, borderColor: '#fff' }] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
cutout: '60%'
|
||||
}
|
||||
});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
checkAuth() {
|
||||
@@ -180,11 +338,14 @@ const MetricsApp = {
|
||||
return map[platform] || platform;
|
||||
},
|
||||
async fetchDashboard() {
|
||||
this.loadingDashboard = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/metrics/dashboard?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.dashboard = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载概览失败: ' + e.message); }
|
||||
finally { this.loadingDashboard = false; this.renderCharts(); }
|
||||
},
|
||||
async fetchTrend() {
|
||||
try {
|
||||
@@ -192,33 +353,38 @@ const MetricsApp = {
|
||||
const res = await fetch('/api/metrics/trend?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) {
|
||||
this.trendData = await res.json();
|
||||
this.maxViews = Math.max(...this.trendData.map(t => t.views || 0), 1);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
} else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载趋势失败: ' + e.message); }
|
||||
this.renderCharts();
|
||||
},
|
||||
async fetchPlatformData() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/metrics/by-platform', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.platformData = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载平台数据失败: ' + e.message); }
|
||||
},
|
||||
async fetchRecommendations() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/metrics/recommend-topics?limit=10', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.recommendations = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载推荐失败: ' + e.message); }
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkAuth();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyCharts();
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(MetricsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// 公共页眉组件 - Vue 3
|
||||
(function() {
|
||||
console.log('[Navbar] 脚本加载');
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('navbar-styles')) return;
|
||||
const styles = `
|
||||
.navbar-component { position: fixed; top: 0; left: 0; right: 0; height: 60px; background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3); z-index: 10000; }
|
||||
.navbar-component .navbar-title { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.3px; }
|
||||
.navbar-component .navbar-user { display: flex; align-items: center; gap: 12px; }
|
||||
.navbar-component .user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.navbar-component .avatar { width: 34px; height: 34px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 600; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.navbar-component .logout-btn { background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 6px 14px; border-radius: 8px; cursor: pointer; margin-left: 12px; font-size: 13px; transition: all 0.2s; }
|
||||
.navbar-component .logout-btn:hover { background: rgba(255,255,255,0.25); border-color: rgba(255,255,255,0.3); transform: translateY(-1px); }
|
||||
.navbar-component .admin-badge { margin-left: 8px; font-size: 11px; background: rgba(255,255,255,0.2); padding: 2px 10px; border-radius: 12px; font-weight: 500; letter-spacing: 0.3px; }
|
||||
@media (min-width: 769px) {
|
||||
.navbar-component { padding: 0 24px 0 180px; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.navbar-component { padding: 0 16px; }
|
||||
.navbar-component .navbar-title { font-size: 16px; }
|
||||
}
|
||||
`;
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'navbar-styles';
|
||||
styleEl.textContent = styles;
|
||||
document.head.appendChild(styleEl);
|
||||
console.log('[Navbar] 样式已注入');
|
||||
}
|
||||
|
||||
const installNavbar = (app) => {
|
||||
console.log('[Navbar] installNavbar called');
|
||||
injectStyles();
|
||||
|
||||
const NavbarComponent = {
|
||||
name: 'NavbarComponent',
|
||||
props: {
|
||||
title: { type: String, default: '宇之然内容创作平台' },
|
||||
username: { type: String, default: '' },
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
onLogout: { type: Function, default: null }
|
||||
},
|
||||
template: `
|
||||
<nav class="navbar-component">
|
||||
<div class="navbar-content" style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||
<h1 class="navbar-title">{{ title }}</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ username ? username.charAt(0).toUpperCase() : '?' }}</div>
|
||||
<span v-if="username">{{ username }}</span>
|
||||
</div>
|
||||
<span v-if="isAdmin" class="admin-badge">管理员</span>
|
||||
<button class="logout-btn" @click="handleLogout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
`,
|
||||
methods: {
|
||||
handleLogout() {
|
||||
if (this.onLogout) {
|
||||
this.onLogout();
|
||||
} else {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('userRole');
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.component('navbar-component', NavbarComponent);
|
||||
console.log('[Navbar] 组件已注册');
|
||||
return app;
|
||||
};
|
||||
|
||||
window.installNavbar = installNavbar;
|
||||
console.log('[Navbar] 脚本已加载');
|
||||
})();
|
||||
@@ -1,383 +0,0 @@
|
||||
// 纯DOM导航 - 通过 installNavigation 直接插入
|
||||
(function() {
|
||||
console.log('[Nav] 脚本加载');
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('navigation-styles')) return;
|
||||
const styles = `
|
||||
.nav-wrapper, .navigation-wrapper { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; z-index: 9999; pointer-events: none; }
|
||||
.nav-wrapper .sidebar, .navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px 8px; box-shadow: 2px 0 12px rgba(0,0,0,0.06); overflow-y: auto; z-index: 10000; border-right: 1px solid #ebeef5; pointer-events: auto; }
|
||||
.nav-wrapper .sidebar-header, .navigation-wrapper .sidebar-header { padding: 12px 12px 16px; border-bottom: 1px solid #f0f2f5; margin-bottom: 12px; }
|
||||
.nav-wrapper .sidebar-header h3, .navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 15px; font-weight: 700; background: linear-gradient(135deg, #2563eb, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.3px; }
|
||||
.nav-wrapper .sidebar-btn, .navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 10px; margin-bottom: 4px; cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); color: #606266; font-size: 13px; display: flex; align-items: center; gap: 8px; position: relative; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nav-wrapper .sidebar-btn:hover, .navigation-wrapper .sidebar-btn:hover { background: #f0f4ff; color: #2563eb; transform: translateX(2px); }
|
||||
.nav-wrapper .sidebar-btn.active, .navigation-wrapper .sidebar-btn.active { background: linear-gradient(135deg, #eef2ff, #e0e7ff); color: #2563eb; font-weight: 600 !important; box-shadow: inset 3px 0 0 #2563eb; }
|
||||
.nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(135deg, #2563eb, #1d4ed8); box-shadow: 0 -4px 20px rgba(37, 99, 235, 0.25); padding: 4px 0; z-index: 99999; justify-content: space-around; pointer-events: auto; }
|
||||
.nav-wrapper .mobile-nav-btn, .navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 4px 2px; text-align: center; font-size: 10px; color: rgba(255,255,255,0.8) !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0; transition: all 0.2s; border-radius: 8px; margin: 0 1px; min-width: 0; }
|
||||
.nav-wrapper .mobile-nav-btn .nav-icon, .navigation-wrapper .mobile-nav-btn .nav-icon { font-size: 16px; line-height: 1.2; }
|
||||
.nav-wrapper .mobile-nav-btn .nav-text, .navigation-wrapper .mobile-nav-btn .nav-text { font-size: 9px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.nav-wrapper .mobile-nav-btn:hover, .navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.15); color: white !important; }
|
||||
.nav-wrapper .mobile-nav-btn.active, .navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.2); color: white !important; font-weight: 600 !important; }
|
||||
.mobile-more-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); z-index: 100000; animation: fadeIn 0.2s ease-out; }
|
||||
.mobile-more-sheet { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-radius: 16px 16px 0 0; box-shadow: 0 -8px 32px rgba(0,0,0,0.15); z-index: 100001; padding: 20px 16px calc(env(safe-area-inset-bottom) + 60px); animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); max-height: 70vh; overflow-y: auto; pointer-events: auto; }
|
||||
.mobile-more-sheet .sheet-handle { width: 36px; height: 4px; background: #e0e0e0; border-radius: 2px; margin: 0 auto 16px; }
|
||||
.mobile-more-sheet .sheet-title { font-size: 16px; font-weight: 700; color: #303133; margin-bottom: 16px; text-align: center; }
|
||||
.mobile-more-sheet .sheet-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.mobile-more-sheet .sheet-item { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 4px; border: none; background: transparent; border-radius: 12px; cursor: pointer; transition: all 0.2s; color: #606266; font-size: 12px; white-space: nowrap; }
|
||||
.mobile-more-sheet .sheet-item:hover { background: #f0f4ff; color: #2563eb; }
|
||||
.mobile-more-sheet .sheet-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }
|
||||
.mobile-more-sheet .sheet-item .item-icon { font-size: 24px; line-height: 1; }
|
||||
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@media (max-width: 768px) { .nav-wrapper .sidebar, .navigation-wrapper .sidebar { display: none !important; } .nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: flex !important; } body > #app > .main-content { padding-bottom: 56px !important; } }
|
||||
body > #app > .main-content { margin-left: 180px !important; padding-top: 60px !important; }
|
||||
@media (max-width: 768px) { body > #app > .main-content { margin-left: 0 !important; padding-bottom: 60px !important; } }
|
||||
`;
|
||||
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'navigation-styles', textContent: styles }));
|
||||
console.log('[Nav] 样式已注入');
|
||||
}
|
||||
|
||||
function createNavigation(currentPage, isAdmin, onNavigate) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'navigation-wrapper';
|
||||
|
||||
// 侧边栏
|
||||
const sidebar = document.createElement('aside');
|
||||
sidebar.className = 'sidebar';
|
||||
sidebar.innerHTML = `
|
||||
<div class="sidebar-header"><h3>宇之然平台</h3></div>
|
||||
<nav class="sidebar-nav">
|
||||
<button class="sidebar-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊 系统概览</button>
|
||||
<button class="sidebar-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋 选题管理</button>
|
||||
<button class="sidebar-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊 数据分析</button>
|
||||
<button class="sidebar-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html">📅 内容日历</button>
|
||||
<button class="sidebar-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️ 素材库</button>
|
||||
<button class="sidebar-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀 创作任务</button>
|
||||
<button class="sidebar-btn ${currentPage==='platforms'?'active':''}" data-page="platforms.html">🌐 平台配置</button>
|
||||
<button class="sidebar-btn ${currentPage==='logs'?'active':''}" data-page="logs.html">📄 系统日志</button>
|
||||
${isAdmin ? `<button class="sidebar-btn ${currentPage==='users'?'active':''}" data-page="users.html">👥 用户管理</button>` : ''}
|
||||
${isAdmin ? `<button class="sidebar-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️ 系统管理</button>` : ''}
|
||||
</nav>
|
||||
`;
|
||||
|
||||
// 移动端底部导航
|
||||
const mobileNav = document.createElement('nav');
|
||||
mobileNav.className = 'mobile-nav';
|
||||
mobileNav.innerHTML = `
|
||||
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/"><span class="nav-icon">📊</span><span class="nav-text">首页</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='topics'?'active':''}" data-page="topics.html"><span class="nav-icon">📋</span><span class="nav-text">选题</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html"><span class="nav-icon">🚀</span><span class="nav-text">任务</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html"><span class="nav-icon">📅</span><span class="nav-text">日历</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html"><span class="nav-icon">🖼️</span><span class="nav-text">素材</span></button>
|
||||
<button class="mobile-nav-btn" id="mobile-more-btn"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
|
||||
`;
|
||||
|
||||
// "更多"弹出菜单
|
||||
const sheetItems = [
|
||||
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
|
||||
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
|
||||
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
|
||||
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true },
|
||||
];
|
||||
|
||||
const sheetItemsHtml = sheetItems.map(item =>
|
||||
`<button class="sheet-item ${currentPage===item.key?'active':''}" data-page="${item.page}"${item.admin ? ' data-admin="1"' : ''} style="${item.admin && !isAdmin ? 'display:none' : ''}">
|
||||
<span class="item-icon">${item.icon}</span><span>${item.label}</span>
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'mobile-more-backdrop';
|
||||
backdrop.id = 'mobile-more-backdrop';
|
||||
backdrop.style.display = 'none';
|
||||
|
||||
const sheet = document.createElement('div');
|
||||
sheet.className = 'mobile-more-sheet';
|
||||
sheet.id = 'mobile-more-sheet';
|
||||
sheet.style.display = 'none';
|
||||
sheet.innerHTML = `<div class="sheet-handle"></div><div class="sheet-title">所有菜单</div><div class="sheet-grid">${sheetItemsHtml}</div>`;
|
||||
|
||||
const showMore = () => {
|
||||
backdrop.style.display = ''; sheet.style.display = '';
|
||||
};
|
||||
const hideMore = () => {
|
||||
backdrop.style.display = 'none'; sheet.style.display = 'none';
|
||||
};
|
||||
|
||||
// 绑定导航跳转
|
||||
const handleNavClick = (btn) => {
|
||||
const page = btn.getAttribute('data-page');
|
||||
if (!page) return;
|
||||
console.log('[Nav] 点击导航:', page);
|
||||
hideMore();
|
||||
if (onNavigate) { onNavigate(page); return; }
|
||||
const target = page === '/' ? '/' : (page.startsWith('/') ? page : '/' + page);
|
||||
window.location.href = target;
|
||||
};
|
||||
|
||||
wrapper.appendChild(sidebar);
|
||||
wrapper.appendChild(mobileNav);
|
||||
wrapper.appendChild(backdrop);
|
||||
wrapper.appendChild(sheet);
|
||||
|
||||
// 事件绑定
|
||||
wrapper.querySelectorAll('.sidebar-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
|
||||
});
|
||||
wrapper.querySelectorAll('.mobile-nav-btn').forEach(btn => {
|
||||
if (btn.id === 'mobile-more-btn') {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); showMore(); });
|
||||
} else {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
|
||||
}
|
||||
});
|
||||
wrapper.querySelectorAll('.sheet-item').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
|
||||
});
|
||||
backdrop.addEventListener('click', hideMore);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// 新的 installNavigation 接口:接收 app 对象(为了兼容),但实际不注册组件
|
||||
window.installNavigation = function(app, options = {}) {
|
||||
console.log('[Nav] installNavigation called (DOM mode)');
|
||||
injectStyles();
|
||||
|
||||
// 延迟执行,确保DOM就绪
|
||||
const init = () => {
|
||||
const container = document.getElementById('app');
|
||||
if (!container) {
|
||||
console.warn('[Nav] #app 未找到,等待');
|
||||
setTimeout(init, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前页面名称(从URL或body类名推断)
|
||||
let currentPage = 'dashboard';
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('topics')) currentPage = 'topics';
|
||||
else if (path.includes('calendar')) currentPage = 'calendar';
|
||||
else if (path.includes('metrics')) currentPage = 'metrics';
|
||||
else if (path.includes('assets')) currentPage = 'assets';
|
||||
else if (path.includes('tasks')) currentPage = 'tasks';
|
||||
else if (path.includes('platforms')) currentPage = 'platforms';
|
||||
else if (path.includes('logs')) currentPage = 'logs';
|
||||
else if (path.includes('users')) currentPage = 'users';
|
||||
else if (path.includes('admin')) currentPage = 'admin';
|
||||
|
||||
// 尝试从 Vue 实例获取 isAdmin
|
||||
let isAdmin = false;
|
||||
let redirectToPage = null;
|
||||
|
||||
// 首先检查 localStorage,如果用户已登录且是管理员
|
||||
const userRole = localStorage.getItem('userRole');
|
||||
if (userRole === 'admin') {
|
||||
isAdmin = true;
|
||||
console.log('[Nav] 从 localStorage 获取到 admin 角色');
|
||||
}
|
||||
|
||||
// 尝试从 Vue 实例提取数据
|
||||
const getVueData = () => {
|
||||
if (app && app._instance && app._instance.proxy) {
|
||||
return app._instance.proxy;
|
||||
} else if (window.Vue && window.Vue.app && window.Vue.app._instance && window.Vue.app._instance.proxy) {
|
||||
return window.Vue.app._instance.proxy;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateNavFromVue = () => {
|
||||
const proxy = getVueData();
|
||||
if (!proxy) return false;
|
||||
|
||||
const newIsAdmin = proxy.isAdmin === true;
|
||||
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
|
||||
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
|
||||
|
||||
// 如果侧边栏已存在,更新管理菜单显示状态
|
||||
const sidebar = document.querySelector('.sidebar-nav');
|
||||
if (sidebar) {
|
||||
const adminBtns = sidebar.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
|
||||
adminBtns.forEach(btn => {
|
||||
btn.style.display = isAdmin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 延迟获取 isAdmin,确保 Vue mounted 已执行
|
||||
setTimeout(() => {
|
||||
updateNavFromVue();
|
||||
console.log('[Nav] 延迟获取后 isAdmin:', isAdmin);
|
||||
|
||||
// 如果侧边栏已存在,再次更新管理菜单显示状态
|
||||
const sidebarEl = container.querySelector('.sidebar-nav');
|
||||
if (sidebarEl) {
|
||||
const adminBtns = sidebarEl.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
|
||||
adminBtns.forEach(btn => {
|
||||
btn.style.display = isAdmin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 持续监听 Vue 数据变化
|
||||
const stopWatch = setInterval(() => {
|
||||
const updated = updateNavFromVue();
|
||||
if (updated && isAdmin) {
|
||||
clearInterval(stopWatch);
|
||||
console.log('[Nav] 已获取到 isAdmin:', isAdmin);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// 5秒后停止监听
|
||||
setTimeout(() => clearInterval(stopWatch), 5000);
|
||||
|
||||
console.log('[Nav] currentPage:', currentPage, '初始 isAdmin:', isAdmin);
|
||||
|
||||
// 默认跳转函数
|
||||
const defaultNavigate = (page) => {
|
||||
const target = page === '/' ? '/index.html' : (page.startsWith('/') ? page : '/' + page);
|
||||
window.location.href = target;
|
||||
};
|
||||
|
||||
// 移除旧的导航容器(如果有)
|
||||
const oldNav = container.querySelector('.navigation-wrapper');
|
||||
if (oldNav) oldNav.remove();
|
||||
|
||||
const nav = createNavigation(currentPage, isAdmin, redirectToPage || defaultNavigate);
|
||||
container.insertBefore(nav, container.firstChild);
|
||||
console.log('[Nav] 导航已插入');
|
||||
};
|
||||
|
||||
// 延迟执行,确保 Vue 实例挂载完成
|
||||
setTimeout(init, 100);
|
||||
return app;
|
||||
};
|
||||
|
||||
console.log('[Nav] 脚本已加载(纯DOM版)');
|
||||
|
||||
// 注册 Vue 组件 (备用方案)
|
||||
const createNavComponent = () => ({
|
||||
props: {
|
||||
currentPage: { type: String, default: 'dashboard' },
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
onNavigate: { type: Function, default: null }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
menuItems: [
|
||||
{ key: 'dashboard', label: '系统概览', icon: '📊', page: '/' },
|
||||
{ key: 'topics', label: '选题管理', icon: '📋', page: 'topics.html' },
|
||||
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
|
||||
{ key: 'calendar', label: '内容日历', icon: '📅', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材库', icon: '🖼️', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '创作任务', icon: '🚀', page: 'tasks.html' },
|
||||
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' }
|
||||
],
|
||||
adminItems: [
|
||||
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html' },
|
||||
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html' }
|
||||
],
|
||||
mobileItems: [
|
||||
{ key: 'dashboard', label: '首页', icon: '📊', page: '/' },
|
||||
{ key: 'topics', label: '选题', icon: '📋', page: 'topics.html' },
|
||||
{ key: 'tasks', label: '任务', icon: '🚀', page: 'tasks.html' },
|
||||
{ key: 'calendar', label: '日历', icon: '📅', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', icon: '🖼️', page: 'assets.html' }
|
||||
],
|
||||
sheetItems: [
|
||||
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
|
||||
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
|
||||
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
|
||||
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true }
|
||||
],
|
||||
showMoreSheet: false
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="nav-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><h3>宇之然平台</h3></div>
|
||||
<nav class="sidebar-nav">
|
||||
<button v-for="item in menuItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
{{ item.icon }} {{ item.label }}
|
||||
</button>
|
||||
<template v-if="isAdmin">
|
||||
<button v-for="item in adminItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
{{ item.icon }} {{ item.label }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
<nav class="mobile-nav">
|
||||
<button v-for="item in mobileItems" :key="item.key" :class="['mobile-nav-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" @click="showMoreSheet = !showMoreSheet"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
|
||||
</nav>
|
||||
<div v-if="showMoreSheet" class="mobile-more-backdrop" @click="showMoreSheet = false"></div>
|
||||
<div v-if="showMoreSheet" class="mobile-more-sheet">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-title">所有菜单</div>
|
||||
<div class="sheet-grid">
|
||||
<button v-for="item in sheetItems" :key="item.key" v-show="!item.admin || isAdmin" :class="['sheet-item', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
<span class="item-icon">{{ item.icon }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
navigate(page) {
|
||||
console.log('[Nav Vue] 点击导航:', page);
|
||||
if (this.onNavigate) {
|
||||
this.onNavigate(page);
|
||||
} else {
|
||||
// 修复路径处理
|
||||
let target;
|
||||
if (page === '/' || page === '') {
|
||||
target = '/';
|
||||
} else if (page.startsWith('/')) {
|
||||
target = page;
|
||||
} else {
|
||||
target = '/' + page;
|
||||
}
|
||||
window.location.href = target;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
injectStyles();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册全局组件
|
||||
window.NavigationComponent = createNavComponent();
|
||||
|
||||
// 自动注入 DOM 导航 (优先使用)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => {
|
||||
if (window.Vue && window.Vue.app) {
|
||||
window.Vue.app._instance.proxy.$nextTick(() => {
|
||||
const app = window.Vue.app;
|
||||
if (app && app._instance && app._instance.proxy) {
|
||||
const proxy = app._instance.proxy;
|
||||
const nav = createNavigation(proxy.currentPage || 'dashboard', proxy.isAdmin || false, proxy.redirectToPage || null);
|
||||
const container = document.getElementById('app');
|
||||
if (container && !container.querySelector('.navigation-wrapper')) {
|
||||
container.insertBefore(nav, container.firstChild);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -7,19 +7,6 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.platform-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 20px; margin-bottom: 16px; transition: all 0.3s ease; word-break: break-word; background: #fff; }
|
||||
.platform-card:hover { border-color: #409eff; box-shadow: 0 4px 20px rgba(64,158,255,0.12); transform: translateY(-1px); }
|
||||
.platform-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; }
|
||||
@@ -39,51 +26,44 @@
|
||||
.rule-item strong { color: #303133; }
|
||||
.rule-item:last-child { margin-bottom: 0; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #909399; }
|
||||
.empty-state-icon { font-size: 56px; margin-bottom: 16px; }
|
||||
.empty-state-text { font-size: 16px; }
|
||||
|
||||
.loading-state { text-align: center; padding: 60px 20px; color: #909399; font-size: 16px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.toolbar { width: 100%; }
|
||||
.toolbar .el-button { flex: 1; justify-content: center; }
|
||||
.platform-card { padding: 16px; }
|
||||
.platform-header { flex-direction: column; }
|
||||
.platform-actions { align-self: flex-end; }
|
||||
.platform-actions { align-self: flex-end; width: 100%; display: flex; justify-content: flex-end; }
|
||||
.platform-meta { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="平台配置" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="platforms" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="平台配置" :username="currentUser.username" :is-admin="isAdmin" current-page="platforms" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">🌐 平台配置</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconGlobe /></el-icon> 平台配置</h2>
|
||||
<div class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button :type="showActiveOnly ? 'primary' : ''" @click="showActiveOnly = true; loadPlatforms()">启用中</el-button>
|
||||
<el-button :type="!showActiveOnly ? 'primary' : ''" @click="showActiveOnly = false; loadPlatforms()">全部</el-button>
|
||||
</el-button-group>
|
||||
<el-button @click="loadPlatforms">🔄 刷新</el-button>
|
||||
<el-button @click="loadPlatforms"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card page-fade">
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="platforms.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon">🌐</div>
|
||||
<div class="empty-state-text">暂无平台配置</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
|
||||
<div class="empty-text">暂无平台配置</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="p in platforms" :key="p.platform" class="platform-card">
|
||||
<div class="platform-header">
|
||||
<div class="platform-name">
|
||||
<span>{{ getPlatformIcon(p.platform) }}</span>
|
||||
<el-icon><component :is="getPlatformIcon(p.platform)" /></el-icon>
|
||||
{{ getPlatformName(p.platform) }}
|
||||
</div>
|
||||
<div class="platform-actions">
|
||||
@@ -108,13 +88,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0" class="rules-section">
|
||||
<div class="rules-title">📋 格式规则</div>
|
||||
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 格式规则</div>
|
||||
<div v-for="(rule, key) in p.format_rules" :key="key" class="rule-item">
|
||||
<strong>{{ key }}:</strong> {{ typeof rule === 'object' ? JSON.stringify(rule) : rule }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="p.compliance_rules && p.compliance_rules.length > 0" class="rules-section">
|
||||
<div class="rules-title">⚖️ 合规规则</div>
|
||||
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconWarning /></el-icon> 合规规则</div>
|
||||
<div v-for="(rule, idx) in p.compliance_rules" :key="idx" class="rule-item">{{ rule }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,6 +132,7 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const PlatformsApp = {
|
||||
@@ -186,8 +167,8 @@ const PlatformsApp = {
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
getPlatformIcon(platform) {
|
||||
const map = { 'zhihu': '💬', 'wechat': '💌', 'xiaohongshu': '📕', 'weibo': '🌐' };
|
||||
return map[platform] || '🌐';
|
||||
const map = { 'zhihu': 'IconTopic', 'wechat': 'IconDocument', 'xiaohongshu': 'IconPicture', 'weibo': 'IconGlobe' };
|
||||
return map[platform] || 'IconGlobe';
|
||||
},
|
||||
getPlatformName(platform) {
|
||||
const map = { 'zhihu': '知乎', 'wechat': '微信公众号', 'xiaohongshu': '小红书', 'weibo': '微博' };
|
||||
@@ -199,7 +180,8 @@ const PlatformsApp = {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/platform-config?active_only=' + this.showActiveOnly, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.platforms = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json(); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载平台配置失败: ' + e.message); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
editPlatform(p) {
|
||||
@@ -234,8 +216,8 @@ const PlatformsApp = {
|
||||
};
|
||||
const app = Vue.createApp(PlatformsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -7,17 +7,7 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.toolbar { gap: 12px; }
|
||||
|
||||
.task-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; margin-bottom: 12px; transition: all 0.3s; background: #fff; }
|
||||
.task-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.12); transform: translateY(-1px); }
|
||||
@@ -56,38 +46,35 @@
|
||||
.detail-value { flex: 1; font-size: 13px; color: #303133; word-break: break-all; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.task-table { display: none; }
|
||||
.task-card-list-mobile { display: block; }
|
||||
.schedule-row { padding: 10px 12px; gap: 10px; flex-wrap: wrap; }
|
||||
.schedule-next { width: 100%; margin-left: 46px; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="创作任务" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="tasks" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="创作任务" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">🚀 定时任务</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 定时任务</h2>
|
||||
<div class="toolbar">
|
||||
<el-button @click="loadSchedulerStatus">🔄 刷新</el-button>
|
||||
<el-button @click="loadSchedulerStatus"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="schedulerLoading" style="text-align: center; padding: 20px; color: #909399;">加载中...</div>
|
||||
<div v-else>
|
||||
<div v-if="schedulerJobs.length === 0" style="text-align: center; padding: 30px 20px; color: #909399; font-size: 14px;">暂无定时任务</div>
|
||||
<div v-for="job in schedulerJobs" :key="job.id" class="schedule-row">
|
||||
<div class="schedule-icon">{{ job.icon }}</div>
|
||||
<div class="schedule-icon"><el-icon><component :is="job.icon" /></el-icon></div>
|
||||
<div class="schedule-info">
|
||||
<div class="schedule-name">{{ job.name }}</div>
|
||||
<div class="schedule-time">⏰ 每日 {{ job.time }}</div>
|
||||
<div class="schedule-time"><el-icon style="vertical-align:-2px;"><IconClock /></el-icon> 每日 {{ job.time }}</div>
|
||||
</div>
|
||||
<div class="schedule-next">
|
||||
<span class="schedule-badge" :class="job.active ? 'active' : 'inactive'"></span>
|
||||
@@ -100,7 +87,7 @@
|
||||
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">🚀 创作任务</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 创作任务</h2>
|
||||
<div class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
|
||||
@@ -109,12 +96,12 @@
|
||||
<el-button :type="filterStatus === 'completed' ? 'primary' : ''" @click="filterStatus = 'completed'; loadTasks()">已完成</el-button>
|
||||
<el-button :type="filterStatus === 'failed' ? 'primary' : ''" @click="filterStatus = 'failed'; loadTasks()">失败</el-button>
|
||||
</el-button-group>
|
||||
<el-button @click="loadTasks">🔄 刷新</el-button>
|
||||
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" style="text-align: center; padding: 40px; color: #909399;">加载中...</div>
|
||||
<div v-else-if="tasks.length === 0" style="text-align: center; padding: 60px 20px; color: #909399;">
|
||||
<div style="font-size: 56px; margin-bottom: 16px;">📋</div>
|
||||
<el-icon style="font-size:56px;margin-bottom:16px;color:#c0c4cc;"><IconTopic /></el-icon>
|
||||
<div style="font-size: 16px;">暂无任务</div>
|
||||
<div style="margin-top: 12px; font-size: 14px;">前往 <a href="/topics.html" style="color: #409eff;">选题管理</a> 创建新任务</div>
|
||||
</div>
|
||||
@@ -173,14 +160,15 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const TasksApp = {
|
||||
data() {
|
||||
const SCHEDULER_JOBS = {
|
||||
'scheduled_sync': { icon: '🔄', name: '数据同步', defaultTime: '02:30' },
|
||||
'scheduled_generate': { icon: '🤖', name: '内容创作', defaultTime: '03:30' },
|
||||
'scheduled_optimize': { icon: '🔍', name: '合规审查', defaultTime: '04:30' },
|
||||
'scheduled_sync': { icon: 'IconRefresh', name: '数据同步', defaultTime: '02:30' },
|
||||
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
|
||||
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04:30' },
|
||||
};
|
||||
return {
|
||||
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
|
||||
@@ -215,7 +203,7 @@ const TasksApp = {
|
||||
const data = await this.api('/api/system/scheduler/status');
|
||||
if (!data) return;
|
||||
this.schedulerJobs = (data.jobs || []).map(job => {
|
||||
const info = this.SCHEDULER_JOBS[job.id] || { icon: '⏰', name: job.id, defaultTime: '' };
|
||||
const info = this.SCHEDULER_JOBS[job.id] || { icon: 'IconClock', name: job.id, defaultTime: '' };
|
||||
// Parse cron trigger for time display
|
||||
let time = info.defaultTime;
|
||||
const m = job.trigger && job.trigger.match(/hour='?(\d+)'?,\s*minute='?(\d+)'?/);
|
||||
@@ -227,7 +215,7 @@ const TasksApp = {
|
||||
}
|
||||
return { id: job.id, icon: info.icon, name: info.name, time, active: data.running, next_run };
|
||||
});
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载调度状态失败: ' + e.message); }
|
||||
finally { this.schedulerLoading = false; }
|
||||
},
|
||||
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
|
||||
@@ -242,7 +230,7 @@ const TasksApp = {
|
||||
let url = '/api/tasks?limit=50';
|
||||
if (this.filterStatus) url += '&status=' + this.filterStatus;
|
||||
this.tasks = await this.api(url) || [];
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载任务列表失败: ' + e.message); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
|
||||
@@ -267,8 +255,8 @@ const TasksApp = {
|
||||
};
|
||||
const app = Vue.createApp(TasksApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -36,12 +36,33 @@
|
||||
.status-badge-modern { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
|
||||
.status-badge-modern.running { background: rgba(103, 194, 58, 0.12); color: #67c23a; border: 1px solid rgba(103, 194, 58, 0.25); animation: pulse-glow 2s infinite; }
|
||||
|
||||
/* Modern el-table refinements */
|
||||
.el-table { border-radius: 12px; overflow: hidden; }
|
||||
/* el-table refinements */
|
||||
.el-table { border-radius: 12px; overflow: hidden; width: 100%; }
|
||||
.el-table .el-table__cell { word-break: break-word; }
|
||||
|
||||
/* Smooth page transitions */
|
||||
.page-enter-active { animation: fadeIn 0.4s ease-out; }
|
||||
|
||||
/* ========== Reset & Base ========== */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
|
||||
|
||||
/* ========== 统一布局 ========== */
|
||||
/* 固定顶部导航占位,所有页面自动继承 */
|
||||
.main-content { padding-top: 60px !important; display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
|
||||
/* ========== 共享卡片 ========== */
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
/* ========== 页面标题 ========== */
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ========== 筛选/工具栏 ========== */
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* ========== H5响应式 ========== */
|
||||
@media (max-width: 768px) {
|
||||
/* 对话框自适应宽度 */
|
||||
@@ -51,14 +72,107 @@
|
||||
/* 表单网格在手机上改为单列 */
|
||||
.el-row .el-col-12 { width: 100% !important; }
|
||||
|
||||
/* 手机端表格卡片 */
|
||||
/* 手机端表格卡片 */
|
||||
.mobile-card { position: relative; }
|
||||
.mobile-card:active { transform: scale(0.98); }
|
||||
|
||||
/* 共享 content-area + card 响应式 */
|
||||
.content-area { padding: 16px !important; padding-bottom: 80px; }
|
||||
.card { padding: 16px !important; }
|
||||
|
||||
/* 页面标题在手机上紧凑 */
|
||||
.page-header { flex-direction: column; align-items: flex-start !important; gap: 8px !important; }
|
||||
}
|
||||
|
||||
/* 触摸优化:增大按钮点击区域 */
|
||||
/* ========== 预览对话框(选题/文章共用) ========== */
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
.preview-dialog-custom.el-dialog { max-height: calc(100vh - 90px); 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; }
|
||||
.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; }
|
||||
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
|
||||
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
|
||||
@media (max-width: 768px) { .preview-iframe { max-height: calc(100vh - 250px) !important; } }
|
||||
@media (min-width: 769px) { .preview-iframe { max-height: calc(100vh - 100px) !important; } .preview-dialog-custom { position: relative; left: 90px; } }
|
||||
|
||||
/* ========== 共享卡片无数据状态 ========== */
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #909399; }
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.4; }
|
||||
.empty-state .empty-text { font-size: 14px; margin-bottom: 16px; }
|
||||
.loading-state { text-align: center; padding: 60px 20px; color: #909399; font-size: 16px; }
|
||||
|
||||
/* ========== 通用移动端卡片列表(替代表格) ========== */
|
||||
.card-table { display: block; }
|
||||
.card-list-mobile { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
.card-table { display: none; }
|
||||
.card-list-mobile { display: block; }
|
||||
}
|
||||
.card-list-mobile .card-item {
|
||||
background: #fafbfc; border-radius: 10px; padding: 14px; margin-bottom: 10px;
|
||||
border: 1px solid #ebeef5; transition: all 0.2s ease;
|
||||
}
|
||||
.card-list-mobile .card-item:active { transform: scale(0.99); }
|
||||
.card-list-mobile .card-row {
|
||||
display: flex; justify-content: space-between; padding: 6px 0;
|
||||
font-size: 13px; border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
.card-list-mobile .card-row:last-child { border-bottom: none; }
|
||||
.card-list-mobile .card-label { color: #909399; flex-shrink: 0; margin-right: 8px; }
|
||||
.card-list-mobile .card-value { color: #303133; text-align: right; word-break: break-word; }
|
||||
.card-list-mobile .card-actions {
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
padding-top: 10px; margin-top: 6px; border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
/* ========== 统计摘要条 ========== */
|
||||
.stat-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.stat-summary .stat-item {
|
||||
background: #f0f5ff; border-radius: 8px; padding: 12px 20px;
|
||||
display: flex; flex-direction: column; align-items: center; min-width: 100px;
|
||||
}
|
||||
.stat-summary .stat-item .num { font-size: 24px; font-weight: 600; color: #409eff; }
|
||||
.stat-summary .stat-item .label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
@media (max-width: 768px) {
|
||||
.stat-summary { gap: 8px; }
|
||||
.stat-summary .stat-item { min-width: 70px; padding: 8px 12px; }
|
||||
.stat-summary .stat-item .num { font-size: 18px; }
|
||||
}
|
||||
|
||||
/* ========== 加载中状态 ========== */
|
||||
.card-loading { display: flex; justify-content: center; align-items: center; min-height: 200px; color: #909399; font-size: 14px; }
|
||||
|
||||
/* ========== 状态圆点 ========== */
|
||||
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
|
||||
.status-dot.pending { background: #E6A23C; }
|
||||
.status-dot.review { background: #F56C6C; }
|
||||
.status-dot.ready { background: #67C23A; }
|
||||
.status-dot.published { background: #409EFF; }
|
||||
.status-dot.draft { background: #909399; }
|
||||
|
||||
/* ========== 搜索栏 ========== */
|
||||
.search-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
|
||||
@media (max-width: 768px) {
|
||||
.search-bar { flex-direction: column; align-items: stretch; }
|
||||
.search-bar .el-input, .search-bar .el-select { width: 100% !important; }
|
||||
}
|
||||
|
||||
/* ========== 触摸优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.el-button { min-height: 36px; }
|
||||
.el-button--small { min-height: 36px; padding: 8px 14px !important; }
|
||||
.el-button--default { padding: 10px 16px !important; }
|
||||
.el-input__inner { min-height: 38px; }
|
||||
.el-table__cell .el-button { padding: 8px 12px !important; }
|
||||
|
||||
.btn-group-mobile { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.btn-group-mobile .el-button { flex: 1; min-width: 0; justify-content: center; }
|
||||
|
||||
.action-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.action-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
|
||||
.filter-bar-mobile { flex-direction: column; align-items: stretch !important; }
|
||||
.filter-bar-mobile .el-select,
|
||||
.filter-bar-mobile .el-input { width: 100% !important; }
|
||||
.page-header-mobile { flex-direction: column; align-items: flex-start !important; gap: 8px !important; }
|
||||
}
|
||||
|
||||
+123
-74
@@ -7,16 +7,6 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.selected-count { color: #909399; font-size: 14px; margin-left: auto; }
|
||||
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 4px; }
|
||||
@@ -28,17 +18,7 @@
|
||||
|
||||
.topic-card-list { display: none; }
|
||||
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
.preview-dialog-custom.el-dialog { max-height: calc(100vh - 90px); 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; }
|
||||
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
|
||||
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 12px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.el-table { display: none; }
|
||||
.topic-card-list { display: block; }
|
||||
.topic-card {
|
||||
@@ -52,41 +32,38 @@
|
||||
.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 { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
.preview-dialog-custom { position: relative; left: 90px; }
|
||||
.topic-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
|
||||
.topic-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
|
||||
.topic-card-actions .el-button:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||
.topic-card.is-checked { border: 2px solid #409eff; box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="选题管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="topics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">📋 选题管理</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 选题管理</h2>
|
||||
<div class="toolbar">
|
||||
<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="triggerReviewSelected" :disabled="selectedTopicIds.length === 0">🔍 批量审查</el-button>
|
||||
<el-button type="primary" size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷新</el-button>
|
||||
<el-button size="small" @click="toggleSelectAll">{{ selectAllLabel }}</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon> 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 批量审查</el-button>
|
||||
<span v-if="selectedTopicIds.length > 0" class="selected-count">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'">今日新增 ({{ todayCount }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; fetchTopics()">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'; fetchTopics()">今日新增 ({{ todayCount }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'; fetchTopics()">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
@@ -102,22 +79,26 @@
|
||||
<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="200" fixed="right">
|
||||
<el-table-column label="操作" width="230" 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="reviewTopic(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 style="display: flex; gap: 4px; white-space: nowrap;">
|
||||
<el-button size="small" @click="openPreview(scope.row)" type="primary" style="padding:5px 8px;">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!loadingTable && filteredTopics.length === 0" class="empty-state" style="margin-top:20px;"><el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon><div class="empty-text">暂无选题数据</div></div>
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card" :class="{ 'is-checked': selectedTopicIds.includes(topic.id) }">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<div class="topic-card-title">
|
||||
<el-checkbox :checked="selectedTopicIds.includes(topic.id)" @change="toggleCheck(topic.id)" style="margin-right:6px;"></el-checkbox>
|
||||
{{ topic.id }}. {{ topic.title }}
|
||||
</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
@@ -133,7 +114,7 @@
|
||||
<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="reviewTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,6 +122,30 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
|
||||
<div v-if="publishTopic">
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
|
||||
<div style="font-size:15px;font-weight:600;color:#303133;">{{ publishTopic.id }}. {{ publishTopic.title }}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选择发布平台:</div>
|
||||
<el-checkbox v-model="publishPlatforms.zhihu" label="zhihu" style="display:block;margin-bottom:8px;">知乎</el-checkbox>
|
||||
<el-checkbox v-model="publishPlatforms.wechat" label="wechat" style="display:block;margin-bottom:8px;">微信公众号</el-checkbox>
|
||||
<el-checkbox v-model="publishPlatforms.xiaohongshu" label="xiaohongshu" style="display:block;">小红书</el-checkbox>
|
||||
</div>
|
||||
<div v-if="publishing" style="text-align:center;padding:12px;color:#909399;">
|
||||
<el-icon class="is-loading" style="margin-right:4px;">
|
||||
<svg viewBox="0 0 1024 1024" width="16" height="16"><path d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896z" fill="none" stroke="currentColor" stroke-width="64"/></svg>
|
||||
</el-icon>
|
||||
正在发布...
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="publishDialogVisible = false" :disabled="publishing">取消</el-button>
|
||||
<el-button type="primary" @click="confirmPublish" :loading="publishing" :disabled="!publishPlatforms.zhihu && !publishPlatforms.wechat && !publishPlatforms.xiaohongshu">确认发布</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
|
||||
<div v-if="previewTopic">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
@@ -181,6 +186,7 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
@@ -191,22 +197,15 @@ const TopicsApp = {
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [], todayCount: 0,
|
||||
previewVisible: false, previewTopic: null, previewFullscreen: false,
|
||||
previewPlatform: 'zhihu', platformContents: {}
|
||||
previewPlatform: 'zhihu', platformContents: {},
|
||||
publishDialogVisible: false, publishTopic: null,
|
||||
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
|
||||
publishing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) return [];
|
||||
if (!this.filterStatus) return this.topics;
|
||||
if (this.filterStatus === 'today') {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
return this.topics.filter(t => {
|
||||
const d = t.created_at;
|
||||
if (!d) return false;
|
||||
return d.slice(0, 10) === todayStr;
|
||||
});
|
||||
}
|
||||
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
|
||||
const allowed = map[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
@@ -231,6 +230,12 @@ const TopicsApp = {
|
||||
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; }
|
||||
},
|
||||
selectAllLabel() {
|
||||
const visible = this.filteredTopics.map(t => t.id);
|
||||
if (visible.length === 0) return '全选';
|
||||
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
|
||||
return allChecked ? '取消全选' : `全选 (${visible.length})`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -245,7 +250,8 @@ const TopicsApp = {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const data = await this.api('/api/topics');
|
||||
const params = this.filterStatus === 'today' ? '?today=true' : '';
|
||||
const data = await this.api('/api/topics' + params);
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
@@ -258,7 +264,7 @@ const TopicsApp = {
|
||||
try {
|
||||
const stats = await this.api('/api/topics/stats');
|
||||
if (stats) this.todayCount = stats.today_created || 0;
|
||||
} catch (e) { console.error('获取统计失败:', e); }
|
||||
} catch (e) { console.error('获取统计失败:', e); this.$message.error('获取统计失败: ' + e.message); }
|
||||
},
|
||||
refreshAll() { this.fetchTopics(); this.fetchTodayCount(); this.$message.success('已刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
@@ -272,7 +278,7 @@ const TopicsApp = {
|
||||
}
|
||||
this.$message.success(`已提交 ${count}/${this.selectedTopicIds.length} 个创作任务,可在「创作任务」页面查看进度`);
|
||||
this.selectedTopicIds = [];
|
||||
setTimeout(() => this.fetchTopics(), 2000);
|
||||
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
|
||||
},
|
||||
async triggerReviewSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
@@ -281,6 +287,7 @@ const TopicsApp = {
|
||||
this.$message.success('批量审查完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (error) { this.$message.error(`批量审查失败: ${error.message}`); }
|
||||
},
|
||||
async openPreview(topic) {
|
||||
@@ -288,9 +295,10 @@ const TopicsApp = {
|
||||
const token = this.getToken();
|
||||
if (!token) return;
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const names = { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' };
|
||||
await Promise.all(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))
|
||||
.then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => { console.error(`加载${p}预览失败:`, e); this.$message.error(`加载${names[p]}预览失败`); })
|
||||
));
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
@@ -321,7 +329,7 @@ const TopicsApp = {
|
||||
try {
|
||||
const data = await this.api('/api/tasks/run-creator?topic_id=' + topic.id, { method: 'POST' });
|
||||
this.$message.success(`创作任务已启动: ${topic.title},可在「创作任务」页面查看进度`);
|
||||
setTimeout(() => this.fetchTopics(), 2000);
|
||||
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
|
||||
} catch (error) { this.$message.error(`创作失败: ${error.message}`); }
|
||||
},
|
||||
async reviewTopic(topic) {
|
||||
@@ -330,15 +338,31 @@ const TopicsApp = {
|
||||
const data = await this.api('/api/system/review/run', { method: 'POST', body: JSON.stringify({ topic_ids: [topic.id] }) });
|
||||
this.$message.success(`审查完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (error) { this.$message.error(`审查失败: ${error.message}`); }
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
openPublishDialog(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) { this.$message.info('仅待发布选题可发布'); return; }
|
||||
this.publishTopic = topic;
|
||||
this.publishPlatforms = { zhihu: true, wechat: true, xiaohongshu: true };
|
||||
this.publishing = false;
|
||||
this.publishDialogVisible = true;
|
||||
},
|
||||
async confirmPublish() {
|
||||
const selected = Object.entries(this.publishPlatforms).filter(([, v]) => v).map(([k]) => k);
|
||||
if (selected.length === 0) { this.$message.warning('请至少选择一个平台'); return; }
|
||||
this.publishing = true;
|
||||
try {
|
||||
const data = await this.api('/api/publishing/create', { method: 'POST', body: JSON.stringify({ topic_id: topic.id }) });
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
const data = await this.api('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
|
||||
});
|
||||
this.$message.success(`发布完成: ${this.publishTopic.title}`);
|
||||
this.publishDialogVisible = false;
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (error) { this.$message.error(`发布失败: ${error.message}`); }
|
||||
finally { this.publishing = false; }
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
try {
|
||||
@@ -346,11 +370,30 @@ const TopicsApp = {
|
||||
await this.api('/api/topics/' + id, { method: 'DELETE' });
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败'); }
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
|
||||
toggleCheck(id) {
|
||||
const idx = this.selectedTopicIds.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
this.selectedTopicIds.splice(idx, 1);
|
||||
} else {
|
||||
this.selectedTopicIds.push(id);
|
||||
}
|
||||
},
|
||||
toggleSelectAll() {
|
||||
const visible = this.filteredTopics.map(t => t.id);
|
||||
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
|
||||
if (allChecked) {
|
||||
this.selectedTopicIds = this.selectedTopicIds.filter(id => !visible.includes(id));
|
||||
} else {
|
||||
const existing = new Set(this.selectedTopicIds);
|
||||
for (const id of visible) { if (!existing.has(id)) { this.selectedTopicIds.push(id); } }
|
||||
}
|
||||
},
|
||||
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' }); }
|
||||
@@ -362,18 +405,24 @@ const TopicsApp = {
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
const filter = new URLSearchParams(window.location.search).get('filter');
|
||||
if (filter) this.filterStatus = filter;
|
||||
const urlFilter = new URLSearchParams(window.location.search).get('filter');
|
||||
if (urlFilter) this.filterStatus = urlFilter;
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchTopics(); this.fetchTodayCount(); })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
watch: {
|
||||
topics() {
|
||||
const urlFilter = new URLSearchParams(window.location.search).get('filter');
|
||||
if (urlFilter) this.filterStatus = urlFilter;
|
||||
}
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
(function () {
|
||||
if (document.getElementById('uni-nav-styles')) return;
|
||||
var style = document.createElement('style');
|
||||
style.id = 'uni-nav-styles';
|
||||
style.textContent = '\
|
||||
.uni-nav {\
|
||||
position: fixed; top: 0; left: 0; right: 0; height: 60px;\
|
||||
z-index: 10000;\
|
||||
display: flex; align-items: center;\
|
||||
padding: 0 16px;\
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
|
||||
color: #fff;\
|
||||
box-shadow: 0 2px 12px rgba(37,99,235,0.25);\
|
||||
}\
|
||||
.uni-nav-inner {\
|
||||
display: flex; align-items: center;\
|
||||
width: 100%; max-width: 1400px; margin: 0 auto;\
|
||||
gap: 8px;\
|
||||
}\
|
||||
.uni-nav-brand {\
|
||||
font-size: 18px; font-weight: 700;\
|
||||
white-space: nowrap;\
|
||||
margin-right: 8px;\
|
||||
letter-spacing: -0.3px;\
|
||||
}\
|
||||
.uni-nav-items {\
|
||||
display: flex; align-items: center; gap: 2px;\
|
||||
flex: 1; overflow: hidden;\
|
||||
}\
|
||||
.uni-nav-item {\
|
||||
display: inline-flex; align-items: center; gap: 4px;\
|
||||
padding: 6px 12px;\
|
||||
border: none; background: transparent;\
|
||||
color: rgba(255,255,255,0.8);\
|
||||
font-size: 13px;\
|
||||
border-radius: 8px;\
|
||||
cursor: pointer;\
|
||||
white-space: nowrap;\
|
||||
transition: all 0.2s;\
|
||||
}\
|
||||
.uni-nav-item:hover { background: rgba(255,255,255,0.12); color: #fff; }\
|
||||
.uni-nav-item.active {\
|
||||
background: rgba(255,255,255,0.18); color: #fff; font-weight: 600;\
|
||||
}\
|
||||
.uni-nav-right {\
|
||||
display: flex; align-items: center; gap: 8px;\
|
||||
flex-shrink: 0;\
|
||||
}\
|
||||
.uni-nav-avatar {\
|
||||
width: 30px; height: 30px;\
|
||||
border-radius: 50%;\
|
||||
background: rgba(255,255,255,0.2);\
|
||||
display: flex; align-items: center; justify-content: center;\
|
||||
font-size: 13px; font-weight: 600;\
|
||||
}\
|
||||
.uni-nav-username { font-size: 13px; color: rgba(255,255,255,0.9); }\
|
||||
.uni-nav-badge {\
|
||||
font-size: 10px;\
|
||||
background: rgba(255,255,255,0.15);\
|
||||
padding: 1px 8px; border-radius: 10px;\
|
||||
margin-left: 4px;\
|
||||
}\
|
||||
.uni-nav-logout {\
|
||||
background: rgba(255,255,255,0.1);\
|
||||
border: 1px solid rgba(255,255,255,0.15);\
|
||||
color: rgba(255,255,255,0.9);\
|
||||
padding: 4px 12px; border-radius: 6px;\
|
||||
cursor: pointer; font-size: 12px;\
|
||||
transition: all 0.2s;\
|
||||
}\
|
||||
.uni-nav-logout:hover { background: rgba(255,255,255,0.2); }\
|
||||
.uni-nav-hamburger {\
|
||||
display: none;\
|
||||
background: rgba(255,255,255,0.1);\
|
||||
border: none; color: #fff;\
|
||||
width: 36px; height: 36px;\
|
||||
border-radius: 8px;\
|
||||
cursor: pointer; font-size: 20px;\
|
||||
align-items: center; justify-content: center;\
|
||||
transition: all 0.2s;\
|
||||
}\
|
||||
.uni-nav-hamburger:hover { background: rgba(255,255,255,0.18); }\
|
||||
.uni-nav-dropdown {\
|
||||
display: none;\
|
||||
position: fixed; top: 60px; left: 0; right: 0;\
|
||||
background: #fff;\
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);\
|
||||
z-index: 9999;\
|
||||
padding: 8px;\
|
||||
max-height: calc(100vh - 60px);\
|
||||
overflow-y: auto;\
|
||||
animation: uniNavSlideDown 0.2s ease-out;\
|
||||
}\
|
||||
.uni-nav-dropdown.open { display: block; }\
|
||||
.uni-nav-dropdown-item {\
|
||||
display: flex; align-items: center; gap: 8px;\
|
||||
width: 100%;\
|
||||
padding: 12px 16px;\
|
||||
border: none; background: transparent;\
|
||||
color: #303133; font-size: 14px;\
|
||||
border-radius: 8px;\
|
||||
cursor: pointer;\
|
||||
transition: all 0.15s;\
|
||||
}\
|
||||
.uni-nav-dropdown-item:hover { background: #f0f4ff; color: #2563eb; }\
|
||||
.uni-nav-dropdown-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }\
|
||||
.uni-nav-dropdown-divider {\
|
||||
height: 1px; background: #f0f0f0; margin: 4px 0;\
|
||||
}\
|
||||
@media (max-width: 768px) {\
|
||||
.uni-nav-items { display: none; }\
|
||||
.uni-nav-username { display: none; }\
|
||||
.uni-nav-hamburger { display: inline-flex; }\
|
||||
}\
|
||||
@keyframes uniNavSlideDown {\
|
||||
from { opacity: 0; transform: translateY(-8px); }\
|
||||
to { opacity: 1; transform: translateY(0); }\
|
||||
}\
|
||||
';
|
||||
document.head.appendChild(style);
|
||||
|
||||
function getCurrentPage() {
|
||||
var path = window.location.pathname;
|
||||
if (path === '/' || path === '/index.html') return 'dashboard';
|
||||
var m = path.match(/\/(\w+)\.html/);
|
||||
return m ? m[1] : 'dashboard';
|
||||
}
|
||||
|
||||
function navItems(isAdmin) {
|
||||
var items = [
|
||||
{ key: 'dashboard', label: '仪表盘', page: '/' },
|
||||
{ key: 'topics', label: '选题', page: 'topics.html' },
|
||||
{ key: 'metrics', label: '数据', page: 'metrics.html' },
|
||||
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
||||
{ key: 'platforms', label: '平台', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '日志', page: 'logs.html' },
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ key: 'users', label: '用户', page: 'users.html', admin: true });
|
||||
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
var UniNav = {
|
||||
name: 'UniNav',
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
username: { type: String, default: '' },
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
currentPage: { type: String, default: null },
|
||||
onNavigate: { type: Function, default: null },
|
||||
},
|
||||
emits: ['logout'],
|
||||
data: function () {
|
||||
return { dropdownOpen: false };
|
||||
},
|
||||
computed: {
|
||||
items: function () {
|
||||
return navItems(this.isAdmin);
|
||||
},
|
||||
page: function () {
|
||||
return this.currentPage || getCurrentPage();
|
||||
},
|
||||
showTitle: function () {
|
||||
return this.title || '宇之然';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
navigate: function (page) {
|
||||
this.dropdownOpen = false;
|
||||
if (this.onNavigate) { this.onNavigate(page); return; }
|
||||
window.location.href = page === '/' ? '/' : '/' + page;
|
||||
},
|
||||
logout: function () {
|
||||
this.$emit('logout');
|
||||
},
|
||||
toggleDropdown: function () {
|
||||
this.dropdownOpen = !this.dropdownOpen;
|
||||
},
|
||||
closeDropdown: function (e) {
|
||||
if (this.dropdownOpen && !this.$el.contains(e.target)) {
|
||||
this.dropdownOpen = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted: function () {
|
||||
document.addEventListener('click', this.closeDropdown);
|
||||
},
|
||||
beforeUnmount: function () {
|
||||
document.removeEventListener('click', this.closeDropdown);
|
||||
},
|
||||
template: '\
|
||||
<nav class="uni-nav">\
|
||||
<div class="uni-nav-inner">\
|
||||
<div class="uni-nav-brand">{{ showTitle }}</div>\
|
||||
<div class="uni-nav-items">\
|
||||
<button v-for="item in items" :key="item.key"\
|
||||
:class="[\'uni-nav-item\', { active: page === item.key }]"\
|
||||
@click="navigate(item.page)">{{ item.label }}</button>\
|
||||
</div>\
|
||||
<div class="uni-nav-right">\
|
||||
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
|
||||
{{ dropdownOpen ? "✕" : "☰" }}\
|
||||
</button>\
|
||||
<div class="uni-nav-avatar">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\
|
||||
<span v-if="username" class="uni-nav-username">{{ username }}</span>\
|
||||
<span v-if="isAdmin" class="uni-nav-badge">管理员</span>\
|
||||
<button class="uni-nav-logout" @click="logout">退出</button>\
|
||||
</div>\
|
||||
</div>\
|
||||
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
|
||||
<button v-for="item in items" :key="item.key"\
|
||||
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\
|
||||
@click="navigate(item.page)">\
|
||||
{{ item.label }}\
|
||||
</button>\
|
||||
</div>\
|
||||
</nav>',
|
||||
};
|
||||
|
||||
window.UniNav = UniNav;
|
||||
|
||||
window.installUniNav = function (app) {
|
||||
app.component('uni-nav', UniNav);
|
||||
return app;
|
||||
};
|
||||
})();
|
||||
@@ -7,26 +7,12 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.card-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.user-table { width: 100%; }
|
||||
.user-table .el-table__cell { word-break: break-word; }
|
||||
|
||||
.user-card-list { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.user-table { display: none; }
|
||||
.user-card-list { display: block; margin: 0 -16px; }
|
||||
.user-card {
|
||||
@@ -64,26 +50,26 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="用户管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="users" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="用户管理" :username="currentUser.username" :is-admin="isAdmin" current-page="users" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">👥 用户管理</h2>
|
||||
<h2 class="card-title"><el-icon style="vertical-align:-2px;"><IconUser /></el-icon> 用户管理</h2>
|
||||
<el-button type="primary" @click="addUser">+ 新建用户</el-button>
|
||||
</div>
|
||||
<el-table :data="users" stripe class="user-table">
|
||||
<el-table :data="users" stripe class="user-table" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="username" label="用户名"></el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="scope">
|
||||
@@ -91,6 +77,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!loading && users.length === 0" style="text-align:center;padding:40px 0;color:#909399;">
|
||||
<el-icon style="font-size:48px;margin-bottom:12px;color:#c0c4cc;"><IconUser /></el-icon>
|
||||
<div>暂无用户</div>
|
||||
</div>
|
||||
<div class="user-card-list">
|
||||
<div v-for="u in users" :key="u.id" class="user-card">
|
||||
<div class="user-card-header">
|
||||
@@ -101,6 +91,10 @@
|
||||
<span style="font-size: 12px; color: #909399;">#{{ u.id }}</span>
|
||||
</div>
|
||||
<div class="user-card-meta">
|
||||
<div class="user-card-meta-item">
|
||||
<span class="user-card-meta-label">组织</span>
|
||||
<span>{{ u.org_id || '-' }}</span>
|
||||
</div>
|
||||
<div class="user-card-meta-item">
|
||||
<span class="user-card-meta-label">创建时间</span>
|
||||
<span>{{ formatDate(u.created_at) }}</span>
|
||||
@@ -114,14 +108,42 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<el-dialog v-model="dialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px" @submit.prevent="submitUser">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" required>
|
||||
<el-input v-model="form.password" type="password" placeholder="至少6位" show-password></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width:100%">
|
||||
<el-option label="编辑" value="editor"></el-option>
|
||||
<el-option label="管理员" value="admin"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUser" :loading="submitting">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const UsersApp = {
|
||||
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, users: [] } },
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
users: [], dialogVisible: false, submitting: false, loading: false,
|
||||
form: { username: '', password: '', role: 'editor' }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchUsers() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { this.$message.error('请先登录'); return; }
|
||||
@@ -129,30 +151,38 @@
|
||||
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
|
||||
const data = await response.json();
|
||||
this.users = data || [];
|
||||
this.$message.success('用户列表加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取用户失败:', error);
|
||||
this.$message.error(`获取用户失败: ${error.message}`);
|
||||
this.users = [];
|
||||
}
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
async addUser() {
|
||||
addUser() {
|
||||
this.form = { username: '', password: '', role: 'editor' };
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
async submitUser() {
|
||||
if (!this.form.username || this.form.username.length < 2) { this.$message.warning('用户名至少2个字符'); return; }
|
||||
if (!this.form.password || this.form.password.length < 6) { this.$message.warning('密码至少6位'); return; }
|
||||
this.submitting = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { this.$message.error('请先登录'); return; }
|
||||
const username = '新用户' + Date.now().toString().slice(-4);
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, role: 'editor' })
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
|
||||
const newUser = await response.json();
|
||||
this.users.push(newUser);
|
||||
this.$message.success('添加用户成功');
|
||||
this.dialogVisible = false;
|
||||
} catch (error) {
|
||||
console.error('添加用户失败:', error);
|
||||
this.$message.error(`添加用户失败: ${error.message}`);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
async deleteUser(id) {
|
||||
@@ -186,8 +216,8 @@
|
||||
};
|
||||
const app = Vue.createApp(UsersApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user