Compare commits
10 Commits
c8a5e8fd87
...
81f89f6f00
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f89f6f00 | |||
| a692f1dd44 | |||
| 4b87125683 | |||
| 5a19e77d27 | |||
| d3ea832b3b | |||
| 1ff015e281 | |||
| f6e5e90736 | |||
| 5caf7bc52e | |||
| dcfeccc97b | |||
| 00431c827b |
@@ -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
@@ -3,7 +3,7 @@
|
|||||||
> 本文件为项目进度唯一真理源,所有进度信息以此为准。
|
> 本文件为项目进度唯一真理源,所有进度信息以此为准。
|
||||||
> 其他文档中的进度描述一律以本文为准。
|
> 其他文档中的进度描述一律以本文为准。
|
||||||
|
|
||||||
**最后更新**:2026-06-16 (v22)
|
**最后更新**:2026-06-17 (v23)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
| M1 | 项目框架搭建完成 | 2026-04-10 | 2026-04-10 | ✅ |
|
| M1 | 项目框架搭建完成 | 2026-04-10 | 2026-04-10 | ✅ |
|
||||||
| M2 | 首批选题确定 | 2026-04-12 | 2026-04-10 | ✅ |
|
| M2 | 首批选题确定 | 2026-04-12 | 2026-04-10 | ✅ |
|
||||||
| M3 | 首篇文章发布 | 2026-04-14 | 2026-04-14 | ✅ |
|
| 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 |
|
| 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 数据引用要求 |
|
| 大纲提示词 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` API;admin.html 新增"搜索排名"tab |
|
| 搜索排名追踪 | 2026-06-09 | `SearchRanking` 模型(文章ID/关键词/排名位置/AI引用);`rank_tracker.py` 定时任务(每日 07:00)查 Bing 排名;`/api/seo/rankings` API;admin.html 新增"搜索排名"tab |
|
||||||
|
| 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 PNG,62KB);之前 404 |
|
||||||
| 生成式 AI 引用检测(Perplexity/Bing AI/百度 AI 搜索) | 中 | rank_tracker 的 `ai_cited` 检测需具体实现 |
|
| Landing 页 GitHub 链接修正 | 2026-06-18 | `your-org` → `github.com` |
|
||||||
| 百度搜索排名接入(需百度站长 API) | 中 | 当前仅 Bing,国内百度覆盖更广 |
|
| v23 全部功能验收 | 2026-06-18 | 12 页 200 OK、org_id 隔离生效、RBAC(require_role)11路由、菜单7项正确、factory/insights 新页、dashboard 重设计 |
|
||||||
| admin.html 任务管理 tab(TaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI |
|
|
||||||
| M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 |
|
|
||||||
| 现有文章重新创作(清除 AI 思考内容) | 中 | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py |
|
|
||||||
| 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -383,7 +379,82 @@ admin.html 新增「GEO 就绪度」标签页包含:
|
|||||||
- 登录流程验证:登录后跳转 `/index.html`
|
- 登录流程验证:登录后跳转 `/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**:
|
||||||
|
- `/` → 200(landing.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_id(941 行)|
|
||||||
|
| `platform/backend/app/database.py` | ALTER TABLE 迁移逻辑(170 行)|
|
||||||
|
| `platform/backend/app/api/auth.py` | org_filter + require_role(196 行)|
|
||||||
|
| `platform/backend/app/schemas.py` | 6 个响应 schema 追加 org_id(637 行)|
|
||||||
|
| `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` 接口
|
||||||
@@ -9,7 +9,7 @@ from ..database import get_db
|
|||||||
from ..models import User, Topic, SystemConfig
|
from ..models import User, Topic, SystemConfig
|
||||||
from ..schemas import UserCreate, UserUpdate, UserResponse
|
from ..schemas import UserCreate, UserUpdate, UserResponse
|
||||||
from ..core.audit_logger import audit_log
|
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
|
import json
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ def update_article_content(
|
|||||||
if not article:
|
if not article:
|
||||||
article = Article(
|
article = Article(
|
||||||
id=article_id, topic_id=topic_id, platform=platform,
|
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)
|
db.add(article)
|
||||||
article.html_content = html_content
|
article.html_content = html_content
|
||||||
|
|||||||
@@ -160,6 +160,16 @@ def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
|||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||||
return current_user
|
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")
|
@router.get("/me")
|
||||||
def get_me(
|
def get_me(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ def create_entry(
|
|||||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||||
raise HTTPException(status_code=404, detail="选题不存在")
|
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.add(entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(entry)
|
db.refresh(entry)
|
||||||
@@ -119,6 +119,13 @@ def update_entry(
|
|||||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
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:
|
if entry.topic_id:
|
||||||
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
|
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()
|
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:
|
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
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.delete(entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -232,7 +241,8 @@ def create_from_topic(
|
|||||||
title=topic.title,
|
title=topic.title,
|
||||||
planned_date=planned_date,
|
planned_date=planned_date,
|
||||||
platform=platform,
|
platform=platform,
|
||||||
created_by=current_user.username
|
created_by=current_user.username,
|
||||||
|
org_id=current_user.org_id or "default"
|
||||||
)
|
)
|
||||||
db.add(entry)
|
db.add(entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import CollectorCategory, CollectorSource
|
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)])
|
router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)])
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import json
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import KeywordDomainMap, SensitiveWord, ContentCleanRule, TrendFieldMapping, CollectorCategory
|
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"])
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import List
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import LLMConfig
|
from ..models import LLMConfig
|
||||||
from ..schemas import LLMConfigBase, LLMConfigResponse
|
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"])
|
router = APIRouter(prefix="/api/admin/llmconfigs", tags=["admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import List
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Menu, User
|
from ..models import Menu, User
|
||||||
from ..schemas import MenuCreate, MenuUpdate, MenuResponse
|
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"])
|
router = APIRouter(prefix="/api/admin/menus", tags=["admin"])
|
||||||
public_router = APIRouter(prefix="/api/menus", tags=["menus"])
|
public_router = APIRouter(prefix="/api/menus", tags=["menus"])
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ def create_metric(
|
|||||||
db.refresh(existing)
|
db.refresh(existing)
|
||||||
return 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.add(metric)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(metric)
|
db.refresh(metric)
|
||||||
@@ -401,6 +401,7 @@ def fetch_zhihu_metrics(
|
|||||||
metric = ContentMetrics(
|
metric = ContentMetrics(
|
||||||
topic_id=data.topic_id,
|
topic_id=data.topic_id,
|
||||||
platform=platform,
|
platform=platform,
|
||||||
|
org_id=topic.org_id,
|
||||||
**metric_data,
|
**metric_data,
|
||||||
last_fetched=datetime.now()
|
last_fetched=datetime.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import PromptConfig
|
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"])
|
router = APIRouter(prefix="/api/admin/prompt-configs", tags=["admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ async def create_publish_record(
|
|||||||
action='publish',
|
action='publish',
|
||||||
status='success',
|
status='success',
|
||||||
operator=operator,
|
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)
|
db.add(record)
|
||||||
results.append(PublishResult(
|
results.append(PublishResult(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import List
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Role, User
|
from ..models import Role, User
|
||||||
from ..schemas import RoleCreate, RoleUpdate, RoleResponse
|
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"])
|
router = APIRouter(prefix="/api/admin/roles", tags=["admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import SearchProvider
|
from ..models import SearchProvider
|
||||||
from .auth import get_current_admin
|
from .auth import get_current_admin, require_role
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/search-providers", tags=["search_providers"])
|
router = APIRouter(prefix="/api/search-providers", tags=["search_providers"])
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ def get_geo_overview(
|
|||||||
"""GEO 概览 — AI 搜索引用 + 就绪度评分"""
|
"""GEO 概览 — AI 搜索引用 + 就绪度评分"""
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
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_geo = base.filter(SearchRanking.search_engine == "geo").count()
|
||||||
total_cited = base.filter(
|
total_cited = base.filter(
|
||||||
SearchRanking.search_engine == "geo",
|
SearchRanking.search_engine == "geo",
|
||||||
@@ -138,7 +141,14 @@ def get_geo_readiness_scores(
|
|||||||
):
|
):
|
||||||
"""获取 GEO 就绪度评分列表"""
|
"""获取 GEO 就绪度评分列表"""
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
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
|
GeoReadinessScore.checked_at >= cutoff
|
||||||
).order_by(desc(GeoReadinessScore.checked_at)).limit(limit).all()
|
).order_by(desc(GeoReadinessScore.checked_at)).limit(limit).all()
|
||||||
return {"ok": True, "data": [s.to_dict() for s in scores]}
|
return {"ok": True, "data": [s.to_dict() for s in scores]}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import List, Dict, Any
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import SystemConfig
|
from ..models import SystemConfig
|
||||||
from ..schemas import SystemConfigBase, SystemConfigResponse
|
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"])
|
router = APIRouter(prefix="/api/admin/systemconfigs", tags=["admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import List, Optional
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import TaskConfig, TaskLog
|
from ..models import TaskConfig, TaskLog
|
||||||
from ..schemas import TaskConfigBase, TaskConfigUpdate, TaskConfigResponse, TaskLogResponse
|
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"])
|
router = APIRouter(prefix="/api/admin/task-configs", tags=["admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
|||||||
|
|
||||||
def _check_task_org(task, current_user, db):
|
def _check_task_org(task, current_user, db):
|
||||||
"""Check if task's topic belongs to user's org"""
|
"""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
|
return True
|
||||||
|
if task.topic_id:
|
||||||
topic = db.query(Topic).filter(Topic.id == task.topic_id).first()
|
topic = db.query(Topic).filter(Topic.id == task.topic_id).first()
|
||||||
if topic and topic.org_id != current_user.org_id:
|
if topic and topic.org_id != current_user.org_id:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +90,8 @@ def create_task(
|
|||||||
topic_id=data.topic_id,
|
topic_id=data.topic_id,
|
||||||
stage=data.stage,
|
stage=data.stage,
|
||||||
status="pending",
|
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.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -438,7 +442,8 @@ def run_creator_task(
|
|||||||
stage="creator",
|
stage="creator",
|
||||||
status="running",
|
status="running",
|
||||||
started_at=now,
|
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.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ def init_db():
|
|||||||
("menus", "sort_order", "INTEGER DEFAULT 0"),
|
("menus", "sort_order", "INTEGER DEFAULT 0"),
|
||||||
("menus", "roles", "JSON DEFAULT '[]'::json"),
|
("menus", "roles", "JSON DEFAULT '[]'::json"),
|
||||||
("menus", "is_active", "BOOLEAN DEFAULT TRUE"),
|
("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:
|
try:
|
||||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ class Article(Base):
|
|||||||
|
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True)
|
||||||
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
|
||||||
|
org_id = Column(String, default="default", nullable=True)
|
||||||
platform = Column(String, nullable=False)
|
platform = Column(String, nullable=False)
|
||||||
file_path = Column(String, nullable=False)
|
file_path = Column(String, nullable=False)
|
||||||
title = Column(String, nullable=True)
|
title = Column(String, nullable=True)
|
||||||
@@ -302,6 +303,7 @@ class PublishRecord(Base):
|
|||||||
platform = Column(String, nullable=False)
|
platform = Column(String, nullable=False)
|
||||||
action = Column(String, nullable=False)
|
action = Column(String, nullable=False)
|
||||||
status = Column(String, nullable=False)
|
status = Column(String, nullable=False)
|
||||||
|
org_id = Column(String, default="default", nullable=True)
|
||||||
operator = Column(String, nullable=True)
|
operator = Column(String, nullable=True)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
suggestion = Column(Text, nullable=True)
|
suggestion = Column(Text, nullable=True)
|
||||||
@@ -327,6 +329,7 @@ class ContentCalendar(Base):
|
|||||||
reminder_time = Column(DateTime, nullable=True)
|
reminder_time = Column(DateTime, nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_by = Column(String, 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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=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)
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
topic_id = Column(String, ForeignKey("topics.id"), nullable=False, index=True)
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=False, index=True)
|
||||||
platform = Column(String, nullable=False)
|
platform = Column(String, nullable=False)
|
||||||
|
org_id = Column(String, default="default", nullable=True)
|
||||||
publish_url = Column(String, nullable=True)
|
publish_url = Column(String, nullable=True)
|
||||||
views = Column(Integer, default=0)
|
views = Column(Integer, default=0)
|
||||||
likes = Column(Integer, default=0)
|
likes = Column(Integer, default=0)
|
||||||
@@ -381,6 +385,7 @@ class ContentMetrics(Base):
|
|||||||
"id": self.id,
|
"id": self.id,
|
||||||
"topic_id": self.topic_id,
|
"topic_id": self.topic_id,
|
||||||
"platform": self.platform,
|
"platform": self.platform,
|
||||||
|
"org_id": self.org_id,
|
||||||
"publish_url": self.publish_url,
|
"publish_url": self.publish_url,
|
||||||
"views": self.views,
|
"views": self.views,
|
||||||
"likes": self.likes,
|
"likes": self.likes,
|
||||||
@@ -502,6 +507,7 @@ class ContentTask(Base):
|
|||||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
duration = Column(Integer, nullable=True)
|
duration = Column(Integer, nullable=True)
|
||||||
created_by = Column(String, 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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
topic = relationship("Topic")
|
topic = relationship("Topic")
|
||||||
@@ -867,6 +873,7 @@ class SearchRanking(Base):
|
|||||||
article_id = Column(String, ForeignKey("articles.id"), nullable=True)
|
article_id = Column(String, ForeignKey("articles.id"), nullable=True)
|
||||||
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
|
||||||
keyword = Column(String, nullable=False, index=True)
|
keyword = Column(String, nullable=False, index=True)
|
||||||
|
org_id = Column(String, default="default", nullable=True)
|
||||||
platform = Column(String, nullable=True)
|
platform = Column(String, nullable=True)
|
||||||
search_engine = Column(String, default="bing") # bing / baidu / google
|
search_engine = Column(String, default="bing") # bing / baidu / google
|
||||||
position = Column(Integer, nullable=True) # 搜索排名位置(null = 未上榜)
|
position = Column(Integer, nullable=True) # 搜索排名位置(null = 未上榜)
|
||||||
@@ -886,6 +893,7 @@ class SearchRanking(Base):
|
|||||||
"article_id": self.article_id,
|
"article_id": self.article_id,
|
||||||
"topic_id": self.topic_id,
|
"topic_id": self.topic_id,
|
||||||
"keyword": self.keyword,
|
"keyword": self.keyword,
|
||||||
|
"org_id": self.org_id,
|
||||||
"platform": self.platform,
|
"platform": self.platform,
|
||||||
"search_engine": self.search_engine,
|
"search_engine": self.search_engine,
|
||||||
"position": self.position,
|
"position": self.position,
|
||||||
@@ -909,6 +917,7 @@ class GeoReadinessScore(Base):
|
|||||||
article_id = Column(String, ForeignKey("articles.id"), nullable=True, index=True)
|
article_id = Column(String, ForeignKey("articles.id"), nullable=True, index=True)
|
||||||
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
|
||||||
platform = Column(String, nullable=True)
|
platform = Column(String, nullable=True)
|
||||||
|
org_id = Column(String, default="default", nullable=True)
|
||||||
total_score = Column(Integer, default=0) # 总分 0-100
|
total_score = Column(Integer, default=0) # 总分 0-100
|
||||||
has_schema = Column(Boolean, default=False) # 是否有结构化数据
|
has_schema = Column(Boolean, default=False) # 是否有结构化数据
|
||||||
schema_types = Column(String, nullable=True) # 含有的 schema 类型列表
|
schema_types = Column(String, nullable=True) # 含有的 schema 类型列表
|
||||||
@@ -926,6 +935,7 @@ class GeoReadinessScore(Base):
|
|||||||
"article_id": self.article_id,
|
"article_id": self.article_id,
|
||||||
"topic_id": self.topic_id,
|
"topic_id": self.topic_id,
|
||||||
"platform": self.platform,
|
"platform": self.platform,
|
||||||
|
"org_id": self.org_id,
|
||||||
"total_score": self.total_score,
|
"total_score": self.total_score,
|
||||||
"has_schema": self.has_schema,
|
"has_schema": self.has_schema,
|
||||||
"schema_types": self.schema_types,
|
"schema_types": self.schema_types,
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ class ArticleBase(BaseModel):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
content: Optional[str] = None
|
content: Optional[str] = None
|
||||||
status: str = "draft"
|
status: str = "draft"
|
||||||
|
org_id: Optional[str] = None
|
||||||
compliance_score: Optional[int] = None
|
compliance_score: Optional[int] = None
|
||||||
word_count: Optional[int] = None
|
word_count: Optional[int] = None
|
||||||
outline: Optional[str] = None
|
outline: Optional[str] = None
|
||||||
@@ -193,6 +194,7 @@ class ContentCalendarUpdate(BaseModel):
|
|||||||
|
|
||||||
class ContentCalendarResponse(ContentCalendarBase):
|
class ContentCalendarResponse(ContentCalendarBase):
|
||||||
id: int
|
id: int
|
||||||
|
org_id: Optional[str] = None
|
||||||
published_date: Optional[date] = None
|
published_date: Optional[date] = None
|
||||||
created_by: Optional[str] = None
|
created_by: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@@ -230,6 +232,7 @@ class ContentMetricsUpdate(BaseModel):
|
|||||||
|
|
||||||
class ContentMetricsResponse(ContentMetricsBase):
|
class ContentMetricsResponse(ContentMetricsBase):
|
||||||
id: int
|
id: int
|
||||||
|
org_id: Optional[str] = None
|
||||||
engagement_rate: float = 0
|
engagement_rate: float = 0
|
||||||
last_fetched: Optional[datetime] = None
|
last_fetched: Optional[datetime] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@@ -265,6 +268,7 @@ class MediaAssetUpdate(BaseModel):
|
|||||||
|
|
||||||
class MediaAssetResponse(MediaAssetBase):
|
class MediaAssetResponse(MediaAssetBase):
|
||||||
id: int
|
id: int
|
||||||
|
org_id: Optional[str] = None
|
||||||
usage_count: int = 0
|
usage_count: int = 0
|
||||||
uploaded_by: Optional[str] = None
|
uploaded_by: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@@ -340,6 +344,7 @@ class ContentTaskCreate(BaseModel):
|
|||||||
|
|
||||||
class ContentTaskResponse(ContentTaskBase):
|
class ContentTaskResponse(ContentTaskBase):
|
||||||
id: int
|
id: int
|
||||||
|
org_id: Optional[str] = None
|
||||||
task_id: str
|
task_id: str
|
||||||
topic_id: Optional[str] = None
|
topic_id: Optional[str] = None
|
||||||
topic_title: Optional[str] = None
|
topic_title: Optional[str] = None
|
||||||
@@ -454,6 +459,7 @@ class PublishRecordResponse(BaseModel):
|
|||||||
platform: str
|
platform: str
|
||||||
action: str
|
action: str
|
||||||
status: str
|
status: str
|
||||||
|
org_id: Optional[str] = None
|
||||||
operator: Optional[str] = None
|
operator: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
suggestion: Optional[str] = None
|
suggestion: Optional[str] = None
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<el-button size="default" :type="activeTab === 'searchrankings' ? 'primary' : ''" @click="switchTab('searchrankings')">搜索排名</el-button>
|
<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 === '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 === 'configitems' ? 'primary' : ''" @click="switchTab('configitems')">配置管理</el-button>
|
||||||
|
<el-button size="default" :type="activeTab === 'taskmanagement' ? 'primary' : ''" @click="switchTab('taskmanagement')">任务管理</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -816,6 +817,122 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 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 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 = {
|
const tabLoaders = {
|
||||||
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
|
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
|
||||||
users: fetchUsers,
|
users: fetchUsers,
|
||||||
@@ -1473,6 +1669,7 @@ const llmConfigs = ref([]);
|
|||||||
searchrankings: loadRankings,
|
searchrankings: loadRankings,
|
||||||
geo: loadGeoData,
|
geo: loadGeoData,
|
||||||
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
|
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
|
||||||
|
taskmanagement: loadTaskConfigs,
|
||||||
};
|
};
|
||||||
const loadedTabs = new Set([]);
|
const loadedTabs = new Set([]);
|
||||||
|
|
||||||
@@ -1533,6 +1730,10 @@ const llmConfigs = ref([]);
|
|||||||
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
|
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
|
||||||
rankings, rankingStats, rankingsLoading, loadRankings,
|
rankings, rankingStats, rankingsLoading, loadRankings,
|
||||||
geoOverview, geoReadinessScores, geoLoading, geoTracking, loadGeoData, triggerGeoTrack,
|
geoOverview, geoReadinessScores, geoLoading, geoTracking, loadGeoData, triggerGeoTrack,
|
||||||
|
taskConfigs, tcLoading, selectedModule, showHistory, taskLogs, tlLoading,
|
||||||
|
loadTaskConfigs, selectModule, toggleModule, loadTaskLogs,
|
||||||
|
tcEditDialogVisible, tcEditSaving, tcEditForm, showEditConfig, saveTaskConfig,
|
||||||
|
resultDialogVisible, resultDialogContent, showResultData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -174,7 +174,9 @@
|
|||||||
'topics.html': 'topics', 'metrics.html': 'metrics',
|
'topics.html': 'topics', 'metrics.html': 'metrics',
|
||||||
'calendar.html': 'calendar', 'assets.html': 'assets',
|
'calendar.html': 'calendar', 'assets.html': 'assets',
|
||||||
'tasks.html': 'tasks', 'platforms.html': 'platforms',
|
'tasks.html': 'tasks', 'platforms.html': 'platforms',
|
||||||
'admin.html': 'admin'
|
'admin.html': 'admin',
|
||||||
|
'factory.html': 'factory',
|
||||||
|
'insights.html': 'insights'
|
||||||
};
|
};
|
||||||
var path = window.location.pathname;
|
var path = window.location.pathname;
|
||||||
var currentPage = pageMap[path] || pageMap['/'];
|
var currentPage = pageMap[path] || pageMap['/'];
|
||||||
@@ -188,6 +190,8 @@
|
|||||||
'tasks': '⚡ 任务页面跟踪所有创作任务。我可以解释各任务的含义、查看执行进度,或排查任务失败原因。',
|
'tasks': '⚡ 任务页面跟踪所有创作任务。我可以解释各任务的含义、查看执行进度,或排查任务失败原因。',
|
||||||
'platforms': '🔗 平台配置管理各发布渠道。我可以帮你了解如何配置平台参数、设置发布格式。',
|
'platforms': '🔗 平台配置管理各发布渠道。我可以帮你了解如何配置平台参数、设置发布格式。',
|
||||||
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
|
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
|
||||||
|
'factory': '🏭 这里是内容工厂。我可以帮你了解创作流程,从选题到发布的全链路跟踪,或排查任务失败原因。',
|
||||||
|
'insights': '📈 数据洞察页面。我可以帮你解读内容表现数据、搜索排名变化趋势,或建议如何提升 GEO 就绪度。',
|
||||||
};
|
};
|
||||||
|
|
||||||
var HISTORY_KEY = 'ai_assistant_history_' + currentPage;
|
var HISTORY_KEY = 'ai_assistant_history_' + currentPage;
|
||||||
@@ -289,7 +293,9 @@
|
|||||||
var resp = await fetch('/api/assistant/chat', {
|
var resp = await fetch('/api/assistant/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
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();
|
removeTyping();
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+401
-56
@@ -30,66 +30,274 @@
|
|||||||
.plan-item:hover { border-color: var(--color-primary); }
|
.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-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); }
|
.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>
|
</style>
|
||||||
|
|
||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" v-cloak>
|
<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>
|
</uni-nav>
|
||||||
<div class="main-content" v-if="isLoggedIn">
|
<div class="main-content" v-if="isLoggedIn">
|
||||||
<main class="content-area">
|
<main class="content-area">
|
||||||
<!-- 系统概览页面 -->
|
<!-- 工作台页面 -->
|
||||||
<div id="page-overview" class="page" :class="{ active: currentPage === 'overview' }">
|
<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);">
|
<!-- Welcome Banner -->
|
||||||
<el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 系统概览
|
<div class="welcome-banner">
|
||||||
</h2>
|
<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;">
|
<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>
|
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
|
||||||
<div>加载中...</div>
|
<div>加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 空状态 -->
|
|
||||||
<div v-else-if="!stats.total && !loadingStats" class="empty-state">
|
<!-- Workspace Content -->
|
||||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
|
<template v-else>
|
||||||
<div class="empty-text">暂无数据</div>
|
<!-- 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>
|
||||||
<!-- 统计卡片 -->
|
<div class="ws-summary-card ws-review" @click="redirectToPage('/factory.html')">
|
||||||
<div v-else class="stats-grid">
|
<span class="ws-icon">🔍</span>
|
||||||
<div class="stat-card primary" @click="goToTopics('')">
|
<div class="ws-value">{{ stats.review }}</div>
|
||||||
<div class="stat-title">选题总数</div>
|
<div class="ws-label">待审查文章</div>
|
||||||
<div class="stat-value">{{ stats.total }}</div>
|
<div class="ws-action">内容工厂 →</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card warning" @click="goToTopics('pending')">
|
<div class="ws-summary-card ws-ready" @click="goToTopics('ready')">
|
||||||
<div class="stat-title">待处理</div>
|
<span class="ws-icon">📦</span>
|
||||||
<div class="stat-value">{{ stats.pending }}</div>
|
<div class="ws-value">{{ stats.ready }}</div>
|
||||||
|
<div class="ws-label">待发布内容</div>
|
||||||
|
<div class="ws-action">去发布 →</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card danger" @click="goToTopics('review')">
|
<div class="ws-summary-card ws-goal">
|
||||||
<div class="stat-title">待审查</div>
|
<span class="ws-icon">🎯</span>
|
||||||
<div class="stat-value">{{ stats.review }}</div>
|
<div class="ws-value">{{ monthlyGoal }}</div>
|
||||||
</div>
|
<div class="ws-label">本月目标</div>
|
||||||
<div class="stat-card success" @click="goToTopics('ready')">
|
<div class="ws-action">{{ monthlyProgress }}% 完成</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模块状态 -->
|
<!-- 今日待办 -->
|
||||||
<h3 style="font-size: var(--font-size-h2); font-weight: 600; margin-bottom: var(--spacing-lg); color: var(--color-text-primary);">
|
<div class="section-header">
|
||||||
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 模块状态
|
<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);">
|
<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-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
|
||||||
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
|
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
|
||||||
{{ schedulerRunning ? '运行中' : '未启动' }}
|
{{ schedulerRunning ? '运行中' : '未启动' }}
|
||||||
@@ -125,26 +333,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模块详情抽屉 -->
|
<!-- 模块详情抽屉 -->
|
||||||
@@ -214,6 +405,8 @@
|
|||||||
currentUser: { username: '' },
|
currentUser: { username: '' },
|
||||||
currentPage: 'overview',
|
currentPage: 'overview',
|
||||||
loadingStats: false,
|
loadingStats: false,
|
||||||
|
loadingTodos: false,
|
||||||
|
loadingSuggestions: false,
|
||||||
stats: {
|
stats: {
|
||||||
total: 0,
|
total: 0,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
@@ -222,6 +415,11 @@
|
|||||||
published: 0,
|
published: 0,
|
||||||
today: 0
|
today: 0
|
||||||
},
|
},
|
||||||
|
todoTopics: [],
|
||||||
|
monthlyGoal: 0,
|
||||||
|
monthlyProgress: 0,
|
||||||
|
topTrendingTopic: null,
|
||||||
|
adminCollapsed: true,
|
||||||
modules: [],
|
modules: [],
|
||||||
schedulerStatus: '',
|
schedulerStatus: '',
|
||||||
schedulerRunning: false,
|
schedulerRunning: false,
|
||||||
@@ -254,6 +452,10 @@
|
|||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
this.currentPage = 'overview';
|
this.currentPage = 'overview';
|
||||||
this.fetchStats();
|
this.fetchStats();
|
||||||
|
this.fetchModules();
|
||||||
|
this.fetchUpcomingPlan();
|
||||||
|
this.fetchTopTopics();
|
||||||
|
this.computeMonthlyGoal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.loginError = '用户名或密码错误';
|
this.loginError = '用户名或密码错误';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -443,6 +645,145 @@
|
|||||||
this.$message.success('已保存');
|
this.$message.success('已保存');
|
||||||
} catch (e) { this.$message.error('保存失败: ' + e.message); }
|
} catch (e) { this.$message.error('保存失败: ' + e.message); }
|
||||||
finally { this.promptSaving = null; }
|
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() {
|
mounted() {
|
||||||
@@ -463,6 +804,10 @@
|
|||||||
this.fetchStats();
|
this.fetchStats();
|
||||||
this.fetchModules();
|
this.fetchModules();
|
||||||
this.fetchUpcomingPlan();
|
this.fetchUpcomingPlan();
|
||||||
|
this.fetchTopTopics();
|
||||||
|
this.computeMonthlyGoal();
|
||||||
|
this.fetchTodos();
|
||||||
|
this.fetchSuggestions();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem('authToken');
|
localStorage.removeItem('authToken');
|
||||||
|
|||||||
@@ -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 ? '✓' : '×' }}</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>
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h4>支持</h4>
|
<h4>支持</h4>
|
||||||
<a href="#faq">常见问题</a>
|
<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>
|
<a href="mailto:hello@yu-zhi-ran.com">联系我们</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -121,23 +121,20 @@
|
|||||||
|
|
||||||
function getCurrentPage() {
|
function getCurrentPage() {
|
||||||
var path = window.location.pathname;
|
var path = window.location.pathname;
|
||||||
if (path === '/index.html') return 'dashboard';
|
if (path === '/index.html') return 'workspace';
|
||||||
var m = path.match(/\/(\w+)\.html/);
|
var m = path.match(/\/(\w+)\.html/);
|
||||||
return m ? m[1] : 'dashboard';
|
return m ? m[1] : 'workspace';
|
||||||
}
|
}
|
||||||
|
|
||||||
function navItems(isAdmin) {
|
function navItems(isAdmin) {
|
||||||
var items = [
|
var items = [
|
||||||
{ key: 'dashboard', label: '仪表盘', page: 'index.html' },
|
{ key: 'workspace', label: '工作台', page: 'index.html' },
|
||||||
{ key: 'topics', label: '选题', page: 'topics.html' },
|
{ key: 'factory', label: '内容工厂', page: 'factory.html' },
|
||||||
{ key: 'metrics', label: '数据', page: 'metrics.html' },
|
{ key: 'insights', label: '数据洞察', page: 'insights.html' },
|
||||||
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
{ key: 'assets', label: '资产库', page: 'assets.html' },
|
||||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
|
||||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
|
||||||
|
|
||||||
];
|
];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -42,7 +42,7 @@ def get_published_articles() -> List[Dict]:
|
|||||||
"title": article.title or topic.title,
|
"title": article.title or topic.title,
|
||||||
"status": article.status,
|
"status": article.status,
|
||||||
"topic_title": topic.title,
|
"topic_title": topic.title,
|
||||||
"field": topic.field or "",
|
"field": topic.field_name or "",
|
||||||
})
|
})
|
||||||
return articles
|
return articles
|
||||||
finally:
|
finally:
|
||||||
@@ -67,7 +67,7 @@ def _build_search_queries(article: Dict) -> List[str]:
|
|||||||
if 4 <= len(p) <= 25:
|
if 4 <= len(p) <= 25:
|
||||||
queries.append(p)
|
queries.append(p)
|
||||||
|
|
||||||
if field:
|
if field and len(field) >= 4:
|
||||||
queries.append(field[:20])
|
queries.append(field[:20])
|
||||||
|
|
||||||
return list(set(q for q in queries if len(q) >= 4))[:5]
|
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 []
|
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]:
|
def check_rankings(article: Dict, keywords: List[str], engine: str = "bing") -> List[Dict]:
|
||||||
"""检查文章关键词在搜索引擎的排名"""
|
"""检查文章关键词在搜索引擎的排名,并检测 AI 搜索引用"""
|
||||||
title = article.get("title") or article.get("topic_title", "")
|
title = article.get("title") or article.get("topic_title", "")
|
||||||
article_id = article.get("id", "")
|
article_id = article.get("id", "")
|
||||||
topic_id = article.get("topic_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]
|
url_found = url[:200]
|
||||||
break
|
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({
|
results.append({
|
||||||
"article_id": article_id,
|
"article_id": article_id,
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id,
|
||||||
@@ -130,8 +173,8 @@ def check_rankings(article: Dict, keywords: List[str], engine: str = "bing") ->
|
|||||||
"search_engine": engine,
|
"search_engine": engine,
|
||||||
"position": position,
|
"position": position,
|
||||||
"url_found": url_found,
|
"url_found": url_found,
|
||||||
"ai_cited": False,
|
"ai_cited": ai_info.get("ai_cited", False),
|
||||||
"ai_source": None,
|
"ai_source": ai_info.get("ai_source"),
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f" [{engine}] '{keyword}' → {'#' + str(position) if position else '未上榜'}"
|
logger.info(f" [{engine}] '{keyword}' → {'#' + str(position) if position else '未上榜'}"
|
||||||
|
|||||||
Reference in New Issue
Block a user