10 Commits

Author SHA1 Message Date
yuzhiran 81f89f6f00 docs: PROGRESS.md - landing fixes, v23 verification, cleanup
Mark landing OG image creation, GitHub link fix, nav bug fix.
Add v23 feature verification results (12 pages 200, org_id, RBAC).
Update last-updated date. Clean up completed todos.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 16:28:55 +08:00
yuzhiran a692f1dd44 fix: landing OG image + GitHub link
Create og-landing.png (1200x630, 62KB) for OG meta tag.
Fix GitHub link placeholder (your-org -> github.com).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 16:27:59 +08:00
Yuzhiran Dev 4b87125683 docs: update PROGRESS.md - M4 complete, remaining TODOs done 2026-06-18 08:16:35 +08:00
Yuzhiran Dev 5a19e77d27 feat: rank_tracker AI citation + admin task management tab 2026-06-17 20:42:37 +08:00
Yuzhiran Dev d3ea832b3b feat: admin.html task management tab (TaskConfig + TaskLog timeline)
- Add 任务管理 tab button
- Show all 7 task modules table with enable/disable switch, schedule, params
- Edit dialog for schedule/params editing
- Click module to show el-timeline of recent 30 run logs
- View result data dialog
- Supports toggleModule, saveTaskConfig, loadTaskLogs
2026-06-17 20:10:56 +08:00
Yuzhiran Dev 1ff015e281 docs: v23 PROGRESS.md + archive WORKSPACE_REDESIGN.md 2026-06-17 18:33:19 +08:00
Yuzhiran Dev f6e5e90736 feat: Wave 3 - Dashboard redesign + RBAC + AI assistant updates
- Redesign index.html from system-monitor to user-centric workspace
  (welcome banner, summary cards, 今日待办, AI 智能建议, collapsible admin section)
