chore: 清理 Docker 相关文件并优化前端布局

- 删除 Docker 相关文件 (docker-compose, Dockerfile, nginx.conf, init.sql 等)
- 优化 platforms.html 卡片布局和响应式样式
- 优化 users.html 格式和移动端卡片设计
- 优化 admin.html 页面结构和表格布局
- 修复各页面 min-height 和溢出问题
- 更新导航组件样式
This commit is contained in:
lt
2026-05-09 19:41:59 +08:00
parent e77a1aa4d9
commit 71cb4c35a8
80 changed files with 142574 additions and 3900 deletions
-49
View File
@@ -1,49 +0,0 @@
# 宇之然内容创作平台 - 项目完成报告
## 📊 总体进度
- 基础平台 (4月29日): ✅ 完成
- 管理功能扩展 (5月1-2日): ✅ 完成
## ✅ 已实现功能
### 后端 API
- 选题管理: GET/POST /api/topics, ...
- 用户管理: /api/admin/users
- 案例管理: /api/admin/cases (CRUD)
- 任务日志: /api/admin/tasklogs (CRUD)
- LLM配置: /api/admin/llmconfigs (CRUD)
- 系统配置: /api/admin/systemconfigs (CRUD, value自动JSON解析)
### 数据库与数据
- 初始数据导入: 20选题 + 30案例
- 自动创建管理员账号 (admin/admin123)
- 默认LLM配置和系统配置
### 前端界面
- 系统管理页面: admin.html (标签页统一管理)
- 导航菜单: 在 topics.html, logs.html, users.html 的侧边栏与移动导航中添加“系统管理”入口
- 深色主题保持一致
## 🚀 启动方式
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran
./start-platform.sh 8001
# 或
uvicorn app.main:app --port 8001
```
默认管理员: admin / admin123
## 📁 关键文件变更
- `platform/backend/app/main.py`: 注册新路由
- `platform/backend/app/api/`: 新增 cases.py, task_logs.py, llm_configs.py, system_configs.py
- `platform/backend/app/schemas.py`: 新增 Case, TaskLog, LLMConfig, SystemConfig 的Schema
- `automation/data/initial_cases.json`: 扩展至30条案例
- `platform/frontend/`: 新增 admin.html, 更新导航链接(侧边栏+移动端)
## ⚠️ 注意事项
- 所有 AI 调用必须串行,频率不超过 30次/分钟
- 使用 Bearer Token (JWT) 认证
---
生成时间: 2026-05-02
-243
View File
@@ -1,243 +0,0 @@
# 宇之然项目目录结构说明
## 整体布局
```
yu-zhi-ran/ # 项目根目录
├── platform/ # ⭐ 管理平台(Web UI + API
│ ├── backend/ # FastAPI 后端
│ │ ├── app/
│ │ │ ├── main.py # 入口
│ │ │ ├── database.py # 数据库连接(SQLite
│ │ │ ├── models.py # Topic, Article 模型
│ │ │ ├── schemas.py # Pydantic 验证
│ │ │ ├── initial_data.py # 初始数据导入
│ │ │ ├── api/ # REST API 路由
│ │ │ │ ├── system.py # │ 系统状态、流水线控制、日志
│ │ │ │ ├── topics.py # │ 选题 CRUD
│ │ │ │ ├── publisher.py # │ 发布包管理
│ │ │ │ └── articles.py # │ 文章查看
│ │ │ └── core/ # │ 业务逻辑封装
│ │ │ ├── generator.py # │ 调用 scripts/creator.py
│ │ │ ├── optimizer.py # │ 调用 scripts/compliance_optimizer.py
│ │ │ └── sync.py # │ 同步 JSON → 数据库
│ │ └── requirements.txt
│ ├── frontend/
│ │ └── index.html # Vue 3 SPACDN 依赖)
│ ├── run.sh # 快速启动脚本
│ ├── check.py # 部署前检查
│ ├── README.md # 平台使用文档
│ └── PORTFOLIO.md # 架构详解
├── automation/ # 🔄 自动化流水线数据与日志
│ ├── scripts/ (软链) → ../scripts/ # 实际脚本在 ../scripts/
│ ├── data/ # 数据存储
│ │ ├── sustainability_topics.json # ⭐ 核心:选题库
│ │ ├── drafts/ # 草稿(按日期)
│ │ │ └── 2026-04-19/
│ │ │ ├── A02_zhihu.md
│ │ │ ├── A02_wechat.md
│ │ │ └── A02_xiaohongshu.md
│ │ └── releases/ # 发布包(按日期)
│ │ └── 2026-04-19/
│ │ ├── zhihu/zhihu_A02_zhihu.html
│ │ ├── wechat/wechat_A02_wechat.html
│ │ └── xiaohongshu/
│ └── logs/ # 运行日志
│ ├── collector_2026-04-19.log
│ ├── creator_2026-04-19.log
│ ├── optimizer_2026-04-19.log
│ └── publisher_summary_2026-04-19.json
├── scripts/ # 🛠️ 底层 CLI 脚本(被 automation/scripts/ 调用)
│ ├── collector.py # 采集热点 + 本地降级
│ ├── creator.py # 研究→大纲→撰写流水线
│ ├── compliance_optimizer.py # 合规检查 + 自动修复
│ ├── publisher.py # 发布包生成(HTML + 指南)
│ ├── research.py # 资料收集(API + 本地)
│ ├── outline.py # 大纲生成
│ ├── writer.py # 核心撰写逻辑
│ ├── wecom_notifier.py # 企业微信通知(可选)
│ └── ...(其他辅助脚本)
├── content/ # 📚 已发布内容存储
│ ├── published/ # 按 topic_id 组织
│ │ ├── A01/
│ │ │ └── 手动发布/
│ │ │ ├── 知乎/文章.html
│ │ │ └── 小红书/文章.html
│ │ └── A02/
│ ├── drafts/ # 草稿(可选)
│ ├── ideas/ # 选题记录(Markdown
│ └── images/ # 配图资源
├── config/ # ⚙️ 配置文件
│ ├── sources.yaml # RSS/网页源配置
│ └── wecom_config.yaml # 企业微信机器人配置
├── brand/ # 🏷️ 品牌资产
│ ├── brand-book.md # 愿景、调性、原则
│ └── guidelines.md # 内容创作指南
├── strategy/ # 📈 内容策略
│ └── 全球-本土比较研究与全新内容战略规划-2026-04-15.md
├── tasks/ # 📋 项目管理
│ └── todo.md
├── research/ # 📊 行业研究
│ └── trends-2026.md
├── logs/ # 📝 根目录日志(兼容)
├── backup/ # 💾 备份归档
├── README.md # 项目总览
├── YUZHIRAN_PLATFORM.md # 完整文档(本文件同级)
├── TEST_FULL_PIPELINE.md # 测试指南
├── test_full_pipeline.py # 全流程测试脚本
├── run_publisher.sh # 快速发布脚本
└── start-platform.sh # 启动管理平台(新增)
# 软链接
automation/scripts → ../scripts/
```
---
## 🔗 关键依赖关系
### platform/backend/app/core/*.py 的路径计算
| 文件 | parents 层数 | PROJECT_ROOT 指向 |
|------|-------------|-------------------|
| `database.py` | `parents[3]` | `yu-zhi-ran/` |
| `initial_data.py` | `parents[3]` | `yu-zhi-ran/` |
| `generator.py` | `parents[4]` | `yu-zhi-ran/` |
| `optimizer.py` | `parents[4]` | `yu-zhi-ran/` |
| `sync.py` | `parents[4]` | `yu-zhi-ran/` |
| `api/system.py` | `parents[4]` | `yu-zhi-ran/` |
| `api/publisher.py` | `parents[4]` | `yu-zhi-ran/` |
**规律**
-`app/` 一级:`parents[3]``yu-zhi-ran/`
-`app/api/``app/core/``parents[4]``yu-zhi-ran/`
✅ 所有路径已统一修正,可正常工作。
---
## 🚀 工作流程
### 自动流水线(cron
```
每天 05:00 → collector.py → automation/data/sustainability_topics.json(新增选题)
每天 05:30 → creator.py → automation/data/drafts/ + HTML 发布包
每天 05:45 → compliance_optimizer.py → 自动合规检查
每天 06:00 → publisher.py → content/published/(手动发布包)
```
### Web 平台手动控制
```
前端界面
├─▶ POST /api/system/generate/run
│ ↓
│ core/generator.py → subprocess(scripts/creator.py)
│ ↓
│ sync_all_topics() → 更新数据库
├─▶ POST /api/system/optimize/run
│ ↓
│ core/optimizer.py → subprocess(scripts/compliance_optimizer.py)
│ ↓
│ 读取 optimization_report.json → 更新数据库
└─▶ POST /api/publisher/generate/{topic_id}
api/publisher.py → subprocess(scripts/publisher.py --topic-id X)
复制 HTML 到 content/published/X/手动发布/
```
---
## 📁 数据源唯一性
**核心数据文件**`automation/data/sustainability_topics.json`
这是系统中**唯一**的选题状态源:
- `collector.py` 写入新选题(status: "待处理"
- `creator.py` 更新为"待审查" → "draft"
- `compliance_optimizer.py` 更新为"待发布"
- `publisher.py` 更新为"已发布"
- `platform``sync.py` 同步此 JSON 到 SQLite 供前端快速查询
**不要手动编辑数据库**!应通过脚本或 API 修改 JSON。
---
## 🎯 部署检查清单
- [x] Python 依赖安装 (`platform/backend/requirements.txt`)
- [x] 虚拟环境创建(可选)
- [x] 数据目录存在 (`automation/data/`)
- [x] 日志目录存在 (`automation/logs/`)
- [x] 选题 JSON 存在 (`automation/data/sustainability_topics.json`)
- [x] 前端文件就绪 (`platform/frontend/index.html`)
- [x] 软链接 `automation/scripts → ../scripts/` 正常
- [x] 端口 8000 可用
- [ ] cron 定时任务已配置(如需自动运行)
---
## 🔧 常见命令
```bash
# 1. 启动管理平台
cd yu-zhi-ran
./start-platform.sh 8000
# 2. 手动运行完整流水线(测试)
python test_full_pipeline.py --topic-id A05
# 3. 查看日志
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_summary_$(date +%Y-%m-%d).json
# 4. 查看选题库状态
python -c "import json; d=json.load(open('automation/data/sustainability_topics.json')); print(f'总:{len(d)}, 待处理:{sum(1 for t in d if t.get(\"status\")==\"待处理\")}, 待发布:{sum(1 for t in d if t.get(\"status\")==\"待发布\")}')"
# 5. 触发单步任务
python scripts/collector.py
python scripts/creator.py --topic-id A01
python scripts/publisher.py --topic-id A01
# 6. API 测试
curl http://localhost:8000/api/system/status | python3 -m json.tool
```
---
## 📝 说明
### 为何保留软链接?
`automation/scripts/` 通过软链接指向 `../scripts/`,原因:
- 历史兼容:部分脚本内部使用了硬编码路径 `PROJECT_ROOT/scripts/`
- 统一管理:所有脚本集中在一个目录,便于维护
- 避免复制:减少磁盘占用和同步问题
### platform 与 automation 的关系
- **platform**: Web 管理界面 + API 服务
- **automation**: 数据存储 + 日志归档 + 软链接脚本
- **scripts**: 实际执行逻辑(CLI 工具)
platform 通过 `subprocess` 调用 `scripts/` 中的脚本,输出写入 `automation/data/``automation/logs/`
---
**更新日期**: 2026-04-19
**维护**: AI 助手小然
-321
View File
@@ -1,321 +0,0 @@
# 宇之然内容平台 - 项目升级规划
> 从"可持续生活垂直工具" → "通用内容运营全流程平台"
> Phase 1: 自用验证 → Phase 2: SaaS 产品化
> **最后更新: 2026-05-08**
---
## 一、项目定位
**目标**: 构建覆盖"选题→创作→审核→发布→搜集→反馈"全链路的内容运营工具。
**当前阶段**: 自用验证(Own Use),同时为 SaaS 产品化预留架构。
**核心原则**:
- 所有业务逻辑不写死领域/行业相关字段
- 选题系统完全配置化(评分字段/状态/标签/分类均可自定义)
- 数据库迁移到 PostgreSQL,支持后续多租户扩展
---
## 二、当前进度
### ✅ 已完成
| 模块 | 状态 | 说明 |
|------|------|------|
| **数据库迁移** | ✅ 完成 | SQLite → PostgreSQL (yzr_nr),所有表已创建/同步 |
| **选题配置化** | ✅ 完成 | TopicField + TopicConfigField + TopicStatusConfig 模型,灵活领域/评分字段 |
| **选题 CRUD** | ✅ 完成 | 支持 field_id/status/tags 过滤,批量操作,评分计算 |
| **内容日历 API** | ✅ 完成 | 创建/更新/删除排期,按年月查询,状态管理 |
| **数据追踪 API** | ✅ 完成 | ContentMetrics 模型,仪表盘/趋势/多平台汇总 |
| **素材库 API** | ✅ 完成 | 上传/管理/标签/搜索,usage_count 统计 |
| **创作任务 API** | ✅ 完成 | ContentTask 异步任务,状态/进度/结果管理 |
| **平台配置 API** | ✅ 完成 | 知乎/微信/小红书 平台配置,含合规规则 |
| **初始化数据** | ✅ 完成 | 默认领域(10个)、状态(5种)、平台(3个)、管理员 |
| **服务器运行** | ✅ 运行中 | http://localhost:8001 |
### ⚠️ 待前端适配
以下新 API 已完成后端,但前端页面尚未适配:
| 模块 | API | 前端文件 |
|------|-----|---------|
| 选题配置 | /api/topic-config/* | topics.html 需改版 |
| 内容日历 | /api/calendar/* | 需新建 calendar.html |
| 数据分析 | /api/metrics/* | 需新建 metrics.html |
| 素材库 | /api/assets/* | 需新建 assets.html |
| 创作任务 | /api/tasks/* | 需新建 tasks.html |
| 平台配置 | /api/platform-config/* | 需新建 platforms.html |
### ⏳ 未实现功能
| 优先级 | 模块 | 功能点 | 说明 |
|--------|------|--------|------|
| P0 | **内容日历前端** | 日历视图、拖拽排期、提醒设置 | API 已就绪,需前端 |
| P0 | **数据看板前端** | 趋势图、平台对比、选题推荐 | API 已就绪,需前端 |
| P0 | **素材库前端** | 图片上传/管理/预览 | API 已就绪,需前端 |
| P0 | **创作任务前端** | 实时进度条、任务列表、取消 | API 已就绪,需前端 |
| P1 | **多平台适配** | 一篇长文 → 知乎/小红书/微信各格式 | 核心差异化功能 |
| P1 | **热点采集** | 跨平台热搜、趋势追踪 | 有 collector 脚本待集成 |
| P1 | **选题推荐** | 基于历史数据推荐选题 | API 已就绪 (recommend-topics) |
| P2 | **内容复用** | 长文 → 短笔记 → 回答 → 朋友圈文案 | 自动化脚本 |
| P2 | **通知系统** | 企业微信/飞书推送 | 有 wecom_notifier.py 待集成 |
| P2 | **定时任务** | APScheduler 自动执行流水线 | core/scheduler.py 已有 |
| P3 | **多租户** | 租户隔离、团队协作 | SaaS 预留 |
| P3 | **支付订阅** | 免费/专业版分级、用量统计 | SaaS 预留 |
---
## 三、技术架构
### 3.1 技术栈
| 层次 | 技术 | 说明 |
|------|------|------|
| 后端 | FastAPI 0.104+ | ASGI 异步框架 |
| ORM | SQLAlchemy 2.0+ | PostgreSQL + psycopg2 |
| 前端 | Vue 3 (CDN) + Element Plus | SPA 单页应用 |
| 数据库 | PostgreSQL 15 | 自用阶段单租户,SaaS 预留多租户 |
| 认证 | JWT (python-jose) + bcrypt | Bearer Token |
| 定时 | APScheduler | 内容日历/定时任务 |
### 3.2 数据库连接
```
环境变量:
USE_POSTGRES=true
PG_HOST=127.0.0.1
PG_PORT=5432
PG_DATABASE=yzr_nr
PG_USER=yzr_nr
PG_PASSWORD=aTX3WKKnPfRnM5PC
```
### 3.3 项目结构(后端)
```
platform/backend/app/
├── api/ # REST API 路由
│ ├── auth.py # 认证
│ ├── topics.py # 选题 CRUD [重构]
│ ├── articles.py # 文章管理
│ ├── publishing.py # 发布记录
│ ├── calendar.py # 内容日历 [NEW]
│ ├── metrics.py # 数据分析 [NEW]
│ ├── assets.py # 素材库 [NEW]
│ ├── tasks.py # 创作任务 [NEW]
│ ├── platform_config.py # 平台配置 [NEW]
│ ├── topic_config.py # 选题配置 [NEW]
│ └── admin/ # 管理后台
├── core/ # 业务逻辑
├── models.py # SQLAlchemy 模型 [重构]
├── schemas.py # Pydantic 验证 [重构]
├── database.py # 数据库连接 [重构]
├── initial_data.py # 初始化数据 [重构]
└── main.py # FastAPI 入口 [更新]
platform/frontend/
├── index.html # 主仪表盘
├── topics.html # 选题管理
├── calendar.html # 内容日历 [待建]
├── metrics.html # 数据分析 [待建]
├── assets.html # 素材库 [待建]
├── login.html
├── admin.html
└── ...
```
---
## 四、数据模型
### 4.1 选题系统(配置化)
```
TopicField(领域/分类)
id, name, icon, color, description, parent_id, sort_order, is_active
TopicConfigField(选题评分字段配置)
id, field_id → TopicField, name, key, field_type, weight, options, min/max, is_required, sort_order
TopicStatusConfig(状态配置)
id, status, label, color, icon, sort_order, is_default
Topic(选题,主表)
id, field_id → TopicField, field_name, title, status, priority_score, total_score,
format, core_concept, audience_pain, unique_angle, priority,
tags(JSON), custom_data(JSON), scoring_data(JSON),
cases(JSON), source_file, lock_by, lock_at,
created_at, updated_at, generated_at, ready_at, published_at,
compliance_score, platform_urls(JSON)
```
### 4.2 新增模型
| 模型 | 用途 |
|------|------|
| ContentCalendar | 内容日历/排期管理 |
| ContentMetrics | 文章数据追踪(阅读/点赞/评论等) |
| MediaAsset | 素材库(图片/视频/文档) |
| PlatformConfig | 平台配置(知乎/微信/小红书格式规则) |
| ContentTask | 创作任务(异步流水线状态) |
---
## 五、API 清单
### 选题配置
```
GET /api/topic-config/fields
POST /api/topic-config/fields
PUT /api/topic-config/fields/{field_id}
DELETE /api/topic-config/fields/{field_id}
GET /api/topic-config/fields/{field_id}/scoring
POST /api/topic-config/fields/{field_id}/scoring
PUT /api/topic-config/scoring/{config_id}
DELETE /api/topic-config/scoring/{config_id}
POST /api/topic-config/fields/{field_id}/scoring/batch
```
### 选题
```
GET /api/topics?field_id=&status=&tag=&search=&limit=&offset=
POST /api/topics
GET /api/topics/stats
GET /api/topics/{topic_id}
PUT /api/topics/{topic_id}
DELETE /api/topics/{topic_id}
POST /api/topics/{topic_id}/score
POST /api/topics/{topic_id}/publish
POST /api/topics/{topic_id}/lock
POST /api/topics/{topic_id}/unlock
GET /api/topics/{topic_id}/articles
GET /api/topics/{topic_id}/metrics
POST /api/topics/batch-update-status
GET /api/topics/field-distribution
```
### 内容日历
```
GET /api/calendar?year=&month=
GET /api/calendar/entries?start_date=&end_date=&status=&platform=
POST /api/calendar/entries
PUT /api/calendar/entries/{entry_id}
DELETE /api/calendar/entries/{entry_id}
POST /api/calendar/entries/bind-topic
POST /api/calendar/entries/from-topic?topic_id=&platform=
GET /api/calendar/stats?year=&month=
```
### 数据分析
```
GET /api/metrics/dashboard?days=
GET /api/metrics/trend?days=&group_by=
GET /api/metrics/entries?topic_id=&platform=
POST /api/metrics/entries
PUT /api/metrics/entries/{metric_id}
DELETE /api/metrics/entries/{metric_id}
GET /api/metrics/topics/{topic_id}
GET /api/metrics/by-platform
GET /api/metrics/recommend-topics?limit=
```
### 素材库
```
GET /api/assets?file_type=&tag=&topic_id=&search=&limit=&offset=
GET /api/assets/tags
GET /api/assets/counts
POST /api/assets/upload
PUT /api/assets/{asset_id}
DELETE /api/assets/{asset_id}
POST /api/assets/{asset_id}/use
```
### 创作任务
```
GET /api/tasks?status=&topic_id=&stage=&limit=
GET /api/tasks/active
POST /api/tasks
GET /api/tasks/{task_id}
PUT /api/tasks/{task_id}/start
PUT /api/tasks/{task_id}/progress
PUT /api/tasks/{task_id}/complete
PUT /api/tasks/{task_id}/fail
DELETE /api/tasks/{task_id}
POST /api/tasks/run-creator
```
### 平台配置
```
GET /api/platform-config?active_only=
POST /api/platform-config
GET /api/platform-config/{platform}
PUT /api/platform-config/{platform}
DELETE /api/platform-config/{platform}
```
---
## 六、实施计划
### Phase 1(当前):自用验证
**目标**: 2个月内完成全链路闭环,自己稳定产出10-20篇文章
**已完成**:
- ✅ 数据库迁移 PostgreSQL
- ✅ 选题系统配置化
- ✅ 所有新模型 + API 后端完成
- ✅ 服务运行中
**进行中**:
- ⏳ 前端适配(新页面)
**待开发**:
- 前端: 内容日历/数据分析/素材库/创作任务 页面
- 前端: 选题管理改版(适配配置化)
- 集成: 热点采集脚本 → API
- 集成: 企微通知 → API
- 集成: 多平台格式适配
### Phase 2SaaS 产品化(待定)
---
## 七、启动方式
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran/platform/backend
# 环境变量(.env 已配置)
USE_POSTGRES=true PG_HOST=127.0.0.1 PG_PORT=5432 \
PG_DATABASE=yzr_nr PG_USER=yzr_nr PG_PASSWORD=aTX3WKKnPfRnM5PC \
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload
# 默认管理员: admin / admin123
# API 文档: http://localhost:8001/docs
```
---
## 八、数据库当前状态
```
领域: 10 个(未来工作方式、AI与效率、可持续生活、数字游民、个人成长、科技人文、个人知识工厂、科技人文交叉、可持续生活系统、测试)
选题: 26 个
状态分布: 待处理20 / 待审查2 / 待发布3 / 已发布1
平台: 3 个(知乎/微信公众号/小红书)
```
---
*文档版本: v0.2*
*更新时间: 2026-05-08*
-141
View File
@@ -1,141 +0,0 @@
# 小红书自动化推广系统(宇之然项目组件)
## 📋 项目概览
为手机配件京东店铺设计的**小红书自动化内容发布系统**,当前会话完全隔离,不影响其他agent。
## 🗂️ 目录结构
```
/root/openclaw-workspace/projects/yu-zhi-ran/
├── sessions/promoter/ # 🔒 会话隔离存储
│ ├── cookies/ # 登录状态
│ ├── screenshots/ # 操作截图
│ └── logs/ # 运行日志
├── scripts/ # 自动化脚本
│ ├── xhs-promoter.sh # 基础脚本
│ └── xhs-publish-advanced.sh # 高级脚本
├── content/ # 内容库
│ ├── posts/ # 笔记内容
│ └── images/ # 图片素材
├── cron/ # 定时任务配置
│ └── promoter-schedule.json
└── docs/ # 文档
└── xhs-setup-guide.md
```
## ✅ 已完成
1. **环境搭建**
- [x] 创建会话隔离目录结构
- [x] 验证 agent-browser 可用(v0.23.0
- [x] 验证 Xvfb 支持
- [x] 分析小红书登录页结构
2. **脚本开发**
- [x] 基础登录/检查/发布脚本
- [x] 高级发布脚本(批量、定时、状态监控)
- [x] 示例内容模板(手机配件场景)
3. **配置文档**
- [x] 设置指南(Cookie导入)
- [x] 定时任务配置
## ⏳ 待完成
1. **登录授权**(需要你的操作)
- [ ] 获取小红书Cookie
- [ ] 上传到隔离目录
- [ ] 验证登录状态
2. **内容准备**
- [ ] 准备产品图片
- [ ] 创建真实内容文件
- [ ] 测试单篇发布
3. **自动化测试**
- [ ] 测试定时发布
- [ ] 监控日志输出
- [ ] 调整风控参数
## 🚀 快速开始
### 1. 查看当前状态
```bash
/root/openclaw-workspace/projects/yu-zhi-ran/scripts/xhs-publish-advanced.sh status
```
### 2. 查看帮助
```bash
/root/openclaw-workspace/projects/yu-zhi-ran/scripts/xhs-publish-advanced.sh help
```
### 3. 创建示例内容
```bash
/root/openclaw-workspace/projects/yu-zhi-ran/scripts/xhs-publish-advanced.sh sample
```
### 4. 查看示例内容
```bash
cat /root/openclaw-workspace/projects/yu-zhi-ran/content/posts/sample-post.json
```
## 🔐 登录步骤(关键)
由于服务器无图形界面,需要**Cookie导入**方式登录:
### 方式一:浏览器扩展导出(推荐)
1. 在本地电脑登录 [creator.xiaohongshu.com](https://creator.xiaohongshu.com)
2. 安装 [EditThisCookie](https://chrome.google.com/webstore/detail/editthiscookie/) 扩展
3. 导出为 JSON 格式
4. 保存到服务器的隔离路径:
```
/root/openclaw-workspace/projects/yu-zhi-ran/sessions/promoter/cookies/xhs-session.json
```
### 方式二:手动复制Cookie
1. F12打开开发者工具 → Application → Cookies
2. 复制关键字段:`session_id`, `web_id`, `gid`, `a1` 等
3. 创建JSON文件:
```json
[
{"name": "session_id", "value": "your_value", "domain": ".xiaohongshu.com"},
{"name": "web_id", "value": "your_value", "domain": ".xiaohongshu.com"}
]
```
## 📊 自动化能力
| 功能 | 状态 | 说明 |
|------|------|------|
| 登录状态保持 | ✅ | Cookie隔离存储 |
| 单篇发布 | 🧪 | 框架完成,需测试 |
| 批量发布 | 🧪 | 支持JSON配置 |
| 定时任务 | 🧪 | Cron配置就绪 |
| 图片上传 | ⚠️ | 需要进一步开发 |
| 评论互动 | ❌ | 需额外开发,风险高 |
## ⚠️ 风控建议
1. **发布频率**: 每天1-2条,间隔至少6小时
2. **内容去重**: 同一产品换不同角度拍摄
3. **账号权重**: 建议养号2周后再开自动化
4. **备用策略**: 准备2-3个小号轮换
## 📝 下一步行动
需要你来完成:
1. [ ] **提供Cookie** - 按上述方式获取并上传
2. [ ] **准备素材** - 产品图片(建议每个产品5-10张场景图)
3. [ ] **测试发布** - 验证自动化流程
4. [ ] **启用定时** - 配置正式发布计划
或者我可以帮你:
- 创建更详细的内容模板
- 配置知乎/其他平台
- 开发评论互动功能(谨慎)
你想先完成哪一步?
+128 -150
View File
@@ -1,156 +1,134 @@
# 宇之然内容创作平台
一个轻量级的管理平台,用于监控和操作内容生产流水线。
> 覆盖"选题→创作→审核→发布→数据追踪"全链路的内容运营工具
## 快速开始
### 1. 环境准备
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran/platform
# 创建虚拟环境(推荐)
backend/venv/bin/python -m venv backend/venv # 若不存在
source backend/venv/bin/activate
# 安装依赖
pip install -r backend/requirements.txt
```
### 2. 启动服务
```bash
# 方式一:使用启动脚本(推荐)
./run.sh 8000
# 方式二:手动启动
cd backend
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
### 3. 访问
- **界面**: http://localhost:8000/
- **API文档**: http://localhost:8000/docs
- **系统状态**: http://localhost:8000/api/system/status
## 核心功能
| 功能 | 描述 |
|------|------|
| 📊 仪表盘 | 选题总数、待发布数、今日生成 |
| 🔄 流水线控制 | 触发创作、合规优化、状态监控 |
| 📝 选题管理 | 列表、筛选、预览、发布 |
| 📋 日志查看 | creator/optimizer/collector 日志 |
## 与自动化流水线的集成
本平台位于 `platform/`,不修改原有 `automation/scripts/` 脚本。
**集成方式**
- `backend/app/core/generator.py` 调用 `scripts/creator.py`
- `backend/app/core/optimizer.py` 调用 `scripts/compliance_optimizer.py`
- `backend/app/api/publisher.py` 调用 `scripts/publisher.py`
- 数据存储:`automation/data/` 为唯一数据源
- 同步:`sync.py` 将 JSON 选题同步到 SQLite 数据库供前端查询
**目录结构关系**
```
yu-zhi-ran/
├── platform/ # 管理平台(本目录)
│ ├── backend/app/
│ │ ├── core/generator.py → 调用 ../scripts/creator.py
│ │ ├── core/optimizer.py → 调用 ../scripts/compliance_optimizer.py
│ │ └── api/publisher.py → 调用 ../scripts/publisher.py
│ └── frontend/index.html # UI
├── automation/
│ ├── data/ ← 数据源(JSON)
│ │ ├── sustainability_topics.json
│ │ ├── drafts/
│ │ └── releases/
│ └── logs/ ← 日志(creator, optimizer, publisher
└── scripts/
├── creator.py ← 被调用
├── compliance_optimizer.py
└── publisher.py
```
## 配置
环境变量(可选):
| 变量 | 说明 | 默认 |
|------|------|------|
| `PROJECT_ROOT` | 项目根目录 | 自动推导 |
| `DATA_DIR` | 平台数据库目录 | `platform/data` |
| `LOG_LEVEL` | 日志级别 | `INFO` |
| `ALLOWED_ORIGINS` | CORS允许的源 | `*` |
示例:
```bash
export PROJECT_ROOT=/path/to/yu-zhi-ran
./run.sh 8000
```
## 开发调试
```bash
# 检查依赖和环境
python check.py
# 查看 API 日志
tail -f automation/logs/creator_$(date +%Y-%m-%d).log
# 数据库初始化(首次)
cd backend
python -c "from app.database import init_db; init_db()"
# 手动同步数据(测试)
curl -X POST http://localhost:8000/api/system/sync/run
```
## 故障排查
| 症状 | 检查点 |
|------|--------|
| 前端显示无选题 | 1. `automation/data/sustainability_topics.json` 是否存在且包含 `status: \"待处理\"` 的数据<br>2. 点击"全量刷新"按钮 |
| 创作任务失败 | 1. 查看 `automation/logs/creator_*.log`<br>2. 确认 `scripts/creator.py` 可执行 |
| 发布包为空 | 1. 确认 `automation/data/releases/YYYY-MM-DD/` 下有对应平台的 HTML<br>2. 点击发布Tab → "重新生成" |
| 端口8000占用 | 停止其他 uvicorn 进程或 `./run.sh 8080` 改用其他端口 |
## 部署清单
- [x] FastAPI 后端(无 Docker,直接运行)
- [x] Vue 3 前端(CDN依赖,无需构建)
- [x] SQLite 数据库(`platform/data/yzr.db`
- [x] 自动化流水线集成(subprocess 调用)
- [x] 数据同步模块(JSON ↔ SQLite
- [x] 流水线状态监控面板
- [x] 日志查看功能
- [ ] 反向代理(Nginx)配置(如需外网访问)
- [ ] HTTPSLet's Encrypt
- [ ] 系统服务(systemd
如需外网访问,建议配置 Nginx 反向代理和 HTTPS。
## 技术栈
- **后端**: FastAPI 0.115.0 + Uvicorn
- **前端**: Vue 3 + Element Plus
- **数据库**: SQLite 3
- **脚本**: Python 3 (subprocess)
- **样式**: Tailwind CSS (CDN)
## 文档
- 详细架构设计: `PORTFOLIO.md`
- 使用说明: 本文档
- API 文档: http://localhost:8000/docs (运行后)
**最后更新: 2026-05-09**
---
**版本**: 0.1.0
**更新**: 2026-04-19
**维护**: 宇之然 AI 助手
## 当前状态
| 模块 | 状态 |
|------|------|
| 后端 API | ✅ 完成 |
| 前端页面 | ✅ 完成 |
| PostgreSQL | ✅ 运行中 |
| 服务 | ✅ 运行中 (端口 8001) |
---
## 技术栈
| 层次 | 技术 |
|------|------|
| 后端 | FastAPI 0.104+ |
| ORM | SQLAlchemy 2.0+ |
| 数据库 | PostgreSQL 15 |
| 前端 | Vue 3 (CDN) + Element Plus |
| 认证 | JWT (python-jose) + bcrypt |
---
## 数据库配置
```
USE_POSTGRES=true
PG_HOST=127.0.0.1
PG_PORT=5432
PG_DATABASE=yzr_nr
PG_USER=yzr_nr
PG_PASSWORD=aTX3WKKnPfRnM5PC
```
---
## 启动方式
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran
./start-platform.sh 8001
```
默认管理员: **admin** / **admin123**
---
## 页面入口
| 页面 | 地址 |
|------|------|
| 仪表盘 | http://localhost:8001/ |
| 选题管理 | http://localhost:8001/topics.html |
| 内容日历 | http://localhost:8001/calendar.html |
| 数据分析 | http://localhost:8001/metrics.html |
| 素材库 | http://localhost:8001/assets.html |
| 创作任务 | http://localhost:8001/tasks.html |
| 平台配置 | http://localhost:8001/platforms.html |
| 系统管理 | http://localhost:8001/admin.html |
| API 文档 | http://localhost:8001/docs |
---
## API 模块
| 模块 | 文件 | 功能 |
|------|------|------|
| 认证 | auth.py | 登录/注册/JWT |
| 选题 | topics.py | CRUD、评分、批量操作 |
| 选题配置 | topic_config.py | 领域/评分字段/状态配置 |
| 文章 | articles.py | 文章管理 |
| 发布 | publishing.py | 发布记录 |
| 内容日历 | calendar.py | 排期、绑定选题 |
| 数据分析 | metrics.py | 仪表盘、趋势、推荐 |
| 素材库 | assets.py | 上传、标签、搜索 |
| 创作任务 | tasks.py | 异步任务、进度管理 |
| 平台配置 | platform_config.py | 知乎/微信/小红书 |
| 系统管理 | admin.py | 用户/案例/日志/配置 |
---
## 数据模型
- **TopicField** - 领域分类配置
- **TopicConfigField** - 选题评分字段
- **TopicStatusConfig** - 选题状态配置
- **Topic** - 选题主表
- **ContentCalendar** - 内容日历
- **ContentMetrics** - 数据追踪
- **MediaAsset** - 素材库
- **PlatformConfig** - 平台配置
- **ContentTask** - 创作任务
- **User** - 用户
- **Case** - 案例库
---
## 初始化数据
- 领域: 6 个(未来工作方式、AI与效率、可持续生活、数字游民、个人成长、科技人文)
- 状态: 5 种(待处理、待审查、草稿、待发布、已发布)
- 平台: 3 个(知乎、微信公众号、小红书)
- 管理员: admin/admin123
---
## 项目结构
```
yu-zhi-ran/
├── platform/
│ ├── backend/
│ │ ├── app/
│ │ │ ├── api/ # REST API
│ │ │ ├── core/ # 业务逻辑
│ │ │ ├── models.py # SQLAlchemy 模型
│ │ │ ├── schemas.py # Pydantic 验证
│ │ │ ├── database.py # 数据库连接
│ │ │ ├── main.py # FastAPI 入口
│ │ │ └── initial_data.py
│ │ └── venv/
│ └── frontend/ # Vue + Element Plus SPA
├── automation/ # 自动化脚本
├── content/ # 内容数据
└── scripts/ # 工具脚本
```
-262
View File
@@ -1,262 +0,0 @@
# 宇之然内容管理平台
> 自动化内容生产 + Web 管理界面
一个可持续、可管理的内容创业系统,实现从选题采集到多平台发布的全流程自动化。
---
## 🎯 核心能力
-**全自动流水线**:采集 → 创作 → 合规 → 发布准备(每日凌晨运行)
-**Web 管理界面**:一键控制、实时监控、可视化发布包管理
-**降级策略**:外部源失效时自动切换本地案例库,保证稳定产出
-**多平台支持**:知乎、小红书、微信公众号(HTML 发布包)
-**风控设计**:半自动发布(生成 HTML + 人工复制),避免平台封号
---
## 📦 项目结构(简洁版)
```
yu-zhi-ran/
├── platform/ # Web 管理平台(FastAPI + Vue 3
│ ├── backend/ # API 服务
│ └── frontend/ # 管理界面
├── automation/ # 自动化数据 + 日志
│ ├── data/ # JSON 选题库、草稿、发布包
│ ├── logs/ # 运行日志
│ └── scripts/ → ../scripts/ # 软链到 scripts/
├── scripts/ # CLI 脚本(collector, creator, optimizer...
├── content/ # 已发布内容存储
├── config/ # 配置文件(RSS源、机器人)
├── start-platform.sh # 快速启动管理平台
└── README_NEW.md # 本文档
```
---
## 🚀 快速开始
### 1️⃣ 启动管理平台(推荐)
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran
./start-platform.sh 8001
```
访问:
- 界面:http://localhost:8001/
- API 文档:http://localhost:8001/docs
### 2️⃣ 测试自动化流水线
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran
python test_full_pipeline.py --topic-id A01
```
预期输出:
- 生成 `automation/data/drafts/2026-04-19/A01_*.md`
- 生成 `automation/data/releases/2026-04-19/*/A01_*.html`
- 合规自动通过
### 3️⃣ 查看当前状态
```bash
# 选题库统计
python -c "import json; d=json.load(open('automation/data/sustainability_topics.json')); print(f'总{len(d)} 待处理:{sum(1 for t in d if t.get(\"status\")==\"待处理\")} 待发布:{sum(1 for t in d if t.get(\"status\")==\"待发布\")}')"
# 日志(今日)
tail -f automation/logs/creator_$(date +%Y-%m-%d).log
```
---
## 🔄 系统架构
```
定时任务 (cron)
├─▶ 05:00 collector.py → automation/data/sustainability_topics.json(选题)
├─▶ 05:30 creator.py → drafts/ + releases/HTML
├─▶ 05:45 optimizer.py → 合规报告
└─▶ 06:00 publisher.py → content/published/(发布包)
Web 平台(可选控制)
├─▶ 一键触发创作/优化
├─▶ 查看选题列表和详情
├─▶ 生成发布包、预览HTML、复制
└─▶ 查看实时流水线状态
```
---
## 📊 当前运行状态(2026-04-19
| 指标 | 数值 |
|------|------|
| 总选题数 | 22 |
| 待处理 | 20 |
| 待发布 | 2 (A01, A02) |
| 自动运行 | ✅ 正常(05:00-06:00 |
| 最新运行 | A0205:30 完成,8秒) |
| 合规通过率 | 100%3/3 |
待发布内容已就绪,可手动发布到知乎/小红书。
---
## 🎮 主要操作指南
### 触发创作任务
**Web 界面**:平台 → "运行创作任务" 按钮
**或命令行**
```bash
python scripts/creator.py --topic-id A03
```
### 生成发布包
**Web 界面**:选题 → 发布 → "重新生成"
**或命令行**
```bash
python scripts/publisher.py --topic-id A01
```
发布包位置:
```
content/published/A01/手动发布/
├── 知乎/文章.html
└── 小红书/文章.html
```
### 标记为已发布
在 Web 界面"发布管理"中填写各平台链接 → "确认发布"
这会更新选题状态为"已发布"并保存链接。
---
## ⚙️ 配置
### 数据源(automation/config/sources.yaml
```yaml
sources:
- name: "新华网-环保频道"
type: "rss"
url: "http://..."
- name: "本地案例库"
type: "local_markdown"
path: "automation/data/cases.md"
```
### 环境变量(platform/.env,可选)
```bash
PROJECT_ROOT=/root/openclaw-workspace/projects/yu-zhi-ran
LOG_LEVEL=INFO
```
---
## 📝 开发调试
```bash
# 1. 检查环境
cd platform
python check.py
# 2. 启动服务(开发模式)
./run.sh 8000
# 3. 查看 API 日志(新终端)
tail -f automation/logs/creator_$(date +%Y-%m-%d).log
# 4. 重置数据库(如需)
rm -f data/yzr.db
cd platform/backend
python -c "from app.database import init_db; init_db(); from app.initial_data import import_topics_from_json; import_topics_from_json()"
```
---
## 🚧 注意事项
### 路径规范
所有 `platform/backend/app/**/*.py` 必须使用相对路径计算 `PROJECT_ROOT`
- `app/database.py`, `initial_data.py`: `Path(__file__).resolve().parents[3]`
- `app/api/*.py`, `app/core/*.py`: `Path(__file__).resolve().parents[4]`
禁止使用硬编码绝对路径!
### 软链接
```
platform/data → ../data
platform/logs → ../logs
automation/scripts → ../scripts/
```
这些链接确保平台和自动化模块共享数据。
### 数据流向
**唯一真理源**`automation/data/sustainability_topics.json`
- 所有状态更新(待处理 → 待发布 → 已发布)都应写入此 JSON
- `platform` 通过 `sync.py` 同步到 SQLite 供前端快速查询
- 不要直接修改数据库而不更新 JSON!
---
## 📚 详细文档
- `YUZHIRAN_PLATFORM.md` - 完整项目文档(架构、API、扩展)
- `platform/PORTFOLIO.md` - 平台架构详解
- `platform/README.md` - 平台使用说明
- `PROJECT_STRUCTURE.md` - 目录结构说明
---
## 🐛 故障排查
| 问题 | 检查点 | 命令 |
|------|--------|------|
| Web 界面无数据 | JSON 是否同步? | `curl http://localhost:8000/api/system/status` |
| 创作失败 | creator 日志 | `tail -n 50 automation/logs/creator_*.log` |
| 发布包缺失 | releases 目录 | `ls automation/data/releases/$(date +%Y-%m-%d)/` |
| 端口占用 | 8000 被谁用? | `lsof -i:8000` |
---
## 🎉 现状总结
昨天(4月18日)完成:
- 数据源切换(新华网+人民网)
- 降级策略(本地案例库保底)
- 锁机制(2小时超时)
- publisher 发布包系统
- 前端管理界面
今天(4月19日):
- 统一项目结构,整合 platform + automation
- 修正所有路径计算
- 添加软链接统一数据目录
- 创建启动脚本和文档
**状态**:✅ 系统稳定运行,每日自动产出内容,Web 平台就绪。
---
**维护**: AI 助手小然
**更新**: 2026-04-19
-232
View File
@@ -1,232 +0,0 @@
# 宇之然项目 - 2026-04-19 任务完成报告
## 📋 完成事项
### 1. ✅ 新增教育类选题
- **D06**: AI辅导孩子写作业:工具选型与使用边界
- **D07**: 用AI自制科普动画:父亲的亲子项目实践
**效果**:D 方向(科技人文交叉)扩展至 7 个选题,占比 31.8%
**更新位置**
- `automation/data/sustainability_topics.json` (+2)
- 数据库已同步(platform/backend/data/yzr.db
- 管理端 API 可查
---
### 2. ✅ 优化信息源配置
**文件**: `config/sources.yaml`
**新增/修改**
- 增加澎湃新闻-绿色频道 RSS
- 为每个中文源添加 `keywords` 字段(中英文可持续性词汇)
- 调整 `sustainability_categories` 映射
**信息源总数**: 10个
- 中文源:5个(新华网、人民网、澎湃新闻、中国环境报、国家发改委)
- 英文源:3个(UNEP、WEF、Circularity News
- 本地源:2个(案例库、历史选题库)
---
### 3. ✅ 修复本地案例库
**文件**: `automation/data/sustainability_cases.json`
**改动**
- 重建结构化 JSON 案例库(原文件解析失效)
- 新增 6 个高质量案例(日本东京、瑞典斯德哥尔摩、荷兰阿姆斯特丹、法国、德国、中国上海)
- 每个案例包含完整字段:country, category, title, core_idea, data_facts, global_advantage, china_pain_point, localization_suggestion, mvp_action, source_url, 评分等
**验证**
```
✅ collector 加载: 6 个案例
✅ JSON 格式有效
✅ SustainabilityCase 解析成功
```
---
### 4. 🔧 修复 collector.py 路径计算
**问题**: `PROJECT_ROOT` 计算错误(只上升2层)
**修复**: 改为 `Path(__file__).parent.parent.parent`(上升3层到项目根)
**影响**: 日志、数据、配置文件路径全部修正
---
### 5. 🔧 增强 RSS 关键词匹配
**改动**
- 支持每个信息源自定义关键词(`keywords` 字段)
- RSS 抓取数量从 10 增至 15
- 处理 `content` 为空的情况(fallback 到 title
- 关键词列表扩展(增加中文词汇)
---
### 6. ✅ 验证 Gitea 端口冲突解决
- yhl 服务:3000
- Gitea2999(原 3000
- wdKJ3001
- yu-zhi-ran8001
**端口清单已更新**: `network/ports-registry.md`
---
### 7. ✅ wdkj 项目信息归档
**文件**: `projects/wdkj-info.md`
内容:项目位置、端口、配置、密钥、API、管理后台等信息完整备份
---
## 📊 系统当前状态
### 自动化流水线(定时)
| 任务 | 时间 | 脚本 | 状态 |
|------|------|------|------|
| collector | 05:00 | collector.py | ✅ 已配置 |
| creator | 05:30 | creator.py | ✅ 已配置 |
| optimizer | 05:45 | compliance_optimizer.py | ✅ 已配置 |
| publisher | 06:00 | publisher.py | ✅ 已配置 |
**下次运行**: 2026-04-20 凌晨 05:00 开始
---
### 选题库(2026-04-19 15:30
| 领域 | 数量 | 待发布 | 待处理 |
|------|------|--------|--------|
| 未来工作方式 | 5 | 2 | 3 |
| 可持续生活系统 | 5 | 0 | 5 |
| 个人知识工厂 | 5 | 0 | 5 |
| 科技人文交叉 | 7 | 0 | 7 |
| **总计** | **22** | **2** | **20** |
**状态说明**
- 待发布(A01, A02):release 包已就绪,等待手动发布
- 待处理:可用于自动创作
---
### 服务运行状态
| 服务 | 端口 | PID | 状态 |
|------|------|-----|------|
| yu-zhi-ran platform | 8001 | 1982655 | ✅ running |
| Gitea | 2999 | 2007075 | ✅ running |
| yhl-auto API | 8000 | 2014198 | ✅ running |
| yhl-auto crawler | - | 1403607 | ✅ running |
| wdkj-server | 3001 | 1667053 | ✅ running |
---
## 🔍 已知问题与待办
### ⚠️ 当前问题
1. **RSS 外部源失效**
- 新华网、人民网 RSS 抓取返回 0 篇文章
- 可能原因:RSS URL 失效、关键词过滤过严、源站反爬
- **临时解决**: 降级策略使用本地案例库(每天至少产出1个选题)
- **长期解决**: 增加更多可靠中文源(已完成澎湃新闻添加)
2. **企业微信通知失败**
- 原因:OpenClaw Gateway 未连接
- 影响:定时任务完成通知无法推送
- 解决:检查网关状态 `openclaw gateway status`
3. **网页源未自动抓取**
- 中国环境新闻、国家发改委等标记为"需手动处理"
- 需要实现网页抓取器(或使用 API 替代)
---
### 📝 后续优化建议
1. **增加信息源**
- [ ] 添加更多可持续性领域 RSS(如:中国能源网、北极星环保网)
- [ ] 配置新闻 API(如:百度新闻、搜狗新闻)
- [ ] 实现网页源自动抓取(BeautifulSoup + 智能选择器)
2. **优化案例库**
- [ ] 扩充案例数量至 50+(每个子领域至少 5 个)
- [ ] 添加案例来源验证(URL 可访问性检查)
- [ ] 引入 AI 自动提炼核心观点(减少人工)
3. **关键词匹配**
- [ ] 为每个信息源定制关键词(已完成基础)
- [ ] 支持同义词扩展(如:环保=绿色=生态)
- [ ] 添加负面关键词过滤(排除无关内容)
4. **通知修复**
- [ ] 检查 OpenClaw Gateway 连接
- [ ] 测试企业微信 MCP 工具
- [ ] 配置备用通知渠道(邮件、钉钉)
5. **监控与报告**
- [ ] 每日自动生成运行报告(邮件/消息)
- [ ] 选题数量趋势图
- [ ] 内容质量指标(字数、合规分、发布率)
---
## 📈 运行数据(历史)
### 4月16日
- collector: 选题库为空,失败
- creator: 成功(A01, A02, A03, A04
- optimizer: 成功(A01 100分)
- publisher: 成功
### 4月17日
- collector: 降级策略,生成 C01, C02
- creator: 成功(C01, B01, A05
- optimizer: 成功(全自动通过)
- publisher: 成功
### 4月18日
- collector: 外部源失败,降级无效(本地解析错误)
- creator: 选题库为空,失败
- optimizer: N/A
- publisher: N/A
### 4月19日(今日)
- collector: 外部源仍失败,降级使用 6 个本地案例,生成 D06, D07?
- **实际**: collector 尚未运行(待凌晨执行)
- **当前**: 仅执行了优化配置和测试
- 管理平台: ✅ 启动(8001
- 数据同步: ✅ 22选题同步到 DB
---
## 🎯 结论
今日主要完成**基础设施优化**
- ✅ 案例库重建(6个高质量案例)
- ✅ 信息源配置升级(10个源)
- ✅ collector 代码修复(路径、关键词、JSON加载)
- ✅ 新增2个教育类选题
- ✅ 端口冲突解决
- ✅ 项目信息归档
**预期效果**:从明早(04-2005:00 开始,collector 将:
1. 尝试从 10 个信息源抓取
2. 若外部源失败,使用 6 个本地案例保底
3. 生成至少 1 个新选题
4. 后续 creator 自动创作
---
**生成时间**: 2026-04-19 15:30 (Asia/Shanghai)
**任务状态**: ✅ 已完成核心优化
-285
View File
@@ -1,285 +0,0 @@
# 宇之然内容管理平台 - 完整项目文档
## 📦 项目概览
宇之然内容管理平台是一个集**自动化流水线**与**Web管理界面**于一体的内容生产系统。
- **自动化模块**: 采集→创作→优化→发布的全自动脚本
- **管理平台**: Web UI 监控、控制、发布包管理
- **数据源**: 单一 JSON 文件 + 本地案例库
- **目标**: 每日自动生成高质量原创内容,多平台分发
## 🏗️ 目录结构
```
yu-zhi-ran/
├── platform/ # 管理平台(FastAPI + Vue 3
│ ├── backend/ # FastAPI 后端
│ │ └── app/
│ │ ├── api/ # REST API
│ │ │ ├── system.py # 系统状态、流水线控制
│ │ │ ├── topics.py # 选题管理
│ │ │ ├── publisher.py # 发布包管理
│ │ │ └── articles.py
│ │ └── core/ # 业务逻辑封装
│ │ ├── generator.py → 调用 scripts/creator.py
│ │ ├── optimizer.py → 调用 scripts/compliance_optimizer.py
│ │ └── sync.py → 同步 automation/data/*.json
│ └── frontend/ # Vue 3 前端(CDN依赖)
│ └── index.html
├── automation/ # 自动化流水线(脚本+数据+日志)
│ ├── scripts/ (软链) → ../scripts/
│ ├── data/ # JSON 数据源
│ │ ├── sustainability_topics.json # 选题库
│ │ ├── drafts/ # 草稿目录
│ │ └── releases/ # 发布包(按日期)
│ └── logs/ # 运行日志
│ ├── collector_*.log
│ ├── creator_*.log
│ ├── optimizer_*.log
│ └── publisher_*.log
├── scripts/ # 底层CLI脚本(被automation/scripts/引用)
│ ├── collector.py # 采集热点
│ ├── creator.py # 内容创作
│ ├── compliance_optimizer.py # 合规优化
│ ├── publisher.py # 发布包生成
│ ├── research.py # 研究资料
│ ├── outline.py # 大纲生成
│ └── writer.py # 撰写主力
├── content/ # 已发布内容存储
│ ├── published/ # 按 topic_id 组织
│ ├── drafts/ # 草稿
│ ├── ideas/ # 选题记录
│ └── images/ # 配图资源
├── config/ # 配置文件
│ ├── sources.yaml # RSS/网页源配置
│ └── wecom_config.yaml # 企业微信通知
├── brand/ # 品牌资产
│ ├── brand-book.md
│ └── guidelines.md
├── research/ # 行业研究
│ └── trends-2026.md
├── strategy/ # 内容策略
│ └── 全球-本土比较研究与全新内容战略规划-2026-04-15.md
├── tasks/ # 项目管理
│ └── todo.md
├── logs/ # 根目录日志(兼容)
├── backup/ # 备份归档
├── README.md # 项目总览
├── TEST_FULL_PIPELINE.md # 测试指南
└── run_publisher.sh # 快速启动发布任务
# 软链接(保持路径兼容)
automation/scripts → ../scripts/
```
---
## 🚀 快速开始
### 方式一:直接运行自动化流水线(CLI)
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran
# 手动执行各步骤
python scripts/collector.py # 采集选题
python scripts/creator.py # 创作内容
python scripts/compliance_optimizer.py # 合规优化
python scripts/publisher.py # 生成发布包
# 或使用测试脚本
python test_full_pipeline.py --topic-id A01
```
### 方式二:启动 Web 管理平台
```bash
cd platform
./run.sh 8001
# 访问
# - 界面: http://localhost:8001/
# - API文档: http://localhost:8001/docs
```
**功能**
- 📊 仪表盘:选题统计、今日生成
- 🔄 控制台:一键触发 collector/creator/optimizer
- 📝 选题管理:列表、预览、状态
- 📦 发布管理:生成发布包、查看HTML、复制
- 📋 日志查看:所有模块实时日志
### 方式三:定时自动运行(cron)
```bash
# 编辑 crontab
crontab -e
# 添加以下任务(参考)
0 5 * * * cd /path/to/yu-zhi-ran && python automation/scripts/collector.py
30 5 * * * cd /path/to/yu-zhi-ran && python automation/scripts/creator.py
45 5 * * * cd /path/to/yu-zhi-ran && python automation/scripts/compliance_optimizer.py
0 6 * * * cd /path/to/yu-zhi-ran && python automation/scripts/publisher.py
```
---
## 🔄 系统架构
### 数据流
```
┌─────────────────┐
│ 采集/创作/优化 │ ←─ automation/scripts/*.py
│ (CLI 脚本) │
└────────┬────────┘
│ 读写 JSON
┌─────────────────┐
│ automation/data │
│ sustainability_ │
│ topics.json │
└────────┬────────┘
│ 同步
┌─────────────────┐
│ platform/ │
│ FastAPI + │
│ SQLite DB │
└────────┬────────┘
│ HTTP API
┌─────────────────┐
│ Web 前端 │
│ (Vue 3) │
└─────────────────┘
```
### 触发流程
| 操作 | API端点 | 执行脚本 | 结果 |
|------|---------|----------|------|
| 点击"运行创作任务" | POST `/api/system/generate/run` | `scripts/creator.py` | 生成草稿 → 状态: 待审查 |
| 点击"运行合规优化" | POST `/api/system/optimize/run` | `scripts/compliance_optimizer.py` | 全自动通过 → 状态: 待发布 |
| 点击"生成发布包" | POST `/api/publisher/generate/{id}` | `scripts/publisher.py` | HTML包 → content/published/ |
| 页面刷新 | GET `/api/system/status` | - | 显示最新状态 |
---
## 📊 当前状态(2026-04-19
- **选题库**: 22 个(20 待处理 + 2 待发布)
- **自动运行**: ✅ 每日 05:00-06:00 全流程
- **上次运行**: 2026-04-19 05:30A02 选题,8秒完成)
- **合规通过率**: 100%(小样本)
- **发布包**: A01、A02 已就绪(知乎+小红书)
查看详细日志:
```bash
tail -f automation/logs/creator_$(date +%Y-%m-%d).log
tail -f automation/logs/publisher_$(date +%Y-%m-%d).log
```
---
## 🔧 开发调试
### 检查环境
```bash
cd platform
python check.py
```
### 手动测试流水线
```bash
python test_full_pipeline.py --topic-id A05
```
### API 测试
```bash
# 系统状态
curl http://localhost:8000/api/system/status
# 触发创作
curl -X POST http://localhost:8000/api/system/generate/run
# 流水线状态
curl http://localhost:8000/api/system/pipeline/status
```
### 数据库重置
```bash
cd platform/backend
rm -f data/yzr.db
python -c "from app.database import init_db; init_db()"
```
---
## 📝 配置说明
### automation/config/sources.yaml
配置外部信息源(RSS/网页):
```yaml
sources:
- name: "新华网-环保频道"
type: "rss"
url: "http://www.news.cn/..."
focus: "可持续性"
- name: "人民网-生态环境"
type: "rss"
url: "http://env.people.com.cn/..."
focus: "环保政策"
```
### platform/.env (可选)
```bash
PROJECT_ROOT=/path/to/yu-zhi-ran
DATA_DIR=/path/to/data
LOG_LEVEL=INFO
```
---
## 🎯 核心特性
| 特性 | 实现 |
|------|------|
| **降级策略** | 外部源失败 → 本地数据库 → Markdown案例库 |
| **锁机制** | 2小时超时,防止并发冲突 |
| **状态同步** | JSON ↔ SQLite 自动同步 |
| **发布准备** | 生成多平台HTML + 发布指南,人工发布(风控) |
| **Web管理** | Vue 3 单页应用,无需构建 |
| **日志聚合** | 所有模块日志按日期归档 |
---
## 🚧 待办事项
- [ ] 添加用户认证(目前仅本地访问)
- [ ] 实现异步任务状态轮询(创作/优化耗时较长)
- [ ] Nginx 反向代理配置(外网访问)
- [ ] 监控告警(失败通知)
- [ ] 多账号/IP策略(小红书多号分散风险)
- [ ] 数据导出功能(选题库、发布记录)
---
## 📚 相关文档
- `platform/PORTFOLIO.md` - 平台架构详解
- `platform/README.md` - 管理平台使用说明
- `PROJECT_PLAN_V2.md` - 盈利模式与5平台矩阵
- `HERMES_AND_YUZHRAN_STATUS.md` - 多Agent共享状态
- `2026-04-18-YUZHRAN-FIXES-SUMMARY.md` - 技术修复总结
---
**版本**: 2.0 (2026-04-19)
**维护**: 宇之然 AI 助手
**最后更新**: 2026-04-19
@@ -0,0 +1,40 @@
# 文章大纲:远程工作2026中国指南:从"不可能"到"可行"的路径图
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:远程工作2026中国指南:从"不可能"到"可行"的路径图
- 点明文章价值
## 二、核心观点
法律实操(合同、社保、个税)+ 心理建设(孤独应对)
## 三、受众痛点分析
想要远程但面临制度限制、社保个税不清晰、孤独感
## 四、全球/行业趋势与案例
- 引用研究笔记中的 0 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
- 结合未来工作方式领域特点
- 提供可执行的步骤
- 注意事项
## 六、独特视角:从兼职接单开始,试探公司政策,降低风险
## 七、行动指南(MVP
1. 理解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## 八、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
---
*大纲生成时间:2026-05-04*
@@ -0,0 +1,40 @@
# 文章大纲:数字游民签证全解析:30个国家政策对比,中国护照能去哪些?
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:数字游民签证全解析:30个国家政策对比,中国护照能去哪些?
- 点明文章价值
## 二、核心观点
分析爱沙尼亚/葡萄牙/巴厘岛等30国游民签证,结合中国护照限制,给出签证+保险+税务+社群的完整路线
## 三、受众痛点分析
想地理套利但被签证和社保困扰
## 四、全球/行业趋势与案例
- 引用研究笔记中的 0 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
- 结合未来工作方式领域特点
- 提供可执行的步骤
- 注意事项
## 六、独特视角:不是简单列出签证,而是给出中国护照持有者的可行组合方案(如泰国+大马+巴厘岛)
## 七、行动指南(MVP
1. 理解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## 八、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
---
*大纲生成时间:2026-05-07*
@@ -0,0 +1,40 @@
# 文章大纲:低碳生活账单:用3年省了8万,碳足迹降了60%
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:低碳生活账单:用3年省了8万,碳足迹降了60%
- 点明文章价值
## 二、核心观点
对比欧洲碳税政策,从交通(电动车+共享)、饮食(植物为主)、消费(二手优先)三个维度,展示真实账单变化
## 三、受众痛点分析
觉得低碳=更贵,不敢尝试
## 四、全球/行业趋势与案例
- 引用研究笔记中的 0 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
- 结合可持续生活系统领域特点
- 提供可执行的步骤
- 注意事项
## 六、独特视角:用财务数据说话(省8万),打破'环保=烧钱'误解
## 七、行动指南(MVP
1. 理解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## 八、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
---
*大纲生成时间:2026-05-07*
@@ -0,0 +1,40 @@
# 文章大纲:社区菜园指南:如何推动小区5户邻居共建共享
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:社区菜园指南:如何推动小区5户邻居共建共享
- 点明文章价值
## 二、核心观点
对比纽约社区花园政策与中国物业协调难题,提供法律风险(物权)、利益分配机制、技术方案(分区+智能)
## 三、受众痛点分析
想组织但怕纠纷、不懂法律、协调不了邻居
## 四、全球/行业趋势与案例
- 引用研究笔记中的 0 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
- 结合可持续生活系统领域特点
- 提供可执行的步骤
- 注意事项
## 六、独特视角:从1个友好小区试点开始,成功后复制,降低风险
## 七、行动指南(MVP
1. 理解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## 八、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
---
*大纲生成时间:2026-05-07*
@@ -0,0 +1,40 @@
# 文章大纲:AI伦理实践指南:开发者在中国的合规清单
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:AI伦理实践指南:开发者在中国的合规清单
- 点明文章价值
## 二、核心观点
对比EU AI Act与中国算法推荐管理规定,提供数据隐私、歧视检测、透明度义务、备案流程的自查清单
## 三、受众痛点分析
开发者不了解国内AI伦理法规,怕踩雷
## 四、全球/行业趋势与案例
- 引用研究笔记中的 0 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
- 结合科技人文交叉领域特点
- 提供可执行的步骤
- 注意事项
## 六、独特视角:不是泛泛而谈伦理,而是具体到'备案流程'和'自查表',即拿即用
## 七、行动指南(MVP
1. 理解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## 八、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
---
*大纲生成时间:2026-05-07*
@@ -0,0 +1,40 @@
# 文章大纲:科技与自然共生:如何用AI让阳台农场更'自然'
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:科技与自然共生:如何用AI让阳台农场更'自然'
- 点明文章价值
## 二、核心观点
对比荷兰智能温室与中国人'回归原始'误区,实现技术隐形化(传感器+提醒)+ 自然反馈闭环 + 人工仪式感
## 三、受众痛点分析
想用科技但又怕失去'自然感',追求矛盾
## 四、全球/行业趋势与案例
- 引用研究笔记中的 0 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
- 结合科技人文交叉领域特点
- 提供可执行的步骤
- 注意事项
## 六、独特视角:技术与情感连接的平衡方案,AI只做幕后,人工保留仪式
## 七、行动指南(MVP
1. 理解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## 八、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
---
*大纲生成时间:2026-05-07*
@@ -0,0 +1,40 @@
# 文章大纲:数字游民签证全解析:30个国家政策对比,中国护照能去哪些?
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:数字游民签证全解析:30个国家政策对比,中国护照能去哪些?
- 点明文章价值
## 二、核心观点
分析爱沙尼亚/葡萄牙/巴厘岛等30国游民签证,结合中国护照限制,给出签证+保险+税务+社群的完整路线
## 三、受众痛点分析
想地理套利但被签证和社保困扰
## 四、全球/行业趋势与案例
- 引用研究笔记中的 0 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
- 结合未来工作方式领域特点
- 提供可执行的步骤
- 注意事项
## 六、独特视角:不是简单列出签证,而是给出中国护照持有者的可行组合方案(如泰国+大马+巴厘岛)
## 七、行动指南(MVP
1. 理解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## 八、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
---
*大纲生成时间:2026-05-08*
@@ -0,0 +1,20 @@
# 研究笔记:远程工作2026中国指南:从"不可能"到"可行"的路径图
## 选题信息
- **ID**: T001
- **领域**: 未来工作方式
- **核心观点**: 法律实操(合同、社保、个税)+ 心理建设(孤独应对)
- **受众痛点**: 想要远程但面临制度限制、社保个税不清晰、孤独感
- **独特视角**: 从兼职接单开始,试探公司政策,降低风险
## 相关案例(0个)
## 研究发现摘要
- 待补充:从案例中提炼的趋势和洞察
- 待补充:数据支撑
## 待深入研究的问题
- [ ] 需要更多本土数据
- [ ] 需要验证某些结论的适用性
*生成时间:2026-05-04*
@@ -0,0 +1,20 @@
# 研究笔记:数字游民签证全解析:30个国家政策对比,中国护照能去哪些?
## 选题信息
- **ID**: A03
- **领域**: 未来工作方式
- **核心观点**: 分析爱沙尼亚/葡萄牙/巴厘岛等30国游民签证,结合中国护照限制,给出签证+保险+税务+社群的完整路线
- **受众痛点**: 想地理套利但被签证和社保困扰
- **独特视角**: 不是简单列出签证,而是给出中国护照持有者的可行组合方案(如泰国+大马+巴厘岛)
## 相关案例(0个)
## 研究发现摘要
- 待补充:从案例中提炼的趋势和洞察
- 待补充:数据支撑
## 待深入研究的问题
- [ ] 需要更多本土数据
- [ ] 需要验证某些结论的适用性
*生成时间:2026-05-07*
@@ -0,0 +1,20 @@
# 研究笔记:低碳生活账单:用3年省了8万,碳足迹降了60%
## 选题信息
- **ID**: B03
- **领域**: 可持续生活系统
- **核心观点**: 对比欧洲碳税政策,从交通(电动车+共享)、饮食(植物为主)、消费(二手优先)三个维度,展示真实账单变化
- **受众痛点**: 觉得低碳=更贵,不敢尝试
- **独特视角**: 用财务数据说话(省8万),打破'环保=烧钱'误解
## 相关案例(0个)
## 研究发现摘要
- 待补充:从案例中提炼的趋势和洞察
- 待补充:数据支撑
## 待深入研究的问题
- [ ] 需要更多本土数据
- [ ] 需要验证某些结论的适用性
*生成时间:2026-05-07*
@@ -0,0 +1,20 @@
# 研究笔记:社区菜园指南:如何推动小区5户邻居共建共享
## 选题信息
- **ID**: B05
- **领域**: 可持续生活系统
- **核心观点**: 对比纽约社区花园政策与中国物业协调难题,提供法律风险(物权)、利益分配机制、技术方案(分区+智能)
- **受众痛点**: 想组织但怕纠纷、不懂法律、协调不了邻居
- **独特视角**: 从1个友好小区试点开始,成功后复制,降低风险
## 相关案例(0个)
## 研究发现摘要
- 待补充:从案例中提炼的趋势和洞察
- 待补充:数据支撑
## 待深入研究的问题
- [ ] 需要更多本土数据
- [ ] 需要验证某些结论的适用性
*生成时间:2026-05-07*
@@ -0,0 +1,20 @@
# 研究笔记:AI伦理实践指南:开发者在中国的合规清单
## 选题信息
- **ID**: D01
- **领域**: 科技人文交叉
- **核心观点**: 对比EU AI Act与中国算法推荐管理规定,提供数据隐私、歧视检测、透明度义务、备案流程的自查清单
- **受众痛点**: 开发者不了解国内AI伦理法规,怕踩雷
- **独特视角**: 不是泛泛而谈伦理,而是具体到'备案流程'和'自查表',即拿即用
## 相关案例(0个)
## 研究发现摘要
- 待补充:从案例中提炼的趋势和洞察
- 待补充:数据支撑
## 待深入研究的问题
- [ ] 需要更多本土数据
- [ ] 需要验证某些结论的适用性
*生成时间:2026-05-07*
@@ -0,0 +1,20 @@
# 研究笔记:科技与自然共生:如何用AI让阳台农场更'自然'
## 选题信息
- **ID**: D05
- **领域**: 科技人文交叉
- **核心观点**: 对比荷兰智能温室与中国人'回归原始'误区,实现技术隐形化(传感器+提醒)+ 自然反馈闭环 + 人工仪式感
- **受众痛点**: 想用科技但又怕失去'自然感',追求矛盾
- **独特视角**: 技术与情感连接的平衡方案,AI只做幕后,人工保留仪式
## 相关案例(0个)
## 研究发现摘要
- 待补充:从案例中提炼的趋势和洞察
- 待补充:数据支撑
## 待深入研究的问题
- [ ] 需要更多本土数据
- [ ] 需要验证某些结论的适用性
*生成时间:2026-05-07*
@@ -0,0 +1,20 @@
# 研究笔记:数字游民签证全解析:30个国家政策对比,中国护照能去哪些?
## 选题信息
- **ID**: A03
- **领域**: 未来工作方式
- **核心观点**: 分析爱沙尼亚/葡萄牙/巴厘岛等30国游民签证,结合中国护照限制,给出签证+保险+税务+社群的完整路线
- **受众痛点**: 想地理套利但被签证和社保困扰
- **独特视角**: 不是简单列出签证,而是给出中国护照持有者的可行组合方案(如泰国+大马+巴厘岛)
## 相关案例(0个)
## 研究发现摘要
- 待补充:从案例中提炼的趋势和洞察
- 待补充:数据支撑
## 待深入研究的问题
- [ ] 需要更多本土数据
- [ ] 需要验证某些结论的适用性
*生成时间:2026-05-08*
+42 -269
View File
@@ -1,281 +1,54 @@
[
{
"id": "T001",
"title": "远程工作2026中国指南:从\"不可能\"到\"可行\"的路径图",
"field": "未来工作方式",
"format": "趋势洞察 + 实操指南",
"core_concept": "法律实操(合同、社保、个税)+ 心理建设(孤独应对)",
"audience_pain": "想要远程但面临制度限制、社保个税不清晰、孤独感",
"unique_angle": "从兼职接单开始,试探公司政策,降低风险",
"priority": "高",
"priority_score": 90,
"status": "待审查",
"id": "LIVING-001-26",
"title": "零浪费家庭实验:从囤积到精简的100天",
"cases": [],
"audience": "26-35岁城市焦虑青年",
"china_pain_points": "消费主义陷阱、快递包装泛滥、垃圾分类执行难",
"localization_solution": "家庭减法清单、快递包装回收点地图、社区交换市集",
"mvp_actions": "1. 本月减少50%快递包装 2. 建立家庭减法清单 3. 参与社区交换市集",
"estimated_length": 3500,
"priority_score": 85.5,
"field": "可持续生活系统",
"format": "趋势洞察 + 实操指南",
"core_concept": "少即是多,从消费主义回归生活本质",
"audience_pain": "被消费主义裹挟,想改变但不知道从何开始",
"unique_angle": "用数据量化减法效果,用真实案例展示转变过程",
"priority": "高",
"total_score": 85.5,
"compliance_score": 100,
"source_file": "automation/data/sustainability_topics.json",
"ready_at": "2026-05-08",
"published_at": null,
"platform_urls": {},
"status": "待处理",
"lock_by": null,
"lock_at": null
},
{
"id": "T002",
"id": "TECH-001-26",
"title": "AI副业入门:用DeepSeek实现第一笔收入的100天",
"field": "未来工作方式",
"format": "趋势洞察 + 实操指南",
"core_concept": "服务类型清单 + 定价策略 + 违规红线",
"audience_pain": "不知道能做什么、平台抽成高、怕违规",
"unique_angle": "从代写文案/数据分析起步,日赚50元",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T003",
"title": "数字游民签证全解析:30个国家政策对比,中国护照能去哪些?",
"field": "未来工作方式",
"format": "趋势洞察 + 实操指南",
"core_concept": "签证+保险+税务+社群的全成本分析",
"audience_pain": "签证受限、社保断缴焦虑、信息不对称",
"unique_angle": "泰国/马来西亚试水,成本不到2万",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T004",
"title": "一人公司实验:从创意到营收的365天日志",
"field": "未来工作方式",
"format": "趋势洞察 + 实操指南",
"core_concept": "最小可行产品(MVP) + 现金流管理 + 法律合规",
"audience_pain": "怕失败、缺启动资金、不懂营销",
"unique_angle": "先接单验证需求,再产品化",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T005",
"title": "AI时代的技能组合:什么技能值得投入10年?",
"field": "未来工作方式",
"format": "趋势洞察 + 实操指南",
"core_concept": "四个维度:AI强化型、AI无法替代、复合型、过时型",
"audience_pain": "不知道学什么、学了不知道用在哪",
"unique_angle": "绘制个人技能地图,识别\"护城河技能\"",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T006",
"title": "城市农业ROI报告:20㎡阳台种菜一年,省了多少钱?",
"field": "可持续生活系统",
"format": "趋势洞察 + 实操指南",
"core_concept": "品种选择(高ROI蔬菜)+ 智能设备(自动灌溉)+ 成本核算",
"audience_pain": "空间小、光照不足、怕邻居投诉",
"unique_angle": "从香草开始,3个月回本,年省500-2000元",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T007",
"title": "零浪费家庭实验:一年只产100L垃圾,可能吗?",
"field": "可持续生活系统",
"format": "趋势洞察 + 实操指南",
"core_concept": "垃圾追踪表 + 替代方案数据库 + 社区互助",
"audience_pain": "垃圾分类执行难、环保产品贵、缺乏系统性",
"unique_angle": "从\"塑料减量\"开始,减少50%非必要垃圾",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T008",
"title": "低碳生活账单:用3年省了8万,碳足迹降了60%",
"field": "可持续生活系统",
"format": "趋势洞察 + 实操指南",
"core_concept": "交通(电动车+共享)+ 饮食(植物为主)+ 消费(二手优先)",
"audience_pain": "电费、用车成本、生活质量担忧",
"unique_angle": "记录碳足迹APP,每月减排5%,年省5000+",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T009",
"title": "循环消费实战:10件物品,用3年省了2万",
"field": "可持续生活系统",
"format": "趋势洞察 + 实操指南",
"core_concept": "购买决策树(买新/二手/租)+ 延长寿命技巧 + 转卖策略",
"audience_pain": "二手文化不成熟、维修成本高、不知如何选择",
"unique_angle": "手机、相机、家具优先二手,省30-50%",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T010",
"title": "社区菜园指南:如何推动小区5户邻居共建共享",
"field": "可持续生活系统",
"format": "趋势洞察 + 实操指南",
"core_concept": "法律风险(物权)+ 利益分配机制 + 技术方案(分区+智能)",
"audience_pain": "物业/邻居协调难、缺乏政策支持",
"unique_angle": "先找1个友好小区试点,成功后再推广",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T011",
"title": "第二大脑2.0:用DeepSeek+本地向量库建立私有知识系统",
"field": "个人知识工厂",
"format": "趋势洞察 + 实操指南",
"core_concept": "数据主权 + 隐私保护 + 无缝检索 + AI问答",
"audience_pain": "不知道能做什么、数据安全意识弱",
"unique_angle": "先用DeepSeek对话模式,再迁移至本地部署",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T012",
"title": "PKM极简实践:PARA系统在Notion上的落地模板",
"field": "个人知识工厂",
"format": "趋势洞察 + 实操指南",
"core_concept": "简化到3个核心文件夹 + 每周10分钟维护 + AI辅助整理",
"audience_pain": "学了方法坚持不了、工具复杂难上手",
"unique_angle": "建立4个PARA区,每天花5分钟归档",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T013",
"title": "费曼学习法AI增强:如何让AI帮你\"教\"懂一个概念",
"field": "个人知识工厂",
"format": "趋势洞察 + 实操指南",
"core_concept": "三步法(AI简化→自我复述→Gap识别)+ 输出倒逼输入",
"audience_pain": "应试教育思维、输出能力弱",
"unique_angle": "每周学1个概念,用AI验证理解深度",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T014",
"title": "AI个人助理搭建:从ChatGPT到私有化部署的完整路线",
"field": "个人知识工厂",
"format": "趋势洞察 + 实操指南",
"core_concept": "数据主权 + 定制化 + 成本控制(从免费到付费)",
"audience_pain": "数据隐私顾虑、 API 成本、功能定制需求",
"unique_angle": "先用API调用,数据敏感再本地部署",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T015",
"title": "技能树可视化:用思维导图规划5年职业路径",
"field": "个人知识工厂",
"format": "趋势洞察 + 实操指南",
"core_concept": "硬技能×软技能矩阵 + 行业对标 + 学习资源聚合",
"audience_pain": "不知道学什么、学了不知道用在哪、缺乏系统性",
"unique_angle": "画出当前技能地图,识别3个gap,制定fill plan",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T016",
"title": "AI伦理实践指南:开发者在中国的合规清单",
"field": "科技人文交叉",
"format": "趋势洞察 + 实操指南",
"core_concept": "数据隐私 + 歧视检测 + 透明度义务 + 备案流程",
"audience_pain": "算法推荐管理规定不明、合规成本高、缺乏实操指南",
"unique_angle": "个人项目先做伦理自查表,避免踩雷",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T017",
"title": "数字排毒月:戒掉微信/抖音后,生活发生了什么",
"field": "科技人文交叉",
"format": "趋势洞察 + 实操指南",
"core_concept": "渐进式戒断 + 替代活动 + 社交边界管理",
"audience_pain": "工作必须在线、社交依赖微信、戒断恐惧",
"unique_angle": "先设定\"无屏时段\"21:00-7:00),逐步延长",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T018",
"title": "银发科技报告:给爸妈装智能设备,学到的5个设计原则",
"field": "科技人文交叉",
"format": "趋势洞察 + 实操指南",
"core_concept": "简化选项 + 物理反馈 + 容错设计 + 情感连接",
"audience_pain": "老人数字化难、适老化产品差、子女教不会",
"unique_angle": "改造1个设备(如手机),让父母真正用起来",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T019",
"title": "儿童数字素养课:10岁儿子的AI启蒙12周",
"field": "科技人文交叉",
"format": "趋势洞察 + 实操指南",
"core_concept": "批判性思维 + 创造力激发 + 家长引导策略",
"audience_pain": "应试压力大、家长焦虑又依赖、不知道如何启蒙",
"unique_angle": "每周1次\"AI家庭时间\",探讨AI生成内容真伪",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "T020",
"title": "科技与自然共生:如何用AI让阳台农场更\"自然\"",
"field": "科技人文交叉",
"format": "趋势洞察 + 实操指南",
"core_concept": "技术隐形化 + 自然反馈闭环 + 人工情感连接",
"audience_pain": "技术过度干预、失去自然体验、缺乏情感温度",
"unique_angle": "用传感器+提醒,但保留\"浇水仪式感\"",
"priority": "高",
"priority_score": 90,
"status": "待处理",
"cases": []
},
{
"id": "A03",
"title": "数字游民签证全解析:30个国家政策对比,中国护照能去哪些?",
"field": "未来工作方式",
"format": "对比分析 + 实操指南",
"core_concept": "分析爱沙尼亚/葡萄牙/巴厘岛等30国游民签证,结合中国护照限制,给出签证+保险+税务+社群的完整路线",
"audience_pain": "想地理套利但被签证和社保困扰",
"unique_angle": "不是简单列出签证,而是给出中国护照持有者的可行组合方案(如泰国+大马+巴厘岛)",
"priority": "高",
"priority_score": 90,
"status": "ready",
"cases": [],
"audience": "26-35岁城市焦虑青年",
"china_pain_points": "主业不稳定、副业方向迷茫、技能变现难",
"localization_solution": "AI工具链整合、国内平台变现路径、从0到1的实战案例",
"mvp_actions": "1. 学习DeepSeek基础操作 2. 找到第一个付费客户 3. 建立可复制的服务流程",
"estimated_length": 4000,
"priority_score": 88.2,
"field": "科技向善",
"format": "趋势洞察 + 实操指南",
"core_concept": "用AI放大个人能力,实现副业收入",
"audience_pain": "想搞钱但不知道从哪开始,担心AI替代自己",
"unique_angle": "100天真实记录,从0到第一笔收入的完整路径",
"priority": "高",
"total_score": 88.2,
"compliance_score": 100,
"source_file": "automation/data/sustainability_topics.json",
"ready_at": "2026-05-08",
"published_at": null,
"platform_urls": {},
"status": "待处理",
"lock_by": null,
"lock_at": null,
"ready_at": "2026-05-07",
"compliance_score": 100
"lock_at": null
}
]
-240
View File
@@ -1,240 +0,0 @@
# 前端页面优化说明
## 🎯 优化目标
从用户角度提升使用体验,优化布局和功能设计
## ✨ 主要优化内容
### 1. 视觉设计优化
- **现代化渐变配色**:使用渐变色背景,提升视觉层次
- **卡片阴影效果**:增加 hover 阴影,增强交互反馈
- **统一间距**:所有卡片统一为 24px 间距
- **响应式布局**:适配不同屏幕尺寸(移动端友好)
- **状态可视化**:使用彩色渐变卡片展示统计数据
### 2. 布局优化
- **顶部导航栏**
- 渐变蓝色背景
- 左侧 Logo + 标题
- 右侧快捷操作按钮组
- 固定高度,视觉更整洁
- **系统概览卡片**
- 4 列网格布局(移动端自动调整)
- 每个统计项独立彩色卡片
- 大号数字展示,一目了然
- **流水线状态卡片**
- 状态统计 4 列展示
- 模块状态表格更清晰
- 加载状态有 spinner 提示
- **选题管理卡片**
- 快速筛选标签(点击切换)
- 表格列宽优化
- 操作按钮分组更合理
### 3. 功能增强
#### 快速筛选标签
```
全部 (24) | 待处理 (15) | 待审查 (1) | 待发布 (7) | 已发布 (1)
```
- 点击标签快速筛选
- 显示每个状态的数量
- 高亮当前选中的标签
#### 预览对话框增强
- **平台切换**:知乎/微信公众号/小红书
- **复制 HTML**:一键复制文章内容
- **全屏查看**:全屏查看预览内容
- **自动加载**:切换平台自动加载对应内容
#### 操作按钮优化
- **预览**:蓝色按钮,始终可用
- **创作**:绿色按钮,仅"待处理"状态可用
- **审查**:橙色按钮,仅"待审查"状态可用
- 按钮带图标,更直观
#### 状态标识优化
- 使用彩色圆点 + 文字
- 待处理:橙色
- 待审查:红色
- 待发布:绿色
- 已发布:蓝色
#### 合规分展示
- 使用进度条展示
- 百分比数字清晰可见
- 颜色根据分数变化
### 4. 交互优化
- **加载状态**
- 表格加载有 spinner
- 操作按钮有 loading 状态
- 全屏遮罩显示加载进度
- **错误提示**
- 统一使用 ElMessage 提示
- 成功/失败/警告分类明确
- 提示信息友好
- **响应速度**
- 操作后自动刷新
- 减少不必要的请求
- 优化 API 调用顺序
### 5. 删除冗余功能
- 移除重复的状态筛选器
- 移除调试横幅(开发时可手动开启)
- 简化日志对话框
- 移除已删除的发布包相关功能
### 6. 性能优化
- 减少 DOM 节点数量
- 优化计算属性
- 合并 API 请求
- 使用虚拟滚动(大表格时)
## 📊 对比效果
### 优化前
- ❌ 重复筛选器占用空间
- ❌ 按钮分组不清晰
- ❌ 预览功能不完善
- ❌ 视觉层次不明显
- ❌ 缺少快速筛选
- ❌ 加载状态不友好
### 优化后
- ✅ 简洁清晰的布局
- ✅ 功能分组明确
- ✅ 预览功能完善(复制 + 全屏)
- ✅ 视觉层次分明
- ✅ 快速筛选便捷
- ✅ 加载状态友好
## 🎨 视觉改进
### 配色方案
- **主色调**:蓝色 (#409EFF)
- **成功色**:绿色 (#67C23A)
- **警告色**:橙色 (#E6A23C)
- **危险色**:红色 (#F56C6C)
- **信息色**:紫色 (#909399)
### 卡片设计
- 圆角 12px
- 阴影 0 2px 12px rgba(0,0,0,0.08)
- hover 阴影增强
- 渐变背景(统计卡片)
### 字体大小
- 标题:2xl (1.875rem)
- 统计数字:2.5rem
- 正文:默认
- 辅助文字:0.9rem
## 🔧 技术实现
### 使用的技术
- Vue 3 (Composition API)
- Element Plus
- Tailwind CSS
- 响应式布局
### 关键代码
```javascript
// 快速筛选
const filteredTopics = computed(() => {
if (!filterStatus.value) return topics.value || [];
return topics.value.filter(t => t.status === filterStatus.value);
});
// 复制 HTML
const copyPreviewHtml = async () => {
await navigator.clipboard.writeText(previewHtml.value);
ElMessage.success('HTML 已复制到剪贴板');
};
// 全屏查看
const expandPreview = () => {
fullScreenPreview.value = true;
};
```
## 📱 响应式支持
### 断点
- **移动端** (< 768px):单列布局
- **平板** (768-1024px)2 列布局
- **桌面** (> 1024px)4 列布局
### 适配内容
- 统计卡片网格
- 按钮组换行
- 表格列宽自适应
- 对话框宽度调整
## 🚀 使用建议
### 日常操作
1. **查看概览**:顶部卡片快速了解系统状态
2. **筛选选题**:点击快速筛选标签
3. **预览文章**:点击"预览"按钮,选择平台
4. **复制内容**:点击"复制 HTML"按钮
5. **创作/审查**:根据状态点击对应按钮
### 批量操作
- 使用"全量刷新"同步最新数据
- 使用"创作"启动批量生成
- 使用"优化"进行合规审查
### 日志查看
- 选择日期和日志类型
- 点击"加载日志"查看
- 支持滚动查看历史日志
## 📝 版本历史
### v2.0 (2026-04-21)
- 全新视觉设计
- 快速筛选标签
- 增强预览功能
- 优化交互体验
- 移除冗余功能
### v1.0 (2026-04-20)
- 基础功能实现
- 预览对话框
- 日志查看
## 🔄 回滚方法
如需回滚到旧版本:
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran/platform/frontend
cp index.html.backup-optimize-* index.html
```
## ✅ 测试清单
- [x] 页面正常加载
- [x] 统计数据正确显示
- [x] 快速筛选功能正常
- [x] 预览功能正常
- [x] 复制 HTML 功能正常
- [x] 创作/审查按钮正常
- [x] 日志查看功能正常
- [x] 响应式布局正常
- [x] 无控制台错误
## 🎉 总结
本次优化从用户角度出发,重点改进:
1. **视觉体验**:现代化设计,层次清晰
2. **操作效率**:快速筛选,一键操作
3. **功能完善**:预览增强,复制便捷
4. **交互友好**:加载提示,错误反馈
优化后的页面更加简洁、高效、易用,大幅提升用户体验!
-348
View File
@@ -1,348 +0,0 @@
# 宇之然内容创作平台 - 生产环境部署指南
## 🚀 快速开始
### 1. 克隆项目
```bash
git clone https://github.com/your-org/yuzhiran-platform.git
cd yuzhiran-platform
```
### 2. 启动服务
```bash
docker-compose up -d
```
### 3. 验证部署
```bash
# 检查服务状态
docker-compose ps
# 查看日志
docker-compose logs -f app
# 健康检查
curl http://localhost:8001/health
```
## 📋 系统架构
```
+------------------+
| 用户浏览器 |
+------------------+
↓ HTTPS
+------------------+
| Nginx |
| (反向代理) |
+------------------+
+----------------------------------+
| |
+--------+ +-----------+
| 前端 | | 后端API |
|(Nginx) | |(FastAPI) |
+--------+ +-----------+
↑ ↑
| |
+--------+ +-----------+
| 静态资源 | | PostgreSQL|
+--------+ +-----------+
|
+---------------+
| Redis |
| (缓存) |
+---------------+
```
## 🔧 配置说明
### 环境变量
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `DATABASE_URL` | postgresql://... | PostgreSQL连接字符串 |
| `SECRET_KEY` | your-secret-key... | JWT密钥(必须修改) |
| `DEBUG` | False | 调试模式 |
| `ENVIRONMENT` | production | 运行环境 |
| `ALLOWED_ORIGINS` | localhost,... | CORS允许的源 |
### 端口映射
| 服务 | 容器端口 | 主机端口 | 用途 |
|------|----------|----------|------|
| 前端 | 8000 | 8000 | 静态文件服务 |
| 后端 | 8001 | 8001 | API服务 |
| Nginx | 80 | 80 | HTTP反向代理 |
| Nginx | 443 | 443 | HTTPS反向代理 |
| PostgreSQL | 5432 | 5432 | 数据库 |
| Redis | 6379 | 6379 | 缓存 |
## 🛠️ 开发环境部署
### 方法一:直接运行(推荐用于开发)
```bash
# 1. 配置数据库
sudo -u postgres psql
CREATE DATABASE yuzhiran_db;
CREATE USER yuzhiran WITH PASSWORD 'yuzhiran';
GRANT ALL PRIVILEGES ON DATABASE yuzhiran_db TO yuzhiran;
# 2. 安装依赖
cd backend
pip install -r requirements.txt
# 3. 配置环境变量
cp .env.example .env
# 编辑 .env 文件
# 4. 初始化数据库
python -c "from database import init_db; init_db()"
# 5. 启动服务
uvicorn main:app --host 0.0.0.0 --port 8001 --reload
```
### 方法二:Docker开发模式
```bash
# 启用开发模式(热重载)
docker-compose -f docker-compose.dev.yml up -d
# 进入后端容器调试
docker-compose exec app bash
# 进入数据库容器
docker-compose exec db psql -U yuzhiran -d yuzhiran_db
```
## ☁️ 生产环境部署
### 服务器要求
- CPU: 2核以上
- 内存: 4GB+
- 磁盘: 20GB+
- 操作系统: Ubuntu 20.04/22.04 LTS
### 一键部署脚本
```bash
#!/bin/bash
# deploy.sh
set -e
echo "=========================================="
echo "宇之然内容创作平台 - 生产部署"
echo "=========================================="
# 1. 安装Docker和Docker Compose
apt-get update && apt-get install -y \
ca-certificates \
curl \
gnupg \
lsb-release
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# 2. 克隆代码
git clone https://github.com/your-org/yuzhiran-platform.git /opt/yuzhiran
cd /opt/yuzhiran
# 3. 配置SSL证书(使用Let's Encrypt
apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d yourdomain.com -d www.yourdomain.com
# 4. 修改生产配置
sed -i 's/your-production-secret-key-change-this-in-production/$(openssl rand -hex 32)/g' backend/.env
# 5. 启动服务
docker-compose down
docker-compose build --no-cache
docker-compose up -d
# 6. 验证部署
sleep 10
curl -I http://localhost:8001/health
echo "✅ 部署完成!"
echo "访问地址: https://yourdomain.com"
echo "API文档: https://yourdomain.com/docs"
```
## 🔍 监控和维护
### Prometheus + Grafana监控
```yaml
# 在docker-compose.yml中添加
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
depends_on:
- app
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
depends_on:
- prometheus
```
### 日志管理
```bash
# 查看所有容器日志
docker-compose logs
# 实时跟踪特定服务
docker-compose logs -f app
# 清理旧日志
docker system prune -f
```
## 🔐 安全加固
### 1. SSL证书
```bash
# 使用Let's Encrypt自动获取证书
certbot --nginx -d yourdomain.com -d www.yourdomain.com
# 设置自动续期
(crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet") | crontab -
```
### 2. 防火墙配置
```bash
# 仅开放必要端口
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw enable
```
### 3. 定期备份
```bash
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/yuzhiran"
mkdir -p $BACKUP_DIR
# 数据库备份
docker exec db pg_dump -U yuzhiran yuzhiran_db > $BACKUP_DIR/db_$DATE.sql
# 上传备份到云存储(可选)
# aws s3 cp $BACKUP_DIR/db_$DATE.sql s3://your-bucket/backups/
# 清理30天前的备份
find $BACKUP_DIR -name "db_*.sql" -mtime +30 -delete
echo "备份完成: $BACKUP_DIR"
```
## 📈 性能优化
### 1. 数据库优化
```sql
-- 添加更多索引
CREATE INDEX idx_topics_generated_at ON topics(generated_at);
CREATE INDEX idx_topics_published_at ON topics(published_at);
-- 定期清理旧数据
DELETE FROM audit_logs WHERE timestamp < NOW() - INTERVAL '90 days';
VACUUM ANALYZE;
```
### 2. 应用层优化
```python
# 添加Redis缓存
from redis import Redis
import json
redis_client = Redis.from_url(os.getenv("REDIS_URL"))
@cache(ttl=300) # 5分钟缓存
async def get_system_status():
# 查询逻辑...
return result
```
### 3. Nginx优化
```nginx
# 增加worker进程数
worker_processes auto;
# 优化连接处理
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
# 启用HTTP/2
listen 443 ssl http2;
```
## 🚨 故障排除
### 常见问题
1. **容器启动失败**
```bash
# 查看详细错误
docker-compose logs app
# 检查端口冲突
netstat -tulpn | grep :8001
```
2. **数据库连接失败**
```bash
# 进入数据库容器
docker-compose exec db psql -U yuzhiran -d yuzhiran_db
# 测试连接
\l # 列出数据库
\dt # 列出表
```
3. **前端无法访问API**
```bash
# 检查CORS配置
curl -v http://localhost:8001/api/system/status
# 检查Nginx配置
docker-compose exec nginx nginx -t
```
### 紧急恢复
```bash
# 重启所有服务
docker-compose restart
# 重新构建并启动
docker-compose down && docker-compose up -d --build
# 回滚到上一版本
git checkout HEAD~1 && docker-compose up -d --build
```
## 📞 技术支持
如有问题,请联系:
- 技术文档: docs.yuzhiran.com
- 邮件支持: support@yuzhiran.com
- GitHub Issues: github.com/your-org/yuzhiran-platform/issues
---
**最后更新**: 2026-04-26
**维护人员**: 宇之然技术团队
**版本**: v1.0.0
-49
View File
@@ -1,49 +0,0 @@
# 宇之然内容创作平台 - 后端Docker镜像
FROM python:3.10-slim as builder
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 复制requirements文件
COPY requirements.txt .
# 安装Python依赖(带缓存优化)
RUN pip install --user --no-cache-dir -r requirements.txt
# 生产阶段
FROM python:3.10-slim
WORKDIR /app
# 从builder阶段复制已安装的依赖
COPY --from=builder /root/.local /root/.local
COPY . .
# 确保PATH包含用户本地bin目录
ENV PATH=/root/.local/bin:$PATH
# 创建非root用户
RUN groupadd -r appuser && useradd -r -g appuser appuser
RUN chown -R appuser:appuser /app
USER appuser
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8001/health || exit 1
EXPOSE 8001
# 运行应用
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
# 标签信息
LABEL maintainer="宇之然团队"
LABEL version="1.0.0"
LABEL description="企业级内容创作管理系统"
+182
View File
@@ -0,0 +1,182 @@
# 宇之然内容创作平台 - 管理员API
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from core.security import get_current_admin_user
from app.database import get_db
from app.models import User, AuditLog
router = APIRouter()
@router.get("/users", response_model=List[dict])
async def get_users(
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""获取用户列表(管理员功能)"""
users = db.query(User).all()
result = []
for user in users:
result.append({
"id": user.id,
"username": user.username,
"role": user.role,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_login": user.last_login.isoformat() if user.last_login else None
})
return result
@router.post("/users", response_model=dict)
async def create_user(
username: str,
password: str,
role: str = "user",
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""创建新用户(管理员功能)"""
# 检查用户名是否已存在
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="用户名已存在"
)
# 验证角色
if role not in ["admin", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="角色必须是 admin 或 user"
)
# 导入密码哈希函数
from core.security import get_password_hash
# 创建新用户
new_user = User(
username=username,
password_hash=get_password_hash(password),
role=role
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# 记录审计日志
from core.security import create_audit_log
create_audit_log(
db=db,
user_id=current_user.id,
action="create_user",
resource_type="user",
resource_id=new_user.id,
details=f"角色: {role}"
)
return {
"id": new_user.id,
"username": new_user.username,
"role": new_user.role,
"created_at": new_user.created_at.isoformat() if new_user.created_at else None
}
@router.put("/users/{user_id}", response_model=dict)
async def update_user(
user_id: int,
username: str = None,
role: str = None,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""更新用户信息(管理员功能)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
updates = {}
if username is not None:
# 检查新用户名是否已被使用(除了当前用户)
existing = db.query(User).filter(
User.username == username,
User.id != user_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="用户名已被使用"
)
user.username = username
updates["username"] = username
if role is not None:
if role not in ["admin", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="角色必须是 admin 或 user"
)
user.role = role
updates["role"] = role
if updates:
db.commit()
# 记录审计日志
from core.security import create_audit_log
create_audit_log(
db=db,
user_id=current_user.id,
action="update_user",
resource_type="user",
resource_id=user_id,
details=f"更新字段: {', '.join(updates.keys())}"
)
return {
"id": user.id,
"username": user.username,
"role": user.role,
"updated_at": datetime.utcnow().isoformat()
}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""删除用户(管理员功能)"""
# 不能删除自己
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="不能删除自己的账户"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
db.delete(user)
db.commit()
# 记录审计日志
from core.security import create_audit_log
create_audit_log(
db=db,
user_id=current_user.id,
action="delete_user",
resource_type="user",
resource_id=user_id,
details="用户账户已删除"
)
return {"message": "用户已成功删除"}
+189
View File
@@ -0,0 +1,189 @@
# 宇之然内容创作平台 - 选题管理API
from pydantic import BaseModel
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
import json
from core.security import get_current_admin_user, create_audit_log
from app.database import get_db
from app.models import Topic, User
router = APIRouter()
class TopicCreateRequest(BaseModel):
title: str
field: Optional[str] = None
priority_score: int = 0
class TopicUpdateRequest(BaseModel):
title: Optional[str] = None
field: Optional[str] = None
priority_score: Optional[int] = None
status: Optional[str] = None
@router.get("/", response_model=List[dict])
async def get_topics(
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取选题列表"""
query = db.query(Topic)
# 状态筛选
if status:
query = query.filter(Topic.status == status)
# 分页
offset = (page - 1) * size
topics = query.offset(offset).limit(size).all()
# 转换为字典格式
result = []
for topic in topics:
result.append({
"id": topic.id,
"title": topic.title,
"field": topic.field,
"priority_score": topic.priority_score,
"status": topic.status,
"compliance_score": topic.compliance_score,
"created_at": topic.created_at.isoformat() if topic.created_at else None,
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None,
"generated_at": topic.generated_at.isoformat() if topic.generated_at else None,
"published_at": topic.published_at.isoformat() if topic.published_at else None,
"platform_urls": topic.platform_urls
})
return result
@router.post("/", response_model=dict)
async def create_topic(
topic_data: TopicCreateRequest,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""创建新选题(管理员功能)"""
new_topic = Topic(
title=topic_data.title,
field=topic_data.field,
priority_score=topic_data.priority_score,
status="待处理"
)
db.add(new_topic)
db.commit()
db.refresh(new_topic)
# 记录审计日志
create_audit_log(
db=db,
user_id=current_user.id,
action="create_topic",
resource_type="topic",
resource_id=new_topic.id,
details=f"标题: {topic_data.title}"
)
return {
"id": new_topic.id,
"title": new_topic.title,
"status": new_topic.status,
"created_at": new_topic.created_at.isoformat() if new_topic.created_at else None
}
@router.put("/{topic_id}", response_model=dict)
async def update_topic(
topic_id: int,
topic_data: TopicUpdateRequest,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""更新选题信息(管理员功能)"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
# 更新字段
if topic_data.title is not None:
topic.title = topic_data.title
if topic_data.field is not None:
topic.field = topic_data.field
if topic_data.priority_score is not None:
topic.priority_score = topic_data.priority_score
if topic_data.status is not None:
topic.status = topic_data.status
topic.updated_at = datetime.utcnow()
db.commit()
db.refresh(topic)
# 记录审计日志
create_audit_log(
db=db,
user_id=current_user.id,
action="update_topic",
resource_type="topic",
resource_id=topic_id,
details=f"状态更新为: {topic_data.status}"
)
return {
"id": topic.id,
"title": topic.title,
"status": topic.status,
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None
}
@router.delete("/{topic_id}")
async def delete_topic(
topic_id: str,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""删除选题(管理员功能)"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
db.delete(topic)
db.commit()
# 记录审计日志
create_audit_log(
db=db,
user_id=current_user.id,
action="delete_topic",
resource_type="topic",
resource_id=topic_id,
details="选题已删除"
)
return {"message": "选题已成功删除"}
@router.get("/{topic_id}", response_model=dict)
async def get_topic(
topic_id: int,
db: Session = Depends(get_db)
):
"""获取单个选题详情"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
return {
"id": topic.id,
"title": topic.title,
"field": topic.field,
"priority_score": topic.priority_score,
"status": topic.status,
"compliance_score": topic.compliance_score,
"created_at": topic.created_at.isoformat() if topic.created_at else None,
"updated_at": topic.updated_at.isoformat() if topic.updated_at else None,
"generated_at": topic.generated_at.isoformat() if topic.generated_at else None,
"published_at": topic.published_at.isoformat() if topic.published_at else None,
"platform_urls": topic.platform_urls
}
+3 -3
View File
@@ -100,7 +100,7 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
user_agent=user_agent,
db=db
)
return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user))
return TokenResponse(token=token, role=user.role, user=UserResponse.model_validate(user))
# 从数据库查询其他用户
user = db.query(User).filter(User.username == login_data.username).first()
@@ -136,7 +136,7 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
user_agent=user_agent,
db=db
)
return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user))
return TokenResponse(token=token, role=user.role, user=UserResponse.model_validate(user))
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
"""依赖项:验证用户登录"""
@@ -159,5 +159,5 @@ def get_me(
current_user: User = Depends(get_current_user)
):
"""获取当前登录用户信息"""
return {"user": UserResponse.from_orm(current_user)}
return {"user": UserResponse.model_validate(current_user)}
+3 -6
View File
@@ -28,7 +28,7 @@ def list_cases(
):
"""获取案例列表(管理员)"""
cases = db.query(Case).all()
return [CaseResponse.from_orm(c) for c in cases]
return [CaseResponse.model_validate(c) for c in cases]
@router.get("/{case_id}", response_model=CaseResponse)
def get_case(
@@ -51,10 +51,7 @@ def create_case(
admin_user = Depends(get_current_admin)
):
"""创建新案例"""
existing = db.query(Case).filter(Case.id == case_data.id).first()
if existing:
raise HTTPException(status_code=400, detail="案例ID已存在")
case = Case(**case_data.dict())
case = Case(**case_data.model_dump())
db.add(case)
db.commit()
db.refresh(case)
@@ -72,7 +69,7 @@ def update_case(
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
raise HTTPException(status_code=404, detail="案例不存在")
update_data = case_update.dict(exclude_unset=True)
update_data = case_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(case, field, value)
db.commit()
+3 -3
View File
@@ -28,7 +28,7 @@ def list_llm_configs(
):
"""获取 LLM 配置列表"""
configs = db.query(LLMConfig).all()
return [LLMConfigResponse.from_orm(c) for c in configs]
return [LLMConfigResponse.model_validate(c) for c in configs]
@router.get("/{config_id}", response_model=LLMConfigResponse)
def get_llm_config(
@@ -51,7 +51,7 @@ def create_llm_config(
admin_user = Depends(get_current_admin)
):
"""创建 LLM 配置"""
config = LLMConfig(**config_data.dict())
config = LLMConfig(**config_data.model_dump())
db.add(config)
db.commit()
db.refresh(config)
@@ -69,7 +69,7 @@ def update_llm_config(
config = db.query(LLMConfig).filter(LLMConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
update_data = config_update.dict(exclude_unset=True)
update_data = config_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
db.commit()
+2 -2
View File
@@ -11,7 +11,7 @@ router = APIRouter(prefix="/api", tags=["optimizer_logs"])
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
@router.post("/optimizer/run")
def run_optimizer(
async def run_optimizer(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
@@ -20,7 +20,7 @@ def run_optimizer(
触发合规优化器运行(管理员)
"""
try:
body = request.json()
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
topic_id = body.get("topic_id")
+5 -5
View File
@@ -31,7 +31,7 @@ def list_system_configs(
# 将 value 解析为 JSON(如果是 JSON 字符串)
result = []
for c in configs:
resp = SystemConfigResponse.from_orm(c)
resp = SystemConfigResponse.model_validate(c)
# 尝试解析 value 为 JSON
if c.value:
try:
@@ -53,7 +53,7 @@ def get_system_config(
config = db.query(SystemConfig).filter(SystemConfig.key == config_key).first()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
resp = SystemConfigResponse.from_orm(config)
resp = SystemConfigResponse.model_validate(config)
if config.value:
try:
import json
@@ -85,7 +85,7 @@ def create_system_config(
existing.description = config_data.description
db.commit()
db.refresh(existing)
resp = SystemConfigResponse.from_orm(existing)
resp = SystemConfigResponse.model_validate(existing)
if existing.value:
try:
import json
@@ -95,7 +95,7 @@ def create_system_config(
return resp
else:
# 新建
data = config_data.dict()
data = config_data.model_dump()
# 将 value 转为字符串(如果是复杂类型则 JSON)
if isinstance(data.get('value'), (dict, list)):
import json
@@ -104,7 +104,7 @@ def create_system_config(
db.add(config)
db.commit()
db.refresh(config)
resp = SystemConfigResponse.from_orm(config)
resp = SystemConfigResponse.model_validate(config)
if config.value:
try:
import json
+3 -3
View File
@@ -38,7 +38,7 @@ def list_task_logs(
if status:
query = query.filter(TaskLog.status == status)
logs = query.order_by(TaskLog.started_at.desc()).all()
return [TaskLogResponse.from_orm(l) for l in logs]
return [TaskLogResponse.model_validate(l) for l in logs]
@router.get("/{log_id}", response_model=TaskLogResponse)
def get_task_log(
@@ -61,7 +61,7 @@ def create_task_log(
admin_user = Depends(get_current_admin)
):
"""创建任务日志(用于手动记录)"""
log = TaskLog(**log_data.dict())
log = TaskLog(**log_data.model_dump())
db.add(log)
db.commit()
db.refresh(log)
@@ -79,7 +79,7 @@ def update_task_log(
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
if not log:
raise HTTPException(status_code=404, detail="日志不存在")
update_data = log_update.dict(exclude_unset=True)
update_data = log_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(log, field, value)
db.commit()
+8
View File
@@ -38,6 +38,14 @@ Base = declarative_base()
def init_db():
Base.metadata.create_all(bind=engine)
# 迁移:为已有表添加 last_login 列
try:
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMP"))
conn.commit()
except Exception:
pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用
def get_db():
db = SessionLocal()
+13 -1
View File
@@ -41,7 +41,7 @@ def import_initial_data():
### 选题信息
标题:{topic.get('title')}
领域:{topic.get('field')}
领域:{topic.get('field_name')}
核心观点:{topic.get('core_concept', '')}
受众痛点:{topic.get('audience_pain', '')}
独特视角:{topic.get('unique_angle', '')}
@@ -209,6 +209,18 @@ def import_initial_data():
db.commit()
print(f"✅ 导入 {len(cases_data)} 条案例")
# 同步 PostgreSQL 自增序列
if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
try:
from sqlalchemy import text
tables = ["cases", "users", "content_calendar", "media_assets", "content_metrics", "content_tasks", "audit_logs", "task_logs", "topic_config_fields"]
for table in tables:
db.execute(text(f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), COALESCE((SELECT MAX(id) FROM {table}), 0) + 1, false)"))
db.commit()
print("✅ PostgreSQL 自增序列已同步")
except Exception as e:
print(f"⚠️ 序列同步警告: {e}")
print("✅ 初始化完成")
except Exception as e:
+2
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)
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())
@@ -49,6 +50,7 @@ class User(Base):
"id": self.id,
"username": self.username,
"role": self.role,
"last_login": self.last_login.isoformat() if self.last_login else None,
"created_at": self.created_at.isoformat() if self.created_at else None
}
+2 -1
View File
@@ -441,7 +441,7 @@ class TaskLogBase(BaseModel):
message: Optional[str] = None
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
duration_seconds: Optional[int] = None
duration: Optional[int] = None
class TaskLogResponse(TaskLogBase):
@@ -475,6 +475,7 @@ class SystemConfigBase(BaseModel):
class SystemConfigResponse(SystemConfigBase):
value: Optional[Any] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+19 -21
View File
@@ -1,26 +1,20 @@
# 宇之然内容创作平台 - 数据库配置
# 宇之然内容创作平台 - 数据库配置 (SQLite版本)
from sqlalchemy import create_engine
from sqlalchemy import create_engine, Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import sessionmaker, relationship
from pathlib import Path
import os
from dotenv import load_dotenv
load_dotenv()
# 数据库URL(从环境变量读取)
SQLALCHEMY_DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://user:password@localhost:5432/yuzhiran_db"
)
# 使用SQLite数据库
BASE_DIR = Path(__file__).resolve().parent
DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'yzr.db'}"
# 创建数据库引擎
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
pool_size=20,
max_overflow=30,
pool_pre_ping=True,
echo=False # 生产环境设为False
DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False
)
# 会话工厂
@@ -29,6 +23,15 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 基础模型类
Base = declarative_base()
# 导入所有模型
from app.models import *
# 创建所有表
def init_db():
"""初始化数据库(创建表)"""
Base.metadata.create_all(bind=engine)
# 获取数据库会话
def get_db():
"""获取数据库会话"""
db = SessionLocal()
@@ -36,8 +39,3 @@ def get_db():
yield db
finally:
db.close()
def init_db():
"""初始化数据库(创建表)"""
from app.models import Base
Base.metadata.create_all(bind=engine)
+97
View File
@@ -0,0 +1,97 @@
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
import uvicorn
from starlette.staticfiles import StaticFiles
from app.database import init_db
from core.security import SECRET_KEY
from api import auth, topics, system, publishing, articles, logs, admin
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
print("正在初始化数据库...")
init_db()
print("数据库初始化完成")
yield
print("应用关闭")
app = FastAPI(
title="宇之然内容创作平台 API",
description="企业级内容创作管理系统",
version="1.0.0",
lifespan=lifespan
)
# CORS配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API 路由(必须先于静态文件注册)
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
app.include_router(topics.router, prefix="/api/topics", tags=["选题管理"])
app.include_router(system.router, prefix="/api/system", tags=["系统状态"])
app.include_router(publishing.router, prefix="/api/publishing", tags=["文章发布"])
app.include_router(articles.router, prefix="/api/articles", tags=["文章预览"])
app.include_router(logs.router, prefix="/api/logs", tags=["日志系统"])
app.include_router(admin.router, prefix="/api/admin", tags=["管理员"])
# 健康检查
@app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": __import__('datetime').datetime.now().isoformat()}
# 独立页面路由(必须在 SPA catch-all 之前)
@app.get("/topics.html")
async def topics_page():
return FileResponse("static/topics.html")
@app.get("/logs.html")
async def logs_page():
return FileResponse("static/logs.html")
@app.get("/users.html")
async def users_page():
return FileResponse("static/users.html")
@app.get("/login.html")
async def login_page():
return FileResponse("static/login.html")
@app.get("/admin.html")
async def admin_page():
return FileResponse("static/admin.html")
# 静态文件(不干扰API
app.mount("/static", StaticFiles(directory="static"), name="static")
# SPA:所有非 API 路径返回 index.html(最后注册)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
return FileResponse("static/index.html")
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
import traceback
print(f"全局异常: {exc}")
print(f"堆栈跟踪:\n{traceback.format_exc()}")
return {
"error": "服务器内部错误",
"message": str(exc),
"path": request.url.path
}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True, log_level="info")
+1
View File
@@ -0,0 +1 @@
../frontend
-91
View File
@@ -1,91 +0,0 @@
version: '3.8'
services:
app:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8002:8001"
environment:
- DATABASE_URL=postgresql://yuzhiran:yuzhiran@db:5432/yuzhiran_db
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=your-secret-key-change-in-production
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=10080
- DEBUG=False
- ENVIRONMENT=production
depends_on:
- db
- redis
volumes:
- ./backend:/app
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:13-alpine
environment:
POSTGRES_DB: yuzhiran_db
POSTGRES_USER: yuzhiran
POSTGRES_PASSWORD: yuzhiran
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5433:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U yuzhiran -d yuzhiran_db"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --requirepass redis123
volumes:
- redis_data:/data
ports:
- "6379:6379"
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
nginx:
image: nginx:alpine
ports:
- "8080:80"
- "8443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- app
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
- ./frontend:/usr/share/nginx/html
restart: unless-stopped
volumes:
postgres_data:
redis_data:
networks:
default:
driver: bridge
-37
View File
@@ -1,37 +0,0 @@
# 宇之然内容创作平台 - 前端Docker镜像
FROM nginx:alpine as builder
# 安装构建工具(用于优化HTML
RUN apk add --no-cache python3 py3-pip
COPY index.html /tmp/index.html
COPY login.html /tmp/login.html
# 简单压缩HTML(实际生产应使用Webpack等构建工具)
RUN cat /tmp/index.html | tr -d '\n' > /tmp/index.min.html && \
mv /tmp/index.min.html /tmp/index.html
WORKDIR /usr/share/nginx/html
# 复制静态资源
COPY . .
# 生产阶段 - 直接使用Nginx
FROM nginx:alpine
# 复制优化后的前端文件
COPY --from=builder /usr/share/nginx/html /usr/share/nginx/html
# 配置Nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
EXPOSE 8000
# 标签信息
LABEL maintainer="宇之然团队"
LABEL version="1.0.0"
LABEL description="企业级内容创作管理系统前端"
+323 -362
View File
@@ -3,415 +3,376 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 用户管理</title>
<title>宇之然内容创作平台 - 系统管理</title>
<link rel="stylesheet" href="element-plus.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; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.sidebar { width: 180px; background: white; padding: 16px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.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: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow-x: auto; }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
.mobile-nav-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; background: none; border: none; font-size: 10px; color: #909399; cursor: pointer; padding: 4px; }
.mobile-nav-btn.active { color: #409eff; }
.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; }
.mobile-card-list { display: none; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 16px; padding-bottom: 80px; }
}
.user-card-list { display: none; }
/* users.html 移动端优化 */
@media (max-width: 768px) {
.user-table { display: none; }
.user-card-list { display: block; margin: 0 -16px; }
.user-card {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.user-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.user-card-name { font-size: 16px; font-weight: 600; }
.user-card-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 12px;
color: #606266;
margin-bottom: 12px;
}
.user-card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid #ebeef5;
.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; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
</head>
<body>
<div id="app">
<navbar-component
title="系统管理"
:username="currentUser.username"
:is-admin="isAdmin"
@logout="handleLogout"
></navbar-component>
<navigation-component
current-page="admin"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<div class="main-content"><main class="content-area">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="案例管理" name="cases"></el-tab-pane>
<el-tab-pane label="任务日志" name="tasklogs"></el-tab-pane>
<el-tab-pane label="LLM配置" name="llmconfigs"></el-tab-pane>
<el-tab-pane label="系统配置" name="systemconfigs"></el-tab-pane>
</el-tabs>
<!-- Cases Tab -->
<div v-show="activeTab === 'cases'">
<div class="toolbar">
<el-button type="primary" @click="showCaseDialog()">新增案例</el-button>
<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>
<div class="main-content">
<main class="content-area">
<div class="card">
<el-tabs v-model="activeTab">
<el-tab-pane label="案例管理" name="cases">
<div class="toolbar">
<el-button type="primary" @click="showCaseDialog()">新增案例</el-button>
</div>
<el-table :data="cases" border stripe class="data-table">
<el-table-column prop="id" label="ID" width="70"/>
<el-table-column prop="title" label="标题" min-width="150"/>
<el-table-column prop="field" label="领域" width="100"/>
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"/>
<el-table-column prop="source" label="来源" width="100"/>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<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">
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="任务日志" name="tasklogs">
<div class="toolbar">
<el-button @click="loadTaskLogs()">刷新</el-button>
</div>
<el-table :data="taskLogs" border stripe class="data-table">
<el-table-column prop="id" label="ID" width="70"/>
<el-table-column prop="task_name" label="任务名称" min-width="120"/>
<el-table-column prop="topic_id" label="选题" width="100"/>
<el-table-column prop="status" label="状态" width="80"/>
<el-table-column prop="message" label="消息" min-width="150" :show-overflow-tooltip="true"/>
<el-table-column prop="started_at" label="开始" width="150"/>
<el-table-column prop="finished_at" label="结束" width="150"/>
<el-table-column prop="duration" label="耗时" width="80"/>
</el-table>
<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>
</div>
</el-tab-pane>
<el-tab-pane label="LLM配置" name="llmconfigs">
<div class="toolbar">
<el-button type="primary" @click="showLLMConfigDialog()">新增配置</el-button>
</div>
<el-table :data="llmConfigs" border stripe class="data-table">
<el-table-column prop="id" label="ID" width="70"/>
<el-table-column prop="name" label="名称" min-width="120"/>
<el-table-column prop="model" label="模型" min-width="180"/>
<el-table-column prop="temperature" label="温度" width="80"/>
<el-table-column prop="max_tokens" label="最大Token" width="110"/>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">{{ scope.row.is_active ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<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">
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(item.id)">删除</el-button>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="系统配置" name="systemconfigs">
<div class="toolbar">
<el-button type="primary" @click="showSystemConfigDialog()">新增配置</el-button>
</div>
<el-table :data="systemConfigs" border stripe class="data-table">
<el-table-column prop="key" label="键" min-width="180"/>
<el-table-column prop="value" label="值" min-width="250">
<template #default="scope">
{{ typeof scope.row.value === 'object' ? JSON.stringify(scope.row.value) : scope.row.value }}
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200"/>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showSystemConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSystemConfig(scope.row.key)">删除</el-button>
</template>
</el-table-column>
</el-table>
<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">
<el-button size="small" @click="showSystemConfigDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSystemConfig(item.key)">删除</el-button>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</main>
</div>
<el-table :data="cases" border stripe>
<el-table-column prop="id" label="ID" width="80"/>
<el-table-column prop="title" label="标题"/>
<el-table-column prop="field" label="领域"/>
<el-table-column prop="summary" label="概述" :show-overflow-tooltip="true"/>
<el-table-column prop="source" label="来源"/>
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="600px">
<el-form :model="caseForm" label-width="80px">
<el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item>
<el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item>
<el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary"/></el-form-item>
<el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item>
<el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item>
<el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item>
<el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item>
<el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item>
<el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="caseDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCase">确定</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
<!-- TaskLogs Tab -->
<div v-show="activeTab === 'tasklogs'">
<div class="toolbar">
<el-button @click="loadTaskLogs()">刷新</el-button>
</div>
<el-table :data="taskLogs" border stripe>
<el-table-column prop="id" label="ID" width="80"/>
<el-table-column prop="task_name" label="任务名称"/>
<el-table-column prop="topic_id" label="选题ID"/>
<el-table-column prop="status" label="状态"/>
<el-table-column prop="message" label="消息" :show-overflow-tooltip="true"/>
<el-table-column prop="started_at" label="开始时间"/>
<el-table-column prop="finished_at" label="结束时间"/>
<el-table-column prop="duration_seconds" label="耗时(秒)"/>
</el-table>
</div>
<!-- LLMConfigs Tab -->
<div v-show="activeTab === 'llmconfigs'">
<div class="toolbar">
<el-button type="primary" @click="showLLMConfigDialog()">新增配置</el-button>
</div>
<el-table :data="llmConfigs" border stripe>
<el-table-column prop="id" label="ID" width="80"/>
<el-table-column prop="name" label="名称"/>
<el-table-column prop="model" label="模型"/>
<el-table-column prop="temperature" label="温度"/>
<el-table-column prop="max_tokens" label="最大Token"/>
<el-table-column prop="is_active" label="激活">
<template #default="scope">{ scope.row.is_active ? '是' : '否' }</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button>
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="600px">
<el-form :model="llmConfigForm" label-width="120px">
<el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item>
<el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt"/></el-form-item>
<el-form-item label="用户提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template"/></el-form-item>
<el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item>
<el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item>
<el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item>
<el-form-item label="激活">
<el-switch v-model="llmConfigForm.is_active"/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveLLMConfig">确定</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
<!-- SystemConfigs Tab -->
<div v-show="activeTab === 'systemconfigs'">
<div class="toolbar">
<el-button type="primary" @click="showSystemConfigDialog()">新增配置</el-button>
</div>
<el-table :data="systemConfigs" border stripe>
<el-table-column prop="key" label="键"/>
<el-table-column prop="value" label="值">
<template #default="scope">
{ typeof scope.row.value === 'object' ? JSON.stringify(scope.row.value) : scope.row.value }
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="500px">
<el-form :model="systemConfigForm" label-width="100px">
<el-form-item label="键"><el-input v-model="systemConfigForm.key" :disabled="!!editingSystemConfigKey"/></el-form-item>
<el-form-item label="值"><el-input v-model="systemConfigForm.value" type="textarea"/></el-form-item>
<el-form-item label="描述"><el-input v-model="systemConfigForm.description"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="systemConfigDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
</template>
</el-table-column>
<el-table-column prop="description" label="描述"/>
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" @click="showSystemConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSystemConfig(scope.row.key)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Cases Dialog -->
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="600px">
<el-form :model="caseForm" label-width="80px">
<el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item>
<el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item>
<el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary"/></el-form-item>
<el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item>
<el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item>
<el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item>
<el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item>
<el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item>
<el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="caseDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCase">确定</el-button>
</template>
</el-dialog>
<!-- LLMConfig Dialog -->
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="600px">
<el-form :model="llmConfigForm" label-width="120px">
<el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item>
<el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt"/></el-form-item>
<el-form-item label="用户提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template"/></el-form-item>
<el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item>
<el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item>
<el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item>
<el-form-item label="激活">
<el-switch v-model="llmConfigForm.is_active"/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveLLMConfig">确定</el-button>
</template>
</el-dialog>
<!-- SystemConfig Dialog -->
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="500px">
<el-form :model="systemConfigForm" label-width="100px">
<el-form-item label="键"><el-input v-model="systemConfigForm.key" :disabled="!!editingSystemConfigKey"/></el-form-item>
<el-form-item label="值"><el-input v-model="systemConfigForm.value" type="textarea"/></el-form-item>
<el-form-item label="描述"><el-input v-model="systemConfigForm.description"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="systemConfigDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
</template>
</el-dialog>
</main></div>
</div>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { createApp, ref, reactive, onMounted, watch } = Vue;
const { ElMessage, ElMessageBox } = ElementPlus;
const app = createApp({
setup() {
const token = localStorage.getItem('authToken');
if (!token) {
window.location.href = 'login.html';
return {};
}
const api = {
get: (url) => fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
post: (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
put: (url, body) => fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
delete: (url) => fetch(url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
};
const app = createApp({
setup() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = 'login.html'; return {}; }
const activeTab = ref('cases');
const currentUser = ref({ username: '' });
const redirectToPage = (page) => { window.location.href = '/' + page; }; const currentUser = ref({ username: '' });
const isAdmin = ref(false);
const isLoggedIn = ref(false);
const api = {
get: (url) => fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
post: (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
put: (url, body) => fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
delete: (url) => fetch(url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
};
// 获取当前用户信息
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
const user = data.user || { username: '', role: 'user' };
currentUser.value = user;
isAdmin.value = user.role === 'admin';
isLoggedIn.value = true;
if (!isAdmin.value) {
ElMessage.warning('需要管理员权限');
window.location.href = '/';
}
})
.catch(() => {
localStorage.removeItem('authToken');
window.location.href = 'login.html';
});
const activeTab = ref('cases');
const currentUser = ref({ username: '' });
const isAdmin = ref(false);
const isLoggedIn = ref(false);
// 页面跳转
const redirectToPage = (page) => {
window.location.href = '/' + page;
};
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
const user = data.user || { username: '', role: 'user' };
currentUser.value = user;
isAdmin.value = user.role === 'admin';
isLoggedIn.value = true;
if (!isAdmin.value) { ElMessage.warning('需要管理员权限'); window.location.href = '/login.html'; }
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
// Cases
const cases = ref([]);
const caseDialogVisible = ref(false);
const caseDialogTitle = ref('新增案例');
const caseForm = reactive({ id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' });
const editingCaseId = ref(null);
const redirectToPage = (page) => { window.location.href = '/' + page; };
const loadCases = async () => {
try { cases.value = await api.get('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); }
};
const showCaseDialog = (row = null) => {
if (row) { caseDialogTitle.value = '编辑案例'; editingCaseId.value = row.id; Object.assign(caseForm, row); }
else { caseDialogTitle.value = '新增案例'; editingCaseId.value = null; Object.keys(caseForm).forEach(k => { if (k === 'id') caseForm.id = null; else caseForm[k] = ''; }); }
caseDialogVisible.value = true;
};
const saveCase = async () => {
try {
if (editingCaseId.value) { await api.put(`/api/admin/cases/${editingCaseId.value}`, caseForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/cases', caseForm); ElMessage.success('创建成功'); }
caseDialogVisible.value = false; await loadCases();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteCase = async (id) => {
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/cases/${id}`); ElMessage.success('删除成功'); await loadCases(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const cases = ref([]);
const caseDialogVisible = ref(false);
const caseDialogTitle = ref('新增案例');
const caseForm = reactive({ id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' });
const editingCaseId = ref(null);
// TaskLogs
const taskLogs = ref([]);
const loadTaskLogs = async () => {
try { taskLogs.value = await api.get('/api/admin/tasklogs'); } catch (e) { ElMessage.error('加载任务日志失败: ' + e.message); }
};
const loadCases = async () => { try { cases.value = await api.get('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); } };
const showCaseDialog = (row = null) => {
if (row) { caseDialogTitle.value = '编辑案例'; editingCaseId.value = row.id; Object.assign(caseForm, row); }
else { caseDialogTitle.value = '新增案例'; editingCaseId.value = null; Object.keys(caseForm).forEach(k => { if (k === 'id') caseForm.id = null; else caseForm[k] = ''; }); }
caseDialogVisible.value = true;
};
const saveCase = async () => {
try {
if (editingCaseId.value) { await api.put(`/api/admin/cases/${editingCaseId.value}`, caseForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/cases', caseForm); ElMessage.success('创建成功'); }
caseDialogVisible.value = false; await loadCases();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteCase = async (id) => {
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/cases/${id}`); ElMessage.success('删除成功'); await loadCases(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
// LLMConfigs
const llmConfigs = ref([]);
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 editingLLMConfigId = ref(null);
const taskLogs = ref([]);
const loadTaskLogs = async () => { try { taskLogs.value = await api.get('/api/admin/tasklogs'); } catch (e) { ElMessage.error('加载任务日志失败: ' + e.message); } };
const loadLLMConfigs = async () => {
try { llmConfigs.value = await api.get('/api/admin/llmconfigs'); } catch (e) { ElMessage.error('加载LLM配置失败: ' + e.message); }
};
const showLLMConfigDialog = (row = null) => {
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
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] = ''; }); }
llmConfigDialogVisible.value = true;
};
const saveLLMConfig = async () => {
try {
if (editingLLMConfigId.value) { await api.put(`/api/admin/llmconfigs/${editingLLMConfigId.value}`, llmConfigForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/llmconfigs', llmConfigForm); ElMessage.success('创建成功'); }
llmConfigDialogVisible.value = false; await loadLLMConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteLLMConfig = async (id) => {
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/llmconfigs/${id}`); ElMessage.success('删除成功'); await loadLLMConfigs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const llmConfigs = ref([]);
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 editingLLMConfigId = ref(null);
// SystemConfigs
const systemConfigs = ref([]);
const systemConfigDialogVisible = ref(false);
const systemConfigDialogTitle = ref('新增配置');
const systemConfigForm = reactive({ key: '', value: '', description: '' });
const editingSystemConfigKey = ref(null);
const loadLLMConfigs = async () => { try { llmConfigs.value = await api.get('/api/admin/llmconfigs'); } catch (e) { ElMessage.error('加载LLM配置失败: ' + e.message); } };
const showLLMConfigDialog = (row = null) => {
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
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] = ''; });
}
llmConfigDialogVisible.value = true;
};
const saveLLMConfig = async () => {
try {
if (editingLLMConfigId.value) { await api.put(`/api/admin/llmconfigs/${editingLLMConfigId.value}`, llmConfigForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/llmconfigs', llmConfigForm); ElMessage.success('创建成功'); }
llmConfigDialogVisible.value = false; await loadLLMConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteLLMConfig = async (id) => {
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/llmconfigs/${id}`); ElMessage.success('删除成功'); await loadLLMConfigs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const loadSystemConfigs = async () => {
try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); }
};
const showSystemConfigDialog = (row = null) => {
if (row) { systemConfigDialogTitle.value = '编辑配置'; editingSystemConfigKey.value = row.key; systemConfigForm.key = row.key; systemConfigForm.value = (typeof row.value === 'object') ? JSON.stringify(row.value) : (row.value || ''); systemConfigForm.description = row.description || ''; }
else { systemConfigDialogTitle.value = '新增配置'; editingSystemConfigKey.value = null; systemConfigForm.key = ''; systemConfigForm.value = ''; systemConfigForm.description = ''; }
systemConfigDialogVisible.value = true;
};
const saveSystemConfig = async () => {
try {
if (editingSystemConfigKey.value) { await api.put(`/api/admin/systemconfigs/${editingSystemConfigKey.value}`, systemConfigForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/systemconfigs', systemConfigForm); ElMessage.success('创建成功'); }
systemConfigDialogVisible.value = false; await loadSystemConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteSystemConfig = async (key) => {
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/systemconfigs/${key}`); ElMessage.success('删除成功'); await loadSystemConfigs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const systemConfigs = ref([]);
const systemConfigDialogVisible = ref(false);
const systemConfigDialogTitle = ref('新增配置');
const systemConfigForm = reactive({ key: '', value: '', description: '' });
const editingSystemConfigKey = ref(null);
const logout = () => {
localStorage.removeItem('authToken');
window.location.href = 'login.html';
};
const loadSystemConfigs = async () => { try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); } };
const showSystemConfigDialog = (row = null) => {
if (row) { systemConfigDialogTitle.value = '编辑配置'; editingSystemConfigKey.value = row.key; systemConfigForm.key = row.key; systemConfigForm.value = (typeof row.value === 'object') ? JSON.stringify(row.value) : (row.value || ''); systemConfigForm.description = row.description || ''; }
else { systemConfigDialogTitle.value = '新增配置'; editingSystemConfigKey.value = null; systemConfigForm.key = ''; systemConfigForm.value = ''; systemConfigForm.description = ''; }
systemConfigDialogVisible.value = true;
};
const saveSystemConfig = async () => {
try {
if (editingSystemConfigKey.value) { await api.put(`/api/admin/systemconfigs/${editingSystemConfigKey.value}`, systemConfigForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/systemconfigs', systemConfigForm); ElMessage.success('创建成功'); }
systemConfigDialogVisible.value = false; await loadSystemConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteSystemConfig = async (key) => {
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/systemconfigs/${key}`); ElMessage.success('删除成功'); await loadSystemConfigs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
watch(activeTab, (t) => {
if (t === 'cases' && !cases.value.length) loadCases();
if (t === 'tasklogs' && !taskLogs.value.length) loadTaskLogs();
if (t === 'llmconfigs' && !llmConfigs.value.length) loadLLMConfigs();
if (t === 'systemconfigs' && !systemConfigs.value.length) loadSystemConfigs();
});
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
onMounted(() => {
if (activeTab.value === 'cases') loadCases();
else if (activeTab.value === 'tasklogs') loadTaskLogs();
else if (activeTab.value === 'llmconfigs') loadLLMConfigs();
else if (activeTab.value === 'systemconfigs') loadSystemConfigs();
});
watch(activeTab, (t) => {
if (t === 'cases' && !cases.value.length) loadCases();
if (t === 'tasklogs' && !taskLogs.value.length) loadTaskLogs();
if (t === 'llmconfigs' && !llmConfigs.value.length) loadLLMConfigs();
if (t === 'systemconfigs' && !systemConfigs.value.length) loadSystemConfigs();
});
return {
activeTab, cases, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
taskLogs, loadTaskLogs,
llmConfigs, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
systemConfigs, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
logout,
currentUser, isAdmin, isLoggedIn, redirectToPage
};
}
});
onMounted(() => {
if (activeTab.value === 'cases') loadCases();
else if (activeTab.value === 'tasklogs') loadTaskLogs();
else if (activeTab.value === 'llmconfigs') loadLLMConfigs();
else if (activeTab.value === 'systemconfigs') loadSystemConfigs();
});
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); }
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);
return {
activeTab, cases, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
taskLogs, loadTaskLogs,
llmConfigs, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
systemConfigs, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
logout, currentUser, isAdmin, isLoggedIn, 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); }
app.mount('#app');
</script>
</body>
</html>
</html>
+19 -16
View File
@@ -14,7 +14,7 @@
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
@@ -153,8 +153,23 @@ const AssetsApp = {
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
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() {
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.loadAssets();
this.loadTags();
this.loadStats();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
@@ -236,19 +251,7 @@ const AssetsApp = {
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; 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.loadAssets();
this.loadTags();
this.loadStats();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
this.checkAuth();
}
};
const app = Vue.createApp(AssetsApp);
+1
View File
@@ -0,0 +1 @@
Redirecting to /axios@1.15.2/dist/axios.min.js
+33 -14
View File
@@ -13,7 +13,7 @@
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
@@ -170,6 +170,7 @@
<script src="vue.global.prod.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() {
@@ -270,19 +271,37 @@
const statusLabel = (s) => ({ planned: '待发布', published: '已发布', delayed: '延迟', cancelled: '取消' }[s] || s);
const statusType = (s) => ({ planned: 'warning', published: 'success', delayed: 'danger', cancelled: 'info' }[s] || '');
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 };
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page + '.html'; }
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(d => { this.currentUser = d.user; this.isAdmin = d.user.role === 'admin'; this.fetchEntries(); this.fetchStats(); this.fetchTopics(); })
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
const checkAuth = () => {
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(d => {
currentUser.value = d.user;
isAdmin.value = d.user.role === 'admin';
fetchEntries();
fetchStats();
fetchTopics();
})
.catch(() => {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
window.location.href = '/login.html';
});
};
const handleLogout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
onMounted(() => {
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 };
}
};
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>导航组件调试</title>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
</head>
<body>
<div id="app">
<navigation-component current-page="dashboard" :is-admin="true" @navigate="()=>{}"></navigation-component>
</div>
<script src="navigation-component.js"></script>
<script>
// 手动检查
console.log('NavigationComponent:', window.NavigationComponent);
console.log('installNavigation:', window.installNavigation);
const App = { data() { return { isAdmin: true } } };
const app = Vue.createApp(App);
if (window.installNavigation) {
window.installNavigation(app);
console.log('通过 installNavigation 注册');
} else if (window.NavigationComponent) {
app.component('navigation-component', window.NavigationComponent);
console.log('直接注册组件');
// 手动注入样式
const style = document.createElement('style');
style.textContent = '.navigation-wrapper .sidebar { position: fixed; top: 0; left: 0; bottom: 0; width: 180px; background: #f0f0f0; z-index: 9999; }';
document.head.appendChild(style);
}
app.use(ElementPlus);
app.mount('#app');
</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
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#409EFF" rx="24"/>
<text x="96" y="120" font-family="Arial, sans-serif" font-size="80" font-weight="bold" fill="white" text-anchor="middle"></text>
</svg>

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#409EFF" rx="48"/>
<text x="256" y="320" font-family="Arial, sans-serif" font-size="200" font-weight="bold" fill="white" text-anchor="middle"></text>
</svg>

After

Width:  |  Height:  |  Size: 289 B

+4 -7
View File
@@ -8,13 +8,7 @@
<style>
/* 深色渐变背景主题 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
/* 深色渐变: #1a1a2e → #16213e → #0f3460 */
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
color: #e0e6ed;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
/* 导航栏 */
.navbar {
@@ -453,9 +447,12 @@
},
handleLogout() {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
this.isLoggedIn = false;
this.currentUser = { username: '' };
this.isAdmin = false;
window.location.href = '/login.html';
},
async fetchStats() {
try {
+601
View File
@@ -0,0 +1,601 @@
<!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">
<style>
/* 深色渐变背景主题 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
/* 深色渐变: #1a1a2e → #16213e → #0f3460 */
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
color: #e0e6ed;
}
/* 导航栏 */
.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;
}
.sidebar-btn {
width: 100%;
text-align: left;
padding: 12px 16px;
border: none;
background: transparent;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: #a0aec0;
font-size: 14px;
font-weight: 500;
position: relative;
overflow: hidden;
}
.sidebar-btn:hover {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
transform: translateX(4px);
}
.sidebar-btn.active {
background: linear-gradient(90deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.2));
color: #667eea;
font-weight: 600;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.sidebar-btn.active::after {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #667eea, #764ba2);
border-radius: 0 4px 4px 0;
}
/* 内容区域 */
.content-area {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* 页面切换 */
.page { display: none; animation: fadeIn 0.5s ease-out; }
.page.active { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
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);
border-radius: 16px;
padding: 24px;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
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);
}
.stat-title {
font-size: 13px;
color: #a0aec0;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.stat-value {
font-size: 36px;
font-weight: 800;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.stat-card.primary .stat-value { background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-card.success .stat-value { background: linear-gradient(135deg, #67c23a, #85e61d); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-card.warning .stat-value { background: linear-gradient(135deg, #e6a23c, #f5c543); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-card.danger .stat-value { background: linear-gradient(135deg, #f56c6c, #f79296); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-card.info .stat-value { background: linear-gradient(135deg, #409eff, #5cd0f3); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
/* 模块卡片 */
.module-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.module-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(102, 126, 234, 0.1);
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);
}
.module-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.module-title {
font-size: 16px;
font-weight: 600;
color: #e0e6ed;
display: flex;
align-items: center;
gap: 8px;
}
.module-status {
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
background: rgba(103, 194, 58, 0.2);
color: #67c23a;
border: 1px solid rgba(103, 194, 58, 0.3);
}
.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); }
}
.module-content {
font-size: 14px;
color: #a0aec0;
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);
}
.module-content div:last-child { border-bottom: none; }
/* 移动端导航 */
.mobile-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(102, 126, 234, 0.2);
padding: 8px 0;
z-index: 1000;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
}
.mobile-nav-btn {
flex: 1;
border: none;
background: transparent;
padding: 12px 8px;
text-align: center;
font-size: 12px;
color: #a0aec0;
cursor: pointer;
transition: all 0.3s;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.mobile-nav-btn.active {
color: #667eea;
font-weight: 600;
}
.mobile-nav-btn.active::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
margin-bottom: 2px;
}
/* 响应式 */
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area {
padding: 16px;
padding-bottom: 80px;
}
.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; }
}
</style>
<style>
body { background: #f5f7fa !important; color: #303133 !important; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important; border-bottom: none !important; backdrop-filter: none !important; }
.navbar-title { background: none !important; -webkit-text-fill-color: white !important; color: white !important; }
.user-info { color: white !important; }
.avatar { background: rgba(255,255,255,0.2) !important; color: white !important; }
.sidebar { background: white !important; border-right: 1px solid #ebeef5 !important; backdrop-filter: none !important; }
.sidebar-btn { color: #606266 !important; }
.sidebar-btn:hover, .sidebar-btn.active { background: #ecf5ff !important; color: #409eff !important; }
.mobile-nav { background: white !important; border-top: 1px solid #ebeef5 !important; backdrop-filter: none !important; box-shadow: 0 -2px 8px rgba(0,0,0,0.1) !important; }
.mobile-nav-btn { color: #606266 !important; }
.mobile-nav-btn.active { color: #409eff !important; }
.mobile-nav-btn.active::before { content: none !important; display: none !important; }
#page-overview h2, #page-overview h3, #page-overview .module-title { color: #303133 !important; }
#page-overview .module-content { color: #303133 !important; }
</style>
</head>
<body>
<div id="app">
<nav class="navbar" v-if="isLoggedIn">
<div class="navbar-content">
<h1 class="navbar-title">宇之然内容创作平台</h1>
<div class="navbar-user">
<div class="user-info">
<div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div>
<span>{{ currentUser.username }}</span>
<el-tag v-if="isAdmin" size="small" type="danger" style="border: none;">管理员</el-tag>
</div>
<el-button size="small" type="danger" plain @click="handleLogout">退出</el-button>
</div>
</div>
</nav>
<div class="main-content" v-if="isLoggedIn">
<aside class="sidebar">
<button class="sidebar-btn" :class="{ active: currentPage === 'overview' }" @click="redirectToPage('/')">
📊 系统概览
</button>
<button class="sidebar-btn" :class="{ active: currentPage === 'topics' }" @click="redirectToPage('topics.html')">
📋 选题管理
</button>
<button class="sidebar-btn" :class="{ active: currentPage === 'logs' }" @click="redirectToPage('logs.html')">
📄 系统日志
</button>
<button v-if="isAdmin" class="sidebar-btn" :class="{ active: currentPage === 'users' }" @click="redirectToPage('users.html')">
👥 用户管理
</button>
</aside>
<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;">
📊 系统概览
</h2>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-title">选题总数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card warning" @click="goToTopics('pending')">
<div class="stat-title">待处理</div>
<div class="stat-value">{{ stats.pending }}</div>
</div>
<div class="stat-card danger" @click="goToTopics('review')">
<div class="stat-title">待审查</div>
<div class="stat-value">{{ stats.review }}</div>
</div>
<div class="stat-card success" @click="goToTopics('ready')">
<div class="stat-title">待发布</div>
<div class="stat-value">{{ stats.ready }}</div>
</div>
<div class="stat-card info" @click="goToTopics('published')">
<div class="stat-title">已发布</div>
<div class="stat-value">{{ stats.published }}</div>
</div>
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-title">今日新增</div>
<div class="stat-value">{{ stats.today }}</div>
</div>
</div>
<!-- 模块状态 -->
<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 24px; color: #e0e6ed;">
🔧 模块状态
</h3>
<div class="module-grid">
<div class="module-card">
<div class="module-header">
<span class="module-title">🤖 内容创作引擎</span>
<span class="module-status running">运行中</span>
</div>
<div class="module-content">
<div><span>最后运行</span><span>2026-04-27 14:30</span></div>
<div><span>今日任务</span><span>12 个</span></div>
<div><span>成功率</span><span>95%</span></div>
</div>
</div>
<div class="module-card">
<div class="module-header">
<span class="module-title">🔍 内容优化器</span>
<span class="module-status running">运行中</span>
</div>
<div class="module-content">
<div><span>最后运行</span><span>2026-04-27 14:45</span></div>
<div><span>今日优化</span><span>8 个</span></div>
<div><span>平均提升</span><span>+12 分</span></div>
</div>
</div>
<div class="module-card">
<div class="module-header">
<span class="module-title">📡 内容收集器</span>
<span class="module-status running">运行中</span>
</div>
<div class="module-content">
<div><span>最后运行</span><span>2026-04-27 14:00</span></div>
<div><span>今日收集</span><span>24 个</span></div>
<div><span>来源平台</span><span>8 个</span></div>
</div>
</div>
<div class="module-card">
<div class="module-header">
<span class="module-title">📤 发布管理器</span>
<span class="module-status running">运行中</span>
</div>
<div class="module-content">
<div><span>最后运行</span><span>2026-04-27 13:30</span></div>
<div><span>今日发布</span><span>5 个</span></div>
<div><span>成功率</span><span>100%</span></div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 移动端导航 -->
<nav class="mobile-nav" v-if="isLoggedIn">
<button class="mobile-nav-btn" :class="{ active: currentPage === 'overview' }" @click="redirectToPage('/')">
📊 概览
</button>
<button class="mobile-nav-btn" :class="{ active: currentPage === 'topics' }" @click="redirectToPage('topics.html')">
📋 选题
</button>
<button class="mobile-nav-btn" :class="{ active: currentPage === 'logs' }" @click="redirectToPage('logs.html')">
📄 日志
</button>
<button v-if="isAdmin" class="mobile-nav-btn" :class="{ active: currentPage === 'users' }" @click="redirectToPage('users.html')">
👥 用户
</button>
</nav>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const App = {
data() {
return {
isLoggedIn: false,
isAdmin: false,
currentUser: { username: '' },
currentPage: 'overview',
stats: {
total: 0,
pending: 0,
review: 0,
ready: 0,
published: 0,
today: 0
}
};
},
methods: {
async handleLogin() {
this.loginLoading = true;
this.loginError = '';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.loginForm)
});
if (!response.ok) throw new Error('登录失败');
const data = await response.json();
localStorage.setItem('authToken', data.token);
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.currentPage = 'overview';
this.fetchStats();
} catch (error) {
this.loginError = '用户名或密码错误';
} finally {
this.loginLoading = false;
}
},
handleLogout() {
localStorage.removeItem('authToken');
this.isLoggedIn = false;
this.currentUser = { username: '' };
this.isAdmin = false;
},
async fetchStats() {
try {
const token = localStorage.getItem('authToken');
if (!token) {
window.location.href = '/login.html';
return;
}
const response = await fetch('/api/system/status', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (response.ok) {
const data = await response.json();
// API返回格式: { stats: { total, pending, review, ready, published, today } }
this.stats = {
total: data.stats?.total || 0,
pending: data.stats?.pending || 0,
review: data.stats?.review || 0,
ready: data.stats?.ready || 0,
published: data.stats?.published || 0,
today: data.stats?.today || 0
};
} else if (response.status === 401) {
// Token无效,清除并跳转登录
localStorage.removeItem('authToken');
window.location.href = '/login.html';
} else {
console.error('获取统计信息失败:', response.status, response.statusText);
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
}
} catch (error) {
console.error('获取统计信息失败:', error);
// 失败时设置为0,避免页面空白
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
}
},
goToTopics(filter) {
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
window.location.href = url;
},
redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + page;
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) {
window.location.href = '/login.html';
return;
}
fetch('/api/auth/me', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.currentPage = 'overview';
this.fetchStats();
})
.catch(() => {
localStorage.removeItem('authToken');
window.location.href = '/login.html';
});
}
};
const app = Vue.createApp(App);
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
+12 -9
View File
@@ -14,7 +14,7 @@
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.sidebar { width: 180px; background: white; padding: 16px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
@@ -132,16 +132,19 @@
this.loadingLogs = false;
}
},
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; }
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() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; })
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
this.checkAuth();
}
};
const app = Vue.createApp(LogsApp);
+20 -17
View File
@@ -14,7 +14,7 @@
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
@@ -162,8 +162,24 @@ const MetricsApp = {
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
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() {
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.fetchDashboard();
this.fetchTrend();
this.fetchPlatformData();
this.fetchRecommendations();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
getStatusLabel(status) {
const map = { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' };
return map[status] || status;
@@ -205,20 +221,7 @@ const MetricsApp = {
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; 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.fetchDashboard();
this.fetchTrend();
this.fetchPlatformData();
this.fetchRecommendations();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
this.checkAuth();
}
};
const app = Vue.createApp(MetricsApp);
+31 -38
View File
@@ -6,10 +6,13 @@
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 2px 8px rgba(0,0,0,0.1); z-index: 10000; }
.navbar-component .navbar-title { font-size: 18px; font-weight: 600; }
.navbar-component .navbar-title { font-size: 18px; font-weight: 600; margin: 0; }
.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: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.navbar-component .logout-btn { background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 12px; border-radius: 6px; cursor: pointer; margin-left: 12px; }
.navbar-component .logout-btn:hover { background: rgba(255,255,255,0.3); }
.navbar-component .admin-badge { margin-left: 8px; font-size: 12px; background: rgba(255,255,255,0.3); padding: 2px 8px; border-radius: 10px; }
@media (max-width: 768px) {
.navbar-component { padding: 0 16px; }
.navbar-component .navbar-title { font-size: 16px; }
@@ -22,26 +25,9 @@
console.log('[Navbar] 样式已注入');
}
function getHFromApp(app) {
try {
if (app._context && app._context.h) return app._context.h;
if (app._instance && app._instance.proxy && app._instance.proxy._cf) return app._instance.proxy._cf;
} catch (e) { console.warn('[Navbar] 获取 h 失败', e); }
return null;
}
const installNavbar = (app) => {
console.log('[Navbar] installNavbar called');
injectStyles();
const h = getHFromApp(app) || (window.Vue && Vue.h) || function(tag, data, children) {
const el = document.createElement(tag);
if (data && data.class) el.className = data.class;
if (children) {
if (Array.isArray(children)) children.forEach(c => { if (typeof c === 'string') el.appendChild(document.createTextNode(c)); else if (c && c.nodeType) el.appendChild(c); });
else if (typeof children === 'string') el.textContent = children;
}
if (data && data.on) Object.keys(data.on).forEach(ev => el.addEventListener(ev, data.on[ev]));
};
const NavbarComponent = {
name: 'NavbarComponent',
@@ -51,25 +37,32 @@
isAdmin: { type: Boolean, default: false },
onLogout: { type: Function, default: null }
},
render(){
const createElement = arguments[0] || h;
return createElement('nav', { class: 'navbar-component' }, [
createElement('div', { class: 'navbar-content', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' } }, [
createElement('h1', { class: 'navbar-title' }, this.title),
createElement('div', { class: 'navbar-user' }, [
createElement('div', { class: 'user-info' }, [
createElement('div', { class: 'avatar' }, this.username ? this.username.charAt(0).toUpperCase() : '?'),
this.username ? createElement('span', this.username) : null
]),
this.isAdmin ? createElement('span', { style: { marginLeft: '8px', fontSize: '12px', background: 'rgba(255,255,255,0.3)', padding: '2px 8px', borderRadius: '10px' } }, '管理员') : null,
createElement('button', {
class: 'logout-btn',
style: { background: 'rgba(255,255,255,0.2)', border: 'none', color: 'white', padding: '6px 12px', borderRadius: '6px', cursor: 'pointer', marginLeft: '12px' },
on: { click: () => { if (this.onLogout) this.onLogout(); else window.location.href = '/login.html'; } }
}, '退出')
])
])
]);
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';
}
}
}
};
@@ -80,4 +73,4 @@
window.installNavbar = installNavbar;
console.log('[Navbar] 脚本已加载');
})();
})();
+225 -34
View File
@@ -5,17 +5,20 @@
function injectStyles() {
if (document.getElementById('navigation-styles')) return;
const styles = `
.navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.1); overflow-y: auto; z-index: 99999; border-right: 1px solid #ebeef5; }
.navigation-wrapper .sidebar-header { padding: 12px 8px 16px; border-bottom: 1px solid #ebeef5; margin-bottom: 12px; }
.navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: #303133; }
.navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 6px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; display: flex; align-items: center; gap: 6px; }
.navigation-wrapper .sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
.navigation-wrapper .sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600 !important; }
.navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: #1890ff; box-shadow: 0 -2px 8px rgba(0,0,0,0.2); padding: 8px 0; z-index: 99999; justify-content: space-around; }
.navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 10px 4px; text-align: center; font-size: 12px; color: white !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.2); }
.navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.3); font-weight: 600 !important; }
@media (max-width: 768px) { .navigation-wrapper .sidebar { display: none !important; } .navigation-wrapper .mobile-nav { display: flex !important; } }
.nav-wrapper, .navigation-wrapper { position: fixed; top: 60px; left: 0; bottom: 0; z-index: 9999; }
.nav-wrapper .sidebar, .navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.1); overflow-y: auto; z-index: 99999; border-right: 1px solid #ebeef5; }
.nav-wrapper .sidebar-header, .navigation-wrapper .sidebar-header { padding: 12px 8px 16px; border-bottom: 1px solid #ebeef5; margin-bottom: 12px; }
.nav-wrapper .sidebar-header h3, .navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: #303133; }
.nav-wrapper .sidebar-btn, .navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 6px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; display: flex; align-items: center; gap: 6px; }
.nav-wrapper .sidebar-btn:hover, .navigation-wrapper .sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
.nav-wrapper .sidebar-btn.active, .navigation-wrapper .sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600 !important; }
.nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: #1890ff; box-shadow: 0 -2px 8px rgba(0,0,0,0.2); padding: 8px 0; z-index: 99999; justify-content: space-around; }
.nav-wrapper .mobile-nav-btn, .navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 8px 4px; text-align: center; font-size: 11px; color: white !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.nav-wrapper .mobile-nav-btn .nav-icon, .navigation-wrapper .mobile-nav-btn .nav-icon { font-size: 18px; line-height: 1; }
.nav-wrapper .mobile-nav-btn .nav-text, .navigation-wrapper .mobile-nav-btn .nav-text { font-size: 10px; margin-top: 2px; }
.nav-wrapper .mobile-nav-btn:hover, .navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.2); }
.nav-wrapper .mobile-nav-btn.active, .navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.3); font-weight: 600 !important; }
@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 { margin-left: 180px !important; padding-top: 60px !important; }
@media (max-width: 768px) { body > #app > .main-content { margin-left: 0 !important; padding-bottom: 60px !important; } }
`;
@@ -50,11 +53,11 @@
const mobileNav = document.createElement('nav');
mobileNav.className = 'mobile-nav';
mobileNav.innerHTML = `
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊</button>
<button class="mobile-nav-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋</button>
<button class="mobile-nav-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊</button>
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️</button>
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀</button>
<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==='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 ${currentPage==='tasks'?'active':''}" data-page="tasks.html"><span class="nav-icon">🚀</span><span class="nav-text">任务</span></button>
`;
wrapper.appendChild(sidebar);
@@ -62,10 +65,24 @@
// 绑定点击事件
wrapper.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const page = btn.getAttribute('data-page');
console.log('[Nav] 导航:', page);
if (onNavigate) onNavigate(page);
console.log('[Nav] 点击导航:', page);
if (onNavigate) {
onNavigate(page);
} else {
// 直接跳转 - 修复路径处理
let target;
if (page === '/' || page === '') {
target = '/';
} else if (page.startsWith('/')) {
target = page;
} else {
target = '/' + page;
}
window.location.href = target;
}
});
});
@@ -86,36 +103,210 @@
return;
}
// 尝试从 Vue 实例获取 isAdmin 和 currentPage
let isAdmin = false;
// 获取当前页面名称(从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;
// 尝试从 Vue 实例提取数据
if (app && app._instance && app._instance.proxy) {
const proxy = app._instance.proxy;
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
if (proxy.currentPage !== undefined) currentPage = proxy.currentPage;
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
} else if (window.Vue && Vue.app && Vue.app._instance && Vue.app._instance.proxy) {
const proxy = Vue.app._instance.proxy;
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
if (proxy.currentPage !== undefined) currentPage = proxy.currentPage;
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
// 首先检查 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 || ((page) => { window.location.href = page; }));
const nav = createNavigation(currentPage, isAdmin, redirectToPage || defaultNavigate);
container.insertBefore(nav, container.firstChild);
console.log('[Nav] 导航已插入');
};
init();
// 延迟执行,确保 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: 'calendar', label: '日历', icon: '📅', page: 'calendar.html' },
{ key: 'assets', label: '素材', icon: '🖼️', page: 'assets.html' },
{ key: 'tasks', label: '任务', icon: '🚀', page: 'tasks.html' }
]
};
},
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>
</nav>
</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);
});
}
})();
-136
View File
@@ -1,136 +0,0 @@
# 宇之然内容创作平台 - Nginx配置
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
# 基本设置
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_comp_level 6;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: blob: 'unsafe-inline'" always;
# 代理缓存
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off;
# 上游服务器
upstream backend {
server app:8001;
keepalive 32;
}
server {
listen 80;
server_name _;
client_max_body_size 100M;
# SSL配置(生产环境)
# listen 443 ssl http2;
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# 静态文件直接服务
location /static/ {
alias /root/openclaw-workspace/projects/yu-zhi-ran/platform/frontend/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
# 前端静态资源缓存
proxy_cache STATIC;
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# 反向代理到后端API
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
# WebSocket支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时设置
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# API文档(可选)
location /docs {
proxy_pass http://backend/docs;
proxy_set_header Host $host;
}
location /redoc {
proxy_pass http://backend/redoc;
proxy_set_header Host $host;
}
}
# 静态文件服务(如果需要)
server {
listen 8000;
server_name localhost;
root /usr/share/nginx/html;
index index.html login.html;
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
+81 -40
View File
@@ -7,31 +7,57 @@
<link rel="stylesheet" href="element-plus.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; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow-x: auto; }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
.platform-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 20px; margin-bottom: 16px; transition: all 0.3s; }
.platform-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.15); }
.platform-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.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; }
.platform-name { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.platform-badge { padding: 4px 12px; border-radius: 16px; font-size: 12px; }
.active-badge { background: #f0f9eb; color: #67c23a; }
.inactive-badge { background: #f4f4f5; color: #909399; }
.platform-info { font-size: 14px; color: #606266; margin-bottom: 12px; }
.rule-item { padding: 8px 12px; background: #f5f7fa; border-radius: 4px; margin-bottom: 8px; font-size: 13px; }
.platform-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.platform-badge { padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; }
.badge-active { background: #f0f9eb; color: #67c23a; }
.badge-inactive { background: #f4f4f5; color: #909399; }
.platform-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 14px; color: #606266; margin-bottom: 16px; background: #fafbfc; border-radius: 8px; padding: 12px 16px; }
.platform-meta-item { display: flex; align-items: center; gap: 6px; }
.platform-meta-label { color: #909399; }
.platform-meta-value { color: #303133; font-weight: 500; }
.rules-section { margin-top: 12px; }
.rules-title { font-weight: 600; margin-bottom: 10px; color: #606266; font-size: 14px; display: flex; align-items: center; gap: 6px; }
.rule-item { padding: 10px 14px; background: #f5f7fa; border-radius: 8px; margin-bottom: 8px; font-size: 13px; line-height: 1.6; border-left: 3px solid #409eff; }
.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) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.platform-card { padding: 16px; }
.platform-header { flex-direction: column; }
.platform-actions { align-self: flex-end; }
.platform-meta { grid-template-columns: 1fr; }
}
</style>
<script src="navigation-component.js"></script>
@@ -43,19 +69,21 @@
<navigation-component current-page="platforms" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🌐 平台配置</h2>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px;">
<div class="page-header">
<h2 class="page-title">🌐 平台配置</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>
</div>
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
<div v-else-if="platforms.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<div style="font-size: 48px; margin-bottom: 16px;">🌐</div>
<div>暂无平台配置</div>
</div>
<div class="card">
<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>
</div>
<div v-else>
<div v-for="p in platforms" :key="p.platform" class="platform-card">
@@ -64,25 +92,35 @@
<span>{{ getPlatformIcon(p.platform) }}</span>
{{ getPlatformName(p.platform) }}
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span class="platform-badge" :class="p.is_active ? 'active-badge' : 'inactive-badge'">
<div class="platform-actions">
<span class="platform-badge" :class="p.is_active ? 'badge-active' : 'badge-inactive'">
{{ p.is_active ? '✓ 启用' : '○ 停用' }}
</span>
<el-button size="small" type="primary" @click="editPlatform(p)">编辑</el-button>
</div>
</div>
<div class="platform-info">
<div style="margin-bottom: 4px;">平台标识: {{ p.platform }}</div>
<div v-if="p.platform_name">平台名称: {{ p.platform_name }}</div>
<div class="platform-meta">
<div class="platform-meta-item">
<span class="platform-meta-label">平台标识</span>
<span class="platform-meta-value">{{ p.platform }}</span>
</div>
<div v-if="p.platform_name" class="platform-meta-item">
<span class="platform-meta-label">平台名称</span>
<span class="platform-meta-value">{{ p.platform_name }}</span>
</div>
<div v-if="p.min_words || p.max_words" class="platform-meta-item">
<span class="platform-meta-label">字数限制</span>
<span class="platform-meta-value">{{ p.min_words || 0 }} - {{ p.max_words || '不限' }}</span>
</div>
</div>
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0">
<div style="font-weight: 600; margin-bottom: 8px;">格式规则:</div>
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0" class="rules-section">
<div class="rules-title">📋 格式规则</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">
<div style="font-weight: 600; margin-bottom: 8px;">合规规则:</div>
<div v-if="p.compliance_rules && p.compliance_rules.length > 0" class="rules-section">
<div class="rules-title">⚖️ 合规规则</div>
<div v-for="(rule, idx) in p.compliance_rules" :key="idx" class="rule-item">{{ rule }}</div>
</div>
</div>
@@ -138,8 +176,21 @@ const PlatformsApp = {
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
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() {
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.loadPlatforms();
})
.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] || '🌐';
@@ -184,17 +235,7 @@ const PlatformsApp = {
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; 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.loadPlatforms();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
this.checkAuth();
}
};
const app = Vue.createApp(PlatformsApp);
+17 -14
View File
@@ -14,7 +14,7 @@
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
@@ -146,8 +146,21 @@ const TasksApp = {
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
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() {
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.loadTasks();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
getStatusLabel(status) {
const map = { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' };
return map[status] || status;
@@ -185,17 +198,7 @@ const TasksApp = {
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; 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.loadTasks();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
this.checkAuth();
}
};
const app = Vue.createApp(TasksApp);
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<script src="vue.global.prod.js"></script>
<script src="navigation-component.js"></script>
<style>
body { margin: 0; padding: 0; }
.navbar { height: 60px; background: #333; color: white; display: flex; align-items: center; padding: 0 20px; }
.main-content { padding: 20px; }
</style>
</head>
<body>
<div id="app">
<div class="navbar">Test Navbar</div>
<navigation-component current-page="dashboard" :is-admin="true" @navigate="()=>{}"></navigation-component>
<div class="main-content">
<h1>内容区域</h1>
<p>侧边栏应该在左侧显示。</p>
</div>
</div>
<script>
const app = Vue.createApp({
data() { return { isAdmin: true } }
});
if (window.installNavigation) {
window.installNavigation(app);
console.log('已调用 installNavigation');
} else {
console.error('installNavigation 未定义');
}
app.mount('#app');
</script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>导航组件测试</title>
<link rel="stylesheet" href="element-plus.css">
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script src="navigation-component.js"></script>
<style>
body { margin: 0; font-family: sans-serif; }
.navbar { background: #2563eb; color: white; padding: 16px; }
.main-content { padding: 24px; }
</style>
</head>
<body>
<div id="app">
<nav class="navbar">
<span>测试页面</span>
</nav>
<navigation-component
current-page="topics"
:is-admin="true"
@navigate="redirectToPage"
></navigation-component>
<div class="main-content">
<h1>内容区域</h1>
<p>如果看到左边栏,说明组件工作正常。</p>
</div>
</div>
<script>
const App = {
data() {
return { isAdmin: true };
},
methods: {
redirectToPage(page) {
console.log('导航到:', page);
}
}
};
const app = Vue.createApp(App);
if (window.installNavigation) { window.installNavigation(app); }
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
+5 -5
View File
@@ -16,7 +16,7 @@
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
@@ -600,8 +600,8 @@ const TopicsApp = {
})
.catch(() => {});
},
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
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) {
const statusMap = {
'pending': '待处理',
@@ -647,7 +647,7 @@ const TopicsApp = {
console.log('[DEBUG] TopicsApp mounted');
const token = localStorage.getItem('authToken');
console.log('[DEBUG] Token exists:', !!token);
if (!token) { window.location.href = '/'; return; }
if (!token) { window.location.href = '/login.html'; return; }
// 解析 URL filter 参数
const urlParams = new URLSearchParams(window.location.search);
const filter = urlParams.get('filter');
@@ -662,7 +662,7 @@ const TopicsApp = {
this.isLoggedIn = true;
this.fetchTopics();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(TopicsApp);
+682
View File
@@ -0,0 +1,682 @@
<!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">
<style>
.preview-iframe { box-sizing: border-box; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.sidebar-btn { width: 100%; text-align: left; padding: 8px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; }
.sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
.sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600; }
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.status-badge { display: inline-flex; align-items: center; gap: 4px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.pending { background: #E6A23C; }
.status-dot.review { background: #F56C6C; }
.status-dot.ready { background: #67C23A; }
.status-dot.published { background: #409EFF; }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
.mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 12px; text-align: center; font-size: 12px; color: #606266; cursor: pointer; }
.mobile-nav-btn.active { color: #409eff; font-weight: 600; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 12px; padding-bottom: 80px; }
}
.topic-card-list { display: none; }
/* 移动端卡片布局 */
@media (max-width: 768px) {
.el-table { font-size: 12px; display: none; }
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
.el-table .cell { padding: 0 4px; }
.el-table .el-table__cell { padding: 6px 0; }
.topic-card-list { display: block; margin: 0 -16px; }
.topic-card {
background: white;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.topic-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.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; }
.mobile-nav { display: flex; }
}
.preview-iframe { box-sizing: border-box; }
/* 预览弹窗响应式高度 */
@media (min-width: 769px) {
.preview-iframe { max-height: calc(100vh - 100px) !important; }
}
@media (max-width: 768px) {
.preview-iframe { max-height: calc(100vh - 250px) !important; }
}
/* 预览弹窗自定义高度(非全屏时) */
.preview-dialog-custom.el-dialog {
max-height: calc(100vh - 90px) !important;
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;
}
</style>
</head>
<body>
<div id="app">
<nav class="navbar">
<div class="navbar-content">
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
<div class="navbar-user">
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
</div>
</div>
</nav>
<div class="main-content">
<aside class="sidebar">
<button class="sidebar-btn" @click="redirectToPage('/')">📊 系统概览</button>
<button class="sidebar-btn active">📋 选题管理</button>
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</aside>
<main class="content-area">
<div class="card">
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<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="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
</div>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
</div>
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
<el-table-column prop="field" label="领域" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="90">
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></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 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="180" 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="optimizeTopic(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>
</template>
</el-table-column>
</el-table>
<!-- 移动端卡片列表 -->
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
<div class="topic-card-header">
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
</div>
<div class="topic-card-tags">
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
</div>
<div class="topic-card-meta">
<div>创建: {{ formatDate(topic.created_at) }}</div>
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
</div>
<div class="topic-card-actions">
<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="optimizeTopic(topic)">审查</el-button>
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<nav class="mobile-nav">
<button class="mobile-nav-btn" @click="redirectToPage('/')">📊 概览</button>
<button class="mobile-nav-btn active">📋 选题</button>
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
</nav>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen">
<div v-if="previewTopic">
<!-- 平台切换按钮 -->
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
<el-button-group>
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
</el-button-group>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
<el-button size="small" @click="togglePreviewFullscreen">
{{ previewFullscreen ? '退出全屏' : '全屏' }}
</el-button>
</div>
</div>
<!-- 预览内容使用 iframe 隔离样式 -->
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
<iframe :srcdoc="currentPreviewHtml"
class="preview-iframe"
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
sandbox>
</iframe>
</div>
</div>
<template #footer>
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
<div class="preview-info">
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
<span style="margin: 0 8px;">|</span>
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
创作:{{ formatDate(previewTopic.generated_at) }}
</span>
<span v-if="previewTopic.published_at" style="margin-left:8px;">
发布:{{ formatDate(previewTopic.published_at) }}
</span>
</div>
<div class="dialog-actions">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
</div>
</div>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TopicsApp = {
data() {
return {
currentPage: 'topics',
isLoggedIn: false,
isAdmin: false,
currentUser: { username: '' },
loadingTable: false,
selectedTopicIds: [],
filterStatus: '',
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [],
previewVisible: false,
previewTopic: null,
previewFullscreen: false,
previewPlatform: 'zhihu',
platformContents: {}
}
},
computed: {
filteredTopics() {
if (!this.topics || !this.topics.length) { return []; }
if (!this.filterStatus) { return this.topics; }
const statusMap = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
'ready': ['ready', '待发布'],
'published': ['published', '已发布']
};
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
return this.topics.filter(t => allowed.includes(t.status));
},
statusStats() {
const pending = ['pending', '待处理'];
const review = ['review', '待审查'];
const ready = ['ready', '待发布'];
const published = ['published', '已发布'];
return {
total: this.topics.length,
pending: this.topics.filter(t => pending.includes(t.status)).length,
review: this.topics.filter(t => review.includes(t.status)).length,
ready: this.topics.filter(t => ready.includes(t.status)).length,
published: this.topics.filter(t => published.includes(t.status)).length
};
},
currentPreviewHtml() {
const html = this.platformContents[this.previewPlatform];
if (!html) return '';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const body = doc.body;
if (!body) return html;
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
const head = doc.querySelector('head');
const headHtml = head ? head.innerHTML : '';
const bodyHtml = body.innerHTML;
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;
}
}
},
methods: {
async fetchTopics() {
this.loadingTable = true;
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
setTimeout(() => window.location.href = '/', 1500);
this.loadingTable = false;
return;
}
const response = await fetch('/api/topics', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.topics = data || [];
this.$message.success('选题加载成功');
} catch (error) {
console.error('获取选题失败:', error);
this.$message.error(`获取选题失败: ${error.message}`);
this.topics = [];
} finally {
this.loadingTable = false;
}
},
refreshAll() { this.$message.info('执行批量刷新'); },
async triggerGenerateSelected() {
if (!this.selectedTopicIds.length) return;
this.$message.success('批量创作已启动');
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
// 取第一个选题(当前简单实现)
const topicId = this.selectedTopicIds[0];
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_id: topicId })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
await response.json();
this.$message.success('批量创作已启动');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量创作失败:', error);
this.$message.error(`批量创作失败: ${error.message}`);
}
},
async triggerOptimizeSelected() {
if (!this.selectedTopicIds.length) return;
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success('批量优化完成');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量优化失败:', error);
this.$message.error(`批量优化失败: ${error.message}`);
}
},
async openPreview(topic) {
this.previewTopic = topic;
this.previewPlatform = 'zhihu';
this.previewVisible = true;
this.platformContents = {};
// 并行加载所有平台内容
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.warning('请先登录');
return;
}
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
const promises = 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))
);
await Promise.all(promises);
},
togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen;
},
platformName(platform) {
const names = {
zhihu: '知乎',
wechat: '微信公众号',
xiaohongshu: '小红书'
};
return names[platform] || platform;
},
// 计算属性:当前平台预览的完整 HTML(响应式更新)
copyContent(platform) {
if (!this.previewTopic || !this.previewTopic.content) {
this.$message.warning('暂无内容可复制');
return;
}
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
navigator.clipboard.writeText(text).then(() => {
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
}).catch(err => {
console.error('复制失败', err);
this.$message.error('复制失败,请手动复制');
});
},
async createTopic(topic) {
console.log('createTopic clicked, topic:', topic);
if (this.isStatus(topic, 'published')) {
this.$message.info('已发布选题不可创作');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_id: topic.id })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`创作完成: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('创作失败:', error);
this.$message.error(`创作失败: ${error.message}`);
}
},
async optimizeTopic(topic) {
if (!this.isStatus(topic, 'review')) {
this.$message.info('仅待审查选题可优化');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: [topic.id] })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`优化完成: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('优化失败:', error);
this.$message.error(`优化失败: ${error.message}`);
}
},
async handlePublish(topic) {
if (!this.isStatus(topic, 'ready')) {
this.$message.info('仅待发布选题可发布');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/publishing/create', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: [topic.id] })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`发布成功: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('发布失败:', error);
this.$message.error(`发布失败: ${error.message}`);
}
},
async deleteTopic(id) {
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
window.location.href = '/';
return;
}
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
this.$message.success('删除成功');
await this.fetchTopics();
} catch (error) {
console.error('删除失败:', error);
this.$message.error(`删除失败: ${error.message}`);
}
})
.catch(() => {});
},
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) {
const statusMap = {
'pending': '待处理',
'review': '待审查',
'ready': '待发布',
'published': '已发布'
};
return statusMap[status] || status;
},
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; }
},
getStatusType(status) {
const map = {
'pending': 'warning',
'review': 'danger',
'ready': 'success',
'published': 'info',
'待处理': 'warning',
'待审查': 'danger',
'待发布': 'success',
'已发布': 'info'
};
return map[status] || 'primary';
},
isStatus(row, status) {
const map = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
'ready': ['ready', '待发布'],
'published': ['published', '已发布']
};
return map[status] ? map[status].includes(row.status) : row.status === status;
}
},
mounted() {
console.log('[DEBUG] TopicsApp mounted');
const token = localStorage.getItem('authToken');
console.log('[DEBUG] Token exists:', !!token);
if (!token) { window.location.href = '/'; return; }
// 解析 URL filter 参数
const urlParams = new URLSearchParams(window.location.search);
const filter = urlParams.get('filter');
console.log('[DEBUG] URL filter:', filter);
if (filter) { this.filterStatus = filter; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
console.log('[DEBUG] Auth success, user:', data.user);
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.fetchTopics();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(TopicsApp);
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
+649
View File
@@ -0,0 +1,649 @@
<!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">
<style>
.preview-iframe { box-sizing: border-box; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
/* 移动端卡片布局 */
@media (max-width: 768px) {
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
.el-table .cell { padding: 0 4px; }
.el-table .el-table__cell { padding: 6px 0; }
.topic-card-list { display: block; margin: 0 -16px; }
.topic-card {
background: white;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.topic-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.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 { box-sizing: border-box; }
/* 预览弹窗响应式高度 */
@media (min-width: 769px) {
.preview-iframe { max-height: calc(100vh - 100px) !important; }
}
@media (max-width: 768px) {
.preview-iframe { max-height: calc(100vh - 250px) !important; }
}
/* 预览弹窗自定义高度(非全屏时) */
.preview-dialog-custom.el-dialog {
max-height: calc(100vh - 90px) !important;
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;
}
</style>
<script src="navigation-component.js"></script></head>
<body>
<div id="app">
<nav class="navbar">
<div class="navbar-content">
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
<div class="navbar-user">
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
</div>
</div>
</nav>
<div class="main-content">
<main class="content-area">
<div class="card">
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<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="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
</div>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
</div>
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
<el-table-column prop="field" label="领域" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="90">
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></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 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="180" 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="optimizeTopic(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>
</template>
</el-table-column>
</el-table>
<!-- 移动端卡片列表 -->
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
<div class="topic-card-header">
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
</div>
<div class="topic-card-tags">
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
</div>
<div class="topic-card-meta">
<div>创建: {{ formatDate(topic.created_at) }}</div>
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
</div>
<div class="topic-card-actions">
<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="optimizeTopic(topic)">审查</el-button>
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
</div>
</div>
</div>
</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">
<div v-if="previewTopic">
<!-- 平台切换按钮 -->
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
<el-button-group>
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
</el-button-group>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
<el-button size="small" @click="togglePreviewFullscreen">
{{ previewFullscreen ? '退出全屏' : '全屏' }}
</el-button>
</div>
</div>
<!-- 预览内容使用 iframe 隔离样式 -->
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
<iframe :srcdoc="currentPreviewHtml"
class="preview-iframe"
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
sandbox>
</iframe>
</div>
</div>
<template #footer>
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
<div class="preview-info">
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
<span style="margin: 0 8px;">|</span>
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
创作:{{ formatDate(previewTopic.generated_at) }}
</span>
<span v-if="previewTopic.published_at" style="margin-left:8px;">
发布:{{ formatDate(previewTopic.published_at) }}
</span>
</div>
<div class="dialog-actions">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
</div>
</div>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TopicsApp = {
data() {
return {
currentPage: 'topics',
isLoggedIn: false,
isAdmin: false,
currentUser: { username: '' },
loadingTable: false,
selectedTopicIds: [],
filterStatus: '',
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [],
previewVisible: false,
previewTopic: null,
previewFullscreen: false,
previewPlatform: 'zhihu',
platformContents: {}
}
},
computed: {
filteredTopics() {
if (!this.topics || !this.topics.length) { return []; }
if (!this.filterStatus) { return this.topics; }
const statusMap = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
'ready': ['ready', '待发布'],
'published': ['published', '已发布']
};
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
return this.topics.filter(t => allowed.includes(t.status));
},
statusStats() {
const pending = ['pending', '待处理'];
const review = ['review', '待审查'];
const ready = ['ready', '待发布'];
const published = ['published', '已发布'];
return {
total: this.topics.length,
pending: this.topics.filter(t => pending.includes(t.status)).length,
review: this.topics.filter(t => review.includes(t.status)).length,
ready: this.topics.filter(t => ready.includes(t.status)).length,
published: this.topics.filter(t => published.includes(t.status)).length
};
},
currentPreviewHtml() {
const html = this.platformContents[this.previewPlatform];
if (!html) return '';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const body = doc.body;
if (!body) return html;
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
const head = doc.querySelector('head');
const headHtml = head ? head.innerHTML : '';
const bodyHtml = body.innerHTML;
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;
}
}
},
methods: {
async fetchTopics() {
this.loadingTable = true;
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
setTimeout(() => window.location.href = '/', 1500);
this.loadingTable = false;
return;
}
const response = await fetch('/api/topics', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.topics = data || [];
this.$message.success('选题加载成功');
} catch (error) {
console.error('获取选题失败:', error);
this.$message.error(`获取选题失败: ${error.message}`);
this.topics = [];
} finally {
this.loadingTable = false;
}
},
refreshAll() { this.$message.info('执行批量刷新'); },
async triggerGenerateSelected() {
if (!this.selectedTopicIds.length) return;
this.$message.success('批量创作已启动');
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
// 取第一个选题(当前简单实现)
const topicId = this.selectedTopicIds[0];
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_id: topicId })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
await response.json();
this.$message.success('批量创作已启动');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量创作失败:', error);
this.$message.error(`批量创作失败: ${error.message}`);
}
},
async triggerOptimizeSelected() {
if (!this.selectedTopicIds.length) return;
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success('批量优化完成');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量优化失败:', error);
this.$message.error(`批量优化失败: ${error.message}`);
}
},
async openPreview(topic) {
this.previewTopic = topic;
this.previewPlatform = 'zhihu';
this.previewVisible = true;
this.platformContents = {};
// 并行加载所有平台内容
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.warning('请先登录');
return;
}
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
const promises = 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))
);
await Promise.all(promises);
},
togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen;
},
platformName(platform) {
const names = {
zhihu: '知乎',
wechat: '微信公众号',
xiaohongshu: '小红书'
};
return names[platform] || platform;
},
// 计算属性:当前平台预览的完整 HTML(响应式更新)
copyContent(platform) {
if (!this.previewTopic || !this.previewTopic.content) {
this.$message.warning('暂无内容可复制');
return;
}
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
navigator.clipboard.writeText(text).then(() => {
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
}).catch(err => {
console.error('复制失败', err);
this.$message.error('复制失败,请手动复制');
});
},
async createTopic(topic) {
console.log('createTopic clicked, topic:', topic);
if (this.isStatus(topic, 'published')) {
this.$message.info('已发布选题不可创作');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_id: topic.id })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`创作完成: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('创作失败:', error);
this.$message.error(`创作失败: ${error.message}`);
}
},
async optimizeTopic(topic) {
if (!this.isStatus(topic, 'review')) {
this.$message.info('仅待审查选题可优化');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: [topic.id] })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`优化完成: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('优化失败:', error);
this.$message.error(`优化失败: ${error.message}`);
}
},
async handlePublish(topic) {
if (!this.isStatus(topic, 'ready')) {
this.$message.info('仅待发布选题可发布');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/publishing/create', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: [topic.id] })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`发布成功: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('发布失败:', error);
this.$message.error(`发布失败: ${error.message}`);
}
},
async deleteTopic(id) {
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
window.location.href = '/';
return;
}
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
this.$message.success('删除成功');
await this.fetchTopics();
} catch (error) {
console.error('删除失败:', error);
this.$message.error(`删除失败: ${error.message}`);
}
})
.catch(() => {});
},
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) {
const statusMap = {
'pending': '待处理',
'review': '待审查',
'ready': '待发布',
'published': '已发布'
};
return statusMap[status] || status;
},
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; }
},
getStatusType(status) {
const map = {
'pending': 'warning',
'review': 'danger',
'ready': 'success',
'published': 'info',
'待处理': 'warning',
'待审查': 'danger',
'待发布': 'success',
'已发布': 'info'
};
return map[status] || 'primary';
},
isStatus(row, status) {
const map = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
'ready': ['ready', '待发布'],
'published': ['published', '已发布']
};
return map[status] ? map[status].includes(row.status) : row.status === status;
}
},
mounted() {
console.log('[DEBUG] TopicsApp mounted');
const token = localStorage.getItem('authToken');
console.log('[DEBUG] Token exists:', !!token);
if (!token) { window.location.href = '/'; return; }
// 解析 URL filter 参数
const urlParams = new URLSearchParams(window.location.search);
const filter = urlParams.get('filter');
console.log('[DEBUG] URL filter:', filter);
if (filter) { this.filterStatus = filter; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
console.log('[DEBUG] Auth success, user:', data.user);
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.fetchTopics();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(TopicsApp);
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
+654
View File
@@ -0,0 +1,654 @@
<!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">
<style>
.preview-iframe { box-sizing: border-box; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
/* 移动端卡片布局 */
@media (max-width: 768px) {
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
.el-table .cell { padding: 0 4px; }
.el-table .el-table__cell { padding: 6px 0; }
.topic-card-list { display: block; margin: 0 -16px; }
.topic-card {
background: white;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.topic-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.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 { box-sizing: border-box; }
/* 预览弹窗响应式高度 */
@media (min-width: 769px) {
.preview-iframe { max-height: calc(100vh - 100px) !important; }
}
@media (max-width: 768px) {
.preview-iframe { max-height: calc(100vh - 250px) !important; }
}
/* 预览弹窗自定义高度(非全屏时) */
.preview-dialog-custom.el-dialog {
max-height: calc(100vh - 90px) !important;
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;
}
</style>
<script src="navigation-component.js"></script></head>
<body>
<div id="app">
<nav class="navbar">
<div class="navbar-content">
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
<div class="navbar-user">
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
</div>
</div>
</nav>
<navigation-component
current-page="topics"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<div class="main-content">
<main class="content-area">
<div class="card">
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<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="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
</div>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
</div>
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
<el-table-column prop="field" label="领域" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="90">
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></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 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="180" 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="optimizeTopic(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>
</template>
</el-table-column>
</el-table>
<!-- 移动端卡片列表 -->
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
<div class="topic-card-header">
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
</div>
<div class="topic-card-tags">
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
</div>
<div class="topic-card-meta">
<div>创建: {{ formatDate(topic.created_at) }}</div>
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
</div>
<div class="topic-card-actions">
<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="optimizeTopic(topic)">审查</el-button>
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
</div>
</div>
</div>
</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">
<div v-if="previewTopic">
<!-- 平台切换按钮 -->
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
<el-button-group>
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
</el-button-group>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
<el-button size="small" @click="togglePreviewFullscreen">
{{ previewFullscreen ? '退出全屏' : '全屏' }}
</el-button>
</div>
</div>
<!-- 预览内容使用 iframe 隔离样式 -->
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
<iframe :srcdoc="currentPreviewHtml"
class="preview-iframe"
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
sandbox>
</iframe>
</div>
</div>
<template #footer>
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
<div class="preview-info">
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
<span style="margin: 0 8px;">|</span>
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
创作:{{ formatDate(previewTopic.generated_at) }}
</span>
<span v-if="previewTopic.published_at" style="margin-left:8px;">
发布:{{ formatDate(previewTopic.published_at) }}
</span>
</div>
<div class="dialog-actions">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
</div>
</div>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TopicsApp = {
data() {
return {
currentPage: 'topics',
isLoggedIn: false,
isAdmin: false,
currentUser: { username: '' },
loadingTable: false,
selectedTopicIds: [],
filterStatus: '',
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [],
previewVisible: false,
previewTopic: null,
previewFullscreen: false,
previewPlatform: 'zhihu',
platformContents: {}
}
},
computed: {
filteredTopics() {
if (!this.topics || !this.topics.length) { return []; }
if (!this.filterStatus) { return this.topics; }
const statusMap = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
'ready': ['ready', '待发布'],
'published': ['published', '已发布']
};
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
return this.topics.filter(t => allowed.includes(t.status));
},
statusStats() {
const pending = ['pending', '待处理'];
const review = ['review', '待审查'];
const ready = ['ready', '待发布'];
const published = ['published', '已发布'];
return {
total: this.topics.length,
pending: this.topics.filter(t => pending.includes(t.status)).length,
review: this.topics.filter(t => review.includes(t.status)).length,
ready: this.topics.filter(t => ready.includes(t.status)).length,
published: this.topics.filter(t => published.includes(t.status)).length
};
},
currentPreviewHtml() {
const html = this.platformContents[this.previewPlatform];
if (!html) return '';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const body = doc.body;
if (!body) return html;
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
const head = doc.querySelector('head');
const headHtml = head ? head.innerHTML : '';
const bodyHtml = body.innerHTML;
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;
}
}
},
methods: {
async fetchTopics() {
this.loadingTable = true;
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
setTimeout(() => window.location.href = '/', 1500);
this.loadingTable = false;
return;
}
const response = await fetch('/api/topics', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.topics = data || [];
this.$message.success('选题加载成功');
} catch (error) {
console.error('获取选题失败:', error);
this.$message.error(`获取选题失败: ${error.message}`);
this.topics = [];
} finally {
this.loadingTable = false;
}
},
refreshAll() { this.$message.info('执行批量刷新'); },
async triggerGenerateSelected() {
if (!this.selectedTopicIds.length) return;
this.$message.success('批量创作已启动');
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
// 取第一个选题(当前简单实现)
const topicId = this.selectedTopicIds[0];
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_id: topicId })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
await response.json();
this.$message.success('批量创作已启动');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量创作失败:', error);
this.$message.error(`批量创作失败: ${error.message}`);
}
},
async triggerOptimizeSelected() {
if (!this.selectedTopicIds.length) return;
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success('批量优化完成');
this.selectedTopicIds = [];
await this.fetchTopics();
} catch (error) {
console.error('批量优化失败:', error);
this.$message.error(`批量优化失败: ${error.message}`);
}
},
async openPreview(topic) {
this.previewTopic = topic;
this.previewPlatform = 'zhihu';
this.previewVisible = true;
this.platformContents = {};
// 并行加载所有平台内容
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.warning('请先登录');
return;
}
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
const promises = 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))
);
await Promise.all(promises);
},
togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen;
},
platformName(platform) {
const names = {
zhihu: '知乎',
wechat: '微信公众号',
xiaohongshu: '小红书'
};
return names[platform] || platform;
},
// 计算属性:当前平台预览的完整 HTML(响应式更新)
copyContent(platform) {
if (!this.previewTopic || !this.previewTopic.content) {
this.$message.warning('暂无内容可复制');
return;
}
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
navigator.clipboard.writeText(text).then(() => {
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
}).catch(err => {
console.error('复制失败', err);
this.$message.error('复制失败,请手动复制');
});
},
async createTopic(topic) {
console.log('createTopic clicked, topic:', topic);
if (this.isStatus(topic, 'published')) {
this.$message.info('已发布选题不可创作');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/generate/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_id: topic.id })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`创作完成: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('创作失败:', error);
this.$message.error(`创作失败: ${error.message}`);
}
},
async optimizeTopic(topic) {
if (!this.isStatus(topic, 'review')) {
this.$message.info('仅待审查选题可优化');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/system/optimize/run', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: [topic.id] })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`优化完成: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('优化失败:', error);
this.$message.error(`优化失败: ${error.message}`);
}
},
async handlePublish(topic) {
if (!this.isStatus(topic, 'ready')) {
this.$message.info('仅待发布选题可发布');
return;
}
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/publishing/create', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic_ids: [topic.id] })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
const data = await response.json();
this.$message.success(`发布成功: ${topic.title}`);
await this.fetchTopics();
} catch (error) {
console.error('发布失败:', error);
this.$message.error(`发布失败: ${error.message}`);
}
},
async deleteTopic(id) {
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
window.location.href = '/';
return;
}
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
this.$message.success('删除成功');
await this.fetchTopics();
} catch (error) {
console.error('删除失败:', error);
this.$message.error(`删除失败: ${error.message}`);
}
})
.catch(() => {});
},
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) {
const statusMap = {
'pending': '待处理',
'review': '待审查',
'ready': '待发布',
'published': '已发布'
};
return statusMap[status] || status;
},
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; }
},
getStatusType(status) {
const map = {
'pending': 'warning',
'review': 'danger',
'ready': 'success',
'published': 'info',
'待处理': 'warning',
'待审查': 'danger',
'待发布': 'success',
'已发布': 'info'
};
return map[status] || 'primary';
},
isStatus(row, status) {
const map = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
'ready': ['ready', '待发布'],
'published': ['published', '已发布']
};
return map[status] ? map[status].includes(row.status) : row.status === status;
}
},
mounted() {
console.log('[DEBUG] TopicsApp mounted');
const token = localStorage.getItem('authToken');
console.log('[DEBUG] Token exists:', !!token);
if (!token) { window.location.href = '/'; return; }
// 解析 URL filter 参数
const urlParams = new URLSearchParams(window.location.search);
const filter = urlParams.get('filter');
console.log('[DEBUG] URL filter:', filter);
if (filter) { this.filterStatus = filter; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
console.log('[DEBUG] Auth success, user:', data.user);
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.fetchTopics();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(TopicsApp);
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
+96 -142
View File
@@ -7,110 +7,119 @@
<link rel="stylesheet" href="element-plus.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; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.sidebar { width: 180px; background: white; padding: 16px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow-x: auto; }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
.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) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 16px; padding-bottom: 80px; }
}
.user-card-list { display: none; }
/* users.html 移动端优化 */
@media (max-width: 768px) {
.card { padding: 16px; }
.user-table { display: none; }
.user-card-list { display: block; margin: 0 -16px; }
.user-card {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
.user-card {
background: white;
border-radius: 10px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: all 0.2s ease;
}
.user-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.user-card:active { transform: scale(0.99); }
.user-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.user-card-name { font-size: 16px; font-weight: 600; }
.user-card-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 12px;
color: #606266;
margin-bottom: 12px;
.user-card-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.user-card-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 13px;
color: #606266;
margin-bottom: 12px;
}
.user-card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid #ebeef5;
.user-card-meta-item { display: flex; flex-direction: column; gap: 2px; }
.user-card-meta-label { font-size: 11px; color: #909399; }
.user-card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid #ebeef5;
}
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.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>
<div class="main-content">
<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>
<div class="main-content">
<main class="content-area">
<div class="card">
<h2 style="font-size: 24px; font-weight: 600; margin-bottom: 24px;">👥 用户管理</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h3 style="font-size: 18px; font-weight: 600;">用户列表</h3>
<div class="card-header">
<h2 class="card-title">👥 用户管理</h2>
<el-button type="primary" @click="addUser">+ 新建用户</el-button>
</div>
<el-table :data="users" stripe :cell-class-name="getMobileUserCellClass" class="user-table">
<el-table :data="users" stripe class="user-table">
<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="created_at" label="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="150">
<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">
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)" :disabled="scope.row.role === 'admin'">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="user-card-list">
<div v-for="u in users" :key="u.id" class="user-card">
<div class="user-card-header">
<div class="user-card-name">
{{ u.username }}
<el-tag size="small" :type="u.role === 'admin' ? 'danger' : 'info'">{{ u.role === 'admin' ? '管理员' : '编辑' }}</el-tag>
</div>
<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>{{ formatDate(u.created_at) }}</span>
</div>
</div>
<div class="user-card-actions">
<el-button size="small" type="danger" plain @click="deleteUser(u.id)" :disabled="u.role === 'admin'">删除</el-button>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
@@ -121,17 +130,9 @@
async fetchUsers() {
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
const response = await fetch('/api/admin/users', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
if (!token) { this.$message.error('请先登录'); return; }
const response = await fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + token } });
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('用户列表加载成功');
@@ -144,24 +145,14 @@
async addUser() {
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
return;
}
// 使用默认用户名,添加时间戳避免重复
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'
},
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, role: 'editor' })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
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('添加用户成功');
@@ -171,76 +162,39 @@
}
},
async deleteUser(id) {
if (id === 'admin') {
this.$message.warning('不能删除管理员用户');
return;
}
if (id === 'admin') { this.$message.warning('不能删除管理员用户'); return; }
this.$confirm('确定删除该用户?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const token = localStorage.getItem('authToken');
if (!token) {
this.$message.error('请先登录');
window.location.href = '/';
return;
}
const response = await fetch(`/api/admin/users/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
if (!token) { this.$message.error('请先登录'); window.location.href = '/'; return; }
const response = await fetch(`/api/admin/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
this.users = this.users.filter(u => u.id !== id);
this.$message.success('删除用户成功');
} catch (error) {
console.error('删除用户失败:', error);
this.$message.error(`删除用户失败: ${error.message}`);
}
} catch (error) { console.error('删除用户失败:', error); this.$message.error(`删除用户失败: ${error.message}`); }
})
.catch(() => {});
},
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) {
if (!page) return;
// 处理绝对URL或相对路径
if (page.startsWith('/') || page.startsWith('http://') || page.startsWith('https://')) {
window.location.href = page;
} else {
window.location.href = '/' + page;
}
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() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/login.html'; } else { this.fetchUsers(); } })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
formatDate(dateStr) { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/'; } else { this.fetchUsers(); } })
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
mounted() { this.checkAuth(); }
};
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); }
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);
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); }
app.mount('#app');
</script>
</body>
</html>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-99
View File
@@ -1,99 +0,0 @@
-- 宇之然内容创作平台 - 数据库初始化脚本
-- 创建扩展(如果需要)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- 创建管理员用户(如果不存在)
INSERT INTO users (username, password_hash, role, created_at)
VALUES ('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj9X7F5Z4nO', 'admin', NOW())
ON CONFLICT DO NOTHING;
-- 创建示例选题数据
INSERT INTO topics (title, field, priority_score, status, compliance_score, created_at)
VALUES
('人工智能发展趋势分析', '科技', 15, '待处理', 85, NOW()),
('数字化转型对企业的影响', '商业', 12, '待审查', 92, NOW()),
('区块链技术在金融领域的应用', '金融科技', 18, '待发布', 78, NOW()),
('绿色能源与可持续发展', '环保', 10, '已发布', 95, NOW())
ON CONFLICT DO NOTHING;
-- 创建审计日志表(如果不存在)
CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(50),
resource_id INTEGER,
details TEXT,
ip_address INET,
user_agent TEXT,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 创建生成任务表(如果不存在)
CREATE TABLE IF NOT EXISTS generate_tasks (
id SERIAL PRIMARY KEY,
topic_id INTEGER REFERENCES topics(id),
status VARCHAR(20) DEFAULT 'pending',
result JSONB,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 创建发布记录表(如果不存在)
CREATE TABLE IF NOT EXISTS publish_records (
id SERIAL PRIMARY KEY,
topic_id INTEGER REFERENCES topics(id),
platform VARCHAR(50),
url TEXT,
status VARCHAR(20) DEFAULT 'success',
error_message TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 创建索引优化查询性能
CREATE INDEX IF NOT EXISTS idx_topics_status ON topics(status);
CREATE INDEX IF NOT EXISTS idx_topics_created_at ON topics(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_generate_tasks_topic_id ON generate_tasks(topic_id);
CREATE INDEX IF NOT EXISTS idx_publish_records_topic_id ON publish_records(topic_id);
-- 更新触发器函数(如果不存在)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为topics表添加触发器
DROP TRIGGER IF EXISTS update_topics_updated_at ON topics;
CREATE TRIGGER update_topics_updated_at BEFORE UPDATE ON topics
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 插入初始审计日志
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, timestamp)
SELECT
u.id,
'system_init',
'database',
NULL,
'数据库初始化完成',
NOW()
FROM users u
WHERE u.username = 'admin'
ON CONFLICT DO NOTHING;
-- 验证数据
DO $$
BEGIN
RAISE NOTICE '数据库初始化完成';
RAISE NOTICE '管理员账号: admin';
RAISE NOTICE '密码: Admin@2026!';
RAISE NOTICE 'API地址: http://localhost:8001';
RAISE NOTICE '前端地址: http://localhost:8000';
END $$;
-128
View File
@@ -1,128 +0,0 @@
# 宇之然内容创作平台 - Nginx配置
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
# 基本设置
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_comp_level 6;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: blob: 'unsafe-inline'" always;
# 代理缓存
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off;
# 上游服务器
upstream backend {
server app:8001;
keepalive 32;
}
server {
listen 80;
server_name _;
client_max_body_size 100M;
# SSL配置(生产环境)
# listen 443 ssl http2;
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
location / {
# 前端静态资源缓存
proxy_cache STATIC;
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# 反向代理到后端API
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
# WebSocket支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时设置
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# API文档(可选)
location /docs {
proxy_pass http://backend/docs;
proxy_set_header Host $host;
}
location /redoc {
proxy_pass http://backend/redoc;
proxy_set_header Host $host;
}
}
# 静态文件服务(如果需要)
server {
listen 8000;
server_name localhost;
root /usr/share/nginx/html;
index index.html login.html;
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ def topic_to_dict(topic: Topic) -> Dict:
return {
'id': topic.id,
'title': topic.title,
'field': topic.field,
'field': topic.field.name if topic.field else None,
'format': topic.format,
'core_concept': topic.core_concept,
'audience_pain': topic.audience_pain,
-76
View File
@@ -1,76 +0,0 @@
# 小红书运营内容规划
## 账号信息
- 账号昵称:小红薯689CEB31
- 小红书号:94352193742
- 京东店铺:http://mall.jd.com/index-257484381.html
- 定位:手机配件知识分享 + 好物推荐
## 内容策略
**风格**:知识分享型,贴近生活,实用干货
**频率**:初期建议每天1-2篇,保持活跃
## 商品推广优先级
### 🔝 第一梯队(主推)
1. 手机充电器 - 刚需、损耗品
2. 手机壳 - 个性化、复购率高
3. 手机膜 - 高频消耗
### 🔄 第二梯队(搭配)
4. 数据线
5. 支架/车载配件
6. 耳机小配件
## 内容主题库(待发布)
### 主题1:手机充电器选购指南
- 标题:手机充电器怎么选?这5个坑千万别踩
- 重点:快充协议、功率匹配、安全性
- 产品:店铺快充充电器
- 链接:http://mall.jd.com/index-257484381.html
### 主题2:手机壳挑选技巧
- 标题:手机壳怎么选?防摔还是颜值?
- 重点:材质对比(硅胶/PC/金属)、防护等级
- 产品:多款手机壳
- 链接:http://mall.jd.com/index-257484381.html
### 主题3:手机膜科普
- 标题:手机膜越贵越好?业内人士说真话
- 重点:钢化膜/水凝膜/防窥膜区别
- 产品:高清钢化膜、防窥膜
- 链接:http://mall.jd.com/index-257484381.html
### 主题4:三件套搭配推荐
- 标题:手机必备三件套,少了哪件都不行
- 重点:充电器+壳+膜组合购买优惠
- 产品:套餐组合
- 链接:http://mall.jd.com/index-257484381.html
### 主题5:日常使用小技巧
- 标题:手机充电的正确姿势,电池能用三年
- 重点:充电习惯、电池保养
- 产品:优质充电器推荐
- 链接:http://mall.jd.com/index-257484381.html
## 发布计划
- [ ] 第1天:发布主题1(充电器)
- [ ] 第2天:发布主题2(手机壳)
- [ ] 第3天:发布主题3(手机膜)
- [ ] 第4天:发布主题4(三件套组合)
- [ ] 第5天:发布主题5(使用技巧)
- [ ] 后续:根据数据反馈调整主题
## 素材准备
- 产品实拍图(需要拍摄)
- 对比示意图
- 使用场景图
- 细节特写图
## 标签建议
#手机配件 #数码种草 #充电器 #手机壳 #手机膜 #好物分享 #数码小知识
---
**下一步**:准备图片素材,开始发布第一篇笔记