Initial commit: yu-zhi-ran platform with automation integration
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# 宇之然内容创作平台 - 环境变量配置示例
|
||||
# 复制为 .env 文件并修改
|
||||
|
||||
# 项目根目录(可选,自动检测)
|
||||
# PROJECT_ROOT=/root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran
|
||||
|
||||
# 数据目录(可选,默认使用 platform/data)
|
||||
# DATA_DIR=/path/to/data
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# CORS 允许的源(生产环境应限制)
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# 数据库(SQLite,默认使用 platform/data/yzr.db)
|
||||
# DATABASE_URL=sqlite:///data/yzr.db
|
||||
|
||||
# 自动化脚本路径(通常不需要修改)
|
||||
# AUTOMATION_DIR=${PROJECT_ROOT}/automation
|
||||
@@ -0,0 +1,57 @@
|
||||
# 宇之然内容创作管理平台 - 架构设计
|
||||
|
||||
## 愿景
|
||||
打造一个可视化、可管理、可持续的内容生产系统,从脚本驱动升级为完整的软件产品。
|
||||
|
||||
## 技术栈
|
||||
- **后端**: FastAPI
|
||||
- **前端**: Vue 3 + Element Plus (CDN引入,无需构建)
|
||||
- **数据库**: SQLite (单文件,便于备份)
|
||||
- **部署**: Docker Compose (后端 + Nginx + 前端)
|
||||
- **认证**: 无(个人使用,本地访问)
|
||||
|
||||
## 核心模块
|
||||
1. **选题管理**: 选题CRUD、状态流转、优先级排序
|
||||
2. **内容创作**: 调用现有creator、预览、手动编辑
|
||||
3. **合规审核**: 自动检查、自动修复、人工审核
|
||||
4. **发布管理**: 多平台发布向导、链接记录、状态更新
|
||||
5. **系统设置**: 定时任务管理、日志查看
|
||||
|
||||
## 实施阶段
|
||||
- **阶段1**: 服务化改造(封装现有脚本为FastAPI接口)
|
||||
- **阶段2**: 基础Web界面(Vue单页应用,通过CDN加载)
|
||||
- **阶段3**: 完善与优化(图片上传、批量操作等)
|
||||
|
||||
## 部署
|
||||
- Docker Compose 部署:app (FastAPI) + nginx (静态文件 + 反向代理)
|
||||
- 数据持久化:数据库文件映射到宿主机
|
||||
- 日志:容器内部分享,可导出
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
platform/
|
||||
├── docker-compose.yml
|
||||
├── backend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── app/
|
||||
│ │ ├── main.py
|
||||
│ │ ├── database.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── schemas.py
|
||||
│ │ ├── api/
|
||||
│ │ └── core/
|
||||
│ └── logs/
|
||||
├── frontend/
|
||||
│ ├── index.html
|
||||
│ ├── app.js
|
||||
│ └── style.css
|
||||
└── data/
|
||||
└── database.sqlite
|
||||
```
|
||||
|
||||
## 决策
|
||||
- 使用SQLite,无需额外服务
|
||||
- 前端用CDN方式,避免Node.js构建复杂度
|
||||
- 复用现有脚本,通过subprocess调用
|
||||
- 未来可扩展PostgreSQL
|
||||
@@ -0,0 +1,110 @@
|
||||
# 实施计划(阶段1-3)
|
||||
|
||||
## 阶段1: 服务化改造 (预计1-2天)
|
||||
|
||||
### 1.1 项目骨架
|
||||
- [x] 创建 `platform/` 目录结构
|
||||
- [ ] 创建 `backend/Dockerfile`
|
||||
- [ ] 创建 `docker-compose.yml`
|
||||
- [ ] 初始化 `backend/requirements.txt`
|
||||
- [ ] 创建 `backend/app/main.py` (FastAPI入口)
|
||||
- [ ] 创建 `backend/app/database.py` (SQLite连接)
|
||||
- [ ] 创建 `backend/app/models.py` (数据模型)
|
||||
- [ ] 创建 `backend/app/core/` (封装脚本逻辑)
|
||||
|
||||
### 1.2 API 设计
|
||||
- `GET /api/status` - 系统状态概览
|
||||
- `GET /api/topics` - 选题列表
|
||||
- `GET /api/topics/{id}` - 选题详情
|
||||
- `POST /api/topics/{id}/generate` - 生成内容
|
||||
- `POST /api/articles/optimize-all` - 合规优化(运行optimizer)
|
||||
- `GET /api/articles/drafts` - 查看草稿
|
||||
- `POST /api/articles/{id}/publish` - 标记已发布
|
||||
- `GET /api/logs/{date}` - 查看日志
|
||||
|
||||
### 1.3 核心封装
|
||||
- `core/generator.py` - 调用 `scripts/creator.py`
|
||||
- `core/optimizer.py` - 调用 `scripts/compliance_optimizer.py`
|
||||
- `core/publisher.py` - 状态更新(模拟发布)
|
||||
- `core/collector.py` - 调用 `scripts/collector.py`
|
||||
|
||||
### 1.4 数据库模型
|
||||
```python
|
||||
class Topic:
|
||||
id: str
|
||||
title: str
|
||||
field: str
|
||||
status: str # pending, draft, ready, published
|
||||
priority_score: int
|
||||
compliance_score: Optional[int]
|
||||
ready_at: Optional[date]
|
||||
published_at: Optional[date]
|
||||
platform_urls: Optional[dict] # {"zhihu": "...", "wechat": "..."}
|
||||
|
||||
class Article:
|
||||
id: str
|
||||
topic_id: str
|
||||
platform: str
|
||||
file_path: str
|
||||
status: str # draft, optimized, published
|
||||
created_at: datetime
|
||||
compliance_score: Optional[int]
|
||||
```
|
||||
|
||||
## 阶段2: 基础Web界面 (预计3-5天)
|
||||
|
||||
### 2.1 前端结构
|
||||
- `frontend/index.html` - 主页面布局
|
||||
- `frontend/app.js` - Vue 3 应用逻辑
|
||||
- `frontend/style.css` - 样式
|
||||
|
||||
### 2.2 页面与功能
|
||||
1. **仪表盘**
|
||||
- 今日状态:生成数、合规率、待发布数
|
||||
- 快捷操作:手动生成、一键优化
|
||||
|
||||
2. **选题管理**
|
||||
- 表格展示(ID、标题、领域、优先级、状态)
|
||||
- 筛选:按状态、领域
|
||||
- 操作:生成、查看详情
|
||||
|
||||
3. **内容预览**
|
||||
- 选题详情模态框
|
||||
- HTML预览(iframe)
|
||||
- 合规报告展示
|
||||
|
||||
4. **发布管理**
|
||||
- 待发布列表
|
||||
- 发布向导:选择平台 → 填写链接 → 确认发布
|
||||
- 已发布历史
|
||||
|
||||
5. **日志查看**
|
||||
- 日期选择
|
||||
- 日志文件内容展示
|
||||
|
||||
### 2.3 API 集成
|
||||
- 使用 fetch 与后端通信
|
||||
- 自动刷新状态
|
||||
- 操作反馈(toast/alert)
|
||||
|
||||
## 阶段3: 完善与优化 (预计2-3天)
|
||||
|
||||
- [ ] 图片上传(封面图、图表)
|
||||
- [ ] 富文本编辑器(手动修改草稿)
|
||||
- [ ] 批量操作(批量生成、批量发布)
|
||||
- [ ] 定时任务配置界面(编辑cron)
|
||||
- [ ] 数据导出(选题库、发布记录)
|
||||
- [ ] 系统监控(CPU、内存、磁盘)
|
||||
- [ ] 容器日志查看
|
||||
|
||||
## 滚动任务
|
||||
- [ ] 编写 Dockerfile (backend + nginx)
|
||||
- [ ] 配置 docker-compose.yml
|
||||
- [ ] 测试端到端流程
|
||||
- [ ] 编写 README(部署、使用说明)
|
||||
|
||||
## 时间估计
|
||||
- 阶段1: 4-8小时
|
||||
- 阶段2: 8-12小时
|
||||
- 阶段3: 4-8小时
|
||||
总计: 16-28小时
|
||||
@@ -0,0 +1,297 @@
|
||||
# 宇之然内容创作平台 - 系统架构与部署指南
|
||||
|
||||
## 系统组成
|
||||
|
||||
整个项目由两个核心部分组成:
|
||||
|
||||
| 组件 | 位置 | 职责 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **内容流水线** | `automation/scripts/` | 选-写-优-发 全自动化脚本 | 已实现 |
|
||||
| **管理平台** | `platform/` | Web管理界面 + API + 数据同步 | 新开发 |
|
||||
| **数据存储** | `automation/data/` | JSON 选题库 + 发布包 | 共享 |
|
||||
|
||||
### 1. 内容流水线(模块化)
|
||||
|
||||
```
|
||||
collector.py → 收集热点 → automation/data/sustainability_topics.json
|
||||
creator.py → 创作内容 → automation/data/drafts/YYYY-MM-DD/
|
||||
optimizer.py → 合规优化 → 生成 optimization_report.json
|
||||
publisher.py → 发布包生成 → automation/data/releases/ + content/published/
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 独立可运行,每个脚本都有 CLI 参数
|
||||
- 数据文件基于日期组织
|
||||
- 日志写入 `automation/logs/`
|
||||
|
||||
### 2. 管理平台(Web UI)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI 入口
|
||||
│ ├── database.py # SQLite 连接
|
||||
│ ├── models.py # Topic, Article 模型
|
||||
│ ├── schemas.py # Pydantic 验证
|
||||
│ ├── api/
|
||||
│ │ ├── system.py # 系统状态、流水线触发、日志查看
|
||||
│ │ ├── topics.py # 选题 CRUD + 发布标记
|
||||
│ │ ├── articles.py # 文章管理
|
||||
│ │ └── publisher.py # 发布包生成与查看
|
||||
│ └── core/
|
||||
│ ├── generator.py # 调用 creator.py
|
||||
│ ├── optimizer.py # 调用 compliance_optimizer.py
|
||||
│ └── sync.py # JSON↔DB 同步
|
||||
frontend/
|
||||
└── index.html # Vue 3 + Element Plus SPA
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 前端无构建,CDN依赖(Tailwind + Vue + Element Plus)
|
||||
- 数据通过 REST API 与后端交互
|
||||
- 实时显示流水线状态
|
||||
|
||||
## 部署方式:直接目录运行(不用 Docker)
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Python 3.10+
|
||||
- `pip install -r platform/backend/requirements.txt`
|
||||
- 确保自动化脚本可运行(`scripts/` 及其依赖已就绪)
|
||||
|
||||
### 启动步骤
|
||||
|
||||
```bash
|
||||
# 1. 进入 platform 目录
|
||||
cd /root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran/platform
|
||||
|
||||
# 2. (首次)创建数据目录
|
||||
mkdir -p data logs
|
||||
|
||||
# 3. 启动服务器
|
||||
./run.sh 8001
|
||||
|
||||
# 或手动:
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload
|
||||
```
|
||||
|
||||
### 访问
|
||||
|
||||
- 前端界面:http://localhost:8000/
|
||||
- API 文档: http://localhost:8000/docs
|
||||
- 健康检查: http://localhost:8000/api/system/status
|
||||
|
||||
## 系统架构与数据流
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Frontend (Vue 3) │
|
||||
│ 仪表盘 | 选题列表 | 预览 | 发布 │
|
||||
└─────────────────┬───────────────────┘
|
||||
│ HTTP API (JSON)
|
||||
┌─────────────────▼───────────────────┐
|
||||
│ FastAPI (backend/app) │
|
||||
│ system | topics | publisher | api │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
│ │ │
|
||||
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ core/ │ │ core/ │ │ core/ │
|
||||
│ generator │ │ optimizer │ │ sync │
|
||||
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
└─────────────────────┼─────────────────────┘
|
||||
│ subprocess (CLI)
|
||||
┌─────────────────▼───────────────────┐
|
||||
│ automation/scripts/*.py │
|
||||
│ collector creator optimizer │
|
||||
│ publisher (生成发布包) │
|
||||
└─────────────────┬───────────────────┘
|
||||
│ 读写
|
||||
┌─────────────────▼───────────────────┐
|
||||
│ automation/data/ │
|
||||
│ sustainability_topics.json │
|
||||
│ drafts/ releases/ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 关键集成点
|
||||
|
||||
1. **触发创作** → `POST /api/system/generate/run`
|
||||
- FastAPI 调用 `generator.py → subprocess creator.py`
|
||||
- 成功后调用 `sync_all_topics()` 同步选题状态到数据库
|
||||
|
||||
2. **触发优化** → `POST /api/system/optimize/run`
|
||||
- `optimizer.py → subprocess compliance_optimizer.py`
|
||||
- 读取报告并更新数据库
|
||||
|
||||
3. **生成发布包** → `POST /api/publisher/generate/{topic_id}`
|
||||
- `publisher.py → subprocess publisher.py --topic-id {id}`
|
||||
- 发布包存到 `content/published/` 和 `automation/data/releases/`
|
||||
|
||||
4. **查看发布包** → `GET /api/publisher/package/{topic_id}/{platform}`
|
||||
- 返回 HTML 内容供前端 iframe 预览或复制
|
||||
|
||||
5. **流水线状态** → `GET /api/system/pipeline/status`
|
||||
- 检查日志文件最后修改时间和错误关键词
|
||||
- 返回各模块健康状态
|
||||
|
||||
## 目录结构对比:原 vs 新
|
||||
|
||||
### 原(纯脚本)
|
||||
|
||||
```
|
||||
yu-zhi-ran/
|
||||
├── automation/ # 流水线
|
||||
├── scripts/ # 同 automation/scripts(软链?)
|
||||
├── content/ # 已发布内容
|
||||
└── 手动操作(打开终端运行脚本)
|
||||
```
|
||||
|
||||
### 新(管理平台 + 流水线)
|
||||
|
||||
```
|
||||
yu-zhi-ran/
|
||||
├── platform/ # 新增:Web管理平台
|
||||
│ ├── backend/
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── main.py
|
||||
│ │ │ ├── api/
|
||||
│ │ │ └── core/
|
||||
│ │ └── requirements.txt
|
||||
│ ├── frontend/
|
||||
│ │ └── index.html
|
||||
│ └── run.sh # 启动脚本
|
||||
├── automation/ # 流水线(不变)
|
||||
├── scripts/ # 流水线脚本(不变)
|
||||
└── (其他目录 unchanged)
|
||||
```
|
||||
|
||||
**关系**:platform 读取 automation/data/ 的数据并调用 scripts/ 执行,不修改原有脚本。
|
||||
|
||||
## 功能清单
|
||||
|
||||
| 功能 | 实现状态 | API端点 | 前端位置 |
|
||||
|------|---------|---------|---------|
|
||||
| 系统概览 | ✅ | `GET /api/system/status` | 仪表盘统计卡片 |
|
||||
| 选题列表 | ✅ | `GET /api/topics?status=` | 选题管理表格 |
|
||||
| 选题详情 | ✅ | `GET /api/topics/{id}` | 预览对话框 |
|
||||
| 触发创作 | ✅ | `POST /api/system/generate/run` | "运行创作任务"按钮 |
|
||||
| 触发优化 | ✅ | `POST /api/system/optimize/run` | "运行合规优化"按钮 |
|
||||
| 生成发布包 | ✅ | `POST /api/publisher/generate/{id}` | 发布Tab → "重新生成" |
|
||||
| 发布包预览 | ✅ | `GET /api/publisher/package/{id}/{platform}` | 发布Tab → "查看" |
|
||||
| 发布包复制 | ✅ | (同上) | 发布Tab → "复制HTML" |
|
||||
| 标记已发布 | ✅ | `POST /api/topics/{id}/publish` | 发布Tab → "确认发布" |
|
||||
| 流水线状态 | ✅ | `GET /api/system/pipeline/status` | 流水线状态面板 |
|
||||
| 同步数据 | ✅ | `POST /api/sync/run` | 全量刷新按钮 |
|
||||
| 日志查看 | ✅ | `GET /api/system/logs/{date}?log_type=` | 日志对话框 |
|
||||
|
||||
## 数据同步说明
|
||||
|
||||
**源**:`automation/data/sustainability_topics.json`(自动化脚本写入)
|
||||
|
||||
**目标**:`platform/backend/data/yzr.db` (SQLite)
|
||||
|
||||
**同步策略**:
|
||||
- **实时同步**:每次创作/优化任务完成后自动调用 `sync_all_topics()`
|
||||
- **手动同步**:前端 "全量刷新" 按钮 → `POST /api/system/refresh`
|
||||
- **定时同步**:可在 platform 启动时预先执行一次
|
||||
|
||||
**字段映射**:
|
||||
|
||||
| JSON 字段 | Topic 模型字段 |
|
||||
|-----------|----------------|
|
||||
| `id` | `id` |
|
||||
| `title` | `title` |
|
||||
| `field` | `field` |
|
||||
| `status` | `status` |
|
||||
| `priority_score` | `priority_score` |
|
||||
| `compliance_score` | `compliance_score` |
|
||||
| `ready_at` | `ready_at` (date) |
|
||||
| `published_at` | `published_at` (date) |
|
||||
| `platform_urls` | `platform_urls` (JSON) |
|
||||
|
||||
**状态对应**:
|
||||
- 自动化脚本使用中文状态:`"待处理"`, `"待发布"`, `"已发布"` 等
|
||||
- 平台数据库保持中文状态(前端也显示中文)
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 添加新平台
|
||||
|
||||
1. 在 `automation/scripts/publisher.py` 的 `PLATFORMS` 添加配置
|
||||
2. 在 `platform/backend/app/api/publisher.py` 的 `list_platform_packages()` 添加平台路径
|
||||
3. 在前端 "发布管理" 对话框添加新平台的输入框
|
||||
|
||||
### 定时任务
|
||||
|
||||
使用 crontab 定时运行自动化脚本:
|
||||
|
||||
```bash
|
||||
# 每天 5:00 收集选题
|
||||
0 5 * * * cd /path/to/yu-zhi-ran && python automation/scripts/collector.py
|
||||
|
||||
# 每天 9:00 生成内容(如果待处理选题充足)
|
||||
0 9 * * * cd /path/to/yu-zhi-ran && python automation/scripts/creator.py
|
||||
|
||||
# 每天 14:00 合规优化(可选)
|
||||
0 14 * * * cd /path/to/yu-zhi-ran && python automation/scripts/optimizer.py
|
||||
|
||||
# 每周一 10:00 发布(手动发布包生成)
|
||||
0 10 * * 1 cd /path/to/yu-zhi-ran && python automation/scripts/publisher.py
|
||||
```
|
||||
|
||||
## 开发调试
|
||||
|
||||
### 日志查看
|
||||
|
||||
```bash
|
||||
# 实时 tail 日志
|
||||
tail -f automation/logs/creator_$(date +%Y-%m-%d).log
|
||||
tail -f automation/logs/optimizer_$(date +%Y-%m-%d).log
|
||||
tail -f automation/logs/publisher_$(date +%Y-%m-%d).log
|
||||
```
|
||||
|
||||
### API 调试
|
||||
|
||||
访问 http://localhost:8000/docs 使用 Swagger UI 测试所有端点。
|
||||
|
||||
### 前端调试
|
||||
|
||||
浏览器 DevTools → Network 查看 API 请求。
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 问题 | 可能原因 | 解决方案 |
|
||||
|------|---------|----------|
|
||||
| 前端显示无数据 | 数据库未同步 | 点击"全量刷新"或访问 `/api/system/sync/run` |
|
||||
| 创作按钮灰色 | 无可用选题 | 检查 `automation/data/sustainability_topics.json` 是否有 `status: "待处理"` |
|
||||
| 生成发布包失败 | HTML不存在 | 检查 `automation/data/releases/YYYY-MM-DD/` 是否存在对应HTML |
|
||||
| 端口占用 | 已有服务运行 | 停止旧的 uvicorn 进程或改端口 |
|
||||
| 依赖缺失 | pip install 未完成 | 运行 `pip install -r platform/backend/requirements.txt` |
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **数据库初始化**: 添加自动创建表 + 初始数据脚本
|
||||
2. **权限控制**: 添加简单登录(当前无认证,仅本地访问)
|
||||
3. **任务队列**: 耗时的流水线步骤改为异步(BackgroundTasks + 状态轮询)
|
||||
4. **配置管理**: 将平台配置(PLATFORMS 的 enabled 状态)移到数据库
|
||||
5. **备份策略**: 定期备份 `automation/data/` 和 `platform/data/`
|
||||
6. **Docker 重构** (可选): 如需容器化,可分别构建 backend 和 nginx 镜像
|
||||
|
||||
## 总结
|
||||
|
||||
- ✅ **无 Docker**:直接 `./run.sh` 启动,依赖 `requirements.txt`
|
||||
- ✅ **双系统集成**:管理平台(Web UI)调用自动化流水线(CLI脚本)
|
||||
- ✅ **数据同步**:JSON ↔ SQLite 自动/手动同步
|
||||
- ✅ **状态监控**:流水线各模块健康状态面板
|
||||
- ✅ **一键操作**:创作、优化、生成发布包全部通过 Web 界面触发
|
||||
|
||||
系统已准备好用于日常内容生产管理。
|
||||
|
||||
---
|
||||
|
||||
**维护者**: AI 助手小然
|
||||
**最后更新**: 2026-04-19
|
||||
@@ -0,0 +1 @@
|
||||
# FastAPI 应用初始化
|
||||
@@ -0,0 +1 @@
|
||||
# API routes
|
||||
@@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
|
||||
router = APIRouter(prefix="/api/articles", tags=["articles"])
|
||||
|
||||
PROJECT_ROOT = Path('/root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran')
|
||||
|
||||
@router.get("/drafts")
|
||||
def list_drafts(publish_date: str = None):
|
||||
"""列出指定日期的草稿文件(三平台)"""
|
||||
if not publish_date:
|
||||
publish_date = date.today().isoformat()
|
||||
base_dir = PROJECT_ROOT / "automation" / "data" / "releases" / publish_date
|
||||
if not base_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="No releases for this date")
|
||||
|
||||
platforms = ["zhihu", "wechat", "xiaohongshu"]
|
||||
result = {}
|
||||
for p in platforms:
|
||||
path = base_dir / p
|
||||
if path.exists():
|
||||
files = sorted([f.name for f in path.glob("*.html") if f.is_file()])
|
||||
result[p] = files
|
||||
else:
|
||||
result[p] = []
|
||||
return {"date": publish_date, "files": result}
|
||||
|
||||
@router.get("/{topic_id}/preview")
|
||||
def preview_article(topic_id: str, platform: str = "zhihu", publish_date: str = None):
|
||||
"""预览某选题的HTML内容"""
|
||||
if not publish_date:
|
||||
publish_date = date.today().isoformat()
|
||||
filename = f"{platform}_{topic_id}_{platform}.html"
|
||||
file_path = PROJECT_ROOT / "automation" / "data" / "releases" / publish_date / platform / filename
|
||||
# DEBUG
|
||||
print(f"[DEBUG] file_path={file_path}, exists={file_path.exists()}")
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Article not found: {file_path}")
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
return {"topic_id": topic_id, "platform": platform, "html": content}
|
||||
|
||||
@router.get("/optimization-report")
|
||||
def get_optimization_report(publish_date: str = None):
|
||||
"""获取合规优化报告"""
|
||||
if not publish_date:
|
||||
publish_date = date.today().isoformat()
|
||||
report_path = PROJECT_ROOT / "automation" / "data" / "drafts" / publish_date / "optimization_report.json"
|
||||
if not report_path.exists():
|
||||
raise HTTPException(status_code=404, detail="No optimization report for this date")
|
||||
report = report_path.read_text(encoding='utf-8')
|
||||
import json
|
||||
return json.loads(report)
|
||||
@@ -0,0 +1,156 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Topic
|
||||
|
||||
router = APIRouter(prefix="/api/publisher", tags=["publisher"])
|
||||
|
||||
# 项目根目录(从 api/publisher.py 上升到 yu-zhi-ran 根目录)
|
||||
import os
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
|
||||
|
||||
@router.get("/ready")
|
||||
def get_ready_topics(
|
||||
platform: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取待发布的选题(状态为 ready)"""
|
||||
query = db.query(Topic).filter(Topic.status == "ready")
|
||||
if platform:
|
||||
# 筛选未在该平台发布的选题
|
||||
# platform_urls 是 JSON 字段,需要特殊处理
|
||||
pass # 简化:暂不筛选
|
||||
topics = query.order_by(Topic.ready_at.desc()).all()
|
||||
return topics
|
||||
|
||||
@router.post("/generate/{topic_id}")
|
||||
def generate_publish_package(
|
||||
topic_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""为指定选题生成发布包(所有平台HTML)"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
|
||||
# 调用 publisher.py 脚本
|
||||
script_path = SCRIPTS_DIR / "publisher.py"
|
||||
if not script_path.exists():
|
||||
raise HTTPException(status_code=500, detail="Publisher script not found")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", str(script_path), "--topic-id", topic_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Publisher failed: {result.stderr}")
|
||||
|
||||
return {
|
||||
"message": "Publish package generated",
|
||||
"topic_id": topic_id,
|
||||
"output": result.stdout
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=504, detail="Publisher timeout")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/packages/{topic_id}")
|
||||
def list_platform_packages(topic_id: str):
|
||||
"""列出某个选题的所有平台发布包"""
|
||||
release_dir = PROJECT_ROOT / "automation" / "data" / "releases"
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
packages = []
|
||||
for platform in ["zhihu", "wechat", "xiaohongshu", "bilibili", "toutiao"]:
|
||||
html_file = release_dir / today / platform / f"{platform}_{topic_id}_{platform}.html"
|
||||
if html_file.exists():
|
||||
packages.append({
|
||||
"platform": platform,
|
||||
"file": str(html_file.relative_to(PROJECT_ROOT)),
|
||||
"size": html_file.stat().st_size
|
||||
})
|
||||
|
||||
published_dir = PROJECT_ROOT / "content" / "published" / topic_id / "手动发布"
|
||||
if published_dir.exists():
|
||||
for platform_dir in published_dir.iterdir():
|
||||
if platform_dir.is_dir():
|
||||
html_file = platform_dir / "文章.html"
|
||||
if html_file.exists():
|
||||
packages.append({
|
||||
"platform": platform_dir.name,
|
||||
"file": str(html_file.relative_to(PROJECT_ROOT)),
|
||||
"size": html_file.stat().st_size,
|
||||
"manual": True
|
||||
})
|
||||
|
||||
return {"topic_id": topic_id, "packages": packages}
|
||||
|
||||
@router.get("/package/{topic_id}/{platform}")
|
||||
def get_package_html(topic_id: str, platform: str):
|
||||
"""获取指定平台发布包的HTML内容"""
|
||||
# 优先查找 published 目录(手动发布包)
|
||||
published_html = PROJECT_ROOT / "content" / "published" / topic_id / "手动发布" / platform / "文章.html"
|
||||
if published_html.exists():
|
||||
return {"html": published_html.read_text(encoding='utf-8')}
|
||||
|
||||
# 其次查找 releases 目录(自动生成)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
release_html = PROJECT_ROOT / "automation" / "data" / "releases" / today / platform / f"{platform}_{topic_id}_{platform}.html"
|
||||
if release_html.exists():
|
||||
return {"html": release_html.read_text(encoding='utf-8')}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
@router.post("/mark/{topic_id}/published")
|
||||
def mark_as_published(
|
||||
topic_id: str,
|
||||
platform_urls: dict,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""手动标记选题为已发布,记录平台链接"""
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
|
||||
topic.status = "published"
|
||||
topic.published_at = datetime.now().date()
|
||||
topic.platform_urls = platform_urls
|
||||
db.commit()
|
||||
|
||||
return {"message": "Topic marked as published", "topic_id": topic_id}
|
||||
|
||||
@router.get("/status")
|
||||
def get_publisher_status():
|
||||
"""获取发布统计"""
|
||||
# 统计今日已发布数量等
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
release_dir = PROJECT_ROOT / "automation" / "data" / "releases" / today
|
||||
|
||||
stats = {
|
||||
"today_releases": 0,
|
||||
"platforms": {}
|
||||
}
|
||||
|
||||
if release_dir.exists():
|
||||
for platform_dir in release_dir.iterdir():
|
||||
if platform_dir.is_dir():
|
||||
count = len(list(platform_dir.glob("*.html")))
|
||||
stats["platforms"][platform_dir.name] = count
|
||||
stats["today_releases"] += count
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,197 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json
|
||||
from ..database import get_db
|
||||
from ..models import Topic, Article
|
||||
from ..schemas import SystemStatus
|
||||
from ..core.generator import run_creator
|
||||
from ..core.optimizer import run_optimizer
|
||||
from ..core.sync import sync_topic_to_db, sync_all_topics
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
@router.get("/status", response_model=SystemStatus)
|
||||
def get_status(db: Session = Depends(get_db)):
|
||||
"""系统状态概览"""
|
||||
total = db.query(Topic).count()
|
||||
by_status_result = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
by_status = {status: count for status, count in by_status_result}
|
||||
# 确保返回所有状态,避免前端 undefined
|
||||
for key in ('pending', 'ready', 'published'):
|
||||
by_status.setdefault(key, 0)
|
||||
|
||||
ready = db.query(Topic).filter(Topic.status == "ready").all()
|
||||
|
||||
today_str = date.today().isoformat()
|
||||
# 计算今日文章数:查找 releases/2026-04-16 目录下的 html 文件
|
||||
# 这里简单统计数据库中 created_at 为今天的文章(不完全准确)
|
||||
today_articles = db.query(Article).filter(
|
||||
func.date(Article.created_at) == date.today()
|
||||
).count()
|
||||
|
||||
# 合规率:假设所有 ready 的都是合规的(实际从report读取)
|
||||
# 可以后续优化
|
||||
|
||||
# 获取最后一次优化时间
|
||||
last_opt = db.query(Article).filter(
|
||||
Article.status == "optimized"
|
||||
).order_by(Article.created_at.desc()).first()
|
||||
|
||||
return SystemStatus(
|
||||
total_topics=total,
|
||||
topics_by_status=by_status,
|
||||
ready_topics=ready,
|
||||
today_articles=today_articles,
|
||||
compliance_rate=100.0, # placeholder
|
||||
last_optimization=last_opt.created_at if last_opt else None
|
||||
)
|
||||
|
||||
@router.post("/generate/run")
|
||||
def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
|
||||
"""手动触发内容创作任务
|
||||
|
||||
Args:
|
||||
topic_id: 可选,指定要创作的选题ID。不指定则创作优先级最高的待处理选题。
|
||||
"""
|
||||
try:
|
||||
result = run_creator(topic_id)
|
||||
if not result["ok"]:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
from ..core.sync import sync_all_topics
|
||||
sync_all_topics()
|
||||
|
||||
return {"message": "Generation triggered", "result": result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/optimize/run")
|
||||
def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_db)):
|
||||
"""手动触发合规优化任务
|
||||
|
||||
Args:
|
||||
topic_ids: 可选,指定要优化的选题ID列表。不指定则优化所有 draft 状态文章。
|
||||
"""
|
||||
try:
|
||||
result = run_optimizer(topic_ids)
|
||||
if not result["ok"]:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
report = result.get("report")
|
||||
if report:
|
||||
from ..core.sync import sync_all_topics
|
||||
sync_all_topics()
|
||||
return {
|
||||
"message": "Optimization completed",
|
||||
"summary": report["summary"]
|
||||
}
|
||||
else:
|
||||
return {"message": "Optimization completed but no report found", "stdout": result.get("stdout", "")}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/logs/{log_date}")
|
||||
def get_logs(log_date: str, log_type: str = "creator"):
|
||||
"""读取日志文件内容,log_type: creator, optimizer, collector"""
|
||||
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
|
||||
if not log_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Log file not found: {log_file}")
|
||||
content = log_file.read_text(encoding='utf-8')
|
||||
lines = content.splitlines()[-100:] if log_type != "collector" else content.splitlines()[-200:]
|
||||
return {"log_date": log_date, "log_type": log_type, "content": lines}
|
||||
|
||||
@router.get("/pipeline/status")
|
||||
def get_pipeline_status():
|
||||
"""获取流水线各模块状态(最后运行时间和结果)"""
|
||||
try:
|
||||
# 读取选题文件
|
||||
topics_file = DATA_DIR / "sustainability_topics.json"
|
||||
topics = []
|
||||
if topics_file.exists():
|
||||
topics = json.loads(topics_file.read_text(encoding='utf-8'))
|
||||
|
||||
# 统计状态分布
|
||||
status_counts = {}
|
||||
for t in topics:
|
||||
s = t.get('status', 'unknown')
|
||||
status_counts[s] = status_counts.get(s, 0) + 1
|
||||
|
||||
# 检查各日志文件的最新修改时间
|
||||
log_files = {
|
||||
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
|
||||
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
|
||||
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
|
||||
"publisher": LOGS_DIR / f"publisher_{date.today()}.log"
|
||||
}
|
||||
|
||||
pipeline_status = {}
|
||||
for name, log_file in log_files.items():
|
||||
if log_file.exists():
|
||||
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
||||
pipeline_status[name] = {
|
||||
"last_run": mtime.isoformat(),
|
||||
"exists": True,
|
||||
"size_bytes": log_file.stat().st_size
|
||||
}
|
||||
# 简单推断成功/失败(TODO: 解析日志加强)
|
||||
last_lines = log_file.read_text(encoding='utf-8').splitlines()[-10:]
|
||||
has_error = any("error" in line.lower() or "失败" in line or "failed" in line.lower() for line in last_lines)
|
||||
pipeline_status[name]["has_error"] = has_error
|
||||
else:
|
||||
pipeline_status[name] = {"exists": False, "last_run": None}
|
||||
|
||||
return {
|
||||
"topics_count": len(topics),
|
||||
"status_distribution": status_counts,
|
||||
"pipeline_modules": pipeline_status,
|
||||
"data_dir": str(DATA_DIR),
|
||||
"logs_dir": str(LOGS_DIR)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/sync/run")
|
||||
def run_sync():
|
||||
"""手动触发数据同步(流水线JSON → 平台数据库)"""
|
||||
try:
|
||||
sync_all_topics()
|
||||
return {"message": "Sync completed"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/automation/topics")
|
||||
def list_automation_topics():
|
||||
"""直接读取自动化流水线的选题JSON(供调试)"""
|
||||
try:
|
||||
topics_file = DATA_DIR / "sustainability_topics.json"
|
||||
if not topics_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Topics JSON not found")
|
||||
topics = json.loads(topics_file.read_text(encoding='utf-8'))
|
||||
return {
|
||||
"count": len(topics),
|
||||
"topics": topics[-50:] # 只返回最近50个,避免过大
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"JSON parse error: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh_all():
|
||||
"""刷新所有数据:同步JSON + 更新状态"""
|
||||
try:
|
||||
sync_all_topics()
|
||||
return {"message": "Refresh completed"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,43 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Topic
|
||||
from ..schemas import TopicResponse, PublishRequest
|
||||
|
||||
router = APIRouter(prefix="/api/topics", tags=["topics"])
|
||||
|
||||
@router.get("", response_model=List[TopicResponse])
|
||||
def list_topics(
|
||||
status: str = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(Topic)
|
||||
if status:
|
||||
query = query.filter(Topic.status == status)
|
||||
topics = query.order_by(Topic.priority_score.desc(), Topic.created_at.desc()).all()
|
||||
return topics
|
||||
|
||||
@router.get("/{topic_id}", response_model=TopicResponse)
|
||||
def get_topic(topic_id: str, db: Session = Depends(get_db)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
return topic
|
||||
|
||||
@router.post("/{topic_id}/publish")
|
||||
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
if topic.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Topic not in ready status")
|
||||
|
||||
topic.status = "published"
|
||||
topic.published_at = datetime.now().date()
|
||||
topic.platform_urls = req.platform_urls
|
||||
db.commit()
|
||||
|
||||
return {"message": "Topic marked as published", "topic_id": topic_id}
|
||||
@@ -0,0 +1 @@
|
||||
# core package
|
||||
@@ -0,0 +1,53 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 计算项目根目录(从本文件位置上升4层)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
# 允许环境变量覆盖(适合容器部署)
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
|
||||
def run_creator(topic_id: str = None):
|
||||
"""运行内容创作脚本,返回简略结果
|
||||
|
||||
Args:
|
||||
topic_id: 可选,指定要创作的选题ID。不指定则创作优先级最高的选题。
|
||||
"""
|
||||
script_path = PROJECT_ROOT / "scripts" / "creator.py"
|
||||
cmd = ["python3", str(script_path)]
|
||||
if topic_id:
|
||||
cmd.extend(["--topic-id", topic_id])
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5分钟超时
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Creator failed: {result.stderr}")
|
||||
return {"ok": False, "error": result.stderr}
|
||||
|
||||
# 解析日志,找出选择了哪个选题
|
||||
topic_id = None
|
||||
for line in result.stdout.splitlines():
|
||||
if "选择了选题:" in line:
|
||||
# 格式: 2026-04-16 ... INFO - 选择了选题: 标题 (优先级: X)
|
||||
# 标题可能在行内,但ID不一定有。我们稍后用文件同步。
|
||||
logger.info(line.strip())
|
||||
if "选题" in line and "已标记为「待发布」" in line:
|
||||
# 如: 2026-04-16 ... INFO - 选题 A01 已标记为「待发布」
|
||||
import re
|
||||
m = re.search(r'选题\s+([A-Za-z0-9]+)', line)
|
||||
if m:
|
||||
topic_id = m.group(1)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"topic_id": topic_id,
|
||||
"stdout": result.stdout[-1000:] if len(result.stdout) > 1000 else result.stdout
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
NVIDIA 专用 LLM 客户端(fixed configuration)
|
||||
使用 OpenAI 兼容接口调用 stepfun-ai/step-3.5-flash
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
class LLMError(Exception):
|
||||
pass
|
||||
|
||||
# 固定配置(你的可用 key)
|
||||
CONFIG = {
|
||||
"base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key": "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",
|
||||
"model": "stepfun-ai/step-3.5-flash",
|
||||
}
|
||||
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
system_prompt: str = "你是一个专业的内容创作助手。",
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
stream: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
调用 NVIDIA LLM 生成文本
|
||||
"""
|
||||
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {CONFIG['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": CONFIG["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(endpoint, json=payload, headers=headers, timeout=120, stream=stream)
|
||||
if resp.status_code != 200:
|
||||
raise LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
if stream:
|
||||
full = []
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(b'data: '):
|
||||
data = line[6:]
|
||||
if data == b'[DONE]':
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
delta = chunk['choices'][0]['delta']
|
||||
# 支持 reasoning_content 或 reasoning 字段
|
||||
if 'reasoning_content' in delta and delta['reasoning_content']:
|
||||
full.append(delta['reasoning_content'])
|
||||
if 'content' in delta and delta['content']:
|
||||
full.append(delta['content'])
|
||||
except Exception:
|
||||
continue
|
||||
return "".join(full)
|
||||
else:
|
||||
data = resp.json()
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
|
||||
return content.strip() if content else ''
|
||||
except requests.RequestException as e:
|
||||
raise LLMError(f"Request failed: {e}")
|
||||
|
||||
def expand_content_with_llm(topic: dict, section_title: str, section_content: str, context: str = "") -> str:
|
||||
"""扩写大纲章节,返回包含 ## 标题的完整 Markdown"""
|
||||
prompt = f"""你是一个专业的内容创作者。请将以下大纲扩展为完整的文章章节。
|
||||
|
||||
# 选题信息
|
||||
- 标题:{topic.get('title')}
|
||||
- 领域:{topic.get('field')}
|
||||
- 核心观点:{topic.get('core_concept', '')}
|
||||
- 受众痛点:{topic.get('audience_pain', '')}
|
||||
- 独特视角:{topic.get('unique_angle', '')}
|
||||
|
||||
# 当前章节
|
||||
## {section_title}
|
||||
{section_content}
|
||||
|
||||
# 要求
|
||||
- 以 `## {section_title}` 作为章节标题开头
|
||||
- 字数:300-500字
|
||||
- 风格:客观、专业、易懂
|
||||
- 使用 Markdown 格式
|
||||
- 包含具体数据或案例(如果有)
|
||||
- 保持与整体文章调性一致
|
||||
|
||||
直接输出完整的 Markdown 章节(包括 ## 标题和正文段落)。"""
|
||||
if context:
|
||||
prompt = f"# 参考资料\n{context}\n\n{prompt}"
|
||||
|
||||
try:
|
||||
result = call_llm(prompt, temperature=0.8, max_tokens=2000)
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"## {section_title}\n\n(LLM 调用失败:{e},请手动补充)"
|
||||
|
||||
# 测试
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(f"[nvidia_client] 使用模型: {CONFIG['model']}")
|
||||
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
|
||||
print(f"[nvidia_client] 响应: {resp}")
|
||||
except Exception as e:
|
||||
print(f"[nvidia_client] 错误: {e}")
|
||||
@@ -0,0 +1,47 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 计算项目根目录(从本文件位置上升4层)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
|
||||
def run_optimizer(topic_ids: List[str] = None):
|
||||
"""运行合规优化脚本,返回报告摘要
|
||||
|
||||
Args:
|
||||
topic_ids: 可选,指定要优化的选题ID列表。不指定则优化所有 draft 文章。
|
||||
"""
|
||||
script_path = PROJECT_ROOT / "scripts" / "compliance_optimizer.py"
|
||||
cmd = ["python3", str(script_path)]
|
||||
if topic_ids:
|
||||
cmd.extend(["--topic-ids", ','.join(topic_ids)])
|
||||
logger.info(f"[DEBUG] Running optimizer with topic_ids={topic_ids}, cmd={' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600 # 10分钟
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Optimizer failed: {result.stderr}")
|
||||
return {"ok": False, "error": result.stderr}
|
||||
|
||||
# 读取优化报告(优化脚本会在 today 的 drafts 目录生成报告)
|
||||
report_date = datetime.now().strftime("%Y-%m-%d")
|
||||
report_path = PROJECT_ROOT / "automation" / "data" / "drafts" / report_date / "optimization_report.json"
|
||||
if report_path.exists():
|
||||
report = json.loads(report_path.read_text(encoding='utf-8'))
|
||||
return {"ok": True, "report": report}
|
||||
else:
|
||||
logger.warning(f"Report not found: {report_path}")
|
||||
return {"ok": True, "report": None, "stdout": result.stdout}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
qnaigc 专用 LLM 客户端
|
||||
模型:arcee-ai/trinity-large-preview
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
class LLMError(Exception):
|
||||
pass
|
||||
|
||||
CONFIG = {
|
||||
"base_url": "https://api.qnaigc.com/v1",
|
||||
"api_key": "sk-2cb9561a18351015d3120ffac4abae0480fa17e0d28469bdce5fc905d1a42e0d",
|
||||
"model": "arcee-ai/trinity-large-preview",
|
||||
}
|
||||
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
system_prompt: str = "你是一个专业的内容创作助手。",
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
stream: bool = False,
|
||||
) -> str:
|
||||
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {CONFIG['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": CONFIG["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": stream,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(endpoint, json=payload, headers=headers, timeout=120, stream=stream)
|
||||
if resp.status_code != 200:
|
||||
raise LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
if stream:
|
||||
full = []
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(b'data: '):
|
||||
data = line[6:]
|
||||
if data == b'[DONE]':
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
delta = chunk['choices'][0]['delta']
|
||||
if 'reasoning_content' in delta and delta['reasoning_content']:
|
||||
full.append(delta['reasoning_content'])
|
||||
if 'content' in delta and delta['content']:
|
||||
full.append(delta['content'])
|
||||
except Exception:
|
||||
continue
|
||||
return "".join(full)
|
||||
else:
|
||||
data = resp.json()
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
|
||||
return content.strip() if content else ''
|
||||
except requests.RequestException as e:
|
||||
raise LLMError(f"Request failed: {e}")
|
||||
|
||||
def expand_content_with_llm(topic: dict, section_title: str, section_content: str, context: str = "") -> str:
|
||||
"""扩写大纲章节,返回包含 ## 标题的完整 Markdown"""
|
||||
prompt = f"""你是一个专业的内容创作者。请将以下大纲扩展为完整的文章章节。
|
||||
|
||||
# 选题信息
|
||||
- 标题:{topic.get('title')}
|
||||
- 领域:{topic.get('field')}
|
||||
- 核心观点:{topic.get('core_concept', '')}
|
||||
- 受众痛点:{topic.get('audience_pain', '')}
|
||||
- 独特视角:{topic.get('unique_angle', '')}
|
||||
|
||||
# 当前章节
|
||||
## {section_title}
|
||||
{section_content}
|
||||
|
||||
# 要求
|
||||
- 以 `## {section_title}` 作为章节标题开头
|
||||
- 字数:300-500字
|
||||
- 风格:客观、专业、易懂
|
||||
- 使用 Markdown 格式
|
||||
- 包含具体数据或案例(如果有)
|
||||
- 保持与整体文章调性一致
|
||||
- 所有数据和时间必须基于2025年及以后,避免引用2024年以前的具体事件或统计数据。如果信息不足,请使用'近期'、'最新'等模糊表述,不要编造旧数据。
|
||||
|
||||
直接输出完整的 Markdown 章节(包括 ## 标题和正文段落)。"""
|
||||
if context:
|
||||
prompt = f"# 参考资料\n{context}\n\n{prompt}"
|
||||
|
||||
try:
|
||||
result = call_llm(prompt, temperature=0.8, max_tokens=2000)
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"## {section_title}\n\n(LLM 调用失败:{e},请手动补充)"
|
||||
|
||||
# 测试
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(f"[qnaigc_client] 使用模型: {CONFIG['model']}")
|
||||
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
|
||||
print(f"[qnaigc_client] 响应: {resp}")
|
||||
except Exception as e:
|
||||
print(f"[qnaigc_client] 错误: {e}")
|
||||
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
from ..database import SessionLocal
|
||||
from ..models import Topic
|
||||
import os
|
||||
|
||||
# 计算项目根目录(从本文件位置上升4层)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
||||
|
||||
def sync_topic_to_db(topic_id: str, db: Session = None) -> Topic:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
topic_data = next((t for t in topics if t['id'] == topic_id), None)
|
||||
if not topic_data:
|
||||
raise ValueError(f"Topic {topic_id} not found in file")
|
||||
|
||||
close_db = False
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
try:
|
||||
db_topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if db_topic is None:
|
||||
db_topic = Topic(
|
||||
id=topic_data['id'],
|
||||
title=topic_data['title'],
|
||||
field=topic_data['field'],
|
||||
format=topic_data.get('format'),
|
||||
core_concept=topic_data.get('core_concept'),
|
||||
audience_pain=topic_data.get('audience_pain'),
|
||||
unique_angle=topic_data.get('unique_angle'),
|
||||
priority=topic_data.get('priority'),
|
||||
priority_score=topic_data.get('priority_score', 0),
|
||||
total_score=topic_data.get('total_score')
|
||||
)
|
||||
db.add(db_topic)
|
||||
db_topic.status = topic_data.get('status', db_topic.status)
|
||||
db_topic.ready_at = datetime.strptime(topic_data['ready_at'], '%Y-%m-%d').date() if topic_data.get('ready_at') else None
|
||||
db_topic.published_at = datetime.strptime(topic_data['published_at'], '%Y-%m-%d').date() if topic_data.get('published_at') else None
|
||||
db_topic.compliance_score = topic_data.get('compliance_score', db_topic.compliance_score)
|
||||
db_topic.platform_urls = topic_data.get('platform_urls', {})
|
||||
db_topic.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(db_topic)
|
||||
return db_topic
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
def sync_all_topics():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
sync_topic_to_db(t['id'], db)
|
||||
print(f"✅ 同步 {len(topics)} 个选题到数据库")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
sync_all_topics()
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran)
|
||||
# __file__: platform/backend/app/database.py
|
||||
# parents[0]=app, [1]=backend, [2]=platform, [3]=yu-zhi-ran
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
|
||||
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
|
||||
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from .database import SessionLocal, init_db
|
||||
from .models import Topic
|
||||
|
||||
# 计算项目根目录(backend/app/initial_data.py -> 上升3层到 yu-zhi-ran)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
||||
|
||||
def import_topics_from_json():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(Topic).count() > 0:
|
||||
print("数据库已有数据,跳过导入")
|
||||
return
|
||||
if not __import__('os').path.exists(TOPICS_FILE):
|
||||
print(f"选题文件不存在: {TOPICS_FILE}")
|
||||
return
|
||||
topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read())
|
||||
for t in topics:
|
||||
topic = Topic(
|
||||
id=t['id'],
|
||||
title=t['title'],
|
||||
field=t['field'],
|
||||
format=t.get('format'),
|
||||
core_concept=t.get('core_concept'),
|
||||
audience_pain=t.get('audience_pain'),
|
||||
unique_angle=t.get('unique_angle'),
|
||||
priority=t.get('priority'),
|
||||
priority_score=t.get('priority_score', 0),
|
||||
total_score=t.get('total_score'),
|
||||
status=t.get('status', 'pending'),
|
||||
cases=t.get('cases', []),
|
||||
source_file=t.get('source_file'),
|
||||
ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None,
|
||||
published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None,
|
||||
compliance_score=t.get('compliance_score'),
|
||||
platform_urls=t.get('platform_urls', {})
|
||||
)
|
||||
db.add(topic)
|
||||
db.commit()
|
||||
print(f"✅ 导入 {len(topics)} 个选题到数据库")
|
||||
except Exception as e:
|
||||
print(f"导入失败: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
import_topics_from_json()
|
||||
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import os
|
||||
from .database import engine, get_db, init_db
|
||||
from .models import Base
|
||||
from .api import topics, system, articles, publisher
|
||||
from .initial_data import import_topics_from_json
|
||||
|
||||
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CORS - 生产环境应限制 origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # TODO: 生产环境改为具体域名
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 初始化数据库
|
||||
Base.metadata.create_all(bind=engine)
|
||||
init_db()
|
||||
import_topics_from_json() # 首次自动导入
|
||||
|
||||
# 注册路由
|
||||
app.include_router(topics.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(articles.router)
|
||||
app.include_router(publisher.router)
|
||||
|
||||
# 挂载前端静态文件
|
||||
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
|
||||
STATIC_DIR = FRONTEND_DIR / "static"
|
||||
|
||||
# 检查前端文件是否存在,若不存在下载Element Plus等依赖
|
||||
if not FRONTEND_DIR.exists():
|
||||
FRONTEND_DIR.mkdir(parents=True, exist_ok=True)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Frontend dir not found: {FRONTEND_DIR}, will serve API only")
|
||||
|
||||
# 默认静态文件服务(若前端存在)
|
||||
if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists():
|
||||
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
logging.getLogger(__name__).info(f"Frontend mounted at / from {FRONTEND_DIR}")
|
||||
else:
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"service": "宇之然内容创作平台 API",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"frontend_missing": str(FRONTEND_DIR)
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from .database import Base
|
||||
from datetime import datetime
|
||||
|
||||
class Topic(Base):
|
||||
__tablename__ = "topics"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
title = Column(String, nullable=False)
|
||||
field = Column(String, nullable=False)
|
||||
format = Column(String)
|
||||
core_concept = Column(Text)
|
||||
audience_pain = Column(Text)
|
||||
unique_angle = Column(Text)
|
||||
priority = Column(String) # 高/中
|
||||
priority_score = Column(Integer, default=0)
|
||||
total_score = Column(Float)
|
||||
status = Column(String, default="pending") # pending/draft/ready/published
|
||||
cases = Column(JSON, default=list)
|
||||
source_file = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
ready_at = Column(Date)
|
||||
published_at = Column(Date)
|
||||
compliance_score = Column(Integer)
|
||||
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
|
||||
|
||||
class Article(Base):
|
||||
__tablename__ = "articles"
|
||||
|
||||
id = Column(String, primary_key=True) # e.g., A01_zhihu
|
||||
topic_id = Column(String, nullable=False)
|
||||
platform = Column(String, nullable=False)
|
||||
file_path = Column(String, nullable=False)
|
||||
status = Column(String, default="draft") # draft/optimized/published
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
compliance_score = Column(Integer)
|
||||
html_content = Column(Text) # 可缓存HTML内容以便预览
|
||||
@@ -0,0 +1,53 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
class TopicBase(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
field: str
|
||||
priority_score: int = 0
|
||||
status: str = "pending"
|
||||
compliance_score: Optional[int] = None
|
||||
ready_at: Optional[date] = None
|
||||
published_at: Optional[date] = None
|
||||
platform_urls: Optional[Dict[str, str]] = None
|
||||
|
||||
class TopicResponse(TopicBase):
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ArticleBase(BaseModel):
|
||||
id: str
|
||||
topic_id: str
|
||||
platform: str
|
||||
file_path: str
|
||||
status: str = "draft"
|
||||
compliance_score: Optional[int] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class ArticleResponse(ArticleBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class SystemStatus(BaseModel):
|
||||
total_topics: int
|
||||
topics_by_status: Dict[str, int]
|
||||
ready_topics: List[TopicResponse]
|
||||
today_articles: int
|
||||
compliance_rate: float
|
||||
last_optimization: Optional[datetime] = None
|
||||
execution_time: Optional[float] = None # 任务执行耗时(秒)
|
||||
|
||||
class OptimizationRequest(BaseModel):
|
||||
topic_ids: Optional[List[str]] = None # None表示全部
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
topic_id: str
|
||||
platform_urls: Dict[str, str] # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
|
||||
|
||||
class BatchPublishRequest(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
pydantic==2.9.2
|
||||
sqlalchemy==2.0.36
|
||||
python-multipart==0.0.9
|
||||
jinja2==3.1.5
|
||||
aiofiles==24.1.0
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2024.2
|
||||
PyYAML>=6.0
|
||||
feedparser>=6.0
|
||||
requests>=2.32.0
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""部署前检查脚本"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
|
||||
def check_file(path, desc):
|
||||
if Path(path).exists():
|
||||
print(f"✅ {desc}: {path}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 缺失: {desc} → {path}")
|
||||
return False
|
||||
|
||||
def check_module(module, desc):
|
||||
if importlib.util.find_spec(module):
|
||||
print(f"✅ Python模块: {module}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 缺失Python模块: {module}(需运行 pip install -r requirements.txt)")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("========================================")
|
||||
print("宇之然内容创作平台 - 部署前检查")
|
||||
print("========================================\n")
|
||||
|
||||
all_ok = True
|
||||
|
||||
# 1. 项目结构检查
|
||||
print("【1】项目结构")
|
||||
base = Path(__file__).parent
|
||||
paths = [
|
||||
(base / "backend" / "app" / "main.py", "后端入口"),
|
||||
(base / "frontend" / "index.html", "前端主文件"),
|
||||
(base / "data", "数据目录"),
|
||||
(base / "logs", "日志目录"),
|
||||
(base / ".." / "automation" / "data" / "sustainability_topics.json", "选题JSON"),
|
||||
(base / ".." / "scripts" / "creator.py", "创作脚本"),
|
||||
(base / ".." / "scripts" / "publisher.py", "发布脚本"),
|
||||
]
|
||||
for p, desc in paths:
|
||||
all_ok &= check_file(p, desc)
|
||||
print()
|
||||
|
||||
# 2. Python依赖检查
|
||||
print("【2】Python依赖")
|
||||
modules = ["fastapi", "uvicorn", "sqlalchemy", "pydantic"]
|
||||
for m in modules:
|
||||
all_ok &= check_module(m, "模块")
|
||||
print()
|
||||
|
||||
# 3. 配置检查
|
||||
print("【3】配置与环境")
|
||||
backend_dir = base / "backend"
|
||||
venv_ok = (backend_dir / "venv").exists()
|
||||
if venv_ok:
|
||||
print("✅ 虚拟环境存在")
|
||||
else:
|
||||
print("⚠️ 虚拟环境不存在(可选,建议创建)")
|
||||
print()
|
||||
|
||||
# 4. 端口检查
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.bind(("0.0.0.0", 8000))
|
||||
print("✅ 端口 8000 可用")
|
||||
except OSError as e:
|
||||
print(f"❌ 端口 8000 被占用: {e}")
|
||||
all_ok = False
|
||||
finally:
|
||||
s.close()
|
||||
print()
|
||||
|
||||
print("========================================")
|
||||
if all_ok:
|
||||
print("✅ 检查通过,可以运行 ./run.sh 启动服务")
|
||||
else:
|
||||
print("❌ 存在问题,请根据上述提示修复")
|
||||
print("========================================")
|
||||
|
||||
return 0 if all_ok else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../data
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然 - 简单版</title>
|
||||
<script src="/static/vue.global.prod.js"></script>
|
||||
<link rel="stylesheet" href="/static/element-plus.css" />
|
||||
<script src="/static/element-plus.full.js"></script>
|
||||
<style>
|
||||
body { margin: 20px; font-family: sans-serif; }
|
||||
.card { border: 1px solid #ddd; padding: 20px; margin: 10px 0; border-radius: 8px; }
|
||||
.stat-value { font-size: 2rem; color: #409EFF; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>宇之然内容创作平台</h1>
|
||||
<div class="card">
|
||||
<h2>系统概览</h2>
|
||||
<div v-if="status">
|
||||
<p>选题总数: {{ status.total_topics }}</p>
|
||||
<p>待发布: {{ status.topics_by_status?.['待发布'] || 0 }}</p>
|
||||
<p>待处理: {{ status.topics_by_status?.['待处理'] || 0 }}</p>
|
||||
</div>
|
||||
<div v-else>加载中...</div>
|
||||
<button @click="refresh">刷新</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>选题列表</h2>
|
||||
<div v-if="topics.length">
|
||||
<ul>
|
||||
<li v-for="t in topics" :key="t.id">
|
||||
{{ t.id }} - {{ t.title }} - {{ t.status }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="topics">无选题</div>
|
||||
<div v-else>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, onMounted } = Vue;
|
||||
createApp({
|
||||
setup() {
|
||||
const API_BASE = '';
|
||||
const status = ref(null);
|
||||
const topics = ref([]);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [s, t] = await Promise.all([
|
||||
fetch(API_BASE + '/api/system/status').then(r => r.json()),
|
||||
fetch(API_BASE + '/api/topics').then(r => r.json())
|
||||
]);
|
||||
status.value = s;
|
||||
topics.value = t;
|
||||
console.log('数据加载成功', s, t);
|
||||
} catch (e) {
|
||||
console.error('刷新失败:', e);
|
||||
alert('加载失败: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log('应用启动');
|
||||
refresh();
|
||||
});
|
||||
|
||||
return { status, topics, refresh };
|
||||
}
|
||||
}).use(ElementPlus).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,573 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="/static/vue.global.prod.js"></script>
|
||||
<link rel="stylesheet" href="/static/element-plus.css" />
|
||||
<script src="/static/element-plus.full.js"></script>
|
||||
<style>
|
||||
.page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 2rem; font-weight: bold; color: #409EFF; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="bg-blue-600 text-white p-4 mb-6">
|
||||
<div class="container mx-auto flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">宇之然内容创作平台</h1>
|
||||
<div class="flex gap-2">
|
||||
<el-button type="primary" @click="refresh">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-bold mb-4">系统概览</h2>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ status.total_topics }}</div>
|
||||
<div>选题总数</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ (status.topics_by_status || {})['待发布'] || 0 }}</div>
|
||||
<div>待发布</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ (status.topics_by_status || {})['待处理'] || 0 }}</div>
|
||||
<div>待处理</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ status.today_articles }}</div>
|
||||
<div>今日生成</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-4">
|
||||
<el-button type="success" @click="triggerGenerate" :loading="generating">▶ 运行创作任务</el-button>
|
||||
<el-button type="warning" @click="triggerOptimize" :loading="optimizing">🔍 运行合规优化</el-button>
|
||||
<el-button type="info" @click="showLogs = true">📄 查看日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流水线状态 -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">📊 流水线状态</h2>
|
||||
<el-button size="small" @click="refreshPipeline">刷新</el-button>
|
||||
</div>
|
||||
<div v-if="pipelineLoading" class="text-gray-500">加载中...</div>
|
||||
<div v-else class="grid grid-cols-4 gap-4">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ pipeline.status_distribution?.['待处理'] || 0 }}</div>
|
||||
<div>待处理</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ pipeline.status_distribution?.['待发布'] || 0 }}</div>
|
||||
<div>待发布</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ pipeline.status_distribution?.['已发布'] || 0 }}</div>
|
||||
<div>已发布</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value">{{ pipeline.topics_count || 0 }}</div>
|
||||
<div>总选题数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<h3 class="text-lg font-semibold mb-2">模块状态</h3>
|
||||
<el-table :data="pipelineModules" border style="width: 100%">
|
||||
<el-table-column prop="module" label="模块" width="120"></el-table-column>
|
||||
<el-table-column prop="last_run" label="最后运行" width="180"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status_ok ? 'success' : 'danger'">{{ scope.row.status_text }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="error" label="错误信息"></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">选题管理</h2>
|
||||
<div class="flex gap-4">
|
||||
<el-button type="primary" size="small" @click="refreshAll">🔄 全量刷新</el-button>
|
||||
<el-button type="success" size="small" @click="openCreateTopic">+ 新建选题</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<el-select v-model="filterStatus" placeholder="筛选状态" clearable style="width: 150px">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="待处理" value="待处理"></el-option>
|
||||
<el-option label="待审查" value="待审查"></el-option>
|
||||
<el-option label="待发布" value="待发布"></el-option>
|
||||
<el-option label="已发布" value="已发布"></el-option>
|
||||
</el-select>
|
||||
<span class="text-gray-500">共 {{ topics.length }} 条</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-select v-model="filterStatus" placeholder="筛选状态" clearable style="width: 150px">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="待处理" value="待处理"></el-option>
|
||||
<el-option label="待审查" value="待审查"></el-option>
|
||||
<el-option label="待发布" value="待发布"></el-option>
|
||||
<el-option label="已发布" value="已发布"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="filteredTopics" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" header-align="center"></el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="220" header-align="center"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="90" header-align="center"></el-table-column>
|
||||
<el-table-column prop="priority_score" label="优先级" width="70" header-align="center"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" header-align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTagType(scope.row.status)">{{ scope.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="70" header-align="center"></el-table-column>
|
||||
<el-table-column label="创建时间" width="120" header-align="center">
|
||||
<template #default="scope">{{ formatDate(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="就绪时间" width="120" header-align="center">
|
||||
<template #default="scope">{{ formatDate(scope.row.ready_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" width="120" header-align="center">
|
||||
<template #default="scope">{{ formatDate(scope.row.published_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" header-align="center">
|
||||
<template #default="scope">
|
||||
<div class="flex gap-2 mb-1">
|
||||
<el-button size="small" @click="openPreview(scope.row)">预览</el-button>
|
||||
<el-button size="small" type="primary" :disabled="scope.row.status !== '待发布'" @click="openPublish(scope.row)">发布</el-button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<el-button size="small" type="success" :disabled="scope.row.status !== '待处理'" @click="createTopic(scope.row)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="scope.row.status !== '待审查'" @click="optimizeTopic(scope.row)">审查</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" :title="previewTopic.title" width="80%">
|
||||
<div class="mb-4">
|
||||
<el-radio-group v-model="previewPlatform" size="small">
|
||||
<el-radio-button label="zhihu">知乎</el-radio-button>
|
||||
<el-radio-button label="wechat">微信公众号</el-radio-button>
|
||||
<el-radio-button label="xiaohongshu">小红书</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div v-if="previewHtml" class="border p-4 bg-gray-50" v-html="previewHtml" style="max-height: 70vh; overflow: auto;"></div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 发布管理对话框(增强版) -->
|
||||
<el-dialog v-model="publishVisible" :title="`发布管理:${publishTopic.title}`" width="900px">
|
||||
<el-tabs v-model="publishTab">
|
||||
<el-tab-pane label="发布链接" name="links">
|
||||
<el-form :model="publishForm" label-width="100px">
|
||||
<el-form-item label="知乎">
|
||||
<el-input v-model="publishForm.zhihu" placeholder="https://zhihu.com/..."></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="微信公众号">
|
||||
<el-input v-model="publishForm.wechat" placeholder="https://mp.weixin.qq.com/..."></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="小红书">
|
||||
<el-input v-model="publishForm.xiaohongshu" placeholder="https://xiaohongshu.com/note/..."></el-input>
|
||||
</el-form-item>
|
||||
<!-- 可扩展其他平台 -->
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="发布包" name="packages">
|
||||
<div v-if="loadingPackages" class="text-center py-4">加载中...</div>
|
||||
<div v-else-if="packages.length === 0" class="text-gray-500 py-4">暂未生成发布包,请点击"重新生成"</div>
|
||||
<div v-else>
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" size="small" @click="generateAllPackages" :loading="generatingPackages">🔄 重新生成所有平台发布包</el-button>
|
||||
</div>
|
||||
<el-table :data="packages" border>
|
||||
<el-table-column prop="platform" label="平台" width="120"></el-table-column>
|
||||
<el-table-column prop="path" label="文件路径" width="300"></el-table-column>
|
||||
<el-table-column prop="size" label="大小" width="80">
|
||||
<template #default="scope">{{ formatSize(scope.row.size) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="viewPackage(scope.row)">查看</el-button>
|
||||
<el-button size="small" type="primary" @click="copyPackage(scope.row)">复制HTML</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="publishVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="publishing" @click="confirmPublish">确认发布并保存链接</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 预览单个发布包 -->
|
||||
<el-dialog v-model="packagePreviewVisible" :title="`预览:${currentPackage?.platform}`" width="80%">
|
||||
<div class="mb-4 flex gap-2">
|
||||
<el-button size="small" @click="packagePreviewVisible = false">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="copyCurrentHtml">复制HTML</el-button>
|
||||
</div>
|
||||
<div class="border p-4 bg-gray-50" v-html="currentHtml" style="max-height: 70vh; overflow: auto;"></div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 日志对话框 -->
|
||||
<el-dialog v-model="showLogs" title="系统日志" width="80%">
|
||||
<div class="mb-4 flex gap-2">
|
||||
<el-date-picker v-model="logDate" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"></el-date-picker>
|
||||
<el-select v-model="logType" style="width: 120px">
|
||||
<el-option label="creator" value="creator"></el-option>
|
||||
<el-option label="publisher" value="publisher"></el-option>
|
||||
<el-option label="collector" value="collector"></el-option>
|
||||
</el-select>
|
||||
<el-button @click="fetchLogs">加载</el-button>
|
||||
</div>
|
||||
<pre class="bg-gray-100 p-4 rounded overflow-auto" style="max-height: 60vh;">{{ logContent }}</pre>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted, watch } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const API_BASE = '';
|
||||
|
||||
const status = ref({});
|
||||
const topics = ref([]);
|
||||
const filterStatus = ref('');
|
||||
const generating = ref(false);
|
||||
const optimizing = ref(false);
|
||||
const publishing = ref(false);
|
||||
const pipeline = ref({});
|
||||
const pipelineLoading = ref(false);
|
||||
const pipelineModules = ref([]);
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewTopic = ref({});
|
||||
const previewPlatform = ref('zhihu');
|
||||
const previewHtml = ref('');
|
||||
|
||||
const publishVisible = ref(false);
|
||||
const publishTopic = ref({});
|
||||
const publishForm = ref({ zhihu: '', wechat: '', xiaohongshu: '' });
|
||||
const publishTab = ref('links');
|
||||
|
||||
// 新增:发布包管理
|
||||
const packages = ref([]);
|
||||
const loadingPackages = ref(false);
|
||||
const generatingPackages = ref(false);
|
||||
const packagePreviewVisible = ref(false);
|
||||
const currentPackage = ref(null);
|
||||
const currentHtml = ref('');
|
||||
|
||||
const showLogs = ref(false);
|
||||
const logType = ref('creator');
|
||||
const logDate = ref(new Date().toISOString().split('T')[0]);
|
||||
const logContent = ref('');
|
||||
|
||||
const filteredTopics = computed(() => {
|
||||
try {
|
||||
if (!filterStatus.value) return topics.value || [];
|
||||
return (topics.value || []).filter(t => t && t.status === filterStatus.value);
|
||||
} catch (e) {
|
||||
console.error('filteredTopics error:', e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [s, t] = await Promise.all([
|
||||
fetch(API_BASE + '/api/system/status').then(r => r.json()),
|
||||
fetch(API_BASE + '/api/topics').then(r => r.json())
|
||||
]);
|
||||
status.value = s;
|
||||
topics.value = t;
|
||||
console.log('刷新成功', s, t);
|
||||
} catch (e) {
|
||||
console.error('刷新失败:', e);
|
||||
// 设置默认数据用于测试
|
||||
status.value = { total_topics: 20, topics_by_status: { '待发布': 4, '待处理': 16 } };
|
||||
// topics.value = []; // 保持空数组
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPipeline = async () => {
|
||||
pipelineLoading.value = true;
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/system/pipeline/status');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
pipeline.value = data;
|
||||
// 构建模块状态表格数据
|
||||
pipelineModules.value = Object.entries(data.pipeline_modules || {}).map(([name, info]) => ({
|
||||
module: name,
|
||||
last_run: info.last_run || '未运行',
|
||||
status_ok: !info.has_error && info.exists,
|
||||
status_text: info.exists && !info.has_error ? '正常' : info.exists ? '有错误' : '缺失',
|
||||
error: info.has_error ? '检测到错误' : ''
|
||||
}));
|
||||
} else {
|
||||
pipelineModules.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('流水线状态获取失败:', e);
|
||||
pipelineModules.value = [];
|
||||
} finally {
|
||||
pipelineLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([refresh(), refreshPipeline()]);
|
||||
};
|
||||
|
||||
const triggerGenerate = async () => {
|
||||
generating.value = true;
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/system/generate/run', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.result && data.result.ok) {
|
||||
ElementPlus.ElMessage.success('创作任务已启动');
|
||||
setTimeout(refresh, 5000);
|
||||
} else {
|
||||
ElementPlus.ElMessage.error('启动失败: ' + (data.error || '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerOptimize = async () => {
|
||||
optimizing.value = true;
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/system/optimize/run', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.summary) {
|
||||
ElementPlus.ElNotification({
|
||||
title: '优化完成',
|
||||
message: `自动通过 ${data.summary.passed_auto} 篇,需人工 ${data.summary.need_manual} 篇`,
|
||||
type: data.summary.need_manual === 0 ? 'success' : 'warning'
|
||||
});
|
||||
await refresh();
|
||||
} else {
|
||||
ElementPlus.ElMessage.success('优化完成');
|
||||
}
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error('请求失败: ' + e);
|
||||
} finally {
|
||||
optimizing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = async (topic) => {
|
||||
previewTopic.value = topic;
|
||||
previewVisible.value = true;
|
||||
previewPlatform.value = 'zhihu';
|
||||
await loadPreview();
|
||||
};
|
||||
|
||||
const loadPreview = async () => {
|
||||
const tid = previewTopic.value.id;
|
||||
const platform = previewPlatform.value;
|
||||
try {
|
||||
const res = await fetch(API_BASE + `/api/articles/${tid}/preview?platform=${platform}`);
|
||||
const data = await res.json();
|
||||
previewHtml.value = data.html;
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error('无法加载预览');
|
||||
}
|
||||
};
|
||||
|
||||
watch(previewPlatform, loadPreview);
|
||||
|
||||
const openPublish = async (topic) => {
|
||||
publishTopic.value = topic;
|
||||
publishForm.value = { zhihu: '', wechat: '', xiaohongshu: '' };
|
||||
publishVisible.value = true;
|
||||
publishTab.value = 'links';
|
||||
await loadPackages();
|
||||
};
|
||||
|
||||
// 加载发布包列表
|
||||
const loadPackages = async () => {
|
||||
const tid = publishTopic.value.id;
|
||||
loadingPackages.value = true;
|
||||
try {
|
||||
const res = await fetch(API_BASE + `/api/publisher/packages/${tid}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
packages.value = data.packages || [];
|
||||
} else {
|
||||
packages.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载发布包失败:', e);
|
||||
packages.value = [];
|
||||
} finally {
|
||||
loadingPackages.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 生成所有平台发布包
|
||||
const generateAllPackages = async () => {
|
||||
const tid = publishTopic.value.id;
|
||||
generatingPackages.value = true;
|
||||
try {
|
||||
const res = await fetch(API_BASE + `/api/publisher/generate/${tid}`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
ElementPlus.ElMessage.success('发布包生成任务已启动');
|
||||
setTimeout(loadPackages, 3000);
|
||||
} else {
|
||||
throw new Error('生成失败');
|
||||
}
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error('生成发布包失败: ' + e);
|
||||
} finally {
|
||||
generatingPackages.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 查看发布包内容
|
||||
const viewPackage = async (pkg) => {
|
||||
currentPackage.value = pkg;
|
||||
try {
|
||||
const res = await fetch(API_BASE + `/api/publisher/package/${publishTopic.value.id}/${pkg.platform}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
currentHtml.value = data.html;
|
||||
packagePreviewVisible.value = true;
|
||||
} else {
|
||||
ElementPlus.ElMessage.error('无法加载发布包');
|
||||
}
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error('请求失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 复制发布包HTML
|
||||
const copyPackage = async (pkg) => {
|
||||
try {
|
||||
const res = await fetch(API_BASE + `/api/publisher/package/${publishTopic.value.id}/${pkg.platform}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
await navigator.clipboard.writeText(data.html);
|
||||
ElementPlus.ElMessage.success('HTML已复制到剪贴板');
|
||||
} else {
|
||||
throw new Error('加载失败');
|
||||
}
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error('复制失败: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCurrentHtml = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentHtml.value);
|
||||
ElementPlus.ElMessage.success('已复制');
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPublish = async () => {
|
||||
publishing.value = true;
|
||||
try {
|
||||
const res = await fetch(API_BASE + `/api/topics/${publishTopic.value.id}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform_urls: publishForm.value })
|
||||
});
|
||||
if (res.ok) {
|
||||
ElementPlus.ElMessage.success('已标记为已发布');
|
||||
publishVisible.value = false;
|
||||
await refresh();
|
||||
} else {
|
||||
throw new Error('发布失败');
|
||||
}
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const dateStr = logDate.value;
|
||||
const res = await fetch(API_BASE + `/api/system/logs/${dateStr}?log_type=${logType.value}`);
|
||||
if (!res.ok) throw new Error('日志文件不存在');
|
||||
const data = await res.json();
|
||||
logContent.value = data.content ? data.content.join('\n') : '无内容';
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error('加载日志失败');
|
||||
logContent.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const statusTagType = (status) => {
|
||||
const map = { '待处理': 'info', '待审查': 'warning', '待发布': 'success', '已发布': 'primary' };
|
||||
return map[status] || '';
|
||||
};
|
||||
|
||||
const formatDate = (val) => {
|
||||
if (!val) return '';
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return val;
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const min = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
refreshPipeline();
|
||||
});
|
||||
|
||||
return {
|
||||
status, topics, filteredTopics, filterStatus,
|
||||
generating, optimizing,
|
||||
previewVisible, previewTopic, previewPlatform, previewHtml,
|
||||
publishVisible, publishTopic, publishForm, publishing,
|
||||
publishTab,
|
||||
packages, loadingPackages, generatingPackages,
|
||||
packagePreviewVisible, currentPackage, currentHtml,
|
||||
showLogs, logType, logDate, logContent, fetchLogs,
|
||||
refresh, refreshPipeline, refreshAll, triggerGenerate, triggerOptimize, openPreview, loadPreview,
|
||||
openPublish, loadPackages, generateAllPackages, viewPackage, copyPackage, copyCurrentHtml,
|
||||
confirmPublish, statusTagType, formatDate, formatSize,
|
||||
pipeline, pipelineLoading, pipelineModules
|
||||
};
|
||||
}
|
||||
}).use(ElementPlus).mount('#app').catch(err => {
|
||||
console.error('Vue mount error:', err);
|
||||
document.getElementById('app').innerHTML = '<div class="card"><h2>应用启动失败</h2><pre>' + err + '</pre></div>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>前端测试</title>
|
||||
<script src="/static/vue.global.prod.js"></script>
|
||||
<link rel="stylesheet" href="/static/element-plus.css" />
|
||||
<script src="/static/element-plus.full.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>测试页面</h1>
|
||||
<p>Vue 已加载: {{ loaded }}</p>
|
||||
<el-button type="primary">测试按钮</el-button>
|
||||
</div>
|
||||
<script>
|
||||
const { createApp, ref } = Vue;
|
||||
createApp({
|
||||
setup() {
|
||||
const loaded = ref(true);
|
||||
return { loaded };
|
||||
}
|
||||
}).use(ElementPlus).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../logs
|
||||
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://172.17.0.1:8000; # 宿主机在 Docker 网桥的 IP
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# 宇之然内容创作平台 - 直接运行脚本
|
||||
# 用法: cd /path/to/platform && ./run.sh [port]
|
||||
|
||||
set -e
|
||||
|
||||
PORT=${1:-8000}
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$PROJECT_ROOT/backend"
|
||||
FRONTEND_DIR="$PROJECT_ROOT/frontend"
|
||||
DATA_DIR="$PROJECT_ROOT/data"
|
||||
LOGS_DIR="$PROJECT_ROOT/logs"
|
||||
CHECK_SCRIPT="$PROJECT_ROOT/check.py"
|
||||
|
||||
# 运行部署前检查(可选)
|
||||
if [ -f "$CHECK_SCRIPT" ]; then
|
||||
echo "【0/4】运行部署前检查..."
|
||||
python3 "$CHECK_SCRIPT"
|
||||
echo
|
||||
fi
|
||||
|
||||
|
||||
# 检查数据目录
|
||||
if [ ! -d "$DATA_DIR" ]; then
|
||||
echo "创建数据目录: $DATA_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
fi
|
||||
|
||||
# 检查日志目录
|
||||
if [ ! -d "$LOGS_DIR" ]; then
|
||||
echo "创建日志目录: $LOGS_DIR"
|
||||
mkdir -p "$LOGS_DIR"
|
||||
fi
|
||||
|
||||
# 检查前端文件
|
||||
if [ ! -f "$FRONTEND_DIR/index.html" ]; then
|
||||
echo "⚠️ 警告: 前端 index.html 不存在"
|
||||
echo "前端目录: $FRONTEND_DIR"
|
||||
echo "请确保前端文件已就绪"
|
||||
fi
|
||||
|
||||
# 检查Python依赖
|
||||
echo "检查Python依赖..."
|
||||
cd "$BACKEND_DIR"
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "创建虚拟环境..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
source venv/bin/activate || echo "使用系统Python(未激活venv)"
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
# 启动服务
|
||||
echo ""
|
||||
echo "🚀 启动服务器..."
|
||||
echo "API文档: http://localhost:$PORT/docs"
|
||||
echo "前端界面: http://localhost:$PORT/"
|
||||
echo "========================================"
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
exec python -m uvicorn app.main:app --host 0.0.0.0 --port $PORT --reload
|
||||
Reference in New Issue
Block a user