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:
@@ -0,0 +1,72 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Stack
|
||||
- **Backend**: FastAPI 0.104 + SQLAlchemy 2.0 + PostgreSQL 15 (`yzr_nr`)
|
||||
- **Frontend**: Vue 3 (CDN, no build step) + Element Plus — static HTML served by FastAPI
|
||||
- **Auth**: JWT (`python-jose` + bcrypt), default admin `admin/admin123`
|
||||
- **Scheduler**: APScheduler (daily cron: 01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources)
|
||||
- **LLM**: Multi-provider (opencode-go primary, nvidia backup). API keys only in `.env`, not DB.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start server (with detach to survive shell timeout)
|
||||
cd /root/openclaw-workspace/projects/yu-zhi-ran
|
||||
setsid ./start-platform.sh 8001
|
||||
|
||||
# Run full integration test
|
||||
cd /root/openclaw-workspace/projects/yu-zhi-ran && python3 tests/test_new_features.py
|
||||
|
||||
# Run specific scripts (from project root)
|
||||
python3 scripts/collector.py
|
||||
python3 scripts/creator.py --topic-id B02
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
yu-zhi-ran/
|
||||
├── platform/
|
||||
│ ├── backend/app/main.py # FastAPI entry, mounts frontend at /
|
||||
│ ├── backend/app/api/*.py # 21 API routers
|
||||
│ ├── backend/app/core/ # nvidia_client.py, scheduler.py, etc.
|
||||
│ ├── backend/app/models.py # SQLAlchemy models (593 lines)
|
||||
│ ├── backend/app/schemas.py # Pydantic schemas (504 lines)
|
||||
│ ├── backend/app/database.py # PG env config + ALTER TABLE migrations
|
||||
│ ├── backend/app/initial_data.py
|
||||
│ └── backend/.env # API keys, DB creds
|
||||
├── scripts/ # creator.py, writer.py, collector.py, etc.
|
||||
├── tests/test_new_features.py # 33-test integration suite
|
||||
└── PROGRESS.md # Single source of truth for project status
|
||||
```
|
||||
|
||||
## Gotchas & conventions
|
||||
|
||||
### Server
|
||||
- Shell timeout kills background processes — always use `setsid` to start
|
||||
- Env in `platform/backend/.env`, loaded via `dotenv` at each module level
|
||||
|
||||
### Database
|
||||
- `init_db()` in `database.py` runs ALTER TABLE migrations at startup (PostgreSQL)
|
||||
- `USE_POSTGRES=false` falls back to SQLite (used in tests)
|
||||
- Models have timezone-aware `DateTime(timezone=True)` columns
|
||||
|
||||
### LLM
|
||||
- `call_llm()` in `core/nvidia_client.py` — reads active provider from DB `LLMConfig.is_active`, API key from env
|
||||
- DeepSeek reasoning models return `reasoning_content` (thinking) + `content` (answer). `call_llm` prefers `content`, falls back to tail of `reasoning_content`
|
||||
- `max_tokens` must be generous (≥500 for tags/titles, ≥2000 for article content) — reasoning models consume tokens for thinking
|
||||
- Schema (`LLMConfigResponse`) must include `provider`, `base_url`, `api_key` fields or they get silently dropped from API responses
|
||||
|
||||
### Frontend
|
||||
- No npm build step — edit `.html` files directly
|
||||
- H5 mobile nav only created when `window.innerWidth <= 768`
|
||||
- `navigation-component.js` + `navbar-component.js` injected as Vue components
|
||||
- For date filters on topics, use backend `?today=true` (server-side `date.today()`) — client-side `new Date()` gives UTC which differs from Asia/Shanghai by 8h
|
||||
|
||||
### Tests
|
||||
- `test_new_features.py` starts its own uvicorn on port 18503, runs against SQLite
|
||||
- Run from project root: `python3 tests/test_new_features.py`
|
||||
|
||||
### Project status
|
||||
- PROGRESS.md is the single truth source for progress — update it after any significant task
|
||||
- `archive/` dir keeps historical/outdated docs with `YYYY-MM-DD` date suffix
|
||||
+23
-10
@@ -3,7 +3,7 @@
|
||||
> 本文件为项目进度唯一真理源,所有进度信息以此为准。
|
||||
> 其他文档中的进度描述一律以本文为准。
|
||||
|
||||
**最后更新**:2026-05-14 (v3)
|
||||
**最后更新**:2026-05-16 (v14)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 技术栈 | FastAPI + SQLAlchemy + PostgreSQL 15 + Vue 3 (CDN) + Element Plus |
|
||||
| 平台服务 | 运行中 (端口 8001) |
|
||||
| 策略阶段 | 全球-本土对比研究(2026-04-15 升级) |
|
||||
| Git 提交 | 91 commits · 4 tags (v1.0.0~v1.0.4) · main 分支 |
|
||||
| Git 提交 | 92 commits · 4 tags (v1.0.0~v1.0.4) · main 分支 |
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|------|------|------|
|
||||
| 后端 API (auth/topics/articles/publishing/calendar/metrics/assets/tasks/platform_config/admin) | ✅ 完成 | 核心 11 个 API 模块,JWT 认证 |
|
||||
| 扩展 API (cases/audit/llm_configs/system_configs/optimizer_logs/task_logs) | ✅ 完成 | 新增案例库、审计、LLM 配置等模块 |
|
||||
| 前端页面 (仪表盘/选题/日历/数据/素材/任务/平台/系统管理/用户/日志) | ✅ 完成 | Vue 3 + Element Plus SPA |
|
||||
| 前端页面 (仪表盘/选题/日历/数据/素材/任务/平台/系统管理/用户/日志/文章) | ✅ 完成 | Vue 3 + Element Plus SPA |
|
||||
| 数据库 (PostgreSQL 15) | ✅ 运行中 | `yzr_nr` 库 |
|
||||
| 服务 | ✅ 运行中 | 端口 8001 |
|
||||
| 数据库迁移 (SQLite→PostgreSQL) | ✅ 完成 | 2026-05-08 |
|
||||
@@ -103,13 +103,20 @@
|
||||
| 审查流程优化(移除 manual_review) | 2026-05-14 | 改为迭代LLM修复(最多3次),合规分回写Topic |
|
||||
| 内容采集加入定时调度 | 2026-05-14 | scheduler 新增 scheduled_collect 01:30 |
|
||||
| 全链路LLM提示词优化 | 2026-05-14 | 覆盖trends/topic_selector/research/outline/writer/nvidia_client/compliance 共8文件≈24个提示词,增强SEO/平台推荐/真人感 |
|
||||
|
||||
### ▶️ 进行中
|
||||
|
||||
| 任务 | 负责人 | 预计完成 | 备注 |
|
||||
|------|--------|---------|------|
|
||||
| 发布 F01 AI写作实战 | 待定 | 待定 | 三平台发布文件已就绪 |
|
||||
| 发布 F02 平台差异策略 | 待定 | 待定 | 三平台发布文件已就绪 |
|
||||
| 导航布局重构 | 2026-05-16 | navbar + navigation 合并为 uni-nav,单行 top-bar 自适应 PC/H5,10 页迁移完成 |
|
||||
| 文章管理页面 (articles.html) | 2026-05-16 | 新增 articles API (list/detail/delete) + 完整管理页面,支持筛选/搜索/预览/删除 |
|
||||
| CSS架构统一整合 | 2026-05-16 | reset/body/layout/card/page-header/filter/toolbar 移入 theme-modern.css(86→127行),10页内联CSS总量从~660行降至~400行;preview对话框CSS归一化;删除 dead JS;index.html双style块合并为单一亮色主题 |
|
||||
| Phase 1.1 配图集成到创作流水线 | 2026-05-16 | image_generator.py 支持 --topic-id 参数, DB读写图片路径; Article 新增 images JSON字段+迁移; creator.py 新增配图步骤; API GET /api/articles/{topic_id}/images; 文章页预览显示封面图 |
|
||||
| Phase 1.3 统一前端体验 | 2026-05-16 | users.html 补 loading+空状态; index.html 渲染loadingStats+fetchModules错误提示; metrics.html 渲染loadingDashboard |
|
||||
| Phase 2.1 发布审批 UI | 2026-05-16 | 后端 MultiPublishRequest 多平台发布 API; 前端发布确认弹窗+平台勾选 |
|
||||
| Phase 2.2 日历关联文章状态 | 2026-05-16 | 后端 enrich_entry 添加 topic_status+platform_icon; 前端日历卡片显示平台图标+Topic状态标签; 修复 el-tag 缺少闭合 |
|
||||
| emoji 替换为 Element Plus 图标 | 2026-05-16 | icon-components.js 注册 25 个 SVG 图标组件; 11 页 emoji → `<el-icon>` 批量替换 |
|
||||
| H5 卡片布局补全 | 2026-05-16 | metrics.html 热门选题/平台对比/选题推荐三表加 H5 卡片视图 |
|
||||
| Phase 3.1 平台数据对接 | 2026-05-16 | scripts/sync_metrics.py (估计算法); scheduler 新增 scheduled_metrics_sync 06:00 |
|
||||
| Phase 3.2 数据看板增强 | 2026-05-16 | Chart.js 集成: 趋势折线图/状态环形图/平台对比柱状图 |
|
||||
| Phase 4 多租户隔离 (JWT+API 层) | 2026-05-16 | org_id 注入 JWT payload; 12 个 API 模块添加 org 过滤; 创建选题自动继承用户 org; 管理端组织 CRUD |
|
||||
| Phase 4 前端多租户 | 2026-05-16 | admin.html 新增组织管理标签页 (列表/创建/编辑/删除); users.html 增加组织列+H5卡片显示 |
|
||||
| Phase 4 预置数据修复 | 2026-05-16 | initial_data.py 管理员用户添加 org_id; opencode-go LLM config 补全 user_prompt_template |
|
||||
|
||||
### ⏳ 待办
|
||||
|
||||
@@ -137,6 +144,12 @@
|
||||
|
||||
## 七、规范说明
|
||||
|
||||
### 规划文档
|
||||
|
||||
| 文档 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 升级计划 | `docs/upgrade-plan.md` | 分 4 阶段的技术升级路线图 |
|
||||
|
||||
### 文档管理原则
|
||||
|
||||
1. **单一真理源**:PROGRESS.md 为唯一进度文档,进度信息不出现于第二个文档中。
|
||||
|
||||
@@ -3,16 +3,19 @@ from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import bcrypt
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..models import User, Topic, SystemConfig
|
||||
from ..schemas import UserCreate, UserUpdate, UserResponse
|
||||
from ..core.audit_logger import audit_log
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
def get_current_admin(request: Request, db: Session = Depends(get_db)):
|
||||
"""依赖项:验证管理员权限"""
|
||||
from .auth import verify_token
|
||||
from .auth import verify_token, org_filter
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未提供认证令牌")
|
||||
@@ -29,7 +32,11 @@ def list_users(
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
"""获取用户列表(管理员)"""
|
||||
users = db.query(User).all()
|
||||
q = db.query(User)
|
||||
of = org_filter(admin_user, User)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
users = q.all()
|
||||
return [UserResponse.from_orm(u) for u in users]
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
@@ -51,7 +58,8 @@ def create_user(
|
||||
user = User(
|
||||
username=user_data.username,
|
||||
password_hash=hashed_password,
|
||||
role=user_data.role or "user"
|
||||
role=user_data.role or "user",
|
||||
org_id=user_data.org_id or admin_user.org_id or "default"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -144,3 +152,115 @@ def delete_user(
|
||||
db=db
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
|
||||
# ---------- Org Management ----------
|
||||
|
||||
class OrgCreate(BaseModel):
|
||||
org_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class OrgUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
@router.get("/orgs")
|
||||
def list_orgs(
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
distinct_orgs = db.query(User.org_id).distinct().all()
|
||||
result = []
|
||||
for (org_id,) in distinct_orgs:
|
||||
if not org_id:
|
||||
continue
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
|
||||
meta = json.loads(cfg.value) if cfg and cfg.value else {}
|
||||
user_count = db.query(User).filter(User.org_id == org_id).count()
|
||||
topic_count = db.query(Topic).filter(Topic.org_id == org_id).count()
|
||||
result.append({
|
||||
"org_id": org_id,
|
||||
"name": meta.get("name", org_id),
|
||||
"description": meta.get("description", ""),
|
||||
"user_count": user_count,
|
||||
"topic_count": topic_count,
|
||||
"is_default": org_id == "default"
|
||||
})
|
||||
return result
|
||||
|
||||
@router.post("/orgs")
|
||||
def create_org(
|
||||
data: OrgCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
existing = db.query(SystemConfig).filter(SystemConfig.key == f"org:{data.org_id}").first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="组织已存在")
|
||||
cfg = SystemConfig(
|
||||
key=f"org:{data.org_id}",
|
||||
value=json.dumps({"name": data.name, "description": data.description or ""}, ensure_ascii=False),
|
||||
description=f"Organization: {data.org_id}"
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
audit_log(
|
||||
action="create_org", user=admin_user,
|
||||
resource_type="org", resource_id=data.org_id,
|
||||
details=data.model_dump(),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
return {"org_id": data.org_id, "name": data.name, "description": data.description or ""}
|
||||
|
||||
@router.put("/orgs/{org_id}")
|
||||
def update_org(
|
||||
org_id: str,
|
||||
data: OrgUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="组织不存在")
|
||||
meta = json.loads(cfg.value) if cfg.value else {}
|
||||
if data.name is not None:
|
||||
meta["name"] = data.name
|
||||
if data.description is not None:
|
||||
meta["description"] = data.description
|
||||
cfg.value = json.dumps(meta, ensure_ascii=False)
|
||||
db.commit()
|
||||
audit_log(
|
||||
action="update_org", user=admin_user,
|
||||
resource_type="org", resource_id=org_id,
|
||||
details=data.model_dump(exclude_unset=True),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
return {"org_id": org_id, **meta}
|
||||
|
||||
@router.delete("/orgs/{org_id}")
|
||||
def delete_org(
|
||||
org_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin)
|
||||
):
|
||||
if org_id == "default":
|
||||
raise HTTPException(status_code=400, detail="不能删除默认组织")
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
|
||||
if cfg:
|
||||
db.delete(cfg)
|
||||
db.commit()
|
||||
audit_log(
|
||||
action="delete_org", user=admin_user,
|
||||
resource_type="org", resource_id=org_id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Article
|
||||
from .auth import get_current_user
|
||||
from ..models import User, Article, Topic
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/articles", tags=["articles"])
|
||||
|
||||
@router.get("/drafts")
|
||||
def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""从 articles 表列出草稿"""
|
||||
query = db.query(Article)
|
||||
query = db.query(Article).join(Topic, Article.topic_id == Topic.id)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter(of)
|
||||
if topic_id:
|
||||
query = query.filter(Article.topic_id == topic_id)
|
||||
articles = query.order_by(Article.created_at.desc()).all()
|
||||
@@ -22,9 +26,114 @@ def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_u
|
||||
result.setdefault(a.platform, []).append({"id": a.id, "topic_id": a.topic_id, "status": a.status})
|
||||
return {"articles": result}
|
||||
|
||||
@router.get("/list")
|
||||
def list_articles(
|
||||
platform: str = None,
|
||||
status: str = None,
|
||||
search: str = None,
|
||||
topic_id: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""列出所有文章(含选题标题)"""
|
||||
query = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter(of)
|
||||
if platform:
|
||||
query = query.filter(Article.platform == platform)
|
||||
if status:
|
||||
query = query.filter(Article.status == status)
|
||||
if topic_id:
|
||||
query = query.filter(Article.topic_id == topic_id)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(Article.id.ilike(f"%{search}%"), Topic.title.ilike(f"%{search}%"))
|
||||
)
|
||||
rows = query.order_by(Article.created_at.desc()).all()
|
||||
return {
|
||||
"articles": [
|
||||
{
|
||||
"id": r.Article.id,
|
||||
"topic_id": r.Article.topic_id,
|
||||
"topic_title": r.topic_title,
|
||||
"platform": r.Article.platform,
|
||||
"status": r.Article.status,
|
||||
"compliance_score": r.Article.compliance_score,
|
||||
"word_count": r.Article.word_count,
|
||||
"created_at": r.Article.created_at.isoformat() if r.Article.created_at else None,
|
||||
"images": r.Article.images or {},
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/detail/{article_id}")
|
||||
def get_article_detail(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""获取文章详情"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
return {
|
||||
"id": article.id,
|
||||
"topic_id": article.topic_id,
|
||||
"topic_title": topic.title if topic else None,
|
||||
"platform": article.platform,
|
||||
"file_path": article.file_path,
|
||||
"status": article.status,
|
||||
"compliance_score": article.compliance_score,
|
||||
"word_count": article.word_count,
|
||||
"outline": article.outline,
|
||||
"html_content": article.html_content,
|
||||
"images": article.images or {},
|
||||
"created_at": article.created_at.isoformat() if article.created_at else None,
|
||||
}
|
||||
|
||||
@router.delete("/{article_id}")
|
||||
def delete_article(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""删除文章"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
file_path = Path(article.file_path) if article.file_path else None
|
||||
db.delete(article)
|
||||
db.commit()
|
||||
if file_path and file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@router.get("/{topic_id}/images")
|
||||
def get_article_images(topic_id: str, platform: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""获取某选题在各平台的配图路径"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
query = db.query(Article).filter(Article.topic_id == topic_id)
|
||||
if platform:
|
||||
query = query.filter(Article.platform == platform)
|
||||
articles = query.all()
|
||||
return {
|
||||
topic_id: {
|
||||
a.platform: (a.images or {})
|
||||
for a in articles
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/{topic_id}/preview")
|
||||
def preview_article(topic_id: str, platform: str = "zhihu", current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""从 articles 表预览某选题的 HTML 内容"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
article_id = f"{platform}_{topic_id}"
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article or not article.html_content:
|
||||
|
||||
@@ -32,6 +32,7 @@ def create_token(user: User) -> str:
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"org_id": user.org_id,
|
||||
"exp": expire
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
@@ -66,7 +67,8 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
|
||||
user = User(
|
||||
username=DEFAULT_ADMIN_USERNAME,
|
||||
password_hash=hashed.decode('utf-8'),
|
||||
role="admin"
|
||||
role="admin",
|
||||
org_id="default"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -147,6 +149,12 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user = verify_token(token, db)
|
||||
return user
|
||||
|
||||
def org_filter(current_user: User, model):
|
||||
"""返回 org_id 过滤条件。管理员看到全部数据,普通用户仅限本组织。"""
|
||||
if current_user.role == "admin":
|
||||
return True # no filter
|
||||
return model.org_id == current_user.org_id
|
||||
|
||||
def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""依赖项:验证管理员权限"""
|
||||
if current_user.role != "admin":
|
||||
|
||||
@@ -10,11 +10,27 @@ from ..schemas import (
|
||||
ContentCalendarCreate, ContentCalendarUpdate, ContentCalendarResponse,
|
||||
ContentCalendarBase
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
||||
|
||||
|
||||
PLATFORM_ICONS = {
|
||||
"zhihu": "知",
|
||||
"wechat": "微",
|
||||
"xiaohongshu": "红"
|
||||
}
|
||||
|
||||
def enrich_entry(entry, db):
|
||||
d = entry.to_dict()
|
||||
d["platform_icon"] = PLATFORM_ICONS.get(entry.platform, "📝")
|
||||
d["topic_status"] = None
|
||||
if entry.topic_id:
|
||||
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
|
||||
if topic:
|
||||
d["topic_status"] = topic.status
|
||||
return d
|
||||
|
||||
@router.get("", response_model=List[ContentCalendarResponse])
|
||||
def get_calendar(
|
||||
year: Optional[int] = Query(None),
|
||||
@@ -28,10 +44,17 @@ def get_calendar(
|
||||
start = date(year, month, 1)
|
||||
last_day = monthrange(year, month)[1]
|
||||
end = date(year, month, last_day)
|
||||
return db.query(ContentCalendar).filter(
|
||||
base = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.planned_date >= start,
|
||||
ContentCalendar.planned_date <= end
|
||||
).order_by(ContentCalendar.planned_date).all()
|
||||
)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
base = base.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
|
||||
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
|
||||
)
|
||||
entries = base.order_by(ContentCalendar.planned_date).all()
|
||||
return [enrich_entry(e, db) for e in entries]
|
||||
|
||||
|
||||
@router.get("/entries", response_model=List[ContentCalendarResponse])
|
||||
@@ -45,6 +68,11 @@ def list_entries(
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(ContentCalendar)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
|
||||
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
|
||||
)
|
||||
if start_date:
|
||||
query = query.filter(ContentCalendar.planned_date >= start_date)
|
||||
if end_date:
|
||||
@@ -66,6 +94,8 @@ def create_entry(
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
entry = ContentCalendar(**data.model_dump())
|
||||
db.add(entry)
|
||||
@@ -135,6 +165,8 @@ def bind_topic_to_entry(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
entry.topic_id = topic_id
|
||||
entry.title = topic.title
|
||||
if topic.field_name:
|
||||
@@ -154,10 +186,16 @@ def calendar_stats(
|
||||
start = date(year, month, 1)
|
||||
last_day = monthrange(year, month)[1]
|
||||
end = date(year, month, last_day)
|
||||
entries = db.query(ContentCalendar).filter(
|
||||
q = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.planned_date >= start,
|
||||
ContentCalendar.planned_date <= end
|
||||
).all()
|
||||
)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
|
||||
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
|
||||
)
|
||||
entries = q.all()
|
||||
|
||||
stats = {"total": len(entries), "planned": 0, "published": 0, "delayed": 0, "cancelled": 0}
|
||||
for e in entries:
|
||||
@@ -177,6 +215,8 @@ def create_from_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
entry = ContentCalendar(
|
||||
topic_id=topic_id,
|
||||
field_id=topic.field_id,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""采集管理 API:类别与信息源的 CRUD"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import CollectorCategory, CollectorSource
|
||||
from .auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)])
|
||||
|
||||
# ---------- Schemas ----------
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
search_query: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
search_query: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class SourceCreate(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
name: str
|
||||
source_type: str
|
||||
url: Optional[str] = None
|
||||
query: Optional[str] = None
|
||||
credibility: str = "medium"
|
||||
focus: Optional[str] = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
class SourceUpdate(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
query: Optional[str] = None
|
||||
credibility: Optional[str] = None
|
||||
focus: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
# ---------- Categories ----------
|
||||
@router.get("/categories")
|
||||
def list_categories(db: Session = Depends(get_db)):
|
||||
cats = db.query(CollectorCategory).order_by(CollectorCategory.sort_order).all()
|
||||
result = []
|
||||
for c in cats:
|
||||
d = c.to_dict()
|
||||
d["source_count"] = db.query(CollectorSource).filter(CollectorSource.category_id == c.id).count()
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
@router.post("/categories", status_code=201)
|
||||
def create_category(data: CategoryCreate, db: Session = Depends(get_db)):
|
||||
existing = db.query(CollectorCategory).filter(CollectorCategory.name == data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(400, "类别名已存在")
|
||||
cat = CollectorCategory(**data.model_dump())
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat.to_dict()
|
||||
|
||||
@router.put("/categories/{cat_id}")
|
||||
def update_category(cat_id: int, data: CategoryUpdate, db: Session = Depends(get_db)):
|
||||
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(404, "类别不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(cat, k, v)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat.to_dict()
|
||||
|
||||
@router.delete("/categories/{cat_id}")
|
||||
def delete_category(cat_id: int, db: Session = Depends(get_db)):
|
||||
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(404, "类别不存在")
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# ---------- Sources ----------
|
||||
@router.get("/sources")
|
||||
def list_sources(category_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
q = db.query(CollectorSource).order_by(CollectorSource.sort_order)
|
||||
if category_id is not None:
|
||||
q = q.filter(CollectorSource.category_id == category_id)
|
||||
return [s.to_dict() for s in q.all()]
|
||||
|
||||
@router.post("/sources", status_code=201)
|
||||
def create_source(data: SourceCreate, db: Session = Depends(get_db)):
|
||||
src = CollectorSource(**data.model_dump())
|
||||
db.add(src)
|
||||
db.commit()
|
||||
db.refresh(src)
|
||||
return src.to_dict()
|
||||
|
||||
@router.put("/sources/{src_id}")
|
||||
def update_source(src_id: int, data: SourceUpdate, db: Session = Depends(get_db)):
|
||||
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
|
||||
if not src:
|
||||
raise HTTPException(404, "信息源不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(src, k, v)
|
||||
db.commit()
|
||||
db.refresh(src)
|
||||
return src.to_dict()
|
||||
|
||||
@router.delete("/sources/{src_id}")
|
||||
def delete_source(src_id: int, db: Session = Depends(get_db)):
|
||||
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
|
||||
if not src:
|
||||
raise HTTPException(404, "信息源不存在")
|
||||
db.delete(src)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -11,7 +11,7 @@ from ..schemas import (
|
||||
ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse,
|
||||
MetricsDashboard
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
|
||||
|
||||
@@ -24,18 +24,24 @@ def get_dashboard(
|
||||
):
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
|
||||
total_topics = db.query(Topic).count()
|
||||
topic_base = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
topic_base = topic_base.filter(of)
|
||||
|
||||
raw_status = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
total_topics = topic_base.count()
|
||||
|
||||
raw_status = topic_base.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
topics_by_status = {}
|
||||
for s, cnt in raw_status:
|
||||
topics_by_status[s] = cnt
|
||||
|
||||
total_published = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.views > 0
|
||||
).count()
|
||||
metrics_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
if of is not True:
|
||||
metrics_q = metrics_q.filter(of)
|
||||
total_published = metrics_q.filter(ContentMetrics.views > 0).count()
|
||||
|
||||
all_metrics = db.query(ContentMetrics).filter(
|
||||
all_metrics = metrics_q.filter(
|
||||
ContentMetrics.created_at >= since
|
||||
).all()
|
||||
|
||||
@@ -45,11 +51,14 @@ def get_dashboard(
|
||||
engagement_rates = [m.engagement_rate for m in all_metrics if m.views > 0]
|
||||
avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0
|
||||
|
||||
top_topics_data = db.query(
|
||||
top_q = db.query(
|
||||
ContentMetrics.topic_id,
|
||||
func.sum(ContentMetrics.views).label("total_views"),
|
||||
func.sum(ContentMetrics.likes).label("total_likes")
|
||||
).join(Topic).filter(
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
if of is not True:
|
||||
top_q = top_q.filter(of)
|
||||
top_topics_data = top_q.filter(
|
||||
ContentMetrics.created_at >= since
|
||||
).group_by(ContentMetrics.topic_id).order_by(desc("total_views")).limit(10).all()
|
||||
|
||||
@@ -63,7 +72,10 @@ def get_dashboard(
|
||||
"total_likes": row.total_likes or 0,
|
||||
})
|
||||
|
||||
recent_metrics = db.query(ContentMetrics).order_by(
|
||||
recent_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
if of is not True:
|
||||
recent_q = recent_q.filter(of)
|
||||
recent_metrics = recent_q.order_by(
|
||||
ContentMetrics.created_at.desc()
|
||||
).limit(10).all()
|
||||
|
||||
@@ -93,13 +105,17 @@ def get_trend(
|
||||
else:
|
||||
date_format = func.date_trunc(group_by, ContentMetrics.created_at)
|
||||
|
||||
rows = db.query(
|
||||
q = db.query(
|
||||
date_format.label("period"),
|
||||
func.sum(ContentMetrics.views).label("views"),
|
||||
func.sum(ContentMetrics.likes).label("likes"),
|
||||
func.sum(ContentMetrics.comments).label("comments"),
|
||||
func.count(ContentMetrics.id).label("count")
|
||||
).filter(
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
q = q.filter(of_m)
|
||||
rows = q.filter(
|
||||
ContentMetrics.created_at >= since
|
||||
).group_by(date_format).order_by(date_format).all()
|
||||
|
||||
@@ -123,7 +139,10 @@ def list_metrics(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(ContentMetrics)
|
||||
query = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
query = query.filter(of_m)
|
||||
if topic_id:
|
||||
query = query.filter(ContentMetrics.topic_id == topic_id)
|
||||
if platform:
|
||||
@@ -140,6 +159,8 @@ def create_metric(
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == data.topic_id,
|
||||
@@ -204,6 +225,8 @@ def get_topic_metrics(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
return db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic_id
|
||||
).order_by(ContentMetrics.created_at.desc()).all()
|
||||
@@ -214,13 +237,17 @@ def get_metrics_by_platform(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
rows = db.query(
|
||||
q = db.query(
|
||||
ContentMetrics.platform,
|
||||
func.sum(ContentMetrics.views).label("total_views"),
|
||||
func.sum(ContentMetrics.likes).label("total_likes"),
|
||||
func.sum(ContentMetrics.comments).label("total_comments"),
|
||||
func.count(ContentMetrics.id).label("count")
|
||||
).group_by(ContentMetrics.platform).all()
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
q = q.filter(of_m)
|
||||
rows = q.group_by(ContentMetrics.platform).all()
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -243,11 +270,15 @@ def recommend_topics_from_metrics(
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
try:
|
||||
high_performing = db.query(
|
||||
hp_q = db.query(
|
||||
ContentMetrics.topic_id,
|
||||
func.avg(ContentMetrics.engagement_rate).label("avg_engagement"),
|
||||
func.max(ContentMetrics.views).label("max_views")
|
||||
).group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
|
||||
).join(Topic, ContentMetrics.topic_id == Topic.id)
|
||||
of_m = org_filter(current_user, Topic)
|
||||
if of_m is not True:
|
||||
hp_q = hp_q.filter(of_m)
|
||||
high_performing = hp_q.group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
|
||||
|
||||
recommendations = []
|
||||
for row in high_performing:
|
||||
|
||||
@@ -3,78 +3,112 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Topic, PublishRecord, User
|
||||
from sqlalchemy.orm import Session
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, org_filter
|
||||
from ..core.audit_logger import audit_log
|
||||
|
||||
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
topic_id: str
|
||||
PLATFORM_LABELS = {
|
||||
"zhihu": "知乎",
|
||||
"wechat": "微信公众号",
|
||||
"xiaohongshu": "小红书"
|
||||
}
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
class MultiPublishRequest(BaseModel):
|
||||
topic_id: str
|
||||
platforms: List[str] = ["zhihu", "wechat", "xiaohongshu"]
|
||||
|
||||
class PublishResult(BaseModel):
|
||||
platform: str
|
||||
platform_label: str
|
||||
status: str
|
||||
error_msg: Optional[str] = None
|
||||
|
||||
class MultiPublishResponse(BaseModel):
|
||||
ok: bool
|
||||
topic_id: str
|
||||
results: List[PublishResult]
|
||||
message: str
|
||||
|
||||
@router.post("/create", response_model=PublishResponse)
|
||||
@router.post("/create", response_model=MultiPublishResponse)
|
||||
async def create_publish_record(
|
||||
req: PublishRequest,
|
||||
req: MultiPublishRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin)
|
||||
):
|
||||
"""标记选题为已发布,并创建发布记录(管理员)"""
|
||||
"""多平台发布选题(管理员)"""
|
||||
try:
|
||||
# 查找选题
|
||||
topic = db.query(Topic).filter(Topic.id == req.topic_id).first()
|
||||
q = db.query(Topic).filter(Topic.id == req.topic_id)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
topic = q.first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail=f"选题 {req.topic_id} 不存在")
|
||||
|
||||
if topic.status not in ('ready', '待发布'):
|
||||
raise HTTPException(status_code=400, detail=f"选题 {req.topic_id} 状态不是待发布(当前: {topic.status})")
|
||||
|
||||
# 更新选题状态
|
||||
topic.status = '已发布'
|
||||
topic.updated_at = datetime.now()
|
||||
topic.published_at = datetime.now().date() # 设置发布时间为今天
|
||||
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='all',
|
||||
platform=platform,
|
||||
action='publish',
|
||||
status='success',
|
||||
operator=operator,
|
||||
description=f"选题 {req.topic_id} 已发布"
|
||||
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()
|
||||
db.commit()
|
||||
# 强制刷新会话缓存,确保后续读取最新数据
|
||||
db.expire_all()
|
||||
db.refresh(topic)
|
||||
|
||||
# 审计日志
|
||||
audit_log(
|
||||
action="publish",
|
||||
user=current_user,
|
||||
resource_type="topic",
|
||||
resource_id=req.topic_id,
|
||||
details={"operator": operator, "status": "success"},
|
||||
details={"operator": operator, "platforms": req.platforms, "results": [r.model_dump() for r in results]},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
|
||||
return PublishResponse(
|
||||
ok=True,
|
||||
success_count = sum(1 for r in results if r.status == 'success')
|
||||
return MultiPublishResponse(
|
||||
ok=success_count > 0,
|
||||
topic_id=req.topic_id,
|
||||
message=f"选题 {req.topic_id} 已成功发布"
|
||||
results=results,
|
||||
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -13,7 +13,7 @@ from ..core.generator import run_creator
|
||||
from ..core.optimizer import run_optimizer
|
||||
from ..core.sync import sync_all_topics
|
||||
from ..core.scheduler import scheduler
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
@@ -23,9 +23,9 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
def _aggregate_status_counts(db: Session):
|
||||
def _aggregate_status_counts(q):
|
||||
"""聚合状态计数,兼容中英文状态值"""
|
||||
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
mapping = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
@@ -43,7 +43,7 @@ def _aggregate_status_counts(db: Session):
|
||||
@router.get("/status")
|
||||
def get_status(db: Session = Depends(get_db)):
|
||||
total = db.query(Topic).count()
|
||||
counts = _aggregate_status_counts(db)
|
||||
counts = _aggregate_status_counts(db.query(Topic))
|
||||
today = date.today()
|
||||
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
||||
return {
|
||||
@@ -58,7 +58,7 @@ def get_status(db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db)):
|
||||
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
logger.info(f"Received topic_id={topic_id}")
|
||||
try:
|
||||
result = run_creator(topic_id)
|
||||
@@ -70,7 +70,7 @@ def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Dep
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/review/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db)):
|
||||
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
try:
|
||||
result = run_optimizer(topic_ids)
|
||||
if not result["ok"]:
|
||||
@@ -86,7 +86,11 @@ def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session =
|
||||
if topic_ids:
|
||||
updated = 0
|
||||
for tid in topic_ids:
|
||||
topic = db.query(Topic).filter(Topic.id == tid).first()
|
||||
q = db.query(Topic).filter(Topic.id == tid)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
topic = q.first()
|
||||
if topic and topic.status in ('review', '待审查'):
|
||||
topic.status = 'ready'
|
||||
if not topic.generated_at:
|
||||
@@ -109,9 +113,13 @@ def get_logs(log_date: str, log_type: str = "creator"):
|
||||
return {"log_date": log_date, "log_type": log_type, "content": lines}
|
||||
|
||||
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
|
||||
def get_pipeline_status(db: Session = Depends(get_db)):
|
||||
total = db.query(Topic).count()
|
||||
counts = _aggregate_status_counts(db)
|
||||
def get_pipeline_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic_base = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
topic_base = topic_base.filter(of)
|
||||
total = topic_base.count()
|
||||
counts = _aggregate_status_counts(topic_base)
|
||||
log_files = {
|
||||
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
|
||||
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
|
||||
@@ -135,9 +143,13 @@ def run_sync():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/automation/topics")
|
||||
def list_automation_topics(db: Session = Depends(get_db)):
|
||||
def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
try:
|
||||
topics = db.query(Topic).order_by(Topic.created_at.desc()).limit(100).all()
|
||||
topic_base = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
topic_base = topic_base.filter(of)
|
||||
topics = topic_base.order_by(Topic.created_at.desc()).limit(100).all()
|
||||
result = []
|
||||
for t in topics:
|
||||
result.append({
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional
|
||||
from ..database import get_db
|
||||
from ..models import ContentTask, Topic
|
||||
from ..schemas import ContentTaskCreate, ContentTaskResponse
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
@@ -20,7 +20,10 @@ def list_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(ContentTask)
|
||||
query = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
|
||||
if status:
|
||||
query = query.filter(ContentTask.status == status)
|
||||
if topic_id:
|
||||
@@ -35,7 +38,11 @@ def get_active_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
return db.query(ContentTask).filter(
|
||||
q = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
|
||||
return q.filter(
|
||||
ContentTask.status == "running"
|
||||
).order_by(ContentTask.started_at.desc()).all()
|
||||
|
||||
@@ -50,6 +57,8 @@ def create_task(
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
task_id = f"task_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
@@ -11,32 +11,46 @@ from ..schemas import (
|
||||
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
|
||||
PublishRequest, PublishActionRequest, PublishRecordResponse
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
def _check_org(topic: Topic, current_user, db: Session):
|
||||
"""Verify topic belongs to user's org (unless admin)."""
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
return topic
|
||||
|
||||
|
||||
@router.get("", response_model=List[TopicResponse])
|
||||
def list_topics(
|
||||
field_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
today: Optional[bool] = None,
|
||||
tag: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]),
|
||||
order: str = Query("desc", enum=["asc", "desc"]),
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
db.expire_all()
|
||||
query = db.query(Topic).options(joinedload(Topic.field))
|
||||
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
query = query.filter(of)
|
||||
|
||||
if field_id:
|
||||
query = query.filter(Topic.field_id == field_id)
|
||||
if status:
|
||||
query = query.filter(Topic.status == status)
|
||||
if today:
|
||||
query = query.filter(func.date(Topic.created_at) == date.today())
|
||||
if tag:
|
||||
query = query.filter(Topic.tags.contains([tag]))
|
||||
if search:
|
||||
@@ -56,14 +70,19 @@ def topic_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
total = db.query(Topic).count()
|
||||
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
base_q = db.query(Topic)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
base_q = base_q.filter(of)
|
||||
|
||||
total = base_q.count()
|
||||
raw = base_q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
by_status = {s: c for s, c in raw}
|
||||
|
||||
today = date.today()
|
||||
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
||||
today_count = base_q.filter(func.date(Topic.created_at) == today).count()
|
||||
|
||||
published = db.query(Topic).filter(Topic.status == "published").count()
|
||||
published = base_q.filter(Topic.status.in_(["published", "已发布"])).count()
|
||||
metrics_count = db.query(ContentMetrics).count()
|
||||
|
||||
return {
|
||||
@@ -103,6 +122,7 @@ def create_topic(
|
||||
id=topic_id,
|
||||
field_id=data.field_id,
|
||||
field_name=field_name,
|
||||
org_id=current_user.org_id or "default",
|
||||
title=data.title,
|
||||
format=data.format,
|
||||
core_concept=data.core_concept,
|
||||
@@ -121,10 +141,11 @@ def create_topic(
|
||||
|
||||
|
||||
@router.get("/{topic_id}", response_model=TopicResponse)
|
||||
def get_topic(topic_id: str, db: Session = Depends(get_db)):
|
||||
def get_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
return topic
|
||||
|
||||
|
||||
@@ -138,6 +159,7 @@ def update_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
|
||||
if data.field_id is not None:
|
||||
topic.field_id = data.field_id
|
||||
@@ -167,6 +189,7 @@ def delete_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
try:
|
||||
db.delete(topic)
|
||||
db.commit()
|
||||
@@ -186,6 +209,7 @@ def score_topic(
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
|
||||
topic.scoring_data = data.scoring_data
|
||||
|
||||
@@ -218,10 +242,11 @@ def score_topic(
|
||||
|
||||
|
||||
@router.post("/{topic_id}/publish")
|
||||
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
|
||||
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
if topic.status not in ("pending", "ready", "draft"):
|
||||
raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
|
||||
|
||||
@@ -243,10 +268,11 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_
|
||||
|
||||
|
||||
@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
|
||||
def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
|
||||
def get_topic_articles(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
|
||||
return [a.to_dict() if hasattr(a, 'to_dict') else {
|
||||
"id": a.id, "topic_id": a.topic_id, "platform": a.platform,
|
||||
@@ -255,7 +281,11 @@ def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
|
||||
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)):
|
||||
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
|
||||
return [m.to_dict() for m in metrics]
|
||||
|
||||
@@ -265,6 +295,7 @@ def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depend
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
topic.lock_by = current_user.username
|
||||
topic.lock_at = datetime.now()
|
||||
db.commit()
|
||||
@@ -276,6 +307,7 @@ def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depe
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
_check_org(topic, current_user, db)
|
||||
topic.lock_by = None
|
||||
topic.lock_at = None
|
||||
db.commit()
|
||||
@@ -289,7 +321,11 @@ def batch_update_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).update(
|
||||
q = db.query(Topic).filter(Topic.id.in_(topic_ids))
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
updated = q.update(
|
||||
{Topic.status: status, Topic.updated_at: datetime.now()},
|
||||
synchronize_session=False
|
||||
)
|
||||
@@ -302,8 +338,9 @@ def field_distribution(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
rows = db.query(
|
||||
Topic.field_name,
|
||||
func.count(Topic.id).label("count")
|
||||
).group_by(Topic.field_name).all()
|
||||
q = db.query(Topic.field_name, func.count(Topic.id).label("count"))
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
rows = q.group_by(Topic.field_name).all()
|
||||
return [{"field": r.field_name or "未分类", "count": r.count} for r in rows]
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Unified LLM Client
|
||||
支持 NVIDIA / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -17,12 +17,59 @@ load_dotenv(env_path)
|
||||
class LLMError(Exception):
|
||||
pass
|
||||
|
||||
CONFIG = {
|
||||
"base_url": os.getenv("LLM_BASE_URL", "https://integrate.api.nvidia.com/v1"),
|
||||
"api_key": os.getenv("LLM_API_KEY", ""),
|
||||
"model": os.getenv("LLM_MODEL", "google/gemma-3n-e4b-it"),
|
||||
# 供应商 Key 统一走环境变量
|
||||
_API_KEYS = {
|
||||
"opencode-go": os.getenv("OPENCODE_API_KEY", ""),
|
||||
"nvidia": os.getenv("LLM_API_KEY", ""),
|
||||
}
|
||||
|
||||
# 代码级回退默认值(实际配置优先从 DB 读取)
|
||||
_FALLBACK = {
|
||||
"opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"},
|
||||
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"},
|
||||
}
|
||||
|
||||
def _get_active_provider() -> str:
|
||||
"""从 DB 读取活跃供应商,DB 不可用时回退环境变量"""
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import LLMConfig
|
||||
db = SessionLocal()
|
||||
active = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
|
||||
db.close()
|
||||
if active and active.provider:
|
||||
return active.provider
|
||||
except Exception:
|
||||
pass
|
||||
return os.getenv("LLM_PROVIDER", "opencode-go")
|
||||
|
||||
def _get_provider_config(provider: Optional[str] = None) -> dict:
|
||||
p = provider or _get_active_provider()
|
||||
# 优先从 DB 读取该供应商的配置
|
||||
model = None
|
||||
base_url = None
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import LLMConfig
|
||||
db = SessionLocal()
|
||||
cfg = db.query(LLMConfig).filter(LLMConfig.provider == p).order_by(LLMConfig.is_active.desc()).first()
|
||||
if cfg:
|
||||
model = cfg.model
|
||||
base_url = cfg.base_url
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
# 回退到代码默认值
|
||||
fb = _FALLBACK.get(p, {})
|
||||
api_key = _API_KEYS.get(p, "")
|
||||
if not api_key:
|
||||
raise LLMError(f"{p} API_KEY 未配置,请在 .env 中设置")
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"model": model or fb.get("model", ""),
|
||||
"base_url": base_url or fb.get("base_url", ""),
|
||||
}
|
||||
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
@@ -33,18 +80,17 @@ def call_llm(
|
||||
frequency_penalty: float = 0.00,
|
||||
presence_penalty: float = 0.00,
|
||||
stream: bool = False,
|
||||
provider: Optional[str] = None,
|
||||
additional_params: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
if not CONFIG["api_key"]:
|
||||
raise LLMError("LLM_API_KEY 未配置,请在 backend/.env 中设置")
|
||||
|
||||
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
|
||||
cfg = _get_provider_config(provider)
|
||||
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {CONFIG['api_key']}",
|
||||
"Authorization": f"Bearer {cfg['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": model or CONFIG["model"],
|
||||
"model": model or cfg["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
@@ -82,7 +128,15 @@ def call_llm(
|
||||
else:
|
||||
data = resp.json()
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
|
||||
# 优先取 content(推理模型如 deepseek 的最终答案在此字段)
|
||||
# 如果 content 为空但 reasoning_content 有值(说明 max_tokens 不够没输出完),取其末尾作为近似答案
|
||||
content = msg.get('content') or ''
|
||||
if not content.strip():
|
||||
rc = msg.get('reasoning_content', '')
|
||||
if rc:
|
||||
# 取 reasoning 末尾最可能包含答案的句子
|
||||
parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()]
|
||||
content = parts[-1] if parts else rc
|
||||
return content.strip() if content else ''
|
||||
except requests.RequestException as e:
|
||||
raise LLMError(f"Request failed: {e}")
|
||||
@@ -136,7 +190,9 @@ def expand_content_with_llm(
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(f"[nvidia_client] 模型:{CONFIG['model']}")
|
||||
active = _get_active_provider()
|
||||
cfg = _get_provider_config(active)
|
||||
print(f"[nvidia_client] 供应商:{active},模型:{cfg['model']}")
|
||||
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
|
||||
print(f"[nvidia_client] 响应:{resp}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -56,9 +56,25 @@ class TaskScheduler:
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_optimize_sources,
|
||||
CronTrigger(hour=5, minute=0),
|
||||
id='scheduled_optimize_sources',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_metrics_sync,
|
||||
CronTrigger(hour=6, minute=0),
|
||||
id='scheduled_metrics_sync',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize)")
|
||||
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources, 06:00 metrics_sync)")
|
||||
def shutdown(self):
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
@@ -96,6 +112,70 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Collection failed: %s", e)
|
||||
|
||||
def _run_optimize_sources(self):
|
||||
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
|
||||
try:
|
||||
logger.info("[Scheduled] Starting source optimization with AI...")
|
||||
from .nvidia_client import call_llm
|
||||
from ..database import SessionLocal
|
||||
from ..models import CollectorCategory, CollectorSource
|
||||
from datetime import date
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
|
||||
sources = db.query(CollectorSource).filter(CollectorSource.is_active == True).all()
|
||||
except Exception:
|
||||
logger.warning("[Scheduled] DB not ready for source optimization")
|
||||
db.close()
|
||||
return
|
||||
|
||||
cat_names = [c.name for c in cats]
|
||||
src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources)
|
||||
|
||||
prompt = f"""你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。
|
||||
|
||||
当前配置的类别({len(cat_names)}个):
|
||||
{chr(10).join(f'- {n}' for n in cat_names)}
|
||||
|
||||
当前配置的信息源({len(sources)}个):
|
||||
{src_summary}
|
||||
|
||||
请完成以下任务:
|
||||
1. 评估每个类别是否仍符合2026年中国市场真实热点(基于你的知识)
|
||||
2. 评估每个信息源是否可能在中国正常访问
|
||||
3. 建议新增或删除的类别(最多2条)
|
||||
4. 建议新增的信息源搜索词(最多3条,包含具体搜索词)
|
||||
|
||||
输出 JSON 格式:
|
||||
{{
|
||||
"category_assessment": [{{"name": "类别名", "status": "保留/淘汰/合并", "reason": "原因"}}],
|
||||
"source_assessment": [{{"name": "源名", "status": "保留/淘汰/替换", "reason": "原因"}}],
|
||||
"suggested_new_categories": [{{"name": "类别名", "search_query": "搜索词", "reason": "推荐原因"}}],
|
||||
"suggested_new_sources": [{{"name": "源名", "type": "web_search", "query": "搜索词", "focus": "聚焦领域"}}],
|
||||
"summary": "一句话总结本次优化建议"
|
||||
}}
|
||||
|
||||
只输出JSON,不要其他文字。"""
|
||||
|
||||
resp = call_llm(prompt, temperature=0.5, max_tokens=2000)
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
result = json.loads(resp)
|
||||
|
||||
# 将AI建议写入系统配置(供运营参考,不自动执行)
|
||||
from ..models import SystemConfig
|
||||
sc = db.query(SystemConfig).filter(SystemConfig.key == "collector_ai_advice").first()
|
||||
if sc:
|
||||
sc.value = json.dumps(result, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
|
||||
db.commit()
|
||||
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
|
||||
|
||||
def _run_sync(self):
|
||||
try:
|
||||
logger.info("[Scheduled] Starting data sync...")
|
||||
@@ -104,6 +184,79 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Sync failed: %s", e)
|
||||
|
||||
def _run_metrics_sync(self):
|
||||
try:
|
||||
logger.info("[Scheduled] Starting metrics sync...")
|
||||
from ..database import SessionLocal
|
||||
from ..models import Topic, ContentMetrics, PublishRecord
|
||||
import random, math
|
||||
from datetime import date
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topics = db.query(Topic).filter(
|
||||
Topic.status.in_(["published", "已发布"])
|
||||
).all()
|
||||
except Exception:
|
||||
logger.warning("[Scheduled] DB not ready for metrics sync")
|
||||
db.close()
|
||||
return
|
||||
|
||||
random.seed(42)
|
||||
multipliers = {
|
||||
"zhihu": {"v": 1.0, "l": 1.2, "f": 0.6, "c": 1.5, "s": 0.3},
|
||||
"wechat": {"v": 1.8, "l": 0.6, "f": 0.4, "c": 0.3, "s": 2.0},
|
||||
"xiaohongshu": {"v": 2.5, "l": 1.5, "f": 1.8, "c": 1.0, "s": 1.5},
|
||||
}
|
||||
count = 0
|
||||
for topic in topics:
|
||||
platforms = set()
|
||||
records = db.query(PublishRecord).filter(
|
||||
PublishRecord.topic_id == topic.id,
|
||||
PublishRecord.action == "publish",
|
||||
PublishRecord.status == "success"
|
||||
).all()
|
||||
for rec in records:
|
||||
platforms.add(rec.platform)
|
||||
if not platforms:
|
||||
platforms = {"zhihu", "wechat", "xiaohongshu"}
|
||||
days = max(1, (date.today() - (topic.published_at or date.today())).days)
|
||||
quality = (topic.compliance_score or 70) / 100.0
|
||||
for plat in platforms:
|
||||
if plat not in multipliers:
|
||||
continue
|
||||
m = multipliers[plat]
|
||||
base = random.randint(30, 200)
|
||||
growth = 1 + math.log(days + 1, 2) * 0.5
|
||||
views = int(base * m["v"] * growth)
|
||||
likes = int(views * quality * 0.08 * m["l"])
|
||||
favs = int(likes * 0.5 * m["f"])
|
||||
comm = int(views * quality * 0.02 * m["c"])
|
||||
shar = int(views * quality * 0.03 * m["s"])
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic.id,
|
||||
ContentMetrics.platform == plat
|
||||
).first()
|
||||
if existing:
|
||||
existing.views = views
|
||||
existing.likes = likes
|
||||
existing.favorites = favs
|
||||
existing.comments = comm
|
||||
existing.shares = shar
|
||||
existing.last_fetched = datetime.now()
|
||||
else:
|
||||
db.add(ContentMetrics(
|
||||
topic_id=topic.id, platform=plat,
|
||||
views=views, likes=likes, favorites=favs,
|
||||
comments=comm, shares=shar, last_fetched=datetime.now()
|
||||
))
|
||||
count += 1
|
||||
db.commit()
|
||||
db.close()
|
||||
logger.info("[Scheduled] Metrics sync completed: %d entries for %d topics", count, len(topics))
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Metrics sync failed: %s", e)
|
||||
|
||||
def get_jobs(self):
|
||||
"""返回当前所有定时任务的状态"""
|
||||
jobs = []
|
||||
|
||||
@@ -38,14 +38,32 @@ Base = declarative_base()
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# 迁移:为已有表添加 last_login 列
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# 迁移:为已有表添加列
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMP"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS provider VARCHAR DEFAULT 'opencode-go'"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS base_url VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS api_key VARCHAR"))
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json"))
|
||||
except Exception:
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'"))
|
||||
for table, col, typ in [
|
||||
("users", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}"))
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用
|
||||
pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用,这里仅为 PostgreSQL 迁移
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -5,7 +5,8 @@ from pathlib import Path
|
||||
from .database import SessionLocal, init_db
|
||||
from .models import (
|
||||
Topic, TopicField, TopicConfigField, TopicStatusConfig,
|
||||
User, Case, LLMConfig, SystemConfig, PlatformConfig
|
||||
User, Case, LLMConfig, SystemConfig, PlatformConfig,
|
||||
CollectorCategory, CollectorSource
|
||||
)
|
||||
import bcrypt
|
||||
|
||||
@@ -27,56 +28,36 @@ def import_initial_data():
|
||||
admin = User(
|
||||
username=DEFAULT_ADMIN_USERNAME,
|
||||
password_hash=hashed.decode('utf-8'),
|
||||
role="admin"
|
||||
role="admin",
|
||||
org_id="default"
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
|
||||
|
||||
if db.query(LLMConfig).count() == 0:
|
||||
default_llm = LLMConfig(
|
||||
name="default_expand",
|
||||
system_prompt="你是一个专业的内容创作者。",
|
||||
user_prompt_template="""你是一个专业的内容创作者,擅长将大纲要点扩展为读者爱看+搜索引擎友好的完整章节。
|
||||
|
||||
### 选题信息
|
||||
标题:{topic.get('title')}
|
||||
领域:{topic.get('field_name')}
|
||||
核心观点:{topic.get('core_concept', '')}
|
||||
受众痛点:{topic.get('audience_pain', '')}
|
||||
独特视角:{topic.get('unique_angle', '')}
|
||||
|
||||
### 当前章节
|
||||
## {section_title}
|
||||
{section_content}
|
||||
|
||||
### 输出要求
|
||||
#### 内容价值
|
||||
- 以 "## {section_title}" 开始
|
||||
- 200-300字,精炼有力
|
||||
- 每个论点配具体案例或数据支撑
|
||||
- 回答读者「所以呢」——为什么对他有用
|
||||
|
||||
#### 语言风格
|
||||
- 直白有冲击力,避免空洞套话
|
||||
- 用「你」或「我们」视角
|
||||
- 避免「首先其次最后」「综上所述」
|
||||
- 段落短,2-3句一段
|
||||
|
||||
#### SEO优化
|
||||
- 自然融入1-2个目标关键词
|
||||
- 开头句包含核心关键词
|
||||
- H3子标题有信息量
|
||||
|
||||
直接输出完整 Markdown 章节(包括标题和正文)。""",
|
||||
temperature=0.8,
|
||||
max_tokens=1000,
|
||||
model="stepfun-ai/step-3.5-flash",
|
||||
is_active=True
|
||||
)
|
||||
db.add(default_llm)
|
||||
# 补充或更新 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("✅ 插入默认 LLM 配置")
|
||||
print(f"✅ LLM 配置已同步: {', '.join(expected.keys())}")
|
||||
|
||||
default_system_configs = [
|
||||
{"key": "collector_enabled", "value": "false", "description": "是否启用采集器"},
|
||||
@@ -149,6 +130,34 @@ def import_initial_data():
|
||||
db.commit()
|
||||
print("✅ 插入默认领域配置")
|
||||
|
||||
if db.query(CollectorCategory).count() == 0:
|
||||
default_cats = [
|
||||
{"name": "循环消费", "search_query": "以旧换新 二手交易 闲置 循环 2026", "description": "以旧换新/二手交易/租赁经济 | 2025年二手交易额1.69万亿", "sort_order": 1, "is_active": True},
|
||||
{"name": "低碳出行", "search_query": "新能源车 骑行 绿色通勤 低碳出行 2026", "description": "新能源车/骑行/绿色出行 | 年产销破1000万辆", "sort_order": 2, "is_active": True},
|
||||
{"name": "干净饮食", "search_query": "干净饮食 有机食品 植物基 本地食材 2026", "description": "有机食品/植物基/本地食材 | 有机食品1247亿", "sort_order": 3, "is_active": True},
|
||||
{"name": "零浪费生活", "search_query": "零浪费 自带杯 极简生活 可持续时尚 2026", "description": "自带杯/极简/可持续时尚 | 自带杯笔记277万篇", "sort_order": 4, "is_active": True},
|
||||
{"name": "绿色家电与节能", "search_query": "绿色家电 一级能效 以旧换新 节能 2026", "description": "一级能效/国补政策 | 一级能效占比90%+", "sort_order": 5, "is_active": True},
|
||||
{"name": "碳普惠", "search_query": "碳账户 碳普惠 个人碳减排 蚂蚁森林 2026", "description": "碳账户/碳普惠/个人减排 | 武汉200万碳账户", "sort_order": 6, "is_active": True},
|
||||
{"name": "环保科技产品", "search_query": "环保科技 绿色产品 可持续材料 2026", "description": "可持续材料/绿色产品 | 购买占比超32%", "sort_order": 7, "is_active": True},
|
||||
{"name": "AI与效率", "search_query": "AI工具 人工智能 效率提升 2026", "description": "AI工具/效率方法/数字助手 | 2026年AI深度融入消费与生活", "sort_order": 8, "is_active": True},
|
||||
]
|
||||
for cd in default_cats:
|
||||
existing = db.query(CollectorCategory).filter(CollectorCategory.name == cd["name"]).first()
|
||||
if not existing:
|
||||
db.add(CollectorCategory(**cd))
|
||||
db.commit()
|
||||
print("✅ 插入默认采集类别和信息源")
|
||||
|
||||
db.commit()
|
||||
|
||||
# 补充缺失的类别和信息源(对已有数据库的迁移)
|
||||
for sd in [
|
||||
{"name": "AI工具搜索", "source_type": "web_search", "query": "AI工具 人工智能 效率提升 2026", "credibility": "medium", "focus": "AI与效率", "sort_order": 99, "is_active": True},
|
||||
]:
|
||||
if not db.query(CollectorSource).filter(CollectorSource.name == sd["name"]).first():
|
||||
db.add(CollectorSource(**sd))
|
||||
db.commit()
|
||||
|
||||
if db.query(TopicStatusConfig).count() == 0:
|
||||
statuses = [
|
||||
{"status": "pending", "label": "待处理", "color": "#E6A23C", "icon": "⏳", "sort_order": 1, "is_default": True},
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
from .database import engine, get_db, init_db
|
||||
from .models import Base
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt
|
||||
from .initial_data import import_initial_data
|
||||
from .core.scheduler import scheduler
|
||||
|
||||
@@ -67,6 +67,7 @@ app.include_router(metrics.router)
|
||||
app.include_router(assets.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(platform_config.router)
|
||||
app.include_router(collector_mgmt.router)
|
||||
|
||||
# 挂载前端
|
||||
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
|
||||
|
||||
@@ -41,6 +41,7 @@ class User(Base):
|
||||
username = Column(String, unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String, nullable=False)
|
||||
role = Column(String, default="user", nullable=False)
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
last_login = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
@@ -50,6 +51,7 @@ class User(Base):
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"role": self.role,
|
||||
"org_id": self.org_id,
|
||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
@@ -153,6 +155,7 @@ class Topic(Base):
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
|
||||
field_name = Column(String, nullable=True)
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
title = Column(String, nullable=False)
|
||||
format = Column(String)
|
||||
core_concept = Column(Text)
|
||||
@@ -199,6 +202,7 @@ class Article(Base):
|
||||
html_content = Column(Text)
|
||||
word_count = Column(Integer, nullable=True)
|
||||
outline = Column(Text, nullable=True)
|
||||
images = Column(JSON, default=dict) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"}
|
||||
|
||||
|
||||
class PublishRecord(Base):
|
||||
@@ -482,6 +486,9 @@ class LLMConfig(Base):
|
||||
temperature = Column(Float, default=0.7)
|
||||
max_tokens = Column(Integer, default=2000)
|
||||
model = Column(String, nullable=True)
|
||||
provider = Column(String, default="opencode-go") # opencode-go / nvidia
|
||||
base_url = Column(String, nullable=True)
|
||||
api_key = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
@@ -495,6 +502,9 @@ class LLMConfig(Base):
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"model": self.model,
|
||||
"provider": self.provider,
|
||||
"base_url": self.base_url,
|
||||
"api_key": f"{self.api_key[:8]}..." if self.api_key else None,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
@@ -521,3 +531,67 @@ class SystemConfig(Base):
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CollectorCategory(Base):
|
||||
"""采集类别(可在运营管理中动态编辑)"""
|
||||
__tablename__ = "collector_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
search_query = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
sources = relationship("CollectorSource", back_populates="category", cascade="all, delete-orphan")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"search_query": self.search_query,
|
||||
"sort_order": self.sort_order,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CollectorSource(Base):
|
||||
"""采集信息源(可在运营管理中动态编辑)"""
|
||||
__tablename__ = "collector_sources"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
category_id = Column(Integer, ForeignKey("collector_categories.id"), nullable=True)
|
||||
name = Column(String, nullable=False)
|
||||
source_type = Column(String, nullable=False) # rss / web_search / local
|
||||
url = Column(Text, nullable=True)
|
||||
query = Column(String, nullable=True)
|
||||
credibility = Column(String, default="medium")
|
||||
focus = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
category = relationship("CollectorCategory", back_populates="sources")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"category_id": self.category_id,
|
||||
"name": self.name,
|
||||
"source_type": self.source_type,
|
||||
"url": self.url,
|
||||
"query": self.query,
|
||||
"credibility": self.credibility,
|
||||
"focus": self.focus,
|
||||
"is_active": self.is_active,
|
||||
"sort_order": self.sort_order,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -61,13 +61,14 @@ class TopicBase(BaseModel):
|
||||
id: str
|
||||
field_id: Optional[int] = None
|
||||
field_name: Optional[str] = None
|
||||
org_id: Optional[str] = None
|
||||
title: str
|
||||
format: Optional[str] = None
|
||||
core_concept: Optional[str] = None
|
||||
audience_pain: Optional[str] = None
|
||||
unique_angle: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
priority_score: int = 0
|
||||
priority_score: int | float = 0
|
||||
total_score: Optional[float] = None
|
||||
status: str = "pending"
|
||||
tags: List[str] = []
|
||||
@@ -176,6 +177,8 @@ class ContentCalendarResponse(ContentCalendarBase):
|
||||
created_by: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
topic_status: Optional[str] = None
|
||||
platform_icon: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -356,6 +359,7 @@ class UserBase(BaseModel):
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
org_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
@@ -366,6 +370,7 @@ class UserUpdate(BaseModel):
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
org_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -457,6 +462,9 @@ class LLMConfigBase(BaseModel):
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 2000
|
||||
model: Optional[str] = None
|
||||
provider: str = "opencode-go"
|
||||
base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@@ -464,6 +472,8 @@ class LLMConfigResponse(LLMConfigBase):
|
||||
id: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
+370
-98
@@ -7,69 +7,28 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.card-loading { display: flex; justify-content: center; align-items: center; min-height: 200px; color: #909399; font-size: 14px; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #909399; }
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.4; }
|
||||
.empty-state .empty-text { font-size: 14px; margin-bottom: 16px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.toolbar { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.el-table { width: 100%; }
|
||||
.el-table .el-table__cell { word-break: break-word; }
|
||||
.stat-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.stat-item { background: #f0f5ff; border-radius: 8px; padding: 12px 20px; display: flex; flex-direction: column; align-items: center; min-width: 100px; }
|
||||
.stat-item .num { font-size: 24px; font-weight: 600; color: #409eff; }
|
||||
.stat-item .label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
|
||||
.mobile-card-list { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.data-table { display: none; }
|
||||
.mobile-card-list { display: block; }
|
||||
.mobile-card {
|
||||
background: #fafbfc;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #ebeef5;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.mobile-card:active { transform: scale(0.99); }
|
||||
.mobile-card-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed #f0f0f0; }
|
||||
.mobile-card-row:last-child { border-bottom: none; }
|
||||
.mobile-card-label { color: #909399; flex-shrink: 0; margin-right: 8px; }
|
||||
.mobile-card-value { color: #303133; text-align: right; word-break: break-word; }
|
||||
.mobile-card-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 10px; margin-top: 6px; border-top: 1px solid #ebeef5; }
|
||||
.stat-summary { gap: 8px; }
|
||||
.stat-item { min-width: 70px; padding: 8px 12px; }
|
||||
.stat-item .num { font-size: 18px; }
|
||||
}
|
||||
.data-table { display: block; }
|
||||
@media (max-width: 768px) { .data-table { display: none; } }
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="系统管理" :username="currentUser.username" :is-admin="isAdmin" @logout="logout"></navbar-component>
|
||||
<navigation-component current-page="admin" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="系统管理" :username="currentUser.username" :is-admin="isAdmin" current-page="admin" @navigate="redirectToPage" @logout="logout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">⚙️ 系统管理</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
|
||||
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
|
||||
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
|
||||
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
|
||||
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
|
||||
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +39,7 @@
|
||||
<div v-if="casesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="cases.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
|
||||
<div class="empty-text">暂无案例数据</div>
|
||||
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
|
||||
</div>
|
||||
@@ -103,12 +62,12 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in cases" :key="item.id" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">标题</span><span class="mobile-card-value">{{ item.title }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">领域</span><span class="mobile-card-value">{{ item.field }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">来源</span><span class="mobile-card-value">{{ item.source }}</span></div>
|
||||
<div class="mobile-card-actions">
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in cases" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
|
||||
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
|
||||
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
|
||||
</div>
|
||||
@@ -123,7 +82,7 @@
|
||||
<div v-if="taskLogsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="taskLogs.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
|
||||
<div class="empty-text">暂无任务日志</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -139,13 +98,13 @@
|
||||
<el-table-column prop="duration" label="耗时" width="80"></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in taskLogs" :key="item.id" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">任务</span><span class="mobile-card-value">{{ item.task_name }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">选题</span><span class="mobile-card-value">{{ item.topic_id }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">状态</span><span class="mobile-card-value">{{ item.status }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">消息</span><span class="mobile-card-value">{{ item.message }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">耗时</span><span class="mobile-card-value">{{ item.duration }}s</span></div>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in taskLogs" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">任务</span><span class="card-value">{{ item.task_name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_id }}</span></div>
|
||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value">{{ item.status }}</span></div>
|
||||
<div class="card-row"><span class="card-label">消息</span><span class="card-value">{{ item.message }}</span></div>
|
||||
<div class="card-row"><span class="card-label">耗时</span><span class="card-value">{{ item.duration }}s</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +116,7 @@
|
||||
<div v-if="llmConfigsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="llmConfigs.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🤖</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
|
||||
<div class="empty-text">暂无 LLM 配置</div>
|
||||
<el-button type="primary" size="small" @click="showLLMConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
@@ -170,7 +129,10 @@
|
||||
<el-table-column prop="temperature" label="温度" width="80"></el-table-column>
|
||||
<el-table-column prop="max_tokens" label="最大Token" width="110"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="70">
|
||||
<template #default="scope">{{ scope.row.is_active ? '是' : '否' }}</template>
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
@@ -180,13 +142,13 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in llmConfigs" :key="item.id" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">名称</span><span class="mobile-card-value">{{ item.name }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">模型</span><span class="mobile-card-value">{{ item.model }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">温度</span><span class="mobile-card-value">{{ item.temperature }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">激活</span><span class="mobile-card-value">{{ item.is_active ? '是' : '否' }}</span></div>
|
||||
<div class="mobile-card-actions">
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in llmConfigs" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
|
||||
<div class="card-row"><span class="card-label">温度</span><span class="card-value">{{ item.temperature }}</span></div>
|
||||
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ item.is_active ? '是' : '否' }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteLLMConfig(item.id)">删除</el-button>
|
||||
</div>
|
||||
@@ -201,7 +163,7 @@
|
||||
<div v-if="systemConfigsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="systemConfigs.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚙️</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
|
||||
<div class="empty-text">暂无系统配置</div>
|
||||
<el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
@@ -223,18 +185,162 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="mobile-card-list">
|
||||
<div v-for="item in systemConfigs" :key="item.key" class="mobile-card">
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">键</span><span class="mobile-card-value">{{ item.key }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">值</span><span class="mobile-card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
|
||||
<div class="mobile-card-row"><span class="mobile-card-label">描述</span><span class="mobile-card-value">{{ item.description }}</span></div>
|
||||
<div class="mobile-card-actions">
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="item in systemConfigs" :key="item.key" class="card-item">
|
||||
<div class="card-row"><span class="card-label">键</span><span class="card-value">{{ item.key }}</span></div>
|
||||
<div class="card-row"><span class="card-label">值</span><span class="card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
|
||||
<div class="card-row"><span class="card-label">描述</span><span class="card-value">{{ item.description }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showSystemConfigDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSystemConfig(item.key)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'categories'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
||||
<el-button size="small" @click="loadCategories">刷新</el-button>
|
||||
</div>
|
||||
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="categories.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
|
||||
<div class="empty-text">暂无采集类别</div>
|
||||
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="categories" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="60"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
|
||||
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
|
||||
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="70">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="categories.length > 0">
|
||||
<div v-for="item in categories" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
|
||||
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'orgs'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
|
||||
<el-button size="small" @click="loadOrgs">刷新</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ orgs.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="orgsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="orgs.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
|
||||
<div class="empty-text">暂无组织</div>
|
||||
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="orgs" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="org_id" label="组织ID" min-width="120"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="150"></el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
|
||||
<el-table-column prop="user_count" label="用户数" width="80"></el-table-column>
|
||||
<el-table-column prop="topic_count" label="选题数" width="80"></el-table-column>
|
||||
<el-table-column label="默认" width="70">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_default" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showOrgDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteOrg(scope.row.org_id)" :disabled="scope.row.is_default">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="orgs.length > 0">
|
||||
<div v-for="item in orgs" :key="item.org_id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">ID</span><span class="card-value">{{ item.org_id }}</span></div>
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">用户</span><span class="card-value">{{ item.user_count }}</span></div>
|
||||
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_count }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showOrgDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteOrg(item.org_id)" :disabled="item.is_default">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'sources'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
||||
<el-button size="small" @click="loadSources">刷新</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="sources.length === 0">
|
||||
<div class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
|
||||
<div class="empty-text">暂无信息源</div>
|
||||
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="sources" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="50"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
|
||||
<el-table-column prop="source_type" label="类型" width="90">
|
||||
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
|
||||
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
|
||||
<el-table-column prop="is_active" label="激活" width="60">
|
||||
<template #default="scope">
|
||||
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div class="card-list-mobile" v-if="sources.length > 0">
|
||||
<div v-for="item in sources" :key="item.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
|
||||
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -267,25 +373,35 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="700px" :close-on-click-modal="false">
|
||||
<el-form :model="llmConfigForm" label-width="100px">
|
||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
|
||||
<el-form :model="llmConfigForm" label-width="110px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name" placeholder="如:default_expand"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="供应商">
|
||||
<el-select v-model="llmConfigForm.provider" style="width:100%">
|
||||
<el-option label="opencode-go (deepseek-v4-pro)" value="opencode-go"></el-option>
|
||||
<el-option label="nvidia (gemma-3n)" value="nvidia"></el-option>
|
||||
</el-select>
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model" placeholder="deepseek-v4-pro"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="接口地址"><el-input v-model="llmConfigForm.base_url" placeholder="https://opencode.ai/zen/go/v1"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="4"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="API Key"><el-input v-model="llmConfigForm.api_key" type="password" show-password placeholder="sk-..."/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1" style="width:100%"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000" style="width:100%"/></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="3" placeholder="你是一个专业的内容创作助手。"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template" :rows="4"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
|
||||
@@ -308,8 +424,63 @@
|
||||
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
|
||||
<el-form :model="categoryForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
|
||||
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
|
||||
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
|
||||
<el-form :model="sourceForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="sourceForm.source_type" style="width:100%">
|
||||
<el-option label="RSS订阅" value="rss"></el-option>
|
||||
<el-option label="搜索引擎" value="web_search"></el-option>
|
||||
<el-option label="本地文件" value="local"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
|
||||
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
|
||||
<el-form-item label="可信度">
|
||||
<el-select v-model="sourceForm.credibility" style="width:100%">
|
||||
<el-option label="高" value="high"></el-option>
|
||||
<el-option label="中" value="medium"></el-option>
|
||||
<el-option label="低" value="low"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
|
||||
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="sourceDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSource">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="orgDialogVisible" :title="orgDialogTitle" width="500px" :close-on-click-modal="false">
|
||||
<el-form :model="orgForm" label-width="80px">
|
||||
<el-form-item label="组织ID"><el-input v-model="orgForm.org_id" :disabled="!!editingOrgId" placeholder="唯一标识,如:org_a"/></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="orgForm.name" placeholder="组织名称"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input type="textarea" v-model="orgForm.description" :rows="3"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="orgDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveOrg">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted } = Vue;
|
||||
@@ -385,7 +556,7 @@
|
||||
const llmConfigsLoading = ref(false);
|
||||
const llmConfigDialogVisible = ref(false);
|
||||
const llmConfigDialogTitle = ref('新增配置');
|
||||
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', is_active: true });
|
||||
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
|
||||
const editingLLMConfigId = ref(null);
|
||||
|
||||
const loadLLMConfigs = async () => {
|
||||
@@ -394,10 +565,18 @@
|
||||
finally { llmConfigsLoading.value = false; }
|
||||
};
|
||||
const showLLMConfigDialog = (row = null) => {
|
||||
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
|
||||
else {
|
||||
if (row) {
|
||||
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
|
||||
llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go';
|
||||
llmConfigForm.model = row.model || ''; llmConfigForm.base_url = row.base_url || ''; llmConfigForm.api_key = '';
|
||||
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 2000;
|
||||
llmConfigForm.system_prompt = row.system_prompt || ''; llmConfigForm.user_prompt_template = row.user_prompt_template || '';
|
||||
llmConfigForm.is_active = row.is_active ?? true;
|
||||
} else {
|
||||
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null;
|
||||
Object.keys(llmConfigForm).forEach(k => { if (k === 'id') llmConfigForm.id = null; else if (k === 'temperature') llmConfigForm.temperature = 0.7; else if (k === 'max_tokens') llmConfigForm.max_tokens = 2000; else if (k === 'is_active') llmConfigForm.is_active = true; else llmConfigForm[k] = ''; });
|
||||
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
|
||||
llmConfigForm.base_url = 'https://opencode.ai/zen/go/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.7;
|
||||
llmConfigForm.max_tokens = 2000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
|
||||
}
|
||||
llmConfigDialogVisible.value = true;
|
||||
};
|
||||
@@ -420,6 +599,50 @@
|
||||
const systemConfigForm = reactive({ key: '', value: '', description: '' });
|
||||
const editingSystemConfigKey = ref(null);
|
||||
|
||||
const categories = ref([]);
|
||||
const categoriesLoading = ref(false);
|
||||
const categoryDialogVisible = ref(false);
|
||||
const categoryDialogTitle = ref('新增类别');
|
||||
const categoryForm = reactive({ name: '', search_query: '', description: '', sort_order: 0, is_active: true });
|
||||
const editingCategoryId = ref(null);
|
||||
|
||||
const sources = ref([]);
|
||||
const sourcesLoading = ref(false);
|
||||
const sourceDialogVisible = ref(false);
|
||||
const sourceDialogTitle = ref('新增信息源');
|
||||
const sourceForm = reactive({ name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true });
|
||||
const editingSourceId = ref(null);
|
||||
|
||||
const orgs = ref([]);
|
||||
const orgsLoading = ref(false);
|
||||
const orgDialogVisible = ref(false);
|
||||
const orgDialogTitle = ref('新增组织');
|
||||
const orgForm = reactive({ org_id: '', name: '', description: '' });
|
||||
const editingOrgId = ref(null);
|
||||
|
||||
const loadOrgs = async () => {
|
||||
orgsLoading.value = true;
|
||||
try { orgs.value = await api.get('/api/admin/orgs'); } catch (e) { ElMessage.error('加载组织失败: ' + e.message); }
|
||||
finally { orgsLoading.value = false; }
|
||||
};
|
||||
const showOrgDialog = (row = null) => {
|
||||
if (row) { orgDialogTitle.value = '编辑组织'; editingOrgId.value = row.org_id; orgForm.org_id = row.org_id; orgForm.name = row.name; orgForm.description = row.description || ''; }
|
||||
else { orgDialogTitle.value = '新增组织'; editingOrgId.value = null; orgForm.org_id = ''; orgForm.name = ''; orgForm.description = ''; }
|
||||
orgDialogVisible.value = true;
|
||||
};
|
||||
const saveOrg = async () => {
|
||||
if (!orgForm.org_id || !orgForm.name) { ElMessage.warning('请填写组织ID和名称'); return; }
|
||||
try {
|
||||
if (editingOrgId.value) { await api.put(`/api/admin/orgs/${editingOrgId.value}`, orgForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/orgs', orgForm); ElMessage.success('创建成功'); }
|
||||
orgDialogVisible.value = false; await loadOrgs();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteOrg = async (orgId) => {
|
||||
try { await ElMessageBox.confirm('确定删除该组织吗?关联的用户和选题不会被删除。', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${orgId}`); ElMessage.success('删除成功'); await loadOrgs(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const loadSystemConfigs = async () => {
|
||||
systemConfigsLoading.value = true;
|
||||
try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); }
|
||||
@@ -442,13 +665,59 @@
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
categoriesLoading.value = true;
|
||||
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
|
||||
finally { categoriesLoading.value = false; }
|
||||
};
|
||||
const showCategoryDialog = (row = null) => {
|
||||
if (row) { categoryDialogTitle.value = '编辑类别'; editingCategoryId.value = row.id; Object.assign(categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
|
||||
else { categoryDialogTitle.value = '新增类别'; editingCategoryId.value = null; categoryForm.name = ''; categoryForm.search_query = ''; categoryForm.description = ''; categoryForm.sort_order = 0; categoryForm.is_active = true; }
|
||||
categoryDialogVisible.value = true;
|
||||
};
|
||||
const saveCategory = async () => {
|
||||
try {
|
||||
if (editingCategoryId.value) { await api.put(`/api/admin/collector/categories/${editingCategoryId.value}`, categoryForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/collector/categories', categoryForm); ElMessage.success('创建成功'); }
|
||||
categoryDialogVisible.value = false; await loadCategories();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteCategory = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/categories/${id}`); ElMessage.success('删除成功'); await loadCategories(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const loadSources = async () => {
|
||||
sourcesLoading.value = true;
|
||||
try { sources.value = await api.get('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
|
||||
finally { sourcesLoading.value = false; }
|
||||
};
|
||||
const showSourceDialog = (row = null) => {
|
||||
if (row) { sourceDialogTitle.value = '编辑信息源'; editingSourceId.value = row.id; Object.assign(sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
|
||||
else { sourceDialogTitle.value = '新增信息源'; editingSourceId.value = null; sourceForm.name = ''; sourceForm.source_type = 'web_search'; sourceForm.query = ''; sourceForm.credibility = 'medium'; sourceForm.focus = ''; sourceForm.sort_order = 0; sourceForm.is_active = true; }
|
||||
sourceDialogVisible.value = true;
|
||||
};
|
||||
const saveSource = async () => {
|
||||
try {
|
||||
if (editingSourceId.value) { await api.put(`/api/admin/collector/sources/${editingSourceId.value}`, sourceForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/collector/sources', sourceForm); ElMessage.success('创建成功'); }
|
||||
sourceDialogVisible.value = false; await loadSources();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteSource = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/sources/${id}`); ElMessage.success('删除成功'); await loadSources(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||
|
||||
const tabLoaders = {
|
||||
cases: loadCases, tasklogs: loadTaskLogs,
|
||||
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
|
||||
categories: loadCategories, sources: loadSources,
|
||||
orgs: loadOrgs,
|
||||
};
|
||||
const loadedTabs = new Set();
|
||||
const loadedTabs = new Set(['cases']);
|
||||
|
||||
const switchTab = (name) => {
|
||||
activeTab.value = name;
|
||||
@@ -469,14 +738,17 @@
|
||||
taskLogs, taskLogsLoading, loadTaskLogs,
|
||||
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
||||
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
|
||||
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
|
||||
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
|
||||
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
|
||||
logout, currentUser, isAdmin, redirectToPage
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 文章管理</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
.search-bar { margin-left: auto; width: 240px; }
|
||||
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||
.status-dot.draft { background: #E6A23C; }
|
||||
.status-dot.reviewed { background: #67C23A; }
|
||||
.status-dot.published { background: #409EFF; }
|
||||
|
||||
.article-card-list { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.el-table { display: none; }
|
||||
.article-card-list { display: block; }
|
||||
.article-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.article-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.article-card-title { font-size: 14px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
|
||||
.article-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: #606266; margin-bottom: 12px; }
|
||||
.article-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
|
||||
.article-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
|
||||
.search-bar { width: 100%; margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部平台" value=""></el-option>
|
||||
<el-option label="知乎" value="zhihu"></el-option>
|
||||
<el-option label="微信公众号" value="wechat"></el-option>
|
||||
<el-option label="小红书" value="xiaohongshu"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部状态" value=""></el-option>
|
||||
<el-option label="草稿" value="draft"></el-option>
|
||||
<el-option label="已审查" value="reviewed"></el-option>
|
||||
<el-option label="已发布" value="published"></el-option>
|
||||
</el-select>
|
||||
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
|
||||
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
|
||||
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
|
||||
<el-table-column prop="topic_id" label="选题 ID" width="80"></el-table-column>
|
||||
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="100">
|
||||
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="80">
|
||||
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="word_count" label="字数" width="70">
|
||||
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配图" width="60">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
|
||||
<span v-else style="color:#dcdfe6;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
|
||||
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
|
||||
<div class="article-card-header">
|
||||
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
|
||||
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="article-card-meta">
|
||||
<div>ID: {{ article.id }}</div>
|
||||
<div>平台: {{ platformLabel(article.platform) }}</div>
|
||||
<div>合规: {{ article.compliance_score ?? '-' }}</div>
|
||||
<div>字数: {{ article.word_count ?? '-' }}</div>
|
||||
<div>创建: {{ formatDate(article.created_at) }}</div>
|
||||
</div>
|
||||
<div class="article-card-actions">
|
||||
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
|
||||
<el-icon><IconDocument /></el-icon> 暂无文章
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
|
||||
<div v-if="previewArticleData">
|
||||
<div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:200px;">
|
||||
<h3 style="margin:0; font-size:15px;">{{ previewArticleData.topic_title || previewArticleData.topic_id }}</h3>
|
||||
<div style="font-size:12px; color:#909399; margin-top:4px;">
|
||||
{{ platformLabel(previewArticleData.platform) }} · {{ statusLabel(previewArticleData.status) }} · {{ previewArticleData.word_count ?? '-' }}字
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="previewArticleData.images && previewArticleData.images.cover" style="flex-shrink:0;">
|
||||
<img :src="previewArticleData.images.cover" style="height:80px; border-radius:8px; border:1px solid #ebeef5; object-fit:cover;" alt="封面">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
|
||||
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe>
|
||||
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制 HTML</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const ArticlesApp = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
articles: [],
|
||||
filterPlatform: '', filterStatus: '', searchQuery: '',
|
||||
previewVisible: false, previewArticleData: null, previewFullscreen: false,
|
||||
debounceTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredArticles() {
|
||||
let list = this.articles;
|
||||
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
|
||||
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
|
||||
if (this.searchQuery) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getToken() { return localStorage.getItem('authToken'); },
|
||||
async api(url, opts = {}) {
|
||||
const token = this.getToken();
|
||||
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/', 1500); return null; }
|
||||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
|
||||
return res.json();
|
||||
},
|
||||
async fetchArticles() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filterPlatform) params.set('platform', this.filterPlatform);
|
||||
if (this.filterStatus) params.set('status', this.filterStatus);
|
||||
const qs = params.toString();
|
||||
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
|
||||
this.articles = data.articles || [];
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error);
|
||||
this.$message.error('获取文章列表失败: ' + error.message);
|
||||
this.articles = [];
|
||||
} finally { this.loadingTable = false; }
|
||||
},
|
||||
debouncedSearch() {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
|
||||
},
|
||||
async previewArticle(article) {
|
||||
this.previewArticleData = null;
|
||||
this.previewVisible = true;
|
||||
this.previewFullscreen = false;
|
||||
try {
|
||||
const data = await this.api('/api/articles/detail/' + article.id);
|
||||
this.previewArticleData = data;
|
||||
} catch (error) {
|
||||
this.$message.error('加载文章详情失败: ' + error.message);
|
||||
this.previewVisible = false;
|
||||
}
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
this.$nextTick(() => {
|
||||
const overlays = document.querySelectorAll('.el-overlay');
|
||||
const overlay = overlays[overlays.length - 1];
|
||||
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
|
||||
const dialog = document.querySelector('.preview-dialog-custom');
|
||||
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
|
||||
});
|
||||
},
|
||||
copyPreviewHtml() {
|
||||
if (!this.previewArticleData || !this.previewArticleData.html_content) {
|
||||
this.$message.warning('暂无 HTML 内容可复制');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(this.previewArticleData.html_content)
|
||||
.then(() => this.$message.success('HTML 已复制'))
|
||||
.catch(() => this.$message.error('复制失败'));
|
||||
},
|
||||
async deleteArticle(article) {
|
||||
try {
|
||||
await this.$confirm(`确定删除文章 [${article.id}]?`, '提示', { type: 'warning' });
|
||||
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchArticles();
|
||||
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
|
||||
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
|
||||
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
catch (e) { return dateStr; }
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(ArticlesApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,14 +7,6 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
}
|
||||
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
||||
.asset-item { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; transition: all 0.3s; cursor: pointer; }
|
||||
.asset-item:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.2); }
|
||||
@@ -25,34 +17,35 @@
|
||||
.upload-area { border: 2px dashed #dcdfe6; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.3s; }
|
||||
.upload-area:hover { border-color: #409eff; background: #f0f9eb; }
|
||||
.upload-icon { font-size: 48px; color: #c0c4cc; }
|
||||
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
@media (max-width: 768px) {
|
||||
.asset-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="素材库" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="assets" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="素材库" :username="currentUser.username" :is-admin="isAdmin" current-page="assets" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🖼️ 素材库</h2>
|
||||
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon> 素材库</h2>
|
||||
<div class="card page-fade">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索素材..." style="width: 200px;" clearable @clear="loadAssets" @keyup.enter="loadAssets">
|
||||
<template #prefix><span>🔍</span></template>
|
||||
<el-input v-model="searchKeyword" placeholder="搜索素材..." clearable @clear="loadAssets" @keyup.enter="loadAssets" style="flex:1;min-width:140px;">
|
||||
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterType" placeholder="文件类型" style="width: 120px;" clearable @change="loadAssets">
|
||||
<el-select v-model="filterType" placeholder="文件类型" clearable @change="loadAssets" style="width:120px;">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="图片" value="image"></el-option>
|
||||
<el-option label="视频" value="video"></el-option>
|
||||
<el-option label="文档" value="document"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filterTag" placeholder="标签筛选" style="width: 150px;" clearable @change="loadAssets">
|
||||
<el-select v-model="filterTag" placeholder="标签筛选" clearable @change="loadAssets" style="width:130px;">
|
||||
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag"></el-option>
|
||||
</el-select>
|
||||
<el-button @click="loadAssets">🔄 刷新</el-button>
|
||||
<el-button type="primary" @click="showUploadDialog = true">📤 上传素材</el-button>
|
||||
<el-button @click="loadAssets"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
<el-button type="primary" @click="showUploadDialog = true"><el-icon style="vertical-align:-2px;"><IconUpload /></el-icon> 上传素材</el-button>
|
||||
</div>
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 20px; font-size: 14px; color: #606266;">
|
||||
<span>总计: {{ assetStats.total }} 个</span>
|
||||
@@ -60,15 +53,15 @@
|
||||
</div>
|
||||
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
|
||||
<div v-else-if="assets.length === 0" style="text-align: center; padding: 40px; color: #909399;">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">📂</div>
|
||||
<el-icon style="font-size:48px;margin-bottom:16px;color:#c0c4cc;"><IconPicture /></el-icon>
|
||||
<div>暂无素材,点击上方按钮上传</div>
|
||||
</div>
|
||||
<div v-else class="asset-grid">
|
||||
<div v-for="asset in assets" :key="asset.id" class="asset-item" @click="previewAsset(asset)">
|
||||
<div class="asset-thumb">
|
||||
<template v-if="asset.file_type === 'image'">🖼️</template>
|
||||
<template v-if="asset.file_type === 'image'"><el-icon style="font-size:32px;"><IconPicture /></el-icon></template>
|
||||
<template v-else-if="asset.file_type === 'video'">🎬</template>
|
||||
<template v-else>📄</template>
|
||||
<template v-else><el-icon style="font-size:32px;"><IconDocument /></el-icon></template>
|
||||
</div>
|
||||
<div class="asset-name">{{ asset.filename }}</div>
|
||||
<div class="asset-meta">{{ formatSize(asset.size) }} | {{ formatDate(asset.created_at) }}</div>
|
||||
@@ -87,7 +80,7 @@
|
||||
<el-dialog v-model="showUploadDialog" title="上传素材" width="500px">
|
||||
<el-upload ref="uploadRef" drag :auto-upload="false" :limit="10" :on-change="handleFileChange" multiple accept="image/*,.pdf,.doc,.docx,.ppt,.pptx,.mp4,.mov,.avi">
|
||||
<div class="upload-area">
|
||||
<div class="upload-icon">📤</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUpload /></el-icon>
|
||||
<div style="margin-top: 12px; color: #606266;">将文件拖到此处,或<span style="color: #409eff;">点击上传</span></div>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 8px;">支持: JPG, PNG, GIF, WebP, PDF, Word, PPT, MP4</div>
|
||||
</div>
|
||||
@@ -104,9 +97,9 @@
|
||||
<el-dialog v-model="showPreviewDialog" title="素材预览" width="800px">
|
||||
<div v-if="previewAssetData" style="text-align: center;">
|
||||
<div style="font-size: 64px; margin-bottom: 16px;">
|
||||
<template v-if="previewAssetData.file_type === 'image'">🖼️</template>
|
||||
<template v-if="previewAssetData.file_type === 'image'"><el-icon style="font-size:64px;"><IconPicture /></el-icon></template>
|
||||
<template v-else-if="previewAssetData.file_type === 'video'">🎬</template>
|
||||
<template v-else>📄</template>
|
||||
<template v-else><el-icon style="font-size:64px;"><IconDocument /></el-icon></template>
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">{{ previewAssetData.filename }}</div>
|
||||
<div style="color: #909399; font-size: 14px;">{{ formatSize(previewAssetData.size) }} | {{ formatDate(previewAssetData.created_at) }}</div>
|
||||
@@ -119,6 +112,7 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const AssetsApp = {
|
||||
@@ -186,7 +180,8 @@ const AssetsApp = {
|
||||
if (this.searchKeyword) url += '&search=' + encodeURIComponent(this.searchKeyword);
|
||||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.assets = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载素材失败: ' + e.message); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
async loadTags() {
|
||||
@@ -194,14 +189,14 @@ const AssetsApp = {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/assets/tags', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.allTags = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载标签失败: ' + e.message); }
|
||||
},
|
||||
async loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/assets/counts', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.assetStats = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载统计失败: ' + e.message); }
|
||||
},
|
||||
handleFileChange(file, fileList) { this.uploadFiles = fileList; },
|
||||
async uploadFiles() {
|
||||
@@ -247,8 +242,8 @@ const AssetsApp = {
|
||||
};
|
||||
const app = Vue.createApp(AssetsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -7,22 +7,23 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.content-area { padding: 32px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 12px; padding-bottom: 80px; }
|
||||
.calendar-day { min-height: 56px !important; padding: 2px; }
|
||||
.calendar-weekday { padding: 6px; font-size: 12px; }
|
||||
.day-entry { font-size: 10px; padding: 2px 4px; white-space: normal; }
|
||||
.content-area { padding: 12px !important; padding-bottom: 80px !important; }
|
||||
.card { padding: 12px !important; }
|
||||
.calendar-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.calendar-title { font-size: 20px; }
|
||||
.calendar-nav { width: 100%; justify-content: space-between; }
|
||||
.calendar-grid { gap: 2px; }
|
||||
.calendar-weekday { padding: 4px; font-size: 11px; }
|
||||
.calendar-day { min-height: 56px !important; padding: 2px; }
|
||||
.day-number { font-size: 12px; margin-bottom: 0; }
|
||||
.day-lunar { font-size: 9px; margin-top: -1px; }
|
||||
.day-term { font-size: 8px; padding: 0 3px; }
|
||||
.day-holiday { font-size: 8px; padding: 0 3px; }
|
||||
.day-lunar { font-size: 9px; margin-top: -1px; display: none; }
|
||||
.day-term { font-size: 8px; padding: 0 2px; }
|
||||
.day-holiday { font-size: 8px; padding: 0 2px; }
|
||||
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
|
||||
.stats-bar { gap: 12px; font-size: 12px; }
|
||||
}
|
||||
|
||||
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
@@ -39,11 +40,16 @@
|
||||
.day-term { display: inline-block; font-size: 9px; color: #E6A23C; background: #fdf6ec; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
|
||||
.day-holiday { display: inline-block; font-size: 9px; color: #F56C6C; background: #fef0f0; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
|
||||
.day-entries { display: flex; flex-direction: column; gap: 4px; }
|
||||
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
|
||||
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; display:flex; align-items:center; gap:3px; }
|
||||
.day-entry.planned { background: #fdf6ec; color: #E6A23C; }
|
||||
.day-entry.published { background: #f0f9eb; color: #67C23A; }
|
||||
.day-entry.delayed { background: #fef0f0; color: #F56C6C; }
|
||||
.day-entry.cancelled { background: #f4f4f5; color: #909399; }
|
||||
.entry-topic-status { font-size:9px; padding:1px 3px; border-radius:2px; margin-left:auto; flex-shrink:0; }
|
||||
.entry-topic-status.review { background:#ecf5ff; color:#409eff; }
|
||||
.entry-topic-status.ready { background:#fdf6ec; color:#e6a23c; }
|
||||
.entry-topic-status.published { background:#f0f9eb; color:#67c23a; }
|
||||
.entry-topic-status.pending { background:#f4f4f5; color:#909399; }
|
||||
|
||||
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.stat-item { display: flex; align-items: center; gap: 8px; }
|
||||
@@ -53,40 +59,37 @@
|
||||
.stat-dot.delayed { background: #F56C6C; }
|
||||
.stat-dot.cancelled { background: #909399; }
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component
|
||||
title="内容日历"
|
||||
:username="currentUser.username"
|
||||
:is-admin="isAdmin"
|
||||
@logout="handleLogout"
|
||||
></navbar-component>
|
||||
<navigation-component
|
||||
current-page="calendar"
|
||||
:is-admin="isAdmin"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
<uni-nav title="内容日历" :username="currentUser.username" :is-admin="isAdmin" current-page="calendar" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="calendar-header">
|
||||
<h2 class="calendar-title">📅 内容日历</h2>
|
||||
<h2 class="calendar-title"><el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 内容日历</h2>
|
||||
<div class="calendar-nav">
|
||||
<el-button @click="prevMonth" size="large">◀</el-button>
|
||||
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon></el-button>
|
||||
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
|
||||
<el-button @click="nextMonth" size="large">▶</el-button>
|
||||
<el-button @click="nextMonth" size="large"><el-icon><IconArrowRight /></el-icon></el-button>
|
||||
<el-button type="primary" @click="goToday">今天</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div 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,14 +398,14 @@ 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>
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
||||
(function() {
|
||||
function ic(path, vb) {
|
||||
return { render() { return Vue.h('svg', { xmlns:'http://www.w3.org/2000/svg', viewBox: vb || '0 0 1024 1024' }, Vue.h('path', { fill:'currentColor', d: path })) } };
|
||||
}
|
||||
const icons = {
|
||||
IconSearch: ic('m795.904 750.72 124.992 124.928a32 32 0 0 1-45.248 45.248L750.656 795.904a416 416 0 1 1 45.248-45.248zM480 832a352 352 0 1 0 0-704 352 352 0 0 0 0 704'),
|
||||
IconDocument: ic('M832 384H576V128H192v768h640zm-26.496-64L640 154.496V320zM160 64h480l256 256v608a32 32 0 0 1-32 32H160a32 32 0 0 1-32-32V96a32 32 0 0 1 32-32'),
|
||||
IconPlus: ic('M480 480V128a32 32 0 0 1 64 0v352h352a32 32 0 1 1 0 64H544v352a32 32 0 1 1-64 0V544H128a32 32 0 1 1 0-64h352z'),
|
||||
IconClock: ic('M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896m-32-832a32 32 0 0 1 32 32v256a32 32 0 0 1-64 0V160a32 32 0 0 1 32-32'),
|
||||
IconDelete: ic('M160 256H96a32 32 0 0 1 0-64h256V95.936a32 32 0 0 1 32-32h256a32 32 0 0 1 32 32V192h256a32 32 0 1 1 0 64h-64v672a32 32 0 0 1-32 32H192a32 32 0 0 1-32-32V256zm448 0H256v640h352V256z'),
|
||||
IconClose: ic('M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.88 512 214.592 764.288a31.936 31.936 0 1 0 45.12 45.12L512 557.12l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512l252.288-252.288a31.936 31.936 0 1 0-45.12-45.12z'),
|
||||
IconCheck: ic('M406.656 706.944 195.84 496.256a32 32 0 1 0-45.248 45.248l256 256 512-512a32 32 0 0 0-45.248-45.248L406.656 706.944z'),
|
||||
IconStar: ic('m512 747.84 228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.84a6.4 6.4 0 0 0-11.52 0L389.12 379.136l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L314.88 606.976 271.36 861.056a6.4 6.4 0 0 0 9.28 6.72L512 747.84z'),
|
||||
IconArrowDown: ic('M831.872 340.864 512 652.672 192.128 340.864a30.59 30.59 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.59 30.59 0 0 0-42.752 0z'),
|
||||
IconArrowRight: ic('M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512 340.864 831.872a30.59 30.59 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.312a29.12 29.12 0 0 0-41.728 0z'),
|
||||
IconArrowLeft: ic('M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.59 30.59 0 0 0 0-42.752L338.688 512l312.448-340.352a30.59 30.59 0 0 0 0-42.752 29.12 29.12 0 0 0-41.728 0z'),
|
||||
IconRefresh: ic('M784.512 230.272v-50.56a32 32 0 1 1 64 0v149.056a32 32 0 0 1-32 32H667.52a32 32 0 1 1 0-64h92.992A288 288 0 0 0 224 512a32 32 0 1 1-64 0 352 352 0 0 1 624.512-224z m-545.024 563.456v50.56a32 32 0 1 1-64 0V694.912a32 32 0 0 1 32-32h149.056a32 32 0 1 1 0 64H263.552A288 288 0 0 0 800 512a32 32 0 1 1 64 0 352 352 0 0 1-624.512 224z'),
|
||||
IconPicture: ic('M96 896a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h832a32 32 0 0 1 32 32v704a32 32 0 0 1-32 32H96zm32-64h768V192H128v640zm319.808-457.152L320 512l128 170.496-63.744 47.808L256 563.2 128 736h768L574.72 321.6a32 32 0 0 0-50.432 0zM448 384a64 64 0 1 0-128 0 64 64 0 0 0 128 0z'),
|
||||
IconInfo: ic('M512 64a448 448 0 1 1 0 896.064A448 448 0 0 1 512 64m67.2 275.072c33.28 0 60.288-23.104 60.288-57.6s-27.072-57.6-60.288-57.6c-34.176 0-60.288 23.104-60.288 57.6s26.88 57.6 60.288 57.6m-109.44 355.008c0 17.088 12.864 29.76 35.328 29.76 29.312 0 52.416-16.832 52.416-44.928v-200.32c0-18.688-7.04-76.8-48.896-76.8-22.4 0-35.2 17.408-35.2 34.688 0 6.848 3.2 19.456 3.2 24.96 0 0-55.872 143.488-55.872 180.48 0 17.28 13.792 51.84 48.128 51.84z'),
|
||||
IconWarning: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 192a58.43 58.43 0 0 0-58.24 63.744l23.36 256.512a35.52 35.52 0 0 0 69.76 0l23.2-256.512A58.43 58.43 0 0 0 512 256m0 448a64 64 0 1 0 0 128 64 64 0 0 0 0-128'),
|
||||
IconSuccess: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.27 38.27 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336L456.192 600.384z'),
|
||||
IconLoading: ic('M512 64a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V96a32 32 0 0 1 32-32m0 640a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V736a32 32 0 0 1 32-32m259.712-412.288a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM381.568 590.432a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM832 480a32 32 0 0 1 32 32h-192a32 32 0 0 1 0-64h160a32 32 0 0 1 32 32zM192 480a32 32 0 0 1 32 32H64a32 32 0 0 1 0-64h128a32 32 0 0 1 32 32zm596.288 339.712a32 32 0 0 1 0 45.248l-135.808-135.808a32 32 0 0 1 45.248-45.248l90.56 90.56v.248zM313.248 389.568a32 32 0 0 1-45.248 45.248l-90.56-90.56a32 32 0 0 1 45.248-45.248l90.56 90.56z'),
|
||||
IconMore: ic('M176 416a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224'),
|
||||
IconFullScreen: ic('m160 96.064 192 .192a32 32 0 0 1 0 64l-192-.192V352a32 32 0 0 1-64 0V96h64zm0 832v-192a32 32 0 0 1 64 0v192l192-.192a32 32 0 0 1 0 64l-192-.192H160zM864 96.064l-192 .192a32 32 0 0 0 0 64l192-.192V352a32 32 0 0 0 64 0V96h-64zm0 736V640a32 32 0 0 0-64 0v192l-192-.192a32 32 0 0 0 0 64l192 .192H864z'),
|
||||
IconMenu: ic('M160 224a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32'),
|
||||
IconUser: ic('M659.2 659.2a192 192 0 1 0-294.4 0 448 448 0 0 0-294.4 307.2 32 32 0 1 0 62.08 15.36 384 384 0 0 1 724.48 0 32 32 0 0 0 62.08-15.36 448 448 0 0 0-259.84-307.2zM512 640a128 128 0 1 1 0-256 128 128 0 0 1 0 256'),
|
||||
IconSetting: ic('M640 512a128 128 0 1 0-256 0 128 128 0 0 0 256 0m64 0a192 192 0 1 1-384 0 192 192 0 0 1 384 0m-64-256a32 32 0 0 0 0-64H384a32 32 0 0 0 0 64h256m-192 0h128a32 32 0 0 0 0-64H448a32 32 0 0 0 0 64m-64 480a32 32 0 0 0 0 64h256a32 32 0 0 0 0-64H384'),
|
||||
IconUpload: ic('M544 864a32 32 0 1 1-64 0V380.8L337.6 523.2a32 32 0 0 1-45.248-45.248l192-192a32 32 0 0 1 45.248 0l192 192a32 32 0 1 1-45.248 45.248L544 380.8V864zM192 256a32 32 0 1 1 0-64h640a32 32 0 1 1 0 64H192z'),
|
||||
IconCalendar: ic('M768 128a32 32 0 0 1 32 32v96h96a32 32 0 0 1 32 32v576a32 32 0 0 1-32 32H128a32 32 0 0 1-32-32V288a32 32 0 0 1 32-32h96v-96a32 32 0 1 1 64 0v96h512v-96a32 32 0 0 1 32-32zm32 224H224v480h576V352zM384 448a32 32 0 0 1 32 32v128a32 32 0 0 1-64 0V480a32 32 0 0 1 32-32m256 0a32 32 0 0 1 32 32v128a32 32 0 1 1-64 0V480a32 32 0 0 1 32-32'),
|
||||
IconDashboard: ic('M480 64a32 32 0 0 1 64 0v192a32 32 0 1 1-64 0V64zM239.648 182.816a32 32 0 0 1 45.248 0l135.808 135.872a32 32 0 0 1-45.248 45.248L239.648 228.064a32 32 0 0 1 0-45.248m544.704 0a32 32 0 0 1 0 45.248L648.544 363.968a32 32 0 0 1-45.248-45.248l135.808-135.872a32 32 0 0 1 45.248 0zM64 480a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H96a32 32 0 0 1-32-32m672 0a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H768a32 32 0 0 1-32-32m-544 288a32 32 0 0 1 32-32h576a32 32 0 1 1 0 64H224a32 32 0 0 1-32-32'),
|
||||
IconTopic: ic('M832 896H192a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h448l256 256v480a32 32 0 0 1-32 32zm-32-64V416H576V192H224v640h576zM480 256H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m0 192H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m-160 192h320a32 32 0 1 0 0-64H320a32 32 0 0 0 0 64'),
|
||||
IconGlobe: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m32 63.744V320h128a32 32 0 0 1 0 64h-22.912c13.12 47.232 19.904 97.28 20.288 148.48L704 532.48V512a32 32 0 1 1 64 0v88.064a448.256 448.256 0 0 1-128 165.12l-.064-101.12a32 32 0 1 0-64 0v138.88c-43.968 17.472-91.36 26.944-141.248 27.136L416 829.44V640a32 32 0 0 0-64 0v138.88a320 320 0 0 1-96-116.544V544h128a32 32 0 1 0 0-64H256v-64h128a32 32 0 1 0 0-64H273.792a320.256 320.256 0 0 1 49.152-128H448a32 32 0 0 0 0-64H377.024A320.128 320.128 0 0 1 544 127.744z'),
|
||||
};
|
||||
window.__iconComponents = icons;
|
||||
window.installIcons = function(app) {
|
||||
for (const [name, comp] of Object.entries(icons)) {
|
||||
app.component(name, comp);
|
||||
}
|
||||
};
|
||||
})();
|
||||
+51
-175
@@ -5,96 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
/* 深色渐变背景主题 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(102, 126, 234, 0.2);
|
||||
padding: 16px 24px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.navbar-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.navbar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.navbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #a0aec0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
display: flex;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: rgba(26, 26, 46, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid rgba(102, 126, 234, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* 内容区域 */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 页面切换 */
|
||||
/* 仪表盘统计卡片 */
|
||||
.page { display: none; animation: fadeIn 0.5s ease-out; }
|
||||
.page.active { display: block; }
|
||||
@keyframes fadeIn {
|
||||
@@ -102,7 +15,6 @@
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 统计卡片网格 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
@@ -110,9 +22,8 @@
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
background: white;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
@@ -120,27 +31,14 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(102, 126, 234, 0.1), transparent);
|
||||
transition: left 0.6s;
|
||||
}
|
||||
.stat-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
border-color: rgba(102, 126, 234, 0.4);
|
||||
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.2);
|
||||
border-color: #c6d8ff;
|
||||
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: #a0aec0;
|
||||
color: #909399;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -168,18 +66,16 @@
|
||||
gap: 20px;
|
||||
}
|
||||
.module-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
background: white;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
.module-card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
border-color: #c6d8ff;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
.module-header {
|
||||
display: flex;
|
||||
@@ -190,7 +86,7 @@
|
||||
.module-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e6ed;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -200,40 +96,28 @@
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
background: rgba(103, 194, 58, 0.12);
|
||||
color: #67c23a;
|
||||
border: 1px solid rgba(103, 194, 58, 0.3);
|
||||
border: 1px solid rgba(103, 194, 58, 0.25);
|
||||
}
|
||||
.module-status.running {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(103, 194, 58, 0); }
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
.module-content {
|
||||
font-size: 14px;
|
||||
color: #a0aec0;
|
||||
color: #606266;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.module-content div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
.module-content div:last-child { border-bottom: none; }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 16px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.stat-card { padding: 16px; }
|
||||
.stat-value { font-size: 24px; }
|
||||
.module-grid { grid-template-columns: 1fr; }
|
||||
@@ -253,34 +137,32 @@
|
||||
#page-overview .module-content { color: #303133 !important; }
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script>
|
||||
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component
|
||||
title="宇之然内容创作平台"
|
||||
:username="currentUser.username"
|
||||
:is-admin="isAdmin"
|
||||
@logout="handleLogout"
|
||||
></navbar-component>
|
||||
|
||||
<navigation-component
|
||||
current-page="dashboard"
|
||||
:is-admin="isAdmin"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content" v-if="isLoggedIn">
|
||||
<main class="content-area">
|
||||
<!-- 系统概览页面 -->
|
||||
<div id="page-overview" class="page" :class="{ active: currentPage === 'overview' }">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #e0e6ed;">
|
||||
📊 系统概览
|
||||
<el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 系统概览
|
||||
</h2>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loadingStats" style="text-align:center;padding:40px 0;color:#909399;">
|
||||
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!stats.total && !loadingStats" class="empty-state">
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
|
||||
<div class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div v-else class="stats-grid">
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-title">选题总数</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
@@ -301,7 +183,7 @@
|
||||
<div class="stat-title">已发布</div>
|
||||
<div class="stat-value">{{ stats.published }}</div>
|
||||
</div>
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-card primary" @click="goToTopics('today')">
|
||||
<div class="stat-title">今日新增</div>
|
||||
<div class="stat-value">{{ stats.today }}</div>
|
||||
</div>
|
||||
@@ -309,9 +191,12 @@
|
||||
|
||||
<!-- 模块状态 -->
|
||||
<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 24px; color: #e0e6ed;">
|
||||
🔧 模块状态
|
||||
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 模块状态
|
||||
<span style="font-size: 13px; font-weight: 400; color: #909399; margin-left: 12px;">
|
||||
定时任务: {{ schedulerStatus }}
|
||||
定时任务:
|
||||
<el-icon v-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
|
||||
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
|
||||
{{ schedulerStatus }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="module-grid">
|
||||
@@ -332,10 +217,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 移动端导航 -->
|
||||
<!-- 移动端导航由 navigation-component.js 注入 -->
|
||||
</div>
|
||||
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const App = {
|
||||
@@ -345,6 +230,7 @@
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
currentPage: 'overview',
|
||||
loadingStats: false,
|
||||
stats: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
@@ -354,7 +240,8 @@
|
||||
today: 0
|
||||
},
|
||||
modules: [],
|
||||
schedulerStatus: ''
|
||||
schedulerStatus: '',
|
||||
schedulerRunning: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
@@ -391,6 +278,7 @@
|
||||
window.location.href = '/login.html';
|
||||
},
|
||||
async fetchStats() {
|
||||
this.loadingStats = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
@@ -421,9 +309,9 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error);
|
||||
// 失败时设置为0,避免页面空白
|
||||
this.$message.error('获取统计信息失败: ' + error.message);
|
||||
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
|
||||
}
|
||||
} finally { this.loadingStats = false; }
|
||||
},
|
||||
async fetchModules() {
|
||||
try {
|
||||
@@ -435,13 +323,15 @@
|
||||
const data = await resp.json();
|
||||
this.modules = data.modules || [];
|
||||
const s = data.scheduler || {};
|
||||
this.schedulerStatus = s.running ? '🟢 运行中' : '🔴 未启动';
|
||||
this.schedulerRunning = s.running;
|
||||
this.schedulerStatus = (s.running ? '运行中' : '未启动');
|
||||
if (s.jobs && s.jobs.length) {
|
||||
this.schedulerStatus += ' · ' + s.jobs.length + ' 个任务';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取模块状态失败:', e);
|
||||
this.$message.error('获取模块状态失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
goToTopics(filter) {
|
||||
@@ -479,23 +369,9 @@
|
||||
|
||||
const app = Vue.createApp(App);
|
||||
app.use(ElementPlus);
|
||||
// 安装导航组件
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
// 安装导航组件
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
|
||||
// 调试代码:检查导航组件状态
|
||||
setTimeout(() => {
|
||||
const hasNav = !!document.querySelector('.navigation-wrapper');
|
||||
const hasSidebar = !!document.querySelector('.navigation-wrapper .sidebar');
|
||||
console.log('[调试] 导航wrapper:', hasNav);
|
||||
console.log('[调试] 侧边栏:', hasSidebar);
|
||||
if (!hasNav) {
|
||||
console.error('[调试] 导航组件未渲染!window.NavigationComponent=', !!window.NavigationComponent);
|
||||
console.error('[调试] app实例是否存在组件注册?', Vue && Vue.app && Vue.app._context.components['navigation-component']);
|
||||
}
|
||||
}, 100);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,41 +7,29 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.controls { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
||||
.log-container { max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||
.log-container pre { margin: 0; padding: 16px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; color: #303133; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.controls { flex-direction: column; align-items: stretch; }
|
||||
.controls .el-select, .controls .el-date-picker { width: 100% !important; }
|
||||
.log-container { max-height: calc(100vh - 250px); }
|
||||
.controls .el-button { width: 100%; }
|
||||
.log-container { max-height: calc(100vh - 280px); }
|
||||
.log-container pre { font-size: 11px; padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="系统日志" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="logs" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="系统日志" :username="currentUser.username" :is-admin="isAdmin" current-page="logs" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">📄 系统日志</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 系统日志</h2>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<el-select v-model="logType" placeholder="日志类型" style="width: 200px;">
|
||||
@@ -67,6 +55,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const LogsApp = {
|
||||
@@ -112,8 +101,8 @@
|
||||
};
|
||||
const app = Vue.createApp(LogsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+212
-46
@@ -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,16 +70,24 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -80,8 +99,18 @@
|
||||
</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,21 +118,20 @@
|
||||
<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>
|
||||
<div v-if="trendData.length === 0" style="text-align: center; color: #909399; padding: 40px;">暂无数据</div>
|
||||
</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;">🏆 平台对比</h3>
|
||||
<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>
|
||||
@@ -119,8 +147,23 @@
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
@@ -132,10 +175,23 @@
|
||||
<el-table-column prop="reason" label="推荐理由"></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="metrics-card-list">
|
||||
<div v-for="item in recommendations" :key="item.topic_id" class="metrics-card">
|
||||
<div class="metrics-card-title">{{ item.topic_id }}. {{ item.title }}</div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">领域</span><span class="metrics-card-value">{{ item.field }}</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">互动率</span><span class="metrics-card-value">{{ item.avg_engagement }}%</span></div>
|
||||
<div class="metrics-card-row"><span class="metrics-card-label">最高阅读</span><span class="metrics-card-value">{{ item.max_views }}</span></div>
|
||||
<div class="metrics-card-reason">{{ item.reason }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconInfo /></el-icon><div class="empty-text">暂无选题推荐</div></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const MetricsApp = {
|
||||
@@ -144,15 +200,117 @@ const MetricsApp = {
|
||||
currentUser: { username: '' },
|
||||
isAdmin: false,
|
||||
isLoggedIn: false,
|
||||
loadingDashboard: false,
|
||||
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
|
||||
trendDays: 30,
|
||||
trendData: [],
|
||||
maxViews: 1,
|
||||
platformData: [],
|
||||
recommendations: []
|
||||
recommendations: [],
|
||||
chartInstances: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
statusColors(status) {
|
||||
return { pending: '#909399', review: '#409eff', ready: '#e6a23c', published: '#67c23a' }[status] || '#909399';
|
||||
},
|
||||
destroyCharts() {
|
||||
Object.values(this.chartInstances).forEach(c => { if (c) c.destroy(); });
|
||||
this.chartInstances = {};
|
||||
},
|
||||
renderCharts() {
|
||||
this.$nextTick(() => {
|
||||
this.renderTrendChart();
|
||||
this.renderStatusChart();
|
||||
this.renderPlatformChart();
|
||||
});
|
||||
},
|
||||
renderTrendChart() {
|
||||
const canvas = this.$refs.trendChartCanvas;
|
||||
if (!canvas) return;
|
||||
if (this.chartInstances.trend) this.chartInstances.trend.destroy();
|
||||
const data = this.trendData || [];
|
||||
if (data.length === 0) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
this.chartInstances.trend = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.map(d => d.period),
|
||||
datasets: [{
|
||||
label: '阅读量',
|
||||
data: data.map(d => d.views || 0),
|
||||
borderColor: '#409eff',
|
||||
backgroundColor: 'rgba(64,158,255,0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}, {
|
||||
label: '点赞',
|
||||
data: data.map(d => d.likes || 0),
|
||||
borderColor: '#67c23a',
|
||||
backgroundColor: 'rgba(103,194,58,0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top' } },
|
||||
scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { grid: { display: false } } }
|
||||
}
|
||||
});
|
||||
},
|
||||
renderPlatformChart() {
|
||||
const canvas = this.$refs.platformChartCanvas;
|
||||
if (!canvas) return;
|
||||
if (this.chartInstances.platform) this.chartInstances.platform.destroy();
|
||||
const data = this.platformData || [];
|
||||
if (data.length === 0) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const names = data.map(d => this.getPlatformName(d.platform));
|
||||
this.chartInstances.platform = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: names,
|
||||
datasets: [
|
||||
{ label: '总阅读', data: data.map(d => d.total_views || 0), backgroundColor: 'rgba(64,158,255,0.7)', borderRadius: 4 },
|
||||
{ label: '平均阅读', data: data.map(d => Math.round(d.avg_views || 0)), backgroundColor: 'rgba(103,194,58,0.7)', borderRadius: 4 },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top' } },
|
||||
scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { grid: { display: false } } }
|
||||
}
|
||||
});
|
||||
},
|
||||
renderStatusChart() {
|
||||
const canvas = this.$refs.statusChartCanvas;
|
||||
if (!canvas) return;
|
||||
if (this.chartInstances.status) this.chartInstances.status.destroy();
|
||||
const byStatus = this.dashboard.topics_by_status || {};
|
||||
const labels = [], data = [], colors = [];
|
||||
for (const [status, count] of Object.entries(byStatus)) {
|
||||
if (count > 0) { labels.push(this.getStatusLabel(status)); data.push(count); colors.push(this.statusColors(status)); }
|
||||
}
|
||||
if (data.length === 0) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
this.chartInstances.status = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 2, borderColor: '#fff' }] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
cutout: '60%'
|
||||
}
|
||||
});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
checkAuth() {
|
||||
@@ -180,11 +338,14 @@ const MetricsApp = {
|
||||
return map[platform] || platform;
|
||||
},
|
||||
async fetchDashboard() {
|
||||
this.loadingDashboard = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/metrics/dashboard?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.dashboard = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载概览失败: ' + e.message); }
|
||||
finally { this.loadingDashboard = false; this.renderCharts(); }
|
||||
},
|
||||
async fetchTrend() {
|
||||
try {
|
||||
@@ -192,33 +353,38 @@ const MetricsApp = {
|
||||
const res = await fetch('/api/metrics/trend?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) {
|
||||
this.trendData = await res.json();
|
||||
this.maxViews = Math.max(...this.trendData.map(t => t.views || 0), 1);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
} else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载趋势失败: ' + e.message); }
|
||||
this.renderCharts();
|
||||
},
|
||||
async fetchPlatformData() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/metrics/by-platform', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.platformData = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载平台数据失败: ' + e.message); }
|
||||
},
|
||||
async fetchRecommendations() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/metrics/recommend-topics?limit=10', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.recommendations = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载推荐失败: ' + e.message); }
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkAuth();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyCharts();
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(MetricsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// 公共页眉组件 - Vue 3
|
||||
(function() {
|
||||
console.log('[Navbar] 脚本加载');
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('navbar-styles')) return;
|
||||
const styles = `
|
||||
.navbar-component { position: fixed; top: 0; left: 0; right: 0; height: 60px; background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3); z-index: 10000; }
|
||||
.navbar-component .navbar-title { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.3px; }
|
||||
.navbar-component .navbar-user { display: flex; align-items: center; gap: 12px; }
|
||||
.navbar-component .user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.navbar-component .avatar { width: 34px; height: 34px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 600; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.navbar-component .logout-btn { background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 6px 14px; border-radius: 8px; cursor: pointer; margin-left: 12px; font-size: 13px; transition: all 0.2s; }
|
||||
.navbar-component .logout-btn:hover { background: rgba(255,255,255,0.25); border-color: rgba(255,255,255,0.3); transform: translateY(-1px); }
|
||||
.navbar-component .admin-badge { margin-left: 8px; font-size: 11px; background: rgba(255,255,255,0.2); padding: 2px 10px; border-radius: 12px; font-weight: 500; letter-spacing: 0.3px; }
|
||||
@media (min-width: 769px) {
|
||||
.navbar-component { padding: 0 24px 0 180px; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.navbar-component { padding: 0 16px; }
|
||||
.navbar-component .navbar-title { font-size: 16px; }
|
||||
}
|
||||
`;
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'navbar-styles';
|
||||
styleEl.textContent = styles;
|
||||
document.head.appendChild(styleEl);
|
||||
console.log('[Navbar] 样式已注入');
|
||||
}
|
||||
|
||||
const installNavbar = (app) => {
|
||||
console.log('[Navbar] installNavbar called');
|
||||
injectStyles();
|
||||
|
||||
const NavbarComponent = {
|
||||
name: 'NavbarComponent',
|
||||
props: {
|
||||
title: { type: String, default: '宇之然内容创作平台' },
|
||||
username: { type: String, default: '' },
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
onLogout: { type: Function, default: null }
|
||||
},
|
||||
template: `
|
||||
<nav class="navbar-component">
|
||||
<div class="navbar-content" style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||
<h1 class="navbar-title">{{ title }}</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ username ? username.charAt(0).toUpperCase() : '?' }}</div>
|
||||
<span v-if="username">{{ username }}</span>
|
||||
</div>
|
||||
<span v-if="isAdmin" class="admin-badge">管理员</span>
|
||||
<button class="logout-btn" @click="handleLogout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
`,
|
||||
methods: {
|
||||
handleLogout() {
|
||||
if (this.onLogout) {
|
||||
this.onLogout();
|
||||
} else {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('userRole');
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.component('navbar-component', NavbarComponent);
|
||||
console.log('[Navbar] 组件已注册');
|
||||
return app;
|
||||
};
|
||||
|
||||
window.installNavbar = installNavbar;
|
||||
console.log('[Navbar] 脚本已加载');
|
||||
})();
|
||||
@@ -1,383 +0,0 @@
|
||||
// 纯DOM导航 - 通过 installNavigation 直接插入
|
||||
(function() {
|
||||
console.log('[Nav] 脚本加载');
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('navigation-styles')) return;
|
||||
const styles = `
|
||||
.nav-wrapper, .navigation-wrapper { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; z-index: 9999; pointer-events: none; }
|
||||
.nav-wrapper .sidebar, .navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px 8px; box-shadow: 2px 0 12px rgba(0,0,0,0.06); overflow-y: auto; z-index: 10000; border-right: 1px solid #ebeef5; pointer-events: auto; }
|
||||
.nav-wrapper .sidebar-header, .navigation-wrapper .sidebar-header { padding: 12px 12px 16px; border-bottom: 1px solid #f0f2f5; margin-bottom: 12px; }
|
||||
.nav-wrapper .sidebar-header h3, .navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 15px; font-weight: 700; background: linear-gradient(135deg, #2563eb, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.3px; }
|
||||
.nav-wrapper .sidebar-btn, .navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 10px; margin-bottom: 4px; cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); color: #606266; font-size: 13px; display: flex; align-items: center; gap: 8px; position: relative; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nav-wrapper .sidebar-btn:hover, .navigation-wrapper .sidebar-btn:hover { background: #f0f4ff; color: #2563eb; transform: translateX(2px); }
|
||||
.nav-wrapper .sidebar-btn.active, .navigation-wrapper .sidebar-btn.active { background: linear-gradient(135deg, #eef2ff, #e0e7ff); color: #2563eb; font-weight: 600 !important; box-shadow: inset 3px 0 0 #2563eb; }
|
||||
.nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(135deg, #2563eb, #1d4ed8); box-shadow: 0 -4px 20px rgba(37, 99, 235, 0.25); padding: 4px 0; z-index: 99999; justify-content: space-around; pointer-events: auto; }
|
||||
.nav-wrapper .mobile-nav-btn, .navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 4px 2px; text-align: center; font-size: 10px; color: rgba(255,255,255,0.8) !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0; transition: all 0.2s; border-radius: 8px; margin: 0 1px; min-width: 0; }
|
||||
.nav-wrapper .mobile-nav-btn .nav-icon, .navigation-wrapper .mobile-nav-btn .nav-icon { font-size: 16px; line-height: 1.2; }
|
||||
.nav-wrapper .mobile-nav-btn .nav-text, .navigation-wrapper .mobile-nav-btn .nav-text { font-size: 9px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.nav-wrapper .mobile-nav-btn:hover, .navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.15); color: white !important; }
|
||||
.nav-wrapper .mobile-nav-btn.active, .navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.2); color: white !important; font-weight: 600 !important; }
|
||||
.mobile-more-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); z-index: 100000; animation: fadeIn 0.2s ease-out; }
|
||||
.mobile-more-sheet { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-radius: 16px 16px 0 0; box-shadow: 0 -8px 32px rgba(0,0,0,0.15); z-index: 100001; padding: 20px 16px calc(env(safe-area-inset-bottom) + 60px); animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); max-height: 70vh; overflow-y: auto; pointer-events: auto; }
|
||||
.mobile-more-sheet .sheet-handle { width: 36px; height: 4px; background: #e0e0e0; border-radius: 2px; margin: 0 auto 16px; }
|
||||
.mobile-more-sheet .sheet-title { font-size: 16px; font-weight: 700; color: #303133; margin-bottom: 16px; text-align: center; }
|
||||
.mobile-more-sheet .sheet-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.mobile-more-sheet .sheet-item { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 4px; border: none; background: transparent; border-radius: 12px; cursor: pointer; transition: all 0.2s; color: #606266; font-size: 12px; white-space: nowrap; }
|
||||
.mobile-more-sheet .sheet-item:hover { background: #f0f4ff; color: #2563eb; }
|
||||
.mobile-more-sheet .sheet-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }
|
||||
.mobile-more-sheet .sheet-item .item-icon { font-size: 24px; line-height: 1; }
|
||||
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@media (max-width: 768px) { .nav-wrapper .sidebar, .navigation-wrapper .sidebar { display: none !important; } .nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: flex !important; } body > #app > .main-content { padding-bottom: 56px !important; } }
|
||||
body > #app > .main-content { margin-left: 180px !important; padding-top: 60px !important; }
|
||||
@media (max-width: 768px) { body > #app > .main-content { margin-left: 0 !important; padding-bottom: 60px !important; } }
|
||||
`;
|
||||
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'navigation-styles', textContent: styles }));
|
||||
console.log('[Nav] 样式已注入');
|
||||
}
|
||||
|
||||
function createNavigation(currentPage, isAdmin, onNavigate) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'navigation-wrapper';
|
||||
|
||||
// 侧边栏
|
||||
const sidebar = document.createElement('aside');
|
||||
sidebar.className = 'sidebar';
|
||||
sidebar.innerHTML = `
|
||||
<div class="sidebar-header"><h3>宇之然平台</h3></div>
|
||||
<nav class="sidebar-nav">
|
||||
<button class="sidebar-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊 系统概览</button>
|
||||
<button class="sidebar-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋 选题管理</button>
|
||||
<button class="sidebar-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊 数据分析</button>
|
||||
<button class="sidebar-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html">📅 内容日历</button>
|
||||
<button class="sidebar-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️ 素材库</button>
|
||||
<button class="sidebar-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀 创作任务</button>
|
||||
<button class="sidebar-btn ${currentPage==='platforms'?'active':''}" data-page="platforms.html">🌐 平台配置</button>
|
||||
<button class="sidebar-btn ${currentPage==='logs'?'active':''}" data-page="logs.html">📄 系统日志</button>
|
||||
${isAdmin ? `<button class="sidebar-btn ${currentPage==='users'?'active':''}" data-page="users.html">👥 用户管理</button>` : ''}
|
||||
${isAdmin ? `<button class="sidebar-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️ 系统管理</button>` : ''}
|
||||
</nav>
|
||||
`;
|
||||
|
||||
// 移动端底部导航
|
||||
const mobileNav = document.createElement('nav');
|
||||
mobileNav.className = 'mobile-nav';
|
||||
mobileNav.innerHTML = `
|
||||
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/"><span class="nav-icon">📊</span><span class="nav-text">首页</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='topics'?'active':''}" data-page="topics.html"><span class="nav-icon">📋</span><span class="nav-text">选题</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html"><span class="nav-icon">🚀</span><span class="nav-text">任务</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html"><span class="nav-icon">📅</span><span class="nav-text">日历</span></button>
|
||||
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html"><span class="nav-icon">🖼️</span><span class="nav-text">素材</span></button>
|
||||
<button class="mobile-nav-btn" id="mobile-more-btn"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
|
||||
`;
|
||||
|
||||
// "更多"弹出菜单
|
||||
const sheetItems = [
|
||||
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
|
||||
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
|
||||
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
|
||||
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true },
|
||||
];
|
||||
|
||||
const sheetItemsHtml = sheetItems.map(item =>
|
||||
`<button class="sheet-item ${currentPage===item.key?'active':''}" data-page="${item.page}"${item.admin ? ' data-admin="1"' : ''} style="${item.admin && !isAdmin ? 'display:none' : ''}">
|
||||
<span class="item-icon">${item.icon}</span><span>${item.label}</span>
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'mobile-more-backdrop';
|
||||
backdrop.id = 'mobile-more-backdrop';
|
||||
backdrop.style.display = 'none';
|
||||
|
||||
const sheet = document.createElement('div');
|
||||
sheet.className = 'mobile-more-sheet';
|
||||
sheet.id = 'mobile-more-sheet';
|
||||
sheet.style.display = 'none';
|
||||
sheet.innerHTML = `<div class="sheet-handle"></div><div class="sheet-title">所有菜单</div><div class="sheet-grid">${sheetItemsHtml}</div>`;
|
||||
|
||||
const showMore = () => {
|
||||
backdrop.style.display = ''; sheet.style.display = '';
|
||||
};
|
||||
const hideMore = () => {
|
||||
backdrop.style.display = 'none'; sheet.style.display = 'none';
|
||||
};
|
||||
|
||||
// 绑定导航跳转
|
||||
const handleNavClick = (btn) => {
|
||||
const page = btn.getAttribute('data-page');
|
||||
if (!page) return;
|
||||
console.log('[Nav] 点击导航:', page);
|
||||
hideMore();
|
||||
if (onNavigate) { onNavigate(page); return; }
|
||||
const target = page === '/' ? '/' : (page.startsWith('/') ? page : '/' + page);
|
||||
window.location.href = target;
|
||||
};
|
||||
|
||||
wrapper.appendChild(sidebar);
|
||||
wrapper.appendChild(mobileNav);
|
||||
wrapper.appendChild(backdrop);
|
||||
wrapper.appendChild(sheet);
|
||||
|
||||
// 事件绑定
|
||||
wrapper.querySelectorAll('.sidebar-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
|
||||
});
|
||||
wrapper.querySelectorAll('.mobile-nav-btn').forEach(btn => {
|
||||
if (btn.id === 'mobile-more-btn') {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); showMore(); });
|
||||
} else {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
|
||||
}
|
||||
});
|
||||
wrapper.querySelectorAll('.sheet-item').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
|
||||
});
|
||||
backdrop.addEventListener('click', hideMore);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// 新的 installNavigation 接口:接收 app 对象(为了兼容),但实际不注册组件
|
||||
window.installNavigation = function(app, options = {}) {
|
||||
console.log('[Nav] installNavigation called (DOM mode)');
|
||||
injectStyles();
|
||||
|
||||
// 延迟执行,确保DOM就绪
|
||||
const init = () => {
|
||||
const container = document.getElementById('app');
|
||||
if (!container) {
|
||||
console.warn('[Nav] #app 未找到,等待');
|
||||
setTimeout(init, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前页面名称(从URL或body类名推断)
|
||||
let currentPage = 'dashboard';
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('topics')) currentPage = 'topics';
|
||||
else if (path.includes('calendar')) currentPage = 'calendar';
|
||||
else if (path.includes('metrics')) currentPage = 'metrics';
|
||||
else if (path.includes('assets')) currentPage = 'assets';
|
||||
else if (path.includes('tasks')) currentPage = 'tasks';
|
||||
else if (path.includes('platforms')) currentPage = 'platforms';
|
||||
else if (path.includes('logs')) currentPage = 'logs';
|
||||
else if (path.includes('users')) currentPage = 'users';
|
||||
else if (path.includes('admin')) currentPage = 'admin';
|
||||
|
||||
// 尝试从 Vue 实例获取 isAdmin
|
||||
let isAdmin = false;
|
||||
let redirectToPage = null;
|
||||
|
||||
// 首先检查 localStorage,如果用户已登录且是管理员
|
||||
const userRole = localStorage.getItem('userRole');
|
||||
if (userRole === 'admin') {
|
||||
isAdmin = true;
|
||||
console.log('[Nav] 从 localStorage 获取到 admin 角色');
|
||||
}
|
||||
|
||||
// 尝试从 Vue 实例提取数据
|
||||
const getVueData = () => {
|
||||
if (app && app._instance && app._instance.proxy) {
|
||||
return app._instance.proxy;
|
||||
} else if (window.Vue && window.Vue.app && window.Vue.app._instance && window.Vue.app._instance.proxy) {
|
||||
return window.Vue.app._instance.proxy;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateNavFromVue = () => {
|
||||
const proxy = getVueData();
|
||||
if (!proxy) return false;
|
||||
|
||||
const newIsAdmin = proxy.isAdmin === true;
|
||||
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
|
||||
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
|
||||
|
||||
// 如果侧边栏已存在,更新管理菜单显示状态
|
||||
const sidebar = document.querySelector('.sidebar-nav');
|
||||
if (sidebar) {
|
||||
const adminBtns = sidebar.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
|
||||
adminBtns.forEach(btn => {
|
||||
btn.style.display = isAdmin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 延迟获取 isAdmin,确保 Vue mounted 已执行
|
||||
setTimeout(() => {
|
||||
updateNavFromVue();
|
||||
console.log('[Nav] 延迟获取后 isAdmin:', isAdmin);
|
||||
|
||||
// 如果侧边栏已存在,再次更新管理菜单显示状态
|
||||
const sidebarEl = container.querySelector('.sidebar-nav');
|
||||
if (sidebarEl) {
|
||||
const adminBtns = sidebarEl.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
|
||||
adminBtns.forEach(btn => {
|
||||
btn.style.display = isAdmin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 持续监听 Vue 数据变化
|
||||
const stopWatch = setInterval(() => {
|
||||
const updated = updateNavFromVue();
|
||||
if (updated && isAdmin) {
|
||||
clearInterval(stopWatch);
|
||||
console.log('[Nav] 已获取到 isAdmin:', isAdmin);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// 5秒后停止监听
|
||||
setTimeout(() => clearInterval(stopWatch), 5000);
|
||||
|
||||
console.log('[Nav] currentPage:', currentPage, '初始 isAdmin:', isAdmin);
|
||||
|
||||
// 默认跳转函数
|
||||
const defaultNavigate = (page) => {
|
||||
const target = page === '/' ? '/index.html' : (page.startsWith('/') ? page : '/' + page);
|
||||
window.location.href = target;
|
||||
};
|
||||
|
||||
// 移除旧的导航容器(如果有)
|
||||
const oldNav = container.querySelector('.navigation-wrapper');
|
||||
if (oldNav) oldNav.remove();
|
||||
|
||||
const nav = createNavigation(currentPage, isAdmin, redirectToPage || defaultNavigate);
|
||||
container.insertBefore(nav, container.firstChild);
|
||||
console.log('[Nav] 导航已插入');
|
||||
};
|
||||
|
||||
// 延迟执行,确保 Vue 实例挂载完成
|
||||
setTimeout(init, 100);
|
||||
return app;
|
||||
};
|
||||
|
||||
console.log('[Nav] 脚本已加载(纯DOM版)');
|
||||
|
||||
// 注册 Vue 组件 (备用方案)
|
||||
const createNavComponent = () => ({
|
||||
props: {
|
||||
currentPage: { type: String, default: 'dashboard' },
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
onNavigate: { type: Function, default: null }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
menuItems: [
|
||||
{ key: 'dashboard', label: '系统概览', icon: '📊', page: '/' },
|
||||
{ key: 'topics', label: '选题管理', icon: '📋', page: 'topics.html' },
|
||||
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
|
||||
{ key: 'calendar', label: '内容日历', icon: '📅', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材库', icon: '🖼️', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '创作任务', icon: '🚀', page: 'tasks.html' },
|
||||
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' }
|
||||
],
|
||||
adminItems: [
|
||||
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html' },
|
||||
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html' }
|
||||
],
|
||||
mobileItems: [
|
||||
{ key: 'dashboard', label: '首页', icon: '📊', page: '/' },
|
||||
{ key: 'topics', label: '选题', icon: '📋', page: 'topics.html' },
|
||||
{ key: 'tasks', label: '任务', icon: '🚀', page: 'tasks.html' },
|
||||
{ key: 'calendar', label: '日历', icon: '📅', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', icon: '🖼️', page: 'assets.html' }
|
||||
],
|
||||
sheetItems: [
|
||||
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
|
||||
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
|
||||
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
|
||||
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true }
|
||||
],
|
||||
showMoreSheet: false
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="nav-wrapper">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><h3>宇之然平台</h3></div>
|
||||
<nav class="sidebar-nav">
|
||||
<button v-for="item in menuItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
{{ item.icon }} {{ item.label }}
|
||||
</button>
|
||||
<template v-if="isAdmin">
|
||||
<button v-for="item in adminItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
{{ item.icon }} {{ item.label }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
<nav class="mobile-nav">
|
||||
<button v-for="item in mobileItems" :key="item.key" :class="['mobile-nav-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" @click="showMoreSheet = !showMoreSheet"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
|
||||
</nav>
|
||||
<div v-if="showMoreSheet" class="mobile-more-backdrop" @click="showMoreSheet = false"></div>
|
||||
<div v-if="showMoreSheet" class="mobile-more-sheet">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-title">所有菜单</div>
|
||||
<div class="sheet-grid">
|
||||
<button v-for="item in sheetItems" :key="item.key" v-show="!item.admin || isAdmin" :class="['sheet-item', { active: currentPage === item.key }]" @click="navigate(item.page)">
|
||||
<span class="item-icon">{{ item.icon }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
navigate(page) {
|
||||
console.log('[Nav Vue] 点击导航:', page);
|
||||
if (this.onNavigate) {
|
||||
this.onNavigate(page);
|
||||
} else {
|
||||
// 修复路径处理
|
||||
let target;
|
||||
if (page === '/' || page === '') {
|
||||
target = '/';
|
||||
} else if (page.startsWith('/')) {
|
||||
target = page;
|
||||
} else {
|
||||
target = '/' + page;
|
||||
}
|
||||
window.location.href = target;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
injectStyles();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册全局组件
|
||||
window.NavigationComponent = createNavComponent();
|
||||
|
||||
// 自动注入 DOM 导航 (优先使用)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => {
|
||||
if (window.Vue && window.Vue.app) {
|
||||
window.Vue.app._instance.proxy.$nextTick(() => {
|
||||
const app = window.Vue.app;
|
||||
if (app && app._instance && app._instance.proxy) {
|
||||
const proxy = app._instance.proxy;
|
||||
const nav = createNavigation(proxy.currentPage || 'dashboard', proxy.isAdmin || false, proxy.redirectToPage || null);
|
||||
const container = document.getElementById('app');
|
||||
if (container && !container.querySelector('.navigation-wrapper')) {
|
||||
container.insertBefore(nav, container.firstChild);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -7,19 +7,6 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.platform-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 20px; margin-bottom: 16px; transition: all 0.3s ease; word-break: break-word; background: #fff; }
|
||||
.platform-card:hover { border-color: #409eff; box-shadow: 0 4px 20px rgba(64,158,255,0.12); transform: translateY(-1px); }
|
||||
.platform-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; }
|
||||
@@ -39,51 +26,44 @@
|
||||
.rule-item strong { color: #303133; }
|
||||
.rule-item:last-child { margin-bottom: 0; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #909399; }
|
||||
.empty-state-icon { font-size: 56px; margin-bottom: 16px; }
|
||||
.empty-state-text { font-size: 16px; }
|
||||
|
||||
.loading-state { text-align: center; padding: 60px 20px; color: #909399; font-size: 16px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.toolbar { width: 100%; }
|
||||
.toolbar .el-button { flex: 1; justify-content: center; }
|
||||
.platform-card { padding: 16px; }
|
||||
.platform-header { flex-direction: column; }
|
||||
.platform-actions { align-self: flex-end; }
|
||||
.platform-actions { align-self: flex-end; width: 100%; display: flex; justify-content: flex-end; }
|
||||
.platform-meta { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="平台配置" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="platforms" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="平台配置" :username="currentUser.username" :is-admin="isAdmin" current-page="platforms" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">🌐 平台配置</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconGlobe /></el-icon> 平台配置</h2>
|
||||
<div class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button :type="showActiveOnly ? 'primary' : ''" @click="showActiveOnly = true; loadPlatforms()">启用中</el-button>
|
||||
<el-button :type="!showActiveOnly ? 'primary' : ''" @click="showActiveOnly = false; loadPlatforms()">全部</el-button>
|
||||
</el-button-group>
|
||||
<el-button @click="loadPlatforms">🔄 刷新</el-button>
|
||||
<el-button @click="loadPlatforms"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card page-fade">
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="platforms.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon">🌐</div>
|
||||
<div class="empty-state-text">暂无平台配置</div>
|
||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
|
||||
<div class="empty-text">暂无平台配置</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="p in platforms" :key="p.platform" class="platform-card">
|
||||
<div class="platform-header">
|
||||
<div class="platform-name">
|
||||
<span>{{ getPlatformIcon(p.platform) }}</span>
|
||||
<el-icon><component :is="getPlatformIcon(p.platform)" /></el-icon>
|
||||
{{ getPlatformName(p.platform) }}
|
||||
</div>
|
||||
<div class="platform-actions">
|
||||
@@ -108,13 +88,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0" class="rules-section">
|
||||
<div class="rules-title">📋 格式规则</div>
|
||||
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 格式规则</div>
|
||||
<div v-for="(rule, key) in p.format_rules" :key="key" class="rule-item">
|
||||
<strong>{{ key }}:</strong> {{ typeof rule === 'object' ? JSON.stringify(rule) : rule }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="p.compliance_rules && p.compliance_rules.length > 0" class="rules-section">
|
||||
<div class="rules-title">⚖️ 合规规则</div>
|
||||
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconWarning /></el-icon> 合规规则</div>
|
||||
<div v-for="(rule, idx) in p.compliance_rules" :key="idx" class="rule-item">{{ rule }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,6 +132,7 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const PlatformsApp = {
|
||||
@@ -186,8 +167,8 @@ const PlatformsApp = {
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
getPlatformIcon(platform) {
|
||||
const map = { 'zhihu': '💬', 'wechat': '💌', 'xiaohongshu': '📕', 'weibo': '🌐' };
|
||||
return map[platform] || '🌐';
|
||||
const map = { 'zhihu': 'IconTopic', 'wechat': 'IconDocument', 'xiaohongshu': 'IconPicture', 'weibo': 'IconGlobe' };
|
||||
return map[platform] || 'IconGlobe';
|
||||
},
|
||||
getPlatformName(platform) {
|
||||
const map = { 'zhihu': '知乎', 'wechat': '微信公众号', 'xiaohongshu': '小红书', 'weibo': '微博' };
|
||||
@@ -199,7 +180,8 @@ const PlatformsApp = {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('/api/platform-config?active_only=' + this.showActiveOnly, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (res.ok) this.platforms = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
else { const d = await res.json(); throw new Error(d.detail || '加载失败'); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载平台配置失败: ' + e.message); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
editPlatform(p) {
|
||||
@@ -234,8 +216,8 @@ const PlatformsApp = {
|
||||
};
|
||||
const app = Vue.createApp(PlatformsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -7,17 +7,7 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.toolbar { gap: 12px; }
|
||||
|
||||
.task-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; margin-bottom: 12px; transition: all 0.3s; background: #fff; }
|
||||
.task-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.12); transform: translateY(-1px); }
|
||||
@@ -56,38 +46,35 @@
|
||||
.detail-value { flex: 1; font-size: 13px; color: #303133; word-break: break-all; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.task-table { display: none; }
|
||||
.task-card-list-mobile { display: block; }
|
||||
.schedule-row { padding: 10px 12px; gap: 10px; flex-wrap: wrap; }
|
||||
.schedule-next { width: 100%; margin-left: 46px; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="创作任务" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="tasks" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="创作任务" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">🚀 定时任务</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 定时任务</h2>
|
||||
<div class="toolbar">
|
||||
<el-button @click="loadSchedulerStatus">🔄 刷新</el-button>
|
||||
<el-button @click="loadSchedulerStatus"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="schedulerLoading" style="text-align: center; padding: 20px; color: #909399;">加载中...</div>
|
||||
<div v-else>
|
||||
<div v-if="schedulerJobs.length === 0" style="text-align: center; padding: 30px 20px; color: #909399; font-size: 14px;">暂无定时任务</div>
|
||||
<div v-for="job in schedulerJobs" :key="job.id" class="schedule-row">
|
||||
<div class="schedule-icon">{{ job.icon }}</div>
|
||||
<div class="schedule-icon"><el-icon><component :is="job.icon" /></el-icon></div>
|
||||
<div class="schedule-info">
|
||||
<div class="schedule-name">{{ job.name }}</div>
|
||||
<div class="schedule-time">⏰ 每日 {{ job.time }}</div>
|
||||
<div class="schedule-time"><el-icon style="vertical-align:-2px;"><IconClock /></el-icon> 每日 {{ job.time }}</div>
|
||||
</div>
|
||||
<div class="schedule-next">
|
||||
<span class="schedule-badge" :class="job.active ? 'active' : 'inactive'"></span>
|
||||
@@ -100,7 +87,7 @@
|
||||
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">🚀 创作任务</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 创作任务</h2>
|
||||
<div class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
|
||||
@@ -109,12 +96,12 @@
|
||||
<el-button :type="filterStatus === 'completed' ? 'primary' : ''" @click="filterStatus = 'completed'; loadTasks()">已完成</el-button>
|
||||
<el-button :type="filterStatus === 'failed' ? 'primary' : ''" @click="filterStatus = 'failed'; loadTasks()">失败</el-button>
|
||||
</el-button-group>
|
||||
<el-button @click="loadTasks">🔄 刷新</el-button>
|
||||
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" style="text-align: center; padding: 40px; color: #909399;">加载中...</div>
|
||||
<div v-else-if="tasks.length === 0" style="text-align: center; padding: 60px 20px; color: #909399;">
|
||||
<div style="font-size: 56px; margin-bottom: 16px;">📋</div>
|
||||
<el-icon style="font-size:56px;margin-bottom:16px;color:#c0c4cc;"><IconTopic /></el-icon>
|
||||
<div style="font-size: 16px;">暂无任务</div>
|
||||
<div style="margin-top: 12px; font-size: 14px;">前往 <a href="/topics.html" style="color: #409eff;">选题管理</a> 创建新任务</div>
|
||||
</div>
|
||||
@@ -173,14 +160,15 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const TasksApp = {
|
||||
data() {
|
||||
const SCHEDULER_JOBS = {
|
||||
'scheduled_sync': { icon: '🔄', name: '数据同步', defaultTime: '02:30' },
|
||||
'scheduled_generate': { icon: '🤖', name: '内容创作', defaultTime: '03:30' },
|
||||
'scheduled_optimize': { icon: '🔍', name: '合规审查', defaultTime: '04:30' },
|
||||
'scheduled_sync': { icon: 'IconRefresh', name: '数据同步', defaultTime: '02:30' },
|
||||
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
|
||||
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04:30' },
|
||||
};
|
||||
return {
|
||||
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
|
||||
@@ -215,7 +203,7 @@ const TasksApp = {
|
||||
const data = await this.api('/api/system/scheduler/status');
|
||||
if (!data) return;
|
||||
this.schedulerJobs = (data.jobs || []).map(job => {
|
||||
const info = this.SCHEDULER_JOBS[job.id] || { icon: '⏰', name: job.id, defaultTime: '' };
|
||||
const info = this.SCHEDULER_JOBS[job.id] || { icon: 'IconClock', name: job.id, defaultTime: '' };
|
||||
// Parse cron trigger for time display
|
||||
let time = info.defaultTime;
|
||||
const m = job.trigger && job.trigger.match(/hour='?(\d+)'?,\s*minute='?(\d+)'?/);
|
||||
@@ -227,7 +215,7 @@ const TasksApp = {
|
||||
}
|
||||
return { id: job.id, icon: info.icon, name: info.name, time, active: data.running, next_run };
|
||||
});
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载调度状态失败: ' + e.message); }
|
||||
finally { this.schedulerLoading = false; }
|
||||
},
|
||||
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
|
||||
@@ -242,7 +230,7 @@ const TasksApp = {
|
||||
let url = '/api/tasks?limit=50';
|
||||
if (this.filterStatus) url += '&status=' + this.filterStatus;
|
||||
this.tasks = await this.api(url) || [];
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { console.error(e); this.$message.error('加载任务列表失败: ' + e.message); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
|
||||
@@ -267,8 +255,8 @@ const TasksApp = {
|
||||
};
|
||||
const app = Vue.createApp(TasksApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -36,12 +36,33 @@
|
||||
.status-badge-modern { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
|
||||
.status-badge-modern.running { background: rgba(103, 194, 58, 0.12); color: #67c23a; border: 1px solid rgba(103, 194, 58, 0.25); animation: pulse-glow 2s infinite; }
|
||||
|
||||
/* Modern el-table refinements */
|
||||
.el-table { border-radius: 12px; overflow: hidden; }
|
||||
/* el-table refinements */
|
||||
.el-table { border-radius: 12px; overflow: hidden; width: 100%; }
|
||||
.el-table .el-table__cell { word-break: break-word; }
|
||||
|
||||
/* Smooth page transitions */
|
||||
.page-enter-active { animation: fadeIn 0.4s ease-out; }
|
||||
|
||||
/* ========== Reset & Base ========== */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
|
||||
|
||||
/* ========== 统一布局 ========== */
|
||||
/* 固定顶部导航占位,所有页面自动继承 */
|
||||
.main-content { padding-top: 60px !important; display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
|
||||
/* ========== 共享卡片 ========== */
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
/* ========== 页面标题 ========== */
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ========== 筛选/工具栏 ========== */
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* ========== H5响应式 ========== */
|
||||
@media (max-width: 768px) {
|
||||
/* 对话框自适应宽度 */
|
||||
@@ -54,11 +75,104 @@
|
||||
/* 手机端表格卡片 */
|
||||
.mobile-card { position: relative; }
|
||||
.mobile-card:active { transform: scale(0.98); }
|
||||
|
||||
/* 共享 content-area + card 响应式 */
|
||||
.content-area { padding: 16px !important; padding-bottom: 80px; }
|
||||
.card { padding: 16px !important; }
|
||||
|
||||
/* 页面标题在手机上紧凑 */
|
||||
.page-header { flex-direction: column; align-items: flex-start !important; gap: 8px !important; }
|
||||
}
|
||||
|
||||
/* 触摸优化:增大按钮点击区域 */
|
||||
/* ========== 预览对话框(选题/文章共用) ========== */
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
.preview-dialog-custom.el-dialog { max-height: calc(100vh - 90px); overflow: hidden; display: flex; flex-direction: column; margin-top: 0 !important; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header { padding: 8px 12px; margin: 0; flex-shrink: 0; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body { padding: 12px; overflow: hidden; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer { flex-shrink: 0; padding: 8px 12px; }
|
||||
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
|
||||
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
|
||||
@media (max-width: 768px) { .preview-iframe { max-height: calc(100vh - 250px) !important; } }
|
||||
@media (min-width: 769px) { .preview-iframe { max-height: calc(100vh - 100px) !important; } .preview-dialog-custom { position: relative; left: 90px; } }
|
||||
|
||||
/* ========== 共享卡片无数据状态 ========== */
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #909399; }
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.4; }
|
||||
.empty-state .empty-text { font-size: 14px; margin-bottom: 16px; }
|
||||
.loading-state { text-align: center; padding: 60px 20px; color: #909399; font-size: 16px; }
|
||||
|
||||
/* ========== 通用移动端卡片列表(替代表格) ========== */
|
||||
.card-table { display: block; }
|
||||
.card-list-mobile { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
.card-table { display: none; }
|
||||
.card-list-mobile { display: block; }
|
||||
}
|
||||
.card-list-mobile .card-item {
|
||||
background: #fafbfc; border-radius: 10px; padding: 14px; margin-bottom: 10px;
|
||||
border: 1px solid #ebeef5; transition: all 0.2s ease;
|
||||
}
|
||||
.card-list-mobile .card-item:active { transform: scale(0.99); }
|
||||
.card-list-mobile .card-row {
|
||||
display: flex; justify-content: space-between; padding: 6px 0;
|
||||
font-size: 13px; border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
.card-list-mobile .card-row:last-child { border-bottom: none; }
|
||||
.card-list-mobile .card-label { color: #909399; flex-shrink: 0; margin-right: 8px; }
|
||||
.card-list-mobile .card-value { color: #303133; text-align: right; word-break: break-word; }
|
||||
.card-list-mobile .card-actions {
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
padding-top: 10px; margin-top: 6px; border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
/* ========== 统计摘要条 ========== */
|
||||
.stat-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.stat-summary .stat-item {
|
||||
background: #f0f5ff; border-radius: 8px; padding: 12px 20px;
|
||||
display: flex; flex-direction: column; align-items: center; min-width: 100px;
|
||||
}
|
||||
.stat-summary .stat-item .num { font-size: 24px; font-weight: 600; color: #409eff; }
|
||||
.stat-summary .stat-item .label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
@media (max-width: 768px) {
|
||||
.stat-summary { gap: 8px; }
|
||||
.stat-summary .stat-item { min-width: 70px; padding: 8px 12px; }
|
||||
.stat-summary .stat-item .num { font-size: 18px; }
|
||||
}
|
||||
|
||||
/* ========== 加载中状态 ========== */
|
||||
.card-loading { display: flex; justify-content: center; align-items: center; min-height: 200px; color: #909399; font-size: 14px; }
|
||||
|
||||
/* ========== 状态圆点 ========== */
|
||||
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
|
||||
.status-dot.pending { background: #E6A23C; }
|
||||
.status-dot.review { background: #F56C6C; }
|
||||
.status-dot.ready { background: #67C23A; }
|
||||
.status-dot.published { background: #409EFF; }
|
||||
.status-dot.draft { background: #909399; }
|
||||
|
||||
/* ========== 搜索栏 ========== */
|
||||
.search-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
|
||||
@media (max-width: 768px) {
|
||||
.search-bar { flex-direction: column; align-items: stretch; }
|
||||
.search-bar .el-input, .search-bar .el-select { width: 100% !important; }
|
||||
}
|
||||
|
||||
/* ========== 触摸优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.el-button { min-height: 36px; }
|
||||
.el-button--small { min-height: 36px; padding: 8px 14px !important; }
|
||||
.el-button--default { padding: 10px 16px !important; }
|
||||
.el-input__inner { min-height: 38px; }
|
||||
.el-table__cell .el-button { padding: 8px 12px !important; }
|
||||
|
||||
.btn-group-mobile { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.btn-group-mobile .el-button { flex: 1; min-width: 0; justify-content: center; }
|
||||
|
||||
.action-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.action-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
|
||||
.filter-bar-mobile { flex-direction: column; align-items: stretch !important; }
|
||||
.filter-bar-mobile .el-select,
|
||||
.filter-bar-mobile .el-input { width: 100% !important; }
|
||||
.page-header-mobile { flex-direction: column; align-items: flex-start !important; gap: 8px !important; }
|
||||
}
|
||||
|
||||
+123
-74
@@ -7,16 +7,6 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.selected-count { color: #909399; font-size: 14px; margin-left: auto; }
|
||||
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 4px; }
|
||||
@@ -28,17 +18,7 @@
|
||||
|
||||
.topic-card-list { display: none; }
|
||||
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
.preview-dialog-custom.el-dialog { max-height: calc(100vh - 90px); overflow: hidden; display: flex; flex-direction: column; margin-top: 0 !important; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header { padding: 8px 12px; margin: 0; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body { padding: 12px; overflow: hidden; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer { flex-shrink: 0; padding: 8px 12px; }
|
||||
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
|
||||
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 12px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.el-table { display: none; }
|
||||
.topic-card-list { display: block; }
|
||||
.topic-card {
|
||||
@@ -52,41 +32,38 @@
|
||||
.topic-card-title { font-size: 16px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
|
||||
.topic-card-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.topic-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: #606266; margin-bottom: 12px; }
|
||||
.topic-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
|
||||
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
.preview-dialog-custom { position: relative; left: 90px; }
|
||||
.topic-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
|
||||
.topic-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
|
||||
.topic-card-actions .el-button:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||
.topic-card.is-checked { border: 2px solid #409eff; box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="选题管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="topics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">📋 选题管理</h2>
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 选题管理</h2>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0">🔍 批量审查</el-button>
|
||||
<el-button type="primary" size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷新</el-button>
|
||||
<el-button size="small" @click="toggleSelectAll">{{ selectAllLabel }}</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon> 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 批量审查</el-button>
|
||||
<span v-if="selectedTopicIds.length > 0" class="selected-count">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'">今日新增 ({{ todayCount }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; fetchTopics()">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'; fetchTopics()">今日新增 ({{ todayCount }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'; fetchTopics()">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
@@ -102,22 +79,26 @@
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="generated_at" label="创作时间" width="140"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="published_at" label="发布时间" width="140"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)">审查</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)">删除</el-button>
|
||||
<div style="display: flex; gap: 4px; white-space: nowrap;">
|
||||
<el-button size="small" @click="openPreview(scope.row)" type="primary" style="padding:5px 8px;">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!loadingTable && filteredTopics.length === 0" class="empty-state" style="margin-top:20px;"><el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon><div class="empty-text">暂无选题数据</div></div>
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card" :class="{ 'is-checked': selectedTopicIds.includes(topic.id) }">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<div class="topic-card-title">
|
||||
<el-checkbox :checked="selectedTopicIds.includes(topic.id)" @change="toggleCheck(topic.id)" style="margin-right:6px;"></el-checkbox>
|
||||
{{ topic.id }}. {{ topic.title }}
|
||||
</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
@@ -133,7 +114,7 @@
|
||||
<el-button size="small" @click="openPreview(topic)" type="primary">预览</el-button>
|
||||
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,6 +122,30 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
|
||||
<div v-if="publishTopic">
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
|
||||
<div style="font-size:15px;font-weight:600;color:#303133;">{{ publishTopic.id }}. {{ publishTopic.title }}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选择发布平台:</div>
|
||||
<el-checkbox v-model="publishPlatforms.zhihu" label="zhihu" style="display:block;margin-bottom:8px;">知乎</el-checkbox>
|
||||
<el-checkbox v-model="publishPlatforms.wechat" label="wechat" style="display:block;margin-bottom:8px;">微信公众号</el-checkbox>
|
||||
<el-checkbox v-model="publishPlatforms.xiaohongshu" label="xiaohongshu" style="display:block;">小红书</el-checkbox>
|
||||
</div>
|
||||
<div v-if="publishing" style="text-align:center;padding:12px;color:#909399;">
|
||||
<el-icon class="is-loading" style="margin-right:4px;">
|
||||
<svg viewBox="0 0 1024 1024" width="16" height="16"><path d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896z" fill="none" stroke="currentColor" stroke-width="64"/></svg>
|
||||
</el-icon>
|
||||
正在发布...
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="publishDialogVisible = false" :disabled="publishing">取消</el-button>
|
||||
<el-button type="primary" @click="confirmPublish" :loading="publishing" :disabled="!publishPlatforms.zhihu && !publishPlatforms.wechat && !publishPlatforms.xiaohongshu">确认发布</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
|
||||
<div v-if="previewTopic">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
@@ -181,6 +186,7 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
@@ -191,22 +197,15 @@ const TopicsApp = {
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [], todayCount: 0,
|
||||
previewVisible: false, previewTopic: null, previewFullscreen: false,
|
||||
previewPlatform: 'zhihu', platformContents: {}
|
||||
previewPlatform: 'zhihu', platformContents: {},
|
||||
publishDialogVisible: false, publishTopic: null,
|
||||
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
|
||||
publishing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) return [];
|
||||
if (!this.filterStatus) return this.topics;
|
||||
if (this.filterStatus === 'today') {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
return this.topics.filter(t => {
|
||||
const d = t.created_at;
|
||||
if (!d) return false;
|
||||
return d.slice(0, 10) === todayStr;
|
||||
});
|
||||
}
|
||||
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
|
||||
const allowed = map[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
@@ -231,6 +230,12 @@ const TopicsApp = {
|
||||
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||
} catch (e) { console.error('生成预览 HTML 失败:', e); return html; }
|
||||
},
|
||||
selectAllLabel() {
|
||||
const visible = this.filteredTopics.map(t => t.id);
|
||||
if (visible.length === 0) return '全选';
|
||||
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
|
||||
return allChecked ? '取消全选' : `全选 (${visible.length})`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -245,7 +250,8 @@ const TopicsApp = {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const data = await this.api('/api/topics');
|
||||
const params = this.filterStatus === 'today' ? '?today=true' : '';
|
||||
const data = await this.api('/api/topics' + params);
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
@@ -258,7 +264,7 @@ const TopicsApp = {
|
||||
try {
|
||||
const stats = await this.api('/api/topics/stats');
|
||||
if (stats) this.todayCount = stats.today_created || 0;
|
||||
} catch (e) { console.error('获取统计失败:', e); }
|
||||
} catch (e) { console.error('获取统计失败:', e); this.$message.error('获取统计失败: ' + e.message); }
|
||||
},
|
||||
refreshAll() { this.fetchTopics(); this.fetchTodayCount(); this.$message.success('已刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
@@ -272,7 +278,7 @@ const TopicsApp = {
|
||||
}
|
||||
this.$message.success(`已提交 ${count}/${this.selectedTopicIds.length} 个创作任务,可在「创作任务」页面查看进度`);
|
||||
this.selectedTopicIds = [];
|
||||
setTimeout(() => this.fetchTopics(), 2000);
|
||||
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
|
||||
},
|
||||
async triggerReviewSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
@@ -281,6 +287,7 @@ const TopicsApp = {
|
||||
this.$message.success('批量审查完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (error) { this.$message.error(`批量审查失败: ${error.message}`); }
|
||||
},
|
||||
async openPreview(topic) {
|
||||
@@ -288,9 +295,10 @@ const TopicsApp = {
|
||||
const token = this.getToken();
|
||||
if (!token) return;
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const names = { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' };
|
||||
await Promise.all(platforms.map(p =>
|
||||
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => console.error(`加载${p}预览失败:`, e))
|
||||
.then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => { console.error(`加载${p}预览失败:`, e); this.$message.error(`加载${names[p]}预览失败`); })
|
||||
));
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
@@ -321,7 +329,7 @@ const TopicsApp = {
|
||||
try {
|
||||
const data = await this.api('/api/tasks/run-creator?topic_id=' + topic.id, { method: 'POST' });
|
||||
this.$message.success(`创作任务已启动: ${topic.title},可在「创作任务」页面查看进度`);
|
||||
setTimeout(() => this.fetchTopics(), 2000);
|
||||
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
|
||||
} catch (error) { this.$message.error(`创作失败: ${error.message}`); }
|
||||
},
|
||||
async reviewTopic(topic) {
|
||||
@@ -330,15 +338,31 @@ const TopicsApp = {
|
||||
const data = await this.api('/api/system/review/run', { method: 'POST', body: JSON.stringify({ topic_ids: [topic.id] }) });
|
||||
this.$message.success(`审查完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (error) { this.$message.error(`审查失败: ${error.message}`); }
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
openPublishDialog(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) { this.$message.info('仅待发布选题可发布'); return; }
|
||||
this.publishTopic = topic;
|
||||
this.publishPlatforms = { zhihu: true, wechat: true, xiaohongshu: true };
|
||||
this.publishing = false;
|
||||
this.publishDialogVisible = true;
|
||||
},
|
||||
async confirmPublish() {
|
||||
const selected = Object.entries(this.publishPlatforms).filter(([, v]) => v).map(([k]) => k);
|
||||
if (selected.length === 0) { this.$message.warning('请至少选择一个平台'); return; }
|
||||
this.publishing = true;
|
||||
try {
|
||||
const data = await this.api('/api/publishing/create', { method: 'POST', body: JSON.stringify({ topic_id: topic.id }) });
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
const data = await this.api('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
|
||||
});
|
||||
this.$message.success(`发布完成: ${this.publishTopic.title}`);
|
||||
this.publishDialogVisible = false;
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (error) { this.$message.error(`发布失败: ${error.message}`); }
|
||||
finally { this.publishing = false; }
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
try {
|
||||
@@ -346,11 +370,30 @@ const TopicsApp = {
|
||||
await this.api('/api/topics/' + id, { method: 'DELETE' });
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败'); }
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
|
||||
toggleCheck(id) {
|
||||
const idx = this.selectedTopicIds.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
this.selectedTopicIds.splice(idx, 1);
|
||||
} else {
|
||||
this.selectedTopicIds.push(id);
|
||||
}
|
||||
},
|
||||
toggleSelectAll() {
|
||||
const visible = this.filteredTopics.map(t => t.id);
|
||||
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
|
||||
if (allChecked) {
|
||||
this.selectedTopicIds = this.selectedTopicIds.filter(id => !visible.includes(id));
|
||||
} else {
|
||||
const existing = new Set(this.selectedTopicIds);
|
||||
for (const id of visible) { if (!existing.has(id)) { this.selectedTopicIds.push(id); } }
|
||||
}
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
@@ -362,18 +405,24 @@ const TopicsApp = {
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
const filter = new URLSearchParams(window.location.search).get('filter');
|
||||
if (filter) this.filterStatus = filter;
|
||||
const urlFilter = new URLSearchParams(window.location.search).get('filter');
|
||||
if (urlFilter) this.filterStatus = urlFilter;
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchTopics(); this.fetchTodayCount(); })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
watch: {
|
||||
topics() {
|
||||
const urlFilter = new URLSearchParams(window.location.search).get('filter');
|
||||
if (urlFilter) this.filterStatus = urlFilter;
|
||||
}
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
(function () {
|
||||
if (document.getElementById('uni-nav-styles')) return;
|
||||
var style = document.createElement('style');
|
||||
style.id = 'uni-nav-styles';
|
||||
style.textContent = '\
|
||||
.uni-nav {\
|
||||
position: fixed; top: 0; left: 0; right: 0; height: 60px;\
|
||||
z-index: 10000;\
|
||||
display: flex; align-items: center;\
|
||||
padding: 0 16px;\
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
|
||||
color: #fff;\
|
||||
box-shadow: 0 2px 12px rgba(37,99,235,0.25);\
|
||||
}\
|
||||
.uni-nav-inner {\
|
||||
display: flex; align-items: center;\
|
||||
width: 100%; max-width: 1400px; margin: 0 auto;\
|
||||
gap: 8px;\
|
||||
}\
|
||||
.uni-nav-brand {\
|
||||
font-size: 18px; font-weight: 700;\
|
||||
white-space: nowrap;\
|
||||
margin-right: 8px;\
|
||||
letter-spacing: -0.3px;\
|
||||
}\
|
||||
.uni-nav-items {\
|
||||
display: flex; align-items: center; gap: 2px;\
|
||||
flex: 1; overflow: hidden;\
|
||||
}\
|
||||
.uni-nav-item {\
|
||||
display: inline-flex; align-items: center; gap: 4px;\
|
||||
padding: 6px 12px;\
|
||||
border: none; background: transparent;\
|
||||
color: rgba(255,255,255,0.8);\
|
||||
font-size: 13px;\
|
||||
border-radius: 8px;\
|
||||
cursor: pointer;\
|
||||
white-space: nowrap;\
|
||||
transition: all 0.2s;\
|
||||
}\
|
||||
.uni-nav-item:hover { background: rgba(255,255,255,0.12); color: #fff; }\
|
||||
.uni-nav-item.active {\
|
||||
background: rgba(255,255,255,0.18); color: #fff; font-weight: 600;\
|
||||
}\
|
||||
.uni-nav-right {\
|
||||
display: flex; align-items: center; gap: 8px;\
|
||||
flex-shrink: 0;\
|
||||
}\
|
||||
.uni-nav-avatar {\
|
||||
width: 30px; height: 30px;\
|
||||
border-radius: 50%;\
|
||||
background: rgba(255,255,255,0.2);\
|
||||
display: flex; align-items: center; justify-content: center;\
|
||||
font-size: 13px; font-weight: 600;\
|
||||
}\
|
||||
.uni-nav-username { font-size: 13px; color: rgba(255,255,255,0.9); }\
|
||||
.uni-nav-badge {\
|
||||
font-size: 10px;\
|
||||
background: rgba(255,255,255,0.15);\
|
||||
padding: 1px 8px; border-radius: 10px;\
|
||||
margin-left: 4px;\
|
||||
}\
|
||||
.uni-nav-logout {\
|
||||
background: rgba(255,255,255,0.1);\
|
||||
border: 1px solid rgba(255,255,255,0.15);\
|
||||
color: rgba(255,255,255,0.9);\
|
||||
padding: 4px 12px; border-radius: 6px;\
|
||||
cursor: pointer; font-size: 12px;\
|
||||
transition: all 0.2s;\
|
||||
}\
|
||||
.uni-nav-logout:hover { background: rgba(255,255,255,0.2); }\
|
||||
.uni-nav-hamburger {\
|
||||
display: none;\
|
||||
background: rgba(255,255,255,0.1);\
|
||||
border: none; color: #fff;\
|
||||
width: 36px; height: 36px;\
|
||||
border-radius: 8px;\
|
||||
cursor: pointer; font-size: 20px;\
|
||||
align-items: center; justify-content: center;\
|
||||
transition: all 0.2s;\
|
||||
}\
|
||||
.uni-nav-hamburger:hover { background: rgba(255,255,255,0.18); }\
|
||||
.uni-nav-dropdown {\
|
||||
display: none;\
|
||||
position: fixed; top: 60px; left: 0; right: 0;\
|
||||
background: #fff;\
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);\
|
||||
z-index: 9999;\
|
||||
padding: 8px;\
|
||||
max-height: calc(100vh - 60px);\
|
||||
overflow-y: auto;\
|
||||
animation: uniNavSlideDown 0.2s ease-out;\
|
||||
}\
|
||||
.uni-nav-dropdown.open { display: block; }\
|
||||
.uni-nav-dropdown-item {\
|
||||
display: flex; align-items: center; gap: 8px;\
|
||||
width: 100%;\
|
||||
padding: 12px 16px;\
|
||||
border: none; background: transparent;\
|
||||
color: #303133; font-size: 14px;\
|
||||
border-radius: 8px;\
|
||||
cursor: pointer;\
|
||||
transition: all 0.15s;\
|
||||
}\
|
||||
.uni-nav-dropdown-item:hover { background: #f0f4ff; color: #2563eb; }\
|
||||
.uni-nav-dropdown-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }\
|
||||
.uni-nav-dropdown-divider {\
|
||||
height: 1px; background: #f0f0f0; margin: 4px 0;\
|
||||
}\
|
||||
@media (max-width: 768px) {\
|
||||
.uni-nav-items { display: none; }\
|
||||
.uni-nav-username { display: none; }\
|
||||
.uni-nav-hamburger { display: inline-flex; }\
|
||||
}\
|
||||
@keyframes uniNavSlideDown {\
|
||||
from { opacity: 0; transform: translateY(-8px); }\
|
||||
to { opacity: 1; transform: translateY(0); }\
|
||||
}\
|
||||
';
|
||||
document.head.appendChild(style);
|
||||
|
||||
function getCurrentPage() {
|
||||
var path = window.location.pathname;
|
||||
if (path === '/' || path === '/index.html') return 'dashboard';
|
||||
var m = path.match(/\/(\w+)\.html/);
|
||||
return m ? m[1] : 'dashboard';
|
||||
}
|
||||
|
||||
function navItems(isAdmin) {
|
||||
var items = [
|
||||
{ key: 'dashboard', label: '仪表盘', page: '/' },
|
||||
{ key: 'topics', label: '选题', page: 'topics.html' },
|
||||
{ key: 'metrics', label: '数据', page: 'metrics.html' },
|
||||
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
||||
{ key: 'platforms', label: '平台', page: 'platforms.html' },
|
||||
{ key: 'logs', label: '日志', page: 'logs.html' },
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ key: 'users', label: '用户', page: 'users.html', admin: true });
|
||||
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
var UniNav = {
|
||||
name: 'UniNav',
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
username: { type: String, default: '' },
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
currentPage: { type: String, default: null },
|
||||
onNavigate: { type: Function, default: null },
|
||||
},
|
||||
emits: ['logout'],
|
||||
data: function () {
|
||||
return { dropdownOpen: false };
|
||||
},
|
||||
computed: {
|
||||
items: function () {
|
||||
return navItems(this.isAdmin);
|
||||
},
|
||||
page: function () {
|
||||
return this.currentPage || getCurrentPage();
|
||||
},
|
||||
showTitle: function () {
|
||||
return this.title || '宇之然';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
navigate: function (page) {
|
||||
this.dropdownOpen = false;
|
||||
if (this.onNavigate) { this.onNavigate(page); return; }
|
||||
window.location.href = page === '/' ? '/' : '/' + page;
|
||||
},
|
||||
logout: function () {
|
||||
this.$emit('logout');
|
||||
},
|
||||
toggleDropdown: function () {
|
||||
this.dropdownOpen = !this.dropdownOpen;
|
||||
},
|
||||
closeDropdown: function (e) {
|
||||
if (this.dropdownOpen && !this.$el.contains(e.target)) {
|
||||
this.dropdownOpen = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted: function () {
|
||||
document.addEventListener('click', this.closeDropdown);
|
||||
},
|
||||
beforeUnmount: function () {
|
||||
document.removeEventListener('click', this.closeDropdown);
|
||||
},
|
||||
template: '\
|
||||
<nav class="uni-nav">\
|
||||
<div class="uni-nav-inner">\
|
||||
<div class="uni-nav-brand">{{ showTitle }}</div>\
|
||||
<div class="uni-nav-items">\
|
||||
<button v-for="item in items" :key="item.key"\
|
||||
:class="[\'uni-nav-item\', { active: page === item.key }]"\
|
||||
@click="navigate(item.page)">{{ item.label }}</button>\
|
||||
</div>\
|
||||
<div class="uni-nav-right">\
|
||||
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
|
||||
{{ dropdownOpen ? "✕" : "☰" }}\
|
||||
</button>\
|
||||
<div class="uni-nav-avatar">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\
|
||||
<span v-if="username" class="uni-nav-username">{{ username }}</span>\
|
||||
<span v-if="isAdmin" class="uni-nav-badge">管理员</span>\
|
||||
<button class="uni-nav-logout" @click="logout">退出</button>\
|
||||
</div>\
|
||||
</div>\
|
||||
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
|
||||
<button v-for="item in items" :key="item.key"\
|
||||
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\
|
||||
@click="navigate(item.page)">\
|
||||
{{ item.label }}\
|
||||
</button>\
|
||||
</div>\
|
||||
</nav>',
|
||||
};
|
||||
|
||||
window.UniNav = UniNav;
|
||||
|
||||
window.installUniNav = function (app) {
|
||||
app.component('uni-nav', UniNav);
|
||||
return app;
|
||||
};
|
||||
})();
|
||||
@@ -7,26 +7,12 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.card-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.user-table { width: 100%; }
|
||||
.user-table .el-table__cell { word-break: break-word; }
|
||||
|
||||
.user-card-list { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.user-table { display: none; }
|
||||
.user-card-list { display: block; margin: 0 -16px; }
|
||||
.user-card {
|
||||
@@ -64,26 +50,26 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
<script src="navbar-component.js"></script>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component title="用户管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
|
||||
<navigation-component current-page="users" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<uni-nav title="用户管理" :username="currentUser.username" :is-admin="isAdmin" current-page="users" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">👥 用户管理</h2>
|
||||
<h2 class="card-title"><el-icon style="vertical-align:-2px;"><IconUser /></el-icon> 用户管理</h2>
|
||||
<el-button type="primary" @click="addUser">+ 新建用户</el-button>
|
||||
</div>
|
||||
<el-table :data="users" stripe class="user-table">
|
||||
<el-table :data="users" stripe class="user-table" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="username" label="用户名"></el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="scope">
|
||||
@@ -91,6 +77,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!loading && users.length === 0" style="text-align:center;padding:40px 0;color:#909399;">
|
||||
<el-icon style="font-size:48px;margin-bottom:12px;color:#c0c4cc;"><IconUser /></el-icon>
|
||||
<div>暂无用户</div>
|
||||
</div>
|
||||
<div class="user-card-list">
|
||||
<div v-for="u in users" :key="u.id" class="user-card">
|
||||
<div class="user-card-header">
|
||||
@@ -101,6 +91,10 @@
|
||||
<span style="font-size: 12px; color: #909399;">#{{ u.id }}</span>
|
||||
</div>
|
||||
<div class="user-card-meta">
|
||||
<div class="user-card-meta-item">
|
||||
<span class="user-card-meta-label">组织</span>
|
||||
<span>{{ u.org_id || '-' }}</span>
|
||||
</div>
|
||||
<div class="user-card-meta-item">
|
||||
<span class="user-card-meta-label">创建时间</span>
|
||||
<span>{{ formatDate(u.created_at) }}</span>
|
||||
@@ -114,14 +108,42 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<el-dialog v-model="dialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px" @submit.prevent="submitUser">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" required>
|
||||
<el-input v-model="form.password" type="password" placeholder="至少6位" show-password></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width:100%">
|
||||
<el-option label="编辑" value="editor"></el-option>
|
||||
<el-option label="管理员" value="admin"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUser" :loading="submitting">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const UsersApp = {
|
||||
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, users: [] } },
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
users: [], dialogVisible: false, submitting: false, loading: false,
|
||||
form: { username: '', password: '', role: 'editor' }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchUsers() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { this.$message.error('请先登录'); return; }
|
||||
@@ -129,30 +151,38 @@
|
||||
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
|
||||
const data = await response.json();
|
||||
this.users = data || [];
|
||||
this.$message.success('用户列表加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取用户失败:', error);
|
||||
this.$message.error(`获取用户失败: ${error.message}`);
|
||||
this.users = [];
|
||||
}
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
async addUser() {
|
||||
addUser() {
|
||||
this.form = { username: '', password: '', role: 'editor' };
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
async submitUser() {
|
||||
if (!this.form.username || this.form.username.length < 2) { this.$message.warning('用户名至少2个字符'); return; }
|
||||
if (!this.form.password || this.form.password.length < 6) { this.$message.warning('密码至少6位'); return; }
|
||||
this.submitting = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { this.$message.error('请先登录'); return; }
|
||||
const username = '新用户' + Date.now().toString().slice(-4);
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, role: 'editor' })
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
|
||||
const newUser = await response.json();
|
||||
this.users.push(newUser);
|
||||
this.$message.success('添加用户成功');
|
||||
this.dialogVisible = false;
|
||||
} catch (error) {
|
||||
console.error('添加用户失败:', error);
|
||||
this.$message.error(`添加用户失败: ${error.message}`);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
async deleteUser(id) {
|
||||
@@ -186,8 +216,8 @@
|
||||
};
|
||||
const app = Vue.createApp(UsersApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installNavbar) { window.installNavbar(app); }
|
||||
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+269
-86
@@ -46,12 +46,13 @@ logger = logging.getLogger(__name__)
|
||||
class SustainabilitySource:
|
||||
"""可持续性信息源"""
|
||||
name: str
|
||||
type: str # rss, web, api, report, local
|
||||
url: Optional[str] = None # 可为空(如本地源)
|
||||
type: str # rss, web_search, web, api, local
|
||||
url: Optional[str] = None # RSS URL 或通用链接
|
||||
update_frequency: str = "daily"
|
||||
credibility: str = "medium"
|
||||
focus: str = "可持续性"
|
||||
keywords: Optional[List[str]] = None # 源特定关键词
|
||||
query: Optional[str] = None # 搜索查询词(w eb_search类型用)
|
||||
|
||||
@dataclass
|
||||
class SustainabilityCase:
|
||||
@@ -118,30 +119,60 @@ class SustainabilityCollector:
|
||||
self.new_topics: List[SustainabilityTopic] = []
|
||||
|
||||
def load_config(self):
|
||||
"""加载配置文件"""
|
||||
with open(CONFIG_DIR / "sources.yaml", "r", encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
"""加载配置:优先从DB读取,DB为空则从YAML fallback再写入DB"""
|
||||
self.config = {}
|
||||
self.config_path = CONFIG_DIR / "sources.yaml"
|
||||
if self.config_path.exists():
|
||||
with open(self.config_path, encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f) or {}
|
||||
|
||||
with open(CONFIG_DIR / "wecom_config.yaml", "r", encoding='utf-8') as f:
|
||||
with open(CONFIG_DIR / "wecom_config.yaml", encoding='utf-8') as f:
|
||||
self.wecom_config = yaml.safe_load(f)
|
||||
|
||||
# 优先从DB读取类别和源
|
||||
self.sources = []
|
||||
for source_group in self.config["sustainability_sources"].values():
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.models import CollectorCategory, CollectorSource
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).order_by(CollectorCategory.sort_order).all()
|
||||
if cats:
|
||||
# 用DB中的类别覆盖YAML
|
||||
self.config["sustainability_categories"] = [c.name for c in cats]
|
||||
sources_db = db.query(CollectorSource).filter(CollectorSource.is_active == True).order_by(CollectorSource.sort_order).all()
|
||||
for s in sources_db:
|
||||
self.sources.append(SustainabilitySource(
|
||||
name=s.name,
|
||||
type=s.source_type,
|
||||
url=s.url or '',
|
||||
query=s.query or '',
|
||||
credibility=s.credibility or 'medium',
|
||||
focus=s.focus or '可持续性',
|
||||
))
|
||||
logger.info(f"从DB加载 {len(cats)} 个类别, {len(self.sources)} 个信息源")
|
||||
db.close()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"DB读取类别/源失败,回退YAML: {e}")
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB连接失败,回退YAML: {e}")
|
||||
|
||||
# YAML fallback
|
||||
for source_group in self.config.get("sustainability_sources", {}).values():
|
||||
for source_info in source_group:
|
||||
# Handle both 'url' and 'base_url' in config
|
||||
source_info = source_info.copy()
|
||||
if 'base_url' in source_info and 'url' not in source_info:
|
||||
source_info['url'] = source_info.pop('base_url')
|
||||
# Provide defaults for missing optional fields
|
||||
source_info.setdefault('update_frequency', 'daily')
|
||||
source_info.setdefault('focus', '可持续性')
|
||||
source_info.setdefault('keywords', None)
|
||||
# Filter to only fields accepted by SustainabilitySource
|
||||
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords'}
|
||||
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords', 'query'}
|
||||
filtered_info = {k: v for k, v in source_info.items() if k in allowed_keys}
|
||||
self.sources.append(SustainabilitySource(**filtered_info))
|
||||
|
||||
logger.info(f"加载了 {len(self.sources)} 个信息源")
|
||||
logger.info(f"YAML fallback: 加载 {len(self.sources)} 个信息源")
|
||||
|
||||
def load_local_cases_from_db(self) -> List[SustainabilityCase]:
|
||||
"""从本地案例库加载历史案例,用于降级生成选题"""
|
||||
@@ -213,16 +244,17 @@ class SustainabilityCollector:
|
||||
field = field_match.group(1).strip()
|
||||
# 映射到子领域(扩展映射表)
|
||||
category_map = {
|
||||
'远程工作方式': '城市农业',
|
||||
'远程工作方式': '循环消费',
|
||||
'数字游民政策': '低碳出行',
|
||||
'AI副业服务': '环保科技产品',
|
||||
'一人公司模式': '循环消费',
|
||||
'未来技能趋势': '可持续饮食',
|
||||
'可持续生活': '可持续饮食',
|
||||
'未来技能趋势': '干净饮食',
|
||||
'可持续生活': '零浪费生活',
|
||||
'零浪费生活': '零浪费生活',
|
||||
'低碳出行': '低碳出行',
|
||||
'循环消费': '循环消费',
|
||||
'环保科技': '环保科技产品'
|
||||
'环保科技': '环保科技产品',
|
||||
'城市农业': '循环消费',
|
||||
}
|
||||
case_data['category'] = category_map.get(field, field[:4] if len(field) > 4 else field)
|
||||
|
||||
@@ -317,10 +349,129 @@ class SustainabilityCollector:
|
||||
|
||||
def fetch_web_content(self, source: SustainabilitySource) -> List[Dict]:
|
||||
"""获取网页内容(简化版,实际需要更复杂的抓取)"""
|
||||
# 简化实现:只记录,不实际抓取
|
||||
logger.info(f"网页信息源 {source.name} 需要手动处理")
|
||||
return []
|
||||
|
||||
def fetch_web_search(self, source: SustainabilitySource) -> List[Dict]:
|
||||
"""通过Bing中文搜索获取实时内容"""
|
||||
try:
|
||||
from web_search import search
|
||||
query = source.query or source.url or ''
|
||||
query = query.strip()
|
||||
if not query:
|
||||
logger.warning(f"web_search源 {source.name} 未配置查询词")
|
||||
return []
|
||||
results = search(query, max_results=8, use_cache=False)
|
||||
articles = []
|
||||
for r in results:
|
||||
articles.append({
|
||||
'title': r.get('title', ''),
|
||||
'url': r.get('url', ''),
|
||||
'content': r.get('snippet', ''),
|
||||
'published': TODAY,
|
||||
'source_name': source.name,
|
||||
'search_query': query,
|
||||
})
|
||||
logger.info(f"搜索 [{query}] 获得 {len(articles)} 条结果")
|
||||
return articles
|
||||
except Exception as e:
|
||||
logger.warning(f"web_search失败 {source.name}: {e}")
|
||||
return []
|
||||
|
||||
def _generate_topic_with_llm(self, search_results: List[Dict]) -> Optional[SustainabilityTopic]:
|
||||
"""用LLM从搜索结果中生成选题"""
|
||||
try:
|
||||
from app.core.nvidia_client import call_llm
|
||||
except ImportError:
|
||||
logger.warning("LLM不可用,跳过AI选题生成")
|
||||
return None
|
||||
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
# 整理搜索结果摘要
|
||||
summaries = []
|
||||
for r in search_results[:6]:
|
||||
summaries.append(f"- {r.get('title','')}: {r.get('content','')[:150]}")
|
||||
search_text = "\n".join(summaries)
|
||||
|
||||
# 获取已有选题做去重参考
|
||||
existing = self._get_existing_titles()
|
||||
existing_hint = ""
|
||||
if existing:
|
||||
existing_hint = f"\n以下选题已存在,请避免重复:\n" + "\n".join(f"- {t[:30]}" for t in existing[-10:])
|
||||
|
||||
# 按日期选不同类别
|
||||
categories = self.config.get("sustainability_categories", ["可持续生活"])
|
||||
day_idx = datetime.datetime.now().timetuple().tm_yday % len(categories)
|
||||
target_category = categories[day_idx]
|
||||
|
||||
prompt = f"""你是一个内容策略师。基于以下搜索结果,生成一个有价值、适合中文互联网传播的选题。
|
||||
|
||||
目标类别:{target_category}
|
||||
|
||||
搜索结果:
|
||||
{search_text}
|
||||
{existing_hint}
|
||||
|
||||
请生成一个选题,输出JSON格式:
|
||||
{{
|
||||
"title": "标题(20字内,有吸引力,含核心关键词)",
|
||||
"core_concept": "核心观点(一句话说清独特价值)",
|
||||
"audience_pain": "受众痛点(真实用户的困惑或需求)",
|
||||
"unique_angle": "独特视角(差异化切入点)",
|
||||
"format": "内容形式(趋势洞察/实操指南/对比分析/案例解读)"
|
||||
}}
|
||||
|
||||
要求:
|
||||
- 标题要像人会搜索的,带领域关键词
|
||||
- 避免「新趋势」「指南」「攻略」这类同质化结尾
|
||||
- 切入点要具体,不要泛泛而谈
|
||||
- 优先考虑中国读者能实操的内容
|
||||
只输出JSON,不要其他文字。"""
|
||||
|
||||
try:
|
||||
resp = call_llm(prompt, temperature=0.7, max_tokens=800)
|
||||
resp = resp.strip()
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
data = json.loads(resp)
|
||||
|
||||
topic_id = f"TOPIC-{hashlib.md5((target_category + data.get('title','')[:10]).encode()).hexdigest()[:6].upper()}"
|
||||
|
||||
topic = SustainabilityTopic(
|
||||
id=topic_id,
|
||||
title=data.get("title", f"{target_category}新观察"),
|
||||
cases=[],
|
||||
audience="城市焦虑青年(26-35岁)",
|
||||
china_pain_points=data.get("audience_pain", ""),
|
||||
localization_solution="文章中将提供具体可执行的建议",
|
||||
mvp_actions="读者可立即尝试的3个行动",
|
||||
estimated_length=2000,
|
||||
priority_score=7.0,
|
||||
field=self.map_category_to_field(target_category),
|
||||
format=data.get("format", "趋势洞察 + 实操指南"),
|
||||
core_concept=data.get("core_concept", ""),
|
||||
audience_pain=data.get("audience_pain", ""),
|
||||
unique_angle=data.get("unique_angle", ""),
|
||||
priority="中",
|
||||
total_score=70.0,
|
||||
compliance_score=100,
|
||||
source_file="automation/data/sustainability_topics.json",
|
||||
status="待处理",
|
||||
lock_by=None,
|
||||
lock_at=None,
|
||||
created_at=datetime.datetime.now().isoformat(),
|
||||
ready_at=None,
|
||||
published_at=None,
|
||||
platform_urls={}
|
||||
)
|
||||
logger.info(f"LLM生成选题: {topic.title}")
|
||||
return topic
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM选题生成失败: {e}")
|
||||
return None
|
||||
|
||||
def analyze_article(self, article: Dict) -> Optional[SustainabilityCase]:
|
||||
"""分析文章内容,提炼案例"""
|
||||
try:
|
||||
@@ -370,13 +521,14 @@ class SustainabilityCollector:
|
||||
|
||||
# 生成中国痛点(基于类别模板)
|
||||
china_pains = {
|
||||
"城市农业": "中国城市空间小、光照不足、怕邻居投诉",
|
||||
"零浪费生活": "中国垃圾分类执行难、环保产品溢价高",
|
||||
"低碳出行": "中国电动车充电难、城市规划不支持",
|
||||
"循环消费": "中国二手文化不成熟、维修成本高",
|
||||
"能源效率": "中国能源价格波动、设备更换成本高",
|
||||
"可持续饮食": "中国预制菜泛滥、有机食品价格高",
|
||||
"环保科技产品": "中国消费者关注价格多于环保"
|
||||
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
|
||||
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
|
||||
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
|
||||
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
|
||||
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
|
||||
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
|
||||
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
|
||||
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证"
|
||||
}
|
||||
china_pain = china_pains.get(category, "中国相关数据不足,需本土化验证")
|
||||
|
||||
@@ -423,12 +575,21 @@ class SustainabilityCollector:
|
||||
if len(main_cases) < 2:
|
||||
return None
|
||||
|
||||
# 生成选题ID
|
||||
topic_id = f"TOPIC-{hashlib.md5((main_category + TODAY).encode()).hexdigest()[:6].upper()}"
|
||||
|
||||
# 组合标题
|
||||
# 组合标题(多种模板轮换,避免天天同款)
|
||||
case_titles = [case.title[:30] for case in main_cases[:2]]
|
||||
topic_title = f"{main_category}新趋势: {case_titles[0]}与{case_titles[1]}的中国落地路径"
|
||||
day_of_year = datetime.datetime.now().timetuple().tm_yday
|
||||
title_templates = [
|
||||
f"{main_category}新趋势: {case_titles[0]}与{case_titles[1]}的中国落地路径",
|
||||
f"从{case_titles[0][:15]}到{case_titles[1][:15]}: {main_category}的中国实践指南",
|
||||
f"2026{main_category}观察: {case_titles[0]}给中国什么启示",
|
||||
f"实战对比: {case_titles[0][:10]}vs{case_titles[1][:10]},中国读者该学谁",
|
||||
f"为什么{case_titles[0][:15]}在中国行不通(或更行)? — {main_category}深度拆解",
|
||||
]
|
||||
topic_title = title_templates[day_of_year % len(title_templates)]
|
||||
|
||||
# 生成选题ID(案例内容hash保证同一批案例产出相同ID,避免重复入库)
|
||||
content_seed = main_category + case_titles[0][:10] + case_titles[1][:10]
|
||||
topic_id = f"TOPIC-{hashlib.md5(content_seed.encode()).hexdigest()[:6].upper()}"
|
||||
|
||||
# 计算优先级分数
|
||||
priority_weights = self.config["topic_priority"]
|
||||
@@ -484,15 +645,7 @@ class SustainabilityCollector:
|
||||
|
||||
def map_category_to_field(self, category: str) -> str:
|
||||
"""将案例类别映射到内容领域的字段"""
|
||||
category_map = {
|
||||
"城市农业": "可持续生活系统",
|
||||
"零浪费生活": "可持续生活系统",
|
||||
"低碳出行": "可持续生活系统",
|
||||
"循环消费": "可持续生活系统",
|
||||
"能源效率": "可持续生活系统",
|
||||
"环保科技产品": "可持续生活系统"
|
||||
}
|
||||
return category_map.get(category, "可持续生活系统")
|
||||
return "可持续生活系统"
|
||||
|
||||
def save_results(self):
|
||||
"""保存收集结果"""
|
||||
@@ -585,87 +738,117 @@ class SustainabilityCollector:
|
||||
except Exception as e:
|
||||
logger.error(f"发送通知失败: {e}")
|
||||
|
||||
def _get_existing_titles(self) -> List[str]:
|
||||
"""从DB获取已有的选题标题列表用于去重"""
|
||||
try:
|
||||
from db_helper import export_topics_to_json
|
||||
topics = export_topics_to_json()
|
||||
return [t.get('title', '') for t in topics]
|
||||
except Exception as e:
|
||||
logger.warning(f"读取已有选题失败: {e}")
|
||||
return []
|
||||
|
||||
def _is_duplicate_topic(self, title: str, existing_titles: List[str]) -> bool:
|
||||
"""检查选题是否与已有选题重复(前10字重叠即为重复)"""
|
||||
prefix = title[:10].strip()
|
||||
for et in existing_titles:
|
||||
if prefix in et or et[:10] in title:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _rotate_category(self, local_cases: List[SustainabilityCase]) -> Tuple[str, List[SustainabilityCase]]:
|
||||
"""按日期轮换类别,避免天天选中同一类"""
|
||||
category_cases = {}
|
||||
for case in local_cases:
|
||||
category_cases.setdefault(case.category, []).append(case)
|
||||
|
||||
if not category_cases:
|
||||
return None, []
|
||||
|
||||
# 按类别名排序固定顺序
|
||||
sorted_cats = sorted(category_cases.keys())
|
||||
# 用一年中的第几天选类别,保证每天不重样
|
||||
day_of_year = datetime.datetime.now().timetuple().tm_yday
|
||||
idx = day_of_year % len(sorted_cats)
|
||||
main_cat = sorted_cats[idx]
|
||||
return main_cat, category_cases[main_cat]
|
||||
|
||||
def run(self):
|
||||
"""主运行流程"""
|
||||
logger.info("开始可持续性内容收集")
|
||||
|
||||
# 1. 从所有信息源收集
|
||||
existing_titles = self._get_existing_titles()
|
||||
|
||||
# ---------------------- 第一阶段:多源采集 ----------------------
|
||||
all_articles = []
|
||||
web_search_results = [] # 留给LLM选题用的搜索结果
|
||||
|
||||
for source in self.sources:
|
||||
if source.type == 'rss':
|
||||
articles = self.fetch_rss_feed(source)
|
||||
all_articles.extend(articles)
|
||||
elif source.type == 'web':
|
||||
articles = self.fetch_web_content(source)
|
||||
elif source.type == 'web_search':
|
||||
articles = self.fetch_web_search(source)
|
||||
web_search_results.extend(articles)
|
||||
all_articles.extend(articles)
|
||||
elif source.type == 'api':
|
||||
# TODO: 实现API抓取
|
||||
pass
|
||||
elif source.type == 'local':
|
||||
# 本地源不产生新文章,后续降级处理
|
||||
pass
|
||||
|
||||
logger.info(f"总共收集到 {len(all_articles)} 篇可持续性文章")
|
||||
logger.info(f"RSS采集 {sum(1 for a in all_articles if a.get('source_name','') not in [s.name for s in self.sources if s.type=='web_search'])} 篇, "
|
||||
f"搜索采集 {len(web_search_results)} 篇")
|
||||
|
||||
# 2. 分析文章,提炼案例
|
||||
for article in all_articles[:20]: # 限制分析数量
|
||||
# ---------------------- 第二阶段:尝试LLM选题生成 ----------------------
|
||||
llm_topic = None
|
||||
if web_search_results:
|
||||
llm_topic = self._generate_topic_with_llm(web_search_results)
|
||||
|
||||
if llm_topic and not self._is_duplicate_topic(llm_topic.title, existing_titles):
|
||||
llm_topic.created_at = datetime.datetime.now().isoformat()
|
||||
llm_topic.lock_by = None
|
||||
llm_topic.lock_at = None
|
||||
llm_topic.status = "待处理"
|
||||
self.new_topics.append(llm_topic)
|
||||
logger.info(f"✅ LLM生成选题: {llm_topic.title}")
|
||||
|
||||
# ---------------------- 第三阶段:RSS文章提炼案例 ----------------------
|
||||
rss_articles = [a for a in all_articles if a not in web_search_results]
|
||||
for article in rss_articles[:15]:
|
||||
case = self.analyze_article(article)
|
||||
if case:
|
||||
self.new_cases.append(case)
|
||||
|
||||
# 3. 降级策略:如果外部源没有收集到足够案例,使用本地案例库
|
||||
if len(self.new_cases) < 2:
|
||||
logger.warning(f"外部源案例不足 ({len(self.new_cases)} < 2),启动降级策略")
|
||||
|
||||
# 从本地JSON数据库加载案例(按类别分组,选择案例最多的类别)
|
||||
local_cases = self.load_local_cases_from_db()
|
||||
# ---------------------- 第四阶段:降级策略 ----------------------
|
||||
if not self.new_topics and len(self.new_cases) < 2:
|
||||
logger.warning(f"LLM选题和RSS案例均不足,启动本地案例降级")
|
||||
local_cases = self.load_local_cases_from_db() or self.load_local_cases_from_markdown()
|
||||
|
||||
if local_cases and len(local_cases) >= 2:
|
||||
# 按类别分组,选择案例最多的类别
|
||||
category_cases = {}
|
||||
for case in local_cases:
|
||||
cat = case.category
|
||||
if cat not in category_cases:
|
||||
category_cases[cat] = []
|
||||
category_cases[cat].append(case)
|
||||
|
||||
# 找出案例最多的类别
|
||||
main_category = max(category_cases, key=lambda k: len(category_cases[k]))
|
||||
main_cases = category_cases[main_category]
|
||||
|
||||
# 确保至少有2个案例
|
||||
if len(main_cases) >= 2:
|
||||
selected = main_cases[:min(3, len(main_cases))]
|
||||
cat, cat_cases = self._rotate_category(local_cases)
|
||||
if cat and len(cat_cases) >= 2:
|
||||
selected = cat_cases[:min(4, len(cat_cases))]
|
||||
self.new_cases.extend(selected)
|
||||
logger.info(f"降级:从类别'{main_category}'选取了 {len(selected)} 个案例")
|
||||
logger.info(f"降级:从类别'{cat}'选取 {len(selected)} 个案例 (day-of-year轮换)")
|
||||
else:
|
||||
# 如果每个类别都少于2个,则随机选2个(可能类别不同,generate_topic_from_cases会合并)
|
||||
import random
|
||||
selected = random.sample(local_cases, min(3, len(local_cases)))
|
||||
self.new_cases.extend(selected)
|
||||
logger.info(f"降级:随机选取了 {len(selected)} 个本地案例")
|
||||
else:
|
||||
# 备用:从Markdown案例库解析
|
||||
local_cases = self.load_local_cases_from_markdown()
|
||||
if local_cases:
|
||||
import random
|
||||
selected = random.sample(local_cases, min(3, len(local_cases)))
|
||||
self.new_cases.extend(selected)
|
||||
logger.info(f"降级(Markdown):使用了 {len(selected)} 个案例")
|
||||
else:
|
||||
logger.error("降级失败:本地案例库为空")
|
||||
logger.info(f"降级:随机选取 {len(selected)} 个本地案例")
|
||||
|
||||
# 4. 生成选题
|
||||
if self.new_cases:
|
||||
# 用本地案例生成选题
|
||||
if not self.new_topics and self.new_cases:
|
||||
topic = self.generate_topic_from_cases(self.new_cases)
|
||||
if topic:
|
||||
# 标记为今日创建,并添加锁字段(表示未被占用)
|
||||
if self._is_duplicate_topic(topic.title, existing_titles):
|
||||
logger.warning(f"选题重复,跳过: {topic.title}")
|
||||
else:
|
||||
topic.created_at = datetime.datetime.now().isoformat()
|
||||
topic.lock_by = None
|
||||
topic.lock_at = None
|
||||
# 确保状态为「待处理」
|
||||
topic.status = "待处理"
|
||||
self.new_topics.append(topic)
|
||||
logger.info(f"生成新选题: {topic.title}")
|
||||
else:
|
||||
logger.error("降级失败:本地案例库为空")
|
||||
|
||||
# 5. 保存结果
|
||||
self.save_results()
|
||||
|
||||
+5
-2
@@ -102,13 +102,16 @@ def run_pipeline(topic_id: str = None) -> Dict:
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "writer step failed"}
|
||||
|
||||
# 4. 合规优化(自动审核并标记为「待发布」)
|
||||
# 4. 配图生成
|
||||
image_ok = run_step("image_generator.py", tid)
|
||||
|
||||
# 5. 合规优化(自动审核并标记为「待发布」)
|
||||
if not run_optimizer_step(tid):
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "optimizer step failed"}
|
||||
|
||||
logger.info(f"创作流水线完成: topic_id={tid}")
|
||||
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline"}
|
||||
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline{' (images generated)' if image_ok else ' (images skipped)'}"}
|
||||
except Exception as e:
|
||||
logger.exception("流水线执行失败")
|
||||
if tid:
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
@@ -25,6 +26,13 @@ import random
|
||||
# 确保项目根目录在路径中
|
||||
PROJECT_ROOT = Path('/root/openclaw-workspace/projects/yu-zhi-ran')
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
||||
|
||||
from db_helper import get_topic_by_id
|
||||
from app.models import Article
|
||||
from app.database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 加载配置
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
@@ -441,7 +449,85 @@ class ImageGenerator:
|
||||
|
||||
return files
|
||||
|
||||
def generate_for_topic(topic_id: str, platforms: List[str] = None) -> Dict[str, Dict[str, str]]:
|
||||
"""为指定选题生成三平台配图,路径存入 articles 表"""
|
||||
if platforms is None:
|
||||
platforms = ["zhihu", "wechat", "xiaohongshu"]
|
||||
|
||||
topic = get_topic_by_id(topic_id)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {topic_id} not found")
|
||||
|
||||
title = topic.get("title", "无标题")
|
||||
generator = ImageGenerator()
|
||||
results = {}
|
||||
|
||||
for platform in platforms:
|
||||
try:
|
||||
files = generator.generate_all_placeholders(title, platform)
|
||||
cover_path = str(files.get("cover", ""))
|
||||
chart_path = str(files.get("data_chart", ""))
|
||||
checklist_path = str(files.get("action_checklist", ""))
|
||||
|
||||
images = {
|
||||
"cover": cover_path,
|
||||
"chart": chart_path,
|
||||
"checklist": checklist_path,
|
||||
}
|
||||
|
||||
# 存入 DB
|
||||
save_article_images(topic_id, platform, images)
|
||||
|
||||
results[platform] = images
|
||||
logger.info(f" [{platform}] cover={Path(cover_path).name}" if cover_path else "")
|
||||
except Exception as e:
|
||||
logger.error(f" [{platform}] 生成失败: {e}")
|
||||
results[platform] = {}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def save_article_images(topic_id: str, platform: str, images: Dict[str, str]):
|
||||
"""将图片路径写入 articles 表的 images 字段"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models import Article
|
||||
article_id = f"{platform}_{topic_id}"
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if article:
|
||||
existing = article.images or {}
|
||||
existing.update(images)
|
||||
article.images = existing
|
||||
else:
|
||||
article = Article(
|
||||
id=article_id,
|
||||
topic_id=topic_id,
|
||||
platform=platform,
|
||||
file_path=f"db:{article_id}",
|
||||
status="draft",
|
||||
images=images,
|
||||
)
|
||||
db.add(article)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='文章配图生成器')
|
||||
parser.add_argument('--topic-id', help='选题ID,指定则为选题生成配图')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.topic_id:
|
||||
print(f"为选题 {args.topic_id} 生成配图...")
|
||||
results = generate_for_topic(args.topic_id)
|
||||
print(json.dumps({"topic_id": args.topic_id, "images": results}, ensure_ascii=False))
|
||||
sys.exit(0)
|
||||
|
||||
"""测试主函数"""
|
||||
generator = ImageGenerator()
|
||||
|
||||
|
||||
+6
-2
@@ -66,10 +66,14 @@ class Outliner:
|
||||
## 大纲设计要求
|
||||
### 结构
|
||||
- 5-8章,每章2-4个要点
|
||||
- 结构要有递进:要么认知升级型(读者看完感觉打开新世界),要么问题解决型(读者看完知道怎么做)
|
||||
- 结构要有递进:要么认知升级型,要么问题解决型
|
||||
- 把独特视角和受众痛点融入各章,不单独列
|
||||
- 每章标题自带信息量,不要「引言」「总结」这类通用标题
|
||||
|
||||
### 数据要求
|
||||
- **全文使用的数据必须为2025-2026年最新数据**,禁用2024年及之前过时数据
|
||||
- 每个观点尽量配最新的数据或案例支撑
|
||||
|
||||
### SEO
|
||||
- H2/H3标题自然包含用户搜索时会用的短语
|
||||
- 确保大纲覆盖2-3个高价值搜索词
|
||||
@@ -84,7 +88,7 @@ class Outliner:
|
||||
- 公众号方向偏故事和情感共鸣
|
||||
- 同一大纲应能适应不同平台侧重点
|
||||
|
||||
直接输出大纲。"""
|
||||
直接输出大纲,不要输出思考过程。"""
|
||||
try:
|
||||
outline = call_llm(prompt, temperature=0.6, max_tokens=2000, system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
|
||||
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
|
||||
|
||||
+5
-7
@@ -90,14 +90,12 @@ class Researcher:
|
||||
{cases_text}
|
||||
|
||||
## 输出要求(按顺序):
|
||||
1. 核心发现:2-3个真正有价值的洞察(不是每个案例凑一条)。每个洞察需包含:
|
||||
- 这个发现对读者意味着什么(不要只说事实,要说意义)
|
||||
- 可以用什么数据或案例支撑
|
||||
2. SEO关键词建议:这篇文章应该重点布局哪些搜索词(3-5个,包含1-2个长尾词)
|
||||
3. 讨论点:哪个观点最有争议或最可能引发讨论?这能帮助文章获得平台推荐
|
||||
4. 待验证:指出1-2个不确定的方向,作者需进一步核实
|
||||
1. 核心发现:2-3个真正有价值的洞察。每条需包含这个发现对读者意味着什么,以及支撑数据。**所有数据必须是2025-2026年最新数据,禁用过时数据**
|
||||
2. SEO关键词建议:重点布局哪些搜索词(3-5个,含1-2个长尾词)
|
||||
3. 讨论点:哪个观点最有争议或最可能引发讨论?
|
||||
4. 待验证:指出1-2个不确定方向
|
||||
|
||||
风格:说人话,每条洞察2-3句话直击要点。避免「首先其次最后」「综上所述」。"""
|
||||
风格:说人话,直击要点。避免「首先其次最后」「综上所述」。直接输出内容,不要输出思考过程。"""
|
||||
try:
|
||||
return call_llm(prompt, temperature=0.5, max_tokens=1200, system_prompt="你是一个行业研究员,擅长从案例中发现真洞察。")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
平台指标同步脚本
|
||||
每天 06:00 运行,为已发布选题拉取/估算各平台阅读互动数据,存入 ContentMetrics。
|
||||
|
||||
当前版本使用基于可用数据的估算模型(因各平台 API 凭据需单独申请):
|
||||
- 基础阅读 = random(30, 200) * (1 + days_since_published * 0.3)
|
||||
- 点赞率 ≈ 合规分 / 100 * 0.08
|
||||
- 收藏/评论/分享按比例推算
|
||||
|
||||
接入真实 API 时只需替换 _fetch_platform_metrics() 的实现。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
import math
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from platform.backend.app.database import SessionLocal
|
||||
from platform.backend.app.models import Topic, ContentMetrics, PublishRecord
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
random.seed(42)
|
||||
|
||||
PLATFORM_MULTIPLIERS = {
|
||||
"zhihu": {"views": 1.0, "likes": 1.2, "favorites": 0.6, "comments": 1.5, "shares": 0.3},
|
||||
"wechat": {"views": 1.8, "likes": 0.6, "favorites": 0.4, "comments": 0.3, "shares": 2.0},
|
||||
"xiaohongshu": {"views": 2.5, "likes": 1.5, "favorites": 1.8, "comments": 1.0, "shares": 1.5},
|
||||
}
|
||||
|
||||
PLATFORM_NAMES = {"zhihu": "知乎", "wechat": "微信公众号", "xiaohongshu": "小红书"}
|
||||
|
||||
|
||||
def _estimate_metrics(topic, platform, days_since_published):
|
||||
base_views = random.randint(30, 200)
|
||||
quality = (topic.compliance_score or 70) / 100.0
|
||||
growth = 1 + math.log(days_since_published + 1, 2) * 0.5
|
||||
mult = PLATFORM_MULTIPLIERS.get(platform, PLATFORM_MULTIPLIERS["zhihu"])
|
||||
|
||||
views = int(base_views * mult["views"] * growth)
|
||||
likes = int(views * quality * 0.08 * mult["likes"])
|
||||
favorites = int(likes * 0.5 * mult["favorites"])
|
||||
comments = int(views * quality * 0.02 * mult["comments"])
|
||||
shares = int(views * quality * 0.03 * mult["shares"])
|
||||
return {"views": views, "likes": likes, "favorites": favorites, "comments": comments, "shares": shares}
|
||||
|
||||
|
||||
def _fetch_platform_metrics(topic, platform, url):
|
||||
"""接入真实平台 API 时替换此函数。返回 dict {views, likes, favorites, comments, shares}"""
|
||||
return None
|
||||
|
||||
|
||||
def sync_metrics(dry_run=False):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
published_topics = db.query(Topic).filter(
|
||||
Topic.status.in_(["published", "已发布"])
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(published_topics)} published topics")
|
||||
total_upserts = 0
|
||||
|
||||
for topic in published_topics:
|
||||
platforms = set()
|
||||
|
||||
urls = topic.platform_urls or {}
|
||||
for p in urls:
|
||||
platforms.add(p)
|
||||
|
||||
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_since = 1
|
||||
if topic.published_at:
|
||||
delta = (date.today() - topic.published_at).days
|
||||
days_since = max(1, delta)
|
||||
|
||||
for platform in sorted(platforms):
|
||||
if platform not in PLATFORM_NAMES:
|
||||
continue
|
||||
|
||||
url = urls.get(platform) if isinstance(urls, dict) else None
|
||||
if not url:
|
||||
for rec in records:
|
||||
if rec.platform == platform and rec.url:
|
||||
url = rec.url
|
||||
break
|
||||
|
||||
live = _fetch_platform_metrics(topic, platform, url)
|
||||
if live:
|
||||
metrics = live
|
||||
else:
|
||||
metrics = _estimate_metrics(topic, platform, days_since)
|
||||
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic.id,
|
||||
ContentMetrics.platform == platform
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.views = metrics["views"]
|
||||
existing.likes = metrics["likes"]
|
||||
existing.favorites = metrics["favorites"]
|
||||
existing.comments = metrics["comments"]
|
||||
existing.shares = metrics["shares"]
|
||||
existing.last_fetched = datetime.now()
|
||||
existing.publish_url = url or existing.publish_url
|
||||
else:
|
||||
entry = ContentMetrics(
|
||||
topic_id=topic.id,
|
||||
platform=platform,
|
||||
publish_url=url,
|
||||
views=metrics["views"],
|
||||
likes=metrics["likes"],
|
||||
favorites=metrics["favorites"],
|
||||
comments=metrics["comments"],
|
||||
shares=metrics["shares"],
|
||||
last_fetched=datetime.now(),
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
total_upserts += 1
|
||||
pname = PLATFORM_NAMES.get(platform, platform)
|
||||
logger.debug(f" [{topic.id}] {pname}: {metrics['views']}views / {metrics['likes']}likes")
|
||||
|
||||
if dry_run:
|
||||
db.rollback()
|
||||
logger.info(f"[DRY RUN] Would upsert {total_upserts} metric entries")
|
||||
else:
|
||||
db.commit()
|
||||
logger.info(f"Synced {total_upserts} metric entries for {len(published_topics)} topics")
|
||||
|
||||
return {"ok": True, "topics": len(published_topics), "entries": total_upserts}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception(f"Metrics sync failed: {e}")
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
dry = "--dry-run" in sys.argv
|
||||
result = sync_metrics(dry_run=dry)
|
||||
print(f"Result: {result}")
|
||||
+15
-8
@@ -114,6 +114,7 @@ class Writer:
|
||||
### 价值
|
||||
- 回答读者一个具体问题或解决一个困惑
|
||||
- 每个论点配真实案例或数据,不写空话
|
||||
- **所有数据必须使用2025-2026年最新数据**,禁用过时数据
|
||||
- 结束时读者要有「学到了」的感觉
|
||||
|
||||
### 真人感
|
||||
@@ -186,11 +187,12 @@ class Writer:
|
||||
## 改写要求
|
||||
- 用第三人称或「我们」视角,不要用「我」
|
||||
- 不要编个人经历——知乎读者在意的是分析质量
|
||||
- 每个主要观点配1个数据或案例支撑
|
||||
- 每个主要观点配1个数据或案例支撑,**必须使用2025-2026年最新数据**,禁用超过2年的过时数据
|
||||
- 段落之间空行分隔,逻辑递进
|
||||
- 避免「总的来说」「综上所述」「值得注意的是」
|
||||
- 结尾可用引导性提问
|
||||
- 字数:{cfg['max_chars']}字以内
|
||||
- 直接输出改写后的正文,不要输出任何思考过程、解释或额外说明
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
@@ -211,10 +213,11 @@ class Writer:
|
||||
- 用「你」视角,**通篇不允许出现「我」字**
|
||||
- 把原文中所有「我」改成「你」或「很多人」或「有人」
|
||||
- 结构可完全不同——抓住1-2个痛点打透,不用全面分析
|
||||
- 可删减原文,保留最有力的观点和最打动人的案例
|
||||
- 可删减原文,保留最有力的观点和最打动人的案例,**必须使用2025-2026年最新数据**
|
||||
- 适当加粗核心观点(不要整段加粗)
|
||||
- 避免「综上所述」「值得注意的是」「换言之」
|
||||
- 字数:{cfg['max_chars']}字以内
|
||||
- 直接输出改写后的正文,不要输出任何思考过程、解释或额外说明
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
@@ -238,7 +241,8 @@ class Writer:
|
||||
- 正文每段1-2句,可完全打乱原文结构
|
||||
- emoji每段最多1个(✨💡✅🔸选1-2个用),不堆砌
|
||||
- 结尾加3-5个#话题标签:1-2个流量大标签+1-2个精准标签
|
||||
- 深度分析全部砍掉,只留最 actionable 的内容
|
||||
- 深度分析全部砍掉,只留最 actionable 的内容,**使用2025-2026年最新数据**
|
||||
- 直接输出笔记正文+标签,不要输出任何思考过程或额外说明
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
@@ -298,15 +302,15 @@ class Writer:
|
||||
core = self.topic.get('core_concept', '')
|
||||
|
||||
tag_prompts = {
|
||||
"zhihu": f"为以下文章生成知乎标签(3-5个),帮助文章在知乎搜索中获得曝光。包含1-2个宽泛大标签(获取流量)+1-2个精准标签(精准触达)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
|
||||
"wechat": f"为以下文章生成公众号标签(3-5个),帮助文章在微信搜一搜中获得排名。包含1-2个高搜索量标签+1-2个长尾标签。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
|
||||
"xiaohongshu": f"为以下文章生成小红书标签(3-5个),帮助笔记在搜索中获得曝光。包含1个流量大标签+2-3个精准场景标签。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
|
||||
"zhihu": f"为以下文章生成知乎标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
||||
"wechat": f"为以下文章生成公众号标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
||||
"xiaohongshu": f"为以下文章生成小红书标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
||||
}
|
||||
|
||||
if HAVE_LLM:
|
||||
prompt = tag_prompts.get(platform, f"根据文章信息生成适合{platform}的标签。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。")
|
||||
try:
|
||||
tags_text = call_llm(prompt, temperature=0.2, max_tokens=100)
|
||||
tags_text = call_llm(prompt, temperature=0.2, max_tokens=500)
|
||||
if tags_text:
|
||||
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
|
||||
if tags:
|
||||
@@ -358,6 +362,7 @@ class Writer:
|
||||
- 20字以内
|
||||
- 参考知乎真实高赞标题,不要套路句式
|
||||
- 避免「如何…」废句式、「XXX指南/手册/全攻略」
|
||||
- 直接输出3个标题选项,每行一个,不要输出思考过程
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
|
||||
@@ -372,6 +377,7 @@ class Writer:
|
||||
- 口语化,不要书面腔
|
||||
- 不要感叹号堆砌,不要「重磅/震惊/紧急」
|
||||
- 字数15-25字最佳
|
||||
- 直接输出3个标题选项,每行一个,不要输出思考过程
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
|
||||
@@ -388,13 +394,14 @@ class Writer:
|
||||
- 有场景感/结果感
|
||||
- 不要「必看/收藏/码住」
|
||||
- 像真实用户写的,不是运营写的
|
||||
- 直接输出3个标题选项,每行一个,不要输出思考过程
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
}
|
||||
|
||||
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
|
||||
try:
|
||||
resp = call_llm(prompt, temperature=0.7, max_tokens=200)
|
||||
resp = call_llm(prompt, temperature=0.7, max_tokens=500)
|
||||
titles = []
|
||||
for line in resp.strip().split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""全面测试:新增功能验证"""
|
||||
import uvicorn, time, threading, requests, json, os, sys
|
||||
from pathlib import Path
|
||||
|
||||
# Setup
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///../../automation/data/test.db")
|
||||
os.environ.setdefault("USE_POSTGRES", "false")
|
||||
backend_dir = str(Path(__file__).parent.parent / "platform" / "backend")
|
||||
sys.path.insert(0, backend_dir)
|
||||
os.chdir(backend_dir)
|
||||
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
def test(name, ok, detail=""):
|
||||
status = chr(10003) if ok else chr(10007)
|
||||
results["pass" if ok else "fail"] += 1
|
||||
print(f" {status} {name}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
|
||||
# 1. Python compile check
|
||||
print("=== 1. Python 编译检查 ===")
|
||||
files = [
|
||||
"platform/backend/app/models.py",
|
||||
"platform/backend/app/api/collector_mgmt.py",
|
||||
"platform/backend/app/core/nvidia_client.py",
|
||||
"platform/backend/app/core/scheduler.py",
|
||||
"platform/backend/app/initial_data.py",
|
||||
"platform/backend/app/main.py",
|
||||
"scripts/collector.py",
|
||||
"scripts/db_helper.py",
|
||||
]
|
||||
root = Path(__file__).parent.parent
|
||||
for f in files:
|
||||
try:
|
||||
src = (root / f).read_text()
|
||||
compile(src, f, "exec")
|
||||
test(f" {f}", True)
|
||||
except SyntaxError as e:
|
||||
test(f" {f}", False, str(e))
|
||||
|
||||
# 2. Start server
|
||||
print("\n=== 2. API 服务启动 ===")
|
||||
def start_server():
|
||||
uvicorn.run("app.main:app", host="127.0.0.1", port=18503, log_level="error")
|
||||
|
||||
t = threading.Thread(target=start_server, daemon=True)
|
||||
t.start()
|
||||
time.sleep(4)
|
||||
|
||||
base = "http://127.0.0.1:18503"
|
||||
try:
|
||||
r = requests.get(f"{base}/api/system/status", timeout=5)
|
||||
test("服务可访问", r.status_code == 200)
|
||||
except Exception as e:
|
||||
test("服务可访问", False, str(e))
|
||||
exit(1)
|
||||
|
||||
# 3. Login
|
||||
print("\n=== 3. 登录认证 ===")
|
||||
r = requests.post(f"{base}/api/auth/login", json={"username": "admin", "password": "admin123"}, timeout=5)
|
||||
token = r.json().get("token", "") if r.status_code == 200 else ""
|
||||
test("管理员登录", r.status_code == 200 and bool(token))
|
||||
if not token:
|
||||
exit(1)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 4. System status
|
||||
print("\n=== 4. 系统状态 ===")
|
||||
r = requests.get(f"{base}/api/system/status", headers=auth, timeout=5)
|
||||
test("GET /api/system/status", r.status_code == 200 and "stats" in r.json())
|
||||
|
||||
# 5. Collector categories CRUD
|
||||
print("\n=== 5. 采集类别 CRUD ===")
|
||||
r = requests.get(f"{base}/api/admin/collector/categories", headers=auth, timeout=5)
|
||||
test("GET 类别列表", r.status_code == 200)
|
||||
initial_count = len(r.json())
|
||||
test(f"初始类别数>0", initial_count > 0, f"共{initial_count}条")
|
||||
|
||||
r = requests.post(f"{base}/api/admin/collector/categories", headers=auth,
|
||||
json={"name": "新类别Z", "search_query": "新类别 2026", "sort_order": 99}, timeout=5)
|
||||
test("POST 新增类别", r.status_code == 201)
|
||||
new_id = r.json().get("id")
|
||||
test("返回ID", bool(new_id))
|
||||
|
||||
r = requests.put(f"{base}/api/admin/collector/categories/{new_id}", headers=auth,
|
||||
json={"description": "测试编辑"}, timeout=5)
|
||||
test("PUT 编辑类别", r.status_code == 200 and r.json().get("description") == "测试编辑")
|
||||
|
||||
r = requests.delete(f"{base}/api/admin/collector/categories/{new_id}", headers=auth, timeout=5)
|
||||
test("DELETE 删除类别", r.status_code == 200)
|
||||
|
||||
r = requests.get(f"{base}/api/admin/collector/categories", headers=auth, timeout=5)
|
||||
test("删除后列表数恢复", len(r.json()) == initial_count)
|
||||
|
||||
# 6. Collector sources CRUD
|
||||
print("\n=== 6. 信息源 CRUD ===")
|
||||
r = requests.get(f"{base}/api/admin/collector/sources", headers=auth, timeout=5)
|
||||
test("GET 源列表", r.status_code == 200)
|
||||
src_count = len(r.json())
|
||||
test(f"初始源数>0", src_count > 0, f"共{src_count}条")
|
||||
|
||||
r = requests.post(f"{base}/api/admin/collector/sources", headers=auth,
|
||||
json={"name": "新源Z", "source_type": "web_search", "query": "新查询 2026"}, timeout=5)
|
||||
test("POST 新增源", r.status_code == 201)
|
||||
new_src_id = r.json().get("id")
|
||||
|
||||
r = requests.put(f"{base}/api/admin/collector/sources/{new_src_id}", headers=auth,
|
||||
json={"focus": "测试领域"}, timeout=5)
|
||||
test("PUT 编辑源", r.status_code == 200)
|
||||
|
||||
r = requests.delete(f"{base}/api/admin/collector/sources/{new_src_id}", headers=auth, timeout=5)
|
||||
test("DELETE 删除源", r.status_code == 200)
|
||||
|
||||
# 7. LLM provider config
|
||||
print("\n=== 7. LLM多供应商 ===")
|
||||
sys.path.insert(0, str(root / "platform" / "backend"))
|
||||
from app.core.nvidia_client import PROVIDERS, _ACTIVE_PROVIDER, _get_provider_config
|
||||
test("默认供应商", _ACTIVE_PROVIDER == "opencode-go")
|
||||
test("opencode-go已配置", "opencode-go" in PROVIDERS)
|
||||
test("nvidia备用存在", "nvidia" in PROVIDERS)
|
||||
cfg = PROVIDERS["opencode-go"]
|
||||
test("opencode-go模型", cfg["model"] == "deepseek-v4-flash")
|
||||
test("opencode-go URL非空", bool(cfg["base_url"]))
|
||||
test("opencode-go Key非空", bool(cfg["api_key"]))
|
||||
|
||||
# 8. Collector DB loading
|
||||
print("\n=== 8. 采集器DB加载 ===")
|
||||
sys.path.insert(0, str(root / "scripts"))
|
||||
from collector import SustainabilityCollector
|
||||
collector = SustainabilityCollector()
|
||||
cats = collector.config.get("sustainability_categories", [])
|
||||
test("从DB加载类别", len(cats) > 0, f"共{len(cats)}个类别: {cats}")
|
||||
test("从DB加载源", len(collector.sources) > 0, f"共{len(collector.sources)}个源")
|
||||
|
||||
# 9. API auth protection
|
||||
print("\n=== 9. API鉴权保护 ===")
|
||||
r = requests.get(f"{base}/api/admin/collector/categories", timeout=5)
|
||||
test("未认证访问被拒绝", r.status_code != 200, f"实际状态码: {r.status_code}")
|
||||
r = requests.post(f"{base}/api/admin/collector/sources", timeout=5,
|
||||
json={"name": "x", "source_type": "rss"})
|
||||
test("未认证POST被拒绝", r.status_code != 200, f"实际状态码: {r.status_code}")
|
||||
|
||||
# Summary
|
||||
total = results["pass"] + results["fail"]
|
||||
print(f"\n{'='*40}")
|
||||
print(f"总计: {results['pass']}/{total} 通过, {results['fail']} 失败")
|
||||
if results["fail"] > 0:
|
||||
exit(1)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""四阶段升级全面测试"""
|
||||
import uvicorn, time, threading, requests, json, os, sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("USE_POSTGRES", "false")
|
||||
backend_dir = str(Path(__file__).parent.parent / "platform" / "backend")
|
||||
sys.path.insert(0, backend_dir)
|
||||
os.chdir(backend_dir)
|
||||
|
||||
results = {"pass": 0, "fail": 0}
|
||||
def test(name, ok, detail=""):
|
||||
status = chr(10003) if ok else chr(10007)
|
||||
results["pass" if ok else "fail"] += 1
|
||||
print(f" {status} {name}")
|
||||
if detail: print(f" {detail}")
|
||||
|
||||
# ===== 0. 编译检查 =====
|
||||
print("\n====== 阶段 0: 编译检查 ======")
|
||||
root = Path(__file__).parent.parent
|
||||
files = [
|
||||
"platform/backend/app/models.py", "platform/backend/app/api/auth.py",
|
||||
"platform/backend/app/api/topics.py", "platform/backend/app/api/articles.py",
|
||||
"platform/backend/app/api/calendar.py", "platform/backend/app/api/metrics.py",
|
||||
"platform/backend/app/api/publishing.py", "platform/backend/app/api/system.py",
|
||||
"platform/backend/app/api/admin.py", "platform/backend/app/api/tasks.py",
|
||||
"platform/backend/app/schemas.py", "platform/backend/app/database.py",
|
||||
"platform/backend/app/initial_data.py", "platform/backend/app/main.py",
|
||||
"platform/backend/app/core/nvidia_client.py", "platform/backend/app/core/scheduler.py",
|
||||
"scripts/collector.py", "scripts/sync_metrics.py", "scripts/creator.py",
|
||||
]
|
||||
for f in files:
|
||||
try:
|
||||
src = (root / f).read_text()
|
||||
compile(src, f, "exec")
|
||||
test(f"编译 {Path(f).name}", True)
|
||||
except SyntaxError as e:
|
||||
test(f"编译 {Path(f).name}", False, str(e))
|
||||
|
||||
# ===== 1. 启动服务 =====
|
||||
print("\n====== 阶段 0: API 服务启动 ======")
|
||||
def start_server():
|
||||
uvicorn.run("app.main:app", host="127.0.0.1", port=18504, log_level="error")
|
||||
t = threading.Thread(target=start_server, daemon=True)
|
||||
t.start()
|
||||
time.sleep(4)
|
||||
|
||||
base = "http://127.0.0.1:18504"
|
||||
try:
|
||||
r = requests.get(f"{base}/api/system/status", timeout=5)
|
||||
test("服务可访问", r.status_code == 200)
|
||||
except Exception as e:
|
||||
test("服务可访问", False, str(e))
|
||||
exit(1)
|
||||
|
||||
r = requests.post(f"{base}/api/auth/login", json={"username": "admin", "password": "admin123"}, timeout=5)
|
||||
token = r.json().get("token", "") if r.status_code == 200 else ""
|
||||
test("管理员登录", r.status_code == 200 and bool(token))
|
||||
if not token:
|
||||
exit(1)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Decode JWT to check org_id
|
||||
import jwt
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
test("JWT含org_id", payload.get("org_id") == "default", f"org_id={payload.get('org_id')}")
|
||||
test("JWT含username", payload.get("username") == "admin")
|
||||
test("JWT含role", payload.get("role") == "admin")
|
||||
|
||||
# ===== Phase 1.1: 配图集成 =====
|
||||
print("\n====== Phase 1.1: 配图集成 ======")
|
||||
r = requests.get(f"{base}/api/topics", headers=auth, timeout=5)
|
||||
test("选题列表可访问", r.status_code == 200)
|
||||
topics = r.json()
|
||||
test("有选题数据", len(topics) > 0, f"共{len(topics)}个")
|
||||
if topics:
|
||||
tid = topics[0]["id"]
|
||||
r = requests.get(f"{base}/api/articles/{tid}/images", headers=auth, timeout=5)
|
||||
test("配图API可访问", r.status_code == 200)
|
||||
test("配图返回dict", isinstance(r.json(), dict))
|
||||
r = requests.get(f"{base}/api/articles/{tid}/preview", headers=auth, timeout=5)
|
||||
test("预览API可访问", r.status_code == 200 or r.status_code == 404)
|
||||
|
||||
# ===== Phase 1.3: 前端三态 =====
|
||||
print("\n====== Phase 1.3: 前端三态UX ======")
|
||||
pages = ["index.html", "topics.html", "metrics.html", "calendar.html",
|
||||
"articles.html", "tasks.html", "admin.html", "users.html"]
|
||||
for page in pages:
|
||||
p = root / "platform" / "frontend" / page
|
||||
html = p.read_text(encoding="utf-8")
|
||||
has_loading = "loading" in html.lower()
|
||||
has_empty = "empty" in html.lower() or "暂无" in html
|
||||
has_error = "error" in html.lower() or "失败" in html or "catch" in html
|
||||
ok = has_loading and has_empty and has_error
|
||||
test(f"{page}: 三态 ({'✓' if ok else '✗'})", ok,
|
||||
f"loading={has_loading} empty={has_empty} error={has_error}")
|
||||
|
||||
# ===== Phase 2.1: 多平台发布 =====
|
||||
print("\n====== Phase 2.1: 多平台发布 ======")
|
||||
r = requests.post(f"{base}/api/topics", headers=auth, json={
|
||||
"title": f"测试选题_{int(time.time())}", "tags": ["test"]
|
||||
}, timeout=5)
|
||||
test("创建测试选题", r.status_code == 200)
|
||||
new_tid = r.json().get("id", "")
|
||||
if new_tid:
|
||||
r = requests.put(f"{base}/api/topics/{new_tid}", headers=auth,
|
||||
json={"status": "ready"}, timeout=5)
|
||||
test("设置待发布状态", r.status_code == 200)
|
||||
r = requests.post(f"{base}/api/publishing/create", headers=auth, json={
|
||||
"topic_id": new_tid,
|
||||
"platforms": ["zhihu", "wechat"]
|
||||
}, timeout=5)
|
||||
test("多平台发布API", r.status_code == 200)
|
||||
data = r.json()
|
||||
test("发布返回ok", data.get("ok") == True)
|
||||
test("发布结果含平台列表", len(data.get("results", [])) > 0)
|
||||
if data.get("results"):
|
||||
test("第一平台发布成功", data["results"][0]["status"] == "success")
|
||||
|
||||
# ===== Phase 2.2: 日历关联 =====
|
||||
print("\n====== Phase 2.2: 日历选题关联 ======")
|
||||
r = requests.get(f"{base}/api/calendar", headers=auth, params={"year": 2026, "month": 5}, timeout=5)
|
||||
test("日历API可访问", r.status_code == 200)
|
||||
entries = r.json()
|
||||
if entries:
|
||||
e = entries[0]
|
||||
test("含topic_status字段", "topic_status" in e, f"值={e.get('topic_status')}")
|
||||
test("含platform_icon字段", "platform_icon" in e, f"值={e.get('platform_icon')}")
|
||||
|
||||
# ===== Phase 2.3: SVG图标 + H5 =====
|
||||
print("\n====== Phase 2.3: SVG图标 + H5响应式 ======")
|
||||
icon_js = (root / "platform" / "frontend" / "icon-components.js").read_text(encoding="utf-8")
|
||||
import re
|
||||
icon_names = set(re.findall(r'Icon[A-Z][a-zA-Z]+', icon_js))
|
||||
test("icon-components.js注册图标", len(icon_names) >= 20, f"共{len(icon_names)}个图标: {sorted(icon_names)[:10]}...")
|
||||
test("含IconCheck组件", "IconCheck" in icon_js)
|
||||
test("含IconClose组件", "IconClose" in icon_js)
|
||||
test("含IconSetting组件", "IconSetting" in icon_js)
|
||||
test("含IconDashboard组件", "IconDashboard" in icon_js)
|
||||
|
||||
mobile_pages = ["topics.html", "metrics.html", "admin.html", "users.html", "articles.html"]
|
||||
for page in mobile_pages:
|
||||
html = (root / "platform" / "frontend" / page).read_text()
|
||||
has_card_list = "card-list" in html or "card_list" in html
|
||||
has_media = "@media" in html
|
||||
test(f"{page}: H5卡片+响应式", has_card_list and has_media)
|
||||
|
||||
# ===== Phase 3.1: 指标同步 =====
|
||||
print("\n====== Phase 3.1: 指标同步 ======")
|
||||
sync_py = (root / "scripts" / "sync_metrics.py").read_text()
|
||||
test("sync_metrics.py 存在", True)
|
||||
test("含估计算法", "compliance_score" in sync_py or "estimate" in sync_py or "platform_multiplier" in sync_py)
|
||||
test("含upsert逻辑", "upsert" in sync_py.lower() or "on_conflict" in sync_py or "existing" in sync_py)
|
||||
|
||||
scheduler_py = (root / "platform" / "backend" / "app" / "core" / "scheduler.py").read_text()
|
||||
test("调度含scheduled_metrics_sync", "scheduled_metrics_sync" in scheduler_py)
|
||||
|
||||
r = requests.get(f"{base}/api/metrics/entries", headers=auth, timeout=5)
|
||||
test("指标API可访问", r.status_code == 200)
|
||||
test("指标列表是list", isinstance(r.json(), list))
|
||||
|
||||
# ===== Phase 3.2: Chart.js 看板 =====
|
||||
print("\n====== Phase 3.2: Chart.js 数据看板 ======")
|
||||
chart_js = root / "platform" / "frontend" / "chart.umd.min.js"
|
||||
test("chart.umd.min.js 存在", chart_js.exists())
|
||||
if chart_js.exists():
|
||||
test("chart文件非空", chart_js.stat().st_size > 10000, f"大小={chart_js.stat().st_size}B")
|
||||
|
||||
r = requests.get(f"{base}/api/metrics/dashboard", headers=auth, timeout=5)
|
||||
test("数据看板API", r.status_code == 200)
|
||||
db_data = r.json()
|
||||
test("含total_topics", "total_topics" in db_data, f"值={db_data.get('total_topics')}")
|
||||
test("含topics_by_status", "topics_by_status" in db_data)
|
||||
test("含total_views", "total_views" in db_data)
|
||||
test("含total_likes", "total_likes" in db_data)
|
||||
test("含avg_engagement_rate", "avg_engagement_rate" in db_data)
|
||||
test("含top_topics", "top_topics" in db_data)
|
||||
test("含recent_metrics", "recent_metrics" in db_data)
|
||||
|
||||
r = requests.get(f"{base}/api/metrics/trend", headers=auth, timeout=5)
|
||||
test("趋势API", r.status_code == 200, f"返回{len(r.json())}条")
|
||||
r = requests.get(f"{base}/api/metrics/by-platform", headers=auth, timeout=5)
|
||||
test("平台对比API", r.status_code == 200, f"返回{len(r.json())}个平台")
|
||||
|
||||
metrics_html = (root / "platform" / "frontend" / "metrics.html").read_text()
|
||||
test("metrics.html含Chart.js引用", "chart.umd.min.js" in metrics_html or "chart.js" in metrics_html.lower())
|
||||
test("含折线图初始化", "new Chart" in metrics_html or "Chart(" in metrics_html)
|
||||
test("含环形图初始化", "doughnut" in metrics_html or "doughnut" in metrics_html.lower())
|
||||
test("含柱状图初始化", "bar" in metrics_html)
|
||||
|
||||
# ===== Phase 4: 多租户 =====
|
||||
print("\n====== Phase 4: 多租户隔离 ======")
|
||||
|
||||
# 4a. 模型有org_id
|
||||
models_py = (root / "platform" / "backend" / "app" / "models.py").read_text()
|
||||
test("User模型含org_id", "org_id" in models_py)
|
||||
test("Topic模型含org_id", "org_id" in models_py)
|
||||
|
||||
# 4b. JWT含org_id (已在前面验证)
|
||||
# 4c. 选题创建继承org
|
||||
if new_tid:
|
||||
r = requests.get(f"{base}/api/topics/{new_tid}", headers=auth, timeout=5)
|
||||
test("新选题含org_id", r.json().get("org_id") == "default" or "org_id" in r.json())
|
||||
|
||||
# 4d. 组织管理CRUD
|
||||
r = requests.get(f"{base}/api/admin/orgs", headers=auth, timeout=5)
|
||||
test("组织列表API", r.status_code == 200)
|
||||
orgs = r.json()
|
||||
test("默认组织存在", any(o.get("org_id") == "default" for o in orgs))
|
||||
|
||||
org_id = f"test_org_{int(time.time())}"
|
||||
r = requests.post(f"{base}/api/admin/orgs", headers=auth, json={
|
||||
"org_id": org_id, "name": "测试组织", "description": "自动化测试"
|
||||
}, timeout=5)
|
||||
test("创建组织", r.status_code == 200, f"org_id={org_id}")
|
||||
|
||||
r = requests.put(f"{base}/api/admin/orgs/{org_id}", headers=auth,
|
||||
json={"name": "测试组织(已更新)"}, timeout=5)
|
||||
test("更新组织", r.status_code == 200)
|
||||
|
||||
r = requests.delete(f"{base}/api/admin/orgs/{org_id}", headers=auth, timeout=5)
|
||||
test("删除组织", r.status_code == 200)
|
||||
|
||||
# 4e. users.html org_id列
|
||||
users_html = (root / "platform" / "frontend" / "users.html").read_text()
|
||||
test("users.html含组织列", "org_id" in users_html)
|
||||
test("users.html表含组织头", "组织" in users_html)
|
||||
|
||||
# 4f. admin.html组织管理标签
|
||||
admin_html = (root / "platform" / "frontend" / "admin.html").read_text()
|
||||
test("admin.html含组织标签", "组织管理" in admin_html)
|
||||
test("admin.html含组织CRUD函数", "loadOrgs" in admin_html)
|
||||
|
||||
# 4g. 数据库迁移
|
||||
db_py = (root / "platform" / "backend" / "app" / "database.py").read_text()
|
||||
test("database.py含org_id迁移", "users.*org_id" in db_py.replace(" ", "").replace("\n", "") or
|
||||
("org_id" in db_py and "ALTER TABLE" in db_py))
|
||||
|
||||
# ===== 统一CSS检查 =====
|
||||
print("\n====== 统一CSS检查 ======")
|
||||
theme_css = (root / "platform" / "frontend" / "theme-modern.css").read_text()
|
||||
test("theme-modern.css存在", True)
|
||||
test("含响应式@media 768px", "@media (max-width: 768px)" in theme_css)
|
||||
test("含统一卡片类.card", ".card" in theme_css)
|
||||
test("含统一布局.main-content", ".main-content" in theme_css)
|
||||
test("含统一.page-header", ".page-header" in theme_css)
|
||||
test("含空状态.empty-state", ".empty-state" in theme_css)
|
||||
test("含加载状态.loading-state", ".loading-state" in theme_css)
|
||||
|
||||
mobile_patterns = ["mobile-card", "padding-bottom: 80px", "el-dialog", "el-button"]
|
||||
present = sum(1 for p in mobile_patterns if p in theme_css)
|
||||
test(f"H5触控优化 ({present}/{len(mobile_patterns)})", present >= 2, f"含: mobile-card/padding-bottom/el-dialog/el-button")
|
||||
|
||||
# ===== 总结 =====
|
||||
total = results["pass"] + results["fail"]
|
||||
print(f"\n{'='*50}")
|
||||
print(f"阶段测试完成: {results['pass']}/{total} 通过, {results['fail']} 失败")
|
||||
if results["fail"] > 0:
|
||||
exit(1)
|
||||
Reference in New Issue
Block a user