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:
Yuzhiran Dev
2026-05-17 06:56:53 +08:00
parent 301dc3e438
commit 9c37c9a574
45 changed files with 3707 additions and 1366 deletions
+124 -4
View File
@@ -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": "删除成功"}
+112 -3
View File
@@ -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:
+9 -1
View File
@@ -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":
+45 -5
View File
@@ -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,
+127
View File
@@ -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}
+48 -17
View File
@@ -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:
+64 -30
View File
@@ -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
+24 -12
View File
@@ -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({
+12 -3
View File
@@ -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]}"
+52 -15
View File
@@ -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]
+70 -14
View File
@@ -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:
+154 -1
View File
@@ -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 = []
+21 -3
View File
@@ -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()
+55 -46
View File
@@ -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},
+2 -1
View File
@@ -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"
+74
View File
@@ -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,
}
+11 -1
View File
@@ -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
View File
@@ -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>
+275
View File
@@ -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>
+26 -31
View File
@@ -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>
+60 -46
View File
@@ -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>
File diff suppressed because one or more lines are too long
+40
View File
@@ -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
View File
@@ -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>
+9 -20
View File
@@ -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
View File
@@ -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>
-79
View File
@@ -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] 脚本已加载');
})();
-383
View File
@@ -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);
});
}
})();
+20 -38
View File
@@ -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>
+20 -32
View File
@@ -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>
+118 -4
View File
@@ -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
View File
@@ -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>
+230
View File
@@ -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;
};
})();
+58 -28
View File
@@ -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>