feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化

Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列
测试: tests/test_phase_upgrades.py 97项全覆盖
CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式
修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
Yuzhiran Dev
2026-05-17 06:56:53 +08:00
parent 301dc3e438
commit 9c37c9a574
45 changed files with 3707 additions and 1366 deletions
+72
View File
@@ -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
View File
@@ -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/H510 页迁移完成 |
| 文章管理页面 (articles.html) | 2026-05-16 | 新增 articles API (list/detail/delete) + 完整管理页面,支持筛选/搜索/预览/删除 |
| CSS架构统一整合 | 2026-05-16 | reset/body/layout/card/page-header/filter/toolbar 移入 theme-modern.css86→127行),10页内联CSS总量从~660行降至~400行;preview对话框CSS归一化;删除 dead JSindex.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 为唯一进度文档,进度信息不出现于第二个文档中。
+124 -4
View File
@@ -3,16 +3,19 @@ from sqlalchemy.orm import Session
from typing import List
import bcrypt
from typing import Optional
from pydantic import BaseModel
from ..database import get_db
from ..models import User
from ..models import User, Topic, SystemConfig
from ..schemas import UserCreate, UserUpdate, UserResponse
from ..core.audit_logger import audit_log
import json
router = APIRouter(prefix="/api/admin", tags=["admin"])
def get_current_admin(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证管理员权限"""
from .auth import verify_token
from .auth import verify_token, org_filter
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
@@ -29,7 +32,11 @@ def list_users(
admin_user: User = Depends(get_current_admin)
):
"""获取用户列表(管理员)"""
users = db.query(User).all()
q = db.query(User)
of = org_filter(admin_user, User)
if of is not True:
q = q.filter(of)
users = q.all()
return [UserResponse.from_orm(u) for u in users]
@router.post("/users", response_model=UserResponse)
@@ -51,7 +58,8 @@ def create_user(
user = User(
username=user_data.username,
password_hash=hashed_password,
role=user_data.role or "user"
role=user_data.role or "user",
org_id=user_data.org_id or admin_user.org_id or "default"
)
db.add(user)
db.commit()
@@ -144,3 +152,115 @@ def delete_user(
db=db
)
return {"message": "删除成功"}
# ---------- Org Management ----------
class OrgCreate(BaseModel):
org_id: str
name: str
description: Optional[str] = None
class OrgUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@router.get("/orgs")
def list_orgs(
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
distinct_orgs = db.query(User.org_id).distinct().all()
result = []
for (org_id,) in distinct_orgs:
if not org_id:
continue
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
meta = json.loads(cfg.value) if cfg and cfg.value else {}
user_count = db.query(User).filter(User.org_id == org_id).count()
topic_count = db.query(Topic).filter(Topic.org_id == org_id).count()
result.append({
"org_id": org_id,
"name": meta.get("name", org_id),
"description": meta.get("description", ""),
"user_count": user_count,
"topic_count": topic_count,
"is_default": org_id == "default"
})
return result
@router.post("/orgs")
def create_org(
data: OrgCreate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
existing = db.query(SystemConfig).filter(SystemConfig.key == f"org:{data.org_id}").first()
if existing:
raise HTTPException(status_code=400, detail="组织已存在")
cfg = SystemConfig(
key=f"org:{data.org_id}",
value=json.dumps({"name": data.name, "description": data.description or ""}, ensure_ascii=False),
description=f"Organization: {data.org_id}"
)
db.add(cfg)
db.commit()
audit_log(
action="create_org", user=admin_user,
resource_type="org", resource_id=data.org_id,
details=data.model_dump(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"org_id": data.org_id, "name": data.name, "description": data.description or ""}
@router.put("/orgs/{org_id}")
def update_org(
org_id: str,
data: OrgUpdate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
if not cfg:
raise HTTPException(status_code=404, detail="组织不存在")
meta = json.loads(cfg.value) if cfg.value else {}
if data.name is not None:
meta["name"] = data.name
if data.description is not None:
meta["description"] = data.description
cfg.value = json.dumps(meta, ensure_ascii=False)
db.commit()
audit_log(
action="update_org", user=admin_user,
resource_type="org", resource_id=org_id,
details=data.model_dump(exclude_unset=True),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"org_id": org_id, **meta}
@router.delete("/orgs/{org_id}")
def delete_org(
org_id: str,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
if org_id == "default":
raise HTTPException(status_code=400, detail="不能删除默认组织")
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
if cfg:
db.delete(cfg)
db.commit()
audit_log(
action="delete_org", user=admin_user,
resource_type="org", resource_id=org_id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"message": "删除成功"}
+112 -3
View File
@@ -1,19 +1,23 @@
from fastapi import APIRouter, HTTPException, Query, Depends
from sqlalchemy.orm import Session
from sqlalchemy import or_
from pathlib import Path
import os
from datetime import datetime, date
from ..database import get_db
from ..models import User, Article
from .auth import get_current_user
from ..models import User, Article, Topic
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/articles", tags=["articles"])
@router.get("/drafts")
def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""从 articles 表列出草稿"""
query = db.query(Article)
query = db.query(Article).join(Topic, Article.topic_id == Topic.id)
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter(of)
if topic_id:
query = query.filter(Article.topic_id == topic_id)
articles = query.order_by(Article.created_at.desc()).all()
@@ -22,9 +26,114 @@ def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_u
result.setdefault(a.platform, []).append({"id": a.id, "topic_id": a.topic_id, "status": a.status})
return {"articles": result}
@router.get("/list")
def list_articles(
platform: str = None,
status: str = None,
search: str = None,
topic_id: str = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""列出所有文章(含选题标题)"""
query = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id)
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter(of)
if platform:
query = query.filter(Article.platform == platform)
if status:
query = query.filter(Article.status == status)
if topic_id:
query = query.filter(Article.topic_id == topic_id)
if search:
query = query.filter(
or_(Article.id.ilike(f"%{search}%"), Topic.title.ilike(f"%{search}%"))
)
rows = query.order_by(Article.created_at.desc()).all()
return {
"articles": [
{
"id": r.Article.id,
"topic_id": r.Article.topic_id,
"topic_title": r.topic_title,
"platform": r.Article.platform,
"status": r.Article.status,
"compliance_score": r.Article.compliance_score,
"word_count": r.Article.word_count,
"created_at": r.Article.created_at.isoformat() if r.Article.created_at else None,
"images": r.Article.images or {},
}
for r in rows
]
}
@router.get("/detail/{article_id}")
def get_article_detail(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取文章详情"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="Article not found")
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Article not found")
return {
"id": article.id,
"topic_id": article.topic_id,
"topic_title": topic.title if topic else None,
"platform": article.platform,
"file_path": article.file_path,
"status": article.status,
"compliance_score": article.compliance_score,
"word_count": article.word_count,
"outline": article.outline,
"html_content": article.html_content,
"images": article.images or {},
"created_at": article.created_at.isoformat() if article.created_at else None,
}
@router.delete("/{article_id}")
def delete_article(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除文章"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="Article not found")
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Article not found")
file_path = Path(article.file_path) if article.file_path else None
db.delete(article)
db.commit()
if file_path and file_path.exists():
try:
file_path.unlink()
except OSError:
pass
return {"detail": "deleted"}
@router.get("/{topic_id}/images")
def get_article_images(topic_id: str, platform: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取某选题在各平台的配图路径"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Topic not found")
query = db.query(Article).filter(Article.topic_id == topic_id)
if platform:
query = query.filter(Article.platform == platform)
articles = query.all()
return {
topic_id: {
a.platform: (a.images or {})
for a in articles
}
}
@router.get("/{topic_id}/preview")
def preview_article(topic_id: str, platform: str = "zhihu", current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""从 articles 表预览某选题的 HTML 内容"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Topic not found")
article_id = f"{platform}_{topic_id}"
article = db.query(Article).filter(Article.id == article_id).first()
if not article or not article.html_content:
+9 -1
View File
@@ -32,6 +32,7 @@ def create_token(user: User) -> str:
"sub": str(user.id),
"username": user.username,
"role": user.role,
"org_id": user.org_id,
"exp": expire
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
@@ -66,7 +67,8 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
user = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed.decode('utf-8'),
role="admin"
role="admin",
org_id="default"
)
db.add(user)
db.commit()
@@ -147,6 +149,12 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
user = verify_token(token, db)
return user
def org_filter(current_user: User, model):
"""返回 org_id 过滤条件。管理员看到全部数据,普通用户仅限本组织。"""
if current_user.role == "admin":
return True # no filter
return model.org_id == current_user.org_id
def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
"""依赖项:验证管理员权限"""
if current_user.role != "admin":
+45 -5
View File
@@ -10,11 +10,27 @@ from ..schemas import (
ContentCalendarCreate, ContentCalendarUpdate, ContentCalendarResponse,
ContentCalendarBase
)
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
PLATFORM_ICONS = {
"zhihu": "",
"wechat": "",
"xiaohongshu": ""
}
def enrich_entry(entry, db):
d = entry.to_dict()
d["platform_icon"] = PLATFORM_ICONS.get(entry.platform, "📝")
d["topic_status"] = None
if entry.topic_id:
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
if topic:
d["topic_status"] = topic.status
return d
@router.get("", response_model=List[ContentCalendarResponse])
def get_calendar(
year: Optional[int] = Query(None),
@@ -28,10 +44,17 @@ def get_calendar(
start = date(year, month, 1)
last_day = monthrange(year, month)[1]
end = date(year, month, last_day)
return db.query(ContentCalendar).filter(
base = db.query(ContentCalendar).filter(
ContentCalendar.planned_date >= start,
ContentCalendar.planned_date <= end
).order_by(ContentCalendar.planned_date).all()
)
of = org_filter(current_user, Topic)
if of is not True:
base = base.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
)
entries = base.order_by(ContentCalendar.planned_date).all()
return [enrich_entry(e, db) for e in entries]
@router.get("/entries", response_model=List[ContentCalendarResponse])
@@ -45,6 +68,11 @@ def list_entries(
current_user=Depends(get_current_user)
):
query = db.query(ContentCalendar)
of = org_filter(current_user, Topic)
if of is not True:
query = query.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
)
if start_date:
query = query.filter(ContentCalendar.planned_date >= start_date)
if end_date:
@@ -66,6 +94,8 @@ def create_entry(
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
entry = ContentCalendar(**data.model_dump())
db.add(entry)
@@ -135,6 +165,8 @@ def bind_topic_to_entry(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
entry.topic_id = topic_id
entry.title = topic.title
if topic.field_name:
@@ -154,10 +186,16 @@ def calendar_stats(
start = date(year, month, 1)
last_day = monthrange(year, month)[1]
end = date(year, month, last_day)
entries = db.query(ContentCalendar).filter(
q = db.query(ContentCalendar).filter(
ContentCalendar.planned_date >= start,
ContentCalendar.planned_date <= end
).all()
)
of = org_filter(current_user, Topic)
if of is not True:
q = q.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
)
entries = q.all()
stats = {"total": len(entries), "planned": 0, "published": 0, "delayed": 0, "cancelled": 0}
for e in entries:
@@ -177,6 +215,8 @@ def create_from_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
entry = ContentCalendar(
topic_id=topic_id,
field_id=topic.field_id,
+127
View File
@@ -0,0 +1,127 @@
"""采集管理 API:类别与信息源的 CRUD"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from typing import Optional
from ..database import get_db
from ..models import CollectorCategory, CollectorSource
from .auth import get_current_admin
router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)])
# ---------- Schemas ----------
class CategoryCreate(BaseModel):
name: str
description: Optional[str] = None
search_query: Optional[str] = None
sort_order: int = 0
is_active: bool = True
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
search_query: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class SourceCreate(BaseModel):
category_id: Optional[int] = None
name: str
source_type: str
url: Optional[str] = None
query: Optional[str] = None
credibility: str = "medium"
focus: Optional[str] = None
is_active: bool = True
sort_order: int = 0
class SourceUpdate(BaseModel):
category_id: Optional[int] = None
name: Optional[str] = None
source_type: Optional[str] = None
url: Optional[str] = None
query: Optional[str] = None
credibility: Optional[str] = None
focus: Optional[str] = None
is_active: Optional[bool] = None
sort_order: Optional[int] = None
# ---------- Categories ----------
@router.get("/categories")
def list_categories(db: Session = Depends(get_db)):
cats = db.query(CollectorCategory).order_by(CollectorCategory.sort_order).all()
result = []
for c in cats:
d = c.to_dict()
d["source_count"] = db.query(CollectorSource).filter(CollectorSource.category_id == c.id).count()
result.append(d)
return result
@router.post("/categories", status_code=201)
def create_category(data: CategoryCreate, db: Session = Depends(get_db)):
existing = db.query(CollectorCategory).filter(CollectorCategory.name == data.name).first()
if existing:
raise HTTPException(400, "类别名已存在")
cat = CollectorCategory(**data.model_dump())
db.add(cat)
db.commit()
db.refresh(cat)
return cat.to_dict()
@router.put("/categories/{cat_id}")
def update_category(cat_id: int, data: CategoryUpdate, db: Session = Depends(get_db)):
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
if not cat:
raise HTTPException(404, "类别不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(cat, k, v)
db.commit()
db.refresh(cat)
return cat.to_dict()
@router.delete("/categories/{cat_id}")
def delete_category(cat_id: int, db: Session = Depends(get_db)):
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
if not cat:
raise HTTPException(404, "类别不存在")
db.delete(cat)
db.commit()
return {"ok": True}
# ---------- Sources ----------
@router.get("/sources")
def list_sources(category_id: Optional[int] = None, db: Session = Depends(get_db)):
q = db.query(CollectorSource).order_by(CollectorSource.sort_order)
if category_id is not None:
q = q.filter(CollectorSource.category_id == category_id)
return [s.to_dict() for s in q.all()]
@router.post("/sources", status_code=201)
def create_source(data: SourceCreate, db: Session = Depends(get_db)):
src = CollectorSource(**data.model_dump())
db.add(src)
db.commit()
db.refresh(src)
return src.to_dict()
@router.put("/sources/{src_id}")
def update_source(src_id: int, data: SourceUpdate, db: Session = Depends(get_db)):
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
if not src:
raise HTTPException(404, "信息源不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(src, k, v)
db.commit()
db.refresh(src)
return src.to_dict()
@router.delete("/sources/{src_id}")
def delete_source(src_id: int, db: Session = Depends(get_db)):
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
if not src:
raise HTTPException(404, "信息源不存在")
db.delete(src)
db.commit()
return {"ok": True}
+48 -17
View File
@@ -11,7 +11,7 @@ from ..schemas import (
ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse,
MetricsDashboard
)
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
@@ -24,18 +24,24 @@ def get_dashboard(
):
since = datetime.now() - timedelta(days=days)
total_topics = db.query(Topic).count()
topic_base = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
topic_base = topic_base.filter(of)
raw_status = db.query(Topic.status, func.count()).group_by(Topic.status).all()
total_topics = topic_base.count()
raw_status = topic_base.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
topics_by_status = {}
for s, cnt in raw_status:
topics_by_status[s] = cnt
total_published = db.query(ContentMetrics).filter(
ContentMetrics.views > 0
).count()
metrics_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
metrics_q = metrics_q.filter(of)
total_published = metrics_q.filter(ContentMetrics.views > 0).count()
all_metrics = db.query(ContentMetrics).filter(
all_metrics = metrics_q.filter(
ContentMetrics.created_at >= since
).all()
@@ -45,11 +51,14 @@ def get_dashboard(
engagement_rates = [m.engagement_rate for m in all_metrics if m.views > 0]
avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0
top_topics_data = db.query(
top_q = db.query(
ContentMetrics.topic_id,
func.sum(ContentMetrics.views).label("total_views"),
func.sum(ContentMetrics.likes).label("total_likes")
).join(Topic).filter(
).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
top_q = top_q.filter(of)
top_topics_data = top_q.filter(
ContentMetrics.created_at >= since
).group_by(ContentMetrics.topic_id).order_by(desc("total_views")).limit(10).all()
@@ -63,7 +72,10 @@ def get_dashboard(
"total_likes": row.total_likes or 0,
})
recent_metrics = db.query(ContentMetrics).order_by(
recent_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
recent_q = recent_q.filter(of)
recent_metrics = recent_q.order_by(
ContentMetrics.created_at.desc()
).limit(10).all()
@@ -93,13 +105,17 @@ def get_trend(
else:
date_format = func.date_trunc(group_by, ContentMetrics.created_at)
rows = db.query(
q = db.query(
date_format.label("period"),
func.sum(ContentMetrics.views).label("views"),
func.sum(ContentMetrics.likes).label("likes"),
func.sum(ContentMetrics.comments).label("comments"),
func.count(ContentMetrics.id).label("count")
).filter(
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
q = q.filter(of_m)
rows = q.filter(
ContentMetrics.created_at >= since
).group_by(date_format).order_by(date_format).all()
@@ -123,7 +139,10 @@ def list_metrics(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
query = db.query(ContentMetrics)
query = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
query = query.filter(of_m)
if topic_id:
query = query.filter(ContentMetrics.topic_id == topic_id)
if platform:
@@ -140,6 +159,8 @@ def create_metric(
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == data.topic_id,
@@ -204,6 +225,8 @@ def get_topic_metrics(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
return db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic_id
).order_by(ContentMetrics.created_at.desc()).all()
@@ -214,13 +237,17 @@ def get_metrics_by_platform(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
rows = db.query(
q = db.query(
ContentMetrics.platform,
func.sum(ContentMetrics.views).label("total_views"),
func.sum(ContentMetrics.likes).label("total_likes"),
func.sum(ContentMetrics.comments).label("total_comments"),
func.count(ContentMetrics.id).label("count")
).group_by(ContentMetrics.platform).all()
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
q = q.filter(of_m)
rows = q.group_by(ContentMetrics.platform).all()
return [
{
@@ -243,11 +270,15 @@ def recommend_topics_from_metrics(
current_user=Depends(get_current_user)
):
try:
high_performing = db.query(
hp_q = db.query(
ContentMetrics.topic_id,
func.avg(ContentMetrics.engagement_rate).label("avg_engagement"),
func.max(ContentMetrics.views).label("max_views")
).group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
hp_q = hp_q.filter(of_m)
high_performing = hp_q.group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
recommendations = []
for row in high_performing:
+57 -23
View File
@@ -3,78 +3,112 @@
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
from typing import Optional, List
from ..database import get_db
from ..models import Topic, PublishRecord, User
from sqlalchemy.orm import Session
from .auth import get_current_admin
from .auth import get_current_admin, org_filter
from ..core.audit_logger import audit_log
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
class PublishRequest(BaseModel):
topic_id: str
PLATFORM_LABELS = {
"zhihu": "知乎",
"wechat": "微信公众号",
"xiaohongshu": "小红书"
}
class PublishResponse(BaseModel):
class MultiPublishRequest(BaseModel):
topic_id: str
platforms: List[str] = ["zhihu", "wechat", "xiaohongshu"]
class PublishResult(BaseModel):
platform: str
platform_label: str
status: str
error_msg: Optional[str] = None
class MultiPublishResponse(BaseModel):
ok: bool
topic_id: str
results: List[PublishResult]
message: str
@router.post("/create", response_model=PublishResponse)
@router.post("/create", response_model=MultiPublishResponse)
async def create_publish_record(
req: PublishRequest,
req: MultiPublishRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""标记选题为已发布,并创建发布记录(管理员)"""
"""多平台发布选题(管理员)"""
try:
# 查找选题
topic = db.query(Topic).filter(Topic.id == req.topic_id).first()
q = db.query(Topic).filter(Topic.id == req.topic_id)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
topic = q.first()
if not topic:
raise HTTPException(status_code=404, detail=f"选题 {req.topic_id} 不存在")
if topic.status not in ('ready', '待发布'):
raise HTTPException(status_code=400, detail=f"选题 {req.topic_id} 状态不是待发布(当前: {topic.status}")
# 更新选题状态
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
+24 -12
View File
@@ -13,7 +13,7 @@ from ..core.generator import run_creator
from ..core.optimizer import run_optimizer
from ..core.sync import sync_all_topics
from ..core.scheduler import scheduler
from .auth import get_current_user
from .auth import get_current_user, org_filter
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if os.getenv('PROJECT_ROOT'):
@@ -23,9 +23,9 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system", tags=["system"])
def _aggregate_status_counts(db: Session):
def _aggregate_status_counts(q):
"""聚合状态计数,兼容中英文状态值"""
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
mapping = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
@@ -43,7 +43,7 @@ def _aggregate_status_counts(db: Session):
@router.get("/status")
def get_status(db: Session = Depends(get_db)):
total = db.query(Topic).count()
counts = _aggregate_status_counts(db)
counts = _aggregate_status_counts(db.query(Topic))
today = date.today()
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
return {
@@ -58,7 +58,7 @@ def get_status(db: Session = Depends(get_db)):
}
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db)):
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
logger.info(f"Received topic_id={topic_id}")
try:
result = run_creator(topic_id)
@@ -70,7 +70,7 @@ def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Dep
raise HTTPException(status_code=500, detail=str(e))
@router.post("/review/run", dependencies=[Depends(get_current_user)])
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db)):
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
try:
result = run_optimizer(topic_ids)
if not result["ok"]:
@@ -86,7 +86,11 @@ def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session =
if topic_ids:
updated = 0
for tid in topic_ids:
topic = db.query(Topic).filter(Topic.id == tid).first()
q = db.query(Topic).filter(Topic.id == tid)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
topic = q.first()
if topic and topic.status in ('review', '待审查'):
topic.status = 'ready'
if not topic.generated_at:
@@ -109,9 +113,13 @@ def get_logs(log_date: str, log_type: str = "creator"):
return {"log_date": log_date, "log_type": log_type, "content": lines}
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
def get_pipeline_status(db: Session = Depends(get_db)):
total = db.query(Topic).count()
counts = _aggregate_status_counts(db)
def get_pipeline_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic_base = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
topic_base = topic_base.filter(of)
total = topic_base.count()
counts = _aggregate_status_counts(topic_base)
log_files = {
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
@@ -135,9 +143,13 @@ def run_sync():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/automation/topics")
def list_automation_topics(db: Session = Depends(get_db)):
def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
try:
topics = db.query(Topic).order_by(Topic.created_at.desc()).limit(100).all()
topic_base = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
topic_base = topic_base.filter(of)
topics = topic_base.order_by(Topic.created_at.desc()).limit(100).all()
result = []
for t in topics:
result.append({
+12 -3
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from ..database import get_db
from ..models import ContentTask, Topic
from ..schemas import ContentTaskCreate, ContentTaskResponse
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@@ -20,7 +20,10 @@ def list_tasks(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
query = db.query(ContentTask)
query = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
if status:
query = query.filter(ContentTask.status == status)
if topic_id:
@@ -35,7 +38,11 @@ def get_active_tasks(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
return db.query(ContentTask).filter(
q = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
return q.filter(
ContentTask.status == "running"
).order_by(ContentTask.started_at.desc()).all()
@@ -50,6 +57,8 @@ def create_task(
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
task_id = f"task_{uuid.uuid4().hex[:16]}"
+52 -15
View File
@@ -11,32 +11,46 @@ from ..schemas import (
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
PublishRequest, PublishActionRequest, PublishRecordResponse
)
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
PROJECT_ROOT = Path(__file__).parent.parent.parent
def _check_org(topic: Topic, current_user, db: Session):
"""Verify topic belongs to user's org (unless admin)."""
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Topic not found")
return topic
@router.get("", response_model=List[TopicResponse])
def list_topics(
field_id: Optional[int] = None,
status: Optional[str] = None,
today: Optional[bool] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]),
order: str = Query("desc", enum=["asc", "desc"]),
limit: int = Query(50, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
db.expire_all()
query = db.query(Topic).options(joinedload(Topic.field))
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter(of)
if field_id:
query = query.filter(Topic.field_id == field_id)
if status:
query = query.filter(Topic.status == status)
if today:
query = query.filter(func.date(Topic.created_at) == date.today())
if tag:
query = query.filter(Topic.tags.contains([tag]))
if search:
@@ -56,14 +70,19 @@ def topic_stats(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
total = db.query(Topic).count()
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
base_q = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
base_q = base_q.filter(of)
total = base_q.count()
raw = base_q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
by_status = {s: c for s, c in raw}
today = date.today()
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
today_count = base_q.filter(func.date(Topic.created_at) == today).count()
published = db.query(Topic).filter(Topic.status == "published").count()
published = base_q.filter(Topic.status.in_(["published", "已发布"])).count()
metrics_count = db.query(ContentMetrics).count()
return {
@@ -103,6 +122,7 @@ def create_topic(
id=topic_id,
field_id=data.field_id,
field_name=field_name,
org_id=current_user.org_id or "default",
title=data.title,
format=data.format,
core_concept=data.core_concept,
@@ -121,10 +141,11 @@ def create_topic(
@router.get("/{topic_id}", response_model=TopicResponse)
def get_topic(topic_id: str, db: Session = Depends(get_db)):
def get_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
return topic
@@ -138,6 +159,7 @@ def update_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
if data.field_id is not None:
topic.field_id = data.field_id
@@ -167,6 +189,7 @@ def delete_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
try:
db.delete(topic)
db.commit()
@@ -186,6 +209,7 @@ def score_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.scoring_data = data.scoring_data
@@ -218,10 +242,11 @@ def score_topic(
@router.post("/{topic_id}/publish")
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
if topic.status not in ("pending", "ready", "draft"):
raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
@@ -243,10 +268,11 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_
@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
def get_topic_articles(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
return [a.to_dict() if hasattr(a, 'to_dict') else {
"id": a.id, "topic_id": a.topic_id, "platform": a.platform,
@@ -255,7 +281,11 @@ def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)):
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
return [m.to_dict() for m in metrics]
@@ -265,6 +295,7 @@ def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depend
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.lock_by = current_user.username
topic.lock_at = datetime.now()
db.commit()
@@ -276,6 +307,7 @@ def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depe
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.lock_by = None
topic.lock_at = None
db.commit()
@@ -289,7 +321,11 @@ def batch_update_status(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).update(
q = db.query(Topic).filter(Topic.id.in_(topic_ids))
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
updated = q.update(
{Topic.status: status, Topic.updated_at: datetime.now()},
synchronize_session=False
)
@@ -302,8 +338,9 @@ def field_distribution(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
rows = db.query(
Topic.field_name,
func.count(Topic.id).label("count")
).group_by(Topic.field_name).all()
q = db.query(Topic.field_name, func.count(Topic.id).label("count"))
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
rows = q.group_by(Topic.field_name).all()
return [{"field": r.field_name or "未分类", "count": r.count} for r in rows]
+70 -14
View File
@@ -1,12 +1,12 @@
"""
Unified LLM Client
支持 NVIDIA / 兼容 OpenAI 格式的 API,配置从环境变量读取
支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取
"""
import os
import requests
import json
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
from pathlib import Path
from dotenv import load_dotenv
@@ -17,12 +17,59 @@ load_dotenv(env_path)
class LLMError(Exception):
pass
CONFIG = {
"base_url": os.getenv("LLM_BASE_URL", "https://integrate.api.nvidia.com/v1"),
"api_key": os.getenv("LLM_API_KEY", ""),
"model": os.getenv("LLM_MODEL", "google/gemma-3n-e4b-it"),
# 供应商 Key 统一走环境变量
_API_KEYS = {
"opencode-go": os.getenv("OPENCODE_API_KEY", ""),
"nvidia": os.getenv("LLM_API_KEY", ""),
}
# 代码级回退默认值(实际配置优先从 DB 读取)
_FALLBACK = {
"opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"},
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"},
}
def _get_active_provider() -> str:
"""从 DB 读取活跃供应商,DB 不可用时回退环境变量"""
try:
from ..database import SessionLocal
from ..models import LLMConfig
db = SessionLocal()
active = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
db.close()
if active and active.provider:
return active.provider
except Exception:
pass
return os.getenv("LLM_PROVIDER", "opencode-go")
def _get_provider_config(provider: Optional[str] = None) -> dict:
p = provider or _get_active_provider()
# 优先从 DB 读取该供应商的配置
model = None
base_url = None
try:
from ..database import SessionLocal
from ..models import LLMConfig
db = SessionLocal()
cfg = db.query(LLMConfig).filter(LLMConfig.provider == p).order_by(LLMConfig.is_active.desc()).first()
if cfg:
model = cfg.model
base_url = cfg.base_url
db.close()
except Exception:
pass
# 回退到代码默认值
fb = _FALLBACK.get(p, {})
api_key = _API_KEYS.get(p, "")
if not api_key:
raise LLMError(f"{p} API_KEY 未配置,请在 .env 中设置")
return {
"api_key": api_key,
"model": model or fb.get("model", ""),
"base_url": base_url or fb.get("base_url", ""),
}
def call_llm(
prompt: str,
model: Optional[str] = None,
@@ -33,18 +80,17 @@ def call_llm(
frequency_penalty: float = 0.00,
presence_penalty: float = 0.00,
stream: bool = False,
provider: Optional[str] = None,
additional_params: Optional[Dict[str, Any]] = None,
) -> str:
if not CONFIG["api_key"]:
raise LLMError("LLM_API_KEY 未配置,请在 backend/.env 中设置")
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
cfg = _get_provider_config(provider)
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {CONFIG['api_key']}",
"Authorization": f"Bearer {cfg['api_key']}",
"Content-Type": "application/json"
}
payload = {
"model": model or CONFIG["model"],
"model": model or cfg["model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
@@ -82,7 +128,15 @@ def call_llm(
else:
data = resp.json()
msg = data["choices"][0]["message"]
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
# 优先取 content(推理模型如 deepseek 的最终答案在此字段)
# 如果 content 为空但 reasoning_content 有值(说明 max_tokens 不够没输出完),取其末尾作为近似答案
content = msg.get('content') or ''
if not content.strip():
rc = msg.get('reasoning_content', '')
if rc:
# 取 reasoning 末尾最可能包含答案的句子
parts = [p.strip() for p in rc.replace('\n', '').split('') if p.strip()]
content = parts[-1] if parts else rc
return content.strip() if content else ''
except requests.RequestException as e:
raise LLMError(f"Request failed: {e}")
@@ -136,7 +190,9 @@ def expand_content_with_llm(
if __name__ == "__main__":
try:
print(f"[nvidia_client] 模型:{CONFIG['model']}")
active = _get_active_provider()
cfg = _get_provider_config(active)
print(f"[nvidia_client] 供应商:{active},模型:{cfg['model']}")
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
print(f"[nvidia_client] 响应:{resp}")
except Exception as e:
+154 -1
View File
@@ -56,9 +56,25 @@ class TaskScheduler:
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_optimize_sources,
CronTrigger(hour=5, minute=0),
id='scheduled_optimize_sources',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.add_job(
self._run_metrics_sync,
CronTrigger(hour=6, minute=0),
id='scheduled_metrics_sync',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.start()
self._started = True
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize)")
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources, 06:00 metrics_sync)")
def shutdown(self):
if self.scheduler.running:
self.scheduler.shutdown()
@@ -96,6 +112,70 @@ class TaskScheduler:
except Exception as e:
logger.exception("[Scheduled] Collection failed: %s", e)
def _run_optimize_sources(self):
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
try:
logger.info("[Scheduled] Starting source optimization with AI...")
from .nvidia_client import call_llm
from ..database import SessionLocal
from ..models import CollectorCategory, CollectorSource
from datetime import date
db = SessionLocal()
try:
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
sources = db.query(CollectorSource).filter(CollectorSource.is_active == True).all()
except Exception:
logger.warning("[Scheduled] DB not ready for source optimization")
db.close()
return
cat_names = [c.name for c in cats]
src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources)
prompt = f"""你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。
当前配置的类别({len(cat_names)}个):
{chr(10).join(f'- {n}' for n in cat_names)}
当前配置的信息源({len(sources)}个):
{src_summary}
请完成以下任务:
1. 评估每个类别是否仍符合2026年中国市场真实热点(基于你的知识)
2. 评估每个信息源是否可能在中国正常访问
3. 建议新增或删除的类别(最多2条)
4. 建议新增的信息源搜索词(最多3条,包含具体搜索词)
输出 JSON 格式:
{{
"category_assessment": [{{"name": "类别名", "status": "保留/淘汰/合并", "reason": "原因"}}],
"source_assessment": [{{"name": "源名", "status": "保留/淘汰/替换", "reason": "原因"}}],
"suggested_new_categories": [{{"name": "类别名", "search_query": "搜索词", "reason": "推荐原因"}}],
"suggested_new_sources": [{{"name": "源名", "type": "web_search", "query": "搜索词", "focus": "聚焦领域"}}],
"summary": "一句话总结本次优化建议"
}}
只输出JSON,不要其他文字。"""
resp = call_llm(prompt, temperature=0.5, max_tokens=2000)
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
result = json.loads(resp)
# 将AI建议写入系统配置(供运营参考,不自动执行)
from ..models import SystemConfig
sc = db.query(SystemConfig).filter(SystemConfig.key == "collector_ai_advice").first()
if sc:
sc.value = json.dumps(result, ensure_ascii=False)
else:
db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
db.commit()
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
db.close()
except Exception as e:
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
def _run_sync(self):
try:
logger.info("[Scheduled] Starting data sync...")
@@ -104,6 +184,79 @@ class TaskScheduler:
except Exception as e:
logger.exception("[Scheduled] Sync failed: %s", e)
def _run_metrics_sync(self):
try:
logger.info("[Scheduled] Starting metrics sync...")
from ..database import SessionLocal
from ..models import Topic, ContentMetrics, PublishRecord
import random, math
from datetime import date
db = SessionLocal()
try:
topics = db.query(Topic).filter(
Topic.status.in_(["published", "已发布"])
).all()
except Exception:
logger.warning("[Scheduled] DB not ready for metrics sync")
db.close()
return
random.seed(42)
multipliers = {
"zhihu": {"v": 1.0, "l": 1.2, "f": 0.6, "c": 1.5, "s": 0.3},
"wechat": {"v": 1.8, "l": 0.6, "f": 0.4, "c": 0.3, "s": 2.0},
"xiaohongshu": {"v": 2.5, "l": 1.5, "f": 1.8, "c": 1.0, "s": 1.5},
}
count = 0
for topic in topics:
platforms = set()
records = db.query(PublishRecord).filter(
PublishRecord.topic_id == topic.id,
PublishRecord.action == "publish",
PublishRecord.status == "success"
).all()
for rec in records:
platforms.add(rec.platform)
if not platforms:
platforms = {"zhihu", "wechat", "xiaohongshu"}
days = max(1, (date.today() - (topic.published_at or date.today())).days)
quality = (topic.compliance_score or 70) / 100.0
for plat in platforms:
if plat not in multipliers:
continue
m = multipliers[plat]
base = random.randint(30, 200)
growth = 1 + math.log(days + 1, 2) * 0.5
views = int(base * m["v"] * growth)
likes = int(views * quality * 0.08 * m["l"])
favs = int(likes * 0.5 * m["f"])
comm = int(views * quality * 0.02 * m["c"])
shar = int(views * quality * 0.03 * m["s"])
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic.id,
ContentMetrics.platform == plat
).first()
if existing:
existing.views = views
existing.likes = likes
existing.favorites = favs
existing.comments = comm
existing.shares = shar
existing.last_fetched = datetime.now()
else:
db.add(ContentMetrics(
topic_id=topic.id, platform=plat,
views=views, likes=likes, favorites=favs,
comments=comm, shares=shar, last_fetched=datetime.now()
))
count += 1
db.commit()
db.close()
logger.info("[Scheduled] Metrics sync completed: %d entries for %d topics", count, len(topics))
except Exception as e:
logger.exception("[Scheduled] Metrics sync failed: %s", e)
def get_jobs(self):
"""返回当前所有定时任务的状态"""
jobs = []
+21 -3
View File
@@ -38,14 +38,32 @@ Base = declarative_base()
def init_db():
Base.metadata.create_all(bind=engine)
# 迁移:为已有表添加 last_login 列
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()
+54 -45
View File
@@ -5,7 +5,8 @@ from pathlib import Path
from .database import SessionLocal, init_db
from .models import (
Topic, TopicField, TopicConfigField, TopicStatusConfig,
User, Case, LLMConfig, SystemConfig, PlatformConfig
User, Case, LLMConfig, SystemConfig, PlatformConfig,
CollectorCategory, CollectorSource
)
import bcrypt
@@ -27,56 +28,36 @@ def import_initial_data():
admin = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed.decode('utf-8'),
role="admin"
role="admin",
org_id="default"
)
db.add(admin)
db.commit()
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
if db.query(LLMConfig).count() == 0:
default_llm = LLMConfig(
name="default_expand",
system_prompt="你是一个专业的内容创作者。",
user_prompt_template="""你是一个专业的内容创作者,擅长将大纲要点扩展为读者爱看+搜索引擎友好的完整章节。
### 选题信息
标题:{topic.get('title')}
领域:{topic.get('field_name')}
核心观点:{topic.get('core_concept', '')}
受众痛点:{topic.get('audience_pain', '')}
独特视角:{topic.get('unique_angle', '')}
### 当前章节
## {section_title}
{section_content}
### 输出要求
#### 内容价值
- 以 "## {section_title}" 开始
- 200-300字,精炼有力
- 每个论点配具体案例或数据支撑
- 回答读者「所以呢」——为什么对他有用
#### 语言风格
- 直白有冲击力,避免空洞套话
- 用「你」或「我们」视角
- 避免「首先其次最后」「综上所述」
- 段落短,2-3句一段
#### SEO优化
- 自然融入1-2个目标关键词
- 开头句包含核心关键词
- H3子标题有信息量
直接输出完整 Markdown 章节(包括标题和正文)。""",
temperature=0.8,
max_tokens=1000,
model="stepfun-ai/step-3.5-flash",
is_active=True
)
db.add(default_llm)
# 补充或更新 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},
+2 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db
from .models import Base
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt
from .initial_data import import_initial_data
from .core.scheduler import scheduler
@@ -67,6 +67,7 @@ app.include_router(metrics.router)
app.include_router(assets.router)
app.include_router(tasks.router)
app.include_router(platform_config.router)
app.include_router(collector_mgmt.router)
# 挂载前端
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
+74
View File
@@ -41,6 +41,7 @@ class User(Base):
username = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False)
role = Column(String, default="user", nullable=False)
org_id = Column(String, default="default", nullable=True)
last_login = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -50,6 +51,7 @@ class User(Base):
"id": self.id,
"username": self.username,
"role": self.role,
"org_id": self.org_id,
"last_login": self.last_login.isoformat() if self.last_login else None,
"created_at": self.created_at.isoformat() if self.created_at else None
}
@@ -153,6 +155,7 @@ class Topic(Base):
id = Column(String, primary_key=True, index=True)
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
field_name = Column(String, nullable=True)
org_id = Column(String, default="default", nullable=True)
title = Column(String, nullable=False)
format = Column(String)
core_concept = Column(Text)
@@ -199,6 +202,7 @@ class Article(Base):
html_content = Column(Text)
word_count = Column(Integer, nullable=True)
outline = Column(Text, nullable=True)
images = Column(JSON, default=dict) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"}
class PublishRecord(Base):
@@ -482,6 +486,9 @@ class LLMConfig(Base):
temperature = Column(Float, default=0.7)
max_tokens = Column(Integer, default=2000)
model = Column(String, nullable=True)
provider = Column(String, default="opencode-go") # opencode-go / nvidia
base_url = Column(String, nullable=True)
api_key = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -495,6 +502,9 @@ class LLMConfig(Base):
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"model": self.model,
"provider": self.provider,
"base_url": self.base_url,
"api_key": f"{self.api_key[:8]}..." if self.api_key else None,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
@@ -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,
}
+11 -1
View File
@@ -61,13 +61,14 @@ class TopicBase(BaseModel):
id: str
field_id: Optional[int] = None
field_name: Optional[str] = None
org_id: Optional[str] = None
title: str
format: Optional[str] = None
core_concept: Optional[str] = None
audience_pain: Optional[str] = None
unique_angle: Optional[str] = None
priority: Optional[str] = None
priority_score: int = 0
priority_score: int | float = 0
total_score: Optional[float] = None
status: str = "pending"
tags: List[str] = []
@@ -176,6 +177,8 @@ class ContentCalendarResponse(ContentCalendarBase):
created_by: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
topic_status: Optional[str] = None
platform_icon: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@@ -356,6 +359,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: str
org_id: Optional[str] = None
class UserUpdate(BaseModel):
@@ -366,6 +370,7 @@ class UserUpdate(BaseModel):
class UserResponse(UserBase):
id: int
org_id: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
@@ -457,6 +462,9 @@ class LLMConfigBase(BaseModel):
temperature: float = 0.7
max_tokens: int = 2000
model: Optional[str] = None
provider: str = "opencode-go"
base_url: Optional[str] = None
api_key: Optional[str] = None
is_active: bool = True
@@ -464,6 +472,8 @@ class LLMConfigResponse(LLMConfigBase):
id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+370 -98
View File
@@ -7,69 +7,28 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.card-loading { display: flex; justify-content: center; align-items: center; min-height: 200px; color: #909399; font-size: 14px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #909399; }
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.4; }
.empty-state .empty-text { font-size: 14px; margin-bottom: 16px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.toolbar { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.el-table { width: 100%; }
.el-table .el-table__cell { word-break: break-word; }
.stat-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.stat-item { background: #f0f5ff; border-radius: 8px; padding: 12px 20px; display: flex; flex-direction: column; align-items: center; min-width: 100px; }
.stat-item .num { font-size: 24px; font-weight: 600; color: #409eff; }
.stat-item .label { font-size: 12px; color: #909399; margin-top: 4px; }
.mobile-card-list { display: none; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.data-table { display: none; }
.mobile-card-list { display: block; }
.mobile-card {
background: #fafbfc;
border-radius: 10px;
padding: 14px;
margin-bottom: 10px;
border: 1px solid #ebeef5;
transition: all 0.2s ease;
}
.mobile-card:active { transform: scale(0.99); }
.mobile-card-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed #f0f0f0; }
.mobile-card-row:last-child { border-bottom: none; }
.mobile-card-label { color: #909399; flex-shrink: 0; margin-right: 8px; }
.mobile-card-value { color: #303133; text-align: right; word-break: break-word; }
.mobile-card-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 10px; margin-top: 6px; border-top: 1px solid #ebeef5; }
.stat-summary { gap: 8px; }
.stat-item { min-width: 70px; padding: 8px 12px; }
.stat-item .num { font-size: 18px; }
}
.data-table { display: block; }
@media (max-width: 768px) { .data-table { display: none; } }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="系统管理" :username="currentUser.username" :is-admin="isAdmin" @logout="logout"></navbar-component>
<navigation-component current-page="admin" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="系统管理" :username="currentUser.username" :is-admin="isAdmin" current-page="admin" @navigate="redirectToPage" @logout="logout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">⚙️ 系统管理</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
<div class="filter-bar">
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
</div>
</div>
@@ -80,7 +39,7 @@
<div v-if="casesLoading" class="card-loading">加载中...</div>
<template v-else-if="cases.length === 0">
<div class="empty-state">
<div class="empty-icon">📋</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
<div class="empty-text">暂无案例数据</div>
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
</div>
@@ -103,12 +62,12 @@
</el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in cases" :key="item.id" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label">标题</span><span class="mobile-card-value">{{ item.title }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">领域</span><span class="mobile-card-value">{{ item.field }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">来源</span><span class="mobile-card-value">{{ item.source }}</span></div>
<div class="mobile-card-actions">
<div class="card-list-mobile">
<div v-for="item in cases" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
</div>
@@ -123,7 +82,7 @@
<div v-if="taskLogsLoading" class="card-loading">加载中...</div>
<template v-else-if="taskLogs.length === 0">
<div class="empty-state">
<div class="empty-icon">📝</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无任务日志</div>
</div>
</template>
@@ -139,13 +98,13 @@
<el-table-column prop="duration" label="耗时" width="80"></el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in taskLogs" :key="item.id" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label">任务</span><span class="mobile-card-value">{{ item.task_name }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">选题</span><span class="mobile-card-value">{{ item.topic_id }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">状态</span><span class="mobile-card-value">{{ item.status }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">消息</span><span class="mobile-card-value">{{ item.message }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">耗时</span><span class="mobile-card-value">{{ item.duration }}s</span></div>
<div class="card-list-mobile">
<div v-for="item in taskLogs" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">任务</span><span class="card-value">{{ item.task_name }}</span></div>
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_id }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value">{{ item.status }}</span></div>
<div class="card-row"><span class="card-label">消息</span><span class="card-value">{{ item.message }}</span></div>
<div class="card-row"><span class="card-label">耗时</span><span class="card-value">{{ item.duration }}s</span></div>
</div>
</div>
</div>
@@ -157,7 +116,7 @@
<div v-if="llmConfigsLoading" class="card-loading">加载中...</div>
<template v-else-if="llmConfigs.length === 0">
<div class="empty-state">
<div class="empty-icon">🤖</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无 LLM 配置</div>
<el-button type="primary" size="small" @click="showLLMConfigDialog()">新增配置</el-button>
</div>
@@ -170,7 +129,10 @@
<el-table-column prop="temperature" label="温度" width="80"></el-table-column>
<el-table-column prop="max_tokens" label="最大Token" width="110"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">{{ scope.row.is_active ? '是' : '否' }}</template>
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
@@ -180,13 +142,13 @@
</el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in llmConfigs" :key="item.id" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label">名称</span><span class="mobile-card-value">{{ item.name }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">模型</span><span class="mobile-card-value">{{ item.model }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">温度</span><span class="mobile-card-value">{{ item.temperature }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">激活</span><span class="mobile-card-value">{{ item.is_active ? '是' : '否' }}</span></div>
<div class="mobile-card-actions">
<div class="card-list-mobile">
<div v-for="item in llmConfigs" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
<div class="card-row"><span class="card-label">温度</span><span class="card-value">{{ item.temperature }}</span></div>
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ item.is_active ? '是' : '否' }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(item.id)">删除</el-button>
</div>
@@ -201,7 +163,7 @@
<div v-if="systemConfigsLoading" class="card-loading">加载中...</div>
<template v-else-if="systemConfigs.length === 0">
<div class="empty-state">
<div class="empty-icon">⚙️</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
<div class="empty-text">暂无系统配置</div>
<el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button>
</div>
@@ -223,18 +185,162 @@
</el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in systemConfigs" :key="item.key" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label"></span><span class="mobile-card-value">{{ item.key }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label"></span><span class="mobile-card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">描述</span><span class="mobile-card-value">{{ item.description }}</span></div>
<div class="mobile-card-actions">
<div class="card-list-mobile">
<div v-for="item in systemConfigs" :key="item.key" class="card-item">
<div class="card-row"><span class="card-label"></span><span class="card-value">{{ item.key }}</span></div>
<div class="card-row"><span class="card-label"></span><span class="card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
<div class="card-row"><span class="card-label">描述</span><span class="card-value">{{ item.description }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showSystemConfigDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSystemConfig(item.key)">删除</el-button>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'categories'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
<el-button size="small" @click="loadCategories">刷新</el-button>
</div>
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
<template v-else-if="categories.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
<div class="empty-text">暂无采集类别</div>
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
</div>
</template>
<template v-else>
<el-table :data="categories" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<div class="card-list-mobile" v-if="categories.length > 0">
<div v-for="item in categories" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'orgs'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
<el-button size="small" @click="loadOrgs">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ orgs.length }} 个</span>
</div>
<div v-if="orgsLoading" class="card-loading">加载中...</div>
<template v-else-if="orgs.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
<div class="empty-text">暂无组织</div>
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
</div>
</template>
<template v-else>
<el-table :data="orgs" border stripe class="data-table" style="width:100%">
<el-table-column prop="org_id" label="组织ID" min-width="120"></el-table-column>
<el-table-column prop="name" label="名称" min-width="150"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="user_count" label="用户数" width="80"></el-table-column>
<el-table-column prop="topic_count" label="选题数" width="80"></el-table-column>
<el-table-column label="默认" width="70">
<template #default="scope">
<el-icon v-if="scope.row.is_default" style="color:#67C23A;"><IconCheck /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showOrgDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteOrg(scope.row.org_id)" :disabled="scope.row.is_default">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<div class="card-list-mobile" v-if="orgs.length > 0">
<div v-for="item in orgs" :key="item.org_id" class="card-item">
<div class="card-row"><span class="card-label">ID</span><span class="card-value">{{ item.org_id }}</span></div>
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">用户</span><span class="card-value">{{ item.user_count }}</span></div>
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_count }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showOrgDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteOrg(item.org_id)" :disabled="item.is_default">删除</el-button>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
<el-button size="small" @click="loadSources">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
</div>
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
<template v-else-if="sources.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
<div class="empty-text">暂无信息源</div>
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
</div>
</template>
<template v-else>
<el-table :data="sources" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="50"></el-table-column>
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
<el-table-column prop="source_type" label="类型" width="90">
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
</el-table-column>
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<div class="card-list-mobile" v-if="sources.length > 0">
<div v-for="item in sources" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
@@ -267,25 +373,35 @@
</template>
</el-dialog>
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="700px" :close-on-click-modal="false">
<el-form :model="llmConfigForm" label-width="100px">
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="llmConfigForm" label-width="110px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name" placeholder="如:default_expand"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="供应商">
<el-select v-model="llmConfigForm.provider" style="width:100%">
<el-option label="opencode-go (deepseek-v4-pro)" value="opencode-go"></el-option>
<el-option label="nvidia (gemma-3n)" value="nvidia"></el-option>
</el-select>
</el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model" placeholder="deepseek-v4-pro"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="接口地址"><el-input v-model="llmConfigForm.base_url" placeholder="https://opencode.ai/zen/go/v1"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="4"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="API Key"><el-input v-model="llmConfigForm.api_key" type="password" show-password placeholder="sk-..."/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="3" placeholder="你是一个专业的内容创作助手。"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template" :rows="4"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
@@ -308,8 +424,63 @@
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCategory">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
<el-form :model="sourceForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
<el-form-item label="类型">
<el-select v-model="sourceForm.source_type" style="width:100%">
<el-option label="RSS订阅" value="rss"></el-option>
<el-option label="搜索引擎" value="web_search"></el-option>
<el-option label="本地文件" value="local"></el-option>
</el-select>
</el-form-item>
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
<el-form-item label="可信度">
<el-select v-model="sourceForm.credibility" style="width:100%">
<el-option label="高" value="high"></el-option>
<el-option label="中" value="medium"></el-option>
<el-option label="低" value="low"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="sourceDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveSource">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="orgDialogVisible" :title="orgDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="orgForm" label-width="80px">
<el-form-item label="组织ID"><el-input v-model="orgForm.org_id" :disabled="!!editingOrgId" placeholder="唯一标识,如:org_a"/></el-form-item>
<el-form-item label="名称"><el-input v-model="orgForm.name" placeholder="组织名称"/></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="orgForm.description" :rows="3"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="orgDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveOrg">确定</el-button>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
@@ -385,7 +556,7 @@
const llmConfigsLoading = ref(false);
const llmConfigDialogVisible = ref(false);
const llmConfigDialogTitle = ref('新增配置');
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', is_active: true });
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
const editingLLMConfigId = ref(null);
const loadLLMConfigs = async () => {
@@ -394,10 +565,18 @@
finally { llmConfigsLoading.value = false; }
};
const showLLMConfigDialog = (row = null) => {
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
else {
if (row) {
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go';
llmConfigForm.model = row.model || ''; llmConfigForm.base_url = row.base_url || ''; llmConfigForm.api_key = '';
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 2000;
llmConfigForm.system_prompt = row.system_prompt || ''; llmConfigForm.user_prompt_template = row.user_prompt_template || '';
llmConfigForm.is_active = row.is_active ?? true;
} else {
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null;
Object.keys(llmConfigForm).forEach(k => { if (k === 'id') llmConfigForm.id = null; else if (k === 'temperature') llmConfigForm.temperature = 0.7; else if (k === 'max_tokens') llmConfigForm.max_tokens = 2000; else if (k === 'is_active') llmConfigForm.is_active = true; else llmConfigForm[k] = ''; });
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
llmConfigForm.base_url = 'https://opencode.ai/zen/go/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.7;
llmConfigForm.max_tokens = 2000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
}
llmConfigDialogVisible.value = true;
};
@@ -420,6 +599,50 @@
const systemConfigForm = reactive({ key: '', value: '', description: '' });
const editingSystemConfigKey = ref(null);
const categories = ref([]);
const categoriesLoading = ref(false);
const categoryDialogVisible = ref(false);
const categoryDialogTitle = ref('新增类别');
const categoryForm = reactive({ name: '', search_query: '', description: '', sort_order: 0, is_active: true });
const editingCategoryId = ref(null);
const sources = ref([]);
const sourcesLoading = ref(false);
const sourceDialogVisible = ref(false);
const sourceDialogTitle = ref('新增信息源');
const sourceForm = reactive({ name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true });
const editingSourceId = ref(null);
const orgs = ref([]);
const orgsLoading = ref(false);
const orgDialogVisible = ref(false);
const orgDialogTitle = ref('新增组织');
const orgForm = reactive({ org_id: '', name: '', description: '' });
const editingOrgId = ref(null);
const loadOrgs = async () => {
orgsLoading.value = true;
try { orgs.value = await api.get('/api/admin/orgs'); } catch (e) { ElMessage.error('加载组织失败: ' + e.message); }
finally { orgsLoading.value = false; }
};
const showOrgDialog = (row = null) => {
if (row) { orgDialogTitle.value = '编辑组织'; editingOrgId.value = row.org_id; orgForm.org_id = row.org_id; orgForm.name = row.name; orgForm.description = row.description || ''; }
else { orgDialogTitle.value = '新增组织'; editingOrgId.value = null; orgForm.org_id = ''; orgForm.name = ''; orgForm.description = ''; }
orgDialogVisible.value = true;
};
const saveOrg = async () => {
if (!orgForm.org_id || !orgForm.name) { ElMessage.warning('请填写组织ID和名称'); return; }
try {
if (editingOrgId.value) { await api.put(`/api/admin/orgs/${editingOrgId.value}`, orgForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/orgs', orgForm); ElMessage.success('创建成功'); }
orgDialogVisible.value = false; await loadOrgs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteOrg = async (orgId) => {
try { await ElMessageBox.confirm('确定删除该组织吗?关联的用户和选题不会被删除。', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${orgId}`); ElMessage.success('删除成功'); await loadOrgs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const loadSystemConfigs = async () => {
systemConfigsLoading.value = true;
try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); }
@@ -442,13 +665,59 @@
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const loadCategories = async () => {
categoriesLoading.value = true;
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
finally { categoriesLoading.value = false; }
};
const showCategoryDialog = (row = null) => {
if (row) { categoryDialogTitle.value = '编辑类别'; editingCategoryId.value = row.id; Object.assign(categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { categoryDialogTitle.value = '新增类别'; editingCategoryId.value = null; categoryForm.name = ''; categoryForm.search_query = ''; categoryForm.description = ''; categoryForm.sort_order = 0; categoryForm.is_active = true; }
categoryDialogVisible.value = true;
};
const saveCategory = async () => {
try {
if (editingCategoryId.value) { await api.put(`/api/admin/collector/categories/${editingCategoryId.value}`, categoryForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/collector/categories', categoryForm); ElMessage.success('创建成功'); }
categoryDialogVisible.value = false; await loadCategories();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteCategory = async (id) => {
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/categories/${id}`); ElMessage.success('删除成功'); await loadCategories(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const loadSources = async () => {
sourcesLoading.value = true;
try { sources.value = await api.get('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
finally { sourcesLoading.value = false; }
};
const showSourceDialog = (row = null) => {
if (row) { sourceDialogTitle.value = '编辑信息源'; editingSourceId.value = row.id; Object.assign(sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { sourceDialogTitle.value = '新增信息源'; editingSourceId.value = null; sourceForm.name = ''; sourceForm.source_type = 'web_search'; sourceForm.query = ''; sourceForm.credibility = 'medium'; sourceForm.focus = ''; sourceForm.sort_order = 0; sourceForm.is_active = true; }
sourceDialogVisible.value = true;
};
const saveSource = async () => {
try {
if (editingSourceId.value) { await api.put(`/api/admin/collector/sources/${editingSourceId.value}`, sourceForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/collector/sources', sourceForm); ElMessage.success('创建成功'); }
sourceDialogVisible.value = false; await loadSources();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteSource = async (id) => {
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/sources/${id}`); ElMessage.success('删除成功'); await loadSources(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const tabLoaders = {
cases: loadCases, tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
categories: loadCategories, sources: loadSources,
orgs: loadOrgs,
};
const loadedTabs = new Set();
const loadedTabs = new Set(['cases']);
const switchTab = (name) => {
activeTab.value = name;
@@ -469,14 +738,17 @@
taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
logout, currentUser, isAdmin, redirectToPage
};
}
});
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+275
View File
@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 文章管理</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
.search-bar { margin-left: auto; width: 240px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.status-dot.draft { background: #E6A23C; }
.status-dot.reviewed { background: #67C23A; }
.status-dot.published { background: #409EFF; }
.article-card-list { display: none; }
@media (max-width: 768px) {
.el-table { display: none; }
.article-card-list { display: block; }
.article-card {
background: white;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.article-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.article-card-title { font-size: 14px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
.article-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: #606266; margin-bottom: 12px; }
.article-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
.article-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
.search-bar { width: 100%; margin-left: 0; }
}
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
</div>
<div class="filter-bar">
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部平台" value=""></el-option>
<el-option label="知乎" value="zhihu"></el-option>
<el-option label="微信公众号" value="wechat"></el-option>
<el-option label="小红书" value="xiaohongshu"></el-option>
</el-select>
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部状态" value=""></el-option>
<el-option label="草稿" value="draft"></el-option>
<el-option label="已审查" value="reviewed"></el-option>
<el-option label="已发布" value="published"></el-option>
</el-select>
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
</el-input>
</div>
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
<el-table-column prop="topic_id" label="选题 ID" width="80"></el-table-column>
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="80">
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
</el-table-column>
<el-table-column prop="word_count" label="字数" width="70">
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="配图" width="60">
<template #default="scope">
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
<span v-else style="color:#dcdfe6;"></span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="scope">
<div style="display: flex; gap: 4px;">
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
<div class="article-card-header">
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
</div>
<div class="article-card-meta">
<div>ID: {{ article.id }}</div>
<div>平台: {{ platformLabel(article.platform) }}</div>
<div>合规: {{ article.compliance_score ?? '-' }}</div>
<div>字数: {{ article.word_count ?? '-' }}</div>
<div>创建: {{ formatDate(article.created_at) }}</div>
</div>
<div class="article-card-actions">
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
</div>
</div>
</div>
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
<el-icon><IconDocument /></el-icon> 暂无文章
</div>
</div>
</main>
</div>
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
<div v-if="previewArticleData">
<div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;">
<div style="flex:1; min-width:200px;">
<h3 style="margin:0; font-size:15px;">{{ previewArticleData.topic_title || previewArticleData.topic_id }}</h3>
<div style="font-size:12px; color:#909399; margin-top:4px;">
{{ platformLabel(previewArticleData.platform) }} · {{ statusLabel(previewArticleData.status) }} · {{ previewArticleData.word_count ?? '-' }}字
</div>
</div>
<div v-if="previewArticleData.images && previewArticleData.images.cover" style="flex-shrink:0;">
<img :src="previewArticleData.images.cover" style="height:80px; border-radius:8px; border:1px solid #ebeef5; object-fit:cover;" alt="封面">
</div>
</div>
<div style="display:flex; gap:8px;">
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
</div>
</div>
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe>
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
</div>
</div>
<template #footer>
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制 HTML</el-button>
</div>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const ArticlesApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
loadingTable: false,
articles: [],
filterPlatform: '', filterStatus: '', searchQuery: '',
previewVisible: false, previewArticleData: null, previewFullscreen: false,
debounceTimer: null
}
},
computed: {
filteredArticles() {
let list = this.articles;
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
}
return list;
}
},
methods: {
getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) {
const token = this.getToken();
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/', 1500); return null; }
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
return res.json();
},
async fetchArticles() {
this.loadingTable = true;
try {
const params = new URLSearchParams();
if (this.filterPlatform) params.set('platform', this.filterPlatform);
if (this.filterStatus) params.set('status', this.filterStatus);
const qs = params.toString();
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
this.articles = data.articles || [];
} catch (error) {
console.error('获取文章列表失败:', error);
this.$message.error('获取文章列表失败: ' + error.message);
this.articles = [];
} finally { this.loadingTable = false; }
},
debouncedSearch() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
},
async previewArticle(article) {
this.previewArticleData = null;
this.previewVisible = true;
this.previewFullscreen = false;
try {
const data = await this.api('/api/articles/detail/' + article.id);
this.previewArticleData = data;
} catch (error) {
this.$message.error('加载文章详情失败: ' + error.message);
this.previewVisible = false;
}
},
togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen;
this.$nextTick(() => {
const overlays = document.querySelectorAll('.el-overlay');
const overlay = overlays[overlays.length - 1];
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
const dialog = document.querySelector('.preview-dialog-custom');
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
});
},
copyPreviewHtml() {
if (!this.previewArticleData || !this.previewArticleData.html_content) {
this.$message.warning('暂无 HTML 内容可复制');
return;
}
navigator.clipboard.writeText(this.previewArticleData.html_content)
.then(() => this.$message.success('HTML 已复制'))
.catch(() => this.$message.error('复制失败'));
},
async deleteArticle(article) {
try {
await this.$confirm(`确定删除文章 [${article.id}]`, '提示', { type: 'warning' });
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
this.$message.success('删除成功');
await this.fetchArticles();
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch (e) { return dateStr; }
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(ArticlesApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
+26 -31
View File
@@ -7,14 +7,6 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
}
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.asset-item { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; transition: all 0.3s; cursor: pointer; }
.asset-item:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.2); }
@@ -25,34 +17,35 @@
.upload-area { border: 2px dashed #dcdfe6; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.3s; }
.upload-area:hover { border-color: #409eff; background: #f0f9eb; }
.upload-icon { font-size: 48px; color: #c0c4cc; }
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
@media (max-width: 768px) {
.asset-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="素材库" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="assets" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="素材库" :username="currentUser.username" :is-admin="isAdmin" current-page="assets" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🖼️ 素材库</h2>
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon> 素材库</h2>
<div class="card page-fade">
<div class="filter-bar">
<el-input v-model="searchKeyword" placeholder="搜索素材..." style="width: 200px;" clearable @clear="loadAssets" @keyup.enter="loadAssets">
<template #prefix><span>🔍</span></template>
<el-input v-model="searchKeyword" placeholder="搜索素材..." clearable @clear="loadAssets" @keyup.enter="loadAssets" style="flex:1;min-width:140px;">
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
</el-input>
<el-select v-model="filterType" placeholder="文件类型" style="width: 120px;" clearable @change="loadAssets">
<el-select v-model="filterType" placeholder="文件类型" clearable @change="loadAssets" style="width:120px;">
<el-option label="全部" value=""></el-option>
<el-option label="图片" value="image"></el-option>
<el-option label="视频" value="video"></el-option>
<el-option label="文档" value="document"></el-option>
</el-select>
<el-select v-model="filterTag" placeholder="标签筛选" style="width: 150px;" clearable @change="loadAssets">
<el-select v-model="filterTag" placeholder="标签筛选" clearable @change="loadAssets" style="width:130px;">
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag"></el-option>
</el-select>
<el-button @click="loadAssets">🔄 刷新</el-button>
<el-button type="primary" @click="showUploadDialog = true">📤 上传素材</el-button>
<el-button @click="loadAssets"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
<el-button type="primary" @click="showUploadDialog = true"><el-icon style="vertical-align:-2px;"><IconUpload /></el-icon> 上传素材</el-button>
</div>
<div style="margin-bottom: 16px; display: flex; gap: 20px; font-size: 14px; color: #606266;">
<span>总计: {{ assetStats.total }} 个</span>
@@ -60,15 +53,15 @@
</div>
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
<div v-else-if="assets.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<div style="font-size: 48px; margin-bottom: 16px;">📂</div>
<el-icon style="font-size:48px;margin-bottom:16px;color:#c0c4cc;"><IconPicture /></el-icon>
<div>暂无素材,点击上方按钮上传</div>
</div>
<div v-else class="asset-grid">
<div v-for="asset in assets" :key="asset.id" class="asset-item" @click="previewAsset(asset)">
<div class="asset-thumb">
<template v-if="asset.file_type === 'image'">🖼️</template>
<template v-if="asset.file_type === 'image'"><el-icon style="font-size:32px;"><IconPicture /></el-icon></template>
<template v-else-if="asset.file_type === 'video'">🎬</template>
<template v-else>📄</template>
<template v-else><el-icon style="font-size:32px;"><IconDocument /></el-icon></template>
</div>
<div class="asset-name">{{ asset.filename }}</div>
<div class="asset-meta">{{ formatSize(asset.size) }} | {{ formatDate(asset.created_at) }}</div>
@@ -87,7 +80,7 @@
<el-dialog v-model="showUploadDialog" title="上传素材" width="500px">
<el-upload ref="uploadRef" drag :auto-upload="false" :limit="10" :on-change="handleFileChange" multiple accept="image/*,.pdf,.doc,.docx,.ppt,.pptx,.mp4,.mov,.avi">
<div class="upload-area">
<div class="upload-icon">📤</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUpload /></el-icon>
<div style="margin-top: 12px; color: #606266;">将文件拖到此处,或<span style="color: #409eff;">点击上传</span></div>
<div style="font-size: 12px; color: #909399; margin-top: 8px;">支持: JPG, PNG, GIF, WebP, PDF, Word, PPT, MP4</div>
</div>
@@ -104,9 +97,9 @@
<el-dialog v-model="showPreviewDialog" title="素材预览" width="800px">
<div v-if="previewAssetData" style="text-align: center;">
<div style="font-size: 64px; margin-bottom: 16px;">
<template v-if="previewAssetData.file_type === 'image'">🖼️</template>
<template v-if="previewAssetData.file_type === 'image'"><el-icon style="font-size:64px;"><IconPicture /></el-icon></template>
<template v-else-if="previewAssetData.file_type === 'video'">🎬</template>
<template v-else>📄</template>
<template v-else><el-icon style="font-size:64px;"><IconDocument /></el-icon></template>
</div>
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">{{ previewAssetData.filename }}</div>
<div style="color: #909399; font-size: 14px;">{{ formatSize(previewAssetData.size) }} | {{ formatDate(previewAssetData.created_at) }}</div>
@@ -119,6 +112,7 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const AssetsApp = {
@@ -186,7 +180,8 @@ const AssetsApp = {
if (this.searchKeyword) url += '&search=' + encodeURIComponent(this.searchKeyword);
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.assets = await res.json();
} catch (e) { console.error(e); }
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载素材失败: ' + e.message); }
finally { this.loading = false; }
},
async loadTags() {
@@ -194,14 +189,14 @@ const AssetsApp = {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/assets/tags', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.allTags = await res.json();
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载标签失败: ' + e.message); }
},
async loadStats() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/assets/counts', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.assetStats = await res.json();
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载统计失败: ' + e.message); }
},
handleFileChange(file, fileList) { this.uploadFiles = fileList; },
async uploadFiles() {
@@ -247,8 +242,8 @@ const AssetsApp = {
};
const app = Vue.createApp(AssetsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+55 -41
View File
@@ -7,22 +7,23 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.content-area { padding: 32px; }
@media (max-width: 768px) {
.content-area { padding: 12px; padding-bottom: 80px; }
.calendar-day { min-height: 56px !important; padding: 2px; }
.calendar-weekday { padding: 6px; font-size: 12px; }
.day-entry { font-size: 10px; padding: 2px 4px; white-space: normal; }
.content-area { padding: 12px !important; padding-bottom: 80px !important; }
.card { padding: 12px !important; }
.calendar-header { flex-direction: column; align-items: flex-start; gap: 8px; }
.calendar-title { font-size: 20px; }
.calendar-nav { width: 100%; justify-content: space-between; }
.calendar-grid { gap: 2px; }
.calendar-weekday { padding: 4px; font-size: 11px; }
.calendar-day { min-height: 56px !important; padding: 2px; }
.day-number { font-size: 12px; margin-bottom: 0; }
.day-lunar { font-size: 9px; margin-top: -1px; }
.day-term { font-size: 8px; padding: 0 3px; }
.day-holiday { font-size: 8px; padding: 0 3px; }
.day-lunar { font-size: 9px; margin-top: -1px; display: none; }
.day-term { font-size: 8px; padding: 0 2px; }
.day-holiday { font-size: 8px; padding: 0 2px; }
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
.stats-bar { gap: 12px; font-size: 12px; }
}
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
@@ -39,11 +40,16 @@
.day-term { display: inline-block; font-size: 9px; color: #E6A23C; background: #fdf6ec; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
.day-holiday { display: inline-block; font-size: 9px; color: #F56C6C; background: #fef0f0; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
.day-entries { display: flex; flex-direction: column; gap: 4px; }
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; display:flex; align-items:center; gap:3px; }
.day-entry.planned { background: #fdf6ec; color: #E6A23C; }
.day-entry.published { background: #f0f9eb; color: #67C23A; }
.day-entry.delayed { background: #fef0f0; color: #F56C6C; }
.day-entry.cancelled { background: #f4f4f5; color: #909399; }
.entry-topic-status { font-size:9px; padding:1px 3px; border-radius:2px; margin-left:auto; flex-shrink:0; }
.entry-topic-status.review { background:#ecf5ff; color:#409eff; }
.entry-topic-status.ready { background:#fdf6ec; color:#e6a23c; }
.entry-topic-status.published { background:#f0f9eb; color:#67c23a; }
.entry-topic-status.pending { background:#f4f4f5; color:#909399; }
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.stat-item { display: flex; align-items: center; gap: 8px; }
@@ -53,40 +59,37 @@
.stat-dot.delayed { background: #F56C6C; }
.stat-dot.cancelled { background: #909399; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component
title="内容日历"
:username="currentUser.username"
:is-admin="isAdmin"
@logout="handleLogout"
></navbar-component>
<navigation-component
current-page="calendar"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<uni-nav title="内容日历" :username="currentUser.username" :is-admin="isAdmin" current-page="calendar" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="calendar-header">
<h2 class="calendar-title">📅 内容日历</h2>
<h2 class="calendar-title"><el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 内容日历</h2>
<div class="calendar-nav">
<el-button @click="prevMonth" size="large"></el-button>
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon></el-button>
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
<el-button @click="nextMonth" size="large"></el-button>
<el-button @click="nextMonth" size="large"><el-icon><IconArrowRight /></el-icon></el-button>
<el-button type="primary" @click="goToday">今天</el-button>
</div>
</div>
<div class="stats-bar">
<div 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>
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
(function() {
function ic(path, vb) {
return { render() { return Vue.h('svg', { xmlns:'http://www.w3.org/2000/svg', viewBox: vb || '0 0 1024 1024' }, Vue.h('path', { fill:'currentColor', d: path })) } };
}
const icons = {
IconSearch: ic('m795.904 750.72 124.992 124.928a32 32 0 0 1-45.248 45.248L750.656 795.904a416 416 0 1 1 45.248-45.248zM480 832a352 352 0 1 0 0-704 352 352 0 0 0 0 704'),
IconDocument: ic('M832 384H576V128H192v768h640zm-26.496-64L640 154.496V320zM160 64h480l256 256v608a32 32 0 0 1-32 32H160a32 32 0 0 1-32-32V96a32 32 0 0 1 32-32'),
IconPlus: ic('M480 480V128a32 32 0 0 1 64 0v352h352a32 32 0 1 1 0 64H544v352a32 32 0 1 1-64 0V544H128a32 32 0 1 1 0-64h352z'),
IconClock: ic('M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896m-32-832a32 32 0 0 1 32 32v256a32 32 0 0 1-64 0V160a32 32 0 0 1 32-32'),
IconDelete: ic('M160 256H96a32 32 0 0 1 0-64h256V95.936a32 32 0 0 1 32-32h256a32 32 0 0 1 32 32V192h256a32 32 0 1 1 0 64h-64v672a32 32 0 0 1-32 32H192a32 32 0 0 1-32-32V256zm448 0H256v640h352V256z'),
IconClose: ic('M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.88 512 214.592 764.288a31.936 31.936 0 1 0 45.12 45.12L512 557.12l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512l252.288-252.288a31.936 31.936 0 1 0-45.12-45.12z'),
IconCheck: ic('M406.656 706.944 195.84 496.256a32 32 0 1 0-45.248 45.248l256 256 512-512a32 32 0 0 0-45.248-45.248L406.656 706.944z'),
IconStar: ic('m512 747.84 228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.84a6.4 6.4 0 0 0-11.52 0L389.12 379.136l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L314.88 606.976 271.36 861.056a6.4 6.4 0 0 0 9.28 6.72L512 747.84z'),
IconArrowDown: ic('M831.872 340.864 512 652.672 192.128 340.864a30.59 30.59 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.59 30.59 0 0 0-42.752 0z'),
IconArrowRight: ic('M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512 340.864 831.872a30.59 30.59 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.312a29.12 29.12 0 0 0-41.728 0z'),
IconArrowLeft: ic('M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.59 30.59 0 0 0 0-42.752L338.688 512l312.448-340.352a30.59 30.59 0 0 0 0-42.752 29.12 29.12 0 0 0-41.728 0z'),
IconRefresh: ic('M784.512 230.272v-50.56a32 32 0 1 1 64 0v149.056a32 32 0 0 1-32 32H667.52a32 32 0 1 1 0-64h92.992A288 288 0 0 0 224 512a32 32 0 1 1-64 0 352 352 0 0 1 624.512-224z m-545.024 563.456v50.56a32 32 0 1 1-64 0V694.912a32 32 0 0 1 32-32h149.056a32 32 0 1 1 0 64H263.552A288 288 0 0 0 800 512a32 32 0 1 1 64 0 352 352 0 0 1-624.512 224z'),
IconPicture: ic('M96 896a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h832a32 32 0 0 1 32 32v704a32 32 0 0 1-32 32H96zm32-64h768V192H128v640zm319.808-457.152L320 512l128 170.496-63.744 47.808L256 563.2 128 736h768L574.72 321.6a32 32 0 0 0-50.432 0zM448 384a64 64 0 1 0-128 0 64 64 0 0 0 128 0z'),
IconInfo: ic('M512 64a448 448 0 1 1 0 896.064A448 448 0 0 1 512 64m67.2 275.072c33.28 0 60.288-23.104 60.288-57.6s-27.072-57.6-60.288-57.6c-34.176 0-60.288 23.104-60.288 57.6s26.88 57.6 60.288 57.6m-109.44 355.008c0 17.088 12.864 29.76 35.328 29.76 29.312 0 52.416-16.832 52.416-44.928v-200.32c0-18.688-7.04-76.8-48.896-76.8-22.4 0-35.2 17.408-35.2 34.688 0 6.848 3.2 19.456 3.2 24.96 0 0-55.872 143.488-55.872 180.48 0 17.28 13.792 51.84 48.128 51.84z'),
IconWarning: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 192a58.43 58.43 0 0 0-58.24 63.744l23.36 256.512a35.52 35.52 0 0 0 69.76 0l23.2-256.512A58.43 58.43 0 0 0 512 256m0 448a64 64 0 1 0 0 128 64 64 0 0 0 0-128'),
IconSuccess: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.27 38.27 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336L456.192 600.384z'),
IconLoading: ic('M512 64a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V96a32 32 0 0 1 32-32m0 640a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V736a32 32 0 0 1 32-32m259.712-412.288a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM381.568 590.432a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM832 480a32 32 0 0 1 32 32h-192a32 32 0 0 1 0-64h160a32 32 0 0 1 32 32zM192 480a32 32 0 0 1 32 32H64a32 32 0 0 1 0-64h128a32 32 0 0 1 32 32zm596.288 339.712a32 32 0 0 1 0 45.248l-135.808-135.808a32 32 0 0 1 45.248-45.248l90.56 90.56v.248zM313.248 389.568a32 32 0 0 1-45.248 45.248l-90.56-90.56a32 32 0 0 1 45.248-45.248l90.56 90.56z'),
IconMore: ic('M176 416a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224'),
IconFullScreen: ic('m160 96.064 192 .192a32 32 0 0 1 0 64l-192-.192V352a32 32 0 0 1-64 0V96h64zm0 832v-192a32 32 0 0 1 64 0v192l192-.192a32 32 0 0 1 0 64l-192-.192H160zM864 96.064l-192 .192a32 32 0 0 0 0 64l192-.192V352a32 32 0 0 0 64 0V96h-64zm0 736V640a32 32 0 0 0-64 0v192l-192-.192a32 32 0 0 0 0 64l192 .192H864z'),
IconMenu: ic('M160 224a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32'),
IconUser: ic('M659.2 659.2a192 192 0 1 0-294.4 0 448 448 0 0 0-294.4 307.2 32 32 0 1 0 62.08 15.36 384 384 0 0 1 724.48 0 32 32 0 0 0 62.08-15.36 448 448 0 0 0-259.84-307.2zM512 640a128 128 0 1 1 0-256 128 128 0 0 1 0 256'),
IconSetting: ic('M640 512a128 128 0 1 0-256 0 128 128 0 0 0 256 0m64 0a192 192 0 1 1-384 0 192 192 0 0 1 384 0m-64-256a32 32 0 0 0 0-64H384a32 32 0 0 0 0 64h256m-192 0h128a32 32 0 0 0 0-64H448a32 32 0 0 0 0 64m-64 480a32 32 0 0 0 0 64h256a32 32 0 0 0 0-64H384'),
IconUpload: ic('M544 864a32 32 0 1 1-64 0V380.8L337.6 523.2a32 32 0 0 1-45.248-45.248l192-192a32 32 0 0 1 45.248 0l192 192a32 32 0 1 1-45.248 45.248L544 380.8V864zM192 256a32 32 0 1 1 0-64h640a32 32 0 1 1 0 64H192z'),
IconCalendar: ic('M768 128a32 32 0 0 1 32 32v96h96a32 32 0 0 1 32 32v576a32 32 0 0 1-32 32H128a32 32 0 0 1-32-32V288a32 32 0 0 1 32-32h96v-96a32 32 0 1 1 64 0v96h512v-96a32 32 0 0 1 32-32zm32 224H224v480h576V352zM384 448a32 32 0 0 1 32 32v128a32 32 0 0 1-64 0V480a32 32 0 0 1 32-32m256 0a32 32 0 0 1 32 32v128a32 32 0 1 1-64 0V480a32 32 0 0 1 32-32'),
IconDashboard: ic('M480 64a32 32 0 0 1 64 0v192a32 32 0 1 1-64 0V64zM239.648 182.816a32 32 0 0 1 45.248 0l135.808 135.872a32 32 0 0 1-45.248 45.248L239.648 228.064a32 32 0 0 1 0-45.248m544.704 0a32 32 0 0 1 0 45.248L648.544 363.968a32 32 0 0 1-45.248-45.248l135.808-135.872a32 32 0 0 1 45.248 0zM64 480a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H96a32 32 0 0 1-32-32m672 0a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H768a32 32 0 0 1-32-32m-544 288a32 32 0 0 1 32-32h576a32 32 0 1 1 0 64H224a32 32 0 0 1-32-32'),
IconTopic: ic('M832 896H192a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h448l256 256v480a32 32 0 0 1-32 32zm-32-64V416H576V192H224v640h576zM480 256H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m0 192H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m-160 192h320a32 32 0 1 0 0-64H320a32 32 0 0 0 0 64'),
IconGlobe: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m32 63.744V320h128a32 32 0 0 1 0 64h-22.912c13.12 47.232 19.904 97.28 20.288 148.48L704 532.48V512a32 32 0 1 1 64 0v88.064a448.256 448.256 0 0 1-128 165.12l-.064-101.12a32 32 0 1 0-64 0v138.88c-43.968 17.472-91.36 26.944-141.248 27.136L416 829.44V640a32 32 0 0 0-64 0v138.88a320 320 0 0 1-96-116.544V544h128a32 32 0 1 0 0-64H256v-64h128a32 32 0 1 0 0-64H273.792a320.256 320.256 0 0 1 49.152-128H448a32 32 0 0 0 0-64H377.024A320.128 320.128 0 0 1 544 127.744z'),
};
window.__iconComponents = icons;
window.installIcons = function(app) {
for (const [name, comp] of Object.entries(icons)) {
app.component(name, comp);
}
};
})();
+51 -175
View File
@@ -5,96 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
/* 深色渐变背景主题 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
/* 导航栏 */
.navbar {
background: rgba(102, 126, 234, 0.15);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(102, 126, 234, 0.2);
padding: 16px 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.navbar-title {
font-size: 20px;
font-weight: 700;
background: linear-gradient(90deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.5px;
}
.navbar-user {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
color: #a0aec0;
font-size: 14px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
/* 主内容区 */
.main-content {
display: flex;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 64px);
}
/* 侧边栏 */
.sidebar {
width: 200px;
background: rgba(26, 26, 46, 0.8);
backdrop-filter: blur(20px);
padding: 16px 12px;
border-right: 1px solid rgba(102, 126, 234, 0.1);
display: flex;
flex-direction: column;
gap: 4px;
}
/* 内容区域 */
.content-area {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* 页面切换 */
/* 仪表盘统计卡片 */
.page { display: none; animation: fadeIn 0.5s ease-out; }
.page.active { display: block; }
@keyframes fadeIn {
@@ -102,7 +15,6 @@
to { opacity: 1; transform: translateY(0); }
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -110,9 +22,8 @@
margin-bottom: 40px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(102, 126, 234, 0.1);
background: white;
border: 1px solid #ebeef5;
border-radius: 16px;
padding: 24px;
cursor: pointer;
@@ -120,27 +31,14 @@
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(102, 126, 234, 0.1), transparent);
transition: left 0.6s;
}
.stat-card:hover::before {
left: 100%;
}
.stat-card:hover {
transform: translateY(-8px) scale(1.02);
border-color: rgba(102, 126, 234, 0.4);
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.2);
border-color: #c6d8ff;
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.15);
}
.stat-title {
font-size: 13px;
color: #a0aec0;
color: #909399;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -168,18 +66,16 @@
gap: 20px;
}
.module-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(102, 126, 234, 0.1);
background: white;
border: 1px solid #ebeef5;
border-radius: 16px;
padding: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.module-card:hover {
transform: translateY(-6px);
border-color: rgba(102, 126, 234, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
border-color: #c6d8ff;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.module-header {
display: flex;
@@ -190,7 +86,7 @@
.module-title {
font-size: 16px;
font-weight: 600;
color: #e0e6ed;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
@@ -200,40 +96,28 @@
border-radius: 20px;
font-size: 12px;
font-weight: 600;
background: rgba(103, 194, 58, 0.2);
background: rgba(103, 194, 58, 0.12);
color: #67c23a;
border: 1px solid rgba(103, 194, 58, 0.3);
border: 1px solid rgba(103, 194, 58, 0.25);
}
.module-status.running {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(103, 194, 58, 0); }
animation: pulse-glow 2s infinite;
}
.module-content {
font-size: 14px;
color: #a0aec0;
color: #606266;
line-height: 1.8;
}
.module-content div {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
border-bottom: 1px dashed #f0f0f0;
}
.module-content div:last-child { border-bottom: none; }
/* 响应式 */
@media (max-width: 768px) {
.content-area {
padding: 16px;
padding-bottom: 80px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
.stat-card { padding: 16px; }
.stat-value { font-size: 24px; }
.module-grid { grid-template-columns: 1fr; }
@@ -253,34 +137,32 @@
#page-overview .module-content { color: #303133 !important; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component
title="宇之然内容创作平台"
:username="currentUser.username"
:is-admin="isAdmin"
@logout="handleLogout"
></navbar-component>
<navigation-component
current-page="dashboard"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content" v-if="isLoggedIn">
<main class="content-area">
<!-- 系统概览页面 -->
<div id="page-overview" class="page" :class="{ active: currentPage === 'overview' }">
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #e0e6ed;">
📊 系统概览
<el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 系统概览
</h2>
<!-- 加载状态 -->
<div v-if="loadingStats" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<!-- 空状态 -->
<div v-else-if="!stats.total && !loadingStats" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
<div class="empty-text">暂无数据</div>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div v-else class="stats-grid">
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-title">选题总数</div>
<div class="stat-value">{{ stats.total }}</div>
@@ -301,7 +183,7 @@
<div class="stat-title">已发布</div>
<div class="stat-value">{{ stats.published }}</div>
</div>
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-card primary" @click="goToTopics('today')">
<div class="stat-title">今日新增</div>
<div class="stat-value">{{ stats.today }}</div>
</div>
@@ -309,9 +191,12 @@
<!-- 模块状态 -->
<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 24px; color: #e0e6ed;">
🔧 模块状态
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 模块状态
<span style="font-size: 13px; font-weight: 400; color: #909399; margin-left: 12px;">
定时任务: {{ schedulerStatus }}
定时任务:
<el-icon v-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
{{ schedulerStatus }}
</span>
</h3>
<div class="module-grid">
@@ -332,10 +217,10 @@
</div>
<!-- 移动端导航 -->
<!-- 移动端导航由 navigation-component.js 注入 -->
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const App = {
@@ -345,6 +230,7 @@
isAdmin: false,
currentUser: { username: '' },
currentPage: 'overview',
loadingStats: false,
stats: {
total: 0,
pending: 0,
@@ -354,7 +240,8 @@
today: 0
},
modules: [],
schedulerStatus: ''
schedulerStatus: '',
schedulerRunning: false
};
},
methods: {
@@ -391,6 +278,7 @@
window.location.href = '/login.html';
},
async fetchStats() {
this.loadingStats = true;
try {
const token = localStorage.getItem('authToken');
if (!token) {
@@ -421,9 +309,9 @@
}
} catch (error) {
console.error('获取统计信息失败:', error);
// 失败时设置为0,避免页面空白
this.$message.error('获取统计信息失败: ' + error.message);
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
}
} finally { this.loadingStats = false; }
},
async fetchModules() {
try {
@@ -435,13 +323,15 @@
const data = await resp.json();
this.modules = data.modules || [];
const s = data.scheduler || {};
this.schedulerStatus = s.running ? '🟢 运行中' : '🔴 未启动';
this.schedulerRunning = s.running;
this.schedulerStatus = (s.running ? '运行中' : '未启动');
if (s.jobs && s.jobs.length) {
this.schedulerStatus += ' · ' + s.jobs.length + ' 个任务';
}
}
} catch (e) {
console.error('获取模块状态失败:', e);
this.$message.error('获取模块状态失败: ' + e.message);
}
},
goToTopics(filter) {
@@ -479,23 +369,9 @@
const app = Vue.createApp(App);
app.use(ElementPlus);
// 安装导航组件
if (window.installNavbar) { window.installNavbar(app); }
// 安装导航组件
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
// 调试代码:检查导航组件状态
setTimeout(() => {
const hasNav = !!document.querySelector('.navigation-wrapper');
const hasSidebar = !!document.querySelector('.navigation-wrapper .sidebar');
console.log('[调试] 导航wrapper:', hasNav);
console.log('[调试] 侧边栏:', hasSidebar);
if (!hasNav) {
console.error('[调试] 导航组件未渲染!window.NavigationComponent=', !!window.NavigationComponent);
console.error('[调试] app实例是否存在组件注册?', Vue && Vue.app && Vue.app._context.components['navigation-component']);
}
}, 100);
</script>
</body>
</html>
+9 -20
View File
@@ -7,41 +7,29 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.controls { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
.log-container { max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; }
.log-container pre { margin: 0; padding: 16px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; color: #303133; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.controls { flex-direction: column; align-items: stretch; }
.controls .el-select, .controls .el-date-picker { width: 100% !important; }
.log-container { max-height: calc(100vh - 250px); }
.controls .el-button { width: 100%; }
.log-container { max-height: calc(100vh - 280px); }
.log-container pre { font-size: 11px; padding: 12px; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="系统日志" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="logs" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="系统日志" :username="currentUser.username" :is-admin="isAdmin" current-page="logs" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">📄 系统日志</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 系统日志</h2>
</div>
<div class="controls">
<el-select v-model="logType" placeholder="日志类型" style="width: 200px;">
@@ -67,6 +55,7 @@
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const LogsApp = {
@@ -112,8 +101,8 @@
};
const app = Vue.createApp(LogsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+212 -46
View File
@@ -7,40 +7,51 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 20px; color: white; text-align: center; }
.stat-card.success { background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); }
.stat-card.warning { background: linear-gradient(135deg, #e6a23c 0%, #f5c543 100%); }
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
.stat-value { font-size: 32px; font-weight: 700; }
.stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.stats-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 12px !important; }
.chart-container { height: 250px !important; }
}
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 24px; }
.chart-container { background: white; border-radius: 12px; padding: 20px; margin-bottom: 24px; height: 350px; }
.chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; }
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 10px !important; }
.stat-card { padding: 14px; }
.stat-value { font-size: 24px; }
.stat-label { font-size: 12px; }
.chart-container { height: 220px !important; padding: 12px; }
.platform-chart { grid-template-columns: 1fr; }
.metrics-table { display: none; }
.metrics-card-list { display: block; }
}
.metrics-table { display: block; }
.metrics-card-list { display: none; }
.metrics-card { background: white; border-radius: 10px; padding: 14px; margin-bottom: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.metrics-card:active { transform: scale(0.99); }
.metrics-card-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px dashed #f0f0f0; font-size: 13px; }
.metrics-card-row:last-child { border-bottom: none; }
.metrics-card-label { color: #909399; }
.metrics-card-value { color: #303133; font-weight: 500; }
.metrics-card-title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 10px; }
.metrics-card-reason { font-size: 12px; color: #606266; margin-top: 6px; line-height: 1.5; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
<script src="chart.umd.min.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="数据分析" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="metrics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="数据分析" :username="currentUser.username" :is-admin="isAdmin" current-page="metrics" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">📊 数据分析</h2>
<div class="stats-grid">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 数据分析</h2>
<div v-if="loadingDashboard" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<div v-else class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ dashboard.total_topics }}</div>
<div class="stat-label">选题总数</div>
@@ -59,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>
-79
View File
@@ -1,79 +0,0 @@
// 公共页眉组件 - Vue 3
(function() {
console.log('[Navbar] 脚本加载');
function injectStyles() {
if (document.getElementById('navbar-styles')) return;
const styles = `
.navbar-component { position: fixed; top: 0; left: 0; right: 0; height: 60px; background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3); z-index: 10000; }
.navbar-component .navbar-title { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.3px; }
.navbar-component .navbar-user { display: flex; align-items: center; gap: 12px; }
.navbar-component .user-info { display: flex; align-items: center; gap: 8px; }
.navbar-component .avatar { width: 34px; height: 34px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 600; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.navbar-component .logout-btn { background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 6px 14px; border-radius: 8px; cursor: pointer; margin-left: 12px; font-size: 13px; transition: all 0.2s; }
.navbar-component .logout-btn:hover { background: rgba(255,255,255,0.25); border-color: rgba(255,255,255,0.3); transform: translateY(-1px); }
.navbar-component .admin-badge { margin-left: 8px; font-size: 11px; background: rgba(255,255,255,0.2); padding: 2px 10px; border-radius: 12px; font-weight: 500; letter-spacing: 0.3px; }
@media (min-width: 769px) {
.navbar-component { padding: 0 24px 0 180px; }
}
@media (max-width: 768px) {
.navbar-component { padding: 0 16px; }
.navbar-component .navbar-title { font-size: 16px; }
}
`;
const styleEl = document.createElement('style');
styleEl.id = 'navbar-styles';
styleEl.textContent = styles;
document.head.appendChild(styleEl);
console.log('[Navbar] 样式已注入');
}
const installNavbar = (app) => {
console.log('[Navbar] installNavbar called');
injectStyles();
const NavbarComponent = {
name: 'NavbarComponent',
props: {
title: { type: String, default: '宇之然内容创作平台' },
username: { type: String, default: '' },
isAdmin: { type: Boolean, default: false },
onLogout: { type: Function, default: null }
},
template: `
<nav class="navbar-component">
<div class="navbar-content" style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h1 class="navbar-title">{{ title }}</h1>
<div class="navbar-user">
<div class="user-info">
<div class="avatar">{{ username ? username.charAt(0).toUpperCase() : '?' }}</div>
<span v-if="username">{{ username }}</span>
</div>
<span v-if="isAdmin" class="admin-badge">管理员</span>
<button class="logout-btn" @click="handleLogout">退出</button>
</div>
</div>
</nav>
`,
methods: {
handleLogout() {
if (this.onLogout) {
this.onLogout();
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
window.location.href = '/login.html';
}
}
}
};
app.component('navbar-component', NavbarComponent);
console.log('[Navbar] 组件已注册');
return app;
};
window.installNavbar = installNavbar;
console.log('[Navbar] 脚本已加载');
})();
-383
View File
@@ -1,383 +0,0 @@
// 纯DOM导航 - 通过 installNavigation 直接插入
(function() {
console.log('[Nav] 脚本加载');
function injectStyles() {
if (document.getElementById('navigation-styles')) return;
const styles = `
.nav-wrapper, .navigation-wrapper { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; z-index: 9999; pointer-events: none; }
.nav-wrapper .sidebar, .navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px 8px; box-shadow: 2px 0 12px rgba(0,0,0,0.06); overflow-y: auto; z-index: 10000; border-right: 1px solid #ebeef5; pointer-events: auto; }
.nav-wrapper .sidebar-header, .navigation-wrapper .sidebar-header { padding: 12px 12px 16px; border-bottom: 1px solid #f0f2f5; margin-bottom: 12px; }
.nav-wrapper .sidebar-header h3, .navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 15px; font-weight: 700; background: linear-gradient(135deg, #2563eb, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.3px; }
.nav-wrapper .sidebar-btn, .navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 10px; margin-bottom: 4px; cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); color: #606266; font-size: 13px; display: flex; align-items: center; gap: 8px; position: relative; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-wrapper .sidebar-btn:hover, .navigation-wrapper .sidebar-btn:hover { background: #f0f4ff; color: #2563eb; transform: translateX(2px); }
.nav-wrapper .sidebar-btn.active, .navigation-wrapper .sidebar-btn.active { background: linear-gradient(135deg, #eef2ff, #e0e7ff); color: #2563eb; font-weight: 600 !important; box-shadow: inset 3px 0 0 #2563eb; }
.nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(135deg, #2563eb, #1d4ed8); box-shadow: 0 -4px 20px rgba(37, 99, 235, 0.25); padding: 4px 0; z-index: 99999; justify-content: space-around; pointer-events: auto; }
.nav-wrapper .mobile-nav-btn, .navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 4px 2px; text-align: center; font-size: 10px; color: rgba(255,255,255,0.8) !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0; transition: all 0.2s; border-radius: 8px; margin: 0 1px; min-width: 0; }
.nav-wrapper .mobile-nav-btn .nav-icon, .navigation-wrapper .mobile-nav-btn .nav-icon { font-size: 16px; line-height: 1.2; }
.nav-wrapper .mobile-nav-btn .nav-text, .navigation-wrapper .mobile-nav-btn .nav-text { font-size: 9px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.nav-wrapper .mobile-nav-btn:hover, .navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.15); color: white !important; }
.nav-wrapper .mobile-nav-btn.active, .navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.2); color: white !important; font-weight: 600 !important; }
.mobile-more-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); z-index: 100000; animation: fadeIn 0.2s ease-out; }
.mobile-more-sheet { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-radius: 16px 16px 0 0; box-shadow: 0 -8px 32px rgba(0,0,0,0.15); z-index: 100001; padding: 20px 16px calc(env(safe-area-inset-bottom) + 60px); animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); max-height: 70vh; overflow-y: auto; pointer-events: auto; }
.mobile-more-sheet .sheet-handle { width: 36px; height: 4px; background: #e0e0e0; border-radius: 2px; margin: 0 auto 16px; }
.mobile-more-sheet .sheet-title { font-size: 16px; font-weight: 700; color: #303133; margin-bottom: 16px; text-align: center; }
.mobile-more-sheet .sheet-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.mobile-more-sheet .sheet-item { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 4px; border: none; background: transparent; border-radius: 12px; cursor: pointer; transition: all 0.2s; color: #606266; font-size: 12px; white-space: nowrap; }
.mobile-more-sheet .sheet-item:hover { background: #f0f4ff; color: #2563eb; }
.mobile-more-sheet .sheet-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }
.mobile-more-sheet .sheet-item .item-icon { font-size: 24px; line-height: 1; }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@media (max-width: 768px) { .nav-wrapper .sidebar, .navigation-wrapper .sidebar { display: none !important; } .nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: flex !important; } body > #app > .main-content { padding-bottom: 56px !important; } }
body > #app > .main-content { margin-left: 180px !important; padding-top: 60px !important; }
@media (max-width: 768px) { body > #app > .main-content { margin-left: 0 !important; padding-bottom: 60px !important; } }
`;
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'navigation-styles', textContent: styles }));
console.log('[Nav] 样式已注入');
}
function createNavigation(currentPage, isAdmin, onNavigate) {
const wrapper = document.createElement('div');
wrapper.className = 'navigation-wrapper';
// 侧边栏
const sidebar = document.createElement('aside');
sidebar.className = 'sidebar';
sidebar.innerHTML = `
<div class="sidebar-header"><h3>宇之然平台</h3></div>
<nav class="sidebar-nav">
<button class="sidebar-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊 系统概览</button>
<button class="sidebar-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋 选题管理</button>
<button class="sidebar-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊 数据分析</button>
<button class="sidebar-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html">📅 内容日历</button>
<button class="sidebar-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️ 素材库</button>
<button class="sidebar-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀 创作任务</button>
<button class="sidebar-btn ${currentPage==='platforms'?'active':''}" data-page="platforms.html">🌐 平台配置</button>
<button class="sidebar-btn ${currentPage==='logs'?'active':''}" data-page="logs.html">📄 系统日志</button>
${isAdmin ? `<button class="sidebar-btn ${currentPage==='users'?'active':''}" data-page="users.html">👥 用户管理</button>` : ''}
${isAdmin ? `<button class="sidebar-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️ 系统管理</button>` : ''}
</nav>
`;
// 移动端底部导航
const mobileNav = document.createElement('nav');
mobileNav.className = 'mobile-nav';
mobileNav.innerHTML = `
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/"><span class="nav-icon">📊</span><span class="nav-text">首页</span></button>
<button class="mobile-nav-btn ${currentPage==='topics'?'active':''}" data-page="topics.html"><span class="nav-icon">📋</span><span class="nav-text">选题</span></button>
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html"><span class="nav-icon">🚀</span><span class="nav-text">任务</span></button>
<button class="mobile-nav-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html"><span class="nav-icon">📅</span><span class="nav-text">日历</span></button>
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html"><span class="nav-icon">🖼️</span><span class="nav-text">素材</span></button>
<button class="mobile-nav-btn" id="mobile-more-btn"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
`;
// "更多"弹出菜单
const sheetItems = [
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true },
];
const sheetItemsHtml = sheetItems.map(item =>
`<button class="sheet-item ${currentPage===item.key?'active':''}" data-page="${item.page}"${item.admin ? ' data-admin="1"' : ''} style="${item.admin && !isAdmin ? 'display:none' : ''}">
<span class="item-icon">${item.icon}</span><span>${item.label}</span>
</button>`
).join('');
const backdrop = document.createElement('div');
backdrop.className = 'mobile-more-backdrop';
backdrop.id = 'mobile-more-backdrop';
backdrop.style.display = 'none';
const sheet = document.createElement('div');
sheet.className = 'mobile-more-sheet';
sheet.id = 'mobile-more-sheet';
sheet.style.display = 'none';
sheet.innerHTML = `<div class="sheet-handle"></div><div class="sheet-title">所有菜单</div><div class="sheet-grid">${sheetItemsHtml}</div>`;
const showMore = () => {
backdrop.style.display = ''; sheet.style.display = '';
};
const hideMore = () => {
backdrop.style.display = 'none'; sheet.style.display = 'none';
};
// 绑定导航跳转
const handleNavClick = (btn) => {
const page = btn.getAttribute('data-page');
if (!page) return;
console.log('[Nav] 点击导航:', page);
hideMore();
if (onNavigate) { onNavigate(page); return; }
const target = page === '/' ? '/' : (page.startsWith('/') ? page : '/' + page);
window.location.href = target;
};
wrapper.appendChild(sidebar);
wrapper.appendChild(mobileNav);
wrapper.appendChild(backdrop);
wrapper.appendChild(sheet);
// 事件绑定
wrapper.querySelectorAll('.sidebar-btn').forEach(btn => {
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
});
wrapper.querySelectorAll('.mobile-nav-btn').forEach(btn => {
if (btn.id === 'mobile-more-btn') {
btn.addEventListener('click', (e) => { e.preventDefault(); showMore(); });
} else {
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
}
});
wrapper.querySelectorAll('.sheet-item').forEach(btn => {
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
});
backdrop.addEventListener('click', hideMore);
return wrapper;
}
// 新的 installNavigation 接口:接收 app 对象(为了兼容),但实际不注册组件
window.installNavigation = function(app, options = {}) {
console.log('[Nav] installNavigation called (DOM mode)');
injectStyles();
// 延迟执行,确保DOM就绪
const init = () => {
const container = document.getElementById('app');
if (!container) {
console.warn('[Nav] #app 未找到,等待');
setTimeout(init, 100);
return;
}
// 获取当前页面名称(从URL或body类名推断)
let currentPage = 'dashboard';
const path = window.location.pathname;
if (path.includes('topics')) currentPage = 'topics';
else if (path.includes('calendar')) currentPage = 'calendar';
else if (path.includes('metrics')) currentPage = 'metrics';
else if (path.includes('assets')) currentPage = 'assets';
else if (path.includes('tasks')) currentPage = 'tasks';
else if (path.includes('platforms')) currentPage = 'platforms';
else if (path.includes('logs')) currentPage = 'logs';
else if (path.includes('users')) currentPage = 'users';
else if (path.includes('admin')) currentPage = 'admin';
// 尝试从 Vue 实例获取 isAdmin
let isAdmin = false;
let redirectToPage = null;
// 首先检查 localStorage,如果用户已登录且是管理员
const userRole = localStorage.getItem('userRole');
if (userRole === 'admin') {
isAdmin = true;
console.log('[Nav] 从 localStorage 获取到 admin 角色');
}
// 尝试从 Vue 实例提取数据
const getVueData = () => {
if (app && app._instance && app._instance.proxy) {
return app._instance.proxy;
} else if (window.Vue && window.Vue.app && window.Vue.app._instance && window.Vue.app._instance.proxy) {
return window.Vue.app._instance.proxy;
}
return null;
};
const updateNavFromVue = () => {
const proxy = getVueData();
if (!proxy) return false;
const newIsAdmin = proxy.isAdmin === true;
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
// 如果侧边栏已存在,更新管理菜单显示状态
const sidebar = document.querySelector('.sidebar-nav');
if (sidebar) {
const adminBtns = sidebar.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
adminBtns.forEach(btn => {
btn.style.display = isAdmin ? '' : 'none';
});
}
return true;
};
// 延迟获取 isAdmin,确保 Vue mounted 已执行
setTimeout(() => {
updateNavFromVue();
console.log('[Nav] 延迟获取后 isAdmin:', isAdmin);
// 如果侧边栏已存在,再次更新管理菜单显示状态
const sidebarEl = container.querySelector('.sidebar-nav');
if (sidebarEl) {
const adminBtns = sidebarEl.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
adminBtns.forEach(btn => {
btn.style.display = isAdmin ? '' : 'none';
});
}
}, 500);
// 持续监听 Vue 数据变化
const stopWatch = setInterval(() => {
const updated = updateNavFromVue();
if (updated && isAdmin) {
clearInterval(stopWatch);
console.log('[Nav] 已获取到 isAdmin:', isAdmin);
}
}, 200);
// 5秒后停止监听
setTimeout(() => clearInterval(stopWatch), 5000);
console.log('[Nav] currentPage:', currentPage, '初始 isAdmin:', isAdmin);
// 默认跳转函数
const defaultNavigate = (page) => {
const target = page === '/' ? '/index.html' : (page.startsWith('/') ? page : '/' + page);
window.location.href = target;
};
// 移除旧的导航容器(如果有)
const oldNav = container.querySelector('.navigation-wrapper');
if (oldNav) oldNav.remove();
const nav = createNavigation(currentPage, isAdmin, redirectToPage || defaultNavigate);
container.insertBefore(nav, container.firstChild);
console.log('[Nav] 导航已插入');
};
// 延迟执行,确保 Vue 实例挂载完成
setTimeout(init, 100);
return app;
};
console.log('[Nav] 脚本已加载(纯DOM版)');
// 注册 Vue 组件 (备用方案)
const createNavComponent = () => ({
props: {
currentPage: { type: String, default: 'dashboard' },
isAdmin: { type: Boolean, default: false },
onNavigate: { type: Function, default: null }
},
data() {
return {
menuItems: [
{ key: 'dashboard', label: '系统概览', icon: '📊', page: '/' },
{ key: 'topics', label: '选题管理', icon: '📋', page: 'topics.html' },
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
{ key: 'calendar', label: '内容日历', icon: '📅', page: 'calendar.html' },
{ key: 'assets', label: '素材库', icon: '🖼️', page: 'assets.html' },
{ key: 'tasks', label: '创作任务', icon: '🚀', page: 'tasks.html' },
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' }
],
adminItems: [
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html' },
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html' }
],
mobileItems: [
{ key: 'dashboard', label: '首页', icon: '📊', page: '/' },
{ key: 'topics', label: '选题', icon: '📋', page: 'topics.html' },
{ key: 'tasks', label: '任务', icon: '🚀', page: 'tasks.html' },
{ key: 'calendar', label: '日历', icon: '📅', page: 'calendar.html' },
{ key: 'assets', label: '素材', icon: '🖼️', page: 'assets.html' }
],
sheetItems: [
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true }
],
showMoreSheet: false
};
},
template: `
<div class="nav-wrapper">
<aside class="sidebar">
<div class="sidebar-header"><h3>宇之然平台</h3></div>
<nav class="sidebar-nav">
<button v-for="item in menuItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
{{ item.icon }} {{ item.label }}
</button>
<template v-if="isAdmin">
<button v-for="item in adminItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
{{ item.icon }} {{ item.label }}
</button>
</template>
</nav>
</aside>
<nav class="mobile-nav">
<button v-for="item in mobileItems" :key="item.key" :class="['mobile-nav-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-text">{{ item.label }}</span>
</button>
<button class="mobile-nav-btn" @click="showMoreSheet = !showMoreSheet"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
</nav>
<div v-if="showMoreSheet" class="mobile-more-backdrop" @click="showMoreSheet = false"></div>
<div v-if="showMoreSheet" class="mobile-more-sheet">
<div class="sheet-handle"></div>
<div class="sheet-title">所有菜单</div>
<div class="sheet-grid">
<button v-for="item in sheetItems" :key="item.key" v-show="!item.admin || isAdmin" :class="['sheet-item', { active: currentPage === item.key }]" @click="navigate(item.page)">
<span class="item-icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</button>
</div>
</div>
</div>
`,
methods: {
navigate(page) {
console.log('[Nav Vue] 点击导航:', page);
if (this.onNavigate) {
this.onNavigate(page);
} else {
// 修复路径处理
let target;
if (page === '/' || page === '') {
target = '/';
} else if (page.startsWith('/')) {
target = page;
} else {
target = '/' + page;
}
window.location.href = target;
}
}
},
mounted() {
injectStyles();
}
});
// 注册全局组件
window.NavigationComponent = createNavComponent();
// 自动注入 DOM 导航 (优先使用)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
if (window.Vue && window.Vue.app) {
window.Vue.app._instance.proxy.$nextTick(() => {
const app = window.Vue.app;
if (app && app._instance && app._instance.proxy) {
const proxy = app._instance.proxy;
const nav = createNavigation(proxy.currentPage || 'dashboard', proxy.isAdmin || false, proxy.redirectToPage || null);
const container = document.getElementById('app');
if (container && !container.querySelector('.navigation-wrapper')) {
container.insertBefore(nav, container.firstChild);
}
}
});
}
}, 500);
});
}
})();
+20 -38
View File
@@ -7,19 +7,6 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.platform-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 20px; margin-bottom: 16px; transition: all 0.3s ease; word-break: break-word; background: #fff; }
.platform-card:hover { border-color: #409eff; box-shadow: 0 4px 20px rgba(64,158,255,0.12); transform: translateY(-1px); }
.platform-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; }
@@ -39,51 +26,44 @@
.rule-item strong { color: #303133; }
.rule-item:last-child { margin-bottom: 0; }
.empty-state { text-align: center; padding: 60px 20px; color: #909399; }
.empty-state-icon { font-size: 56px; margin-bottom: 16px; }
.empty-state-text { font-size: 16px; }
.loading-state { text-align: center; padding: 60px 20px; color: #909399; font-size: 16px; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.toolbar { width: 100%; }
.toolbar .el-button { flex: 1; justify-content: center; }
.platform-card { padding: 16px; }
.platform-header { flex-direction: column; }
.platform-actions { align-self: flex-end; }
.platform-actions { align-self: flex-end; width: 100%; display: flex; justify-content: flex-end; }
.platform-meta { grid-template-columns: 1fr; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="平台配置" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="platforms" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="平台配置" :username="currentUser.username" :is-admin="isAdmin" current-page="platforms" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="page-header">
<h2 class="page-title">🌐 平台配置</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconGlobe /></el-icon> 平台配置</h2>
<div class="toolbar">
<el-button-group>
<el-button :type="showActiveOnly ? 'primary' : ''" @click="showActiveOnly = true; loadPlatforms()">启用中</el-button>
<el-button :type="!showActiveOnly ? 'primary' : ''" @click="showActiveOnly = false; loadPlatforms()">全部</el-button>
</el-button-group>
<el-button @click="loadPlatforms">🔄 刷新</el-button>
<el-button @click="loadPlatforms"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div class="card page-fade">
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="platforms.length === 0" class="empty-state">
<div class="empty-state-icon">🌐</div>
<div class="empty-state-text">暂无平台配置</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
<div class="empty-text">暂无平台配置</div>
</div>
<div v-else>
<div v-for="p in platforms" :key="p.platform" class="platform-card">
<div class="platform-header">
<div class="platform-name">
<span>{{ getPlatformIcon(p.platform) }}</span>
<el-icon><component :is="getPlatformIcon(p.platform)" /></el-icon>
{{ getPlatformName(p.platform) }}
</div>
<div class="platform-actions">
@@ -108,13 +88,13 @@
</div>
</div>
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0" class="rules-section">
<div class="rules-title">📋 格式规则</div>
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 格式规则</div>
<div v-for="(rule, key) in p.format_rules" :key="key" class="rule-item">
<strong>{{ key }}:</strong> {{ typeof rule === 'object' ? JSON.stringify(rule) : rule }}
</div>
</div>
<div v-if="p.compliance_rules && p.compliance_rules.length > 0" class="rules-section">
<div class="rules-title">⚖️ 合规规则</div>
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconWarning /></el-icon> 合规规则</div>
<div v-for="(rule, idx) in p.compliance_rules" :key="idx" class="rule-item">{{ rule }}</div>
</div>
</div>
@@ -152,6 +132,7 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const PlatformsApp = {
@@ -186,8 +167,8 @@ const PlatformsApp = {
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
getPlatformIcon(platform) {
const map = { 'zhihu': '💬', 'wechat': '💌', 'xiaohongshu': '📕', 'weibo': '🌐' };
return map[platform] || '🌐';
const map = { 'zhihu': 'IconTopic', 'wechat': 'IconDocument', 'xiaohongshu': 'IconPicture', 'weibo': 'IconGlobe' };
return map[platform] || 'IconGlobe';
},
getPlatformName(platform) {
const map = { 'zhihu': '知乎', 'wechat': '微信公众号', 'xiaohongshu': '小红书', 'weibo': '微博' };
@@ -199,7 +180,8 @@ const PlatformsApp = {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/platform-config?active_only=' + this.showActiveOnly, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.platforms = await res.json();
} catch (e) { console.error(e); }
else { const d = await res.json(); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载平台配置失败: ' + e.message); }
finally { this.loading = false; }
},
editPlatform(p) {
@@ -234,8 +216,8 @@ const PlatformsApp = {
};
const app = Vue.createApp(PlatformsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+20 -32
View File
@@ -7,17 +7,7 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.toolbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.toolbar { gap: 12px; }
.task-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; margin-bottom: 12px; transition: all 0.3s; background: #fff; }
.task-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.12); transform: translateY(-1px); }
@@ -56,38 +46,35 @@
.detail-value { flex: 1; font-size: 13px; color: #303133; word-break: break-all; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.task-table { display: none; }
.task-card-list-mobile { display: block; }
.schedule-row { padding: 10px 12px; gap: 10px; flex-wrap: wrap; }
.schedule-next { width: 100%; margin-left: 46px; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="创作任务" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="tasks" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="创作任务" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">🚀 定时任务</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 定时任务</h2>
<div class="toolbar">
<el-button @click="loadSchedulerStatus">🔄 刷新</el-button>
<el-button @click="loadSchedulerStatus"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div v-if="schedulerLoading" style="text-align: center; padding: 20px; color: #909399;">加载中...</div>
<div v-else>
<div v-if="schedulerJobs.length === 0" style="text-align: center; padding: 30px 20px; color: #909399; font-size: 14px;">暂无定时任务</div>
<div v-for="job in schedulerJobs" :key="job.id" class="schedule-row">
<div class="schedule-icon">{{ job.icon }}</div>
<div class="schedule-icon"><el-icon><component :is="job.icon" /></el-icon></div>
<div class="schedule-info">
<div class="schedule-name">{{ job.name }}</div>
<div class="schedule-time"> 每日 {{ job.time }}</div>
<div class="schedule-time"><el-icon style="vertical-align:-2px;"><IconClock /></el-icon> 每日 {{ job.time }}</div>
</div>
<div class="schedule-next">
<span class="schedule-badge" :class="job.active ? 'active' : 'inactive'"></span>
@@ -100,7 +87,7 @@
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">🚀 创作任务</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 创作任务</h2>
<div class="toolbar">
<el-button-group>
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
@@ -109,12 +96,12 @@
<el-button :type="filterStatus === 'completed' ? 'primary' : ''" @click="filterStatus = 'completed'; loadTasks()">已完成</el-button>
<el-button :type="filterStatus === 'failed' ? 'primary' : ''" @click="filterStatus = 'failed'; loadTasks()">失败</el-button>
</el-button-group>
<el-button @click="loadTasks">🔄 刷新</el-button>
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div v-if="loading" style="text-align: center; padding: 40px; color: #909399;">加载中...</div>
<div v-else-if="tasks.length === 0" style="text-align: center; padding: 60px 20px; color: #909399;">
<div style="font-size: 56px; margin-bottom: 16px;">📋</div>
<el-icon style="font-size:56px;margin-bottom:16px;color:#c0c4cc;"><IconTopic /></el-icon>
<div style="font-size: 16px;">暂无任务</div>
<div style="margin-top: 12px; font-size: 14px;">前往 <a href="/topics.html" style="color: #409eff;">选题管理</a> 创建新任务</div>
</div>
@@ -173,14 +160,15 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TasksApp = {
data() {
const SCHEDULER_JOBS = {
'scheduled_sync': { icon: '🔄', name: '数据同步', defaultTime: '02:30' },
'scheduled_generate': { icon: '🤖', name: '内容创作', defaultTime: '03:30' },
'scheduled_optimize': { icon: '🔍', name: '合规审查', defaultTime: '04:30' },
'scheduled_sync': { icon: 'IconRefresh', name: '数据同步', defaultTime: '02:30' },
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04:30' },
};
return {
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
@@ -215,7 +203,7 @@ const TasksApp = {
const data = await this.api('/api/system/scheduler/status');
if (!data) return;
this.schedulerJobs = (data.jobs || []).map(job => {
const info = this.SCHEDULER_JOBS[job.id] || { icon: '', name: job.id, defaultTime: '' };
const info = this.SCHEDULER_JOBS[job.id] || { icon: 'IconClock', name: job.id, defaultTime: '' };
// Parse cron trigger for time display
let time = info.defaultTime;
const m = job.trigger && job.trigger.match(/hour='?(\d+)'?,\s*minute='?(\d+)'?/);
@@ -227,7 +215,7 @@ const TasksApp = {
}
return { id: job.id, icon: info.icon, name: info.name, time, active: data.running, next_run };
});
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载调度状态失败: ' + e.message); }
finally { this.schedulerLoading = false; }
},
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
@@ -242,7 +230,7 @@ const TasksApp = {
let url = '/api/tasks?limit=50';
if (this.filterStatus) url += '&status=' + this.filterStatus;
this.tasks = await this.api(url) || [];
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载任务列表失败: ' + e.message); }
finally { this.loading = false; }
},
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
@@ -267,8 +255,8 @@ const TasksApp = {
};
const app = Vue.createApp(TasksApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+117 -3
View File
@@ -36,12 +36,33 @@
.status-badge-modern { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.status-badge-modern.running { background: rgba(103, 194, 58, 0.12); color: #67c23a; border: 1px solid rgba(103, 194, 58, 0.25); animation: pulse-glow 2s infinite; }
/* Modern el-table refinements */
.el-table { border-radius: 12px; overflow: hidden; }
/* el-table refinements */
.el-table { border-radius: 12px; overflow: hidden; width: 100%; }
.el-table .el-table__cell { word-break: break-word; }
/* Smooth page transitions */
.page-enter-active { animation: fadeIn 0.4s ease-out; }
/* ========== Reset & Base ========== */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
/* ========== 统一布局 ========== */
/* 固定顶部导航占位,所有页面自动继承 */
.main-content { padding-top: 60px !important; display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
/* ========== 共享卡片 ========== */
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
/* ========== 页面标题 ========== */
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
/* ========== 筛选/工具栏 ========== */
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* ========== H5响应式 ========== */
@media (max-width: 768px) {
/* 对话框自适应宽度 */
@@ -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
View File
@@ -7,16 +7,6 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.selected-count { color: #909399; font-size: 14px; margin-left: auto; }
.status-badge { display: inline-flex; align-items: center; gap: 4px; }
@@ -28,17 +18,7 @@
.topic-card-list { display: none; }
.preview-iframe { box-sizing: border-box; }
.preview-dialog-custom.el-dialog { max-height: calc(100vh - 90px); overflow: hidden; display: flex; flex-direction: column; margin-top: 0 !important; }
.preview-dialog-custom.el-dialog .el-dialog__header { padding: 8px 12px; margin: 0; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; }
.preview-dialog-custom.el-dialog .el-dialog__body { padding: 12px; overflow: hidden; }
.preview-dialog-custom.el-dialog .el-dialog__footer { flex-shrink: 0; padding: 8px 12px; }
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
@media (max-width: 768px) {
.content-area { padding: 12px; padding-bottom: 80px; }
.card { padding: 16px; }
.el-table { display: none; }
.topic-card-list { display: block; }
.topic-card {
@@ -52,41 +32,38 @@
.topic-card-title { font-size: 16px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
.topic-card-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.topic-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: #606266; margin-bottom: 12px; }
.topic-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
.preview-iframe { max-height: calc(100vh - 250px) !important; }
}
@media (min-width: 769px) {
.preview-iframe { max-height: calc(100vh - 100px) !important; }
.preview-dialog-custom { position: relative; left: 90px; }
.topic-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
.topic-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
.topic-card-actions .el-button:last-child:nth-child(odd) { grid-column: 1 / -1; }
.topic-card.is-checked { border: 2px solid #409eff; box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="选题管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="topics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">📋 选题管理</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 选题管理</h2>
<div class="toolbar">
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0">🔍 批量审查</el-button>
<el-button type="primary" size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷新</el-button>
<el-button size="small" @click="toggleSelectAll">{{ selectAllLabel }}</el-button>
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon> 批量创作</el-button>
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 批量审查</el-button>
<span v-if="selectedTopicIds.length > 0" class="selected-count">已选 {{ selectedTopicIds.length }} 项</span>
</div>
</div>
<div class="filter-bar">
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'">今日新增 ({{ todayCount }})</el-button>
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; fetchTopics()">全部 ({{ topics.length }})</el-button>
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'; fetchTopics()">今日新增 ({{ todayCount }})</el-button>
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'; fetchTopics()">待处理 ({{ statusStats.pending }})</el-button>
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button>
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
</div>
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
<el-table-column type="selection" width="55"></el-table-column>
@@ -102,22 +79,26 @@
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column prop="generated_at" label="创作时间" width="140"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
<el-table-column prop="published_at" label="发布时间" width="140"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="230" fixed="right">
<template #default="scope">
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)">审查</el-button>
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)">删除</el-button>
<div style="display: flex; gap: 4px; white-space: nowrap;">
<el-button size="small" @click="openPreview(scope.row)" type="primary" style="padding:5px 8px;">预览</el-button>
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button>
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="!loadingTable && filteredTopics.length === 0" class="empty-state" style="margin-top:20px;"><el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon><div class="empty-text">暂无选题数据</div></div>
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card">
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card" :class="{ 'is-checked': selectedTopicIds.includes(topic.id) }">
<div class="topic-card-header">
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
<div class="topic-card-title">
<el-checkbox :checked="selectedTopicIds.includes(topic.id)" @change="toggleCheck(topic.id)" style="margin-right:6px;"></el-checkbox>
{{ topic.id }}. {{ topic.title }}
</div>
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
</div>
<div class="topic-card-tags">
@@ -133,7 +114,7 @@
<el-button size="small" @click="openPreview(topic)" type="primary">预览</el-button>
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button>
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
</div>
</div>
@@ -141,6 +122,30 @@
</div>
</main>
</div>
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
<div v-if="publishTopic">
<div style="margin-bottom:16px;">
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
<div style="font-size:15px;font-weight:600;color:#303133;">{{ publishTopic.id }}. {{ publishTopic.title }}</div>
</div>
<div style="margin-bottom:16px;">
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选择发布平台:</div>
<el-checkbox v-model="publishPlatforms.zhihu" label="zhihu" style="display:block;margin-bottom:8px;">知乎</el-checkbox>
<el-checkbox v-model="publishPlatforms.wechat" label="wechat" style="display:block;margin-bottom:8px;">微信公众号</el-checkbox>
<el-checkbox v-model="publishPlatforms.xiaohongshu" label="xiaohongshu" style="display:block;">小红书</el-checkbox>
</div>
<div v-if="publishing" style="text-align:center;padding:12px;color:#909399;">
<el-icon class="is-loading" style="margin-right:4px;">
<svg viewBox="0 0 1024 1024" width="16" height="16"><path d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896z" fill="none" stroke="currentColor" stroke-width="64"/></svg>
</el-icon>
正在发布...
</div>
</div>
<template #footer>
<el-button @click="publishDialogVisible = false" :disabled="publishing">取消</el-button>
<el-button type="primary" @click="confirmPublish" :loading="publishing" :disabled="!publishPlatforms.zhihu && !publishPlatforms.wechat && !publishPlatforms.xiaohongshu">确认发布</el-button>
</template>
</el-dialog>
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
<div v-if="previewTopic">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
@@ -181,6 +186,7 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TopicsApp = {
@@ -191,22 +197,15 @@ const TopicsApp = {
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [], todayCount: 0,
previewVisible: false, previewTopic: null, previewFullscreen: false,
previewPlatform: 'zhihu', platformContents: {}
previewPlatform: 'zhihu', platformContents: {},
publishDialogVisible: false, publishTopic: null,
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
publishing: false
}
},
computed: {
filteredTopics() {
if (!this.topics || !this.topics.length) return [];
if (!this.filterStatus) return this.topics;
if (this.filterStatus === 'today') {
const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
return this.topics.filter(t => {
const d = t.created_at;
if (!d) return false;
return d.slice(0, 10) === todayStr;
});
}
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
const allowed = map[this.filterStatus] || [this.filterStatus];
return this.topics.filter(t => allowed.includes(t.status));
@@ -231,6 +230,12 @@ const TopicsApp = {
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
} catch (e) { console.error('生成预览 HTML 失败:', e); return html; }
},
selectAllLabel() {
const visible = this.filteredTopics.map(t => t.id);
if (visible.length === 0) return '全选';
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
return allChecked ? '取消全选' : `全选 (${visible.length})`;
}
},
methods: {
@@ -245,7 +250,8 @@ const TopicsApp = {
async fetchTopics() {
this.loadingTable = true;
try {
const data = await this.api('/api/topics');
const params = this.filterStatus === 'today' ? '?today=true' : '';
const data = await this.api('/api/topics' + params);
this.topics = data || [];
this.$message.success('选题加载成功');
} catch (error) {
@@ -258,7 +264,7 @@ const TopicsApp = {
try {
const stats = await this.api('/api/topics/stats');
if (stats) this.todayCount = stats.today_created || 0;
} catch (e) { console.error('获取统计失败:', e); }
} catch (e) { console.error('获取统计失败:', e); this.$message.error('获取统计失败: ' + e.message); }
},
refreshAll() { this.fetchTopics(); this.fetchTodayCount(); this.$message.success('已刷新'); },
async triggerGenerateSelected() {
@@ -272,7 +278,7 @@ const TopicsApp = {
}
this.$message.success(`已提交 ${count}/${this.selectedTopicIds.length} 个创作任务,可在「创作任务」页面查看进度`);
this.selectedTopicIds = [];
setTimeout(() => this.fetchTopics(), 2000);
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
},
async triggerReviewSelected() {
if (!this.selectedTopicIds.length) return;
@@ -281,6 +287,7 @@ const TopicsApp = {
this.$message.success('批量审查完成');
this.selectedTopicIds = [];
await this.fetchTopics();
await this.fetchTodayCount();
} catch (error) { this.$message.error(`批量审查失败: ${error.message}`); }
},
async openPreview(topic) {
@@ -288,9 +295,10 @@ const TopicsApp = {
const token = this.getToken();
if (!token) return;
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
const names = { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' };
await Promise.all(platforms.map(p =>
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => console.error(`加载${p}预览失败:`, e))
.then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => { console.error(`加载${p}预览失败:`, e); this.$message.error(`加载${names[p]}预览失败`); })
));
},
togglePreviewFullscreen() {
@@ -321,7 +329,7 @@ const TopicsApp = {
try {
const data = await this.api('/api/tasks/run-creator?topic_id=' + topic.id, { method: 'POST' });
this.$message.success(`创作任务已启动: ${topic.title},可在「创作任务」页面查看进度`);
setTimeout(() => this.fetchTopics(), 2000);
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
} catch (error) { this.$message.error(`创作失败: ${error.message}`); }
},
async reviewTopic(topic) {
@@ -330,15 +338,31 @@ const TopicsApp = {
const data = await this.api('/api/system/review/run', { method: 'POST', body: JSON.stringify({ topic_ids: [topic.id] }) });
this.$message.success(`审查完成: ${topic.title}`);
await this.fetchTopics();
await this.fetchTodayCount();
} catch (error) { this.$message.error(`审查失败: ${error.message}`); }
},
async handlePublish(topic) {
openPublishDialog(topic) {
if (!this.isStatus(topic, 'ready')) { this.$message.info('仅待发布选题可发布'); return; }
this.publishTopic = topic;
this.publishPlatforms = { zhihu: true, wechat: true, xiaohongshu: true };
this.publishing = false;
this.publishDialogVisible = true;
},
async confirmPublish() {
const selected = Object.entries(this.publishPlatforms).filter(([, v]) => v).map(([k]) => k);
if (selected.length === 0) { this.$message.warning('请至少选择一个平台'); return; }
this.publishing = true;
try {
const data = await this.api('/api/publishing/create', { method: 'POST', body: JSON.stringify({ topic_id: topic.id }) });
this.$message.success(`发布成功: ${topic.title}`);
const data = await this.api('/api/publishing/create', {
method: 'POST',
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
});
this.$message.success(`发布完成: ${this.publishTopic.title}`);
this.publishDialogVisible = false;
await this.fetchTopics();
await this.fetchTodayCount();
} catch (error) { this.$message.error(`发布失败: ${error.message}`); }
finally { this.publishing = false; }
},
async deleteTopic(id) {
try {
@@ -346,11 +370,30 @@ const TopicsApp = {
await this.api('/api/topics/' + id, { method: 'DELETE' });
this.$message.success('删除成功');
await this.fetchTopics();
await this.fetchTodayCount();
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败'); }
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
toggleCheck(id) {
const idx = this.selectedTopicIds.indexOf(id);
if (idx >= 0) {
this.selectedTopicIds.splice(idx, 1);
} else {
this.selectedTopicIds.push(id);
}
},
toggleSelectAll() {
const visible = this.filteredTopics.map(t => t.id);
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
if (allChecked) {
this.selectedTopicIds = this.selectedTopicIds.filter(id => !visible.includes(id));
} else {
const existing = new Set(this.selectedTopicIds);
for (const id of visible) { if (!existing.has(id)) { this.selectedTopicIds.push(id); } }
}
},
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
@@ -362,18 +405,24 @@ const TopicsApp = {
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
const filter = new URLSearchParams(window.location.search).get('filter');
if (filter) this.filterStatus = filter;
const urlFilter = new URLSearchParams(window.location.search).get('filter');
if (urlFilter) this.filterStatus = urlFilter;
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchTopics(); this.fetchTodayCount(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
watch: {
topics() {
const urlFilter = new URLSearchParams(window.location.search).get('filter');
if (urlFilter) this.filterStatus = urlFilter;
}
}
};
const app = Vue.createApp(TopicsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+230
View File
@@ -0,0 +1,230 @@
(function () {
if (document.getElementById('uni-nav-styles')) return;
var style = document.createElement('style');
style.id = 'uni-nav-styles';
style.textContent = '\
.uni-nav {\
position: fixed; top: 0; left: 0; right: 0; height: 60px;\
z-index: 10000;\
display: flex; align-items: center;\
padding: 0 16px;\
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
color: #fff;\
box-shadow: 0 2px 12px rgba(37,99,235,0.25);\
}\
.uni-nav-inner {\
display: flex; align-items: center;\
width: 100%; max-width: 1400px; margin: 0 auto;\
gap: 8px;\
}\
.uni-nav-brand {\
font-size: 18px; font-weight: 700;\
white-space: nowrap;\
margin-right: 8px;\
letter-spacing: -0.3px;\
}\
.uni-nav-items {\
display: flex; align-items: center; gap: 2px;\
flex: 1; overflow: hidden;\
}\
.uni-nav-item {\
display: inline-flex; align-items: center; gap: 4px;\
padding: 6px 12px;\
border: none; background: transparent;\
color: rgba(255,255,255,0.8);\
font-size: 13px;\
border-radius: 8px;\
cursor: pointer;\
white-space: nowrap;\
transition: all 0.2s;\
}\
.uni-nav-item:hover { background: rgba(255,255,255,0.12); color: #fff; }\
.uni-nav-item.active {\
background: rgba(255,255,255,0.18); color: #fff; font-weight: 600;\
}\
.uni-nav-right {\
display: flex; align-items: center; gap: 8px;\
flex-shrink: 0;\
}\
.uni-nav-avatar {\
width: 30px; height: 30px;\
border-radius: 50%;\
background: rgba(255,255,255,0.2);\
display: flex; align-items: center; justify-content: center;\
font-size: 13px; font-weight: 600;\
}\
.uni-nav-username { font-size: 13px; color: rgba(255,255,255,0.9); }\
.uni-nav-badge {\
font-size: 10px;\
background: rgba(255,255,255,0.15);\
padding: 1px 8px; border-radius: 10px;\
margin-left: 4px;\
}\
.uni-nav-logout {\
background: rgba(255,255,255,0.1);\
border: 1px solid rgba(255,255,255,0.15);\
color: rgba(255,255,255,0.9);\
padding: 4px 12px; border-radius: 6px;\
cursor: pointer; font-size: 12px;\
transition: all 0.2s;\
}\
.uni-nav-logout:hover { background: rgba(255,255,255,0.2); }\
.uni-nav-hamburger {\
display: none;\
background: rgba(255,255,255,0.1);\
border: none; color: #fff;\
width: 36px; height: 36px;\
border-radius: 8px;\
cursor: pointer; font-size: 20px;\
align-items: center; justify-content: center;\
transition: all 0.2s;\
}\
.uni-nav-hamburger:hover { background: rgba(255,255,255,0.18); }\
.uni-nav-dropdown {\
display: none;\
position: fixed; top: 60px; left: 0; right: 0;\
background: #fff;\
box-shadow: 0 8px 24px rgba(0,0,0,0.12);\
z-index: 9999;\
padding: 8px;\
max-height: calc(100vh - 60px);\
overflow-y: auto;\
animation: uniNavSlideDown 0.2s ease-out;\
}\
.uni-nav-dropdown.open { display: block; }\
.uni-nav-dropdown-item {\
display: flex; align-items: center; gap: 8px;\
width: 100%;\
padding: 12px 16px;\
border: none; background: transparent;\
color: #303133; font-size: 14px;\
border-radius: 8px;\
cursor: pointer;\
transition: all 0.15s;\
}\
.uni-nav-dropdown-item:hover { background: #f0f4ff; color: #2563eb; }\
.uni-nav-dropdown-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }\
.uni-nav-dropdown-divider {\
height: 1px; background: #f0f0f0; margin: 4px 0;\
}\
@media (max-width: 768px) {\
.uni-nav-items { display: none; }\
.uni-nav-username { display: none; }\
.uni-nav-hamburger { display: inline-flex; }\
}\
@keyframes uniNavSlideDown {\
from { opacity: 0; transform: translateY(-8px); }\
to { opacity: 1; transform: translateY(0); }\
}\
';
document.head.appendChild(style);
function getCurrentPage() {
var path = window.location.pathname;
if (path === '/' || path === '/index.html') return 'dashboard';
var m = path.match(/\/(\w+)\.html/);
return m ? m[1] : 'dashboard';
}
function navItems(isAdmin) {
var items = [
{ key: 'dashboard', label: '仪表盘', page: '/' },
{ key: 'topics', label: '选题', page: 'topics.html' },
{ key: 'metrics', label: '数据', page: 'metrics.html' },
{ key: 'calendar', label: '日历', page: 'calendar.html' },
{ key: 'assets', label: '素材', page: 'assets.html' },
{ key: 'tasks', label: '任务', page: 'tasks.html' },
{ key: 'platforms', label: '平台', page: 'platforms.html' },
{ key: 'logs', label: '日志', page: 'logs.html' },
];
if (isAdmin) {
items.push({ key: 'users', label: '用户', page: 'users.html', admin: true });
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
}
return items;
}
var UniNav = {
name: 'UniNav',
props: {
title: { type: String, default: '' },
username: { type: String, default: '' },
isAdmin: { type: Boolean, default: false },
currentPage: { type: String, default: null },
onNavigate: { type: Function, default: null },
},
emits: ['logout'],
data: function () {
return { dropdownOpen: false };
},
computed: {
items: function () {
return navItems(this.isAdmin);
},
page: function () {
return this.currentPage || getCurrentPage();
},
showTitle: function () {
return this.title || '宇之然';
},
},
methods: {
navigate: function (page) {
this.dropdownOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; }
window.location.href = page === '/' ? '/' : '/' + page;
},
logout: function () {
this.$emit('logout');
},
toggleDropdown: function () {
this.dropdownOpen = !this.dropdownOpen;
},
closeDropdown: function (e) {
if (this.dropdownOpen && !this.$el.contains(e.target)) {
this.dropdownOpen = false;
}
},
},
mounted: function () {
document.addEventListener('click', this.closeDropdown);
},
beforeUnmount: function () {
document.removeEventListener('click', this.closeDropdown);
},
template: '\
<nav class="uni-nav">\
<div class="uni-nav-inner">\
<div class="uni-nav-brand">{{ showTitle }}</div>\
<div class="uni-nav-items">\
<button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-item\', { active: page === item.key }]"\
@click="navigate(item.page)">{{ item.label }}</button>\
</div>\
<div class="uni-nav-right">\
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
{{ dropdownOpen ? "✕" : "☰" }}\
</button>\
<div class="uni-nav-avatar">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\
<span v-if="username" class="uni-nav-username">{{ username }}</span>\
<span v-if="isAdmin" class="uni-nav-badge">管理员</span>\
<button class="uni-nav-logout" @click="logout">退出</button>\
</div>\
</div>\
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
<button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\
@click="navigate(item.page)">\
{{ item.label }}\
</button>\
</div>\
</nav>',
};
window.UniNav = UniNav;
window.installUniNav = function (app) {
app.component('uni-nav', UniNav);
return app;
};
})();
+58 -28
View File
@@ -7,26 +7,12 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.card-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.user-table { width: 100%; }
.user-table .el-table__cell { word-break: break-word; }
.user-card-list { display: none; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.user-table { display: none; }
.user-card-list { display: block; margin: 0 -16px; }
.user-card {
@@ -64,26 +50,26 @@
}
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="用户管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="users" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="用户管理" :username="currentUser.username" :is-admin="isAdmin" current-page="users" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="card-header">
<h2 class="card-title">👥 用户管理</h2>
<h2 class="card-title"><el-icon style="vertical-align:-2px;"><IconUser /></el-icon> 用户管理</h2>
<el-button type="primary" @click="addUser">+ 新建用户</el-button>
</div>
<el-table :data="users" stripe class="user-table">
<el-table :data="users" stripe class="user-table" v-loading="loading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="username" label="用户名"></el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
</el-table-column>
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
@@ -91,6 +77,10 @@
</template>
</el-table-column>
</el-table>
<div v-if="!loading && users.length === 0" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:48px;margin-bottom:12px;color:#c0c4cc;"><IconUser /></el-icon>
<div>暂无用户</div>
</div>
<div class="user-card-list">
<div v-for="u in users" :key="u.id" class="user-card">
<div class="user-card-header">
@@ -101,6 +91,10 @@
<span style="font-size: 12px; color: #909399;">#{{ u.id }}</span>
</div>
<div class="user-card-meta">
<div class="user-card-meta-item">
<span class="user-card-meta-label">组织</span>
<span>{{ u.org_id || '-' }}</span>
</div>
<div class="user-card-meta-item">
<span class="user-card-meta-label">创建时间</span>
<span>{{ formatDate(u.created_at) }}</span>
@@ -114,14 +108,42 @@
</div>
</main>
</div>
<el-dialog v-model="dialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
<el-form :model="form" label-width="80px" @submit.prevent="submitUser">
<el-form-item label="用户名" required>
<el-input v-model="form.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="form.password" type="password" placeholder="至少6位" show-password></el-input>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width:100%">
<el-option label="编辑" value="editor"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="submitting">创建</el-button>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const UsersApp = {
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, users: [] } },
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
users: [], dialogVisible: false, submitting: false, loading: false,
form: { username: '', password: '', role: 'editor' }
}
},
methods: {
async fetchUsers() {
this.loading = true;
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); return; }
@@ -129,30 +151,38 @@
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
const data = await response.json();
this.users = data || [];
this.$message.success('用户列表加载成功');
} catch (error) {
console.error('获取用户失败:', error);
this.$message.error(`获取用户失败: ${error.message}`);
this.users = [];
}
} finally { this.loading = false; }
},
async addUser() {
addUser() {
this.form = { username: '', password: '', role: 'editor' };
this.dialogVisible = true;
},
async submitUser() {
if (!this.form.username || this.form.username.length < 2) { this.$message.warning('用户名至少2个字符'); return; }
if (!this.form.password || this.form.password.length < 6) { this.$message.warning('密码至少6位'); return; }
this.submitting = true;
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); return; }
const username = '新用户' + Date.now().toString().slice(-4);
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, role: 'editor' })
body: JSON.stringify(this.form)
});
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
const newUser = await response.json();
this.users.push(newUser);
this.$message.success('添加用户成功');
this.dialogVisible = false;
} catch (error) {
console.error('添加用户失败:', error);
this.$message.error(`添加用户失败: ${error.message}`);
} finally {
this.submitting = false;
}
},
async deleteUser(id) {
@@ -186,8 +216,8 @@
};
const app = Vue.createApp(UsersApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+269 -86
View File
@@ -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
View File
@@ -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:
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+161
View File
@@ -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
View File
@@ -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()
+149
View File
@@ -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)
+258
View File
@@ -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)