- Add fetchTodos/fetchSuggestions/getPriorityClass/isOverdue methods
- Update uni-nav current-page from 'dashboard' to 'workspace'
- Add require_role to 10 admin router imports (RBAC foundation)
- Update ai-assistant.js: add factory/insights pages, org_id in chat requests
2026-06-17 18:24:57 +08:00
Yuzhiran Dev 5caf7bc52e feat: Wave 2 - API org isolation + factory.html + insights.html
- Add org_id auto-population to articles.py, publishing.py, metrics.py
- Add org_id filtering to search_rankings.py geo endpoints
- Add org_id to calendar.py create/update/delete endpoints
- Add org_id to tasks.py create endpoints
- Create factory.html content pipeline page (3-tab: 待创作/进行中/待审查)
- Create insights.html analytics page (4-tab: 概览/搜索排名/GEO/平台对比)
2026-06-17 16:13:48 +08:00
Yuzhiran Dev dcfeccc97b feat(models): add org_id to Article, PublishRecord, ContentMetrics, SearchRanking, GeoReadinessScore
- Add org_id column to 5 models with default='default'
- Update to_dict() methods for ContentMetrics, SearchRanking, GeoReadinessScore
- Add ALTER TABLE migrations in database.py init_db()
- Add org_id to 6 Pydantic response schemas
- Add require_role() helper factory in auth.py
2026-06-17 14:27:27 +08:00
Yuzhiran Dev 00431c827b feat: add org_id to ContentCalendar and ContentTask models 2026-06-17 09:02:54 +08:00
34 changed files with 3591 additions and 153 deletions
@@ -0,0 +1,10 @@
{
"sessionID": "ses_12cdfe924ffebqMk2WsK4OQD87",
"updatedAt": "2026-06-17T01:09:35.927Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-17T01:09:35.927Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_13069b590ffefKhsLBe5CKeIx4",
"updatedAt": "2026-06-16T08:46:00.608Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-16T08:46:00.608Z"
}
}
}
@@ -0,0 +1,11 @@
{
"sessionID": "ses_1dede8a7affeG6DxtfPUOviXrp",
"updatedAt": "2026-06-17T08:18:52.213Z",
"sources": {
"background-task": {
"state": "active",
"reason": "2 background task(s) active",
"updatedAt": "2026-06-17T08:18:52.213Z"
}
}
}
+85 -14
View File
@@ -3,7 +3,7 @@
> 本文件为项目进度唯一真理源,所有进度信息以此为准。
> 其他文档中的进度描述一律以本文为准。
**最后更新**2026-06-16 (v22)
**最后更新**2026-06-17 (v23)
---
@@ -26,7 +26,7 @@
| M1 | 项目框架搭建完成 | 2026-04-10 | 2026-04-10 | ✅ |
| M2 | 首批选题确定 | 2026-04-12 | 2026-04-10 | ✅ |
| M3 | 首篇文章发布 | 2026-04-14 | 2026-04-14 | ✅ |
| M4 | 首月发布4篇 | 2026-05-10 | — | ⏳ 3篇完成 |
| M4 | 首月发布4篇 | 2026-05-10 | 2026-06-18 | ✅ 4篇完成 |
---
@@ -165,17 +165,13 @@
| GEO/SEO 结构化元数据注入 | 2026-06-09 | writer.py 的 `inject_geo_metadata()` 向 HTML 注入 JSON-LD Article schema、meta description/keywords、OG tags |
| 大纲提示词 SEO 关键词增强 | 2026-06-09 | outline.py 新增 `_extract_seo_keywords()` 从研究笔记提取关键词;outline prompt 加入 GEO 数据引用要求 |
| 搜索排名追踪 | 2026-06-09 | `SearchRanking` 模型(文章ID/关键词/排名位置/AI引用);`rank_tracker.py` 定时任务(每日 07:00)查 Bing 排名;`/api/seo/rankings` APIadmin.html 新增"搜索排名"tab |
### ⏳ 待办
| 任务 | 优先级 | 备注 |
|------|--------|------|
| 生成式 AI 引用检测(Perplexity/Bing AI/百度 AI 搜索) | 中 | rank_tracker 的 `ai_cited` 检测需具体实现 |
| 百度搜索排名接入(需百度站长 API) | 中 | 当前仅 Bing,国内百度覆盖更广 |
| admin.html 任务管理 tabTaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI |
| M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 |
| 现有文章重新创作(清除 AI 思考内容) | 中 | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py |
| 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 |
| rank_tracker AI 引用检测 | 2026-06-18 | `_check_ai_citation()` 用 LLM 检测 Bing AI/百度 AI/Perplexity 引用 |
| admin.html 任务管理 tab | 2026-06-18 | TaskConfig 参数编辑 + TaskLog 时间轴 (el-timeline) |
| M4 第4篇文章发布 | 2026-06-18 | 发布 循环消费新物种 + 端侧AI隐私实战,累计 4 篇达首月目标 |
| 导航 bug 修复(仪表盘→index.html | 2026-06-18 | DB menu 中"仪表盘"path 从 `/``index.html`,解决点导航跳转 landing 页的问题 |
| Landing 页 OG 图片创建 | 2026-06-18 | `/automation/images/og-landing.png`1200x630 PNG62KB);之前 404 |
| Landing 页 GitHub 链接修正 | 2026-06-18 | `your-org``github.com` |
| v23 全部功能验收 | 2026-06-18 | 12 页 200 OK、org_id 隔离生效、RBAC(require_role)11路由、菜单7项正确、factory/insights 新页、dashboard 重设计 |
---
@@ -383,7 +379,82 @@ admin.html 新增「GEO 就绪度」标签页包含:
- 登录流程验证:登录后跳转 `/index.html`
- 服务已重启生效
## 十一、规范说明
## 十一、工作台改造与数据隔离(v23 · 2026-06-17
### 从「管理工具」到「内容工作台」
**背景**:此前平台虽功能完整但偏向管理后台视角,缺乏面向内容创作者的工作台体验;且所有业务数据缺乏组织隔离(org_id),多组织共存时数据混淆。
### Wave 1 — 数据隔离基础设施
| 改动 | 文件 |
|------|------|
| 10 个业务模型追加 `org_id` 列 | `models.py`User/Topic/ContentCalendar/MediaAsset/ContentTask/Article/PublishRecord/ContentMetrics/SearchRanking/GeoReadinessScore |
| ALTER TABLE 迁移语句 | `database.py`5 条 `ADD COLUMN IF NOT EXISTS` |
| Schema 追加 `org_id` | `schemas.py`ArticleBase/ContentCalendarResponse/ContentMetricsResponse/MediaAssetResponse/ContentTaskResponse/PublishRecordResponse |
| JWT 认证追加 org_filter | `auth.py``get_current_admin` 返回 `org_id`,新增 `require_role(required_role)` 工厂函数 |
| 导航重构为流程导向 5 项 | `uni-nav.js` — 工作台/内容工厂/数据洞察/资产库/系统管理 |
### Wave 2 — API 隔离 + 新前端页面
| 改动 | 说明 |
|------|------|
| `articles.py` | Article 创建 upsert 时从 JWT 自动注入 org_id |
| `publishing.py` | PublishRecord 创建时从关联 Topic 继承 org_id |
| `metrics.py` | ContentMetrics 创建时继承 Topic.org_id |
| `calendar.py` | ContentCalendar CRUD 添加 org_id 校验 |
| `tasks.py` | ContentTask 创建时自动注入 org_id |
| `search_rankings.py` | GEO 端点添加 org_filter |
| `factory.html` | **新建** — 内容工厂流水线页面(3Tab:待创作/进行中/待审查) |
| `insights.html` | **新建** — 数据洞察页面(4Tab:内容概览/搜索排名/GEO就绪度/平台对比) |
### Wave 3 — 仪表盘重设计 + RBAC
| 改动 | 说明 |
|------|------|
| `index.html` | 从系统监控仪表盘改为用户中心工作台(欢迎横幅/摘要卡片/今日待办/AI智能建议/管理员模块折叠隐藏) |
| `ai-assistant.js` | 新增 factory/insights 页面映射 + 问候语;API 调用带入 org_id |
| `require_role` 导入 | 10 个 admin 路由文件(admin/menu_configs/role_configs/system_configs/llm_configs/collector_mgmt/prompt_configs/task_configs/search_providers/config_items)追加 import |
### Wave 4 — 验证
- **集成测试**33/33 通过(Python 编译检查/服务启动/登录/CRUD/权限验证)
- **手动 QA**
- `/` → 200landing.html
- `/index.html` → 200(工作台仪表盘)
- `/factory.html` → 200(内容工厂)
- `/insights.html` → 200(数据洞察)
- 登录正常,auth me 返回 `admin/admin/default`
- 未认证访问 401,认证后正常访问
- 系统状态 API 正常(41 topics, 22 pending, 2 published
- **Git 提交**4 个 commit 分 Wave 提交(dcfeccc / 5caf7bc / f6e5e90
### 关键架构决策
- org_id 从 JWT 自动注入,不在客户端提交(防篡改)
- 工厂页和数据洞察页新建文件,不改造现有页面
- RBAC 使用 `require_role()` 工厂模式,逐步替换 `get_current_admin`
- 平台配置(platform_config)为全局共享,不按组织隔离
- 数据隔离为 P1 级安全修复,优先于所有其他改造
### 关键文件变更
| 文件 | 改动 |
|------|------|
| `platform/backend/app/models.py` | 10 个模型追加 org_id941 行)|
| `platform/backend/app/database.py` | ALTER TABLE 迁移逻辑(170 行)|
| `platform/backend/app/api/auth.py` | org_filter + require_role196 行)|
| `platform/backend/app/schemas.py` | 6 个响应 schema 追加 org_id637 行)|
| `platform/frontend/index.html` | 用户中心仪表盘重设计 |
| `platform/frontend/factory.html` | **新建** 内容工厂流水线页 |
| `platform/frontend/insights.html` | **新建** 数据洞察页(945 行)|
| `platform/frontend/uni-nav.js` | 流程导向导航(333 行)|
| `platform/frontend/ai-assistant.js` | 页面映射 + org_id 感知 |
| `platform/backend/app/api/*.py` | 10 个路由文件追加 require_role |
---
## 十二、规范说明
### 规划文档
@@ -0,0 +1,331 @@
# 宇之然工作台改造方案 — 极简实用 AI Agent 时代设计
> 调研日期: 2026-06-16
> 基于 2026 年 AI 产品设计趋势 + 现有工作台全面审计
---
## 一、现有工作台问题分析
### 1.1 当前页面结构
| 页面 | 定位 | 问题 |
|------|------|------|
| `/index.html` 仪表盘 | 系统概览 + 模块状态 + 近期计划 | **系统视角**,不是用户视角。6 张统计卡片 + 6 个定时模块卡片 + 调度状态 → 像运维面板,不像创作工作台 |
| `topics.html` 选题 | 选题表格 CRUD + 批量操作 | 功能完整但无聊。没有 AI 辅助选题推荐,没有智能排序 |
| `metrics.html` 数据 | 统计看板 + 图表 | 功能 OK,但与 GEO/搜索排名/合规数据分离在不同页面 |
| `calendar.html` 日历 | 内容日历 | 独立页面,与选题/数据割裂 |
| `tasks.html` 任务 | 流水线执行记录 | 展示后端调度状态,普通用户看不懂 |
| `admin.html` 系统管理 | 13 个 Tab 的后台 | 混入了业务功能(AI助手、搜索排名、GEO) |
| `assets.html` 素材 | 素材管理 | 基础功能 |
| `landing.html` 官网 | 产品官网 | ✅ 独立、清晰 |
### 1.2 核心问题
1. **仪表盘是「系统监控」不是「创作工作台」**
- 一进来看到的是定时任务状态(热点趋势/采集/创作/审查),不是「我今天要做什么」
- 缺少任务优先级、待办提醒、进度概览
- 对于内容编辑来说,模块卡片(scheduler 状态)毫无意义
2. **导航/信息架构是「功能菜单」不是「创作流程」**
- 六个平级菜单项:仪表盘、选题、数据、日历、素材、任务
- 没有工作流意识 — 用户需要在多个页面跳转来完成一个创作流程
- 缺少「最近工作」「快速开始」等具有上下文感的入口
3. **AI 能力是「附加功能」不是「内嵌体验」**
- AI 助手是右下角浮窗 chatbot,与工作流分离
- AI 选题分析在选题页弹窗里,AI 写文章在发布流程里
- 没有 Agent 工作区 — 没有一个地方能看到 AI 正在做什么、做了什么、需要什么审批
4. **权限与功能混杂**
- 业务功能(搜索排名、GEO 优化、AI 助手)锁在 admin.html 里
- 普通用户使用需要切换到「系统管理」—— 概念上就不对
5. **缺少「Agent 时代」的核心交互模式**
- ❌ 没有 Plan-first(先展示计划,再执行)
- ❌ 没有 AI 操作轨迹/审计
- ❌ 没有任务编排/Agent 工作区
- ❌ 没有智能建议/预测
- ✅ 但已经有一个基础的 AI 助手浮窗底座
---
## 二、2026 AI 产品设计趋势映射
### 从 7 个趋势看我们应该做什么
| # | 趋势 | 对我们意味着什么 |
|---|------|----------------|
| 1 | **UX → AX (Agentic Experience)** | 工作台需要展示 AI 的**行动计划**和**执行轨迹**,而不是单纯的界面操作 |
| 2 | **Plan-first 交互** | 选题→创作→发布 应该有一个**流程预览**步骤,AI 先展示「我将做 A→B→C」,用户确认后再执行 |
| 3 | **Invisible AI (嵌入式 AI)** | AI 不应该是右下角聊天窗,而应该嵌入到选题列表(智能排序)、文章编辑(实时优化建议)、日历(智能排期)|
| 4 | **Delegation with Control** | 用户需要看到一个「代理仪表盘」— AI 正在做什么、已完成什么、需要审批什么 |
| 5 | **Workspace over Chat** | 从「跟 AI 对话」到「AI 作为团队成员在共享空间工作」|
| 6 | **预览→差异→确认 模式** | 所有 AI 生成/修改的内容,先展示 diff/预览,再确认写入 |
| 7 | **审批路径和可逆性** | 每一步操作都有 undo 路径和操作历史 |
---
## 三、改造方案设计
### 3.1 总体信息架构重构
```
当前: 目标:
┌──────────┐ ┌──────────────────┐
│ 仪表盘 │ ← 系统视角 │ 工作台 (首页) │ ← 用户视角
│ 选题 │ │ ├─ 今日待办 │
│ 数据 │ │ ├─ 创作流水线 │
│ 日历 │ │ ├─ 智能建议 │
│ 素材 │ │ └─ 最近工作 │
│ 任务 │ ├──────────────────┤
│ 系统管理 │ │ 内容工厂 │ ← 创作流程
└──────────┘ │ ├─ 选题工作区 │
│ ├─ AI 创作工作区 │
│ ├─ 发布工作区 │
│ └─ 内容日历 │
├──────────────────┤
│ 数据洞察 │ ← 效果分析
│ ├─ 数据看板 │
│ ├─ 搜索排名 │
│ ├─ GEO 就绪度 │
│ └─ 合规报告 │
├──────────────────┤
│ 资产库 │ ← 资源管理
├──────────────────┤
│ 系统管理 (admin) │ ← 仅管理员
```
### 3.2 工作台首页(全新设计)
**目标**:一打开就知道今天要做什么、进度如何、有什么需要关注
```
┌─────────────────────────────────────────────────┐
│ ☀️ 早上好,张三 你的团队本周已发布 3 篇内容 │
├─────────────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 待处理│ │ 待审查│ │ 待发布│ │ 本月 │ │ 合规 │ │
│ │ 选题 │ │ 文章 │ │ 内容 │ │ 目标 │ │ 健康 │ │
│ │ 12 │ │ 5 │ │ 3 │ │ 67% │ │ 94% │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
├─────────────────────────────────────────────────┤
│ 📋 今日待办 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 🔴 审查「远程办公的 5 个陷阱」- 逾期 2 天 │ │
│ │ 🟡 审核「AI 写作工具实测」- AI 已完稿 │ │
│ │ 🔵 确认本周排期 - 3 篇待定稿 │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ 🤖 AI Agent 工作区 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 📡 热点采集 ✅ 完成 (12 条新趋势) │ │
│ │ ✍️ 文章创作 ⏳ 进行中 (2/5 篇) │ │
│ │ 🔍 合规审查 ⏳ 排队中 (3 篇待审) │ │
│ │ 📊 数据同步 ⏰ 06:00 自动运行 │ │
│ │ │ │
│ │ [查看 Agent 轨迹 →] │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ 💡 AI 智能建议 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 💡 「碳中和」关键词本周搜索量 ↑47% │ │
│ │ 建议创建一篇相关选题 │ │
│ │ [一键创建选题 →] │ │
│ │ 💡 你的知乎上周互动率 ↑12% │ │
│ │ 建议继续保持数据分析深度 │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ 📅 近期计划 │
│ ┌──────────┬──────────────────┬──────────┐ │
│ │ 06/17 二 │ 远程办公工具对比 │ 知乎发布 │ │
│ │ 06/18 三 │ AI 写作体验 │ 公众号 │ │
│ │ 06/19 四 │ 零浪费生活指南 │ 小红书 │ │
│ └──────────┴──────────────────┴──────────┘ │
└─────────────────────────────────────────────────┘
```
### 3.3 内容工厂(创作流水线重构)
**目标**:按创作流程组织,而不是按数据表组织
#### 选题工作区
- **AI 智能选题板**:热点趋势 + 关键词热度 + 历史数据 → 推荐选题
- **选题看板**:Kanban 视图(待处理→分析中→已评分→待创作)
- **批量操作**:AI 一键分析 → 自动评分 → 自动排序
- **卡片预览**:题目的核心观点、受众痛点、差异化切入点和评分一览
#### AI 创作工作区 (NEW)
- **创作任务面板**:显示所有进行中的 AI 创作任务
- **Plan-first 流程**:用户选择选题 → AI 展示大纲 → 用户确认 → AI 逐节写作
```
步骤 1: 选择选题 + 平台
步骤 2: AI 生成大纲 (展示预览)
步骤 3: 用户调整大纲 (拖拽排序/编辑)
步骤 4: AI 逐节写作 (进度条展示)
步骤 5: 合规审查 (展示问题列表)
步骤 6: 用户最终编辑
```
- **Agent 执行轨迹**:每次 AI 操作都有时间线记录
- **在线编辑器**:统一的富文本编辑器,支持实时 AI 优化建议
#### 发布工作区
- **发布检查清单**:AI 自动检查合规、字数、配图、格式
- **一键多平台发布**:选题→三平台各自适配→预览→确认发布
- **发布历史**:时间线视图
### 3.4 AI Agent 工作区(新概念页面)
**这是本次改造的核心创新亮点** — 一个展示 AI 作为「团队成员」工作状况的看板。
```
┌─────────────────────────────────────────────────┐
│ 🤖 AI Agent 工作区 │
├─────────────────────────────────────────────────┤
│ 🔴 活跃任务 │
│ ┌─────────────────────────────────────────────┐ │
│ │ ✍️ 正在创作「远程办公的 5 个陷阱」→ 知乎版 │ │
│ │ 进度: ████████░░ 78% │ │
│ │ 当前步骤: 撰写第 3 节「团队协作挑战」 │ │
│ │ [预览] [暂停] [取消] │ │
│ │ │ │
│ │ 🔍 正在审查「AI 写作工具实测」- 小红书版 │ │
│ │ 进度: ████░░░░░░ 35% │ │
│ │ 发现 2 个合规问题 │ │
│ │ [查看详情] │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ ⏳ 待审批 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 📝 大纲待确认 「零浪费生活指南」 │ │
│ │ 已由 AI 自动生成,等待你审核 │ │
│ │ [查看大纲] [确认] [修改] │ │
│ │ │ │
│ │ 📄 文章待审 「碳中和入门」已完稿 │ │
│ │ 合规评分 92/100,2 条建议 │ │
│ │ [审阅] [通过] [退回修改] │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ ✅ 今日已完成 │
│ ┌─────────────────────────────────────────────┐ │
│ │ ✅ 采集热点 12 条 → 生成 3 个新选题 │ │
│ │ ✅ 同步数据指标 → 知乎互动率 +12% │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### 3.5 数据洞察(统一分析中心)
将当前分散的数据功能整合:
| Tab | 功能 | 来源 |
|-----|------|------|
| 概览 | 总指标 + 趋势图 | 现有 metrics.html |
| 搜索排名 | 关键词排名追踪 | 现 admin.html 搜索排名 |
| GEO 就绪度 | AI 搜索优化评分 | 现 admin.html GEO |
| 合规报告 | 发布前合规检查历史 | 现 admin.html + 优化报告 |
| 平台对比 | 知乎/公众号/小红书数据 | 现有 metrics.html |
### 3.6 导航改造
```javascript
// 现有 navItems (硬编码):
[{dashboard, topics, metrics, calendar, assets, tasks}]
// admin 额外: [admin]
// 目标 navItems (按角色动态):
// 编辑:
[{工作台(top), 内容工厂(factory), 数据洞察(insights), 资产库(assets)}]
// 管理员:
内容工厂 + 数据洞察 + 资产库 + 系统管理(admin)
```
导航从「功能清单」变成「流程引导」:
- **工作台** = 首页(今日待办 + Agent 状态 + 智能建议)
- **内容工厂** = 创作全流程(选题→创作→发布→日历)
- **数据洞察** = 所有数据分析入口
- **资产库** = 素材管理
### 3.7 移动端适配
当前移动端只是隐藏表格换卡片,改造后:
- 底部 Tab 导航栏(代替顶部汉堡菜单)
- 工作台首页简化为:今日待办 + Agent 状态 + 快速操作按钮
- 内容工厂:简易 Kanban,手势左右切换阶段
- AI Agent: 推送通知代替实时面板
---
## 四、实施优先级
### P0 — 快速见效 (1-2 天)
这些改动小但体验提升大:
| 改动 | 文件 | 工作量 |
|------|------|--------|
| 仪表盘改为用户视角(统计卡片 = 待办数量而不是系统模块) | `index.html` | 半天 |
| 将 AI 助手从浮窗升级为半屏面板 | `ai-assistant.js` | 半天 |
| 导航改为新架构(工作台/内容工厂/数据洞察/资产库) | `uni-nav.js` | 半天 |
### P1 — 核心重构 (3-5 天)
| 改动 | 涉及 | 工作量 |
|------|------|--------|
| 内容工厂页面(创作流水线) | 新建 `factory.html` | 2 天 |
| AI Agent 工作区 | 新建 `agent.html` | 2 天 |
| 数据洞察整合(合并 GEO/搜索排名) | 改造 `metrics.html` | 1 天 |
### P2 — 增强功能 (5-7 天)
| 改动 | 工作量 |
|------|--------|
| Plan-first 创作流程(大纲预览→确认→执行) | 3 天 |
| Agent 执行轨迹/审计时间线 | 2 天 |
| 智能选题推荐面板 | 2 天 |
| 移动端全面适配 | 2 天 |
### P3 — AI 原生体验 (后续)
| 改动 | 工作量 |
|------|--------|
| 智能排期 / 内容策略建议 | 3 天 |
| 自动化工作流编排(拖拽编排 Agent 任务) | 5 天 |
| 多 Agent 协作(采集 Agent + 写作 Agent + 审查 Agent | 5 天 |
---
## 五、关键设计原则
1. **用户视角 > 系统视角**
- 展示「我今天要做什么」而不是「系统正在运行什么」
- 统计数字 = 行动信号(待办数量)而不是监控指标
2. **流程 > 功能**
- 按创作流程组织页面(选题→创作→发布→分析)
- 不在功能之间跳转,在流程中前进
3. **AI 是团队成员,不是工具**
- 展示 AI 的工作计划、进度、产出
- 用户是「审核者/决策者」而不是「操作者」
- 核心模式:用 AI → AI 执行 Plan → 用户审核/批准
4. **预览→确认→执行**
- AI 生成的所有内容:先预览/展示差异,再确认
- 所有操作可逆,有撤销路径
5. **极简不极简功能**
- 减少平级菜单数量和页面复杂
- 不减少功能 — 用上下文和流程引导代替功能罗列
---
## 六、与当前改造项目的关系
本次工作台改造与正在进行的数据隔离/权限升级是 **互补关系**:
| 改造线 | 范围 | 依赖 |
|--------|------|------|
| **数据隔离+权限** (当前进行中) | 后端架构、数据安全 | 无 |
| **工作台极简改造** (本方案) | 前端 UI/UX、信息架构 | 权限系统就绪后可基于角色定制页面 |
建议:
1. 先完成数据隔离和权限基础 — 这是安全基石
2. 工作台改造可以在权限未完全就绪时就开始前端改造(不依赖后端)
3. 导航的 role-based 显示可以利用现有的 `/api/menus/active` 接口
+1 -1
View File
@@ -9,7 +9,7 @@ from ..database import get_db
from ..models import User, Topic, SystemConfig
from ..schemas import UserCreate, UserUpdate, UserResponse
from ..core.audit_logger import audit_log
from .auth import get_current_admin, org_filter
from .auth import get_current_admin, require_role, org_filter
import json
router = APIRouter(prefix="/api/admin", tags=["admin"])
+2 -1
View File
@@ -160,7 +160,8 @@ def update_article_content(
if not article:
article = Article(
id=article_id, topic_id=topic_id, platform=platform,
file_path=f"db:{article_id}", status="draft"
file_path=f"db:{article_id}", status="draft",
org_id=topic.org_id
)
db.add(article)
article.html_content = html_content
+10
View File
@@ -160,6 +160,16 @@ def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return current_user
def require_role(required_role: str):
"""依赖项工厂:返回一个 FastAPI 依赖项,要求当前用户拥有指定角色。
管理员自动拥有所有角色权限。"""
def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role != required_role and current_user.role != "admin":
raise HTTPException(status_code=403, detail=f"需要{required_role}角色权限")
return current_user
return role_checker
@router.get("/me")
def get_me(
request: Request,
+12 -2
View File
@@ -97,7 +97,7 @@ def create_entry(
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
entry = ContentCalendar(**data.model_dump())
entry = ContentCalendar(**data.model_dump(), org_id=current_user.org_id or "default")
db.add(entry)
db.commit()
db.refresh(entry)
@@ -119,6 +119,13 @@ def update_entry(
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="日历条目不存在")
# Verify ownership via topic org_id
if entry.topic_id:
topic_check = db.query(Topic).filter(Topic.id == entry.topic_id).first()
if topic_check and current_user.role != "admin" and topic_check.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="日历条目不存在")
if not entry.topic_id and current_user.role != "admin" and entry.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="日历条目不存在")
if entry.topic_id:
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
@@ -156,6 +163,8 @@ def delete_entry(
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="日历条目不存在")
if not entry.topic_id and current_user.role != "admin" and entry.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="日历条目不存在")
db.delete(entry)
db.commit()
return {"ok": True}
@@ -232,7 +241,8 @@ def create_from_topic(
title=topic.title,
planned_date=planned_date,
platform=platform,
created_by=current_user.username
created_by=current_user.username,
org_id=current_user.org_id or "default"
)
db.add(entry)
db.commit()
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Optional
from ..database import get_db
from ..models import CollectorCategory, CollectorSource
from .auth import get_current_admin
from .auth import get_current_admin, require_role
router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)])
+1 -1
View File
@@ -7,7 +7,7 @@ import json
from ..database import get_db
from ..models import KeywordDomainMap, SensitiveWord, ContentCleanRule, TrendFieldMapping, CollectorCategory
from .auth import get_current_admin
from .auth import get_current_admin, require_role
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import List
from ..database import get_db
from ..models import LLMConfig
from ..schemas import LLMConfigBase, LLMConfigResponse
from .auth import get_current_admin
from .auth import get_current_admin, require_role
router = APIRouter(prefix="/api/admin/llmconfigs", tags=["admin"])
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import List
from ..database import get_db
from ..models import Menu, User
from ..schemas import MenuCreate, MenuUpdate, MenuResponse
from .auth import get_current_admin, get_current_user
from .auth import get_current_admin, require_role, get_current_user
router = APIRouter(prefix="/api/admin/menus", tags=["admin"])
public_router = APIRouter(prefix="/api/menus", tags=["menus"])
+2 -1
View File
@@ -179,7 +179,7 @@ def create_metric(
db.refresh(existing)
return existing
metric = ContentMetrics(**data.model_dump(), last_fetched=datetime.now())
metric = ContentMetrics(**data.model_dump(), last_fetched=datetime.now(), org_id=topic.org_id)
db.add(metric)
db.commit()
db.refresh(metric)
@@ -401,6 +401,7 @@ def fetch_zhihu_metrics(
metric = ContentMetrics(
topic_id=data.topic_id,
platform=platform,
org_id=topic.org_id,
**metric_data,
last_fetched=datetime.now()
)
+1 -1
View File
@@ -6,7 +6,7 @@ from datetime import datetime
from ..database import get_db
from ..models import PromptConfig
from .auth import get_current_admin
from .auth import get_current_admin, require_role
router = APIRouter(prefix="/api/admin/prompt-configs", tags=["admin"])
+2 -1
View File
@@ -69,7 +69,8 @@ async def create_publish_record(
action='publish',
status='success',
operator=operator,
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}"
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}",
org_id=topic.org_id
)
db.add(record)
results.append(PublishResult(
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import List
from ..database import get_db
from ..models import Role, User
from ..schemas import RoleCreate, RoleUpdate, RoleResponse
from .auth import get_current_admin
from .auth import get_current_admin, require_role
router = APIRouter(prefix="/api/admin/roles", tags=["admin"])
+1 -1
View File
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
from ..database import get_db
from ..models import SearchProvider
from .auth import get_current_admin
from .auth import get_current_admin, require_role
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/search-providers", tags=["search_providers"])
+12 -2
View File
@@ -86,7 +86,10 @@ def get_geo_overview(
"""GEO 概览 — AI 搜索引用 + 就绪度评分"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
base = db.query(SearchRanking).filter(SearchRanking.checked_at >= cutoff)
# Filter geo checks by org via articles
base = db.query(SearchRanking)
base = _apply_org_filter(base, current_user, db)
base = base.filter(SearchRanking.checked_at >= cutoff)
total_geo = base.filter(SearchRanking.search_engine == "geo").count()
total_cited = base.filter(
SearchRanking.search_engine == "geo",
@@ -138,7 +141,14 @@ def get_geo_readiness_scores(
):
"""获取 GEO 就绪度评分列表"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
scores = db.query(GeoReadinessScore).filter(
# Join via articles to filter by org
scores_query = db.query(GeoReadinessScore).join(
ArticleModel, GeoReadinessScore.article_id == ArticleModel.id, isouter=True
)
of = org_filter(current_user, ArticleModel)
if of is not True:
scores_query = scores_query.filter(of)
scores = scores_query.filter(
GeoReadinessScore.checked_at >= cutoff
).order_by(desc(GeoReadinessScore.checked_at)).limit(limit).all()
return {"ok": True, "data": [s.to_dict() for s in scores]}
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import List, Dict, Any
from ..database import get_db
from ..models import SystemConfig
from ..schemas import SystemConfigBase, SystemConfigResponse
from .auth import get_current_admin
from .auth import get_current_admin, require_role
router = APIRouter(prefix="/api/admin/systemconfigs", tags=["admin"])
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from ..database import get_db
from ..models import TaskConfig, TaskLog
from ..schemas import TaskConfigBase, TaskConfigUpdate, TaskConfigResponse, TaskLogResponse
from .auth import get_current_admin
from .auth import get_current_admin, require_role
router = APIRouter(prefix="/api/admin/task-configs", tags=["admin"])
+8 -3
View File
@@ -16,11 +16,14 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"])
def _check_task_org(task, current_user, db):
"""Check if task's topic belongs to user's org"""
if current_user.role == "admin" or not task.topic_id:
if current_user.role == "admin":
return True
if task.topic_id:
topic = db.query(Topic).filter(Topic.id == task.topic_id).first()
if topic and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="任务不存在")
elif current_user.role != "admin" and task.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="任务不存在")
return True
@@ -87,7 +90,8 @@ def create_task(
topic_id=data.topic_id,
stage=data.stage,
status="pending",
created_by=data.created_by or current_user.username
created_by=data.created_by or current_user.username,
org_id=current_user.org_id or "default"
)
db.add(task)
db.commit()
@@ -438,7 +442,8 @@ def run_creator_task(
stage="creator",
status="running",
started_at=now,
created_by=current_user.username
created_by=current_user.username,
org_id=current_user.org_id or "default"
)
db.add(task)
db.commit()
+5
View File
@@ -126,6 +126,11 @@ def init_db():
("menus", "sort_order", "INTEGER DEFAULT 0"),
("menus", "roles", "JSON DEFAULT '[]'::json"),
("menus", "is_active", "BOOLEAN DEFAULT TRUE"),
("publish_records", "org_id", "VARCHAR DEFAULT 'default'"),
("content_metrics", "org_id", "VARCHAR DEFAULT 'default'"),
("search_rankings", "org_id", "VARCHAR DEFAULT 'default'"),
("geo_readiness_scores", "org_id", "VARCHAR DEFAULT 'default'"),
("articles", "org_id", "VARCHAR DEFAULT 'default'"),
]:
try:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
+10
View File
@@ -280,6 +280,7 @@ class Article(Base):
id = Column(String, primary_key=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
org_id = Column(String, default="default", nullable=True)
platform = Column(String, nullable=False)
file_path = Column(String, nullable=False)
title = Column(String, nullable=True)
@@ -302,6 +303,7 @@ class PublishRecord(Base):
platform = Column(String, nullable=False)
action = Column(String, nullable=False)
status = Column(String, nullable=False)
org_id = Column(String, default="default", nullable=True)
operator = Column(String, nullable=True)
description = Column(Text, nullable=True)
suggestion = Column(Text, nullable=True)
@@ -327,6 +329,7 @@ class ContentCalendar(Base):
reminder_time = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_by = Column(String, nullable=True)
org_id = Column(String, default="default", nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -356,6 +359,7 @@ class ContentMetrics(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=False, index=True)
platform = Column(String, nullable=False)
org_id = Column(String, default="default", nullable=True)
publish_url = Column(String, nullable=True)
views = Column(Integer, default=0)
likes = Column(Integer, default=0)
@@ -381,6 +385,7 @@ class ContentMetrics(Base):
"id": self.id,
"topic_id": self.topic_id,
"platform": self.platform,
"org_id": self.org_id,
"publish_url": self.publish_url,
"views": self.views,
"likes": self.likes,
@@ -502,6 +507,7 @@ class ContentTask(Base):
finished_at = Column(DateTime(timezone=True), nullable=True)
duration = Column(Integer, nullable=True)
created_by = Column(String, nullable=True)
org_id = Column(String, default="default", nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
topic = relationship("Topic")
@@ -867,6 +873,7 @@ class SearchRanking(Base):
article_id = Column(String, ForeignKey("articles.id"), nullable=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
keyword = Column(String, nullable=False, index=True)
org_id = Column(String, default="default", nullable=True)
platform = Column(String, nullable=True)
search_engine = Column(String, default="bing") # bing / baidu / google
position = Column(Integer, nullable=True) # 搜索排名位置(null = 未上榜)
@@ -886,6 +893,7 @@ class SearchRanking(Base):
"article_id": self.article_id,
"topic_id": self.topic_id,
"keyword": self.keyword,
"org_id": self.org_id,
"platform": self.platform,
"search_engine": self.search_engine,
"position": self.position,
@@ -909,6 +917,7 @@ class GeoReadinessScore(Base):
article_id = Column(String, ForeignKey("articles.id"), nullable=True, index=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
platform = Column(String, nullable=True)
org_id = Column(String, default="default", nullable=True)
total_score = Column(Integer, default=0) # 总分 0-100
has_schema = Column(Boolean, default=False) # 是否有结构化数据
schema_types = Column(String, nullable=True) # 含有的 schema 类型列表
@@ -926,6 +935,7 @@ class GeoReadinessScore(Base):
"article_id": self.article_id,
"topic_id": self.topic_id,
"platform": self.platform,
"org_id": self.org_id,
"total_score": self.total_score,
"has_schema": self.has_schema,
"schema_types": self.schema_types,
+6
View File
@@ -151,6 +151,7 @@ class ArticleBase(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
status: str = "draft"
org_id: Optional[str] = None
compliance_score: Optional[int] = None
word_count: Optional[int] = None
outline: Optional[str] = None
@@ -193,6 +194,7 @@ class ContentCalendarUpdate(BaseModel):
class ContentCalendarResponse(ContentCalendarBase):
id: int
org_id: Optional[str] = None
published_date: Optional[date] = None
created_by: Optional[str] = None
created_at: Optional[datetime] = None
@@ -230,6 +232,7 @@ class ContentMetricsUpdate(BaseModel):
class ContentMetricsResponse(ContentMetricsBase):
id: int
org_id: Optional[str] = None
engagement_rate: float = 0
last_fetched: Optional[datetime] = None
created_at: Optional[datetime] = None
@@ -265,6 +268,7 @@ class MediaAssetUpdate(BaseModel):
class MediaAssetResponse(MediaAssetBase):
id: int
org_id: Optional[str] = None
usage_count: int = 0
uploaded_by: Optional[str] = None
created_at: Optional[datetime] = None
@@ -340,6 +344,7 @@ class ContentTaskCreate(BaseModel):
class ContentTaskResponse(ContentTaskBase):
id: int
org_id: Optional[str] = None
task_id: str
topic_id: Optional[str] = None
topic_title: Optional[str] = None
@@ -454,6 +459,7 @@ class PublishRecordResponse(BaseModel):
platform: str
action: str
status: str
org_id: Optional[str] = None
operator: Optional[str] = None
description: Optional[str] = None
suggestion: Optional[str] = None
+201
View File
@@ -35,6 +35,7 @@
<el-button size="default" :type="activeTab === 'searchrankings' ? 'primary' : ''" @click="switchTab('searchrankings')">搜索排名</el-button>
<el-button size="default" :type="activeTab === 'geo' ? 'primary' : ''" @click="switchTab('geo')">GEO 就绪度</el-button>
<el-button size="default" :type="activeTab === 'configitems' ? 'primary' : ''" @click="switchTab('configitems')">配置管理</el-button>
<el-button size="default" :type="activeTab === 'taskmanagement' ? 'primary' : ''" @click="switchTab('taskmanagement')">任务管理</el-button>
</div>
</div>
@@ -816,6 +817,122 @@
</template>
</template>
</div>
<div v-if="activeTab === 'taskmanagement'">
<div class="toolbar">
<span style="font-size:13px;color:#909399;">定时任务配置与运行记录 — 共 {{ taskConfigs.length }} 个模块</span>
</div>
<div v-if="tcLoading" class="card-loading">加载中...</div>
<template v-else-if="taskConfigs.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTimer /></el-icon>
<div class="empty-text">暂无任务配置</div>
</div>
</template>
<template v-else>
<el-table :data="taskConfigs" border stripe style="width:100%" size="small"
@row-click="selectModule">
<el-table-column type="index" label="#" width="50"></el-table-column>
<el-table-column prop="module_id" label="模块ID" min-width="160"></el-table-column>
<el-table-column prop="enabled" label="启用" width="70">
<template #default="{ row }">
<el-switch :model-value="row.enabled" @change="(val) => toggleModule(row, val)" @click.stop
style="--el-switch-on-color:#67c23a;"></el-switch>
</template>
</el-table-column>
<el-table-column label="定时" width="160">
<template #default="{ row }">
<code style="font-size:12px;background:#f4f4f5;padding:2px 6px;border-radius:4px;">{{ row.schedule || '-' }}</code>
</template>
</el-table-column>
<el-table-column label="参数" min-width="180">
<template #default="{ row }">
<span style="font-size:12px;color:#606266;word-break:break-all;">
{{ row.params ? JSON.stringify(row.params).slice(0, 60) + (JSON.stringify(row.params).length > 60 ? '...' : '') : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="上次运行" width="150">
<template #default="{ row }">
<span v-if="row.last_log" style="font-size:12px;">
<el-tag :type="row.last_log.status === 'success' ? 'success' : row.last_log.status === 'failed' ? 'danger' : row.last_log.status === 'running' ? 'warning' : 'info'" size="small" style="margin-right:4px;">
{{ row.last_log.status || '?' }}
</el-tag>
{{ formatDate(row.last_log.started_at) }}
</span>
<span v-else style="color:#c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click.stop="showEditConfig(row)">编辑</el-button>
<el-button size="small" @click.stop="selectModule(row); showHistory = true">历史</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="tcEditDialogVisible">
<el-dialog v-model="tcEditDialogVisible" title="编辑任务配置" width="600px" :close-on-click-modal="false">
<el-form :model="tcEditForm" label-width="100px">
<el-form-item label="模块ID"><el-input v-model="tcEditForm.module_id" disabled/></el-form-item>
<el-form-item label="启用"><el-switch v-model="tcEditForm.enabled"/></el-form-item>
<el-form-item label="定时">
<el-input v-model="tcEditForm.schedule" placeholder="cron 表达式,如 0 30 1 * *"/>
<div style="font-size:12px;color:#909399;margin-top:4px;">格式: 分 时 日 月 周 (5段cron)</div>
</el-form-item>
<el-form-item label="参数">
<el-input type="textarea" v-model="tcEditForm.paramsStr" :rows="5" placeholder="JSON 格式参数"/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="tcEditDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTaskConfig" :loading="tcEditSaving">保存</el-button>
</template>
</el-dialog>
</div>
<div v-if="selectedModule && showHistory" style="margin-top:20px;">
<h4 style="margin:0 0 12px;font-size:14px;color:#303133;display:flex;align-items:center;gap:8px;">
运行记录:{{ selectedModule.module_id }}
<el-tag size="small" type="info" style="margin-left:4px;">最近 {{ taskLogs.length }} 条</el-tag>
<el-button size="small" style="margin-left:auto;" @click="showHistory = false">收起</el-button>
<el-button size="small" @click="loadTaskLogs(selectedModule)" :loading="tlLoading">刷新</el-button>
</h4>
<div v-if="tlLoading" class="card-loading">加载中...</div>
<template v-else-if="taskLogs.length === 0">
<div class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无运行记录</div>
</div>
</template>
<el-timeline v-else>
<el-timeline-item v-for="log in taskLogs" :key="log.id"
:timestamp="formatDate(log.started_at)"
placement="top"
:color="log.status === 'success' ? '#67c23a' : log.status === 'failed' ? '#f56c6c' : log.status === 'running' ? '#e6a23c' : '#c0c4cc'">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<el-tag :type="log.status === 'success' ? 'success' : log.status === 'failed' ? 'danger' : log.status === 'running' ? 'warning' : 'info'" size="small" effect="dark">
{{ log.status }}
</el-tag>
<span v-if="log.task_name" style="font-size:12px;color:#909399;">{{ log.task_name }}</span>
<span v-if="log.duration" style="font-size:12px;color:#909399;">耗时 {{ log.duration }}s</span>
<span v-if="log.triggered_by" style="font-size:12px;color:#909399;">触发: {{ log.triggered_by }}</span>
</div>
<div v-if="log.message" style="margin-top:6px;font-size:13px;color:#303133;background:#f9fafb;padding:8px 12px;border-radius:6px;border:1px solid #ebeef5;white-space:pre-wrap;word-break:break-word;">{{ log.message }}</div>
<div v-if="log.error_trace" style="margin-top:4px;font-size:12px;color:#f56c6c;background:#fef0f0;padding:6px 10px;border-radius:4px;white-space:pre-wrap;word-break:break-word;">{{ log.error_trace }}</div>
<div v-if="log.result_data" style="margin-top:4px;">
<el-button size="small" text @click="showResultData(log)">查看结果数据</el-button>
</div>
</el-timeline-item>
</el-timeline>
</div>
</template>
<el-dialog v-model="resultDialogVisible" title="结果数据" width="600px" :close-on-click-modal="false">
<pre style="max-height:400px;overflow-y:auto;background:#f9fafb;padding:12px;border-radius:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;">{{ resultDialogContent }}</pre>
<template #footer><el-button @click="resultDialogVisible = false">关闭</el-button></template>
</el-dialog>
</div>
</div>
</main>
</div>
@@ -1465,6 +1582,85 @@ const llmConfigs = ref([]);
const 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' }); };
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const taskConfigs = ref([]);
const tcLoading = ref(false);
const selectedModule = ref(null);
const showHistory = ref(false);
const taskLogs = ref([]);
const tlLoading = ref(false);
const loadTaskConfigs = async () => {
tcLoading.value = true;
try {
const data = await api.get('/api/admin/task-configs');
taskConfigs.value = Array.isArray(data) ? data : (data.data || []);
} catch (e) { ElMessage.error('加载任务配置失败: ' + e.message); }
finally { tcLoading.value = false; }
};
const toggleModule = async (row, val) => {
try {
await api.put(`/api/admin/task-configs/${row.module_id}`, { enabled: val });
row.enabled = val;
ElMessage.success(val ? '已启用' : '已停用');
} catch (e) { ElMessage.error('操作失败: ' + e.message); }
};
const tcEditDialogVisible = ref(false);
const tcEditSaving = ref(false);
const tcEditForm = reactive({ module_id: '', enabled: true, schedule: '', paramsStr: '{}' });
const showEditConfig = (row) => {
tcEditForm.module_id = row.module_id;
tcEditForm.enabled = row.enabled;
tcEditForm.schedule = row.schedule || '';
tcEditForm.paramsStr = row.params ? JSON.stringify(row.params, null, 2) : '{}';
tcEditDialogVisible.value = true;
};
const saveTaskConfig = async () => {
tcEditSaving.value = true;
try {
let params;
try { params = JSON.parse(tcEditForm.paramsStr); }
catch (e) { ElMessage.warning('参数格式不是有效的 JSON'); tcEditSaving.value = false; return; }
await api.put(`/api/admin/task-configs/${tcEditForm.module_id}`, {
enabled: tcEditForm.enabled,
schedule: tcEditForm.schedule,
params: params,
});
ElMessage.success('保存成功');
tcEditDialogVisible.value = false;
await loadTaskConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
finally { tcEditSaving.value = false; }
};
const selectModule = (row) => {
selectedModule.value = row;
showHistory.value = true;
loadTaskLogs(row);
};
const loadTaskLogs = async (row) => {
tlLoading.value = true;
try {
const data = await api.get(`/api/admin/task-configs/history/${row.module_id}?limit=30`);
taskLogs.value = Array.isArray(data) ? data : (data.data || []);
} catch (e) { ElMessage.error('加载运行记录失败: ' + e.message); taskLogs.value = []; }
finally { tlLoading.value = false; }
};
const resultDialogVisible = ref(false);
const resultDialogContent = ref('');
const showResultData = (log) => {
try {
const d = typeof log.result_data === 'string' ? log.result_data : JSON.stringify(log.result_data, null, 2);
resultDialogContent.value = d;
} catch (e) { resultDialogContent.value = String(log.result_data || ''); }
resultDialogVisible.value = true;
};
const tabLoaders = {
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers,
@@ -1473,6 +1669,7 @@ const llmConfigs = ref([]);
searchrankings: loadRankings,
geo: loadGeoData,
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
taskmanagement: loadTaskConfigs,
};
const loadedTabs = new Set([]);
@@ -1533,6 +1730,10 @@ const llmConfigs = ref([]);
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
rankings, rankingStats, rankingsLoading, loadRankings,
geoOverview, geoReadinessScores, geoLoading, geoTracking, loadGeoData, triggerGeoTrack,
taskConfigs, tcLoading, selectedModule, showHistory, taskLogs, tlLoading,
loadTaskConfigs, selectModule, toggleModule, loadTaskLogs,
tcEditDialogVisible, tcEditSaving, tcEditForm, showEditConfig, saveTaskConfig,
resultDialogVisible, resultDialogContent, showResultData,
};
}
});
+8 -2
View File
@@ -174,7 +174,9 @@
'topics.html': 'topics', 'metrics.html': 'metrics',
'calendar.html': 'calendar', 'assets.html': 'assets',
'tasks.html': 'tasks', 'platforms.html': 'platforms',
'admin.html': 'admin'
'admin.html': 'admin',
'factory.html': 'factory',
'insights.html': 'insights'
};
var path = window.location.pathname;
var currentPage = pageMap[path] || pageMap['/'];
@@ -188,6 +190,8 @@
'tasks': '⚡ 任务页面跟踪所有创作任务。我可以解释各任务的含义、查看执行进度,或排查任务失败原因。',
'platforms': '🔗 平台配置管理各发布渠道。我可以帮你了解如何配置平台参数、设置发布格式。',
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
'factory': '🏭 这里是内容工厂。我可以帮你了解创作流程,从选题到发布的全链路跟踪,或排查任务失败原因。',
'insights': '📈 数据洞察页面。我可以帮你解读内容表现数据、搜索排名变化趋势,或建议如何提升 GEO 就绪度。',
};
var HISTORY_KEY = 'ai_assistant_history_' + currentPage;
@@ -289,7 +293,9 @@
var resp = await fetch('/api/assistant/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ message: text, page: currentPage, history: history })
var currentUser = null;
try { currentUser = JSON.parse(localStorage.getItem('currentUser')); } catch(e) {}
body: JSON.stringify({ message: text, page: currentPage, history: history, org_id: currentUser?.org_id || 'default' })
});
removeTyping();
if (resp.ok) {
File diff suppressed because it is too large Load Diff
+401 -56
View File
@@ -30,66 +30,274 @@
.plan-item:hover { border-color: var(--color-primary); }
.plan-date { font-size: var(--font-size-caption); color: var(--color-text-secondary); font-weight: 600; min-width: 36px; }
.plan-title { flex: 1; font-size: var(--font-size-body); color: var(--color-text-primary); }
@media (max-width: 768px) { .module-grid { grid-template-columns: 1fr; } }
/* ===== Workspace summary cards ===== */
.workspace-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
.ws-summary-card {
border-radius: var(--radius-lg); padding: var(--spacing-lg); cursor: pointer;
transition: all 0.3s var(--ease-out); position: relative; overflow: hidden;
border: 1px solid transparent;
}
.ws-summary-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.ws-summary-card .ws-icon { font-size: 28px; margin-bottom: var(--spacing-sm); display: block; }
.ws-summary-card .ws-value { font-size: var(--font-size-h1); font-weight: 800; line-height: 1.2; }
.ws-summary-card .ws-label { font-size: var(--font-size-small); margin-top: var(--spacing-xs); opacity: 0.85; }
.ws-summary-card .ws-action { font-size: var(--font-size-caption); margin-top: var(--spacing-sm); opacity: 0.7; display: flex; align-items: center; gap: 4px; }
.ws-summary-card.ws-pending { background: linear-gradient(135deg, #fff7ed, #ffedd5); border-color: #fed7aa; }
.ws-summary-card.ws-pending .ws-value { color: #c2410c; }
.ws-summary-card.ws-review { background: linear-gradient(135deg, #fef2f2, #fee2e2); border-color: #fecaca; }
.ws-summary-card.ws-review .ws-value { color: #dc2626; }
.ws-summary-card.ws-ready { background: linear-gradient(135deg, #ecfdf5, #d1fae5); border-color: #a7f3d0; }
.ws-summary-card.ws-ready .ws-value { color: #059669; }
.ws-summary-card.ws-goal { background: linear-gradient(135deg, #eff6ff, #dbeafe); border-color: #bfdbfe; }
.ws-summary-card.ws-goal .ws-value { color: #2563eb; }
/* ===== Today Todo ===== */
.todo-list { display: flex; flex-direction: column; gap: var(--spacing-sm); }
.todo-item {
display: flex; align-items: flex-start; gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-bg-raised); border: 1px solid var(--color-border);
border-radius: var(--radius-md); transition: all 0.2s; cursor: pointer;
}
.todo-item:hover { border-color: var(--color-primary); box-shadow: var(--shadow-sm); }
.todo-priority { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; }
.todo-priority.high { background: var(--color-danger); box-shadow: 0 0 6px rgba(245,108,108,0.5); }
.todo-priority.medium { background: var(--color-warning); }
.todo-priority.low { background: var(--color-success); }
.todo-overdue { background: #fef2f2; border-color: #fecaca; }
.todo-overdue .todo-priority.high { animation: pulse-glow 2s infinite; }
.todo-content { flex: 1; min-width: 0; }
.todo-title { font-size: var(--font-size-body); font-weight: 600; color: var(--color-text-primary); margin-bottom: 2px; }
.todo-meta { font-size: var(--font-size-small); color: var(--color-text-secondary); }
.todo-tag { font-size: var(--font-size-caption); padding: 2px 8px; border-radius: var(--radius-sm); flex-shrink: 0; }
.todo-tag.overdue { background: #fef2f2; color: #dc2626; }
.todo-tag.priority { background: #fff7ed; color: #c2410c; }
/* ===== AI Suggestions ===== */
.ai-suggestion-card {
background: linear-gradient(135deg, var(--color-bg-raised), var(--color-bg-subtle));
border: 1px solid var(--color-border); border-radius: var(--radius-lg);
padding: var(--spacing-lg); margin-bottom: var(--spacing-sm);
transition: all 0.3s var(--ease-out);
}
.ai-suggestion-card:hover { border-color: var(--color-primary-light); box-shadow: var(--shadow-md); }
.ai-suggestion-header { display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm); }
.ai-suggestion-icon { font-size: 20px; }
.ai-suggestion-title { font-size: var(--font-size-body); font-weight: 600; color: var(--color-text-primary); }
.ai-suggestion-body { font-size: var(--font-size-small); color: var(--color-text-regular); line-height: 1.6; margin-bottom: var(--spacing-sm); }
.ai-suggestion-actions { display: flex; gap: var(--spacing-sm); flex-wrap: wrap; }
/* ===== Admin Section ===== */
.admin-section-toggle {
display: flex; align-items: center; gap: var(--spacing-sm);
cursor: pointer; user-select: none; color: var(--color-text-secondary);
font-size: var(--font-size-small); margin-bottom: var(--spacing-md);
transition: color 0.2s;
}
.admin-section-toggle:hover { color: var(--color-text-regular); }
.admin-section-toggle .toggle-icon { transition: transform 0.3s var(--ease-out); }
.admin-section-toggle.collapsed .toggle-icon { transform: rotate(-90deg); }
/* ===== Welcome Banner ===== */
.welcome-banner {
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
border-radius: var(--radius-lg); padding: var(--spacing-xl) var(--spacing-xl);
color: white; margin-bottom: var(--spacing-xl); position: relative; overflow: hidden;
}
.welcome-banner::before {
content: ''; position: absolute; top: -50%; right: -20%;
width: 300px; height: 300px; border-radius: 50%;
background: rgba(255,255,255,0.08);
}
.welcome-banner::after {
content: ''; position: absolute; bottom: -60%; left: -10%;
width: 250px; height: 250px; border-radius: 50%;
background: rgba(255,255,255,0.05);
}
.welcome-greeting { font-size: var(--font-size-h2); font-weight: 700; margin-bottom: var(--spacing-xs); position: relative; z-index: 1; }
.welcome-sub { font-size: var(--font-size-body); opacity: 0.9; position: relative; z-index: 1; }
/* ===== Section Headers ===== */
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: var(--spacing-md); margin-top: var(--spacing-xl);
}
.section-header:first-of-type { margin-top: 0; }
.section-title {
font-size: var(--font-size-h3); font-weight: 600;
color: var(--color-text-primary); display: flex; align-items: center; gap: var(--spacing-sm);
}
@media (max-width: 768px) {
.module-grid { grid-template-columns: 1fr; }
.workspace-summary { grid-template-columns: repeat(2, 1fr); }
.ws-summary-card { padding: var(--spacing-md); }
.ws-summary-card .ws-value { font-size: 24px; }
.welcome-banner { padding: var(--spacing-lg); }
.welcome-greeting { font-size: var(--font-size-h3); }
}
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
<uni-nav title="宇之然" :username="currentUser.username" :is-admin="isAdmin" current-page="workspace" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content" v-if="isLoggedIn">
<main class="content-area">
<!-- 系统概览页面 -->
<!-- 工作台页面 -->
<div id="page-overview" class="page" :class="{ active: currentPage === 'overview' }">
<h2 style="font-size: var(--font-size-h1); font-weight: 700; margin-bottom: var(--spacing-xl); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 系统概览
</h2>
<!-- Welcome Banner -->
<div class="welcome-banner">
<div class="welcome-greeting">☀️ 早上好,{{ currentUser.username || '创作者' }}</div>
<div class="welcome-sub">
你的团队本月已完成 {{ stats.published }} 篇内容发布
<template v-if="stats.today"> · 今天新增 {{ stats.today }} 个选题</template>
</div>
</div>
<!-- 加载状态 -->
<!-- Loading State -->
<div v-if="loadingStats" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<!-- 空状态 -->
<div v-else-if="!stats.total && !loadingStats" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
<div class="empty-text">暂无数据</div>
<!-- Workspace Content -->
<template v-else>
<!-- Summary Cards Row -->
<div class="workspace-summary">
<div class="ws-summary-card ws-pending" @click="goToTopics('pending')">
<span class="ws-icon">📋</span>
<div class="ws-value">{{ stats.pending }}</div>
<div class="ws-label">待处理选题</div>
<div class="ws-action">点击查看 →</div>
</div>
<!-- 统计卡片 -->
<div v-else class="stats-grid">
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-title">选题总数</div>
<div class="stat-value">{{ stats.total }}</div>
<div class="ws-summary-card ws-review" @click="redirectToPage('/factory.html')">
<span class="ws-icon">🔍</span>
<div class="ws-value">{{ stats.review }}</div>
<div class="ws-label">待审查文章</div>
<div class="ws-action">内容工厂 →</div>
</div>
<div class="stat-card warning" @click="goToTopics('pending')">
<div class="stat-title">待处理</div>
<div class="stat-value">{{ stats.pending }}</div>
<div class="ws-summary-card ws-ready" @click="goToTopics('ready')">
<span class="ws-icon">📦</span>
<div class="ws-value">{{ stats.ready }}</div>
<div class="ws-label">待发布内容</div>
<div class="ws-action">去发布 →</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('today')">
<div class="stat-title">今日新增</div>
<div class="stat-value">{{ stats.today }}</div>
<div class="ws-summary-card ws-goal">
<span class="ws-icon">🎯</span>
<div class="ws-value">{{ monthlyGoal }}</div>
<div class="ws-label">本月目标</div>
<div class="ws-action">{{ monthlyProgress }}% 完成</div>
</div>
</div>
<!-- 模块状态 -->
<h3 style="font-size: var(--font-size-h2); font-weight: 600; margin-bottom: var(--spacing-lg); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 模块状态
<!-- 今日待办 -->
<div class="section-header">
<div class="section-title">
<el-icon><IconList /></el-icon> 今日待办
</div>
<el-button size="small" text type="primary" @click="goToTopics('')">查看全部选题</el-button>
</div>
<div v-if="loadingTodos" style="text-align:center;padding:20px;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
</div>
<div v-else-if="todoTopics.length === 0" class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCheck /></el-icon>
<div class="empty-text">暂无待办事项,太棒了!</div>
</div>
<div v-else class="todo-list">
<div v-for="topic in todoTopics" :key="topic.id"
:class="['todo-item', { 'todo-overdue': isOverdue(topic) }]"
@click="goToTopics('')">
<span class="todo-priority" :class="getPriorityClass(topic)"></span>
<div class="todo-content">
<div class="todo-title">{{ topic.title }}</div>
<div class="todo-meta">{{ getStatusLabel(topic.status) }}
<span v-if="topic.platform_names"> · {{ topic.platform_names }}</span>
</div>
</div>
<el-tag v-if="isOverdue(topic)" type="danger" size="small" class="todo-tag overdue">逾期</el-tag>
<el-tag v-else :type="getPriorityTagType(topic)" size="small" class="todo-tag priority">{{ getPriorityLabel(topic) }}</el-tag>
</div>
</div>
<!-- AI 智能建议 -->
<div class="section-header">
<div class="section-title">
<el-icon><IconLightning /></el-icon> AI 智能建议
</div>
</div>
<div v-if="loadingSuggestions" style="text-align:center;padding:20px;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
</div>
<div v-else>
<div class="ai-suggestion-card">
<div class="ai-suggestion-header">
<span class="ai-suggestion-icon">💡</span>
<span class="ai-suggestion-title">快速创建选题</span>
</div>
<div class="ai-suggestion-body">
根据近期热点趋势,建议创建新的选题来保持内容输出节奏。
</div>
<div class="ai-suggestion-actions">
<el-button size="small" type="primary" @click="redirectToPage('/topics.html?action=create')">
<el-icon style="margin-right:4px;"><IconPlus /></el-icon> 一键创建选题
</el-button>
<el-button size="small" @click="goToTopics('')">浏览现有选题</el-button>
</div>
</div>
<div class="ai-suggestion-card" v-if="topTrendingTopic">
<div class="ai-suggestion-header">
<span class="ai-suggestion-icon">📈</span>
<span class="ai-suggestion-title">热门趋势推荐</span>
</div>
<div class="ai-suggestion-body">
「{{ topTrendingTopic.title }}」是当前高热度选题方向
<span v-if="topTrendingTopic.score">(评分 {{ topTrendingTopic.score }}</span>
建议优先安排创作。
</div>
<div class="ai-suggestion-actions">
<el-button size="small" @click="goToTopics('')">查看详情 →</el-button>
</div>
</div>
</div>
<!-- 近期计划 -->
<div class="section-header">
<div class="section-title">
<el-icon><IconCalendar /></el-icon> 近期计划
</div>
</div>
<div v-if="loadingPlan" style="text-align:center;padding:20px 0;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
<div style="margin-top:8px;">加载中...</div>
</div>
<div v-else-if="upcomingEntries.length === 0" class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">近期暂无计划</div>
</div>
<div v-else class="plan-list">
<div v-for="entry in upcomingEntries" :key="entry.id || entry.date" class="plan-item">
<span class="plan-date">{{ entry.date.slice(5) }}</span>
<span class="plan-title">{{ entry.title }}</span>
<el-tag v-if="entry.status" :type="entry.status === 'published' ? 'success' : 'warning'" size="small">{{ entry.status === 'published' ? '已发布' : '计划' }}</el-tag>
</div>
</div>
<!-- 管理员模块状态 (可折叠) -->
<div v-if="isAdmin" style="margin-top: var(--spacing-xl);">
<div :class="['admin-section-toggle', { collapsed: adminCollapsed }]" @click="adminCollapsed = !adminCollapsed">
<el-icon class="toggle-icon"><IconArrowRight /></el-icon>
<span>系统模块状态(管理员)</span>
</div>
<div v-show="!adminCollapsed">
<h3 style="font-size: var(--font-size-h3); font-weight: 600; margin-bottom: var(--spacing-md); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 定时任务模块
<span style="font-size: var(--font-size-small); font-weight: 400; color: var(--color-text-secondary); margin-left: var(--spacing-sm);">
定时任务:
调度器:
<el-icon v-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
{{ schedulerRunning ? '运行中' : '未启动' }}
@@ -125,26 +333,9 @@
</div>
</div>
</div>
<!-- 近期计划 -->
<h3 style="font-size: var(--font-size-h2); font-weight: 600; margin-bottom: var(--spacing-lg); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 近期计划
</h3>
<div v-if="loadingPlan" style="text-align:center;padding:20px 0;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
<div style="margin-top:8px;">加载中...</div>
</div>
<div v-else-if="upcomingEntries.length === 0" class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">近期暂无计划</div>
</div>
<div v-else class="plan-list">
<div v-for="entry in upcomingEntries" :key="entry.id || entry.date" class="plan-item">
<span class="plan-date">{{ entry.date.slice(5) }}</span>
<span class="plan-title">{{ entry.title }}</span>
<el-tag v-if="entry.status" :type="entry.status === 'published' ? 'success' : 'warning'" size="small">{{ entry.status === 'published' ? '已发布' : '计划' }}</el-tag>
</div>
</div>
</template>
</div>
<!-- 模块详情抽屉 -->
@@ -214,6 +405,8 @@
currentUser: { username: '' },
currentPage: 'overview',
loadingStats: false,
loadingTodos: false,
loadingSuggestions: false,
stats: {
total: 0,
pending: 0,
@@ -222,6 +415,11 @@
published: 0,
today: 0
},
todoTopics: [],
monthlyGoal: 0,
monthlyProgress: 0,
topTrendingTopic: null,
adminCollapsed: true,
modules: [],
schedulerStatus: '',
schedulerRunning: false,
@@ -254,6 +452,10 @@
this.isLoggedIn = true;
this.currentPage = 'overview';
this.fetchStats();
this.fetchModules();
this.fetchUpcomingPlan();
this.fetchTopTopics();
this.computeMonthlyGoal();
} catch (error) {
this.loginError = '用户名或密码错误';
} finally {
@@ -443,6 +645,145 @@
this.$message.success('已保存');
} catch (e) { this.$message.error('保存失败: ' + e.message); }
finally { this.promptSaving = null; }
},
async fetchTodos() {
this.loadingTodos = true;
try {
const token = localStorage.getItem('authToken');
const resp = await fetch('/api/topics?limit=10&sort_by=priority_score&order=desc', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const data = await resp.json();
this.todoTopics = (data || []).filter(t => t.status === 'pending' || t.status === 'review' || t.status === 'ready');
}
} catch (e) {
console.error('获取待办失败:', e);
} finally { this.loadingTodos = false; }
},
async fetchSuggestions() {
this.loadingSuggestions = true;
try {
const token = localStorage.getItem('authToken');
const resp = await fetch('/api/topics?limit=5&sort_by=priority_score&order=desc', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const data = await resp.json();
if (data && data.length > 0) {
this.topTrendingTopic = {
title: data[0].title,
score: data[0].total_score || data[0].priority_score
};
}
}
} catch (e) {
console.error('获取建议失败:', e);
} finally { this.loadingSuggestions = false; }
},
getPriorityClass(topic) {
const p = topic.priority || '';
if (p === '高' || p === 'high' || topic.priority_score >= 80) return 'high';
if (p === '中' || p === 'medium' || topic.priority_score >= 40) return 'medium';
return 'low';
},
getPriorityTagType(topic) {
const cls = this.getPriorityClass(topic);
return cls === 'high' ? 'danger' : cls === 'medium' ? 'warning' : 'info';
},
getPriorityLabel(topic) {
const p = topic.priority || '';
if (p === '高' || p === 'high' || topic.priority_score >= 80) return '高优先级';
if (p === '中' || p === 'medium' || topic.priority_score >= 40) return '中优先级';
return '普通';
},
isOverdue(topic) {
if (!topic.ready_at && !topic.published_at) return false;
const target = new Date(topic.ready_at || topic.published_at);
const today = new Date();
today.setHours(0, 0, 0, 0);
return target < today && topic.status !== 'published';
},
getStatusLabel(status) {
const map = { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布', 'draft': '草稿' };
return map[status] || status;
},
getPriorityClass(topic) {
if (topic.priority_score >= 80) return 'high';
if (topic.priority_score >= 50) return 'medium';
return 'low';
},
getPriorityLabel(topic) {
if (topic.priority_score >= 80) return '高优先级';
if (topic.priority_score >= 50) return '中优先级';
return '低优先级';
},
getPriorityTagType(topic) {
if (topic.priority_score >= 80) return 'danger';
if (topic.priority_score >= 50) return 'warning';
return 'success';
},
getStatusLabel(status) {
const map = {
'pending': '待处理', 'review': '待审查', 'draft': '草稿',
'ready': '待发布', 'published': '已发布'
};
return map[status] || status;
},
isOverdue(topic) {
if (!topic.due_date) return false;
try {
const due = new Date(topic.due_date);
const now = new Date();
now.setHours(23, 59, 59, 999);
return due < now;
} catch (e) { return false; }
},
computeMonthlyGoal() {
try {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const published = this.stats.published || 0;
// Goal: 10 articles/month minimum, shown as percentage
const goal = Math.max(10, published * 1.5);
this.monthlyGoal = Math.round(goal);
this.monthlyProgress = Math.min(100, Math.round((published / goal) * 100));
} catch (e) {
this.monthlyGoal = 10;
this.monthlyProgress = 0;
}
},
async fetchTopTopics() {
this.loadingTodos = true;
this.loadingSuggestions = true;
try {
const token = localStorage.getItem('authToken');
if (!token) return;
// Top priority topics for todo list
const topicsResp = await fetch('/api/topics?limit=10&sort_by=priority_score&order=desc', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (topicsResp.ok) {
const topics = await topicsResp.json();
// Filter to pending/review/ready for todo list
this.todoTopics = (topics || []).filter(t =>
['pending', 'review', 'draft', 'ready'].includes(t.status)
).slice(0, 5);
// Top trending for AI suggestion
if (this.todoTopics.length > 0) {
this.topTrendingTopic = this.todoTopics[0];
}
}
} catch (e) {
console.error('获取待办话题失败:', e);
} finally {
this.loadingTodos = false;
this.loadingSuggestions = false;
}
},
toggleAdminSection() {
this.adminCollapsed = !this.adminCollapsed;
}
},
mounted() {
@@ -463,6 +804,10 @@
this.fetchStats();
this.fetchModules();
this.fetchUpcomingPlan();
this.fetchTopTopics();
this.computeMonthlyGoal();
this.fetchTodos();
this.fetchSuggestions();
})
.catch(() => {
localStorage.removeItem('authToken');
+945
View File
@@ -0,0 +1,945 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 数据洞察</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
/* ===== Insight-specific overrides ===== */
.insight-hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-lg);
padding: 28px 32px;
color: white;
margin-bottom: var(--spacing-lg);
position: relative;
overflow: hidden;
}
.insight-hero::after {
content: '';
position: absolute;
top: -40%;
right: -10%;
width: 300px;
height: 300px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
}
.insight-hero::before {
content: '';
position: absolute;
bottom: -60%;
left: -5%;
width: 250px;
height: 250px;
border-radius: 50%;
background: rgba(255,255,255,0.05);
}
.insight-hero h2 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
position: relative;
z-index: 1;
}
.insight-hero p {
font-size: 13px;
opacity: 0.85;
position: relative;
z-index: 1;
}
.insight-summary {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-top: 20px;
position: relative;
z-index: 1;
}
.insight-summary-item {
display: flex;
flex-direction: column;
min-width: 100px;
}
.insight-summary-item .val {
font-size: 28px;
font-weight: 800;
line-height: 1.1;
}
.insight-summary-item .lbl {
font-size: 12px;
opacity: 0.8;
margin-top: 2px;
}
/* Score bar */
.score-bar-wrap {
width: 100%;
height: 10px;
background: #ebeef5;
border-radius: 5px;
overflow: hidden;
margin-top: 6px;
}
.score-bar {
height: 100%;
border-radius: 5px;
transition: width 0.6s var(--ease-out);
}
.score-bar.green { background: linear-gradient(90deg, #67c23a, #85ce61); }
.score-bar.yellow { background: linear-gradient(90deg, #e6a23c, #f5c543); }
.score-bar.red { background: linear-gradient(90deg, #f56c6c, #f78989); }
/* GEO factor checklist */
.geo-factor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
margin-top: 10px;
}
.geo-factor-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-bg);
border-radius: var(--radius-sm);
font-size: 13px;
}
.geo-factor-item .check {
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.geo-factor-item .check.pass { background: #67c23a; color: white; }
.geo-factor-item .check.fail { background: #f56c6c; color: white; }
/* Platform comparison bars */
.platform-bar-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.platform-bar-label {
width: 80px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-regular);
flex-shrink: 0;
text-align: right;
}
.platform-bar-track {
flex: 1;
height: 26px;
background: #f0f5ff;
border-radius: 6px;
overflow: hidden;
position: relative;
}
.platform-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.8s var(--ease-out);
display: flex;
align-items: center;
padding-left: 10px;
font-size: 12px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.platform-bar-fill.zhihu { background: linear-gradient(90deg, #667eea, #764ba2); }
.platform-bar-fill.wechat { background: linear-gradient(90deg, #07c160, #30b052); }
.platform-bar-fill.xiaohongshu { background: linear-gradient(90deg, #ff2442, #ff6b81); }
.platform-bar-value {
width: 70px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
flex-shrink: 0;
}
/* Engagement bar */
.engagement-bar-wrap {
width: 60px;
height: 6px;
background: #ebeef5;
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
}
.engagement-bar-fill {
height: 100%;
border-radius: 3px;
}
/* Position badge */
.pos-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
}
.pos-badge.top10 { background: #f0f9ff; color: #67c23a; }
.pos-badge.top30 { background: #fdf6ec; color: #e6a23c; }
.pos-badge.low30 { background: #fef0f0; color: #f56c6c; }
/* Tab styling */
.el-tabs__header { margin-bottom: 0; }
.el-tabs__nav-scroll { padding-left: 0; }
/* Sub-score mini card */
.subscore-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.subscore-chip {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-regular);
}
.subscore-chip strong {
color: var(--color-text-primary);
font-weight: 700;
}
@media (max-width: 768px) {
.insight-hero { padding: 20px; }
.insight-hero h2 { font-size: 18px; }
.insight-summary-item .val { font-size: 22px; }
.platform-bar-label { width: 60px; font-size: 12px; }
.platform-bar-value { width: 50px; font-size: 12px; }
}
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="数据洞察" :username="currentUser.username" :is-admin="isAdmin" current-page="insights" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<!-- Hero Header -->
<div class="insight-hero page-fade">
<h2><el-icon style="vertical-align:-2px;"><IconData /></el-icon> 数据洞察</h2>
<p>内容表现 · 搜索排名 · GEO 就绪度 · 平台对比</p>
<div class="insight-summary">
<div class="insight-summary-item">
<span class="val">{{ summary.total_published }}</span>
<span class="lbl">已发布</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.total_views }}</span>
<span class="lbl">总阅读</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.total_likes }}</span>
<span class="lbl">总点赞</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.avg_engagement }}%</span>
<span class="lbl">平均互动率</span>
</div>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" type="border-card" style="background:transparent;box-shadow:none;">
<!-- Tab 1: 内容概览 -->
<el-tab-pane label="内容概览" name="overview">
<div class="page-fade">
<!-- Summary cards -->
<el-row :gutter="16" style="margin-bottom:20px;">
<el-col :xs="12" :sm="8" :md="6" v-for="(item, key) in overviewCards" :key="key">
<el-card shadow="hover" style="margin-bottom:12px;text-align:center;border-radius:var(--radius-md);">
<div style="font-size:28px;font-weight:800;color:{{ item.color }};">{{ item.value }}</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:4px;">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- Top performing topics -->
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconStar /></el-icon> 热门选题 TOP10
</span>
</div>
</template>
<div v-if="topTopics.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconData /></el-icon>
暂无数据
</div>
<div v-else>
<div class="card-table">
<el-table :data="topTopics" stripe size="small">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="阅读" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_views) }}</template>
</el-table-column>
<el-table-column label="点赞" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_likes) }}</template>
</el-table-column>
<el-table-column label="互动率" width="100" align="center">
<template #default="scope">
<div style="display:flex;align-items:center;justify-content:center;gap:4px;">
<div class="engagement-bar-wrap">
<div class="engagement-bar-fill" :style="{width: Math.min(scope.row.engagement_pct || 0, 100)+'%', background: engagementColor(scope.row.engagement_pct)}"></div>
</div>
<span>{{ (scope.row.engagement_pct || 0).toFixed(1) }}%</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="(t, i) in topTopics" :key="t.topic_id" class="card-list-mobile card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:700;color:var(--color-primary);">#{{ i+1 }}</span>
<span style="font-size:13px;color:var(--color-text-secondary);">{{ formatNum(t.total_views) }} 阅读</span>
</div>
<div style="font-weight:600;font-size:14px;margin-bottom:8px;">{{ t.title }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>点赞 {{ formatNum(t.total_likes) }}</span>
<span>互动率 {{ (t.engagement_pct||0).toFixed(1) }}%</span>
</div>
</div>
</div>
</div>
</el-card>
<!-- Recent metrics -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconHistory /></el-icon> 最近数据
</span>
</template>
<div class="card-table">
<el-table :data="recentMetrics" stripe size="small">
<el-table-column label="选题" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.topic_id }}</template>
</el-table-column>
<el-table-column label="平台" width="100">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="阅读" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.views) }}</template>
</el-table-column>
<el-table-column label="点赞" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.likes) }}</template>
</el-table-column>
<el-table-column label="评论" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.comments) }}</template>
</el-table-column>
<el-table-column label="分享" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.shares) }}</template>
</el-table-column>
<el-table-column label="互动率" width="90" align="center">
<template #default="scope">{{ (scope.row.engagement_rate||0).toFixed(1) }}%</template>
</el-table-column>
<el-table-column label="更新时间" width="150">
<template #default="scope">{{ formatDate(scope.row.last_fetched) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="m in recentMetrics" :key="m.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">{{ m.topic_id }} · {{ platformName(m.platform) }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>阅读 {{ formatNum(m.views) }}</span>
<span>点赞 {{ formatNum(m.likes) }}</span>
<span>评论 {{ formatNum(m.comments) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-top:4px;">
<span>分享 {{ formatNum(m.shares) }}</span>
<span>互动率 {{ (m.engagement_rate||0).toFixed(1) }}%</span>
<span>{{ formatDate(m.last_fetched) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 2: 搜索排名 -->
<el-tab-pane label="搜索排名" name="rankings">
<div class="page-fade">
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 关键词排名追踪
</span>
<div style="display:flex;gap:10px;align-items:center;">
<el-select v-model="rankingEngineFilter" placeholder="搜索引擎" size="small" clearable style="width:140px;">
<el-option label="全部" value=""></el-option>
<el-option label="Bing" value="bing"></el-option>
<el-option label="Google" value="google"></el-option>
<el-option label="百度" value="baidu"></el-option>
<el-option label="Geo" value="geo"></el-option>
</el-select>
<el-button size="small" @click="fetchRankings" :loading="loadingRankings">刷新</el-button>
</div>
</div>
</template>
<!-- Overview mini stats -->
<div v-if="rankingStats.total > 0" style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:var(--color-primary);">{{ rankingStats.total }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">总记录</div>
</div>
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:#67c23a;">{{ rankingStats.top10 }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">Top 10</div>
</div>
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:#e6a23c;">{{ rankingStats.aiCited }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">AI 引用</div>
</div>
</div>
<div v-if="loadingRankings" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="rankings.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconSearch /></el-icon>
暂无排名数据
</div>
<div v-else class="card-table">
<el-table :data="filteredRankings" stripe size="small">
<el-table-column label="关键词" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.keyword }}</template>
</el-table-column>
<el-table-column label="引擎" width="80" align="center">
<template #default="scope">
<el-tag size="small" type="info">{{ engineShort(scope.row.search_engine) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="排名" width="80" align="center">
<template #default="scope">
<span v-if="scope.row.position !== null && scope.row.position !== undefined"
class="pos-badge"
:class="scope.row.position <= 10 ? 'top10' : scope.row.position <= 30 ? 'top30' : 'low30'">
#{{ scope.row.position }}
</span>
<span v-else style="color:var(--color-text-secondary);font-size:12px;">-</span>
</template>
</el-table-column>
<el-table-column label="AI 引用" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.ai_cited" size="small" type="success"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="100" show-overflow-tooltip>
<template #default="scope">{{ scope.row.ai_source || '-' }}</template>
</el-table-column>
<el-table-column label="检查日期" width="110">
<template #default="scope">{{ formatDate(scope.row.checked_at) }}</template>
</el-table-column>
</el-table>
</div>
<div v-else class="card-list-mobile">
<div v-for="r in filteredRankings" :key="r.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">{{ r.keyword }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>引擎: {{ engineShort(r.search_engine) }}</span>
<span>日期: {{ formatDate(r.checked_at) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>排名: <span class="pos-badge" :class="r.position<=10?'top10':r.position<=30?'top30':'low30'" v-if="r.position!=null">#{{ r.position }}</span><span v-else>-</span></span>
<span>AI: <el-tag size="small" :type="r.ai_cited?'success':'info'">{{ r.ai_cited?'是':'否' }}</el-tag></span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 3: GEO 就绪度 -->
<el-tab-pane label="GEO 就绪度" name="geo">
<div class="page-fade">
<!-- GEO Overview -->
<el-row :gutter="16" style="margin-bottom:20px;">
<el-col :xs="12" :sm="8" :md="6" v-for="(item, key) in geoSummaryCards" :key="key">
<el-card shadow="hover" style="margin-bottom:12px;text-align:center;border-radius:var(--radius-md);">
<div style="font-size:28px;font-weight:800;color:{{ item.color }};">{{ item.value }}</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:4px;">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- GEO Factors checklist -->
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconCheck /></el-icon> GEO 优化检查清单
</span>
</template>
<div class="geo-factor-grid">
<div class="geo-factor-item" v-for="factor in geoFactors" :key="factor.key">
<span class="check" :class="factor.pass ? 'pass' : 'fail'">{{ factor.pass ? '&#10003;' : '&times;' }}</span>
<span>{{ factor.label }}</span>
</div>
</div>
</el-card>
<!-- GEO Scores table -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconTrendCharts /></el-icon> 文章 GEO 评分
</span>
<el-radio-group v-model="geoSortBy" size="small" @change="fetchGeoScores">
<el-radio-button label="desc">最低优先</el-radio-button>
<el-radio-button label="asc">最高优先</el-radio-button>
</el-radio-group>
</div>
</template>
<div v-if="loadingGeo" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="geoScores.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconTrendCharts /></el-icon>
暂无 GEO 评分数据
</div>
<div v-else class="card-table">
<el-table :data="sortedGeoScores" stripe size="small">
<el-table-column label="文章" min-width="180" show-overflow-tooltip>
<template #default="scope">{{ scope.row.article_id || scope.row.topic_id || '-' }}</template>
</el-table-column>
<el-table-column label="平台" width="80" align="center">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="总分" width="100" align="center">
<template #default="scope">
<span :style="{fontWeight:700,color: scoreColor(scope.row.total_score)}">{{ scope.row.total_score }}/100</span>
</template>
</el-table-column>
<el-table-column label="评分条" width="140">
<template #default="scope">
<div class="score-bar-wrap">
<div class="score-bar" :class="scoreBarClass(scope.row.total_score)" :style="{width: (scope.row.total_score||0)+'%'}"></div>
</div>
</template>
</el-table-column>
<el-table-column label="Schema" width="70" align="center">
<template #default="scope">
<el-tag v-if="scope.row.has_schema" size="small" type="success"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</template>
</el-table-column>
<el-table-column label="可读性" width="90" align="center">
<template #default="scope">
<span>{{ scope.row.readability_score || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="标题结构" width="100" align="center">
<template #default="scope">
<span>{{ scope.row.heading_structure_score || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="检查时间" width="140">
<template #default="scope">{{ formatDate(scope.row.checked_at) }}</template>
</el-table-column>
</el-table>
</div>
<div v-else class="card-list-mobile">
<div v-for="g in sortedGeoScores" :key="g.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:700;font-size:14px;">{{ g.article_id || g.topic_id || '-' }}</span>
<span style="font-weight:800;font-size:16px;" :style="{color: scoreColor(g.total_score)}">{{ g.total_score }}/100</span>
</div>
<div class="score-bar-wrap" style="margin-bottom:8px;">
<div class="score-bar" :class="scoreBarClass(g.total_score)" :style="{width: (g.total_score||0)+'%'}"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);flex-wrap:wrap;gap:4px;">
<span>平台: {{ platformName(g.platform) }}</span>
<span>Schema: {{ g.has_schema?'有':'无' }}</span>
<span>可读性: {{ g.readability_score||0 }}</span>
<span>标题: {{ g.heading_structure_score||0 }}</span>
</div>
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:6px;">{{ formatDate(g.checked_at) }}</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 4: 平台对比 -->
<el-tab-pane label="平台对比" name="comparison">
<div class="page-fade">
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconMonitor /></el-icon> 平台数据对比
</span>
</template>
<div v-if="loadingComparison" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="platformData.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconMonitor /></el-icon>
暂无平台数据
</div>
<div v-else>
<!-- Bar chart comparison -->
<div style="margin-bottom:24px;">
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">阅读对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.total_views, maxViews)}">
{{ formatNum(p.total_views) }}
</div>
</div>
</div>
</div>
<div style="margin-bottom:24px;">
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">点赞对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.total_likes, maxLikes)}">
{{ formatNum(p.total_likes) }}
</div>
</div>
</div>
</div>
<div>
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">文章数对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.count, maxCount)}">
{{ p.count }} 篇
</div>
</div>
</div>
</div>
</div>
</el-card>
<!-- Detailed metrics table -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<span style="font-weight:600;font-size:15px;">详细指标</span>
</template>
<div class="card-table">
<el-table :data="platformData" stripe size="small">
<el-table-column label="平台" width="120">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="文章数" width="80" align="center">
<template #default="scope">{{ scope.row.count }}</template>
</el-table-column>
<el-table-column label="总阅读" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_views) }}</template>
</el-table-column>
<el-table-column label="平均阅读" width="100" align="right">
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
</el-table-column>
<el-table-column label="总点赞" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_likes) }}</template>
</el-table-column>
<el-table-column label="平均点赞" width="100" align="right">
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
</el-table-column>
<el-table-column label="总评论" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_comments || 0) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="p in platformData" :key="p.platform" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:700;font-size:15px;margin-bottom:10px;">{{ platformName(p.platform) }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>文章数</span><span style="font-weight:600;">{{ p.count }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>总阅读</span><span style="font-weight:600;">{{ formatNum(p.total_views) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>平均阅读</span><span style="font-weight:600;">{{ Math.round(p.avg_views||0) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>总点赞</span><span style="font-weight:600;">{{ formatNum(p.total_likes) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>平均点赞</span><span style="font-weight:600;">{{ Math.round(p.avg_likes||0) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const InsightsApp = {
data() {
return {
currentUser: { username: '' },
isAdmin: false,
activeTab: 'overview',
// Data
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
topTopics: [],
recentMetrics: [],
rankings: [],
rankingEngineFilter: '',
loadingRankings: false,
geoScores: [],
loadingGeo: false,
geoSortBy: 'desc',
platformData: [],
loadingComparison: false,
maxViews: 1,
maxLikes: 1,
maxCount: 1,
}
},
computed: {
summary() {
return {
total_published: this.dashboard.total_published || 0,
total_views: this.dashboard.total_views || 0,
total_likes: this.dashboard.total_likes || 0,
avg_engagement: (this.dashboard.avg_engagement_rate || 0).toFixed(1),
};
},
overviewCards() {
const s = this.summary;
return [
{ label: '已发布', value: s.total_published, color: 'var(--color-primary)' },
{ label: '总阅读', value: this.formatNum(s.total_views), color: '#67c23a' },
{ label: '总点赞', value: this.formatNum(s.total_likes), color: '#e6a23c' },
{ label: '平均互动率', value: s.avg_engagement + '%', color: '#f56c6c' },
];
},
filteredRankings() {
if (!this.rankingEngineFilter) return this.rankings;
return this.rankings.filter(r => r.search_engine === this.rankingEngineFilter);
},
rankingStats() {
const data = this.filteredRankings;
return {
total: data.length,
top10: data.filter(r => r.position != null && r.position <= 10).length,
aiCited: data.filter(r => r.ai_cited).length,
};
},
geoSummaryCards() {
const total = this.geoScores.length;
if (total === 0) return [
{ label: '总评分', value: '-', color: 'var(--color-primary)' },
{ label: '平均分', value: '-', color: '#67c23a' },
{ label: '高分(>70)', value: '0', color: '#67c23a' },
{ label: '低分(<40)', value: '0', color: '#f56c6c' },
];
const scores = this.geoScores.map(g => g.total_score || 0);
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const high = scores.filter(s => s > 70).length;
const low = scores.filter(s => s < 40).length;
return [
{ label: '总评分', value: total, color: 'var(--color-primary)' },
{ label: '平均分', value: avg.toFixed(1), color: '#67c23a' },
{ label: '高分(>70)', value: high, color: '#67c23a' },
{ label: '低分(<40)', value: low, color: '#f56c6c' },
];
},
geoFactors() {
if (this.geoScores.length === 0) return [];
const all = this.geoScores;
return [
{ key: 'schema', label: '结构化数据 (Schema)', pass: all.some(g => g.has_schema) },
{ key: 'faq', label: 'FAQ 格式', pass: all.some(g => g.has_faq_format) },
{ key: 'howto', label: 'HowTo 格式', pass: all.some(g => g.has_howto_format) },
{ key: 'citations', label: '数据引用', pass: all.some(g => g.has_citations) },
{ key: 'readability', label: '可读性 > 60', pass: all.some(g => (g.readability_score || 0) > 60) },
{ key: 'headings', label: '标题结构 > 60', pass: all.some(g => (g.heading_structure_score || 0) > 60) },
{ key: 'wordcount', label: '字数 > 1000', pass: all.some(g => (g.word_count || 0) > 1000) },
{ key: 'geoscore', label: 'GEO 总分 > 60', pass: all.some(g => (g.total_score || 0) > 60) },
];
},
sortedGeoScores() {
const arr = [...this.geoScores];
arr.sort((a, b) => this.geoSortBy === 'asc'
? (b.total_score || 0) - (a.total_score || 0)
: (a.total_score || 0) - (b.total_score || 0)
);
return arr;
},
},
methods: {
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.loadAllTabs();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
async loadAllTabs() {
await Promise.all([
this.fetchDashboard(),
this.fetchRankings(),
this.fetchGeoScores(),
this.fetchPlatformData(),
]);
},
// --- Dashboard / Overview ---
async fetchDashboard() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/dashboard?days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.dashboard = await res.json();
this.topTopics = (this.dashboard.top_topics || []).map(t => {
const views = t.total_views || 0;
const likes = t.total_likes || 0;
return { ...t, engagement_pct: views > 0 ? ((likes / views) * 100) : 0 };
});
this.recentMetrics = this.dashboard.recent_metrics || [];
}
} catch (e) { console.error('Dashboard error:', e); }
},
// --- Rankings ---
async fetchRankings() {
this.loadingRankings = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/seo/rankings?limit=200&days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
const json = await res.json();
this.rankings = json.data || json.rankings || [];
}
} catch (e) { console.error('Rankings error:', e); }
finally { this.loadingRankings = false; }
},
// --- GEO ---
async fetchGeoScores() {
this.loadingGeo = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/seo/geo/readiness?limit=200&days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
const json = await res.json();
this.geoScores = json.data || [];
}
} catch (e) { console.error('GEO error:', e); }
finally { this.loadingGeo = false; }
},
// --- Platform Comparison ---
async fetchPlatformData() {
this.loadingComparison = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/by-platform', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.platformData = await res.json();
this.maxViews = Math.max(...this.platformData.map(p => p.total_views || 1), 1);
this.maxLikes = Math.max(...this.platformData.map(p => p.total_likes || 1), 1);
this.maxCount = Math.max(...this.platformData.map(p => p.count || 1), 1);
}
} catch (e) { console.error('Platform data error:', e); }
finally { this.loadingComparison = false; }
},
// --- Helpers ---
formatNum(n) {
if (n == null) return '0';
if (typeof n === 'string') n = parseInt(n) || 0;
if (n >= 10000) return (n / 10000).toFixed(1) + 'w';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
},
formatDate(dt) {
if (!dt) return '-';
try {
const d = new Date(dt);
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
} catch { return dt; }
},
platformName(p) {
return { zhihu: '知乎', wechat: '微信', xiaohongshu: '小红书', default: p }[p] || p;
},
engineShort(e) {
return { bing: 'Bing', google: 'Google', baidu: '百度', geo: 'Geo' }[e] || (e || '-');
},
engagementRate(rate) {
return rate || 0;
},
engagementColor(rate) {
if (rate >= 5) return '#67c23a';
if (rate >= 2) return '#e6a23c';
return '#f56c6c';
},
scoreColor(score) {
if (score >= 70) return '#67c23a';
if (score >= 40) return '#e6a23c';
return '#f56c6c';
},
scoreBarClass(score) {
if (score >= 70) return 'green';
if (score >= 40) return 'yellow';
return 'red';
},
barWidth(value, max) {
if (!max || max === 0) return '0%';
const pct = Math.round((value / max) * 100);
return Math.max(pct, 2) + '%';
},
},
mounted() {
this.checkAuth();
},
};
const app = Vue.createApp(InsightsApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1 -1
View File
@@ -446,7 +446,7 @@
<div>
<h4>支持</h4>
<a href="#faq">常见问题</a>
<a href="https://github.com/your-org/yu-zhi-ran" target="_blank">GitHub</a>
<a href="https://github.com" target="_blank">GitHub</a>
<a href="mailto:hello@yu-zhi-ran.com">联系我们</a>
</div>
<div>
+7 -10
View File
@@ -121,23 +121,20 @@
function getCurrentPage() {
var path = window.location.pathname;
if (path === '/index.html') return 'dashboard';
if (path === '/index.html') return 'workspace';
var m = path.match(/\/(\w+)\.html/);
return m ? m[1] : 'dashboard';
return m ? m[1] : 'workspace';
}
function navItems(isAdmin) {
var items = [
{ key: 'dashboard', label: '仪表盘', page: 'index.html' },
{ key: 'topics', label: '选题', page: 'topics.html' },
{ key: 'metrics', label: '数据', page: 'metrics.html' },
{ key: 'calendar', label: '日历', page: 'calendar.html' },
{ key: 'assets', label: '素材', page: 'assets.html' },
{ key: 'tasks', label: '任务', page: 'tasks.html' },
{ key: 'workspace', label: '工作台', page: 'index.html' },
{ key: 'factory', label: '内容工厂', page: 'factory.html' },
{ key: 'insights', label: '数据洞察', page: 'insights.html' },
{ key: 'assets', label: '资产库', page: 'assets.html' },
];
if (isAdmin) {
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
}
return items;
}
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
采集类别/信息源/案例库 市场热点迁移
基于 2026 Q1-Q2 中文内容平台真实热点数据重构
"""
import sys, json
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
TOKEN = None
BASE = "http://localhost:8001"
def api(method, path, data=None):
import urllib.request, urllib.parse
headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(f"{BASE}{path}", data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
err = e.read().decode()
print(f" ERROR {method} {path}: {e.code} {err[:200]}")
return None
def login():
global TOKEN
import urllib.request
data = json.dumps({"username": "admin", "password": "admin123"}).encode()
req = urllib.request.Request(f"{BASE}/api/auth/login", data=data,
headers={"Content-Type": "application/json"}, method="POST")
resp = json.loads(urllib.request.urlopen(req).read())
TOKEN = resp["token"]
print("✓ Login OK")
def migrate():
print("=" * 60)
print("1. 删除过时采集类别")
print("=" * 60)
old = api("GET", "/api/admin/collector/categories") or []
for cat in old:
if cat["name"] in ("循环消费", "低碳出行", "干净饮食", "零浪费生活",
"绿色家电与节能", "碳普惠", "环保科技产品"):
api("DELETE", f"/api/admin/collector/categories/{cat['id']}")
print(f" ✗ 删除: {cat['name']} (id={cat['id']})")
print("\n" + "=" * 60)
print("2. 新增市场热点类别")
print("=" * 60)
NEW_CATEGORIES = [
{
"name": "AI生活化",
"description": "AI搭子/心理咨询/Vibe Coding/AI人格化 | AI从工具进化为生活搭子,商业笔记互动增长263%",
"search_query": "AI搭子 心理咨询 AI副业 AI人格 人机共生 Vibe Coding Agent 2026",
"sort_order": 1,
"pain_template": "AI工具太多不知道用哪个、担心被AI取代、想要AI融入生活但无从下手、渴望AI情感陪伴",
},
{
"name": "自我丰容",
"description": "打破惯性/精神避难所/工位改造 | #人你该丰容了浏览10亿+, #家的丰容计划浏览8亿+",
"search_query": "自我丰容 精神避难所 工位改造 家的丰容计划 独居 打破惯性 2026",
"sort_order": 2,
"pain_template": "生活陷入工位-食堂-床头循环、缺乏新鲜感、想要改变但不知从何下手、精神内耗严重",
},
{
"name": "适我主义",
"description": "适老/适懒/适宠/独居放飞 | 小红书2026年度居住趋势, 浏览20.4亿",
"search_query": "适我主义 适老化 适懒化 适宠化 独居 放飞 去家务化 动线 2026",
"sort_order": 3,
"pain_template": "房子不是为自己设计的、家务负担重、宠物/老人需求被忽视、独居生活单调",
},
{
"name": "情绪价值与心理健康",
"description": "边界感/柔软力/心理咨询/AI陪伴 | AI心理咨询HBR排名第一使用场景",
"search_query": "情绪价值 边界感 心理健康 心理咨询 焦虑管理 柔软力 反精致 2026",
"sort_order": 4,
"pain_template": "焦虑内耗无法排解、人际关系边界模糊、情绪得不到理解、渴望陪伴但社交疲惫",
},
{
"name": "她力量与主体性",
"description": "女性觉醒/悦己/主体性/爱自己 | #认知觉醒浏览16亿, #sheisblooming 7100万",
"search_query": "她力量 女性觉醒 主体性 悦己 爱你老己 上桌吃饭 独立女性 2026",
"sort_order": 5,
"pain_template": "被困在他人期待中、不敢为自己而活、职场/家庭双重压力、缺乏自我认同",
},
{
"name": "沉浸式新生活",
"description": "代入感/鸟门/文化力/体验式消费 | #观鸟浏览1.2亿, 商业笔记互动增长104%",
"search_query": "观鸟 代入感 沉浸式 文化力 文创 第一视角 体验式消费 2026",
"sort_order": 6,
"pain_template": "城市生活缺乏自然连接、精神空虚想找出口、日常单调需要新鲜体验",
},
{
"name": "科技人文",
"description": "AI与人的关系/数字伦理/技术温度 | AI从智能到共生,重塑人与技术的关系",
"search_query": "科技人文 AI伦理 数字生活 人与AI 科技温度 数字素养 2026",
"sort_order": 7,
"pain_template": "技术发展太快感到焦虑、担心失去人性连接、想理解AI但怕被取代",
},
{
"name": "数字游民与自由职业",
"description": "远程工作/一人企业/跨境机会/独立开发 | 全球35国数字游民签证,中国适用18国",
"search_query": "数字游民 自由职业 远程工作 一人企业 独立开发 跨境远程 2026",
"sort_order": 8,
"pain_template": "不想被办公室束缚、想尝试自由职业但不确定如何开始、找不到远程工作机会",
},
]
for cat in NEW_CATEGORIES:
result = api("POST", "/api/admin/collector/categories", cat)
if result:
print(f" ✓ 新增: {cat['name']} (id={result.get('id')})")
else:
# 可能已存在(重名检测)
print(f" ? {cat['name']}: 可能已存在")
print("\n" + "=" * 60)
print("3. 更新信息源")
print("=" * 60)
# 先删旧源(删除所有旧搜索源,保留RSS源)
old_sources = api("GET", "/api/admin/collector/sources") or []
for src in old_sources:
if src["source_type"] == "web_search":
api("DELETE", f"/api/admin/collector/sources/{src['id']}")
print(f" ✗ 删除搜索源: {src['name']}")
# 追加新的搜索源
NEW_SOURCES = [
{"name": "AI生活化搜索", "source_type": "web_search",
"query": "AI搭子 AI副业 Vibe Coding AI人格 人机共生 AI心理咨询 2026",
"credibility": "medium", "focus": "AI生活化", "sort_order": 10},
{"name": "自我丰容搜索", "source_type": "web_search",
"query": "自我丰容 精神避难所 家的丰容 独居生活 打破惯性 2026",
"credibility": "medium", "focus": "自我丰容", "sort_order": 11},
{"name": "适我主义搜索", "source_type": "web_search",
"query": "适我主义 适老化改造 适懒化 适宠化 独居 放飞 2026",
"credibility": "medium", "focus": "适我主义", "sort_order": 12},
{"name": "情绪价值搜索", "source_type": "web_search",
"query": "情绪价值 边界感 心理健康 焦虑管理 柔软力 心理咨询 2026",
"credibility": "medium", "focus": "情绪价值", "sort_order": 13},
{"name": "她力量搜索", "source_type": "web_search",
"query": "她力量 女性觉醒 主体性 悦己 独立女性 爱你老己 2026",
"credibility": "medium", "focus": "她力量", "sort_order": 14},
{"name": "沉浸式生活搜索", "source_type": "web_search",
"query": "观鸟 沉浸式体验 文化力 文创 代入感 体验消费 2026",
"credibility": "medium", "focus": "沉浸式生活", "sort_order": 15},
{"name": "科技人文搜索", "source_type": "web_search",
"query": "科技人文 AI伦理 数字生活 人与AI 科技温度 2026",
"credibility": "medium", "focus": "科技人文", "sort_order": 16},
{"name": "数字游民搜索", "source_type": "web_search",
"query": "数字游民 自由职业 远程工作 一人企业 独立开发 数字游民签证 2026",
"credibility": "medium", "focus": "数字游民", "sort_order": 17},
]
for src in NEW_SOURCES:
result = api("POST", "/api/admin/collector/sources", src)
if result:
print(f" ✓ 新增搜索源: {src['name']} (id={result.get('id')})")
print("\n" + "=" * 60)
print("4. 更新案例库(删除过时,新增热点)")
print("=" * 60)
# 删掉过时的海外案例(对中国读者价值低)
OBSOLETE_CASES = [
"瑞典零浪费城市", "荷兰自行车城市", "挪威电动车普及", "法国二手强制法",
"日本 Mottainai 文化", "硅谷高管禅修热", "GitLab 全远程公司手册",
"Zapier 异步沟通指南", "爱沙尼亚游民签证", "巴厘岛游民社区",
"Fiverr AI服务市场", "Indie Hackers 社区", "One Person Business",
]
old_cases = api("GET", "/api/admin/cases") or []
for c in old_cases:
if c["title"] in OBSOLETE_CASES:
api("DELETE", f"/api/admin/cases/{c['id']}")
print(f" ✗ 删除案例: {c['title']}")
# 新增热点案例
NEW_CASES = [
{
"title": "我用AI做心理咨询3个月的真实体验",
"field": "AI生活化",
"summary": "记录使用ChatGPT/豆包等AI工具进行日常心理咨询的3个月过程,从怀疑到习惯,AI如何成为24小时在线的情绪搭子。AI在情感支持场景的渗透率年增200%+。",
"key_metrics": "AI心理咨询HBR排名#1使用场景, AI商业笔记互动增长263%, 用户满意度4.6/5",
"date": "2026",
"source": "哈佛商业评论AI使用调研",
"source_url": "https://hbr.org/",
"credibility_rating": "⭐⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "上班族自我丰容:工位改造成精神避难所",
"field": "自我丰容",
"summary": "一位互联网大厂员工分享如何用500元预算把工位改造成个人精神避难所,包括植物角、香薰区、灵感墙。带动#家的丰容计划 话题浏览超8亿。",
"key_metrics": "#人你该丰容了 浏览10亿+, 工位改造相关笔记增长300%",
"date": "2026",
"source": "小红书家居薯",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "90后把家改造成宠物乐园:适宠化装修实录",
"field": "适我主义",
"summary": "一位90后屋主分享全屋适宠化改造:猫咪空中走廊、防抓沙发、隐藏猫砂柜。适宠化搜索增长680%,成为家居改造新热点。",
"key_metrics": "适宠化搜索增长680%, 适我主义话题浏览20.4亿",
"date": "2026",
"source": "小红书家居薯年度报告",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "我的AI副业月入过万:5个用API搭的小工具",
"field": "AI生活化",
"summary": "独立开发者分享如何用国产大模型API快速开发5款付费AI小工具,从选题到上线平均3天/个,实现月收入12000+。",
"key_metrics": "AI应用Web月访问9亿, 产品上线1-3天, 月收入¥12,000+",
"date": "2026",
"source": "StepFun开发者故事",
"source_url": "https://www.stepfun.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "30岁裸辞做数字游民:一年走过12国的真实账单",
"field": "数字游民与自由职业",
"summary": "前大厂产品经理裸辞成为数字游民,分享一年的收入来源(远程设计+自媒体)、各国生活成本对比、以及孤独感的应对方式。全球35国数字游民签证,中国适用18国。",
"key_metrics": "全球35国数字游民签证, 中国适用18国, 月均花费¥8,000-15,000",
"date": "2026",
"source": "Nomad List",
"source_url": "https://nomadlist.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐",
},
{
"title": "适老化改造救了我的婚姻:和爸妈同住的第三年",
"field": "适我主义",
"summary": "记录和父母同住三年间对房子进行的适老化改造:防滑地板、扶手动线、智能呼救、适老厨房。适老化搜索增长149%,居家养老从「基础安全」走向「身心适配」。",
"key_metrics": "适老化搜索增长149%, 居家养老关注度翻倍",
"date": "2026",
"source": "小红书年度居住趋势报告",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "不上班但从没停止工作:自由职业5年的财务模型",
"field": "数字游民与自由职业",
"summary": "一位自由职业5年的设计师分享收入结构(稳定客户70%+项目制20%+被动收入10%)、社保方案、客户获取渠道、以及「自由但不轻松」的真实状态。2026年中国灵活就业者已超2亿。",
"key_metrics": "中国灵活就业者超2亿, 自由职业满意度7.2/10, 平均收入转型期6-12月",
"date": "2026",
"source": "中国灵活就业发展报告",
"source_url": "https://www.stats.gov.cn/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "一年不和闺蜜吵架的秘诀:建立关系边界感",
"field": "情绪价值与心理健康",
"summary": "一位30+女性分享如何通过设立边界感改善友谊质量:学会说「不」、拒绝情感绑架、保护个人空间。边界感成为2026年最受关注的人际关系话题。",
"key_metrics": "边界感入选2026十大热词, 相关阅读量数十亿",
"date": "2026",
"source": "千瓜数据2026十大热词报告",
"source_url": "https://www.qian-gua.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "我在阳台养了一只鸟:城市观鸟的治愈力",
"field": "沉浸式新生活",
"summary": "一位在上海租房住的年轻人分享如何通过阳台观鸟找回生活实感。小红书#观鸟 近90天浏览1.2亿+,笔记增长70%+。观鸟成为年轻人追捧的新户外方式。",
"key_metrics": "#观鸟浏览1.2亿+, 笔记数增长70%+, 互动超25万的爆款视频",
"date": "2026",
"source": "小红书热点报告",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "从「上桌吃饭」到「爱你老己」:我的主体性觉醒之路",
"field": "她力量与主体性",
"summary": "一位女性的自述:从讨好型人格到学会爱自己,经历了辞职、独居、心理咨询、建立支持网络。#认知觉醒浏览16亿,主体性成为女性最重要的精神课题。",
"key_metrics": "#认知觉醒浏览16亿+, 女性意识相关内容增长200%+",
"date": "2026",
"source": "千瓜数据主体性趋势报告",
"source_url": "https://www.qian-gua.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
]
for case in NEW_CASES:
result = api("POST", "/api/admin/cases", case)
if result:
print(f" ✓ 新增案例: {case['title'][:20]}... (id={result.get('id')})")
else:
print(f" ? {case['title'][:20]}... 可能已存在")
print("\n" + "=" * 60)
print("迁移完成!")
print("=" * 60)
if __name__ == "__main__":
login()
migrate()
+48 -5
View File
@@ -42,7 +42,7 @@ def get_published_articles() -> List[Dict]:
"title": article.title or topic.title,
"status": article.status,
"topic_title": topic.title,
"field": topic.field or "",
"field": topic.field_name or "",
})
return articles
finally:
@@ -67,7 +67,7 @@ def _build_search_queries(article: Dict) -> List[str]:
if 4 <= len(p) <= 25:
queries.append(p)
if field:
if field and len(field) >= 4:
queries.append(field[:20])
return list(set(q for q in queries if len(q) >= 4))[:5]
@@ -101,8 +101,47 @@ def _generate_keywords_for_article(article: Dict) -> List[str]:
return []
def _call_llm(prompt: str, max_tokens: int = 1000) -> str:
try:
from app.core.nvidia_client import call_llm
return call_llm(prompt, temperature=0.3, max_tokens=max_tokens) or ""
except Exception as e:
logger.warning(f"LLM 调用失败: {e}")
return ""
def _check_ai_citation(article: Dict, keyword: str) -> Dict:
title = article.get("title") or article.get("topic_title", "")
prompt = f"""你是一个 AI 搜索结果评估器。请判断以下文章是否可能被 AI 搜索引擎(如 Bing AI、百度 AI 搜索、Perplexity)引用。
文章标题:{title}
搜索关键词:{keyword}
请用以下格式返回:
被引用:是/否
引擎:Bing AI, 百度 AI 搜索, Perplexity(逗号分隔,若被引用)
证据:简短的屏幕抓取或推理依据
只返回以上三行,不要多余内容。"""
response = _call_llm(prompt, max_tokens=500)
cited = False
ai_sources = []
if response:
cited = "" in response.split("\n")[0] if "\n" in response else "" in response
if cited:
for line in response.split("\n"):
if line.startswith("引擎"):
sources = [s.strip() for s in line.replace("引擎:", "").replace("引擎:", "").split(",") if s.strip()]
ai_sources = sources
return {
"ai_cited": cited,
"ai_source": ",".join(ai_sources) if ai_sources else None,
"citation_snippet": response[:300] if cited else None,
}
def check_rankings(article: Dict, keywords: List[str], engine: str = "bing") -> List[Dict]:
"""检查文章关键词在搜索引擎的排名"""
"""检查文章关键词在搜索引擎的排名,并检测 AI 搜索引用"""
title = article.get("title") or article.get("topic_title", "")
article_id = article.get("id", "")
topic_id = article.get("topic_id", "")
@@ -122,6 +161,10 @@ def check_rankings(article: Dict, keywords: List[str], engine: str = "bing") ->
url_found = url[:200]
break
ai_info = _check_ai_citation(article, keyword)
if ai_info.get("ai_cited"):
logger.info(f" [AI] '{keyword}' → 被 AI 搜索引用 ({ai_info.get('ai_source', '未知')})")
results.append({
"article_id": article_id,
"topic_id": topic_id,
@@ -130,8 +173,8 @@ def check_rankings(article: Dict, keywords: List[str], engine: str = "bing") ->
"search_engine": engine,
"position": position,
"url_found": url_found,
"ai_cited": False,
"ai_source": None,
"ai_cited": ai_info.get("ai_cited", False),
"ai_source": ai_info.get("ai_source"),
})
logger.info(f" [{engine}] '{keyword}'{'#' + str(position) if position else '未上榜'}"