30 Commits

Author SHA1 Message Date
yuzhiran f9547ba21f feat: 商业模式官网营销升级(免费试用/私有化/源码售卖)+ 企业微信
- 新增 docs/BUSINESS_MODEL.md 商业与营销方案
- landing.html 定价重构为三模式:免费试用 / 私有化部署 / 源码售卖
- 新增「开始合作」联系区(线索表单 + 企业微信二维码 wecom-qr.jpg)
- 商务邮箱更新为 contact@yuzhiran.com
- deploy/README 交付建议同步三模式
- PROGRESS 记至第十八章
2026-07-12 10:16:39 +08:00
yuzhiran 7e953afbd2 feat: 创作工作台统一 + AI味检测/白标 + 移动端补全 + 合规发布闭环
- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式)
- 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级)
- 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包)
- 发布闭环:publishing 放宽至 editor + records/mark-published 接口
- 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底)
- 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立)
- 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
2026-07-11 12:36:01 +08:00
yuzhiran 3d596275d5 fix: preview body empty when no article content
topics.html: currentPreviewBody returns placeholder instead of empty string when platform has no content; openTopicArticlePreview properly awaits fetch

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 19:02:33 +08:00
yuzhiran a4242ada0e fix: search_rankings missing columns 500 error + campaigns.html var typo
database.py: add 5 missing search_rankings columns (ai_search_engine, citation_snippet, citation_url, geo_score, content_type) - caused /api/seo/rankings Internal Server Error

campaigns.html: fix template referencing rankingOverview instead of keywordRankingOverview

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 16:55:12 +08:00
yuzhiran d11d7f4980 fix: pipeline content tracking + topic article preview
db_helper.py: save_article now calculates and persists word_count
generator.py: run_creator_blocking sets word_count for HTML-imported articles
writer.py: fix title regex stripping content-leading numbers (35岁后→岁后)
trends.py: fix Baidu hot_score str/int type comparison crash
database.py: add missing content_tasks.org_id ALTER TABLE migration
schemas.py + topics.py: topic list API returns article_count + articles[] previews
topics.html: table view and card view show article badges with word counts, clickable to open preview

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 12:31:02 +08:00
yuzhiran 57e6e16c11 docs: finalize DB cleanup - delete 5 old TopicFields + 5 stale TrendFieldMapping rows 2026-06-23 18:36:54 +08:00
yuzhiran 187b414193 docs: document DB cleanup Phase F (32 old topics + 66 articles + 5 tasks removed)
Records the DB cleanup executed on 2026-06-23: cascade-deleted 32 sustainability/carbon topics, 66 articles, 5 tasks. Deactivated orphan TopicFields (科技人文交叉, 测试). 15 AI/tech topics remain.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 17:45:45 +08:00
yuzhiran 56c9376c58 docs: document brand optimization v25.1 (brand tone, SEO/GEO, sources, series column, WeChat publisher)
PROGRESS.md: adds Phase A-F brand optimization to v25 section covering 6 prompt brand tone injection, long-tail SEO keywords, differentiated sources (machine-intelligence/arXiv/indie dev/industry reports/AI products), series column feature, and WeChat publisher blocked status.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:32:23 +08:00
yuzhiran 302efd5f2d fix(ui): responsive mobile layout with auth flow refactoring for campaigns
Adds: v-cloak fix, mobile card-list views for all tables, responsive CSS breakpoints, early auth redirect, CDN script reorder to head, ElementPlusIconsVue safety check, global ElMessage reference. Moves async mounted to .then() pattern to avoid Vue 3 SFC issues.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:30:19 +08:00
yuzhiran 897f72012f chore(cleanup): remove old content ideas and sustainability data
Deleted: 10 legacy topic cards (001-010), README from content/ideas/, old sustainability_topics.json, test-fix notification. These belonged to deprecated 'sustainable living' content pillar.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:26:50 +08:00
yuzhiran 0258a9af9f feat(script): add DB migration script for content focus refactoring
Comprehensive DB migration: TopicField cleanup (5 old deactivated, 3 new updated), Topic reassignment (43/47 migrated), Article cleanup (15 deleted), CollectorCategory/Cleaning (12 old deactivated), TrendFieldMapping sync (11 entries)

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:26:22 +08:00
yuzhiran 9058539bae feat(config): add differentiated information sources (arXiv/indie dev/industry reports)
Adds 5 new sources: machine-intelligence RSS, arXiv paper search, indie developer search, industry data report search, AI product changelog search. Differentiates from generic AI media.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:25:39 +08:00
yuzhiran 9fba863ce7 feat(config): align content strategy with brand direction
initial_data: field descriptions humanized, collector search queries include long-tail keywords, platform formats updated collector.py: DEFAULT_CHINA_PAINS expanded with specific pain questions (e.g. '35岁学AI来得及吗') topic_selector.py: domain mapping 11->18 entries (Prompt/AI编程/AI写作/数据隐私/AI教育/一人企业)

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:25:11 +08:00
yuzhiran 45ad5443c1 feat(prompt): inject brand tone into 6 platform prompts
Section expansion: zhihu (unique data + debate), wechat (tech-humanity reflection), xiaohongshu (real usage data) Title optimize: all 3 platforms aligned with new brand direction

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:24:38 +08:00
yuzhiran ef289b18d1 feat(ui): add series filter to topics search area
Adds column/category filter dropdown in topics.html search form, with seriesOptions computed and filteredTopics logic

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:23:19 +08:00
yuzhiran f1e6c11505 feat(models): add series column to Topic for content columns
Topic model: new series VARCHAR field (optional, indexed)
Schema: series in TopicBase/TopicCreate/TopicUpdate
API: GET /api/topics supports ?series= query param
DB migration: ALTER TABLE topics ADD COLUMN series

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 13:22:13 +08:00
yuzhiran e9cd0374f4 feat(script): SEO auditor + PROGRESS.md v24 update
Add seo_auditor.py - crawls external websites, scores 6 SEO dimensions (meta/heading/content/performance/links/mobile), generates optimization tasks. Update PROGRESS.md with v24 external promotion feature.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:07:43 +08:00
yuzhiran 8a04a23e86 feat(ui): external promotion dashboard (campaigns.html)
Add campaigns.html with 6 tabs (Overview/Products/Campaigns/Rankings/Audits/Tasks) using Vue 3 + Element Plus CDN. Add nav entry to uni-nav.js.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:06:46 +08:00
yuzhiran 8f5a9e7ede feat(api): external promotion CRUD + audit endpoints
Add 20 API endpoints under /api/external/* covering products, campaigns, keywords, audits, rankings, optimization tasks with org_id filtering.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:05:55 +08:00
yuzhiran 12e49f2811 feat(models): add external promotion tables (6 models)
Add ExternalProduct, PromotionCampaign, CampaignKeyword, SEOAudit, KeywordRanking, OptimizationTask models and 18 Pydantic schemas for the external promotion module.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:04:52 +08:00
yuzhiran 81f89f6f00 docs: PROGRESS.md - landing fixes, v23 verification, cleanup
Mark landing OG image creation, GitHub link fix, nav bug fix.
Add v23 feature verification results (12 pages 200, org_id, RBAC).
Update last-updated date. Clean up completed todos.

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

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

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 16:27:59 +08:00
Yuzhiran Dev 4b87125683 docs: update PROGRESS.md - M4 complete, remaining TODOs done 2026-06-18 08:16:35 +08:00
Yuzhiran Dev 5a19e77d27 feat: rank_tracker AI citation + admin task management tab 2026-06-17 20:42:37 +08:00
Yuzhiran Dev d3ea832b3b feat: admin.html task management tab (TaskConfig + TaskLog timeline)
- Add 任务管理 tab button
- Show all 7 task modules table with enable/disable switch, schedule, params
- Edit dialog for schedule/params editing
- Click module to show el-timeline of recent 30 run logs
- View result data dialog
- Supports toggleModule, saveTaskConfig, loadTaskLogs
2026-06-17 20:10:56 +08:00
Yuzhiran Dev 1ff015e281 docs: v23 PROGRESS.md + archive WORKSPACE_REDESIGN.md 2026-06-17 18:33:19 +08:00
Yuzhiran Dev f6e5e90736 feat: Wave 3 - Dashboard redesign + RBAC + AI assistant updates
- Redesign index.html from system-monitor to user-centric workspace
  (welcome banner, summary cards, 今日待办, AI 智能建议, collapsible admin section)
- Add fetchTodos/fetchSuggestions/getPriorityClass/isOverdue methods
- Update uni-nav current-page from 'dashboard' to 'workspace'
- Add require_role to 10 admin router imports (RBAC foundation)
- Update ai-assistant.js: add factory/insights pages, org_id in chat requests
2026-06-17 18:24:57 +08:00
Yuzhiran Dev 5caf7bc52e feat: Wave 2 - API org isolation + factory.html + insights.html
- Add org_id auto-population to articles.py, publishing.py, metrics.py
- Add org_id filtering to search_rankings.py geo endpoints
- Add org_id to calendar.py create/update/delete endpoints
- Add org_id to tasks.py create endpoints
- Create factory.html content pipeline page (3-tab: 待创作/进行中/待审查)
- Create insights.html analytics page (4-tab: 概览/搜索排名/GEO/平台对比)
2026-06-17 16:13:48 +08:00
Yuzhiran Dev dcfeccc97b feat(models): add org_id to Article, PublishRecord, ContentMetrics, SearchRanking, GeoReadinessScore
- Add org_id column to 5 models with default='default'
- Update to_dict() methods for ContentMetrics, SearchRanking, GeoReadinessScore
- Add ALTER TABLE migrations in database.py init_db()
- Add org_id to 6 Pydantic response schemas
- Add require_role() helper factory in auth.py
2026-06-17 14:27:27 +08:00
Yuzhiran Dev 00431c827b feat: add org_id to ContentCalendar and ContentTask models 2026-06-17 09:02:54 +08:00
102 changed files with 9164 additions and 2448 deletions
@@ -0,0 +1,10 @@
{
"sessionID": "ses_12cdfe924ffebqMk2WsK4OQD87",
"updatedAt": "2026-06-17T01:09:35.927Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-17T01:09:35.927Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_13069b590ffefKhsLBe5CKeIx4",
"updatedAt": "2026-06-16T08:46:00.608Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-16T08:46:00.608Z"
}
}
}
@@ -0,0 +1,11 @@
{
"sessionID": "ses_1dede8a7affeG6DxtfPUOviXrp",
"updatedAt": "2026-07-04T02:48:32.809Z",
"sources": {
"background-task": {
"state": "active",
"reason": "1 background task(s) active",
"updatedAt": "2026-07-04T02:48:32.809Z"
}
}
}
+470 -13
View File
@@ -3,7 +3,7 @@
> 本文件为项目进度唯一真理源,所有进度信息以此为准。 > 本文件为项目进度唯一真理源,所有进度信息以此为准。
> 其他文档中的进度描述一律以本文为准。 > 其他文档中的进度描述一律以本文为准。
**最后更新**2026-06-16 (v22) **最后更新**2026-07-04 (v26)
--- ---
@@ -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,21 +165,215 @@
| 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` APIadmin.html 新增"搜索排名"tab | | 搜索排名追踪 | 2026-06-09 | `SearchRanking` 模型(文章ID/关键词/排名位置/AI引用);`rank_tracker.py` 定时任务(每日 07:00)查 Bing 排名;`/api/seo/rankings` APIadmin.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 PNG62KB);之前 404 |
| Landing 页 GitHub 链接修正 | 2026-06-18 | `your-org``github.com` |
| v23 全部功能验收 | 2026-06-18 | 12 页 200 OK、org_id 隔离生效、RBAC(require_role)11路由、菜单7项正确、factory/insights 新页、dashboard 重设计 |
### ⏳ 待办 ## 十一、外推营销系统(v24 · 2026-06-21
| 任务 | 优先级 | 备注 | ### 从「内容生产」到「外部推广」
|------|--------|------|
| 生成式 AI 引用检测(Perplexity/Bing AI/百度 AI 搜索) | 中 | rank_tracker 的 `ai_cited` 检测需具体实现 | **背景**:平台此前只覆盖平台内生产的内容的 SEO/GEO 优化,无法对用户已有的外部网站、公众号、小程序等做 SEO 推广。v24 新增完整的外部推广模块。
| 百度搜索排名接入(需百度站长 API) | 中 | 当前仅 Bing,国内百度覆盖更广 |
| admin.html 任务管理 tabTaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI | ### 新增数据模型(6 张表)
| M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 |
| 现有文章重新创作(清除 AI 思考内容) | | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py | | 模型 | 表名 | 说明 |
| 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 | |------|------|------|
| `ExternalProduct` | `external_products` | 外部推广产品(website/article/wechat_account/miniprogram|
| `PromotionCampaign` | `promotion_campaigns` | 推广活动,关联产品和关键词 |
| `CampaignKeyword` | `campaign_keywords` | 关键词跟踪(搜索量/难度/排名)|
| `SEOAudit` | `seo_audits` | SEO 审计报告(6 维度评分)|
| `KeywordRanking` | `keyword_rankings` | 多引擎关键词排名记录 |
| `OptimizationTask` | `optimization_tasks` | AI 生成的优化建议 |
### API 端点(20 个)
| 端点 | 功能 |
|------|------|
| `GET/POST/PUT/DELETE /api/external/products` | 产品 CRUD |
| `GET/POST/PUT/DELETE /api/external/campaigns` | 活动 CRUD |
| `GET/POST/PUT/DELETE /api/external/keywords` | 关键词 CRUD |
| `GET /api/external/audits` | SEO 审计记录列表 |
| `GET /api/external/audits/{id}` | 审计详情(含优化建议)|
| `GET /api/external/rankings` | 关键词排名列表 |
| `GET /api/external/rankings/overview` | 排名概览统计 |
| `GET /api/external/optimization-tasks` | 优化建议列表 |
| `PATCH /api/external/optimization-tasks/{id}` | 更新任务状态 |
| `GET /api/external/overview` | 外推营销总览 |
### SEO 审计脚本
`scripts/seo_auditor.py` — 自动抓取目标网站并分析:
| 维度 | 权重 | 检测项 |
|------|------|--------|
| Meta 标签 | 20% | title/description/keywords/OG tags |
| 标题结构 | 15% | H1/H2 存在性、层级连续性 |
| 内容质量 | 25% | 字数、图片 alt、关键词密度 |
| 性能 | 10% | 渲染阻塞资源、图片尺寸 |
| 链接 | 10% | 内链/外链/断链/nofollow |
| 移动端 | 20% | viewport、响应式、字号 |
审计结果自动保存到 DB,生成可追踪的 OptimizationTask。
### 关键文件变更
| 文件 | 改动 |
|------|------|
| `platform/backend/app/models.py` | 新增 6 个模型(ExternalProduct/PromotionCampaign/CampaignKeyword/SEOAudit/KeywordRanking/OptimizationTask|
| `platform/backend/app/schemas.py` | 新增 18 个 Pydantic schema 类 |
| `platform/backend/app/api/external_promotion.py` | **新建** — 20 个 API 端点 |
| `platform/backend/app/main.py` | 注册 external_promotion router |
| `platform/frontend/campaigns.html` | **新建** — 外推营销 SPA 页面(6 Tab)|
| `platform/frontend/uni-nav.js` | 导航增加「外推营销」入口 |
| `scripts/seo_auditor.py` | **新建** — SEO 审计自动化脚本 |
--- ---
## 六、归档日志 ## 十二、内容策略聚焦改造(v25 · 2026-06-23
### 从「6领域分散」到「3领域聚焦」
**背景**:此前内容覆盖可持续生活、数字游民、个人成长等 6 个领域,资源分散、品牌辨识度低。v25 聚焦「宇之然」品牌核心方向:AI 与前沿科技。
**聚焦的 3 个领域:**
| 领域 | 方向 | 覆盖话题 |
|------|------|---------|
| AI与效率 🤖 | AI 工具、工作流、效率方法 | AI工具实测、AI前沿资讯、效率方法论 |
| 科技人文 🔬 | AI 伦理、科技反思 | 数据隐私、人机关系、数字生活反思 |
| 未来工作方式 💼 | 远程工作、职业转型 | AI时代职场、一人企业、技能升级 |
### Phase 1 — 代码改造(6 个文件)
| 文件 | 改动 |
|------|------|
| `initial_data.py` | TopicField 从 6 个缩减为 3 个;CollectorCategory 从 8 个环保类改为 5 个 AI/科技类;PlatformConfig allowed_tags 全部替换为 AI/效率/科技标签 |
| `prompt_loader.py` | `sources_optimization` 提示词从"可持续生活领域"改为"AI与科技人文领域" |
| `config/sources.yaml` | 全部信息源替换为 AI/科技相关(36氪RSS、少数派RSS、5 组 AI 搜索词、1 个本地案例库) |
| `collector.py` | `DEFAULT_CHINA_PAINS` 从 8 个(7 环保 + 1 AI)改为 5 个 AI 焦点;`map_category_to_field` 返回 AI 领域;移除 4 处"可持续性"日志/默认值 |
| `topic_selector.py` | `DEFAULT_TREND_DOMAIN_MAP` 从 12 条(含可持续生活/个人成长/副业/家庭教育)改为 11 条纯 AI/科技映射 |
| `migrate_content_focus.py` | **新建** — Phase 2 DB 迁移脚本 |
### Phase 2 — DB 迁移
| 操作 | 影响 |
|------|------|
| TopicField | 更新 3 个新领域字段(排序/描述/图标),停用 5 个旧领域(可持续生活系统/可持续生活/数字游民/个人成长/个人知识工厂) |
| Topic | 47 个 Topic 中 43 个已迁移到新领域(4 个无法映射的归入"AI与效率" |
| Article | 删除 15 篇与旧可持续领域关联的旧文章 |
| CollectorCategory | 创建 3 个新分类(AI前沿资讯/AI工具实测/AI与职场),更新 2 个(AI生活化/科技人文),停用 12 个旧分类(循环消费/低碳出行/干净饮食/零浪费生活/绿色家电与节能/碳普惠/环保科技产品/自我丰容/适我主义/情绪价值与心理健康/沉浸式新生活/数字游民与自由职业) |
| CollectorSource | 停用 1 个旧源,创建 2 个新 AI 搜索源 |
| PlatformConfig | 3 个平台(知乎/微信/小红书)的 allowed_tags 全部替换为 AI/科技标签 |
| TrendFieldMapping | 同步 11 条新映射,停用旧映射 |
### Phase 3 — 文件清理
| 项目 | 数量 | 释放空间 |
|------|------|---------|
| 日志文件 (`automation/logs/`) | 400 个 | 2.5 MB |
| 旧选题卡片 (`content/ideas/`) | 11 个文件 | 96 KB |
| 可持续采集原始数据 (`sustainability_raw/`) | 多子目录 | 224 KB |
| 旧数据文件 (`sustainability_cases.json/topics.json`) | 2 个 | - |
| 旧草稿/大纲/研究笔记 (`drafts/outlines/research/`) | 91 个文件 | 740 KB |
| 通知记录 (`notifications/`) | 1 个 | 4 KB |
### Phase 4 — 重启验证
- 服务运行中:HTTP 200 ✅
- API 认证正常 ✅
- TopicFields 仅返回 3 个活跃领域 ✅
- Topic 总计 47 个,全部关联新领域 ✅
- Articles 已清理(0 篇过时文章)✅
- CollectorCategories 仅 5 个 AI 分类活跃 ✅
### 关键架构决策
- `collector.py` 类名(SustainabilityCollector/SustainabilitySource/SustainabilityCase)保留未改——减少中断风险,功能已从 sustainable 转向 AI/tech,重命名需要跨 20+ 文件修改
- DB 种子数据按 `if count() == 0` 判断,已有数据的库通过独立迁移脚本处理
- 旧数据优先保留(硬删除可能影响引用完整性),标记 `is_active=False` 而非删除
### 品牌调性与内容深化(v25.1 · 2026-06-23 第二次迭代)
**问题诊断**:第一次聚焦改造后,内容方向虽已收敛到 AI 3 领域,但存在品牌割裂("宇之然"调性偏人文自然 vs 内容全是纯工具测评)、AI 赛道竞争红海、平台策略错配(公众号叙事体不适合写 AI 工具)等深层问题。
**Phase A — 品牌调性注入(最核心改动)**
| prompt | 改动 | 预期效果 |
|--------|------|---------|
| `section_expansion_zhihu` | 加入独家数据要求+观点交锋,不只是介绍工具而是探讨技术如何改变人 | 在拥挤的 AI 知乎赛道上做出壁垒 |
| `section_expansion_wechat` | 从「AI 工具叙事」→「科技人文思辨」:用「我」口吻写 AI 时代的困惑、顿悟或观察 | 对齐品牌调性,摆脱"AI 测评不适合叙事体"的结构性矛盾 |
| `section_expansion_xiaohongshu` | 每个结论配亲测数据/对比/结果 | 用真实数据对抗同质化 |
| `title_optimize_zhihu` | 增加观点交锋感+立场态度 | 从"工具文标题"→"辩论帖标题" |
| `title_optimize_wechat` | 改为科技叙事方向,参考风格:「AI帮我写周报的第3个月,我终于发现哪里不对了」 | 对齐公众号新定位 |
| `title_optimize_xhs` | 每条标题含数字/时间/效果结果感 | 提高点击率 |
**Phase B — SEO/GEO 长尾关键词**
- `collector.py`: `DEFAULT_CHINA_PAINS` 每个痛点扩展 3-5 个长尾问题("AI写周报""AI提示词怎么写""35岁学AI来得及吗"
- `topic_selector.py`: `DEFAULT_TREND_DOMAIN_MAP` 从 11 条→18 条(新增 Prompt/提示词/AI编程/AI写作/数据隐私/AI教育/一人企业)
**Phase C — 差异化信息源**
`sources.yaml` 中新增 5 个与通用 AI 媒体形成区分的源:
| 新源 | 差异化定位 |
|------|-----------|
| 机器之心 RSS | AI 学术前沿,区别于大众科技媒体 |
| AI 学术论文搜索(arXiv 中文) | 一手论文解读内容源 |
| 独立开发者搜索 | 独特的小众 AI 产品视角 |
| 行业数据报告搜索 | 自采数据的基础来源 |
| AI 产品动态搜索 | 第一手产品更新信息 |
**Phase D — 栏目化内容机制**
为「AI工具实测周报」等固定栏目铺平道路:
- Topic 模型新增 `series` 字段(VARCHAR, optional, indexed
- API 端点 `/api/topics` 新增 `?series=` 筛选参数
- 前端 topics.html 搜索区增加「栏目」下拉筛选器
- `database.py` 新增 ALTER TABLE 迁移(`topics.series`
**Phase E — 公众号发布流水线**[需提供 WeChat APP_ID/APP_SECRET]
- 已安装 `/wechat-mp-publisher` 技能,支持远程 MCP 中转
- `wechat.env``mcp.json` 尚未配置
### 关键文件变更汇总
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `scripts/prompt_loader.py` | 修改 | 6 个 prompt 品牌调性注入(section_expansion×3 + title_optimize×3 |
| `platform/backend/app/initial_data.py` | 修改 | 领域描述人文化、采集搜索词长尾化、平台格式说明更新 |
| `scripts/collector.py` | 修改 | DEFAULT_CHINA_PAINS 长尾关键词扩展 |
| `scripts/topic_selector.py` | 修改 | DEFAULT_TREND_DOMAIN_MAP 从 11→18 条 |
| `config/sources.yaml` | 修改 | 新增机器之心/arXiv/独立开发者/行业报告/AI产品动态 5 个差异源 |
| `platform/backend/app/models.py` | 修改 | Topic 模型新增 series 字段 |
| `platform/backend/app/schemas.py` | 修改 | TopicBase/TopicCreate/TopicUpdate 新增 series |
| `platform/backend/app/api/topics.py` | 修改 | list_topics 新增 series 查询参数 |
| `platform/backend/app/database.py` | 修改 | ALTER TABLE 添加 topics.series 列 |
| `platform/frontend/topics.html` | 修改 | 搜索区新增栏目下拉筛选器 |
| `scripts/migrate_content_focus.py` | 新建 | DB 迁移脚本(Phase 2 遗留) |
### Phase F — 数据库旧数据清理(2026-06-23 第三次迭代)
品牌优化完成后,发现旧数据库中存在大量迁移遗留的错配数据——32 个旧选题(循环消费/碳普惠/可持续方向)被 `migrate_content_focus.py` 的 fallback 逻辑错误地分配到了「AI与效率」领域,并持有 66 篇关联文章和 5 个任务。
| 操作 | 数量 |
|------|------|
| 删除旧选题(sustainability/carbon 方向) | 32 个 |
| 删除关联文章 | 66 篇 |
| 删除关联任务 | 5 条 |
| 停用 orphan 领域(科技人文交叉/测试) | 2 个 |
| 删除 5 个旧 TopicField + 5 条 stale TrendFieldMapping | 10 条 |
| **剩余数据** | **15 个选题 / 9 篇文章 / 3 个活跃领域 / 11 条 TrendFieldMapping** |
所有保留选题均为 AI/tech 方向,与聚焦的 3 领域一致。TopicFields 表从 10 行精简到 3 行,TrendFieldMapping 从 16 行精简到 11 行(全 active),无任何悬空引用。
---
> 过时文档统一移入 `archive/`,防止误导。 > 过时文档统一移入 `archive/`,防止误导。
@@ -383,7 +577,129 @@ 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**
- `/` → 200landing.html
- `/index.html` → 200(工作台仪表盘)
- `/factory.html` → 200(内容工厂)
- `/insights.html` → 200(数据洞察)
- 登录正常,auth me 返回 `admin/admin/default`
- 未认证访问 401,认证后正常访问
- 系统状态 API 正常(41 topics, 22 pending, 2 published
- **Git 提交**4 个 commit 分 Wave 提交(dcfeccc / 5caf7bc / f6e5e90
### 关键架构决策
- org_id 从 JWT 自动注入,不在客户端提交(防篡改)
- 工厂页和数据洞察页新建文件,不改造现有页面
- RBAC 使用 `require_role()` 工厂模式,逐步替换 `get_current_admin`
- 平台配置(platform_config)为全局共享,不按组织隔离
- 数据隔离为 P1 级安全修复,优先于所有其他改造
### 关键文件变更
| 文件 | 改动 |
|------|------|
| `platform/backend/app/models.py` | 10 个模型追加 org_id941 行)|
| `platform/backend/app/database.py` | ALTER TABLE 迁移逻辑(170 行)|
| `platform/backend/app/api/auth.py` | org_filter + require_role196 行)|
| `platform/backend/app/schemas.py` | 6 个响应 schema 追加 org_id637 行)|
| `platform/frontend/index.html` | 用户中心仪表盘重设计 |
| `platform/frontend/factory.html` | **新建** 内容工厂流水线页 |
| `platform/frontend/insights.html` | **新建** 数据洞察页(945 行)|
| `platform/frontend/uni-nav.js` | 流程导向导航(333 行)|
| `platform/frontend/ai-assistant.js` | 页面映射 + org_id 感知 |
| `platform/backend/app/api/*.py` | 10 个路由文件追加 require_role |
---
## 十三、2026 合规与市场契合升级(v26 · 2026-07-04
### 背景:2025-09-01《人工智能生成合成内容标识办法》正式施行
微信/小红书/抖音/B站/微博 强制 AI 生成内容**主动声明标识**;未标识/疑似 AI 内容限流、下架、封号。微信 3-27 新规明确禁"非真人自动化创作行为"(脚本/程序托管全程代写代发)。叠加创作者痛点从"怎么写快"转向"怎么不被限流/怎么有活人感/怎么变现",项目做了一轮合规与市场化升级。
### P0 — 合规生存(已落地)
| 改造 | 文件 | 说明 |
|------|------|------|
| 强制 AI 生成标识注入 | `scripts/writer.py` | 新增 `inject_ai_disclosure()`:按《标识办法》给三平台正文文末注入「AI 辅助创作」声明(显式),head 注入 `ai-generated` meta + AIGC 隐式标识 JSON-LD(隐式)。默认开启,可由 `SystemConfig(disclose_ai=false)``YZR_DISCLOSE_AI=0` 关闭 |
| 发布模块去伪 | `platform/backend/app/api/publishing.py` | **删除伪造的 `status='success'`**:发布接口改为生成发布稿 + 置选题为 `pending_publish`(待人工发布),PublishRecord 状态为 `pending_manual`;明确"AI 稿须真人校验声明后手动发布,系统不代发" |
| LLM 降级响亮失败 | `scripts/writer.py` | `_expand_section` 在 LLM 缺失时标记 `_degraded``run()` 结尾对降级稿报错告警,不再静默吐稀薄内容混入库 |
### P1 — 市场契合(已落地)
| 改造 | 文件 | 说明 |
|------|------|------|
| AI味检测+净化 可见化 | `platform/backend/app/api/ai_slop.py` + `platform/frontend/ai-slop.html` | 新 APIGET /report 跑合规检测、POST /purify 一键净化)+ 新前端页 + 导航"AI味检测"。把已有的去套话/合规能力变成用户可感知卖点(直击 2026 限流焦虑) |
| 长文→短视频拆条 | `scripts/shortvideo.py` + `scheduler.py` | 新脚本将选题长文拆成 N 条短视频脚本(口播/分镜/适配平台),入库 `articles(platform=shortvideo_i)`;新增定时任务 `scheduled_shortvideo` 08:00,对前日待发布选题自动拆条 |
| 外部推广去伪 | `platform/backend/app/api/external_promotion.py` | 新增 `POST /api/external/audit/run` 真实调用 `seo_auditor.py`(此前该脚本从未被任何接口/调度触发),让 SEO 审计可用而非摆设 |
### P2 — 健壮性(已落地)
| 改造 | 文件 | 说明 |
|------|------|------|
| 指标同步扩平台 | `platform/backend/app/core/scheduler.py` | `_run_metrics_sync` 从仅知乎扩展为遍历 `platform_urls`;微信/小红书无公开 API 时优雅跳过并日志说明,不再伪装"仅知乎有数据" |
| 合规闸门防绕过 | `scripts/compliance_optimizer.py` | `force_pass` 选题不自动推进到 `ready`(保留待审查),避免绕过合规闸门直接进发布 |
### 关键文件变更汇总
| 文件 | 类型 | 说明 |
|------|------|------|
| `scripts/writer.py` | 修改 | AI 标识注入 + 降级标记 |
| `platform/backend/app/api/publishing.py` | 修改 | 发布去伪,改待人工发布 |
| `scripts/compliance_optimizer.py` | 修改 | force_pass 不推进状态 |
| `platform/backend/app/api/ai_slop.py` | 新建 | AI味检测/净化 API |
| `platform/frontend/ai-slop.html` | 新建 | AI味检测页 |
| `scripts/shortvideo.py` | 新建 | 长文→短视频拆条 |
| `platform/backend/app/core/scheduler.py` | 修改 | 指标扩平台 + 短视频定时任务 |
| `platform/backend/app/api/external_promotion.py` | 修改 | SEO 审计可触发 |
### 验证
- 全部修改文件 `ast.parse` 通过;`app.api.ai_slop / publishing / external_promotion``core.scheduler` 在 venv 下 `import` 成功
- 注:本次为代码升级,**未重启服务/未跑全量测试套件**;发布/净化/拆条需运营在真实平台按声明手动完成
---
## 十二、规范说明
### 规划文档 ### 规划文档
@@ -410,3 +726,144 @@ admin.html 新增「GEO 就绪度」标签页包含:
如需 → 同步 README.md 摘要 如需 → 同步 README.md 摘要
``` ```
---
## 第十四章:前端布局修复 + 盈利模式(私有化/白标)升级
**日期**2026-07-11
**目标**:① 修复前端布局/样式不合理项;② 将项目从"自用中台"升级为"可私有化部署 / 白标转售"产品。
### 一、前端布局/样式修复
- **A1 导航未固定 + 顶部双空隙**:`uni-nav.js` 删除 `<nav>` 内联 `position:relative`,恢复 CSS `position:fixed`。(同步修复 A3 移动端下拉菜单脱离)
- **A2 平板导航裁切**:汉堡菜单断点由 `max-width:768px` 提升至 `1000px`,769–1000px 链接改走下拉,不再静默丢失。
- **A4 预览弹窗偏右**`theme-modern.css` 移除 `@media(min-width:769px)``.preview-dialog-custom{left:90px}`,对话框恢复居中(影响 articles.html / factory.html)。
- **A6 卡片横向滚动条**`.card` 去掉 `overflow-x:auto`(el-table 自带内部滚动,重复滚动条消除,阴影不再被裁)。
- **A5 暗色模式半成品(对比度不可读)**:暂禁用 `prefers-color-scheme:dark` 块,统一亮色主题,待引入 EP 暗色变量后启用。
- **topics.html 筛选表单响应式**6 个 `el-col``:xs/:sm/:md` 断点,移动端堆叠不溢出。
- **metrics.html 统计卡**`.stats-grid``repeat(4,1fr)` 改为 `repeat(auto-fit,minmax(180px,1fr))`,平板自适应列数。
- **factory.html**`.topic-grid``minmax(min(340px,100%),1fr)`,≤320px 屏不溢出。
### 二、盈利模式升级:白标 + 私有化部署
- **白标配置后端**:新增 `api/branding.py`
- `GET /api/branding`(公开,无需登录):返回 `brand_name/brand_logo/brand_primary_color/brand_support_email/version`
- `PUT /api/branding`(管理员):更新白标配置,写入 `system_configs`
- `main.py` 注册 `branding.router`
- **白标种子**`initial_data.py` 写入 `brand_name/brand_logo/brand_primary_color/brand_support_email` 默认。
- **前端应用品牌**`uni-nav.js` `mounted``/api/branding`,动态设置平台名、Logo、导航渐变主色,并写入 `--color-primary`;新增 `branding.html` 白标设置页(管理员,含实时预览)。
- **导航入口**`initial_data.py` 菜单加「白标设置」(admin),`uni-nav.js` 默认 admin 项补充。
- **部署包 `deploy/`**
- `Dockerfile`python:3.11-slim,默认 SQLite,零依赖)
- `docker-compose.yml`(含可选 PostgreSQL 服务与数据卷)
- `.env.example`(管理员密码 / LLM Key / 数据库)
- `README.md`(私有化 + 白标交付指南、定价建议)
### 验证
- 服务 `yzr-platform.service` 重启成功,无启动错误。
- `GET /api/branding` 返回种子默认值;`PUT` 管理员保存后持久化生效,重置回默认。
- `node --check uni-nav.js``ast.parse(branding.py)` 均通过。
### 待办(已解决,见第十六章)
- ~~`init_db()` 的 SQLite `topics.series` 迁移缺口(测试 32/33)~~:经复跑为陈旧 `test.db` 所致,当前 33/33 全过;迁移 fallback 对 SQLite 正常。
- 菜单幂等播种缺陷(见第十六章):`initial_data.py``if Menu.count()==0` 全有/全无逻辑导致已在库时章节四新增的"文章管理/内容工厂/白标设置"未入库,导航不可达,已修复。
---
## 第十五章:移动端响应式补全
**日期**2026-07-11
**目标**:审计并补全全部前端的移动端(≤420px)适配。
### 审计结论
- 已良好适配:topics / articles / campaigns / tasks / metrics / insights / platforms / assets / factory / index / landing / login / users / logs(表格均有 card-list 兜底或本就是卡片布局;固定宽弹窗由全局 `≤768px` 全宽规则兜底)。
- 真实破裂点(3 处 el-table 无移动端兜底):
1. `ai-slop.html` 问题明细表(line 47
2. `calendar.html` 日程弹窗内 `selectedDayEntries` 表(line 189
3. `admin.html` 平台配置表(line 202,缺 `data-table` 类,全局隐藏规则未覆盖)
### 修复
- 三处统一改为「`el-table`(`.card-table`/`.data-table`) + `.card-list-mobile` 卡片列表」的 `v-if`/`v-else` 兜底,沿用 `theme-modern.css``.card-list-mobile` 全局样式(≤768px 自动切换)。
- 桌面端表格保留,移动端自动转为可点列表,操作按钮(编辑/删除/查看)一并带入卡片。
- 校验:三页结构 `v-if`/`v-else` 配对正确,HTTP 200 正常服务。
### 结论
前端现已**完整支持移动端响应式**:所有数据表在窄屏均以卡片列表呈现,导航/筛选/弹窗/统计网格均自适应,无横向溢出。
---
## 第十六章:菜单播种缺陷修复(导航可达性)
**日期**2026-07-11
**触发**:生产服务重启后核对 `/api/menus/active`,发现导航缺少"文章管理 / 内容工厂 / 白标设置",而 `articles.html`(章节四重写)、`factory.html``branding.html` 均为真实页面 → 这些入口被"孤立",用户无法从导航进入。
### 根因
- `initial_data.py` 菜单播种原逻辑为 `if db.query(Menu).count() == 0:` —— **仅当菜单表完全为空时才播种**
- 生产库此前已有旧菜单集,故章节四新增的三个菜单从未插入;`uni-nav.js` 运行时以 DB 菜单覆盖 fallback,于是三个入口从导航消失。
- 另:旧库首页菜单 `path="index.html"`(无斜杠)与新种子 `"/index.html"` 不匹配,初次修复导致"工作台"重复出现(count 13)。
### 修复
- 菜单播种改为**按 path 幂等对齐**:已存在则更新 `sort_order/roles/icon/is_active`(保留原名称,避免意外重命名),缺失则插入。
- 新增首页路径归一化:若同时存在 `index.html``/index.html` 则删除旧的;若仅 `index.html` 则改路径为 `/index.html`,消除重复入口。
- `init_db()` 在每次启动执行,重启后自动对齐。
### 验证
- 重启 `yzr-platform.service``/api/menus/active` 返回 **12 个菜单**,顺序正确、无重复:工作台→选题→文章管理→内容工厂→数据→日历→素材→任务→外推营销→AI味检测→白标设置→系统。
- 12 个对应 HTML 页面均 HTTP 200。
- `python3 tests/test_new_features.py`:**33/33 通过**(菜单播种变更未破坏 seeding)。
### 当前完整导航(运营闭环)
选题 → 内容工厂生成 → 文章管理编辑/AI味检测 → 发布记录标记已发布(合规:系统不代发,真人校验后手动发布)。
---
## 第十七章:创作流程统一(导航去碎片化)
**日期**2026-07-11
**目标**:将分散的「选题 / 内容工厂 / 文章管理」三个独立导航入口合并为单一「创作工作台」,减少导航碎片化。
### 方案
采用 **`studio.html` 单一入口 + `el-tabs` 内嵌三个已有页面(`?embed=1` 模式)**,而非重写三套逻辑——复用已验证页面,零逻辑重复、风险最低。
### 实现
- **新增 `studio.html`「创作工作台」**`el-tabs` 三个标签页(选题 / 内容生成 / 文章管理),各以 `<iframe>` 懒加载对应页面(`topics.html?embed=1` 等);首次切到才加载,保留各页状态。整页 flex 布局填满视口,iframe `position:absolute; inset:0` 自适应。
- **嵌入模式 `?embed=1`**`topics.html` / `factory.html` / `articles.html``mounted` 检测 `embed` 参数 → `isEmbed=true``<body class="embed">`
- `<uni-nav>``v-if="!isEmbed"` 隐藏子页面自身导航(避免双层导航);
- `theme-modern.css` 新增 `body.embed .main-content { padding-top:0 }` 去掉顶部留白;
- 跨页链接(`redirectToPage` / `goToArticles`)在 embed 下改用 `window.top.location.href`,整页跳转而非 iframe 内嵌套。
- **导航收敛**`initial_data.py` 菜单种子以「创作工作台→studio.html」替换原三入口,并将 `topics.html/factory.html/articles.html``is_active=False`(页面仍可被 studio 内嵌与直接 URL 访问,不丢失功能);`uni-nav.js` fallback 同步更新。
- 排序:工作台(0) → 创作工作台(1) → 数据(2) → 日历(3) → 素材(4) → 任务(5) → 外推营销(6) → AI味检测(7) → 白标设置(8,admin) → 系统(9,admin)。
### 验证
- 重启 `yzr-platform.service``/api/menus/active` 返回 **10 个菜单**,含「创作工作台(studio.html)」,原三入口已不在主导航。
- `studio.html / topics.html / factory.html / articles.html` 均 HTTP 200studio 内 iframe `src``embed=1`
- 三个页面内联脚本 `node --check` 通过;`initial_data.py` 编译通过。
- `python3 tests/test_new_features.py`**33/33 通过**。
### 当前导航(10 项,创作闭环归于单一入口)
工作台 → **创作工作台(选题·生成·文章)** → 数据 / 日历 / 素材 / 任务 / 外推营销 / AI味检测 / 白标设置 / 系统。
---
## 第十八章:商业模式与官网营销升级
**日期**2026-07-11
**目标**:把产品从自用工具定位升级为可对外售卖的 AI 内容中台,并在官网首页展示「免费试用 / 私有化部署 / 源码售卖」三种模式。
### 方案沉淀
- 新增 `docs/BUSINESS_MODEL.md`:功能盘点 + 2026 市场需求 + 三模式可行性 + 首页改造方案 + 定价建议。结论:以私有化部署 + 源码售卖为主力,免费试用作获客钩子(避开纯 SaaS 红海)。
### 实现(landing.html 改造)
- **定价区(#pricing)重构为三段式**:免费试用(¥0 起,私有化试用版/演示)→ 私有化部署(¥29,800 起买断,推荐,featured)→ 源码售卖(面议授权)。CTA 统一锚定 #contact
- **FAQ 增补**:私有化部署说明、源码采购、免费试用申请、AI 内容合规(不代发/人工闸门)、数据安全/多租户。
- **新增「开始合作」联系区(#contact)**:联系信息卡 + 轻量线索表单(姓名/公司/合作方式/联系方式/需求),提交唤起 mailto 至 hello@yu-zhi-ran.com(无后端依赖,低风险)。
- **文案对齐**:导航「免费使用」→「免费试用」+ 新增「联系销售」;Hero / CTA 按钮对齐;移动端菜单同步。
- **deploy/README.md**:交付建议同步为三模式定价。
- 新增 `.contact` 相关 CSS(深色区块,与 `.cta` 视觉一致)。
### 验证
- `curl /landing.html` → 200;新内容(免费试用/私有化部署/源码售卖/id="contact"/trialForm/申请试用/获取报价/联系销售)均存在。
- `<section>` 开合平衡(8/8);旧 SaaS 三档(入门/专业/企业版)已清除。
- 未做后端线索入库(当前 mailto 方案足够);若需留资分析可后续加 `/api/leads`
### 待办(可选)
- 真实线索入库(Lead 表 + /api/leads);私有化试用版自动下发;报价单生成。
@@ -0,0 +1,331 @@
# 宇之然工作台改造方案 — 极简实用 AI Agent 时代设计
> 调研日期: 2026-06-16
> 基于 2026 年 AI 产品设计趋势 + 现有工作台全面审计
---
## 一、现有工作台问题分析
### 1.1 当前页面结构
| 页面 | 定位 | 问题 |
|------|------|------|
| `/index.html` 仪表盘 | 系统概览 + 模块状态 + 近期计划 | **系统视角**,不是用户视角。6 张统计卡片 + 6 个定时模块卡片 + 调度状态 → 像运维面板,不像创作工作台 |
| `topics.html` 选题 | 选题表格 CRUD + 批量操作 | 功能完整但无聊。没有 AI 辅助选题推荐,没有智能排序 |
| `metrics.html` 数据 | 统计看板 + 图表 | 功能 OK,但与 GEO/搜索排名/合规数据分离在不同页面 |
| `calendar.html` 日历 | 内容日历 | 独立页面,与选题/数据割裂 |
| `tasks.html` 任务 | 流水线执行记录 | 展示后端调度状态,普通用户看不懂 |
| `admin.html` 系统管理 | 13 个 Tab 的后台 | 混入了业务功能(AI助手、搜索排名、GEO) |
| `assets.html` 素材 | 素材管理 | 基础功能 |
| `landing.html` 官网 | 产品官网 | ✅ 独立、清晰 |
### 1.2 核心问题
1. **仪表盘是「系统监控」不是「创作工作台」**
- 一进来看到的是定时任务状态(热点趋势/采集/创作/审查),不是「我今天要做什么」
- 缺少任务优先级、待办提醒、进度概览
- 对于内容编辑来说,模块卡片(scheduler 状态)毫无意义
2. **导航/信息架构是「功能菜单」不是「创作流程」**
- 六个平级菜单项:仪表盘、选题、数据、日历、素材、任务
- 没有工作流意识 — 用户需要在多个页面跳转来完成一个创作流程
- 缺少「最近工作」「快速开始」等具有上下文感的入口
3. **AI 能力是「附加功能」不是「内嵌体验」**
- AI 助手是右下角浮窗 chatbot,与工作流分离
- AI 选题分析在选题页弹窗里,AI 写文章在发布流程里
- 没有 Agent 工作区 — 没有一个地方能看到 AI 正在做什么、做了什么、需要什么审批
4. **权限与功能混杂**
- 业务功能(搜索排名、GEO 优化、AI 助手)锁在 admin.html 里
- 普通用户使用需要切换到「系统管理」—— 概念上就不对
5. **缺少「Agent 时代」的核心交互模式**
- ❌ 没有 Plan-first(先展示计划,再执行)
- ❌ 没有 AI 操作轨迹/审计
- ❌ 没有任务编排/Agent 工作区
- ❌ 没有智能建议/预测
- ✅ 但已经有一个基础的 AI 助手浮窗底座
---
## 二、2026 AI 产品设计趋势映射
### 从 7 个趋势看我们应该做什么
| # | 趋势 | 对我们意味着什么 |
|---|------|----------------|
| 1 | **UX → AX (Agentic Experience)** | 工作台需要展示 AI 的**行动计划**和**执行轨迹**,而不是单纯的界面操作 |
| 2 | **Plan-first 交互** | 选题→创作→发布 应该有一个**流程预览**步骤,AI 先展示「我将做 A→B→C」,用户确认后再执行 |
| 3 | **Invisible AI (嵌入式 AI)** | AI 不应该是右下角聊天窗,而应该嵌入到选题列表(智能排序)、文章编辑(实时优化建议)、日历(智能排期)|
| 4 | **Delegation with Control** | 用户需要看到一个「代理仪表盘」— AI 正在做什么、已完成什么、需要审批什么 |
| 5 | **Workspace over Chat** | 从「跟 AI 对话」到「AI 作为团队成员在共享空间工作」|
| 6 | **预览→差异→确认 模式** | 所有 AI 生成/修改的内容,先展示 diff/预览,再确认写入 |
| 7 | **审批路径和可逆性** | 每一步操作都有 undo 路径和操作历史 |
---
## 三、改造方案设计
### 3.1 总体信息架构重构
```
当前: 目标:
┌──────────┐ ┌──────────────────┐
│ 仪表盘 │ ← 系统视角 │ 工作台 (首页) │ ← 用户视角
│ 选题 │ │ ├─ 今日待办 │
│ 数据 │ │ ├─ 创作流水线 │
│ 日历 │ │ ├─ 智能建议 │
│ 素材 │ │ └─ 最近工作 │
│ 任务 │ ├──────────────────┤
│ 系统管理 │ │ 内容工厂 │ ← 创作流程
└──────────┘ │ ├─ 选题工作区 │
│ ├─ AI 创作工作区 │
│ ├─ 发布工作区 │
│ └─ 内容日历 │
├──────────────────┤
│ 数据洞察 │ ← 效果分析
│ ├─ 数据看板 │
│ ├─ 搜索排名 │
│ ├─ GEO 就绪度 │
│ └─ 合规报告 │
├──────────────────┤
│ 资产库 │ ← 资源管理
├──────────────────┤
│ 系统管理 (admin) │ ← 仅管理员
```
### 3.2 工作台首页(全新设计)
**目标**:一打开就知道今天要做什么、进度如何、有什么需要关注
```
┌─────────────────────────────────────────────────┐
│ ☀️ 早上好,张三 你的团队本周已发布 3 篇内容 │
├─────────────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 待处理│ │ 待审查│ │ 待发布│ │ 本月 │ │ 合规 │ │
│ │ 选题 │ │ 文章 │ │ 内容 │ │ 目标 │ │ 健康 │ │
│ │ 12 │ │ 5 │ │ 3 │ │ 67% │ │ 94% │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
├─────────────────────────────────────────────────┤
│ 📋 今日待办 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 🔴 审查「远程办公的 5 个陷阱」- 逾期 2 天 │ │
│ │ 🟡 审核「AI 写作工具实测」- AI 已完稿 │ │
│ │ 🔵 确认本周排期 - 3 篇待定稿 │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ 🤖 AI Agent 工作区 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 📡 热点采集 ✅ 完成 (12 条新趋势) │ │
│ │ ✍️ 文章创作 ⏳ 进行中 (2/5 篇) │ │
│ │ 🔍 合规审查 ⏳ 排队中 (3 篇待审) │ │
│ │ 📊 数据同步 ⏰ 06:00 自动运行 │ │
│ │ │ │
│ │ [查看 Agent 轨迹 →] │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ 💡 AI 智能建议 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 💡 「碳中和」关键词本周搜索量 ↑47% │ │
│ │ 建议创建一篇相关选题 │ │
│ │ [一键创建选题 →] │ │
│ │ 💡 你的知乎上周互动率 ↑12% │ │
│ │ 建议继续保持数据分析深度 │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ 📅 近期计划 │
│ ┌──────────┬──────────────────┬──────────┐ │
│ │ 06/17 二 │ 远程办公工具对比 │ 知乎发布 │ │
│ │ 06/18 三 │ AI 写作体验 │ 公众号 │ │
│ │ 06/19 四 │ 零浪费生活指南 │ 小红书 │ │
│ └──────────┴──────────────────┴──────────┘ │
└─────────────────────────────────────────────────┘
```
### 3.3 内容工厂(创作流水线重构)
**目标**:按创作流程组织,而不是按数据表组织
#### 选题工作区
- **AI 智能选题板**:热点趋势 + 关键词热度 + 历史数据 → 推荐选题
- **选题看板**:Kanban 视图(待处理→分析中→已评分→待创作)
- **批量操作**:AI 一键分析 → 自动评分 → 自动排序
- **卡片预览**:题目的核心观点、受众痛点、差异化切入点和评分一览
#### AI 创作工作区 (NEW)
- **创作任务面板**:显示所有进行中的 AI 创作任务
- **Plan-first 流程**:用户选择选题 → AI 展示大纲 → 用户确认 → AI 逐节写作
```
步骤 1: 选择选题 + 平台
步骤 2: AI 生成大纲 (展示预览)
步骤 3: 用户调整大纲 (拖拽排序/编辑)
步骤 4: AI 逐节写作 (进度条展示)
步骤 5: 合规审查 (展示问题列表)
步骤 6: 用户最终编辑
```
- **Agent 执行轨迹**:每次 AI 操作都有时间线记录
- **在线编辑器**:统一的富文本编辑器,支持实时 AI 优化建议
#### 发布工作区
- **发布检查清单**:AI 自动检查合规、字数、配图、格式
- **一键多平台发布**:选题→三平台各自适配→预览→确认发布
- **发布历史**:时间线视图
### 3.4 AI Agent 工作区(新概念页面)
**这是本次改造的核心创新亮点** — 一个展示 AI 作为「团队成员」工作状况的看板。
```
┌─────────────────────────────────────────────────┐
│ 🤖 AI Agent 工作区 │
├─────────────────────────────────────────────────┤
│ 🔴 活跃任务 │
│ ┌─────────────────────────────────────────────┐ │
│ │ ✍️ 正在创作「远程办公的 5 个陷阱」→ 知乎版 │ │
│ │ 进度: ████████░░ 78% │ │
│ │ 当前步骤: 撰写第 3 节「团队协作挑战」 │ │
│ │ [预览] [暂停] [取消] │ │
│ │ │ │
│ │ 🔍 正在审查「AI 写作工具实测」- 小红书版 │ │
│ │ 进度: ████░░░░░░ 35% │ │
│ │ 发现 2 个合规问题 │ │
│ │ [查看详情] │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ ⏳ 待审批 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 📝 大纲待确认 「零浪费生活指南」 │ │
│ │ 已由 AI 自动生成,等待你审核 │ │
│ │ [查看大纲] [确认] [修改] │ │
│ │ │ │
│ │ 📄 文章待审 「碳中和入门」已完稿 │ │
│ │ 合规评分 92/100,2 条建议 │ │
│ │ [审阅] [通过] [退回修改] │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ ✅ 今日已完成 │
│ ┌─────────────────────────────────────────────┐ │
│ │ ✅ 采集热点 12 条 → 生成 3 个新选题 │ │
│ │ ✅ 同步数据指标 → 知乎互动率 +12% │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### 3.5 数据洞察(统一分析中心)
将当前分散的数据功能整合:
| Tab | 功能 | 来源 |
|-----|------|------|
| 概览 | 总指标 + 趋势图 | 现有 metrics.html |
| 搜索排名 | 关键词排名追踪 | 现 admin.html 搜索排名 |
| GEO 就绪度 | AI 搜索优化评分 | 现 admin.html GEO |
| 合规报告 | 发布前合规检查历史 | 现 admin.html + 优化报告 |
| 平台对比 | 知乎/公众号/小红书数据 | 现有 metrics.html |
### 3.6 导航改造
```javascript
// 现有 navItems (硬编码):
[{dashboard, topics, metrics, calendar, assets, tasks}]
// admin 额外: [admin]
// 目标 navItems (按角色动态):
// 编辑:
[{工作台(top), 内容工厂(factory), 数据洞察(insights), 资产库(assets)}]
// 管理员:
内容工厂 + 数据洞察 + 资产库 + 系统管理(admin)
```
导航从「功能清单」变成「流程引导」:
- **工作台** = 首页(今日待办 + Agent 状态 + 智能建议)
- **内容工厂** = 创作全流程(选题→创作→发布→日历)
- **数据洞察** = 所有数据分析入口
- **资产库** = 素材管理
### 3.7 移动端适配
当前移动端只是隐藏表格换卡片,改造后:
- 底部 Tab 导航栏(代替顶部汉堡菜单)
- 工作台首页简化为:今日待办 + Agent 状态 + 快速操作按钮
- 内容工厂:简易 Kanban,手势左右切换阶段
- AI Agent: 推送通知代替实时面板
---
## 四、实施优先级
### P0 — 快速见效 (1-2 天)
这些改动小但体验提升大:
| 改动 | 文件 | 工作量 |
|------|------|--------|
| 仪表盘改为用户视角(统计卡片 = 待办数量而不是系统模块) | `index.html` | 半天 |
| 将 AI 助手从浮窗升级为半屏面板 | `ai-assistant.js` | 半天 |
| 导航改为新架构(工作台/内容工厂/数据洞察/资产库) | `uni-nav.js` | 半天 |
### P1 — 核心重构 (3-5 天)
| 改动 | 涉及 | 工作量 |
|------|------|--------|
| 内容工厂页面(创作流水线) | 新建 `factory.html` | 2 天 |
| AI Agent 工作区 | 新建 `agent.html` | 2 天 |
| 数据洞察整合(合并 GEO/搜索排名) | 改造 `metrics.html` | 1 天 |
### P2 — 增强功能 (5-7 天)
| 改动 | 工作量 |
|------|--------|
| Plan-first 创作流程(大纲预览→确认→执行) | 3 天 |
| Agent 执行轨迹/审计时间线 | 2 天 |
| 智能选题推荐面板 | 2 天 |
| 移动端全面适配 | 2 天 |
### P3 — AI 原生体验 (后续)
| 改动 | 工作量 |
|------|--------|
| 智能排期 / 内容策略建议 | 3 天 |
| 自动化工作流编排(拖拽编排 Agent 任务) | 5 天 |
| 多 Agent 协作(采集 Agent + 写作 Agent + 审查 Agent | 5 天 |
---
## 五、关键设计原则
1. **用户视角 > 系统视角**
- 展示「我今天要做什么」而不是「系统正在运行什么」
- 统计数字 = 行动信号(待办数量)而不是监控指标
2. **流程 > 功能**
- 按创作流程组织页面(选题→创作→发布→分析)
- 不在功能之间跳转,在流程中前进
3. **AI 是团队成员,不是工具**
- 展示 AI 的工作计划、进度、产出
- 用户是「审核者/决策者」而不是「操作者」
- 核心模式:用 AI → AI 执行 Plan → 用户审核/批准
4. **预览→确认→执行**
- AI 生成的所有内容:先预览/展示差异,再确认
- 所有操作可逆,有撤销路径
5. **极简不极简功能**
- 减少平级菜单数量和页面复杂
- 不减少功能 — 用上下文和流程引导代替功能罗列
---
## 六、与当前改造项目的关系
本次工作台改造与正在进行的数据隔离/权限升级是 **互补关系**:
| 改造线 | 范围 | 依赖 |
|--------|------|------|
| **数据隔离+权限** (当前进行中) | 后端架构、数据安全 | 无 |
| **工作台极简改造** (本方案) | 前端 UI/UX、信息架构 | 权限系统就绪后可基于角色定制页面 |
建议:
1. 先完成数据隔离和权限基础 — 这是安全基石
2. 工作台改造可以在权限未完全就绪时就开始前端改造(不依赖后端)
3. 导航的 role-based 显示可以利用现有的 `/api/menus/active` 接口
@@ -1,14 +0,0 @@
{
"task": "test",
"time": "2026-04-22 19:00",
"topic_count": 1,
"case_count": 2,
"source_count": 3,
"details_link": "/test",
"topic_title": "测试选题",
"image_count": 5,
"output_dir": "/test",
"status": "测试状态",
"error": "",
"log_path": "/tmp/test.log"
}
@@ -0,0 +1,27 @@
## 标题(爆款钩子)
73%的人因AI替身失去信任,你还在用它省那12%的时间?
## 口播文案
你以为AI换脸能帮你省时间?[停顿]别傻了,每一次用AI形象开会,都是在职场里透支信任。
2025年卡内基梅隆大学的研究发现,[停顿]73%的商务谈判者,一旦知道对方用的是AI替身,信任度直接暴跌。你省下的那12%会议时长,得用几十次真诚沟通才能补回来。[停顿]
为啥?人眼天生能识别“非自然”的脸,哪怕只是一丝微笑延迟,你的大脑杏仁核就会报警——“这人不靠谱!”[停顿]哈佛的脑成像实验更狠:跟AI形象合作时,你大脑里的共情区域活跃度下降30%。你根本不在意对方有没有在听,对方也感受不到你的真诚。
所以那些跨国大公司为什么明令禁止?[停顿]因为信任一旦崩了,客户流失率六个季度能涨18%。你省的时间,抵不过一个客户的流失。[停顿]记住:高信任场景——客户会、谈判、一对一辅导,绝对不要用AI替身。
你觉得有道理吗?评论聊聊,你的公司禁用AI换脸了吗?
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 镜头1 | 博主正对镜头,背后是电脑屏幕显示视频会议中的AI换脸头像 | “AI换脸省时间?你省的是信任” | 0-8秒 |
| 镜头2 | 切换至数据图表:73%的数字放大闪烁,旁边有“信任度暴跌”字样 | “73%谈判者因AI替身失去信任” | 8-20秒 |
| 镜头3 | 博主做“恐怖谷”表情演示:先微笑、然后眼神呆滞、再突然回神 | “大脑报警:不自然的微笑=不靠谱” | 20-35秒 |
| 镜头4 | 动画展示大脑区域变暗(共情区下降30%),旁边出现客户流失率18% | “共情关闭,客户跑路” | 35-50秒 |
| 镜头5 | 博主回到正对镜头,手势强调“三场景禁用” | “客户会、谈判、一对一,禁用!” | 50-65秒 |
## 适配平台
- **抖音**:开头3秒用“73%”数字怼脸,加热门BGM(悬疑类),字幕用大字跳闪。
- **视频号**:节奏稍缓,加“你认同吗”互动投票卡片,结尾引导关注。
- **小红书视频**:封面用“AI替身毁信任”金句,文案加emoji分段,评论区置顶“自查清单”。
@@ -0,0 +1,28 @@
## 标题(爆款钩子)
别让AI换脸毁了客户!3条保命铁律立刻救你
## 口播文案
「[停顿]」你知道吗?你那个完美的AI替身,正在让你和客户之间的信任疯狂透支。今天三条干货,立刻就能用。
第一条:所有对外会议、重要谈判、一对一辅导,千万别用AI形象。[停顿]加州大学研究证明,哪怕眼珠动慢一丁点,对方大脑立刻拉警报——73%的商务谈判者会直接判定你不诚实。信任崩塌了,修复一次需要5到8次真诚互动。
第二条:内部非正式头脑风暴,用AI形象可以提升流畅度,[停顿]但必须提前告知团队,划清边界。盖洛普数据显示,明确边界的公司员工满意度高23%,客户推荐值高12%。
第三条:如果已经用了AI形象,立刻补救。真诚道歉,换成真人视频,主动暴露缺点。[停顿]别拿“效率”骗自己,信任是账面上的隐形资产。
记住:信任建立要日积月累,崩塌只在一念之间。觉得有用?点赞收藏,别再踩坑了。
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 博主正面近景,严肃表情,手指镜头 | 别让AI换脸毁了客户!3条保命铁律 | 8秒 |
| 2 | 博主拿起手机或电脑屏幕,展示一个AI换脸会议场景图标 | 73%的人直接判定不诚实 | 一次失信需要5-8次修复 | 12秒 |
| 3 | 博主切换到侧身,用手势列举要点,镜头缓慢推进 | 第一条:对外会议禁用 | 第二条:内部告知划边界 | 15秒 |
| 4 | 博主站起,背景出现“信任度收益”图表动画 | 第三条:用了立刻补救 | 信任是隐形资产 | 12秒 |
| 5 | 博主回到正面,微笑,手指向上做“收藏”手势 | 点赞收藏,别再踩坑 | 8秒 |
## 适配平台
- **抖音**:前3秒加一个“你中招了吗”的悬念语气,用快切放大“73%”等数据,字幕用黄色大字突出。
- **视频号**:开场加一句“我做商务8年,今天说个血泪教训”,增加真实感和信任感;节奏略慢,留出互动引导时间。
- **小红书视频**:首帧用“🔥AI换脸避坑指南”彩色标题,口播再简练10%,强调“收藏备用”;视频下方配图文摘要文字。
@@ -0,0 +1,19 @@
## 标题(爆款钩子)
我用AI替身开视频会,结果被客户当场识破,丢了200万的单
## 口播文案
上个月我哥们儿跟我喝酒,差点哭了[停顿]他说他为了赶一个跨国客户的视频会,用了最高级的AI换脸软件——皮肤零瑕疵、微笑完美,连声音都调了音。结果你猜怎么着?会议开到一半,对方突然问:“你本人是不是没在镜头前?”[停顿]原来客户那边有人工智能识别系统,发现他瞳孔反光不对,微表情卡顿。当场翻脸,200万的单子黄了[停顿]你以为AI替身能省时间?错了[停顿]大脑天生对“假脸”拉警报,瞳孔放大、心跳加速,你根本没法建立信任。老板们醒醒,效率再高也换不来真心合作。你身边有公司因为AI形象吃过亏吗?评论区聊聊。
## 分镜建议
| 画面 | 字幕 | 时长 |
|------|------|------|
| 中景:博主坐在桌前,表情无奈摇头 | 哥们儿用AI替身开会 单子黄了 | 0-5s |
| 特写:博主手指敲桌面,语气加重 | 客户突然问:你本人不在? | 5-15s |
| 切到手机/电脑屏幕动画:AI脸+显微镜下瞳孔异常 | 瞳孔反光不对 微表情卡顿 | 15-25s |
| 博主直视镜头,双手摊开 | 大脑对假脸拉警报! | 25-35s |
| 博主低头写笔记状,抬头问 | 你身边有公司吃过亏吗?评论区聊聊 | 35-45s |
## 适配平台
- **抖音**:前3秒加“你敢信?”等悬念字幕,节奏再快一点,每秒换画面。
- **视频号**:保持自然聊天的语气,结尾加一句“关注我,了解更多职场避坑技巧”。
- **小红书视频**:封面用大字“AI换脸翻车实录”,口播语速放慢,每句之间留呼吸感。
@@ -0,0 +1,29 @@
## 标题(爆款钩子)
别再挤景区了!4.2 亿人都在假装过端午,你还要继续吗?
## 口播文案
承认吧,你端午那趟旅行,根本不算过节,只是去当了个“人肉背景板”![停顿]
2026 年端午,4.2 亿人涌进景区。你拍的是密密麻麻的后脑勺,回家翻相册,除了证明“我去过”,对屈原、龙舟的理解,还停留在小学课本那两句话。[停顿]
调查显示,超六成人都在“走马观花”。问题真不在节日没内涵,而是你太懒了![停顿]你只会被动跟着人流挪动,却忘了主动探索。
换个思路试试?让 AI 做你的私人文化向导。[停顿]输入“屈原流放路线”或“龙舟制作工艺”,它立马生成专属路径。不再是大锅饭式的打卡,而是穿越千年的深度对话。
现在,四成游客愿意为这种“独家体验”多付 30% 的钱。[停顿]因为大家终于醒了:真正的文化,从来不在拥挤的人堆里,而在你主动迈出的那一步。
今年假期,你是继续去挤照片,还是用 AI 定制一场真正的文化穿越?评论区告诉我!
## 分镜建议
| 画面 | 字幕 | 时长 |
| :--- | :--- | :--- |
| **近景**:博主对着镜头一脸严肃,背景合成极度拥挤的景区人头攒动画面,甚至有点压抑感。 | 4.2 亿人<br>全是背景板? | 5s |
| **中景**:博主无奈地翻看手机相册,屏幕特写显示全是模糊的人头照,随后画面切换成枯燥的课本插图。 | 走马观花<br>等于没去 | 8s |
| **特写**:博主眼神突然亮起,手持手机展示 AI 界面,手指滑动生成一条炫酷的 3D 地图路线。 | 拒绝被动<br>AI 定制路径 | 10s |
| **混剪**:快速闪过几个高质量画面:亲手触摸龙舟木纹、在屈原故里听沉浸式故事,对比之前的拥挤画面。 | 深度体验<br>值得多付 30% | 10s |
| **近景**:博主回到镜头前,身体前倾,发出真诚邀请,屏幕下方弹出评论框引导。 | 你是哪种?<br>评论区见 | 7s |
## 适配平台
- **抖音**:前 3 秒拥挤画面要配合嘈杂的环境音效和急促的 BGM,制造强烈的焦虑感,随后音乐瞬间转为科技感轻音乐。
- **视频号**:文案中“小学课本”、“文化穿越”等情感共鸣点要加重语气,结尾引导转发给“只会打卡的朋友”。
- **小红书**:封面图要用"AI 定制路线 vs 传统人挤人”的强烈对比拼图,标题加上"🆘避雷”和"✨小众玩法”emoji。
@@ -0,0 +1,23 @@
## 标题(爆款钩子)
端午出游4.2亿人,AI教你3招避开人挤人
## 口播文案
你是不是也这样?[停顿]端午挤进景区,举着手机拍的全是人头,回家翻相册就剩“我去过”三个字。[停顿]超过六成游客都说体验太同质化,走马观花。[停顿]问题出在哪?不是节日没文化,是你太被动。[停顿]今天直接给你3条干货,让AI帮你定制专属文化路线,彻底告别“人从众”。[停顿]
第一条:别搜攻略,先问自己。[停顿]打开手机,输入“屈原流放路线”“端午习俗演变”或者“现代龙舟怎么造”,AI就能秒出主题路线。[停顿]第二条:别只看景点,要看故事。[停顿]AI会沿途穿插历史讲解、互动体验,让你从“拍照”变成“沉浸”。[停顿]第三条:多花30%的钱,换100%的深度体验。[停顿]最新数据说,超过四成游客愿意为个性化深度体验多付费,AI让这钱花得值。[停顿]
赶紧试试,评论区告诉我你用过AI定制旅行吗?[停顿]点赞收藏,下次出游再也不当背景板!
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 近景:博主对着镜头,手机展示假期人山人海的视频片段 | 端午出游4.2亿人 | 8秒 |
| 2 | 中景:博主手势比划,手机屏幕出现“屈原流放路线”输入框 | 6成游客:体验同质化 | 10秒 |
| 3 | 特写:手机屏幕快速滑动,显示AI生成的路线图、历史故事卡片、互动体验图标 | AI定制路线:输入→秒出 | 10秒 |
| 4 | 中景:博主拿着手机,表情兴奋,画面上方弹出数据图“40%游客愿多付30%” | 多花30%,换100%深度 | 8秒 |
| 5 | 近景:博主笑着对镜头,手指向屏幕“评论区见” | 评论区聊聊你的AI旅行 | 4秒 |
## 适配平台
- **抖音**:前3秒上“人潮拥挤”的实拍画面+快节奏卡点音乐,字幕用动态大字强调“4.2亿”。
- **视频号**:语速放慢10%,开头加一句“端午刚过,你踩雷了吗?”引发共鸣,结尾引导点赞转发。
- **小红书视频**:封面用“AI定制旅行”效果图,文案侧重“小众玩法”“拒绝人挤人”,画面多展示手机操作界面。
@@ -0,0 +1,29 @@
## 标题(爆款钩子)
端午挤到怀疑人生?她用AI玩出了新高度
## 口播文案
上周末端午,我闺蜜兴冲冲去古镇打卡,[停顿]结果你猜怎么着?朋友圈发的全是人头,连屈原像都没挤到跟前。回来她跟我说:感觉自己就是个移动的背景板,除了累,啥也没记住。[停顿]
其实,问题不是节日没意思,是我们太“被动”了。跟着人流走,走到哪拍到哪,回家一翻相册——哦,我去过。[停顿]
但如果反过来呢?你打开手机,让AI根据你的兴趣规划一条专属路线:喜欢历史,就走屈原流放路线;喜欢手艺,就去学包粽子、看龙舟怎么做。[停顿]我闺蜜后来试了一下,她说那天她才知道,五彩绳原来有五种含义,不是随便戴的。
数据也说了,超四成人愿意多花30%的钱,就为了这种“私人定制”的体验。[停顿]所以别再当“人挤人”里的沙粒了,主动一点,让文化真正走进你心里。[停顿]
你觉得呢?评论区聊聊你遇到过最坑的旅行经历。
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 中景:博主对着镜头,表情略带无奈,手里拿手机翻照片 | 端午古镇,全是人头 | 8秒 |
| 2 | 插入画面:景区人山人海的短视频素材(或模拟),然后切回博主 | 六成人觉得走马观花 | 10秒 |
| 3 | 特写:博主手指在手机屏幕上滑动,屏幕出现AI定制界面(可后期特效) | 输入“屈原流放路线” | 12秒 |
| 4 | 博主回忆状,微笑 | 五彩绳原来有五种含义 | 10秒 |
| 5 | 博主正对镜头,语气坚定,手势强调 | 别再做背景板了 | 8秒 |
(总时长约48秒,可适当调整节奏)
## 适配平台
- **抖音**:前3秒加一个“人多到哭”的音效或变脸特效,快速抓注意力。
- **视频号**:开头增加一个朋友圈实拍片段(人头),强化真实感,结尾引导点赞转发。
- **小红书视频**:画面加柔和滤镜,字幕用可爱字体,突出“闺蜜故事”的亲切感,文末加话题标签。
@@ -0,0 +1,19 @@
## 标题(爆款钩子)
🔥你的Logo设计单,正被邻居用旧电脑10分钟抢走!
## 口播文案
你刚接的那个Logo单,[停顿]可能正被隔壁邻居用一台三年前的笔记本,十分钟搞定。[停顿]别不信——2026年,Stability AI的端侧模型,能让普通笔记本15秒出商业级矢量图,成本只有云端的1/50。[停顿]翻译更惨,谷歌的离线实时口译,准确率已经92%,你还在按字收钱?[停顿]客户发现:本地跑AI不仅免费,隐私还更安全。[停顿]那咱们就等死吗?[停顿]不,自救关键是往“非标”钻——复杂人际博弈、现场应急决策、情感陪伴,这些端侧AI学不会。[停顿]立刻停止卖标准时间,转做深度咨询和定制方案,把AI变成你的杠杆。[停顿]听懂了的,评论区扣“转型”。
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 特写:手机屏幕显示一个Logo设计稿,然后一只手把设计稿推开 | 你的Logo单,邻居10分钟搞定 | 8秒 |
| 2 | 中景:博主坐在电脑前,表情严肃,手比划“15秒” | Stability AI端侧模型:15秒出图,成本1/50 | 10秒 |
| 3 | 切换:手机画面展示谷歌翻译界面,然后快速切换成离线模式 | 离线实时口译,准确率92%,按字收费模式已死 | 12秒 |
| 4 | 近景:博主身体前倾,眼神坚定,手指点向镜头 | 自救:深耕“非标”领域,复杂博弈、情感陪伴 | 10秒 |
| 5 | 特写:博主手拿话筒,背景虚化,字幕滚动 | 停止卖时间,做深度咨询,AI是你的杠杆 | 8秒 |
## 适配平台
- **抖音**:前3秒用冲突画面+快节奏剪辑,字幕用动态大字,激发完播。
- **视频号**:口播语速稍慢,增加“认同吗?评论区聊聊”等互动引导,强化信任感。
- **小红书视频**:封面用“2026自由职业生存指南”文字,口播中加入“干货警告”,结尾加话题标签。
@@ -0,0 +1,27 @@
## 标题(爆款钩子)
端侧AI爆发!3条自救指南,不想被淘汰的立刻看
## 口播文案
你还在做这3种活儿吗?赶紧停![停顿]
第一种,设计外包。你熬三天做的Logo,邻居用三年前的笔记本,15秒出图,成本只有你报价的1/50。客户发现本地跑AI免费还安全,你的报价单下季度就废了。[停顿]
第二种,翻译文案。谷歌的离线口译延迟不到0.2秒,医疗法律准确率92%,客户凭什么还按字付你钱?记住,AI不是抢你饭碗,是让“文字搬运工”技能归零。[停顿]
第三种,卖成品稿件。35%的中小企业已经用本地营销代理自动生成海报文案了,你要是不转型成训练这些智能体的“教练”,就会被踢出局。[停顿]
自救就一条路:深耕端侧AI搞不定的“非标”领域。比如复杂人际博弈、现场应急决策、高度情感陪伴。别卖标准时间,卖信任和深度咨询,把AI当杠杆,处理人类才能定义的模糊地带。[停顿]
现在,立刻,检查你的业务有没有被这三条砸中?评论区聊聊。
## 分镜建议
- 镜头1:特写,博主对着镜头,表情严肃,手指向屏幕。字幕:“3种职业正在被AI干掉”。时长:8秒
- 镜头2:切画面,展示AI快速生成Logo的对比(左边是你熬夜加班,右边是电脑15秒出图)。字幕:“设计外包→15秒出图”。时长:15秒
- 镜头3:博主口播,背景闪现谷歌翻译界面和字幕。字幕:“翻译文案→92%准确率”。时长:15秒
- 镜头4:博主口播,屏幕显示Gartner报告数据。字幕:“35%企业用AI代理→你该做教练”。时长:15秒
- 镜头5:博主面向镜头,语气坚定,手指点向观众。字幕:“自救:深耕非标领域,卖信任”。时长:10秒
## 适配平台
- **抖音**:开头加“别划走,3条干货救你饭碗”,语速更快,每句留1秒停顿。
- **视频号**:开头加“这条视频可能会被限流,但建议你看到最后”,语气更稳重,停顿稍长。
- **小红书视频**:开头加“姐妹们,别再踩坑了”,结尾加“关注我,只分享能用的干货”,画面更干净,字幕加emoji。
@@ -0,0 +1,17 @@
## 标题(爆款钩子)
你接的Logo单,邻居用旧电脑10分钟搞定|端侧AI来了
## 口播文案
上个月,一个朋友接了个Logo单子,报价3000块。[停顿]结果呢?他隔壁邻居,一个从没学过设计的大学生,用一台三年前的旧笔记本,装了最新的AI工具,十分钟出了三套方案。客户直接取消订单。[停顿]你可能会觉得夸张,但这就是2026年正在发生的事——端侧AI已经能本地跑商业级矢量图,15秒出图,成本是云端的五十分之一。[停顿]翻译、文案、设计这些自由职业者还在按字收费?客户已经发现:AI翻译带情感色彩和方言,能达到初级译员水平。[停顿]所以,如果你还在卖“时间”和“技能”,你的报价单下个季度就废了。[停顿]真正的出路不是跟AI比快,而是做AI做不了的事——比如复杂人际博弈、现场决策、情感陪伴。你得从“工具人”变成“教练”,帮客户调教本地AI,而不是被AI替代。[停顿]
## 分镜建议
- 镜头1(近景):博主对着镜头,表情略带无奈,手里拿着一张画着Logo的纸。字幕:“上周接了个Logo单子,报价3000。” 时长:8秒
- 镜头2(中景):切到博主旁边放一台旧笔记本电脑,屏幕快速闪过AI生成Logo的过程。字幕:“邻居大学生,十分钟出三套方案。” 时长:10秒
- 镜头3(近景):博主手指屏幕,语气加重。字幕:“15秒出图,成本是云端的1/50。” 时长:6秒
- 镜头4(全景):博主站起来,伸出双手做“停止”动作。字幕:“还在按字收费?客户已经不需要你了。” 时长:8秒
- 镜头5(特写):博主指向自己胸口,眼神坚定。字幕:“做AI做不了的,当教练。” 时长:8秒
## 适配平台
- **抖音**:前3秒用“你接的Logo单子”开头,快速钩住注意力;节奏紧凑,加上热门BGM。
- **视频号**:偏重情绪共鸣,可以放大“邻居大学生”的故事冲突,结尾多停留两秒引导点赞。
- **小红书视频**:封面用“3000块的单子被AI抢了”醒目大字,文案更干货,附上“你中招了吗”互动话题。
@@ -0,0 +1,17 @@
## 标题(爆款钩子)
做了8年SEO,流量却暴跌68%?你的文章正在给AI打工!
## 口播文案
老张上周在群里崩溃了,写了8年SEO,花三天打磨一篇爆款,结果流量掉了68%。[停顿] 他不信邪,搜自己标题——排在第一条的,不是他的文章,而是AI的摘要。[停顿] 你的完美SEO文章,正在变成AI搜索的免费数据源。这不是危言耸听。[停顿] 去年Google推出AI概览,直接在你内容里抽答案,用户根本不用点进来。你堆的关键词、铺的内链,全成了AI的养料。[停顿] 怎么办?别再写那种标准答案式的文章了。要写AI不会写的东西:你的个人故事、独家数据、争议观点。[停顿] 当你的内容不可替代时,AI只能把你当参考来源,而不是直接吃掉。[停顿] 记住,未来的SEO不是讨好算法,是成为AI不敢忽视的权威。评论“转型”,我教你具体做法。
## 分镜建议
- **镜头1**(0-8秒):博主面对镜头,表情严肃,背景是电脑显示SEO数据图表。字幕:“你的文章正在给AI打工”。时长8秒。
- **镜头2**(8-18秒):切换手机屏幕画面,展示一个AI搜索结果,红色圆圈圈出摘要框。字幕:“用户根本不用点进去”。时长10秒。
- **镜头3**(18-30秒):博主手比划,旁边放一块白板,画“旧SEO”和“新SEO”两个对比概念。字幕:“别再堆关键词”。时长12秒。
- **镜头4**(30-45秒):博主微笑自信,手指向自己胸前。字幕:“成为AI不敢忽视的权威”。时长15秒。
- **镜头5**(45-60秒):博主指向屏幕外,做出引导互动的动作。字幕:“评论‘转型’”。时长15秒。
## 适配平台
- **抖音**:前三秒必须情绪拉满,建议把“流量暴跌68%”做成动态大字炸屏,配合紧张BGM。
- **视频号**:增加1-2秒行业数据引用画面(如Google AI概览覆盖率),强化专业感和可信度。
- **小红书视频**:开头用“刷到这条视频你赚到了”代替直接冲突,结尾引导收藏并@好友,提高互动率。
@@ -0,0 +1,43 @@
## 标题(爆款钩子)
SEO已死?3招让AI主动抓你的文章,流量翻10倍!
## 口播文案
做SEO的老铁注意了![停顿]你花三天写的“完美文章”,标题塞满关键词,内链铺了12个,结果流量掉68%?原因很简单——AI搜索时代,你的文章被当免费数据喂给机器了![停顿]今天直接给三条能立刻上手的干货,让你成为AI的“必引内容”。
第一,别堆关键词了,改成结构化排版。用清晰的小标题、短段落、加粗重点、加列表。AI喜欢直接抓取“答案盒子”,你写成散文它理都不理。[停顿]
第二,疯狂建外链!让高权重网站引用你,AI搜索的排序算法超级看重反向链接。找个行业大号互推,或者去知乎、quora回答问题时顺手贴链接。[停顿]
第三,给你的文章加Schema标记。百度、谷歌都有免费工具,一键生成结构化数据标签,告诉AI这篇是“教程”还是“评测”。[停顿]
记住:以后你不是写给人看,是写给AI看的。AI先读,再推荐给你用户。三条立马去执行,下个月搜索广场就是你首页![停顿]
## 分镜建议
- 画面:博主对着镜头,表情焦虑,手里拿手机显示流量曲线暴跌图
字幕:SEO老张的惨痛教训
时长:5秒
- 画面:快速切换三个关键词卡片:结构化排版、外链引用、Schema标记,每个卡片停留1秒
字幕:3招立刻上手
时长:5秒
- 画面:博主手指屏幕,展示一个清晰的结构化文章示例(有小标题、列表)
字幕:AI喜欢“盒子内容”
时长:8秒
- 画面:博主做出打电话动作,画外显示“联系大V互推”字样
字幕:外链=流量密码
时长:6秒
- 画面:博主演示在搜索引擎工具页面点选Schema类型
字幕:一行代码让AI秒懂你
时长:6秒
- 画面:博主自信微笑,手指向镜头,文字弹出“现在就去改你的文章!”
字幕:行动才有流量
时长:5秒
## 适配平台
- 抖音:开头加个“你中招了吗”的钩子,字幕用更大字号,前3秒必须出现文题。
- 视频号:节奏稍慢,每招后加1秒表情特写,适合中年创业者观看习惯。
- 小红书视频:封面做对比图“旧SEO vs 新AI搜流”,正文精简到3个要点图,口播语速放慢。
@@ -0,0 +1,23 @@
## 标题(爆款钩子)
AI换脸开会?省了10分钟,丢了18%客户!
## 口播文案
你有没有碰到过这种场景?[停顿]打开视频会议,对面坐着个皮肤零瑕疵、眼神却发僵的“AI替身”。是不是瞬间觉得怪怪的?[停顿]别怪你太敏感,加州大学研究发现,见到这种“完美脸”,你的大脑会本能拉响警报——瞳孔放大,皮肤电反应飙升,这是进化留给你的保命本能。[停顿]
但很多人还在吹AI换脸多高效:会议时间缩短12%![停顿]可你知道另一面吗?同一家公司,员工抱怨“会议变陌生”“判断不了对方有没有在听”的比例,暴涨了41%。[停顿]更致命的是,一家欧洲金融科技公司用了AI形象后,客户流失率18个月内飙升18%——客户觉得你在“省成本”,而不是“在乎关系”。[停顿]
所谓效率,不过是在透支信任。[停顿]信任崩塌,比效率低更致命。你公司敢用AI换脸开会吗?评论区聊聊。[停顿]
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 主播正面特写,表情略带疑惑 | “AI换脸开会?省了10分钟,丢了18%客户!” | 5秒 |
| 2 | 画面插入数据对比图:左边“会议缩短12%”,右边“信任下降41%” | 会议缩短12% vs 信任下降41% | 8秒 |
| 3 | 主播用手比划大脑,背景出现杏仁核示意图 | 大脑拉响警报:瞳孔放大,皮肤电升高 | 10秒 |
| 4 | 切换场景:金融公司logo+客户流失曲线图 | 客户流失率18个月上升18% | 8秒 |
| 5 | 主播回到正面,直视镜头,摊手提问 | 你公司敢用AI换脸吗?评论区聊聊 | 7秒 |
## 适配平台
- **抖音**:前3秒用“你肯定遇到过”情绪钩子,节奏加快,字幕多用大号关键词。
- **视频号**:适当增加数据引用(如“盖洛普2026年报告”),语气更沉稳,强化信任与效率的理性对比。
- **小红书视频**:开头用“姐妹们,这事太离谱了”拉近距离,结尾引导收藏和“@你的老板来看”。
@@ -0,0 +1,42 @@
## 标题(爆款钩子)
AI换脸被职场封杀?这3个真相让你立刻关掉它
## 口播文案
你知道吗?用AI换脸开视频会议,73%的客户会直接降低对你的信任。[停顿]卡内基梅隆大学的研究实锤了——就算看起来再逼真,大脑杏仁核也会本能拉响警报,觉得“这人不对劲”。[停顿]
而且啊,你以为节省了时间?哈佛实验发现,和AI形象互动时,共情脑区活跃度整整下降30%。[停顿]员工匿名抱怨增加了41%——说“感觉会议变得陌生”。效率背后,是看不见的信任透支。[停顿]
更扎心的是,“完美形象”反而让人不舒服。斯坦福的研究说,AI永远模仿不了微表情的那些细微抖动,你的社交脑会陷入矛盾:看起来是人,直觉告诉你不是,持续消耗你的注意力。[停顿]
所以干货来了,三条立刻能用的:
第一条,对外客户会议、重要谈判、一对一辅导——绝对禁用AI脸。[停顿]
第二条,内部非正式头脑风暴可以用,但必须提前告知对方。[停顿]
第三条,每次想用AI形象前,先问自己三个问题:客户介意吗?团队能真正连接吗?如果因此失去一个客户,你接受吗?[停顿]
信任积累要几年,崩塌只需要一张AI脸。点赞收藏,别让效率毁了你的职场关系。
## 分镜建议
- 画面:博主面对镜头,表情严肃,手指指向屏幕上的“73%”数据字幕
- 字幕:73%的客户信任直线下降
- 时长:8秒
- 画面:快速切换大脑示意图/脑区动画,然后切回博主说话
- 字幕:共情脑区活跃度下降30%|员工抱怨+41%
- 时长:8秒
- 画面:展示一张完美AI人脸图片,然后手指划掉,出现“抖动的微表情”文字
- 字幕:完美 ≠ 真实|社交脑陷入矛盾
- 时长:8秒
- 画面:博主用手势比划“三”,然后逐条列出文字,配合手写动画
- 字幕:三条实操:①对外禁用②内部告知③三问自检
- 时长:12秒
- 画面:博主微笑总结,手势做“点赞”动作,结尾留钩子
- 字幕:信任积累几年,崩塌只需一张AI脸
- 时长:7秒
## 适配平台
- **抖音**:开头放5秒恐怖谷动画吸引眼球,全程快剪+高能字幕,结尾引导点赞收藏。
- **视频号**:节奏稍慢0.5倍,中间加入情绪停顿(比如叹息),适合熟人传播信任感。
- **小红书视频**:标题改成“打工人避坑!AI换脸会议避雷指南”,重点突出“亲测”“血泪教训”人设,评论区引导提问。
@@ -0,0 +1,22 @@
## 标题(爆款钩子)
就因为客户用了AI换脸,我200万的单子一夜黄了
## 口播文案
你知道吗?我有个做销售的朋友,去年年初谈一个200万的大单,[停顿]视频会议里客户全程用了一张AI换脸,皮肤完美、笑容标准,但眼神就是不太对劲。[停顿]他说自己全程浑身不舒服,最后报价都报得没底气。结果呢?客户反馈说“感觉你们公司不真诚”,直接选了别家。[停顿]
这不是个例啊。加州大学研究早就证明了,人看到“完美但空洞”的AI脸时,大脑会本能拉响警报——瞳孔放大、皮肤电反应升高,这是几百万年进化保留下来的警惕机制。[停顿]卡内基梅隆大学更狠:73%的商务谈判参与者,只要发现对方用了AI形象,信任直接打骨折。[停顿]
所以现在很多大公司开始明文封杀AI换脸,不是抵制效率,是害怕信任崩塌。[停顿]想想看,一场会议省了12%的时间,却要多花几十次真诚沟通才能挽回信用,这笔账你算得过来吗?[停顿]
你觉得AI形象该在职场用吗?评论区聊聊你的真实感受。[停顿]点赞收藏,别让更多人踩这个坑。
## 分镜建议
| 镜头 | 画面(景别/动作/场景) | 字幕(关键词) | 时长 |
|------|------------------------|----------------|------|
| 1 | 近景,主播在办公室对着镜头,表情略带懊恼 | “我朋友丢了200万的大单” | 8秒 |
| 2 | 插入视频会议画面,客户AI形象在微笑,眼神空洞,画面加恐怖谷特效 | “客户用的是AI换脸” | 6秒 |
| 3 | 切回主播近景,手指敲桌子加强语气 | “73%的人信任度下降” | 6秒 |
| 4 | 画面出现两个天平:左边是“省时间12%”,右边是“5-8次真诚沟通”,天平向右倾斜 | “效率 vs 信任,你选哪个?” | 6秒 |
| 5 | 主播直视镜头,做出询问表情,最后挥手 | “评论区等你故事” | 4秒 |
## 适配平台
- **抖音**:开头前3秒用“200万”+“黄了”制造强冲击,字幕加粗放大,节奏紧凑,可加#AI换脸职场封杀 话题标签。
- **视频号**:保留更多数据细节(73%、12%),结尾可引导关注账号获取“信任修复清单”,适合知识传播。
- **小红书视频**:封面用“❌AI换脸毁了我的大单”黄色大字,文案增加情绪词“气死了”“凭什么”,评论区引导互动共鸣。
@@ -0,0 +1,33 @@
## 标题(爆款钩子)
AI 预警快 15 分钟?别高兴太早,误报可能更要命!
## 口播文案
大家都觉得 AI 预警龙卷风是救星,能抢回救命 15 分钟?[停顿]
大错特错!
如果告诉你,这多出来的 15 分钟,可能是把你骗进死胡同的“催命符”,你信吗?[停顿]
传统雷达确实慢,要等风成型才报警。但 AI 太“敏感”了!
去年美国中部那场大乌龙还记得吗?[停顿]
AI 把一团普通云彩看成了超级龙卷风,几百万人半夜狂奔逃生,结果呢?连个风影都没见着。
这就是最可怕的“狼来了”效应。[停顿]
一旦误报多了,下次真龙卷风来了,你还会跑吗?
你会想:“又是 AI 抽风吧?”然后继续睡觉。
技术是快了,但信任没了,这才是最大的安全隐患。[停顿]
所以,别盲目吹捧 AI 万能。
在生死面前,快,不一定就是好。
你觉得,是宁可错杀一千,还是宁可慢那十几分钟?[停顿]
评论区告诉我你的选择。
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
| :--- | :--- | :--- | :--- |
| 1 | 主播怼脸特写,表情严肃甚至带点质疑,手做“停止”手势 | AI 预警=救星?<br>大错特错! | 5s |
| 2 | 快速剪辑:新闻画面中人群惊慌奔跑 vs 平静无风的街道(对比强烈) | 百万人大逃亡<br>结果:0 伤害 | 15s |
| 3 | 主播侧身,背景出现“狼来了”的卡通插图或警报器失灵特效 | 狼来了效应<br>信任崩塌 | 15s |
| 4 | 主播回到正脸,眼神诚恳,语速放缓,强调观点 | 快≠好<br>信任>速度 | 15s |
| 5 | 主播抛出问题,身体前倾,引导互动,屏幕出现投票条样式 | 宁可错报?<br>还是宁可慢? | 10s |
## 适配平台
- **抖音**:开头前 3 秒语速要更快,背景音乐用紧张的倒计时音效,强化冲突感。
- **视频号**:在“狼来了”段落可插入真实的新闻误报片段截图,增加中老年观众的信服力。
- **小红书**:封面图要用大字报风格写上“误报比迟到更可怕”,文案中增加 emoji 软化语气,引导讨论“安全感”。
@@ -0,0 +1,21 @@
## 标题(爆款钩子)
龙卷风预警提前15分钟?3个救命技巧别等误报后悔
## 口播文案
你知道吗?传统龙卷风预警平均只留给你13分钟![停顿]AI现在能提前15分钟,但误报率飙高。别慌,这3条干货,现在就能用上。
第一,别只盯手机短信。[停顿]气象局APP、本地应急广播全开,多个渠道多一重确认。第二,学会看天。漏斗云、旋转灰黑云团,就是死神敲门。别等警报响才跑,看到反常就撤。第三,提前找避难所。地下室、内部小房间,别躲车库或活动房。AI误报不怕,怕你麻痹大意。[停顿]记住,预警是提醒,自己预判才是保命符。评论区说说,你收到过假警报吗?
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 主播面对镜头,手指比划数字“13”和“15” | 13分钟 vs 15分钟,谁更快? | 5秒 |
| 2 | 切换手机预警通知截图,快速划过多个APP图标 | 多渠道预警,别只等短信 | 8秒 |
| 3 | 插入龙卷风来临前天空实拍(漏斗云旋转) | 漏斗云=死神敲门,看到就跑 | 10秒 |
| 4 | 主播手指向下指,背景出现地下室示意图 | 地下室最安全!别躲车库 | 8秒 |
| 5 | 主播回归镜头,微笑提问 | AI误报不可怕,麻痹才要命 | 7秒 |
## 适配平台
- **抖音**:前3秒用强冲突字幕“你的预警短信可能比死神晚到”,节奏更快,加热门音效。
- **视频号**:开头加“点赞收藏保命”引导互动,结尾带话题#极端天气自救指南
- **小红书视频**:封面用醒目大字“3条龙卷风保命干货”,口播语速稍慢,强调“提前准备”。
@@ -0,0 +1,32 @@
## 标题(爆款钩子)
龙卷风撕碎屋顶那刻,救命短信竟迟到了 13 分钟?
## 口播文案
2026 年 7 月,湖北那个下午,老张正修着屋顶。[停顿]
突然,天像被撕开个大口子,灰黑色的漏斗云直插下来。
就在瓦片满天飞、老张下意识抱头的那一秒,手机终于响了。
是一条红色预警短信。[停顿]
可这时候,跑还来得及吗?
这不是电影,是真实发生的“生死时间差”。
传统雷达太“慢热”了,非要等龙卷风完全成型才报警,平均晚整整 13 分钟!
对你我来说,这 13 分钟,就是生与死的距离。
有人寄希望于 AI 能快那 15 分钟,可去年美国那场大规模误报,又让几百万人白跑一趟,吓出心脏病。[停顿]
太快怕误报,太慢怕没命。
在极端天气面前,我们到底该怎么跑赢死神?
这道题,气象学家还在争,但留给我们的思考时间,真的不多了。[停顿]
你觉得,这命该交给算法,还是交给经验?评论区聊聊。
## 分镜建议
| 镜头 | 画面描述 | 字幕关键词 | 时长 |
| :--- | :--- | :--- | :--- |
| 1 | **特写 + 音效**:手机屏幕特写,显示时间跳动,背景音是呼啸的风声和瓦片碎裂声,突然弹出红色短信通知。 | 迟到的救命短信 | 5s |
| 2 | **中景/情景再现**:一个中年男子(老张)在屋顶惊慌失措,背景是合成特效的灰黑色漏斗云逼近,色调压抑。 | 生死 13 分钟 | 8s |
| 3 | **近景/口播**:博主面对镜头,神情严肃,手势比划出“时间差”的概念,背景可配一张传统雷达波形图。 | 传统雷达太“慢热” | 10s |
| 4 | **快剪/资料画面**:快速闪过美国新闻画面,人群惊慌奔跑,随后打上大大的红色问号“误报?”。 | AI 也会“狼来了”? | 8s |
| 5 | **特写/口播**:博主眼神直视镜头,抛出问题,画面逐渐变暗,只留眼神光。 | 你选速度还是准确? | 5s |
## 适配平台
- **抖音**:前 3 秒必须把“瓦片乱飞 + 短信迟到”的冲突画面剪在一起,配合急促的音效,完播率才高。
- **视频号**:文案结尾可加重“家人安全”的情感引导,评论区多引导中年用户分享身边的极端天气经历。
- **小红书**:封面图要用“漏斗云实拍 + 大字报标题”,文案中增加“避雷指南”或“自救干货”的标签属性。
@@ -0,0 +1,25 @@
## 标题(爆款钩子)
一道初中数学题,让GPT-4o集体死机:AI的数学智商到底有多差?
## 口播文案
你敢信?[停顿]那个能写论文、写代码、碾压围棋的AI,竟然被韦东奕的一道初中数学题给整破防了![停顿]
GPT-4o、Claude 3.5,还有所谓的“专用数学模型”……全给出错误答案,甚至直接放弃![停顿]可这道题,就是一个聪明的高中生花半小时也能做出来。[停顿]
为什么?因为AI根本没有“数学直觉”。[停顿]它只会疯狂穷举、暴力检索,遇到需要构造辅助线、灵光一现的题,立马变成傻子。[停顿]
说白了,人类最后一道防线不是算力,而是那一瞬间的“灵感”。[停顿]你还在替AI焦虑?先问问自己:你的直觉,还活着吗?
## 分镜建议
| 镜头 | 画面 | 字幕(关键词) | 时长 |
|------|------|----------------|------|
| 1 | 博主直视镜头,表情略带挑衅 | “一道初中数学题,AI集体死机” | 3秒 |
| 2 | 快速闪过韦东奕照片,叠加大模型logo(GPT-4o、Claude等) | “韦神出题 vs 四大AI” | 5秒 |
| 3 | 展示一张几何题图(辅助线题型)+红色叉号 | “全错!甚至直接放弃” | 4秒 |
| 4 | 博主手势比划“思路”,背景出现灯泡图标 | “人类数学直觉:灵光一现” | 5秒 |
| 5 | 博主摊手,嘴角微笑 | “你的直觉,还活着吗?” | 3秒 |
## 适配平台
- **抖音**:前3秒用“你敢信?一道初中题”直接引爆好奇心,全程语速加快,每句后留0.5秒反应时间。
- **视频号**:开头加一句“这可能是你今年听到最反认知的事”,结尾改成“转发给还在神化AI的朋友”增强社交互动。
- **小红书视频**:首页配图文标题“AI被初中题碾压?真相扎心了”,全程语速放缓,突出“数学直觉”关键词,适合知识收藏。
@@ -0,0 +1,27 @@
## 标题(爆款钩子)
韦神一道题让AI集体死机?3招保住你的数学直觉
## 口播文案
你听说了吗?[停顿]韦东奕出了一道初中数学题,结果GPT-4o、Claude 3.5全体翻车,直接“死机”[停顿]凭什么?因为AI没有数学直觉,它只会套公式,不会构造辅助线。今天给你3条干货,让你在AI时代也能守住人类最后的防线。
第一条:别让AI替你思考。[停顿]遇到难题,先自己画图、写步骤,逼自己找突破口。AI给答案太快,你根本没机会训练直觉。
第二条:刻意练习“开放式问题”。[停顿]平面几何、数论证明这类题,没有标准解法,需要你构造辅助线、猜规律。每天一道,大脑就会长出“直觉肌肉”。
第三条:把AI当陪练,别当老师。[停顿]做完题,再用AI验证思路,看它怎么错。你发现它卡在哪,你就在哪赢了。
这三招,今天就能用,别等脑子生锈。评论区告诉我,你遇到过AI算不出来的题吗?
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 近景:博主对着镜头,表情惊讶,手指比划 | 韦神一道题,AI集体死机? | 5秒 |
| 2 | 中景:博主拿出一张白纸,画一条辅助线,然后摇头 | 没有数学直觉,只会套公式 | 8秒 |
| 3 | 特写:博主用手势模仿思考,然后指向自己 | 别让AI替你思考,先自己画图 | 10秒 |
| 4 | 中景:博主展示一张几何题,用笔圈出关键点 | 每天一道开放式问题,训练直觉肌肉 | 10秒 |
| 5 | 近景:博主拿起手机,假装和AI对话,然后微笑 | 把AI当陪练,看它怎么错 | 8秒 |
## 适配平台
- **抖音**:前3秒用“韦神+AI翻车”标题+悬念剪辑,节奏更快,字幕加粗放大。
- **视频号**:增加“你同意吗?”结尾互动,结合朋友圈转发话术,强调“人类优势”。
- **小红书视频**:标题改为“AI翻车后我悟了:3个数学直觉训练法”,封面用几何题+红色感叹号。
@@ -0,0 +1,19 @@
## 标题(爆款钩子)
韦神一道初中题,AI全部“死机”——人类直觉到底有多强?
## 口播文案
前几天,韦东奕出了一道初中数学题。[停顿]结果你猜怎么着?那些号称能写论文、编代码、替人类打工的顶级AI,像GPT-4o、Claude 3.5,居然集体翻车了。[停顿]是真的“死机”,连思路都理不清。[停顿]我当时看到这个新闻,心里咯噔一下——原来AI再厉害,也学不会那种灵光一现的直觉。[停顿]数学靠的是巧劲儿,是十几年甚至几十年磨出来的感觉。而AI呢?它只会算,不会“想”。[停顿]所以别老焦虑自己被替代了。有些东西,机器就是学不会。比如你脑子里的那根“辅助线”,比如你看到问题突然冒出来的那个解法。[停顿]这,才是我们人类的最后防线。
## 分镜建议
| 镜头 | 画面 | 字幕 | 时长 |
|------|------|------|------|
| 1 | 镜头对准手机屏幕,展示韦东奕照片或新闻截图,快速拉近 | “韦神出题” | 5秒 |
| 2 | 切到电脑屏幕,显示AI回答错误或加载失败动画,配合音效 | “GPT-4o、Claude 3.5……全懵了” | 8秒 |
| 3 | 真人出镜,皱眉、摊手,语气惊讶 | “是不是觉得不可思议?” | 6秒 |
| 4 | 镜头切回真人,一边说一边比划“辅助线”的手势 | “数学直觉,才是人类最后的防线” | 10秒 |
| 5 | 真人微笑,屏幕下方弹出话题标签,结尾定格 | “有些东西,机器就是学不会” | 6秒 |
## 适配平台
- **抖音**:前3秒用快节奏切屏和音效冲突感,字幕加emoji,结尾引导点赞评论“你觉得自己直觉准吗?”
- **视频号**:适当放慢语速,保留停顿感,增加“你身边有没有这样的例子?”互动话术,适合中老年用户传播。
- **小红书视频**:封面用韦东奕照片+“AI翻车”大字,正文加#人类直觉 #韦东奕 标签,文案侧重“普通人如何训练自己的直觉”。
@@ -1,54 +0,0 @@
[
{
"id": "LIVING-001-26",
"title": "零浪费家庭实验:从囤积到精简的100天",
"cases": [],
"audience": "26-35岁城市焦虑青年",
"china_pain_points": "消费主义陷阱、快递包装泛滥、垃圾分类执行难",
"localization_solution": "家庭减法清单、快递包装回收点地图、社区交换市集",
"mvp_actions": "1. 本月减少50%快递包装 2. 建立家庭减法清单 3. 参与社区交换市集",
"estimated_length": 3500,
"priority_score": 85.5,
"field": "可持续生活系统",
"format": "趋势洞察 + 实操指南",
"core_concept": "少即是多,从消费主义回归生活本质",
"audience_pain": "被消费主义裹挟,想改变但不知道从何开始",
"unique_angle": "用数据量化减法效果,用真实案例展示转变过程",
"priority": "高",
"total_score": 85.5,
"compliance_score": 100,
"source_file": "automation/data/sustainability_topics.json",
"ready_at": "2026-05-08",
"published_at": null,
"platform_urls": {},
"status": "待处理",
"lock_by": null,
"lock_at": null
},
{
"id": "TECH-001-26",
"title": "AI副业入门:用DeepSeek实现第一笔收入的100天",
"cases": [],
"audience": "26-35岁城市焦虑青年",
"china_pain_points": "主业不稳定、副业方向迷茫、技能变现难",
"localization_solution": "AI工具链整合、国内平台变现路径、从0到1的实战案例",
"mvp_actions": "1. 学习DeepSeek基础操作 2. 找到第一个付费客户 3. 建立可复制的服务流程",
"estimated_length": 4000,
"priority_score": 88.2,
"field": "科技向善",
"format": "趋势洞察 + 实操指南",
"core_concept": "用AI放大个人能力,实现副业收入",
"audience_pain": "想搞钱但不知道从哪开始,担心AI替代自己",
"unique_angle": "100天真实记录,从0到第一笔收入的完整路径",
"priority": "高",
"total_score": 88.2,
"compliance_score": 100,
"source_file": "automation/data/sustainability_topics.json",
"ready_at": "2026-05-08",
"published_at": null,
"platform_urls": {},
"status": "待处理",
"lock_by": null,
"lock_at": null
}
]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+73 -87
View File
@@ -1,4 +1,4 @@
# 信息源配置(可持续性相关 # 信息源配置(AI与科技人文领域
# 注意:仅保留国内可稳定访问的源,移除被墙/下线的源 # 注意:仅保留国内可稳定访问的源,移除被墙/下线的源
sustainability_sources: sustainability_sources:
@@ -9,16 +9,14 @@ sustainability_sources:
url: "https://36kr.com/feed" url: "https://36kr.com/feed"
update_frequency: "daily" update_frequency: "daily"
credibility: "high" credibility: "high"
focus: "科技商业、绿色经济" focus: "科技商业、AI行业"
keywords: keywords:
- "AI" - "AI"
- "新能源" - "人工智能"
- "可持续" - "大模型"
- "环保" - "科技"
- "绿色" - "数字化"
- "低碳" - "创业"
- "ESG"
- "循环"
- name: "少数派" - name: "少数派"
type: "rss" type: "rss"
url: "https://sspai.com/feed" url: "https://sspai.com/feed"
@@ -31,99 +29,87 @@ sustainability_sources:
- "工具" - "工具"
- "数字" - "数字"
- "智能" - "智能"
- "工作流"
- name: "机器之心"
type: "rss"
url: "https://jiqizhixin.com/rss"
update_frequency: "daily"
credibility: "high"
focus: "AI学术前沿、论文解读"
keywords:
- "AI"
- "论文"
- "大模型"
- "算法"
- "研究"
- "学术"
# 搜索引擎采集(通过Bing中文搜索抓取热点) # 搜索引擎采集(通过Bing中文搜索抓取热点)
# 基于《2025中国可持续消费报告》《绿色低碳消费大数据报告》等权威调研得出的七大热点方向
web_search: web_search:
- name: "循环消费搜索" - name: "AI前沿资讯搜索"
type: "web_search" type: "web_search"
query: "以旧换新 二手交易 闲置 循环 2026" query: "AI 人工智能 大模型 前沿 突破 2026"
update_frequency: "daily" update_frequency: "daily"
credibility: "medium" credibility: "medium"
focus: "以旧换新/二手循环/租赁经济" focus: "AI行业动态、技术突破"
- name: "低碳出行搜索"
type: "web_search"
query: "新能源车 骑行 绿色通勤 低碳出行 2026"
update_frequency: "daily"
credibility: "medium"
focus: "新能源车/骑行/绿色出行"
- name: "干净饮食搜索"
type: "web_search"
query: "干净饮食 有机食品 植物基 本地食材 2026"
update_frequency: "daily"
credibility: "medium"
focus: "干净饮食/有机食品/植物基"
- name: "零浪费生活搜索"
type: "web_search"
query: "零浪费 自带杯 极简生活 可持续时尚 2026"
update_frequency: "daily"
credibility: "medium"
focus: "零浪费/自带杯/极简/可持续时尚"
- name: "绿色家电搜索"
type: "web_search"
query: "绿色家电 一级能效 以旧换新 节能 2026"
update_frequency: "daily"
credibility: "medium"
focus: "绿色家电/家庭节能/国补政策"
- name: "碳普惠搜索"
type: "web_search"
query: "碳账户 碳普惠 个人碳减排 蚂蚁森林 2026"
update_frequency: "daily"
credibility: "medium"
focus: "碳账户/碳普惠/个人碳减排"
- name: "环保科技搜索"
type: "web_search"
query: "环保科技 绿色产品 可持续材料 2026"
update_frequency: "daily"
credibility: "medium"
focus: "环保科技/可持续产品/绿色材料"
- name: "AI工具搜索" - name: "AI工具搜索"
type: "web_search" type: "web_search"
query: "AI工具 人工智能 效率提升 2026" query: "AI工具 效率提升 AI工作流 实用 2026"
update_frequency: "daily" update_frequency: "daily"
credibility: "medium" credibility: "medium"
focus: "AI工具/效率方法" focus: "AI工具评测、效率方法"
- name: "AI职场搜索"
type: "web_search"
query: "AI 裁员 职业转型 AI技能 远程工作 2026"
update_frequency: "daily"
credibility: "medium"
focus: "AI对就业影响、职业转型"
- name: "AI生活化搜索"
type: "web_search"
query: "AI陪伴 AI生活助手 AI心理咨询 智能体 2026"
update_frequency: "daily"
credibility: "medium"
focus: "AI人格化、生活场景应用"
- name: "科技人文搜索"
type: "web_search"
query: "AI伦理 数字生活 科技反思 人机关系 2026"
update_frequency: "daily"
credibility: "medium"
focus: "AI伦理、科技人文反思"
# ── 差异化源(与通用AI媒体形成区分)──
- name: "AI学术论文搜索"
type: "web_search"
query: "arXiv 论文 AI 最新研究 大模型 2026"
update_frequency: "weekly"
credibility: "high"
focus: "AI学术前沿、论文中文解读"
- name: "独立开发者搜索"
type: "web_search"
query: "独立开发者 AI产品 一人公司 出海工具 indie hacking 2026"
update_frequency: "daily"
credibility: "medium"
focus: "独立开发者生态、小众AI产品"
- name: "行业数据报告搜索"
type: "web_search"
query: "AI行业报告 数据统计 市场分析 AI应用渗透率 2026"
update_frequency: "daily"
credibility: "high"
focus: "行业数据、市场报告、AI应用统计"
- name: "AI产品动态搜索"
type: "web_search"
query: "AI产品发布 更新 changelog 新功能 OpenAI Claude 2026"
update_frequency: "daily"
credibility: "medium"
focus: "AI产品迭代、功能更新"
# 本地案例库(保底数据源) # 本地案例库(保底数据源)
local: local:
- name: "宇之然本地案例库" - name: "宇之然AI案例库"
type: "local" type: "local"
file: "strategy/全球案例数据库-v1.md" file: "automation/data/ai_cases.json"
update_frequency: "weekly" update_frequency: "weekly"
credibility: "medium" credibility: "medium"
focus: "全球-本土案例" focus: "AI行业案例"
- name: "历史选题库"
type: "local"
file: "automation/data/sustainability_topics.json"
update_frequency: "daily"
credibility: "medium"
focus: "复用已有选题"
# 案例验证要求
validation:
require_at_least_2_sources: false
prefer_official_data: false
min_data_points: 1
# 选题优先级权重
topic_priority:
audience_match: 0.3
data_availability: 0.25
uniqueness: 0.2
executability: 0.15
brand_fit: 0.1
# 可持续性子领域分类(基于2025-2026中国消费市场实际趋势调研)
# 参考来源:《2025中国可持续消费报告》《绿色低碳消费大数据报告》商务部等9部门绿色消费20条
sustainability_categories:
- "循环消费" # 以旧换新/二手交易/租赁经济 | 2025年二手交易额1.69万亿,闲鱼6亿用户
- "低碳出行" # 新能源车/骑行/绿色通勤 | 新能源车年产销破1000万辆,骑行爱好者超1800万
- "干净饮食" # 有机食品/植物基/本地食材 | 有机食品1247亿销售,干净饮食笔记344万篇
- "零浪费生活" # 自带杯/极简/可持续时尚 | 自带杯笔记277万篇,精简护肤121万篇,极简1186万讨论
- "绿色家电与节能" # 一级能效/智能节能/以旧换新 | 一级能效家电占比90%+,国补政策明确导向
- "碳普惠" # 碳账户/碳积分/个人碳减排 | 武汉200万市民碳账户,多城碳普惠平台上线
- "环保科技产品" # 可持续材料/绿色产品/环保科技 | 消费者愿意为绿色产品支付溢价,但需可感知价值
- "AI与效率" # AI工具/效率方法/数字助手 | 2026年AI深度融入消费与生活,消费链被AI重新定义
# 内容合规检查清单 # 内容合规检查清单
compliance: compliance:
@@ -136,5 +122,5 @@ compliance:
required: required:
- "至少3个可靠引用" - "至少3个可靠引用"
- "数据标注来源" - "数据标注来源"
- "包含MVP行动" - "包含可操作建议"
- "中性客观语气" - "中性客观语气"
@@ -1,123 +0,0 @@
# 在上海阳台种菜一年,我收获了啥?
**领域**:自然 / 生活
**形式**:实操指南 + 个人故事
**预估字数**2500
**核心观点**:城市农业不仅是种菜,更是与自然重建连接的生活方式
**受众痛点**
- 想体验田园生活但没条件去农村
- 担心食品安全,想自己种菜但不懂技术
- 阳台空间小,不知道能种什么
- 担心养不活植物,缺乏信心
- 城市生活压力大,寻求放松方式
**独特角度**
- 不鼓吹"田园牧歌",客观展示失败和踩坑
- 用数据说话(产量、成本、时间投入)
- 结合科技元素(智能种植、垂直农业)
- 从心理层面谈与植物建立连接的意义
- 延伸讨论城市食物系统和可持续生活
**数据/案例**(至少3个来源):
1. 上海阳台种植调研报告(找本地社区数据)
2. 不同蔬菜的生长周期和产量数据(农业网站)
3. 城市农业的心理学研究(减压、幸福感)
4. 自己一年的种植记录(日志、照片)
5. 智能种植设备对比(自动浇水、补光灯等)
**预估完成时间**4天
**优先级**:高
**预计发布时间**2026-04-15
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 8 | 城市青年有共鸣,但相对垂直 |
| 独特性 | 9 | 结合个人故事+科学种植+城市农业视角 |
| 数据可得性 | 8 | 自己有一年数据,可补充调研 |
| 可持续性 | 7 | 可写成系列(不同季节、不同植物) |
| 平台契合度 | 9 | 知乎生活/园艺话题,小红书也适合 |
| 品牌契合度 | 10 | 科技+自然+生活的完美结合 |
| **总分** | **51** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(300字)
- 场景:在上海20㎡阳台种出一个小菜园
- 问题:很多人觉得不可能/太麻烦
- 引子:一年的收获不仅是蔬菜,还有更多
### 主体(2000字)
**第一部分:为什么开始?(300字)**
- 疫情后的食物焦虑
- 对超市蔬菜的不信任
- 想找回"土地连接感"
- 初始期待(vs 现实)
**第二部分:阳台农业的真相(500字)**
- 空间利用:垂直种植+合理规划
- 品种选择:适合上海的蔬菜(番茄、辣椒、生菜、香草)
- 成本核算:投入 vs 产出(经济账和心理账)
- 时间投入:每天半小时,周末2小时
- 失败案例分享(死掉的植物+经验)
**第三部分:科技让种菜变简单(400字)**
- 智能灌溉系统(DIY vs 购买)
- 补光灯的应用(光照不足的冬天)
- 种植APP辅助(记录、提醒、社区)
- 垂直农业设备介绍(Aerogarden等)
- AI助手帮我分析植物健康(拍照识别病害)
**第四部分:收获的不仅是蔬菜(400字)**
- 产量记录(一年收获多少斤)
- 心理收获:减压、专注、成就感
- 家庭关系改善(和家人一起照料)
- 环保意义(减少碳足迹、包装垃圾)
- 生活方式的改变(更关注季节性、本地食物)
**第五部分:给新手的建议(400字)**
- 从简单的开始(香草、生菜)
- 不要一次买太多植物
- 必备工具清单(花盆、土、工具)
- 常见问题和解决方案
- 哪里获取信息和社区支持
### 结尾(200字)
- 城市农业不是回归原始,而是科技赋能的现代生活选择
- 哪怕只有一盆香草,也是与自然的连接
- 呼吁:从小事做起,重建人与自然的联系
- 预告:后续会分享具体种植技术细节
---
## 资源清单
### 数据来源(待收集)
- [ ] 上海市社区农业调查报告
- [ ] 阳台种植产量统计数据
- [ ] 城市农业心理健康研究论文
- [ ] 各蔬菜品种生长数据
### 图片素材(待准备)
- [ ] 一年种植时间线照片
- [ ] 不同生长阶段特写
- [ ] 收获成果展示
- [ ] 种植设施和工具照片
### 引用来源(待标注)
- [ ] 农业技术网站
- [ ] 学术数据库
- [ ] 种植社区讨论
- [ ] 科技产品官网
---
**决策**:这是一个高潜力选题,品牌契合度极高,预计能获得良好反响。建议优先执行。
@@ -1,143 +0,0 @@
# AI时代,什么能力不会被替代?
**领域**:科技 / 职场
**形式**:趋势洞察 + 实操建议
**预估字数**2800
**核心观点**:AI淘汰的不是工作,而是不会使用AI的人;但有些"人类特质"能力反而更珍贵
**受众痛点**
- 担心被AI替代,职业焦虑严重
- 不知道如何提升自己的竞争力
- 听说AI很强,但不知道学什么
- 想转行但方向迷茫
- 担心投入学习最后还是被淘汰
**独特角度**
- 区分配"被AI强化的能力"和"AI无法替代的能力"
- 从历史技术进步规律看职业演变(不是第一次自动化革命)
- 强调"审美、共情、意义构建"等软技能
- 给出具体的能力提升路径,不是泛泛而谈
- 结合宇之然"科技沟通万物,回归自然"的哲学
**数据/案例**(至少3个来源):
1. 世界经济论坛《未来就业报告》技能趋势数据
2. 美国劳工统计局职业自动化概率研究
3. AI能力边界研究(GPT-4o vs 人类能力对比)
4. 历史案例:工业革命、计算机革命后的职业变化
5. 访谈案例:哪些岗位在AI时代反而增值?
**预估完成时间**5天
**优先级**:高
**预计发布时间**2026-04-18
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 9 | 所有职场人士都关心,流量潜力大 |
| 独特性 | 8 | 避开"AI工具教程"红海,聚焦深度思考 |
| 数据可得性 | 9 | 大量研究报告和历史数据 |
| 可持续性 | 8 | 可写成系列(不同行业、不同能力维度) |
| 平台契合度 | 9 | 知乎职场/科技话题热门 |
| 品牌契合度 | 10 | 科技向善+人文关怀,契合价值观 |
| **总分** | **53** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(400字)
- 现象:朋友被AI工具震撼,担心失业
- 核心问题:AI时代,我们该学什么?
- 观点:AI淘汰的不是工作,而是不会使用AI的人;但有些"人类特质"能力反而更珍贵
- 预告:本文将分析AI的能力边界,并给出具体的技能提升建议
### 主体(2200字)
**第一部分:AI到底有多强?(400字)**
- 当前AI能力范围(文本、图像、代码、数据分析)
- AI的局限性:没有意识、没有真实体验、无法承担责任
- AI的依赖:需要人类提供高质量prompt、数据、验证
- 数据:哪些职业自动化概率高(根据研究报告)
- 事实:AI不是万能,只是工具
**第二部分:历史给我们的启示(300字)**
- 工业革命:机器取代体力劳动,但创造了新职业
- 计算机革命:打字员消失,但程序员、设计师兴起
- 每次技术革命都淘汰旧技能,但同时创造更多新技能
- 规律:重复性工作易被替代,创造性、社交性工作更难
- 结论:不恐惧技术,而是学会驾驭技术
**第三部分:AI无法替代的3类能力(800字)**
**A. 审美与创造力**
- AI生成的美术、音乐、文字缺少"灵魂"
- 真正的好设计需要理解人的情感和文化
- 创意工作者(作家、艺术家、设计师)的价值被提升而非降低
- 案例:AI辅助创作,但最终决策权在人
- 如何培养审美能力?(艺术欣赏、多元输入、实践创作)
**B. 共情与沟通**
- AI无法真正理解人类情感
- 医护、教育、管理、咨询等职业的"人味"不可替代
- 高情商沟通、冲突调解、心理支持
- 案例:AI心理咨询的局限性
- 如何提升共情能力?(倾听、换位思考、情绪识别)
**C. 意义构建与战略思维**
- AI能提供数据,但不能替你做价值判断
- 决策需要理解"为什么",而不仅是"怎么做"
- 战略规划、伦理判断、文化塑造
- 案例:企业战略制定,AI只能辅助分析
- 如何培养战略思维?(跨领域学习、哲学思考、长期视角)
**第四部分:AI会强化的能力(400字)**
- 学习能力(快速掌握新工具)
- 信息筛选与批判性思维(辨别AI输出质量)
- 工具使用能力(prompt engineering、AI工作流设计)
- 协同能力(人机协作,AI作为副驾驶)
- 快速迭代能力(AI加速实验和反馈)
**第五部分:具体能力提升路径(300字)**
- 初级(0-1年):掌握基础AI工具,提升学习效率
- 中级(1-3年):发展审美、沟通、批判性思维
- 高级(3-5年):建立战略思维,成为AI无法替代的决策者
- 学习资源推荐(书籍、课程、实践方法)
- 宇之然品牌如何帮助读者提升这些能力?(预告后续内容)
### 结尾(200字)
- AI不是敌人,而是工具
- 人类的优势在于"人性":审美、共情、意义构建
- 建议:拥抱AI提升效率,同时刻意训练人类特质能力
- 呼吁:不要焦虑,而是主动进化
- 互动:你在工作中感受到AI的哪些影响?欢迎评论区分享
---
## 资源清单
### 数据来源(待收集)
- [ ] WEF《未来就业报告》最新版
- [ ] 美国劳工统计局职业自动化概率表
- [ ] OpenAI/GPT能力边界研究论文
- [ ] 历史技术革命对就业影响的经济学研究
- [ ] AI能力与人类能力对比可视化数据
### 图片素材(待准备)
- [ ] AI能力范围示意图
- [ ] 自动化概率职业分布图
- [ ] 技能矩阵图(会被替代 vs 更珍贵)
- [ ] 能力提升路径时间线
### 引用来源(待标注)
- [ ] 世界经济论坛官网
- [ ] 学术期刊数据库
- [ ] 行业研究报告
- [ ] 知名专家观点
---
**决策**:这是一个必爆的热门选题,但内容必须深度且有数据支撑,不能流于表面。需要较强的信息整合能力。建议medium-high优先级执行。
-135
View File
@@ -1,135 +0,0 @@
# 从程序员到数字游民:我的三年转型之路
**领域**:工作 / 职场
**形式**:个人故事 + 实操指南
**预估字数**2600
**核心观点**:数字游民不是逃离,而是更聪明的工作生活方式选择
**受众痛点**
- 受够了996,想自由但不敢行动
- 不知道数字游民能做什么,收入从哪来
- 担心不稳定、没社保、老了怎么办
- 想远程但公司不让,不知如何说服老板
- 羡慕别人环游世界,自己却卡在工位
**独特角度**
- 不美化"边玩边赚",客观展示挑战和代价
- 从技术转型案例切入(程序员是最适合数字游民的职业之一)
- 用数据说话:收入对比、时间分配、成本核算
- 结合"宇之然"理念:科技让人自由,而非束缚
- 提供可操作的转型路线图(不是鸡汤)
**数据/案例**(至少3个来源):
1. 数字游民调研报告(人数、收入、职业分布)
2. 不同国家的签证政策和生活成本对比(数字游民签证国家)
3. 远程工作平台收入数据(Upwork、Toptal等)
4. 个人三年转型历程(时间线、收入变化、心路历程)
5. 成功/失败数字游民案例访谈
**预估完成时间**5天
**优先级**:高
**预计发布时间**2026-04-20
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 9 | 大量职场人向往自由工作,流量潜力巨大 |
| 独特性 | 8 | 避开"如何找远程工作"表层,深入转型战略 |
| 数据可得性 | 8 | 调研报告多,个人案例丰富 |
| 可持续性 | 9 | 可系列化(不同职业、不同国家、不同阶段) |
| 平台契合度 | 9 | 知乎职场/创业热门话题 |
| 品牌契合度 | 10 | 科技提升生活自由度,契合"科技沟通万物" |
| **总分** | **53** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(400字)
- 场景:三年前坐在格子间,加班到深夜,问自己"这就是我想要的生活吗?"
- 转折:发现程序员完全可以远程,开始转型尝试
- 现状:现在在清迈咖啡馆工作,月收入 comparable,但生活品质完全不同
- 核心观点:数字游民不是逃避,而是更聪明的生活方式选择
### 主体(2200字)
**第一部分:数字游民的真实生活(400字)**
- 破除神话(不是天天旅游、不是躺着赚钱)
- 真实时间表:上午工作、下午探索、晚上复盘
- 收入稳定性如何保障?(多个客户、项目制)
- 孤独感和社交挑战(社群、Co-working)
- 数据:平均收入、工作小时、留存率
**第二部分:为什么程序员适合?(300字)**
- 技能可远程交付(代码在哪都能写)
- 高需求(全球IT人才短缺)
- 收入高且相对稳定
- 学习资源丰富(可自学新技能)
- 本人的转型路径(从全职到自由职业)
**第三部分:转型前的准备(500字)**
- 财务准备:6-12个月紧急基金
- 技能盘点:哪些技能可远程变现?
- 作品集建设:GitHub、个人项目、案例
- 平台选择:Upwork、Toptal、电鸭、远程工作社区
- 先从兼职开始,不要裸辞
- 法律和社保:如何自己交?国际医保?
**第四部分:我的三年路线图(400字)**
- 第1年:保存+考证+接小项目(月入5k)
- 第2年:建立客户群+提价+过渡(月入15k)
- 第3年:稳定客户+被动收入+探索(月入20k+)
- 关键节点和决策点
- 踩过的坑(低价竞争、客户跑路、签证问题)
**第五部分:在哪里生活?(300字)**
- 数字游民热门城市对比(清迈、巴厘岛、里斯本、东京)
- 成本核算(住宿、交通、签证、保险)
- 网络和基础设施(稳定WiFi、Coworking
- 社区和社交(容易找到同类人)
- 安全和生活便利性
**第六部分:给想转型者的建议(300字)**
- 先试水:请假去远程工作一个月
- 不要孤注一掷:保持至少一个稳定收入源
- 建立系统:不只是接单,而是建立个人品牌
- 心理准备:自由带来责任,自律才能自由
- "宇之然"理念:科技让人自由,但自由需要能力支撑
### 结尾(200字)
- 数字游民不是适合所有人,但值得尝试
- 关键是找到适合自己的工作生活方式
- 鼓励读者思考:我想要什么样的生活?科技能帮我实现吗?
- 预告:后续分享具体技能提升路线(程序员转远程)
---
## 资源清单
### 数据来源(待收集)
- [ ] 数字游民数量增长数据( Nomad List 、相关报告)
- [ ] 程序员远程工作薪资调研
- [ ] 各国数字游民签证政策汇总
- [ ] Coworking 空间成本统计
- [ ] IT自由职业平台收入报告
### 图片素材(待准备)
- [ ] 三年转型时间线图
- [ ] 不同城市成本对比图表
- [ ] 收入变化曲线
- [ ] 世界各地工作地点照片(清迈咖啡馆等)
- [ ] Coworking 空间环境图
### 引用来源(待标注)
- [ ] Nomad List 官网数据
- [ ] 数字游民社区统计
- [ ] 各国签证政策官网
- [ ] 远程工作平台文档
---
**决策**:这是一个高流量选题,竞争也激烈,需要深度个人故事+系统化建议才能脱颖而出。建议 medium-high 优先级。
@@ -1,143 +0,0 @@
# 零浪费生活一年:一个家庭产生的垃圾去哪儿了?
**领域**:自然 / 生活
**形式**:实验记录 + 科普指南
**预估字数**2300
**核心观点**:零浪费不是苦行,而是更聪明的消费选择;个人行动虽小,但能改变系统
**受众痛点**
- 想环保但不知道从何下手
- 觉得"我一个人做有什么用"
- 担心成本太高,负担不起
- 嫌麻烦,步骤太多坚持不下去
- 家人不理解,无法统一战线
- 超市买买买停不下来,事后又后悔
**独特角度**
- 不道德绑架,用数据和成本说服
- 展示失败和妥协(不是完美零浪费,而是持续改进)
- 从家庭视角(夫妻+孩子)而非个人
- 连接科技:用App记录、智能采购、循环经济平台
- 强调经济收益(省了多少钱)而非道德优越
**数据/案例**(至少3个来源):
1. 中国城市生活垃圾产生量数据(统计年鉴)
2. 不同包装方式的碳足迹对比(生产、运输、处理)
3. 零浪费家庭采访或书籍(如Bea Johnson, 0 Waste Home
4. 自己一年的垃圾记录(称重、分类、分析)
5. 环保产品性价比对比(可重复使用 vs 一次性)
**预估完成时间**4天
**优先级**:高
**预计发布时间**2026-04-22
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 8 | 环保意识提升,但执行力不足的人多 |
| 独特性 | 9 | 家庭实验视角,真实数据,不教条 |
| 数据可得性 | 8 | 垃圾数据、环保报告、产品对比可获取 |
| 可持续性 | 8 | 可系列(厨余、包装、衣物、电子产品等) |
| 平台契合度 | 8 | 知乎生活/环保话题,小红书也适合 |
| 品牌契合度 | 10 | 自然+科技+生活,完美契合价值观 |
| **总分** | **51** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(300字)
- 场景:2025年1月1日,我在家门口放了一个垃圾桶,记录一整年的垃圾
- 问题:一个家庭一年会产生多少垃圾?它们最后去哪了?
- 引子:零浪费不是不吃不喝,而是 smarter consumption
- 预告:分享一年实验数据、踩坑经验、经济收益
### 主体(1800字)
**第一部分:我们这一年扔了啥?(300字)**
- 垃圾总量:XX公斤(对比全国人均)
- 分类数据:塑料、包装、厨余、其他
- 月度趋势(节假日更多,夏季更多)
- 最大来源:外卖包装、快递箱、食品包装
- 可视化:饼图+月度折线图
**第二部分:我们是怎么做到的?(400字)**
- 原则5R:Refuse(拒绝不需要的)、Reduce(减少)、Reuse(重复使用)、Recycle(回收)、Rot(堆肥)
- 购物习惯改变:
- 带袋子买菜(拒绝塑料袋)
- 散装优先(减少包装)
- 耐用品替代一次性(硅胶保鲜膜、可重复使用的咖啡杯)
- 二手优先(家具、电子产品、衣物)
- 厨余处理:堆肥桶+社区堆肥点
- 家庭共识:夫妻+孩子的参与,不是一个人负重前行
- 妥协:不可能100%,目标是逐年降低
**第三部分:科技让零浪费变简单(300字)**
- 零浪费购物App(包, 乐活等)
- 智能库存管理(避免买多导致浪费)
- 社区交换平台(闲鱼、小区群)
- 可降解产品追踪(哪些真的环保?)
- 碳足迹计算器(量化自己的贡献)
**第四部分:经济账(200字)**
- 初始投入:玻璃罐、棉布袋、不锈钢饭盒(XXX元)
- 每月节省:减少外卖/包装(XXX元)、二手购物(XXX元)
- 一年净节省:XXXX元(ROI 200%+
- 隐性收益:更健康饮食(少点外卖),更理性消费
- 环保不是富人游戏,穷人更应该
**第五部分:挑战与妥协(300字)**
- 家人不配合:老人舍不得扔旧物,孩子要零食包装
- 社交压力:聚会自带杯子被嘲笑
- 便利性:有时候真的没时间准备
- 产品缺失:市场上零浪费选择太少
- 我们没有做到的地方:电子产品循环难、医疗包装无法避免
- 心态调整:进步不是完美,可持续才是关键
**第六部分:我们的建议(300字)**
- 从最容易的3件事开始:带袋子、少点外卖、二手优先
- 设定合理目标(比如年度垃圾减量20%)
- 记录数据(否则不知道进步)
- 找到社区(一个人走不远)
- 关注政策(垃圾分类、再生资源回收)
- 科技赋能(用工具降低门槛)
- "宇之然"理念:零浪费不是回到原始,而是科技赋能的智能生活
### 结尾(200字)
- 一年垃圾量:XXX公斤(对比第一年减少XX%)
- 零浪费不是苦行,是更高质量的生活
- 每一个小行动都在影响系统
- 呼吁:不要追求完美,追求持续改进
- 互动:你的零浪费实践是什么?欢迎分享
---
## 资源清单
### 数据来源(待收集)
- [ ] 中国城市生活垃圾产生量年度数据(国家统计局)
- [ ] 各类包装碳足迹数据(学术论文)
- [ ] 零浪费家庭案例(国内外博客、书籍)
- [ ] 零浪费产品成本与一次性对比
- [ ] 社区堆肥项目案例
### 图片素材(待准备)
- [ ] 一年垃圾记录照片(分类展示)
- [ ] 零浪费购物装备(布袋、罐子、饭盒)
- [ ] 堆肥桶和成果
- [ ] 数据图表(垃圾量趋势、分类占比、经济收益)
- [ ] 前后对比(购物方式、垃圾桶状态)
### 引用来源(待标注)
- [ ] 零浪费运动创始人Bea Johnson
- [ ] 《零浪费生活》等书籍
- [ ] 环保组织报告(如WWF、Greenpeace
- [ ] 国内零 waste community
---
**决策**:实验性质内容,容易引发共鸣和讨论,互动率高。适合知乎和小红书。建议high优先级。
-166
View File
@@ -1,166 +0,0 @@
# 深度工作实践:如何在干扰世界中保持专注
**领域**:工作 / 生产力
**形式**:实操指南 + 个人实验
**预估字数**2400
**核心观点**:专注力是新时代最稀缺的资源,通过系统方法可以重建深度工作能力
**受众痛点**
- 每天忙碌但没产出,时间碎片化
- 容易被手机、消息、同事打断
- 知道要专注,但就是控制不住刷手机
- 工作1小时,实际只有30分钟在干活
- 想要心流状态但总进不去
- 下班累但没成就感
**独特角度**
- 不鸡汤,用实验数据和自我追踪说话
- 结合科技工具(Forest、番茄钟、白噪音)和传统方法
- 承认现代工作环境的现实(完全隔离不现实),提供折中方案
- 从神经科学和心理学角度解释why,不只是how
- 强调"深度休息"与"深度工作"的交替
**数据/案例**(至少3个来源):
1. Cal Newport《深度工作》理论体系
2. 注意力恢复理论(Attention Restoration Theory
3. 番茄工作法实证研究(效果数据)
4. 个人3个月实验数据(专注时间、任务完成量、压力水平)
5. 知识工作者专注力调研( interruptions cost
**预估完成时间**4天
**优先级**:高
**预计发布时间**2026-04-23
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 9 | 所有知识工作者都需要,流量潜力极大 |
| 独特性 | 7 | 话题热门,需要用数据和实验脱颖而出 |
| 数据可得性 | 9 | 大量研究文献和工具可用 |
| 可持续性 | 8 | 可系列(不同环境、不同工具、不同性格) |
| 平台契合度 | 9 | 知乎职场/效率热门,小红书打卡也适合 |
| 品牌契合度 | 8 | 科技辅助专注,但最终回归人的本质 |
| **总分** | **50** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(300字)
- 场景:上周写一份报告,计划4小时,实际花了8小时,中间刷了20次手机
- 问题:为什么我们越来越忙,产出却越来越少?
- 观点:不是时间不够,是注意力被碎片化
- 预告:分享3个月深度工作实验,数据+方法+工具
### 主体(1900字)
**第一部分:专注力危机(300字)**
- 数据:平均专注时长从26分钟降到8分钟(Microsoft研究)
- 知识工作者每天被中断多少次?(59次/天,平均)
- 切换成本:每次中断后需要23分钟回到深度状态
- 现代工作环境:开放式办公室、即时通讯、通知轰炸
- 后果:疲惫、低效、创造力下降
**第二部分:什么是深度工作?(200字)**
- Cal Newport定义:无干扰的认知挑战性工作
- 与肤浅工作的对比(容易回复的邮件、会议、行政事务)
- 为什么深度工作有价值?(高价值产出、技能增长、成就感)
- 神经科学解释:默认模式网络 vs 专注模式
- 个人实验目标:将每日深度工作时间从1h提升到4h
**第三部分:我的3个月实验设计(200字)**
- 基线测量:第一周不干预,记录专注情况(RescueTime数据)
- 变量引入:每周测试一种方法(时间块、地点、工具)
- 评估指标:深度工作时长、任务完成量、主观疲劳度
- 数据收集:自动追踪+每日日志
- 工具:Forest、番茄钟、白噪音、时间追踪App
**第四部分:7个有效方法(800字)**
1. **时间块法**:每天固定2-3个90分钟深度时段(早上最佳)
- 如何保护这些时段?(关闭通知、告知同事)
- 案例:我如何说服老板让我上午不参加会议
2. **地点隔离**:物理空间决定心理状态
- 家里打造深度工作角(哪怕只有一张桌子)
- 共享办公/图书馆vs咖啡馆(哪个更好?)
- 通勤时间利用(地铁上不适合深度,但可做浅层)
3. **工具辅助**:科技帮你专注,而不是分心
- Forest种树:游戏化保持专注
- Cold Turkey Blocker:屏蔽 distracting sites
- 白噪音/自然声:掩盖环境噪音
- 双显示器设置(主屏工作,副屏消息)
4. **仪式感建立**:让大脑进入"工作模式"
- 开始前:一杯茶+5分钟冥想+清单
- 结束时:总结+小奖励
- 服装:穿"工作装"即使在家
5. **批处理浅层工作**:集中处理邮件、消息、会议
- 每天2个固定时段(下午3点、5点)
- 其他时间绝不查看
- 快速回复模板
6. **注意力训练**:像肌肉一样训练专注
- 每天10分钟正念冥想(Headspace
- 单任务练习(吃饭不刷手机)
- 阅读长文章(训练持续注意力)
7. **深度休息**:专注需要高质量的休息
- 90分钟工作+15分钟休息(非屏幕活动)
- 午休必须离开工位
- 晚上完全断开(数字安息日)
- 睡眠优先(专注力的基础)
**第五部分:实验结果与数据(300字)**
- 深度工作时长变化:基线1.2h/天 → 实验后3.8h/天
- 任务完成量提升:每周完成项目数从2个→5个
- 主观疲劳度下降:从每天7分(10分制)降到4分
- 最有用的方法:时间块法(增加1.5h)+ 工具辅助(增加0.8h)
- 意外发现:地点隔离效果不明显(家里角落即可)
**第六部分:常见障碍与解决方案(200字)**
- 同事/老板随时找你怎么办?(设置"专注时段"通知)
- 自己忍不住刷手机怎么办?(物理隔离手机)
- 老板要求即时回复怎么办?(协商沟通规则)
- 创意型工作无法计划怎么办?(灵感记录+深度时间块)
- 适应期烦躁怎么办?(从1小时开始,逐步增加)
### 结尾(200字)
- 深度工作不是天赋,是可以训练的技能
- 关键不是完美,而是持续改进
- 科技让我们更容易分心,但也给了我们工具对抗
- "宇之然"理念:专注才能产生深度价值
- 挑战:从明天开始,尝试一个90分钟的时间块
---
## 资源清单
### 数据来源(待收集)
- [ ] Microsoft 注意力跨度研究报告
- [ ] RescueTime 知识工作者专注数据
- [ ] 番茄工作法效果 meta-analysis
- [ ] 神经科学关于注意力切换的研究
- [ ] 正念冥想对专注力的影响
### 图片素材(待准备)
- [ ] 深度工作时长趋势图(实验前后对比)
- [ ] 7种方法示意图
- [ ] 时间块安排示例(日历截图)
- [ ] 专注工具界面截图(Forest、白噪音)
- [ ] 实验数据表格
### 引用来源(待标注)
- [ ] Cal Newport《深度工作》
- [ ] 《倦怠社会》等社会学视角
- [ ] 心理学研究(注意力、心流)
- [ ] 工具官网和教程
---
**决策**:非常实用的主题,数据驱动,易引发收藏和转发。适合知乎收藏夹和小红书打卡。建议 high 优先级。
-183
View File
@@ -1,183 +0,0 @@
# 副业月入过万:我一个程序员的三年探索之路
**领域**:工作 / 副业
**形式**:实操经验 + 方法论总结
**预估字数**2700
**核心观点**:副业不是投机,而是将主业技能产品化、多元化的过程
**受众痛点**
- 工资不够花,想搞钱但不知道从哪开始
- 听说过很多副业(自媒体、电商、咨询),但不知哪个适合
- 时间有限,如何在主业之外有效投入
- 担心副业影响主业,被公司发现
- 交过学费买课,但没赚到钱
- 想开始但总在观望,不敢行动
**独特角度**
- 从技术人视角(程序员)看副业选项,但方法论通用
- 不鼓吹"月入十万"的毒鸡汤,诚实展示时间和金钱投入
- 强调"技能产品化"而非"时间换钱"(避免陷入低效劳务)
- 用数据说话:我的三年副业收入变化、时间分配、失败案例
- 结合长期主义:副业是为了人生更多选择,不仅是赚钱
**数据/案例**(至少3个来源):
1. 中国职场人副业调研报告(参与率、收入分布)
2. 程序员技能变现渠道对比(外包、咨询、产品、内容)
3. 时间投入与回报曲线(前期低效,后期复利)
4. 个人三年副业数据(2019-2021):时间投入vs收入产出
5. 成功/失败副业案例分析(为什么有些成了有些黄了)
**预估完成时间**5天
**优先级**:高
**预计发布时间**2026-04-25
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 9 | 大量职场人有副业需求,搜索和阅读量巨大 |
| 独特性 | 7 | 副业话题热,需要真实数据和深度分析才能出彩 |
| 数据可得性 | 9 | 调研报告多,个人案例可详细追踪 |
| 可持续性 | 9 | 可系列(不同阶段、不同技能、不同平台) |
| 平台契合度 | 9 | 知乎职场/赚钱热门,小红书副业也火 |
| 品牌契合度 | 9 | 科技赋能多元收入,增加人生选择权 |
| **总分** | **52** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(400字)
- 场景:2019年,我还是一枚996程序员,每月税后1.2万,觉得不够用
- 尝试:接过外包、做过咨询、写过公众号、卖过课程、尝试过跨境电商
- 现状:现在副业收入已超过主业,时间自由,有选择权
- 核心观点:副业不是一夜暴富,而是技能产品化和持续积累的过程
- 预告:分享三年探索的数据、踩坑、方法论
### 主体(2200字)
**第一部分:副业真相(破除幻想)(300字)**
- 不是所有副业都赚钱:80%的人副业收入<2000元
- 不是 Easily Money:前期投入巨大(时间、学习、试错)
- 不是可持续的劳务:用时间换钱必然后劲不足
- 数据:我的前18个月副业收入总计才8000元
- 真相:副业是"第二曲线"需要长期培育
**第二部分:副业类型全景图(300字)**
- **技能变现型**:外包、咨询、培训(适合技术人)
- 平台:Upwork、电鸭、知乎 Live、自有客户
- 特点:时间换钱,单价高但线性增长
- **产品型**:开发工具、课程、模板、SaaS
- 前期投入大,后期复利
- 例子:我做的 Chrome 插件年收入X万
- **内容型**:公众号、知乎、B站、小红书
- 长周期变现,但品牌效应强
- 例子:我的技术博客如何带来咨询客户
- **电商/贸易型**:跨境电商、二手、定制
- 资金和库存压力大,技术人不擅长
- 我失败的案例:淘宝店
- **投资型**:股票、基金、加密货币(不推荐作为主业外重点)
- **混合型**:我的组合:技能变现+产品+内容
**第三部分:程序员适合什么副业?(300字)**
- 优势:技术可远程交付、技能稀缺、学习能力强
- 最适合:
1. 技术外包/咨询(起步最快,但天花板低)
2. 开发小工具/SaaS(复利高,但需要产品思维)
3. 技术内容创作(博客、课程、咨询)
4. 技术团队组建(成为自由职业团队leader)
- 不适合:
1. 需要大量资金的(电商库存)
2. 纯体力或线下服务的
3. 需要法律资质的(金融咨询)
**第四部分:我的三年路线图(400字)**
- **第1年(2019**:探索+试错
- 目标:尝试3种副业,找到1-2个可行的
- 行动:接外包(赚了1.5万)、写博客(0收入)、尝试跨境电商(亏损2k)
- 学习:时间管理、客户沟通、基础营销
- 结果:发现外包可行但累,内容有潜力但慢
- 时间投入:每周10-15小时
- **第2年(2020**:聚焦+优化
- 目标:在技术外包+内容创作双线推进,月入稳定5000
- 行动:提高外包单价(从100$/h到150$/h),博客系统性写作
- 学习:个人品牌、定价策略、产品化
- 结果:外包月均3000,博客带来1个咨询项目(5000)
- 时间投入:每周15小时(主业压力大)
- **第3年(2021)**:产品化+规模化
- 目标:将知识产品化,打造"第二曲线"
- 行动:开发Chrome插件(年收入2万),出版电子书(1.5万),高单价咨询
- 学习:产品运营、用户增长、自动化
- 结果:副业月均1.2万,首次超过主业
- 时间投入:每周20小时(但其中50%是产品维护)
**第五部分:时间管理:主业+副业如何平衡?(200字)**
- 原则1:主业优先,不能因副业影响主业绩效(可能被开除)
- 原则2:副业时间固定(早上6-8点、晚上8-10点、周末4小时)
- 原则3:批量处理(每周日规划,减少切换成本)
- 原则4:学会说"不"(拒绝低价值副业请求)
- 我的时间分配:周总副业时间15h(低于20h安全线)
**第六部分:技能产品化:从时间换钱到复利增长(300字)**
- 外包(时间换钱):1h=150元,但做才有
- 产品(一次创造,多次销售):开发一个工具,持续销售
- 内容(建立品牌,带来被动机会):博客带来咨询,不需主动找
- 我的产品:Chrome插件(每月销售,收入稳定)
- 如何选择产品方向?(你解决自己的问题,可能也是别人的问题)
- 最小可行产品(MVP)快速验证
**第七部分:避坑指南(200字)**
- 坑1:低价竞争(不要接低于市场价的活,会拉低你的价值)
- 坑2:完美主义(先做出来,再优化;不要等完美才发布)
- 坑3:孤军奋战(找导师、加入社群、互相支持)
- 坑4:忽略法律(兼职合同、知识产权、个税)
- 坑5:忽视健康(连续熬夜接单,不可持续)
- 坑6:过度承诺(能力范围内再接,否则口碑崩塌)
**第八部分:给初学者的行动清单(200字)**
1. 盘点你的技能:什么可远程交付?什么可产品化?
2. 选择1-2个方向(不要贪多)
3. 用3个月探索:每方向投入100小时试水
4. 记录数据:时间投入、收入、满意度
5. 6个月后评估:哪个ROI最高?哪个最有潜力?
6. 聚焦+优化:放弃低ROI的,加注高潜力的
7. 建立系统:不是每次从零开始,而是有流程
8. 定期复盘(每月一次)
### 结尾(200字)
- 副业不只是赚钱,更是探索人生更多可能
- 我的现状:主业稳定,副业自由,有选择权
- "宇之然"理念:科技给人自由,副业是实现自由的一种路径
- 鼓励:从今晚开始,花1小时盘点你的技能
- 互动:你的副业探索经历如何?欢迎分享
---
## 资源清单
### 数据来源(待收集)
- [ ] 中国职场人副业现状调研(智联、脉脉等)
- [ ] 程序员收入与副业相关性数据
- [ ] 自由职业平台(Upwork、电鸭)收入报告
- [ ] 数字游民与副业的关系研究
- [ ] 时间投入与收入曲线(复利效应数据)
### 图片素材(待准备)
- [ ] 三年副业收入增长曲线
- [ ] 时间分配饼图(每天/每周)
- [ ] 副业类型对比矩阵(时间vs金钱vs长期价值)
- [ ] 技能产品化流程图
- [ ] 避坑指南信息图
### 引用来源(待标注)
- [ ] 《副业赚钱》等书籍
- [ ] 知名副业博主案例
- [ ] Upwork/Toptal 平台报告
- [ ] 个人博客和项目数据
---
**决策**:刚需话题,竞争激烈但需求巨大。真实数据和实操经验是关键。适合知乎和小红书。建议 high 优先级。
@@ -1,180 +0,0 @@
# 正念冥想一年:从焦虑到平静的转变
**领域**:人文 / 心理健康
**形式**:个人实验 + 科学解读
**预估字数**2200
**核心观点**:冥想不是玄学,是可训练的大脑肌肉;一年练习带来可测量的认知和情绪变化
**受众痛点**
- 焦虑、失眠、注意力不集中
- 想尝试冥想但觉得"太虚"、"静不下来"
- 工作压力大,找不到放松方法
- 情绪容易波动,想提升情绪调节能力
- 听说过冥想好处,但不知如何开始
- 试过几次没效果就放弃了
**独特角度**
- 用科学家态度做自我实验(追踪数据、前后对比)
- 破除玄学色彩,基于神经科学和心理学
- 展示失败和挣扎(不是一蹴而就)
- 结合科技:Meditation App、生物反馈设备
- 强调"微小习惯":每天10分钟胜过每周2小时
**数据/案例**(至少3个来源):
1. 冥想改变大脑结构的研究(灰质增加、杏仁核缩小)
2. 压力激素(皮质醇)水平变化数据
3. 注意力、情绪调节能力的心理测量
4. 个人一年追踪:每天10分钟,睡眠、焦虑评分、专注力自评
5. 其他冥想者案例(长期练习者)
**预估完成时间**4天
**优先级**:中
**预计发布时间**2026-04-27
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 8 | 压力大的都市人广泛需要,但转化率不确定 |
| 独特性 | 8 | 个人实验+数据追踪,破除玄学 |
| 数据可得性 | 8 | 神经科学研究多,自我追踪简单 |
| 可持续性 | 7 | 可系列(不同时长、不同技术、不同人群) |
| 平台契合度 | 8 | 知乎心理学/健康,小红书打卡风 |
| 品牌契合度 | 9 | 回归本心,科技辅助内心平静 |
| **总分** | **48** | 考虑执行 ✅ |
---
## 大纲草稿
### 开头(300字)
- 场景:2024年初,我焦虑到失眠,朋友建议"试试冥想"
- 抵触:觉得是玄学,静不下来,10分钟像几个小时
- 决定:用科学家态度做个实验,每天10分钟,追踪一年
- 结果:焦虑评分下降40%,睡眠质量提升,专注力改善
- 预告:分享这一年我的数据、方法、挣扎和收获
### 主体(1700字)
**第一部分:冥想是什么?(破除迷思)(200字)**
- 不是宗教,不是玄学,是注意力训练
- 不是停止思考,是观察思绪而不被带走
- 不是马上变平静,是练习"觉知"
- 科学解释:默认模式网络、前额叶皮层、神经可塑性
- 我的初始怀疑:一个程序员如何相信这个?
**第二部分:为什么现代人更需要冥想?(200字)**
- 信息过载:每天被数千条信息轰炸
- 多任务假象:切换导致注意力碎片化
- 压力激素持续分泌(皮质醇)
- 社交媒体比较和FOMO
- 缺乏"无所事事"的时间
- 后果:焦虑、失眠、注意力涣散、情绪不稳定
**第三部分:我的实验设计(200字)**
- 目标:一年后减少焦虑、改善睡眠、提升专注
- 方法:每天10分钟冥想(App指导:Headspace + Calm
- 追踪指标:
- 主观:焦虑评分(1-10)、睡眠质量(1-5)、专注时长
- 客观:皮质醇(唾液测试,每月1次)、心率变异性(HRV)
- 工具:Muse头环(脑电生物反馈)、Apple Watch睡眠追踪
- 记录:每日简记,每周复盘
**第四部分:前3个月的挣扎(200字)**
- 第1周:完全静不下来,中途放弃多次
- 第1月:平均每次5分钟就开始看时间
- 第2月:开始有一点点"空白时刻"
- 第3月:发现思绪模式(焦虑的思绪、工作的思绪)
- 关键:坚持每天做,不追求效果
- 工具帮助:App的引导声音让我stay
**第五部分:6个月时的变化(200字)**
- 主观感受:更容易进入状态(5分钟就能平静)
- 睡眠:入睡更快,夜间醒来减少(数据:平均入睡时间从30min→15min)
- 焦虑:明显降低(评分从7→4
- 专注:工作中更容易"心流"
- 意外:对情绪觉察力提升(能 early detect stress
- 生物反馈:HRV提升(从50ms→70ms
**第六部分:一年后的数据(200字)**
- 坚持率:85%365天,310天完成10min+
- 焦虑评分:从7.2降至4.1(下降43%)
- 睡眠质量:从2.8升至4.1(1-5分制)
- 专注时间:基线1.2h/天 → 2.5h/天( meas by RescueTime
- 皮质醇:早晨水平下降20%
- 大脑变化:Muse数据显示α波(放松)增加,β波(焦虑)减少
- ROI:每天10分钟,换来心理和生理健康提升,值吗?(一个数量级的健康价值)
**第七部分:关键技术/方法(300字)**
1. **呼吸冥想**:专注呼吸,是最基础也最有效的
- 方法:鼻吸鼻呼,数呼吸(1-10循环)
- 最佳时段:早晨起床后或晚上睡前
- 困难:思绪飘走正常,温柔拉回即可
2. **身体扫描**:释放身体紧张
- 方法:从脚到头,逐个部位觉察
- 适合:压力大时,身体紧绷
- 效果:发现很多平日忽略的肌肉紧张
3. **正念行走**:把步行变成冥想
- 方法:走路时觉察脚步、身体移动、呼吸
- 适合:久坐办公室,通勤路上
- 我:午休15分钟步行冥想,下午效率高
4. **3分钟呼吸空间**:快速冷静
- 方法:1分钟觉察,1分钟专注呼吸,1分钟觉察整体
- 适合:会议前、压力事件前
- 使用频率:平均每天2-3次
5. **科技辅助**
- Headspace/Calm:引导冥想(新手强烈推荐)
- Insight Timer:免费课程和统计
- Muse头环:生物反馈,可视化大脑状态
- Apple Watch:呼吸App,随时做1分钟
**第八部分:常见问题与解答(200字)**
- Q:静不下来怎么办?(接受它,坚持做,改变需要时间)
- Q:每天多久合适?(10分钟起步,30分钟最佳)
- Q:什么时间做最好?(早晨或睡前最易坚持)
- Q:必须坐垫子吗?(任何姿势,关键是舒适且清醒)
- Q:多久见效?(2-4周有初步感受,1年明显变化)
- Q:要加入宗教吗?(完全不需要, secular mindfulness
- Q:影响创造力吗?(反而提升,因为减少心理噪音)
### 结尾(200字)
- 冥想一年,是我给自己最好的投资之一
- 不是变成"无欲无求",而是更清晰、更平静、更有选择权
- "宇之然"理念:科技沟通万物,最终回归内心本真
- 鼓励:从今晚5分钟开始,用数据追踪自己的变化
- 互动:你有冥想经验吗?分享你的故事
---
## 资源清单
### 数据来源(待收集)
- [ ] 冥想改变大脑结构研究(哈佛、斯坦福等)
- [ ] 压力激素皮质醇与冥想关系研究
- [ ] 注意力、情绪调节能力测量工具(PANAS等)
- [ ] 心率变异性(HRV)与冥想数据
- [ ] 冥想对睡眠影响 meta-analysis
### 图片素材(待准备)
- [ ] 一年冥想坚持日历(打卡图)
- [ ] 追踪数据图表(焦虑、睡眠、专注趋势)
- [ ] 大脑结构变化示意图(灰质、杏仁核)
- [ ] HRV和脑电数据截图(如果使用设备)
- [ ] 冥想姿势和环境照片
### 引用来源(待标注)
- [ ] Headspace/Calm 科学页面
- [ ] 冥想研究综述(如《心理学前沿》)
- [ ] 《正念的奇迹》《禅与摩托车维修艺术》等书籍
- [ ] 神经科学教材
---
**决策**:心理健康内容需求大,但竞争也激烈。个人实验+数据追踪+科学解释是差异点。适合知乎和小红书。建议 medium 优先级。
-182
View File
@@ -1,182 +0,0 @@
# 极简3年:我从囤积症到少物生活的转变
**领域**:人文 / 生活方式
**形式**:个人转变故事 + 实操指南
**预估字数**2500
**核心观点**:极简不是扔东西,而是重新定义"足够";通过减少物质,增加精神丰盈
**受众痛点**
- 家里东西越来越多,整理完很快又乱
- 冲动购物,买了很多用不上的东西
- 感觉被物品包围,想要斷舍离但下不去手
- 想简化生活但不知从何开始
- 担心扔了后悔,觉得"万一以后用得上"
- 追求品质但预算有限,陷入"买 cheap stuff 然后浪费"
**独特角度**
- 从囤积症患者到极简主义者的真实转变(有心理过程)
- 不鼓吹"一无所有",而是"刚刚好"
- 量化成果:减少了70%物品,省了多少钱,时间如何变化
- 连接科技:智能家居、数字替代(电子书、云存储)、物品追踪
- 从物质极简到精神极简(信息、社交、目标)
**数据/案例**(至少3个来源):
1. 极简主义创始人(Joshua & Ryan)理念和实践
2. 物品生命周期研究(平均使用次数、浪费数据)
3. 消费心理学(冲动购物的神经机制)
4. 个人3年极简数据:物品数量变化、消费减少、时间分配
5. 多人案例采访(不同极简程度)
**预估完成时间**4天
**优先级**:中
**预计发布时间**2026-04-29
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 8 | 物质过剩的都市人有共鸣,但行动者少 |
| 独特性 | 8 | 转变故事有吸引力,数据驱动 |
| 数据可得性 | 8 | 个人追踪数据+消费研究+极简社区案例 |
| 可持续性 | 8 | 可系列(不同房间、不同人群、不同阶段) |
| 平台契合度 | 8 | 知乎生活方式/小红书极简话题 |
| 品牌契合度 | 10 | 回归自然本心,少物多思 |
| **总分** | **50** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(300字)
- 场景:2019年,我搬进新家,发现60%的物品两年没用过
- 触目惊心:衣柜塞满、抽屉爆炸、储物柜 hidden junk
- 决定:开始极简之路,目标是"每件东西都有用且愉悦"
- 3年变化:物品减少70%,消费减半,时间多出,焦虑下降
- 预告:分享转变过程、方法、数据和教训
### 主体(2000字)
**第一部分:从囤积到觉醒(300字)**
- 我的囤积症:舍不得扔,总觉得"会用到"
- 触发点:搬家发现大量未拆封的购物袋、过期的优惠券
- 消费主义陷阱:打折、社交媒体种草、身份焦虑
- 觉醒:物品在消耗我的注意力、空间、金钱
- 开始:先从小区域(一个抽屉)动手
**第二部分:极简不是数字,是心态(300字)**
- 误区:极简=苦行= empty room
- 真相:极简是"足够"哲学,留下真正重要和愉悦的
- 我的标准:每件物品是否在用?是否带来愉悦?是否可替代?
- 过程不是一次断舍离,而是持续优化
- 3年我扔了/捐了/卖了 1000+ 件物品
- 不是目标,是手段(为了时间、精力、金钱自由)
**第三部分:我的3年路线图(400字)**
- 第1年(2019):粗筛+快速清理
- 方法:一年未用的全扔(除了纪念品)
- 成果:物品减少40%,清空3个储物箱
- 挑战:扔东西的心痛感(浪费钱的心理)
- 第2年(2020):精细化+系统化
- 方法:One In, One Out(进一出一),严格购买决策流程
- 成果:物品再减少20%,消费减少35%
- 学习:识别真实需求 vs 虚假欲望
- 建立"30天清单":想买的东西放30天,90%会取消
- 第3年(2021):深化+扩展
- 方法:数字极简、信息极简、社交极简
- 成果:物品再减少10%,精神更清爽
- 维持:定期季度审查,防止反弹
- 极简成为习惯,不再需要"努力"
**第四部分:具体方法(500字)**
1. **分类清理法**
- 从易到难:抽屉→衣柜→书柜→储物间
- 4箱法:扔、捐、卖、留(保留)
- 纪念品处理:选一个盒子(大小限制),其他拍照留底
2. **购买决策流程**
- 问自己:我需要它吗?我有替代品吗?它带来长期愉悦吗?
- 30天等待期(除非是消耗品)
- 控制冲动:取消促销订阅、不逛购物App
- 质量优先:买少买好,耐用性优先
- 案例:少买了100件便宜T恤,买了5件优质耐穿的
3. **数字极简**
- 卸载不用的App,取消不看的订阅
- 照片整理:删除模糊、重复的,精选打印
- 文件存档:云存储+本地双备份,删临时文件
- 通知管理:只留必要的,减少干扰
4. **信息极简**
- RSS订阅精选(从200+减到20个高质量源)
- 社交媒体定时关闭(每天30min
- 不追热点,只深度阅读
- 信息输入输出平衡(少摄入,多创作)
5. **社交极简**
- 减少无效社交,保留高质量关系
- 会议只参加必要的,其他异步沟通
- 学会拒绝:时间是最稀缺资源
**第五部分:量化成果(200字)**
- 物品数量:从约1500件 → 450件(减少70%
- 消费:年均减少 40%(省下约3万/年)
- 时间:每周少花5小时整理、购物、找东西
- 空间:清空3个储物柜,家里更宽敞
- 心理:焦虑下降(从6→3分),决策更快(因为选择少)
- 环保:减少碳足迹(估算:每年少产生200kg垃圾)
**第六部分:挑战与妥协(200字)**
- 家人不理解:老婆/父母不配合,只能整理自己的部分
- 社会压力:节日送礼、人情往来难简化
- 实用性:有些"可能需要"的东西还是留了(工具、应急)
- 不是完美:还有10%的物品是"maybe future use"
- 转折点:当体验到"清爽"后,家人慢慢受影响
**第七部分:给初学者的建议(200字)**
- 从小开始:先整理一个抽屉或一个角落
- 不要一次扔太多:给缓刑期,减少心理阻力
- 拍照留念:纪念品不扔,但数字化
- 寻找社群:极简社区、Minimalism subreddit
- 接受不完美:目标是"足够好",不是空无一物
- 聚焦价值:极简是为了释放资源(时间、金钱、精力)做更重要的事
- "宇之然"理念:少物多思,回归内心真实需求
### 结尾(200字)
- 极简3年,生活更轻盈,更有方向
- 不是物质越多越幸福,而是"足够"才幸福
- 极简让我有更多资源去追求:深度阅读、健康生活、家人陪伴
- 呼吁:从今天开始,清理一个角落,体验清爽的感觉
- 互动:你的极简实践是什么?有什么困惑?
---
## 资源清单
### 数据来源(待收集)
- [ ] 极简主义书籍(Goodreads、豆瓣)
- [ ] 消费心理学研究(过度消费、囤积症)
- [ ] 物品生命周期和浪费数据(环保组织)
- [ ] 时间追踪数据(整理、购物时间)
- [ ] 极简对幸福感影响研究
### 图片素材(待准备)
- [ ] 整理前后对比图(同一区域)
- [ ] 物品数量变化图表
- [ ] 消费金额变化曲线
- [ ] 空间对比图(清空的储物柜)
- [ ] 极简生活场景(清爽的房间、精选的物品)
### 引用来源(待标注)
- [ ] Joshua Fields Millburn & Ryan Nicodemus
- [ ] 《极简主义》《断舍离》等书籍
- [ ] The Minimalists Podcast
- [ ] 消费主义批判文献
---
**决策**:生活方式的个人转变故事容易引发共鸣,适合叙事+方法论。小红书和知乎都合适。建议 medium 优先级。
-176
View File
@@ -1,176 +0,0 @@
# 城市观鸟指南:如何在水泥森林发现 biodiversity
**领域**:自然 / 科普
**形式**:实操指南 + 城市生态观察
**预估字数**2000
**核心观点**:自然不在远方,就在身边;城市也是wildlife的栖息地,学会观察能提升生活幸福感
**受众痛点**
- 想接触自然但没时间去郊外
- 觉得城市只有鸽子麻雀,没啥可看
- 想带孩子体验自然不知从何开始
- 对生物多样性有概念但不会识别
- 担心需要专业设备和大量学习
- 城市生活枯燥,寻求新鲜感和治愈
**独特角度**
- 城市观鸟入门,从"完全小白"到"常见鸟达人"
- 结合科技:观鸟App(Merlin、鸟哨识别)、社区数据平台(eBird)
- 强调"身边":小区、公园、甚至阳台上就能开始
- 用数据说话:上海/北京等城市的鸟类种类统计
- 连接宇之然理念:科技帮助人与自然的连接
**数据/案例**(至少3个来源):
1. 城市生物多样性调研报告(中国的城市化与野生动物)
2. 常见城市鸟类图鉴(种类、特征、习性)
3. eBird/懂鸟等平台用户提交数据(观测频率)
4. 个人城市观鸟记录(一年在X城市观测到XX种)
5. 观鸟对心理健康的研究(缓解压力、提升注意力)
**预估完成时间**3天
**优先级**:中
**预计发布时间**2026-05-01
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 7 | 相对垂直,但亲子、自然爱好者多 |
| 独特性 | 9 | 城市+观鸟结合,实用性强,科普价值高 |
| 数据可得性 | 8 | 鸟类数据、App工具、调研报告充足 |
| 可持续性 | 9 | 可系列(不同季节、不同城市、不同难度) |
| 平台契合度 | 8 | 知乎科普/小红书图文,适合短视频延伸 |
| 品牌契合度 | 10 | 科技+自然+生活,完美契合 |
| **总分** | **51** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(200字)
- 场景:在上海陆家嘴办公室,突然听到窗外各种鸟叫,一个想法:城市里有啥鸟?
- 意外:下载观鸟App后,发现小区里有20+种鸟
- 变化:现在走路会抬头,阳台常备望远镜,生活多了乐趣
- 核心观点:城市不是自然的对立面,而是生态系统一部分
- 预告:教你从零开始城市观鸟,需要什么、去哪看、如何识别
### 主体(1600字)
**第一部分:城市里有多少鸟?(200字)**
- 数据:上海已记录鸟类 400+ 种,北京 500+ 种
- 常见的是哪些?(麻雀、白头鹎、乌鸫、珠颈斑鸠、白鹭、喜鹊等)
- 季节性:候鸟迁徙(春秋季更多)
- 城市生态:公园、河流、小区绿地、屋顶都能看到
- 我的记录:在XX小区/公园一年观测到XX种
- 结论:比你想象的多得多
**第二部分:观鸟入门装备(200字)**
- **最低配置**(手机 + 免费App):
- 懂鸟/Merlin Bird ID(声音识别神器)
- eBird中国(记录、查看附近观测)
- 观鸟相机(手机即可,需要长焦建议入门望远镜2k-3k)
- **进阶配置**
- 望远镜(8x42 双筒,入门推荐)
- 鸟类图鉴(纸质书或App
- 记录本或电子表格
- **着装**:低调颜色(避免亮色惊鸟),舒适鞋子
- **.total cost*:零成本起步,深度5000元以内
**第三部分:去哪看?(200字)**
- **城市公园**:最佳入门地(有人维护、鸟类聚集)
- 上海:世纪公园、滨江森林公园、上海植物园
- 北京:奥林匹克森林公园、颐和园、圆明园
- **湿地/河流**:水鸟聚集(白鹭、翠鸟、水鸡)
- 例子:上海崇明东滩、北京野鸭湖
- **小区和校园**:你身边的第一站
- 树木多的老小区往往有惊喜
- 大学校园绿化好,鸟类多
- **城市高点**:观猛禽(迁徙季节)
- **App辅助**eBird Hotspots 查看附近观测点
**第四部分:如何识别?(300字)**
- 观鸟四要素:体型、颜色、行为、声音
- **步骤1**:先看大小和整体形状(像麻雀还是喜鹊?)
- **步骤2**:看颜色分布(头部、背部、腹部、翅膀)
- **步骤3**:看行为(在地上找食?树上鸣叫?水中游?)
- **步骤4**:听声音(Merlin识别超准)
- **步骤5**:对照图鉴或App
- **常见城市鸟快速指南**(选10种):
- 麻雀:灰褐色,群体,地面觅食,城市最常见
- 白头鹎:白色头顶,背部橄榄绿,叫"WA-DA-DI"
- 乌鸫:雄性黑褐色,黄色眼圈,鸣叫婉转
- 珠颈斑鸠:棕灰色,颈后白斑像珍珠,咕咕叫
- 白鹭:白色,长腿长颈,水边常见
- 喜鹊:黑白分明,长尾巴,聪明
- 灰喜鹊:蓝灰色,群居,山区更多
- 大山雀:蓝黄配色,叫"锌锌"
- 啄木鸟:斑纹红头,树洞敲击声
- 普通翠鸟:橙蓝色, waterside,极快闪
**第五部分:季节与迁徙(200字)**
- 春季(3-5月):候鸟北归,种类最多,常见莺类
- 夏季(6-8月):留鸟+繁殖季,早上鸣叫活跃
- 秋季(9-11月):候鸟南迁,猛禽(鹰、鸮)容易看到
- 冬季(12-2月):候鸟到南方越冬,水鸟多
- 迁徙高峰期:每年4月中、10月中,去湿地有惊喜
**第六部分:科技让观鸟更简单(200字)**
- **声音识别**Merlin Bird ID(免费的!),对着鸟叫录一下,马上指出种类和概率
- **图像识别**:懂鸟App上传照片,准确率90%+
- **社区数据**:eBird 查看附近谁看到了什么,热门 spots
- **记录与统计**:电子清单,个人记录,年度总结
- **社交**:加入城市观鸟社群,组团学习
- "宇之然":科技降低自然体验门槛
**第七部分:给新手的建议(200字)**
1. 从小区开始,别追求稀有鸟
2. 带双筒望远镜(非必需但提升体验)
3. 下载 Merlin 和懂鸟,声音识别是关键
4. 学习10种最常见的,再扩展
5. 晨起最佳(鸟活跃,人少)
6. 安静缓慢,避免惊鸟
7. 记录观测(App自动记录)
8. 加入本地观鸟群,老手带入门快
9. 长期坚持,你会发现城市生命的丰富
10. 保护鸟类:不打扰、不投喂、不破坏栖息地
### 结尾(200字)
- 观鸟一年,我在城市发现了20+种鸟,还记录到罕见的红嘴蓝鹊
- 自然不是远方的诗,是身边的小确幸
- 提升了观察力、耐心、对季节变化的敏感
- 带孩子观鸟是最佳的自然教育
- "宇之然":科技沟通万物,让我重新发现身边自然
- 鼓励:明天早上花15分钟,去小区听鸟叫、看鸟飞
- 互动:你所在城市常见什么鸟?分享你的发现
---
## 资源清单
### 数据来源(待收集)
- [ ] 中国主要城市鸟类名录(中国科学院、地方鸟类学会)
- [ ] eBird 中国数据(观测统计)
- [ ] 城市生态学研究(城市化对鸟类影响)
- [ ] 鸟类迁徙路线图(东亚-澳大利西亚迁飞区)
- [ ] 观鸟对心理健康益处研究
### 图片素材(待准备)
- [ ] 常见城市鸟类照片(自己拍摄或购买授权)
- [ ] 观鸟装备照片(望远镜、App截图)
- [ ] 城市观鸟地点(公园、湿地照片)
- [ ] 观鸟者视角(手持望远镜看鸟)
- [ ] 鸟类识别对比图(相似鸟区分)
### 引用来源(待标注)
- [ ] Cornell Lab Merlin Bird ID
- [ ] eBird 中国(https://ebird.org/china
- [ ] 中国观鸟组织(如:中国观鸟记录中心)
- [ ] 鸟类图鉴(如《中国鸟类观察手册》)
- [ ] 城市生态学文献
---
**决策**:入门级自然科普,大众接受度高,亲子群体尤其适合。内容轻量但实用,适合快速创作。建议 medium 优先级。
-231
View File
@@ -1,231 +0,0 @@
# 个人知识管理系统(PKM)实践:我用一年搭建的第二大脑
**领域**:工作 / 生产力
**形式**:实操教程 + 系统设计
**预估字数**2800
**核心观点**:信息时代,会学习不如会管理知识;一个适合自己的PKM系统能释放认知带宽,提升创造力和决策质量
**受众痛点**
- 收藏无数,但从未回看(收藏夹吃灰)
- 知识点散落在各处(笔记App、书签、微信收藏、纸质本)
- 需要时找不到,找到的又不完整
- 学会了方法但无法坚持(Roam太复杂、Notion不会用)
- 信息过载,不知道如何筛选和组织
- 想建立系统但被工具选择困住(工具 switcher)
**独特角度**
- 不推销特定工具,强调方法论+适合自己的选择
- 展示一年的实践数据和系统演变(不是一蹴而就)
- 强调"用"而非"建":系统为输出服务,不为收藏
- 结合AI辅助(自动标签、智能摘要、关联发现)
- 从程序员视角但内容通用
**数据/案例**(至少3个来源):
1. PKM方法论(Zettelkasten、PARA、CODE等)
2. 工具对比研究(Notion、Obsidian、Roam、Logseq、飞书、Notability
3. 个人一年数据:笔记数量、链接数、输出成果、时间节省
4. 知识工作者效率研究(信息检索耗时)
5. 成功案例:其他内容创作者如何用PKM支撑输出
**预估完成时间**5天
**优先级**:中
**预计发布时间**2026-05-03
**状态**:待处理
---
## 选题评估矩阵
| 维度 | 评分 | 说明 |
|------|------|------|
| 受众覆盖 | 8 | 知识工作者广泛需要,但执行率低 |
| 独特性 | 8 | 个人系统演变+数据追踪,避免工具论 |
| 数据可得性 | 9 | PKM方法论丰富,个人数据可量化 |
| 可持续性 | 9 | 可系列(不同工具、不同场景、进阶技巧) |
| 平台契合度 | 9 | 知乎效率/工具,小红书个人成长 |
| 品牌契合度 | 9 | 科技赋能知识管理,提升深度思考 |
| **总分** | **52** | 优先执行 ✅ |
---
## 大纲草稿
### 开头(400字)
- 场景:2024年,我发现自己有5个笔记App、2000+零散笔记、无数书签,但写文章时还是找不到资料
- 问题:信息时代,我们被信息淹没,却缺乏可用的知识
- 决定:用一年时间,搭建并完善个人知识管理系统(PKM)
- 结果:笔记整理到统一系统,知识关联起来了,写文章效率提升3倍
- 核心观点:PKM不是收藏信息,而是将信息转化为知识资产
- 预告:分享我的方法论、工具选择、系统设计、一年数据和教训
### 主体(2200字)
**第一部分:为什么你需要PKM?(300字)**
- 信息过载时代:每年产生信息量 vs 大脑处理能力
- 知识 ≠ 信息:信息是 raw data,知识是 processed and connected
- 不管理的代价:
- 重复学习(同一个知识点看过多次但没记住)
- 需要时找不到(搜索耗时>30分钟)
- 知识孤岛(孤立信息无法产生新想法)
- 决策质量低(缺乏完整背景)
- 研究:知识工作者平均每天花1.5小时找信息(麦肯锡)
- PKM的价值:提高检索效率、支持创造性连接、减少认知负荷
**第二部分:PKM方法论概览(200字)**
- **Zettelkasten**(卡片盒笔记法):原子化、链接、渐进式总结
- 核心:每个笔记一个想法,用链接建立连接
- 适合:深度思考者、研究者、作家
- **PARA**Tiago Forte):按可行动性组织(Projects, Areas, Resources, Archives
- 核心:不是按主题,而是按"使用状态"分类
- 适合:项目驱动、注重行动的人
- **CODE**Capture, Organize, Distill, Express):知识生命周期
- 核心:从收集到输出的完整流程
- 适合:内容创作者、学习型人才
- **选择建议**:没有最佳,只有最适合;可混合使用
- 我的选择:CODE框架 + Zettelkasten 链接 + PARA 分类
**第三部分:我的系统演变(3个阶段)(300字)**
- **第1阶段(混沌期,2019-2020**
- 工具:微信收藏 + Evernote + 书签
- 问题:信息散落,从未整理,基本不用
- 产出:0
- **第2阶段(工具迷恋,2021)**:
- 尝试:Roam Research(双链笔记)、Notion(数据库)、Obsidian(本地Markdown
- 问题:花大量时间折腾工具,而不是用系统
- 产出:少量笔记,但没形成知识网络
- **第3阶段(方法驱动,2022-2023**
- 选择:Notion(数据库+协作)+ 本地Markdown备份
- 方法论:CODE流程 + 原子笔记 + 强制链接
- 成果:整理笔记1500+条,链接1000+,支撑输出文章40+篇
- 关键:从"怎么建"转向"怎么用"
**第四部分:我的当前系统设计(400字)**
- **工具栈**
- 主库:Notion(数据库+关联视图+协作)
- 优点:全平台、强查询、美观
- 缺点:网络依赖、长期成本
- 本地备份:ObsidianMarkdown + 双链)
- 优点:数据自主、快、强链接
- 缺点:移动端弱、协作差
- 收集箱:Readwise + Instapaper + 微信收藏 + 书签
- AI辅助:使用LLM自动摘要、标签、提取关键点
- **结构(按CODE**
- **Capture**(收集):
- 快速收藏(Readwise高亮、微信收藏 → 每日清理到收集箱)
- 来源:文章、书、视频、对话、思考
- **Organize**(组织):
- 分类:PARAProjects/Areas/Resources/Archives
- 标签:主题(科技、自然、人文)+ 状态(草稿、完成)
- 链接:原子笔记之间建立连接(双向链接)
- **Distill**(提炼):
- 原子化:每笔记一个概念/想法/发现
- 渐进式总结:原始笔记 → 粗摘要 → 精要 → 综述
- MOC(内容地图):索引相关笔记,形成知识模块
- **Express**(输出):
- 创作模式:从PKM查询相关笔记 → 拼图式写作
- 模板:文章模板自动从PKM填充资料
- 发布后,将文章存入PKM作为新笔记
- **工作流**
- 每日:收集(30min + 整理(30min
- 每周:链接和MOC维护(1h
- 创作时:查询 → 连接 → 输出(PKM支撑)
**第五部分:量化结果(200字)**
- 笔记总量:1500+ 条(原子笔记)
- 笔记间链接:1200+ 个(网络密度 0.8)
- 知识领域:8个大类,50+个小类
- 时间节省:资料查找从平均30min → 5min83%
- 输出支撑:40+ 篇文章,其中80%从PKM直接提取资料
- 知识复用率:70%的文章引用已有笔记(不是每次都重研究)
- 意外收益:笔记价值随时间复利增长(旧笔记连接新笔记产生新洞见)
**第六部分:关键技巧(300字)**
1. **原子化**
- 每笔记一个概念、一个发现、一个问题
- 优点:易复用、易链接、易更新
- 例子:不要记整本书笔记,记"书的核心框架"+"3个关键观点"+"我的疑问"
2. **强制链接**
- 新笔记必须链接到至少2个已有笔记
- 链接要语义相关(不只是关键词匹配)
- 使用 Zettelkasten 的自下而上连接
- 工具:Obsidian的Graph View、Notion的Relation
3. **渐进式总结**
- 原始笔记(source原文高亮+简评)
- 一阶摘要(用自己的话)
- 二阶精要(关键点 bullet
- 三阶综合(多个笔记的整合+我的观点)
- 成果:长期使用,笔记越来越有价值
4. **MOC(内容地图)**
- 索引同一主题的所有笔记(类似目录)
- 动态更新,保持相关性
- 例子:"AI伦理" MOC 链接60+条笔记
- 创作时,先看MOC快速定位
5. **定期修剪**
- 每月审查:合并重复、删除过时、更新链接
- 保持系统健康,避免熵增
**第七部分:AI如何辅助PKM?(200字)**
- **自动摘要**:上传文章,LLM生成关键点(节省阅读时间)
- **智能标签**:自动建议标签,保持一致性
- **关联发现**:AI找语义相关笔记,建议链接
- **问答式检索**:自然语言查询("我怎么写关于Zettelkasten的文章?" → 相关笔记)
- **质量检查**:识别孤立笔记、断裂链接
- 工具:ChatGPT/Claude API + 自定义脚本
- "宇之然"AI时代,PKM是核心基础设施
**第八部分:常见问题与避坑(200字)**
- Q:工具太多,选哪个?(先用一个,习惯后再考虑迁移)
- Q:笔记分类 vs 标签?(分类是宏观,标签是微观,两者都需)
- Q:多久整理一次?(每天小整,每周大整)
- Q:隐私问题?(本地工具如Obsidian;云工具加密)
- Q:如何坚持?(绑定创作需求,系统为输出服务)
- 避坑:
- 不要过度组织(1-2小时整理1条笔记,效率低)
- 不要追求完美的分类(允许模糊)
- 不要忽视维护(熵增是必然,定期修剪)
- 不要只看不输出(PKM的价值在输出)
### 结尾(200字)
- PKM一年,我的知识从混沌到有序,从孤岛到网络
- 最大的收益:写文章不再从零开始,而是"拼图式创作"
- 知识管理是终身投资,越早开始复利越大
- "宇之然":科技沟通知识,让我更好地表达思想
- 行动:从今天开始,用Notation至少10个原子笔记
- 互动:你的知识管理实践如何?遇到的问题?
---
## 资源清单
### 数据来源(待收集)
- [ ] PKM方法论原始文献(Zettelkasten、PARA、CODE
- [ ] 知识管理工作效率研究(查找时间、复用率)
- [ ] 工具对比(Notion、Obsidian、Roam、Logseq、飞书)
- [ ] Notion API和自动化案例
- [ ] AI辅助知识管理工具(Memex、Rewind、Upabase
### 图片素材(待准备)
- [ ] 系统架构图(工具栈、数据流)
- [ ] Notion数据库截图(匿名化)
- [ ] Obsidian Graph View截图(展示连接密度)
- [ ] 工作流可视化(Capture → Organize → Distill → Express
- [ ] 量化结果图表(笔记数、链接数、时间节省)
- [ ] 渐进式总结示例(同一笔记的不同版本)
### 引用来源(待标注)
- [ ] Sönke Ahrens《How to Take Smart Notes》
- [ ] Tiago Forte《Building a Second Brain》
- [ ] Andy Matuschak & Michael Nielsen《How to Make a Networked Notebook That Explains Things》
- [ ] PARA方法论官网
- [ ] Zettelkasten.de社区
- [ ] AI知识管理工具(Mem.ai等)
---
**决策**:生产力领域刚需,受众广但竞争烈。结合个人系统演变数据+方法论+AI辅助,能做出差异化。适合知乎和小红书。建议 medium-high 优先级。
-71
View File
@@ -1,71 +0,0 @@
# 选题库管理
本目录收录所有选题卡片,按领域和优先级分类。
## 选题结构
每个选题文件格式:`YYYY-MM-DD-选题名称.md`
内容模板:
```markdown
# [选题名称]
**领域**:科技/自然/工作/人文
**形式**:观点/指南/趋势/故事
**预估字数**2000-3000
**核心观点**:(一句话说清楚)
**受众痛点**:(读者为什么关心?)
**独特角度**:(与其他人的区别)
**数据/案例**:(至少3个来源)
**预估完成时间**3-5天
**优先级**:高/中/低
**预计发布时间**YYYY-MM-DD
**状态**:待处理/研究中/写作中/已发布
```
## 选题评估得分
使用品牌手册中的"选题评估矩阵"打分:
- 受众覆盖:1-10分
- 独特性:1-10分
- 数据可得性:1-10分
- 可持续性:1-10分
- 平台契合度:1-10分
- 品牌契合度:1-10分
**总分 > 40分** → 优先执行
**30-40分** → 考虑执行
**< 30分** → 暂缓或放弃
## 当前选题列表(个人叙事版 001-010)
| 编号 | 选题名称 | 领域 | 优先级 | 得分 | 状态 |
|------|----------|------|--------|------|------|
| 001 | 在上海阳台种菜一年,我收获了啥? | 自然/生活 | 高 | 51 | ✅ 已发布 |
| 002 | AI时代,什么能力不会被替代? | 科技/职场 | 高 | 48 | 📝 待创作 |
| 003 | 从程序员到数字游民:三年转型之路 | 工作/职场 | 高 | 45 | 📝 待创作 |
| 004 | 零浪费生活一年实验 | 自然/生活 | 中 | 42 | 📝 待创作 |
| 005 | 深度工作实践:如何在干扰世界保持专注 | 工作/生产力 | 高 | 46 | 📝 待创作 |
| 006 | 副业月入过万:程序员的三年探索 | 工作/副业 | 中 | 40 | 📝 待创作 |
| 007 | 正念冥想一年:从焦虑到平静的转变 | 人文/心理 | 中 | 41 | 📝 待创作 |
| 008 | 极简3年:从囤积症到少物生活的转变 | 人文/生活 | 中 | 39 | 📝 待创作 |
| 009 | 城市观鸟指南:如何在水泥森林发现biodiversity | 自然/科普 | 中 | 38 | 📝 待创作 |
| 010 | 个人知识管理系统(PKM)实践 | 工作/生产力 | 高 | 47 | 📝 待创作 |
> 另有 **全球-本土对比新选题(20个)** 详见 `strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md`,分为4大支柱:未来工作方式、可持续生活、知识工厂、科技人文。
## 批量添加选题
新选题从以下来源获取:
- 行业趋势分析(research/trends-2026.md
- 日常观察记录
- 热点事件(冷静筛选)
- 读者问题反馈
- 竞品分析(差异化)
**每周选题会议**:每周日晚上20:00,评估新选题,确定下周发布计划。
---
**维护**:动态更新
**负责人**AI助手 + 用户协作
+39
View File
@@ -0,0 +1,39 @@
# ===== 数据库 =====
# 默认 SQLiteUSE_POSTGRES=false);生产设 true 并填 DATABASE_URL
USE_POSTGRES=false
# PG_HOST=db
# PG_PORT=5432
# PG_DATABASE=yzr_nr
# PG_USER=yzr
# PG_PASSWORD=change_me_strong
# DATABASE_URL=postgresql://yzr:yzr_password@db:5432/yzr_nr
# ===== 安全 =====
# 务必修改为随机长字符串
SECRET_KEY=change_this_to_a_long_random_secret_key
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_DAYS=7
# ===== 初始管理员 =====
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123
# ===== 项目根(容器内固定为 /app =====
PROJECT_ROOT=/app
# ===== 主 LLM(按 provider 填写对应 Key =====
# provider 可选:nvidia / opencode-go / sensenova
LLM_PROVIDER=opencode-go
LLM_API_KEY=your_main_llm_key
LLM_MODEL=deepseek-v4-flash
LLM_BASE_URL=https://opencode.ai/zen/go/v1
# ===== 备用推理通道(opencode-go =====
OPENCODE_API_KEY=your_opencode_key
OPENCODE_BASE_URL=https://opencode.ai/zen/go/v1
OPENCODE_MODEL=deepseek-v4-flash
# ===== 配图生成(sensenova,可选) =====
SENSENOVA_API_KEY=
SENSENOVA_BASE_URL=https://token.sensenova.cn/v1
SENSENOVA_IMAGE_MODEL=sensenova-6.7-flash-lite
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
# 安装依赖(先拷 requirements 享受层缓存)
COPY platform/backend/requirements.txt /app/platform/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/platform/backend/requirements.txt
# 拷贝整个项目(保持 automation/ scripts/ 等相对路径)
COPY . /app
# 关键:让 initial_data.py 的 PROJECT_ROOT 指向 /app(与运行时一致)
ENV PROJECT_ROOT=/app
ENV USE_POSTGRES=false
ENV PYTHONUNBUFFERED=1
EXPOSE 8001
# 若使用 venv 可改为 ./start-platform.sh 8001
CMD ["sh", "-c", "cd /app/platform/backend && uvicorn app.main:app --host 0.0.0.0 --port 8001"]
+74
View File
@@ -0,0 +1,74 @@
# 宇之然内容创作平台 — 私有化 / 白标部署包
# YuZhiRan Content Platform — Private / White-label Deployment Package
本目录用于把「宇之然内容创作平台」作为**可私有化部署的 AI 内容中台**交付给客户(MCN、企业市场部、行业自媒体团队),
或作为白标产品转售。平台已内置白标能力:部署后管理员在「白标设置」页即可改平台名称 / Logo / 主色 / 支持邮箱。
## 交付形态
- 单容器即可运行(默认 SQLite,零外部依赖);生产建议挂 PostgreSQL(见下方 docker-compose 注释)。
- 全部为静态前端 + FastAPI 后端,无第三方 SaaS 依赖,可跑在内网 / 离线环境。
- 符合《AI 生成合成内容标识办法》:AI 稿默认注入显式 + 隐式声明,发布前必须由真人校验(系统不代发)。
## 一分钟启动(SQLite,零依赖)
```bash
# 1. 准备后端环境变量(含管理员密码、LLM Key)
cp .env.example ../platform/backend/.env
vim ../platform/backend/.env # 至少填 LLM_API_KEY / DEFAULT_ADMIN_PASSWORD
# 2. 构建并启动
docker compose up -d --build
# 3. 访问
# 前台: http://<服务器IP>:8001/
# 后台: http://<服务器IP>:8001/admin.html (默认 admin / 见 .env 中 DEFAULT_ADMIN_PASSWORD
```
## 生产部署(PostgreSQL
编辑 `docker-compose.yml`,取消 `db` 服务与 `web``USE_POSTGRES=true` / `DATABASE_URL` 的注释,
并删掉 `web``USE_POSTGRES=false` 那一行。数据库数据持久化在命名卷 `yzr_pgdata`
## 白标(转售 / 客户内网)
1. 用管理员登录后台 → 左侧「白标设置」。
2. 填入客户的平台名称、Logo URL、主色、支持邮箱 → 保存。
3. 前台导航与品牌色即时生效。无需改代码、无需重新构建镜像。
(底层读取 `system_configs` 表中 `brand_name / brand_logo / brand_primary_color / brand_support_email`
亦可直接 `UPDATE system_configs SET value='...' WHERE key='brand_name';` 批量初始化多客户。)
## 数据目录
容器内:
- 代码:`/app`
- SQLite 数据:`/app/platform/backend/yu_zhi_ran.db`
- 生成内容/图片:`/app/automation`(建议挂载卷持久化)
## 环境变量说明(platform/backend/.env
| 变量 | 说明 |
| --- | --- |
| USE_POSTGRES | true 用 PostgreSQLfalse 用 SQLite |
| PG_HOST/PG_PORT/PG_DATABASE/PG_USER/PG_PASSWORD | PostgreSQL 连接(USE_POSTGRES=true 时) |
| SECRET_KEY | JWT 签名密钥(**务必修改**) |
| DEFAULT_ADMIN_USERNAME / DEFAULT_ADMIN_PASSWORD | 初始管理员(首次启动创建) |
| LLM_API_KEY / LLM_MODEL / LLM_PROVIDER | 主 LLMnvidia / opencode-go / sensenova 等) |
| OPENCODE_API_KEY / OPENCODE_BASE_URL / OPENCODE_MODEL | 备用推理通道 |
| SENSENOVA_API_KEY | 配图生成(可选) |
## 目录结构(源码)
```
yu-zhi-ran/
├── platform/backend/ # FastAPI + SQLAlchemy 后端
├── platform/frontend/ # Vue3 CDN 前端(无需构建)
├── automation/ # 选题/案例/生成内容数据
├── scripts/ # 采集 / 创作 / 发布 脚本
├── tests/ # 集成测试
└── deploy/ # 本部署包
```
## 许可与交付建议
三种对外合作模式(详见官网 landing.html 的「合作模式 / 定价」区):
1. **免费试用**:私有化试用版(功能完整、限文章数)或预约 1v1 演示,零成本获客钩子。
2. **私有化部署(主力)**:每个客户一套独立部署(数据隔离),支持品牌白标。
- 建议定价:买断(¥29,800 起)+ 年运维/更新订阅(¥6,000/年起);或按实例数 / 坐席授权。
3. **源码售卖(高利润)**:完整源代码一次性授权 + 年订阅技术支持,可二次开发。
- AI 内容合规责任在内容发布方(客户),平台仅提供声明注入与发布前人工校验闸门(不代发)。
+39
View File
@@ -0,0 +1,39 @@
services:
web:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: yzr-platform
ports:
- "8001:8001"
environment:
# 默认 SQLite(零依赖)。生产请改用下方 PostgreSQL 配置:
- USE_POSTGRES=false
# - USE_POSTGRES=true
# - DATABASE_URL=postgresql://yzr:yzr_password@db:5432/yzr_nr
- PROJECT_ROOT=/app
- PYTHONUNBUFFERED=1
volumes:
# 持久化生成内容与数据
- app_automation:/app/automation
- app_content:/app/content
# 挂载后端 .env(含管理员密码、LLM Key
- ./backend.env:/app/platform/backend/.env:ro
restart: unless-stopped
# 生产用 PostgreSQL(取消注释并启用上方 USE_POSTGRES=true / DATABASE_URL
# db:
# image: postgres:16-alpine
# container_name: yzr-db
# environment:
# - POSTGRES_USER=yzr
# - POSTGRES_PASSWORD=yzr_password
# - POSTGRES_DB=yzr_nr
# volumes:
# - yzr_pgdata:/var/lib/postgresql/data
# restart: unless-stopped
volumes:
app_automation:
app_content:
yzr_pgdata:
+86
View File
@@ -0,0 +1,86 @@
# 宇之然 · 商业模式与营销方案(2026-07)
> 目标:把产品从"自用内部工具"升级为"可对外售卖的 AI 内容中台",并在官网首页(landing.html)展示三种商业模式。
---
## 一、项目功能梳理(能力盘点)
| 模块 | 能力 | 商业价值 |
|------|------|----------|
| 选题 topics | 热点采集 → 选题 → 审查 → 待发布状态机 | 解决"不知道写什么" |
| 内容工厂 factory / studio | 三平台差异化生成(知乎深度 / 公众号叙事 / 小红书干货) | 解决"写不出 / 质量低" |
| 文章管理 articles | 编辑 / AI 味检测 / 合规闸门 / 发布记录 | 解决"AI 味重 + 合规风险" |
| AI 味检测 ai_slop | 30+ 套话规则 + 软硬问题分级 | 差异化卖点(合规) |
| 搜索 / GEO metrics + search_rankings | Bing + AI 引用追踪、GEO 就绪度 | 解决"写了没流量" |
| 外推 / 素材 / 日历 / 任务 | 全流程运营闭环 | 一体化(vs 多工具拼凑) |
| 白标 branding + 私有化 deploy | 改品牌名 / Logo / 主色,Docker 一键部署 | **可直接当产品卖给客户** |
核心差异化:**三平台独立成稿 + AI 味检测 + GEO 获客 + 合规闸门**,且是"可私有化 / 可白标"的交付形态——这正是 to-B 卖点。
---
## 二、市场需求(2026
- **内容获客刚需**:公众号 / 知乎 / 小红书仍是中小团队低成本获客主阵地,但"日更 + 多平台 + 质量"人力成本高。
- **GEO 新窗口**ChatGPT / DeepSeek / Perplexity 等 AI 搜索兴起,品牌"被 AI 引用"成新需求,竞品少、溢价高。
- **合规强约束**:《AI 生成合成内容标识办法》要求 AI 稿真人校验后发布——产品内"不代发、人工闸门"正好切中。
- **私有化偏好上升**:数据不出域、品牌自主,是 MCN / 企业市场部 / 政务行业的硬需求(也是 SaaS 红海里能避开的价格带)。
---
## 三、三种商业模式可行性
| 模式 | 可行性 | 现状 | 要做的事 |
|------|--------|------|----------|
| **私有化部署** | ✅ 最成熟 | deploy/ Docker 包 + 白标已就绪 | 主要做营销包装 + 报价,几乎零研发 |
| **源码售卖** | ✅ 容易 | 整套代码已在 | 加 License / 授权条款 + 定价页(买断 / 年订阅 / 实例数) |
| **免费试用** | ⚠️ 需补研发 | 无自注册(仅 admin 建用户) | 以"私有化试用版 / 预约演示 / 限时体验"作为获客钩子,避开纯 SaaS 基础设施投入 |
**策略结论(沿用第二章)**:纯公有云 SaaS 是红海。真正壁垒是"可私有化 + 可白标 + 合规",因此以 **私有化部署 + 源码售卖为主力,免费试用作获客钩子(而非独立 SaaS)** 最稳。
---
## 四、首页营销展示方案(landing.html 改造)
官网营销首页 `landing.html` 已存在(Hero / 功能 / 流程 / 定价 / FAQ / CTA),但定价仍停留在"SaaS 月订阅"框架。改造内容:
### 4.1 定价区(#pricing)重构为三段式
1. **免费试用**(引流):私有化试用版 / 预约演示,限文章数,零成本体验完整流程。CTA → 申请试用(锚点 #contact)。
2. **私有化部署**(主力,推荐):买断 + 年运维订阅,数据不出域 + 白标 + 合规闸门。CTA → 获取报价(#contact)。
3. **源码售卖**(高利润):一次性授权 + 年订阅支持,可二次开发。CTA → 联系销售(#contact)。
### 4.2 FAQ 增补
- 什么是私有化部署?数据在哪里?
- 可以买源码自己改吗?授权方式?
- 免费试用怎么申请?
- 内容合规怎么保证?(人工闸门 / 不代发)
- 数据安全 / 多租户隔离。
### 4.3 新增"开始合作"联系区(#contact
- 联系信息(邮箱 / 微信 / 商务对接)
- 轻量线索表单(姓名 / 公司 / 需求 / 联系方式)→ mailto 提交(无后端依赖,低风险)
- 定价 CTA 统一锚定 #contact
### 4.4 其它文案对齐
- 导航"免费使用"→"免费试用";新增"联系销售"
- Hero CTA 对齐
- deploy/README 定价说明同步为三模式
---
## 五、落地优先级
1. **立即(本次实现)**landing.html 定价三段式 + FAQ + #contact 表单 + 文案对齐。
2. **可选增强**:真实线索入库(/api/leads + Lead 表)、私有化试用版自动下发、报价单生成。
3. **远期**:若决定做纯 SaaS 免费试用,再补"开放注册 + 额度"产品能力(当前非必需)。
---
## 六、定价建议(示例,可调整)
| 模式 | 参考报价 | 备注 |
|------|----------|------|
| 免费试用 | ¥0 | 私有化试用版 / 预约演示,限文章数 |
| 私有化部署 | ¥29,800 起(买断)+ 年运维订阅 ¥6,000/年起 | 按实例 / 坐席可选 |
| 源码售卖 | ¥XX,XXX(一次性授权)+ 年订阅支持 | 可二次开发,含部署指导 |
+1 -1
View File
@@ -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"])
+206
View File
@@ -0,0 +1,206 @@
import sys
from pathlib import Path
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends, Body
from pydantic import BaseModel
from ..database import get_db
from ..models import User, Topic, Article
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/ai-slop", tags=["ai-slop"])
PROJECT_ROOT = Path(__file__).resolve().parents[4]
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
if not SCRIPTS_DIR.exists():
SCRIPTS_DIR = PROJECT_ROOT.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
check_article = None
polish_with_llm = None
clean_html_content = None
strip_ai_preface = None
strip_thinking_html = None
try:
from compliance_checker import check_article
except Exception:
check_article = None
try:
from compliance_optimizer import polish_with_llm
except Exception:
polish_with_llm = None
try:
from content_cleaner import clean_html_content, strip_ai_preface, strip_thinking_html
except Exception:
clean_html_content = None
strip_ai_preface = None
strip_thinking_html = None
HARD_ISSUE_TYPES = ("敏感词", "法律法规", "平台规则", "品牌规范", "资源合规")
class IssueItem(BaseModel):
type: str
category: str = ""
detail: str = ""
suggestion: str = ""
severity: str = "medium"
class PlatformReport(BaseModel):
platform: str
score: int
passed: bool
issues: List[IssueItem]
html_preview: str = ""
class ReportResponse(BaseModel):
topic_id: str
platforms: List[PlatformReport]
class PurifyRequest(BaseModel):
topic_id: str
platform: str
class PurifyResponse(BaseModel):
ok: bool
platform: str
score_before: Optional[int] = None
score_after: Optional[int] = None
issues_before: List[IssueItem] = []
issues_after: List[IssueItem] = []
preview: str = ""
message: str = ""
def _normalize_issues(issues: list) -> List[IssueItem]:
result = []
for i in issues or []:
itype = i.get("type", "")
severity = "high" if itype in HARD_ISSUE_TYPES else "medium"
detail = i.get("detail") or i.get("suggestion") or i.get("word") or i.get("tag") or i.get("pattern") or ""
result.append(IssueItem(
type=itype,
category=i.get("category", ""),
detail=detail,
suggestion=i.get("suggestion", ""),
severity=severity,
))
return result
def _extract_title(html: str) -> str:
import re
m = re.search(r"<title>\s*([^<]+?)\s*</title>", html, re.IGNORECASE)
if not m:
m = re.search(r"<h1[^>]*>\s*([^<]+?)\s*</h1>", html, re.IGNORECASE)
return m.group(1).strip() if m else ""
def _extract_content(html: str) -> str:
import re
text = re.sub(r"<style.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<script.*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
return re.sub(r"\s+", " ", text).strip()
def _load_topic_data(db, topic_id: str) -> dict:
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
return {}
return {
"topic": {
"title": getattr(topic, "title", "") or "",
"field": getattr(topic, "field", "") or "",
"core_concept": getattr(topic, "core_concept", "") or "",
}
}
@router.get("/report", response_model=ReportResponse)
def get_report(topic_id: str, current_user: User = Depends(get_current_user), db=Depends(get_db)):
if check_article is None:
raise HTTPException(status_code=503, detail="合规检测模块不可用")
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
of = org_filter(current_user, Topic)
if of is not True and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
topic_data = _load_topic_data(db, topic_id)
from db_helper import get_articles_by_topic
articles = get_articles_by_topic(topic_id)
platforms = []
for art in articles:
platform = art.get("platform")
html = art.get("html_content") or ""
if not html:
continue
res = check_article(html, platform, topic_data=topic_data)
platforms.append(PlatformReport(
platform=platform,
score=res.get("score", 0),
passed=res.get("passed", False),
issues=_normalize_issues(res.get("issues", [])),
html_preview=html[:600],
))
return ReportResponse(topic_id=topic_id, platforms=platforms)
@router.post("/purify", response_model=PurifyResponse)
def purify(req: PurifyRequest, current_user: User = Depends(get_current_user), db=Depends(get_db)):
if check_article is None:
raise HTTPException(status_code=503, detail="合规检测模块不可用")
topic = db.query(Topic).filter(Topic.id == req.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
of = org_filter(current_user, Topic)
if of is not True and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
from db_helper import get_articles_by_topic, save_article
articles = get_articles_by_topic(req.topic_id)
target = next((a for a in articles if a.get("platform") == req.platform), None)
if not target or not target.get("html_content"):
raise HTTPException(status_code=404, detail=f"未找到 {req.platform} 平台的文章")
html = target["html_content"]
topic_data = _load_topic_data(db, req.topic_id)
before = check_article(html, req.platform, topic_data=topic_data)
issues_before = _normalize_issues(before.get("issues", []))
raw_issues = before.get("issues", [])
polished_html, log_msg = (html, None)
if polish_with_llm is not None:
polished_html, log_msg = polish_with_llm(html, req.platform, remaining_issues=raw_issues)
cleaned = polished_html
if clean_html_content is not None:
cleaned = clean_html_content(cleaned)
if strip_ai_preface is not None:
cleaned = strip_ai_preface(cleaned)
if strip_thinking_html is not None:
cleaned = strip_thinking_html(cleaned)
title = _extract_title(cleaned)
content = _extract_content(cleaned)
save_article(req.topic_id, req.platform, cleaned, title=title, content=content, db=db)
after = check_article(cleaned, req.platform, topic_data=topic_data)
issues_after = _normalize_issues(after.get("issues", []))
message = "净化完成" + (f"{log_msg}" if log_msg else "(仅执行清洗,未调用 LLM")
return PurifyResponse(
ok=True,
platform=req.platform,
score_before=before.get("score"),
score_after=after.get("score"),
issues_before=issues_before,
issues_after=issues_after,
preview=cleaned[:600],
message=message,
)
+2 -1
View File
@@ -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
+10
View File
@@ -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,
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Optional
from pydantic import BaseModel
from ..database import get_db
from ..models import SystemConfig
from .auth import get_current_admin
router = APIRouter(prefix="/api/branding", tags=["branding"])
BRAND_KEYS = {
"brand_name": "宇之然内容创作平台",
"brand_logo": "",
"brand_primary_color": "#2563eb",
"brand_support_email": "",
}
PUBLIC_RESP_DESC = "白标实例公开信息(供前端运行时应用品牌)"
class BrandingResponse(BaseModel):
brand_name: str
brand_logo: str
brand_primary_color: str
brand_support_email: str
version: str
class BrandingUpdate(BaseModel):
brand_name: Optional[str] = None
brand_logo: Optional[str] = None
brand_primary_color: Optional[str] = None
brand_support_email: Optional[str] = None
def _get(db: Session, key: str) -> str:
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if not cfg or cfg.value is None:
return BRAND_KEYS.get(key, "")
return cfg.value
@router.get("", response_model=BrandingResponse)
def get_branding(db: Session = Depends(get_db)):
"""公开接口:返回当前实例的白标配置(无需登录),用于前端运行时渲染品牌"""
try:
from ..main import app
version = getattr(app, "version", "0.1.0")
except Exception:
version = "0.1.0"
return BrandingResponse(
brand_name=_get(db, "brand_name"),
brand_logo=_get(db, "brand_logo"),
brand_primary_color=_get(db, "brand_primary_color"),
brand_support_email=_get(db, "brand_support_email"),
version=version,
)
@router.put("", response_model=BrandingResponse)
def update_branding(
payload: BrandingUpdate,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""管理员接口:更新白标配置(品牌名/Logo/主色/支持邮箱)"""
data = payload.model_dump(exclude_unset=True)
for key, value in data.items():
if key not in BRAND_KEYS:
continue
if value is None:
continue
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if cfg:
cfg.value = str(value)
else:
db.add(SystemConfig(key=key, value=str(value), description="白标配置"))
db.commit()
return get_branding(db)
+12 -2
View File
@@ -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()
+1 -1
View File
@@ -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)])
+1 -1
View File
@@ -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"])
@@ -0,0 +1,524 @@
import os
import sys
import subprocess
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import Optional
from datetime import datetime, timezone
from ..database import get_db
from ..models import ExternalProduct, PromotionCampaign, CampaignKeyword, SEOAudit, KeywordRanking, OptimizationTask, User
from .auth import get_current_user, get_current_admin, org_filter
router = APIRouter(prefix="/api/external", tags=["external_promotion"])
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
SEO_AUDITOR_PATH = PROJECT_ROOT / "scripts" / "seo_auditor.py"
class AuditRunRequest(BaseModel):
product_id: Optional[str] = None
url: Optional[str] = None
def _org_filtered_query(db, model, current_user):
query = db.query(model)
of = org_filter(current_user, model)
if of is not True:
query = query.filter(of)
return query
# ===== External Products =====
@router.get("/products")
def list_products(
type: Optional[str] = None,
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = _org_filtered_query(db, ExternalProduct, current_user)
if type:
query = query.filter(ExternalProduct.type == type)
products = query.order_by(desc(ExternalProduct.created_at)).limit(limit).all()
return {"ok": True, "data": [p.to_dict() for p in products]}
@router.get("/products/{product_id}")
def get_product(
product_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
product = _org_filtered_query(db, ExternalProduct, current_user).filter(
ExternalProduct.id == product_id
).first()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
return {"ok": True, "data": product.to_dict()}
@router.post("/products")
def create_product(
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
product = ExternalProduct(
name=body.get("name"),
type=body.get("type", "website"),
url=body.get("url"),
domain=body.get("domain"),
app_id=body.get("app_id"),
account_id=body.get("account_id"),
description=body.get("description"),
org_id=current_user.org_id,
)
db.add(product)
db.commit()
db.refresh(product)
return {"ok": True, "data": product.to_dict()}
@router.put("/products/{product_id}")
def update_product(
product_id: int,
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
product = _org_filtered_query(db, ExternalProduct, current_user).filter(
ExternalProduct.id == product_id
).first()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
for field in ("name", "type", "url", "domain", "app_id", "account_id", "description"):
if field in body:
setattr(product, field, body[field])
db.commit()
db.refresh(product)
return {"ok": True, "data": product.to_dict()}
@router.delete("/products/{product_id}")
def delete_product(
product_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
product = _org_filtered_query(db, ExternalProduct, current_user).filter(
ExternalProduct.id == product_id
).first()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
db.delete(product)
db.commit()
return {"ok": True, "message": "已删除"}
# ===== Promotion Campaigns =====
@router.get("/campaigns")
def list_campaigns(
product_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = _org_filtered_query(db, PromotionCampaign, current_user)
if product_id:
query = query.filter(PromotionCampaign.product_id == product_id)
if status:
query = query.filter(PromotionCampaign.status == status)
campaigns = query.order_by(desc(PromotionCampaign.created_at)).limit(limit).all()
result = []
for c in campaigns:
d = c.to_dict()
product = db.query(ExternalProduct).filter(ExternalProduct.id == c.product_id).first()
d["product_name"] = product.name if product else None
result.append(d)
return {"ok": True, "data": result}
@router.get("/campaigns/{campaign_id}")
def get_campaign(
campaign_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
campaign = _org_filtered_query(db, PromotionCampaign, current_user).filter(
PromotionCampaign.id == campaign_id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="活动不存在")
d = campaign.to_dict()
product = db.query(ExternalProduct).filter(ExternalProduct.id == campaign.product_id).first()
d["product_name"] = product.name if product else None
return {"ok": True, "data": d}
@router.post("/campaigns")
def create_campaign(
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
product = db.query(ExternalProduct).filter(ExternalProduct.id == body.get("product_id")).first()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
campaign = PromotionCampaign(
name=body.get("name"),
product_id=body.get("product_id"),
status=body.get("status", "active"),
keywords=body.get("keywords", []),
target_engines=body.get("target_engines", []),
notes=body.get("notes"),
org_id=current_user.org_id,
)
db.add(campaign)
db.commit()
db.refresh(campaign)
d = campaign.to_dict()
d["product_name"] = product.name
return {"ok": True, "data": d}
@router.put("/campaigns/{campaign_id}")
def update_campaign(
campaign_id: int,
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
campaign = _org_filtered_query(db, PromotionCampaign, current_user).filter(
PromotionCampaign.id == campaign_id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="活动不存在")
for field in ("name", "status", "keywords", "target_engines", "notes"):
if field in body:
setattr(campaign, field, body[field])
db.commit()
db.refresh(campaign)
d = campaign.to_dict()
product = db.query(ExternalProduct).filter(ExternalProduct.id == campaign.product_id).first()
d["product_name"] = product.name if product else None
return {"ok": True, "data": d}
@router.delete("/campaigns/{campaign_id}")
def delete_campaign(
campaign_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
campaign = _org_filtered_query(db, PromotionCampaign, current_user).filter(
PromotionCampaign.id == campaign_id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="活动不存在")
db.delete(campaign)
db.commit()
return {"ok": True, "message": "已删除"}
# ===== Campaign Keywords =====
@router.get("/keywords")
def list_keywords(
campaign_id: Optional[int] = None,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = _org_filtered_query(db, CampaignKeyword, current_user)
if campaign_id:
query = query.filter(CampaignKeyword.campaign_id == campaign_id)
keywords = query.order_by(desc(CampaignKeyword.created_at)).limit(limit).all()
return {"ok": True, "data": [k.to_dict() for k in keywords]}
@router.post("/keywords")
def create_keyword(
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
campaign = db.query(PromotionCampaign).filter(PromotionCampaign.id == body.get("campaign_id")).first()
if not campaign:
raise HTTPException(status_code=404, detail="活动不存在")
kw = CampaignKeyword(
campaign_id=body.get("campaign_id"),
keyword=body.get("keyword"),
search_volume=body.get("search_volume"),
difficulty=body.get("difficulty"),
current_rank=body.get("current_rank"),
target_rank=body.get("target_rank"),
org_id=current_user.org_id,
)
db.add(kw)
db.commit()
db.refresh(kw)
return {"ok": True, "data": kw.to_dict()}
@router.put("/keywords/{keyword_id}")
def update_keyword(
keyword_id: int,
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
kw = _org_filtered_query(db, CampaignKeyword, current_user).filter(
CampaignKeyword.id == keyword_id
).first()
if not kw:
raise HTTPException(status_code=404, detail="关键词不存在")
for field in ("search_volume", "difficulty", "current_rank", "target_rank"):
if field in body:
setattr(kw, field, body[field])
if body.get("update_last_checked"):
kw.last_checked = datetime.now(timezone.utc)
db.commit()
db.refresh(kw)
return {"ok": True, "data": kw.to_dict()}
@router.delete("/keywords/{keyword_id}")
def delete_keyword(
keyword_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
kw = _org_filtered_query(db, CampaignKeyword, current_user).filter(
CampaignKeyword.id == keyword_id
).first()
if not kw:
raise HTTPException(status_code=404, detail="关键词不存在")
db.delete(kw)
db.commit()
return {"ok": True, "message": "已删除"}
# ===== SEO Audits =====
@router.get("/audits")
def list_audits(
product_id: Optional[int] = None,
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = _org_filtered_query(db, SEOAudit, current_user)
if product_id:
query = query.filter(SEOAudit.product_id == product_id)
audits = query.order_by(desc(SEOAudit.checked_at)).limit(limit).all()
result = []
for a in audits:
d = a.to_dict()
product = db.query(ExternalProduct).filter(ExternalProduct.id == a.product_id).first()
d["product_name"] = product.name if product else None
result.append(d)
return {"ok": True, "data": result}
@router.get("/audits/{audit_id}")
def get_audit(
audit_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
audit = _org_filtered_query(db, SEOAudit, current_user).filter(
SEOAudit.id == audit_id
).first()
if not audit:
raise HTTPException(status_code=404, detail="审计记录不存在")
d = audit.to_dict()
d["raw_data"] = audit.raw_data or {}
tasks = db.query(OptimizationTask).filter(OptimizationTask.audit_id == audit_id).all()
d["optimization_tasks"] = [t.to_dict() for t in tasks]
return {"ok": True, "data": d}
# ===== Keyword Rankings =====
@router.get("/rankings")
def list_keyword_rankings(
campaign_id: Optional[int] = None,
keyword_id: Optional[int] = None,
product_id: Optional[int] = None,
search_engine: Optional[str] = None,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = _org_filtered_query(db, KeywordRanking, current_user)
if campaign_id:
query = query.filter(KeywordRanking.campaign_id == campaign_id)
if keyword_id:
query = query.filter(KeywordRanking.keyword_id == keyword_id)
if product_id:
query = query.filter(KeywordRanking.product_id == product_id)
if search_engine:
query = query.filter(KeywordRanking.search_engine == search_engine)
rankings = query.order_by(desc(KeywordRanking.checked_at)).limit(limit).all()
return {"ok": True, "data": [r.to_dict() for r in rankings]}
@router.get("/rankings/overview")
def rankings_overview(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
base = _org_filtered_query(db, KeywordRanking, current_user)
total = base.count()
with_rank = base.filter(KeywordRanking.rank.isnot(None)).count()
engine_breakdown = {}
for row in base.with_entities(KeywordRanking.search_engine,
KeywordRanking.rank).all():
eng = row.search_engine
if eng not in engine_breakdown:
engine_breakdown[eng] = {"total": 0, "with_rank": 0}
engine_breakdown[eng]["total"] += 1
if row.rank is not None:
engine_breakdown[eng]["with_rank"] += 1
return {
"ok": True,
"data": {
"total": total,
"with_rank": with_rank,
"engine_breakdown": engine_breakdown,
}
}
# ===== Optimization Tasks =====
@router.get("/optimization-tasks")
def list_optimization_tasks(
product_id: Optional[int] = None,
audit_id: Optional[int] = None,
status: Optional[str] = None,
severity: Optional[str] = None,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = _org_filtered_query(db, OptimizationTask, current_user)
if product_id:
query = query.filter(OptimizationTask.product_id == product_id)
if audit_id:
query = query.filter(OptimizationTask.audit_id == audit_id)
if status:
query = query.filter(OptimizationTask.status == status)
if severity:
query = query.filter(OptimizationTask.severity == severity)
tasks = query.order_by(desc(OptimizationTask.created_at)).limit(limit).all()
return {"ok": True, "data": [t.to_dict() for t in tasks]}
@router.patch("/optimization-tasks/{task_id}")
def update_optimization_task(
task_id: int,
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = _org_filtered_query(db, OptimizationTask, current_user).filter(
OptimizationTask.id == task_id
).first()
if not task:
raise HTTPException(status_code=404, detail="优化任务不存在")
if "status" in body:
task.status = body["status"]
if "severity" in body:
task.severity = body["severity"]
if "recommendation" in body:
task.recommendation = body["recommendation"]
db.commit()
db.refresh(task)
return {"ok": True, "data": task.to_dict()}
@router.get("/overview")
def external_overview(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
products = _org_filtered_query(db, ExternalProduct, current_user).count()
campaigns = _org_filtered_query(db, PromotionCampaign, current_user).count()
active_campaigns = _org_filtered_query(db, PromotionCampaign, current_user).filter(
PromotionCampaign.status == "active"
).count()
audits = _org_filtered_query(db, SEOAudit, current_user).count()
keywords = _org_filtered_query(db, CampaignKeyword, current_user).count()
open_tasks = _org_filtered_query(db, OptimizationTask, current_user).filter(
OptimizationTask.status == "open"
).count()
type_breakdown = {}
for row in _org_filtered_query(db, ExternalProduct, current_user).with_entities(
ExternalProduct.type
).all():
t = row.type
type_breakdown[t] = type_breakdown.get(t, 0) + 1
return {
"ok": True,
"data": {
"products": products,
"campaigns": campaigns,
"active_campaigns": active_campaigns,
"audits": audits,
"keywords": keywords,
"open_tasks": open_tasks,
"type_breakdown": type_breakdown,
}
}
# ===== Run SEO Audit (delegate to scripts/seo_auditor.py) =====
@router.post("/audit/run")
def run_audit(
body: AuditRunRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
cmd = [sys.executable, str(SEO_AUDITOR_PATH)]
if body.product_id:
cmd += ["--product-id", str(body.product_id)]
elif body.url:
cmd += ["--url", body.url]
else:
raise HTTPException(status_code=400, detail="product_id 或 url 至少提供一个")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=408, detail="SEO 审计超时")
except Exception as e:
raise HTTPException(status_code=500, detail=f"审计执行失败: {str(e)}")
if result.returncode != 0:
raise HTTPException(
status_code=502,
detail={
"message": "SEO 审计脚本返回错误",
"stderr": result.stderr,
"stdout": result.stdout,
},
)
return {
"ok": True,
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
}
+1 -1
View File
@@ -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"])
+1 -1
View File
@@ -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"])
+2 -1
View File
@@ -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()
) )
+1 -1
View File
@@ -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"])
+105 -14
View File
@@ -2,13 +2,13 @@
"""发布管理 API""" """发布管理 API"""
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta, date
from typing import Optional, List from typing import Optional, List
from ..database import get_db from ..database import get_db
from ..models import Topic, PublishRecord, User from ..models import Topic, PublishRecord, User
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth import get_current_admin, org_filter from .auth import get_current_user, org_filter
from ..core.audit_logger import audit_log from ..core.audit_logger import audit_log
router = APIRouter(prefix="/api/publishing", tags=["publishing"]) router = APIRouter(prefix="/api/publishing", tags=["publishing"])
@@ -40,9 +40,9 @@ async def create_publish_record(
req: MultiPublishRequest, req: MultiPublishRequest,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin) current_user: User = Depends(get_current_user)
): ):
"""多平台发布选题(管理员""" """多平台发布选题(运营/管理员,按组织隔离"""
try: try:
q = db.query(Topic).filter(Topic.id == req.topic_id) q = db.query(Topic).filter(Topic.id == req.topic_id)
of = org_filter(current_user, Topic) of = org_filter(current_user, Topic)
@@ -66,16 +66,17 @@ async def create_publish_record(
record = PublishRecord( record = PublishRecord(
topic_id=req.topic_id, topic_id=req.topic_id,
platform=platform, platform=platform,
action='publish', action='stage',
status='success', status='pending_manual',
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(
platform=platform, platform=platform,
platform_label=PLATFORM_LABELS.get(platform, platform), platform_label=PLATFORM_LABELS.get(platform, platform),
status='success' status='pending_manual'
)) ))
except Exception as e: except Exception as e:
results.append(PublishResult( results.append(PublishResult(
@@ -85,16 +86,17 @@ async def create_publish_record(
error_msg=str(e) error_msg=str(e)
)) ))
topic.status = '已发布' # 诚实状态流转:本项目无真实自动发布器(且按《AI生成内容标识办法》AI 稿需真人把关),
# 故仅将选题置为「待人工发布」,待运营在对应平台校验声明后手动发布。
topic.status = 'pending_publish'
_now = datetime.now(timezone(timedelta(hours=8))) _now = datetime.now(timezone(timedelta(hours=8)))
topic.updated_at = _now topic.updated_at = _now
topic.published_at = _now.date()
db.commit() db.commit()
db.expire_all() db.expire_all()
db.refresh(topic) db.refresh(topic)
audit_log( audit_log(
action="publish", action="publish_stage",
user=current_user, user=current_user,
resource_type="topic", resource_type="topic",
resource_id=req.topic_id, resource_id=req.topic_id,
@@ -104,15 +106,104 @@ async def create_publish_record(
db=db db=db
) )
success_count = sum(1 for r in results if r.status == 'success') staged = sum(1 for r in results if r.status == 'pending_manual')
return MultiPublishResponse( return MultiPublishResponse(
ok=success_count > 0, ok=staged == len(results),
topic_id=req.topic_id, topic_id=req.topic_id,
results=results, results=results,
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)" message=(
f"选题 {req.topic_id} 已生成 {staged}/{len(results)} 个平台的发布稿并置为「待人工发布」。"
f"AI 生成内容须由真人校验声明后手动发布,系统不代发。"
)
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/records")
def list_publish_records(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""列出发布记录(按组织隔离),用于运营查看「待人工发布 / 已发布」状态"""
q = db.query(PublishRecord)
of = org_filter(current_user, PublishRecord)
if of is not True:
q = q.filter(of)
recs = q.order_by(PublishRecord.created_at.desc()).all()
result = []
for r in recs:
topic = db.query(Topic).filter(Topic.id == r.topic_id).first()
result.append({
"id": r.id,
"topic_id": r.topic_id,
"topic_title": topic.title if topic else None,
"platform": r.platform,
"action": r.action,
"status": r.status,
"operator": r.operator,
"description": r.description,
"url": r.url,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
})
return {"records": result}
class MarkPublishedResponse(BaseModel):
ok: bool
topic_id: str
message: str
@router.post("/{topic_id}/mark-published", response_model=MarkPublishedResponse)
def mark_published(
topic_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""运营在对应平台手动发布后,回填「已发布」闭环状态"""
q = db.query(Topic).filter(Topic.id == topic_id)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
topic = q.first()
if not topic:
raise HTTPException(status_code=404, detail=f"选题 {topic_id} 不存在")
if topic.status != 'pending_publish':
raise HTTPException(status_code=400, detail=f"选题 {topic_id} 当前状态为「{topic.status}」,无需标记发布")
_now = datetime.now(timezone(timedelta(hours=8)))
topic.status = 'published'
topic.published_at = date.today()
topic.updated_at = _now
recs = db.query(PublishRecord).filter(
PublishRecord.topic_id == topic_id,
PublishRecord.status == 'pending_manual'
)
for r in recs:
r.status = 'published'
r.updated_at = _now
db.commit()
audit_log(
action="publish_confirm",
user=current_user,
resource_type="topic",
resource_id=topic_id,
details={"operator": current_user.username},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return MarkPublishedResponse(
ok=True,
topic_id=topic_id,
message=f"选题 {topic_id} 已标记为「已发布」,发布记录同步更新。"
)
+1 -1
View File
@@ -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"])
+1 -1
View File
@@ -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"])
+12 -2
View File
@@ -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]}
+1 -1
View File
@@ -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"])
+1 -1
View File
@@ -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"])
+8 -3
View File
@@ -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()
+60 -1
View File
@@ -31,6 +31,7 @@ def list_topics(
status: Optional[str] = None, status: Optional[str] = None,
today: Optional[bool] = None, today: Optional[bool] = None,
tag: Optional[str] = None, tag: Optional[str] = None,
series: Optional[str] = None,
search: Optional[str] = None, search: Optional[str] = None,
sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]), sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]),
order: str = Query("desc", enum=["asc", "desc"]), order: str = Query("desc", enum=["asc", "desc"]),
@@ -54,6 +55,8 @@ def list_topics(
query = query.filter(func.date(Topic.created_at) == date.today()) query = query.filter(func.date(Topic.created_at) == date.today())
if tag: if tag:
query = query.filter(Topic.tags.contains([tag])) query = query.filter(Topic.tags.contains([tag]))
if series:
query = query.filter(Topic.series == series)
if search: if search:
query = query.filter(Topic.title.contains(search)) query = query.filter(Topic.title.contains(search))
@@ -63,7 +66,63 @@ def list_topics(
else: else:
query = query.order_by(sort_col.asc()) query = query.order_by(sort_col.asc())
return query.offset(offset).limit(limit).all() topics = query.offset(offset).limit(limit).all()
# Enrich with article count and previews
from ..models import Article
topic_ids = [t.id for t in topics]
if topic_ids:
article_rows = (
db.query(Article.topic_id, Article.platform, Article.title, Article.word_count, Article.status)
.filter(Article.topic_id.in_(topic_ids))
.all()
)
articles_by_topic: Dict[str, list] = {}
for ar in article_rows:
articles_by_topic.setdefault(ar.topic_id, []).append({
"platform": ar.platform,
"title": ar.title,
"word_count": ar.word_count,
"status": ar.status,
})
else:
articles_by_topic = {}
result = []
for t in topics:
t_dict = {
"id": t.id,
"field_id": t.field_id,
"field_name": t.field_name,
"org_id": t.org_id,
"title": t.title,
"format": t.format,
"core_concept": t.core_concept,
"audience_pain": t.audience_pain,
"unique_angle": t.unique_angle,
"priority": t.priority,
"priority_score": t.priority_score,
"total_score": t.total_score,
"status": t.status,
"tags": t.tags or [],
"custom_data": t.custom_data or {},
"scoring_data": t.scoring_data or {},
"lock_by": t.lock_by,
"lock_at": t.lock_at,
"series": t.series,
"created_at": t.created_at,
"updated_at": t.updated_at,
"generated_at": t.generated_at,
"reviewed_at": t.reviewed_at,
"ready_at": t.ready_at,
"published_at": t.published_at,
"compliance_score": t.compliance_score,
"platform_urls": t.platform_urls or {},
"cases": [],
"article_count": len(articles_by_topic.get(t.id, [])),
"articles": articles_by_topic.get(t.id, []),
}
result.append(t_dict)
return result
@router.get("/stats") @router.get("/stats")
+2 -1
View File
@@ -84,8 +84,9 @@ def run_creator_blocking(topic_id: str = None, timeout: int = 1800):
existing = db.query(Article).filter(Article.id == article_id).first() existing = db.query(Article).filter(Article.id == article_id).first()
if existing: if existing:
existing.html_content = html existing.html_content = html
existing.word_count = len(html)
else: else:
db.add(Article(id=article_id, topic_id=topic_id, platform=platform_dir, file_path=f"db:{article_id}", html_content=html, status="draft")) db.add(Article(id=article_id, topic_id=topic_id, platform=platform_dir, file_path=f"db:{article_id}", html_content=html, word_count=len(html), status="draft"))
hf.unlink() hf.unlink()
for platform_dir in ["zhihu", "wechat", "xiaohongshu"]: for platform_dir in ["zhihu", "wechat", "xiaohongshu"]:
pdir = dd / platform_dir pdir = dd / platform_dir
+1 -2
View File
@@ -25,8 +25,7 @@ def _get_cmd(topic_ids: List[str] = None):
cmd = [str(venv_python), str(script_path)] if venv_python.exists() else ["python3", str(script_path)] cmd = [str(venv_python), str(script_path)] if venv_python.exists() else ["python3", str(script_path)]
if topic_ids: if topic_ids:
cmd.extend(["--topic-ids", ','.join(topic_ids)]) cmd.extend(["--topic-ids", ','.join(topic_ids)])
else: # 不再使用 --today-only,避免三平台串行撰写时小红书文章错过审查窗口
cmd.append("--today-only")
return cmd return cmd
def run_optimizer(topic_ids: List[str] = None): def run_optimizer(topic_ids: List[str] = None):
+54 -4
View File
@@ -58,6 +58,7 @@ MODULES = {
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"}, "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
"scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"}, "scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"},
"scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"}, "scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"},
"scheduled_shortvideo": {"name": "🎬 短视频拆条", "cron": "08:00"},
} }
LOG_FILE_MAP = { LOG_FILE_MAP = {
@@ -71,6 +72,7 @@ LOG_FILE_MAP = {
"scheduled_task_monitor": "task_monitor", "scheduled_task_monitor": "task_monitor",
"scheduled_rank_tracker": "rank_tracker", "scheduled_rank_tracker": "rank_tracker",
"scheduled_geo_tracker": "geo_tracker", "scheduled_geo_tracker": "geo_tracker",
"scheduled_shortvideo": "shortvideo",
} }
def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None): def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None):
@@ -188,6 +190,7 @@ class TaskScheduler:
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"), ("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"), ("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"),
("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"), ("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"),
("scheduled_shortvideo", self._run_shortvideo, "短视频拆条"),
] ]
for module_id, fn, name in MODULE_JOBS: for module_id, fn, name in MODULE_JOBS:
@@ -418,10 +421,11 @@ class TaskScheduler:
count = 0 count = 0
for topic in topics: for topic in topics:
platform_urls = topic.platform_urls or {} platform_urls = topic.platform_urls or {}
zhihu_url = platform_urls.get("zhihu", "") for platform, url in platform_urls.items():
if not zhihu_url: if not url:
continue continue
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', zhihu_url) if platform == "zhihu":
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', url)
if not m: if not m:
continue continue
post_id = m.group(1) post_id = m.group(1)
@@ -442,7 +446,7 @@ class TaskScheduler:
"comments": raw.get("comment_count", raw.get("comments_count", 0)), "comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0), "shares": raw.get("share_count", 0),
"last_fetched": datetime.now(), "last_fetched": datetime.now(),
"publish_url": zhihu_url, "publish_url": url,
"data_snapshot": raw, "data_snapshot": raw,
} }
if existing: if existing:
@@ -453,6 +457,12 @@ class TaskScheduler:
count += 1 count += 1
except Exception: except Exception:
continue continue
elif platform in ("wechat_mp", "xiaohongshu"):
logger.info("平台 %s 无公开指标 API,跳过", platform)
continue
else:
logger.info("平台 %s 暂无指标同步支持,跳过", platform)
continue
if count: if count:
db.commit() db.commit()
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count) logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
@@ -661,6 +671,46 @@ class TaskScheduler:
started_at=started, finished_at=datetime.now(timezone.utc)) started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[GeoTracker] AI 搜索引用追踪失败: %s", e) logger.exception("[GeoTracker] AI 搜索引用追踪失败: %s", e)
def _run_shortvideo(self):
"""每日将前一日「待发布」选题的长文拆条为短视频脚本"""
started = datetime.now(timezone.utc)
log_id = _log_task("scheduled_shortvideo", "running", started_at=started)
try:
from ..database import SessionLocal
from ..models import Topic
db = SessionLocal()
try:
topics = db.query(Topic).filter(
Topic.status.in_(["ready", "待发布", "pending_publish"])
).order_by(Topic.ready_at.desc().nullslast()).limit(3).all()
finally:
db.close()
if not topics:
_log_task("scheduled_shortvideo", "success", log_id=log_id,
message="无待拆条选题", started_at=started, finished_at=datetime.now(timezone.utc))
return
import subprocess
total = 0
for t in topics:
res = subprocess.run(
[sys.executable, str(PROJECT_ROOT / "scripts" / "shortvideo.py"), "--topic-id", t.id, "--count", "3"],
capture_output=True, text=True, timeout=600
)
if res.returncode == 0:
total += 1
_log_task("scheduled_shortvideo", "success", log_id=log_id,
message=f"拆条完成: {total}/{len(topics)} 选题",
result_data={"topics": len(topics), "done": total},
started_at=started, finished_at=datetime.now(timezone.utc))
logger.info("[ShortVideo] 拆条完成: %d/%d", total, len(topics))
except Exception as e:
import traceback
_log_task("scheduled_shortvideo", "failed", log_id=log_id,
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[ShortVideo] 拆条失败: %s", e)
def get_jobs(self): def get_jobs(self):
"""返回当前所有定时任务的状态""" """返回当前所有定时任务的状态"""
jobs = [] jobs = []
+12
View File
@@ -126,6 +126,18 @@ 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'"),
("search_rankings", "ai_search_engine", "VARCHAR"),
("search_rankings", "citation_snippet", "TEXT"),
("search_rankings", "citation_url", "VARCHAR"),
("search_rankings", "geo_score", "INTEGER DEFAULT 0"),
("search_rankings", "content_type", "VARCHAR"),
("geo_readiness_scores", "org_id", "VARCHAR DEFAULT 'default'"),
("articles", "org_id", "VARCHAR DEFAULT 'default'"),
("content_tasks", "org_id", "VARCHAR DEFAULT 'default'"),
("topics", "series", "VARCHAR"),
]: ]:
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}"))
+62 -30
View File
@@ -76,6 +76,17 @@ def import_initial_data():
for cfg in default_system_configs: for cfg in default_system_configs:
if db.query(SystemConfig).filter(SystemConfig.key == cfg["key"]).first() is None: if db.query(SystemConfig).filter(SystemConfig.key == cfg["key"]).first() is None:
db.add(SystemConfig(**cfg)) db.add(SystemConfig(**cfg))
# 白标默认值(私有化部署/卖给他人时可改)
default_branding = [
{"key": "brand_name", "value": "宇之然内容创作平台", "description": "白标:平台名称"},
{"key": "brand_logo", "value": "", "description": "白标:Logo URL(留空用文字)"},
{"key": "brand_primary_color", "value": "#2563eb", "description": "白标:主色(导航/按钮)"},
{"key": "brand_support_email", "value": "", "description": "白标:支持邮箱"},
]
for b in default_branding:
if db.query(SystemConfig).filter(SystemConfig.key == b["key"]).first() is None:
db.add(SystemConfig(**b))
if db.query(SystemConfig).filter(SystemConfig.key == "review_llm_id").first() is None: if db.query(SystemConfig).filter(SystemConfig.key == "review_llm_id").first() is None:
first_llm = db.query(LLMConfig).filter(LLMConfig.is_active == True).first() first_llm = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if first_llm: if first_llm:
@@ -105,7 +116,7 @@ def import_initial_data():
"default_format": "深度分析 3000-8000字,数据驱动", "default_format": "深度分析 3000-8000字,数据驱动",
"compliance_rules": { "compliance_rules": {
"max_title_len": 100, "max_title_len": 100,
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"], "allowed_tags": ["科技", "AI", "效率", "职场", "教育", "远程工作", "未来工作", "工具"],
"forbidden_patterns": ["加微信", "私聊", "付费咨询", "点击领取"] "forbidden_patterns": ["加微信", "私聊", "付费咨询", "点击领取"]
}, },
"is_active": True, "is_active": True,
@@ -124,7 +135,7 @@ def import_initial_data():
"default_format": "个人叙事 2000-4000字,对话感", "default_format": "个人叙事 2000-4000字,对话感",
"compliance_rules": { "compliance_rules": {
"max_title_len": 32, "max_title_len": 32,
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"], "allowed_tags": ["科技", "AI", "效率", "职场", "教育", "远程工作", "未来工作", "工具"],
"forbidden_patterns": ["诱导分享", "朋友圈", "转发群"] "forbidden_patterns": ["诱导分享", "朋友圈", "转发群"]
}, },
"is_active": True, "is_active": True,
@@ -143,7 +154,7 @@ def import_initial_data():
"default_format": "精炼干货 400-1000字,实用优先", "default_format": "精炼干货 400-1000字,实用优先",
"compliance_rules": { "compliance_rules": {
"max_title_len": 50, "max_title_len": 50,
"allowed_tags": ["生活方式", "可持续", "AI", "个人成长", "极简", "环保"], "allowed_tags": ["AI", "效率", "科技", "工具", "职场", "生活", "学习方法"],
"forbidden_patterns": ["私信", "加群", "导流"] "forbidden_patterns": ["私信", "加群", "导流"]
}, },
"is_active": True, "is_active": True,
@@ -163,9 +174,9 @@ def import_initial_data():
else: else:
# 更新已有平台配置的字数要求(迁移:2026-06 内容质量升级) # 更新已有平台配置的字数要求(迁移:2026-06 内容质量升级)
platform_updates = { platform_updates = {
"zhihu": {"min_words": 3000, "max_words": 8000, "default_format": "深度分析 3000-8000字,数据驱动"}, "zhihu": {"min_words": 3000, "max_words": 8000, "default_format": "深度分析 3000-8000字,数据驱动+观点交锋,用一手数据和独特视角切入"},
"wechat": {"min_words": 2000, "max_words": 4000, "default_format": "个人叙事 2000-4000字,对话感"}, "wechat": {"min_words": 2000, "max_words": 4000, "default_format": "科技人文叙事 2000-4000字,个人反思+情感共鸣,记录人与技术之间的故事"},
"xiaohongshu": {"min_words": 400, "max_words": 1000, "default_format": "精炼干货 400-1000字,实用优先"}, "xiaohongshu": {"min_words": 400, "max_words": 1000, "default_format": "精炼干货 400-1000字,亲测数据+实操结果,每个结论配真实对比"},
} }
for p in db.query(PlatformConfig).all(): for p in db.query(PlatformConfig).all():
if p.platform in platform_updates: if p.platform in platform_updates:
@@ -178,41 +189,36 @@ def import_initial_data():
if db.query(TopicField).count() == 0: if db.query(TopicField).count() == 0:
fields = [ fields = [
{"name": "未来工作方式", "icon": "💼", "color": "#667eea", "description": "远程工作、零工经济、职业转型", "sort_order": 1}, {"name": "AI与效率", "icon": "🤖", "color": "#764ba2", "description": "AI工具实测对比、工作流效率方法、前沿资讯解读——关注技术如何改变人的工作方式", "sort_order": 1},
{"name": "AI与效率", "icon": "🤖", "color": "#764ba2", "description": "AI工具、数字助手、效率方法", "sort_order": 2}, {"name": "科技人文", "icon": "🔬", "color": "#f56c6c", "description": "AI伦理困境、数字生活反思、人机关系探索——科技的温度与边界", "sort_order": 2},
{"name": "可持续生活", "icon": "🌿", "color": "#67c23a", "description": "环保、低碳、自然生活方式", "sort_order": 3}, {"name": "未来工作方式", "icon": "💼", "color": "#667eea", "description": "远程协作实践、AI时代职业转型、一人企业模式——未来不是等来的", "sort_order": 3},
{"name": "数字游民", "icon": "🌍", "color": "#409eff", "description": "旅行、地理自由、海外生活", "sort_order": 4},
{"name": "个人成长", "icon": "📚", "color": "#e6a23c", "description": "学习、技能、认知升级", "sort_order": 5},
{"name": "科技人文", "icon": "🔬", "color": "#f56c6c", "description": "科技伦理、数字生活反思", "sort_order": 6},
] ]
for f in fields: for f in fields:
db.add(TopicField(**f)) db.add(TopicField(**f))
db.commit() db.commit()
print("✅ 插入默认领域配置") print("✅ 插入默认领域配置(聚焦AI×人文×未来工作)")
if db.query(CollectorCategory).count() == 0: if db.query(CollectorCategory).count() == 0:
default_cats = [ default_cats = [
{"name": "循环消费", "search_query": "以旧换新 二手交易 闲置 循环 2026", "description": "以旧换新/二手交易/租赁经济 | 2025年二手交易额1.69万亿", "sort_order": 1, "is_active": True}, {"name": "AI前沿资讯", "search_query": "AI 人工智能 大模型 2026 前沿 突破 论文解读", "description": "AI行业动态、大模型发布、技术突破 | 2026年AI全面嵌入产业", "sort_order": 1, "is_active": True},
{"name": "低碳出行", "search_query": "新能源车 骑行 绿色通勤 低碳出行 2026", "description": "新能源车/骑行/绿色出行 | 年产销破1000万辆", "sort_order": 2, "is_active": True}, {"name": "AI工具实测", "search_query": "AI工具 效率提升 工作流 prompt教程 Cursor Copilot 2026", "description": "AI工具评测、效率工作流、实操指南 | 2026年AI工具爆发", "sort_order": 2, "is_active": True},
{"name": "干净饮食", "search_query": "干净饮食 有机食品 植物基 本地食材 2026", "description": "有机食品/植物基/本地食材 | 有机食品1247亿", "sort_order": 3, "is_active": True}, {"name": "AI与职场", "search_query": "AI 裁员 职业转型 AI技能 远程工作 一人企业 2026", "description": "AI对就业影响、职业转型、技能升级 | 2026年AI重塑就业结构", "sort_order": 3, "is_active": True},
{"name": "零浪费生活", "search_query": "零浪费 自带杯 极简生活 可持续时尚 2026", "description": "自带杯/极简/可持续时尚 | 自带杯笔记277万篇", "sort_order": 4, "is_active": True}, {"name": "AI生活", "search_query": "AI陪伴 AI心理咨询 生活助手 AI写作 智能体 2026", "description": "AI心理咨询/生活搭子/AI人格化 | 商业笔记互动增长263%", "sort_order": 4, "is_active": True},
{"name": "绿色家电与节能", "search_query": "绿色家电 一级能效 以旧换新 节能 2026", "description": "一级能效/国补政策 | 一级能效占比90%+", "sort_order": 5, "is_active": True}, {"name": "科技人文", "search_query": "AI伦理 数字生活 科技反思 人机关系 数据隐私 2026", "description": "AI伦理/数字生活反思/科技温度 | AI从工具到共生", "sort_order": 5, "is_active": True},
{"name": "碳普惠", "search_query": "碳账户 碳普惠 个人碳减排 蚂蚁森林 2026", "description": "碳账户/碳普惠/个人减排 | 武汉200万碳账户", "sort_order": 6, "is_active": True},
{"name": "环保科技产品", "search_query": "环保科技 绿色产品 可持续材料 2026", "description": "可持续材料/绿色产品 | 购买占比超32%", "sort_order": 7, "is_active": True},
{"name": "AI与效率", "search_query": "AI工具 人工智能 效率提升 2026", "description": "AI工具/效率方法/数字助手 | 2026年AI深度融入消费与生活", "sort_order": 8, "is_active": True},
] ]
for cd in default_cats: for cd in default_cats:
existing = db.query(CollectorCategory).filter(CollectorCategory.name == cd["name"]).first() existing = db.query(CollectorCategory).filter(CollectorCategory.name == cd["name"]).first()
if not existing: if not existing:
db.add(CollectorCategory(**cd)) db.add(CollectorCategory(**cd))
db.commit() db.commit()
print("✅ 插入默认采集类别和信息源") print("✅ 插入默认采集类别(聚焦AI×科技人文)")
db.commit() db.commit()
# 补充缺失的类别和信息源(对已有数据库的迁移) # 补充缺失的采集源(对已有数据库的迁移)
for sd in [ for sd in [
{"name": "AI工具搜索", "source_type": "web_search", "query": "AI工具 人工智能 效率提升 2026", "credibility": "medium", "focus": "AI与效率", "sort_order": 99, "is_active": True}, {"name": "AI前沿搜索", "source_type": "web_search", "query": "AI 人工智能 大模型 前沿 2026", "credibility": "medium", "focus": "AI前沿资讯", "sort_order": 1, "is_active": True},
{"name": "AI工具搜索", "source_type": "web_search", "query": "AI工具 效率提升 AI工作流 2026", "credibility": "medium", "focus": "AI工具实测", "sort_order": 2, "is_active": True},
]: ]:
if not db.query(CollectorSource).filter(CollectorSource.name == sd["name"]).first(): if not db.query(CollectorSource).filter(CollectorSource.name == sd["name"]).first():
db.add(CollectorSource(**sd)) db.add(CollectorSource(**sd))
@@ -299,21 +305,47 @@ def import_initial_data():
db.commit() db.commit()
print("✅ 插入默认角色") print("✅ 插入默认角色")
# 初始化默认菜单(与 uni-nav.js 对齐) # 归一化首页菜单路径,避免 index.html 与 /index.html 产生重复入口
if db.query(Menu).count() == 0: idx_plain = db.query(Menu).filter(Menu.path == "index.html").first()
idx_slash = db.query(Menu).filter(Menu.path == "/index.html").first()
if idx_plain and not idx_slash:
idx_plain.path = "/index.html"
db.commit()
elif idx_plain and idx_slash:
db.delete(idx_plain)
db.commit()
# 初始化默认菜单:幂等按 path 对齐(已有时更新排序/角色,缺失时插入)。
# 运行时会用 DB 菜单覆盖 uni-nav.js 的 fallback,因此这里必须保证文章管理/内容工厂/白标设置等入口存在。
default_menus = [ default_menus = [
{"name": "仪表盘", "path": "/", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]}, {"name": "工作台", "path": "/index.html", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
{"name": "选题", "path": "topics.html", "icon": "IconTopic", "sort_order": 1, "roles": ["admin", "editor"]}, {"name": "创作工作台", "path": "studio.html", "icon": "IconEdit", "sort_order": 1, "roles": ["admin", "editor"]},
{"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]}, {"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]},
{"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]}, {"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]},
{"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]}, {"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]},
{"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]}, {"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]},
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 6, "roles": ["admin"]}, {"name": "外推营销", "path": "campaigns.html", "icon": "IconStar", "sort_order": 6, "roles": ["admin", "editor"]},
{"name": "AI味检测", "path": "ai-slop.html", "icon": "IconSearch", "sort_order": 7, "roles": ["admin", "editor"]},
{"name": "白标设置", "path": "branding.html", "icon": "IconSetting", "sort_order": 8, "roles": ["admin"]},
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 9, "roles": ["admin"]},
] ]
for m in default_menus: for m in default_menus:
found = db.query(Menu).filter(Menu.path == m["path"]).first()
if found:
found.sort_order = m["sort_order"]
found.roles = m["roles"]
found.icon = m["icon"]
found.is_active = True
# 保留原名称,避免意外重命名
else:
db.add(Menu(**m)) db.add(Menu(**m))
# 原独立的选题/内容工厂/文章管理已合并进「创作工作台」,从主导航隐藏(页面仍可被 studio 内嵌与直接访问)
for legacy in ["topics.html", "factory.html", "articles.html"]:
legacy_menu = db.query(Menu).filter(Menu.path == legacy).first()
if legacy_menu:
legacy_menu.is_active = False
db.commit() db.commit()
print("插入默认菜单") print("✅ 菜单已对齐(创作工作台合并选题/内容工厂/文章管理)")
# 同步 PostgreSQL 自增序列 # 同步 PostgreSQL 自增序列
if os.getenv('USE_POSTGRES', 'true').lower() == 'true': if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
+4 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db from .database import engine, get_db, init_db
from .models import Base from .models import Base
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers, search_rankings from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers, search_rankings, external_promotion, ai_slop, branding
from .initial_data import import_initial_data from .initial_data import import_initial_data
from .core.scheduler import scheduler from .core.scheduler import scheduler
@@ -105,6 +105,9 @@ app.include_router(menu_configs.router)
app.include_router(menu_configs.public_router) app.include_router(menu_configs.public_router)
app.include_router(search_providers.router) app.include_router(search_providers.router)
app.include_router(search_rankings.router) app.include_router(search_rankings.router)
app.include_router(external_promotion.router)
app.include_router(ai_slop.router)
app.include_router(branding.router)
# 挂载自动生成的图片(必须先于前端根挂载) # 挂载自动生成的图片(必须先于前端根挂载)
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
+216
View File
@@ -265,6 +265,7 @@ class Topic(Base):
platform_urls = Column(JSON, default=dict) platform_urls = Column(JSON, default=dict)
lock_by = Column(String, nullable=True) lock_by = Column(String, nullable=True)
lock_at = Column(DateTime, nullable=True) lock_at = Column(DateTime, nullable=True)
series = Column(String, nullable=True, index=True, comment="所属栏目,如:AI工具实测周报、科技人文思辨")
field = relationship("TopicField", backref="topics") field = relationship("TopicField", backref="topics")
@@ -280,6 +281,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 +304,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 +330,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 +360,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 +386,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 +508,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 +874,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 +894,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 +918,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 +936,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,
@@ -937,3 +948,208 @@ class GeoReadinessScore(Base):
"heading_structure_score": self.heading_structure_score, "heading_structure_score": self.heading_structure_score,
"checked_at": self.checked_at.isoformat() if self.checked_at else None, "checked_at": self.checked_at.isoformat() if self.checked_at else None,
} }
class ExternalProduct(Base):
"""外部推广产品"""
__tablename__ = "external_products"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, nullable=False, comment="产品名称")
type = Column(String, nullable=False, comment="类型: website/article/wechat_account/miniprogram")
url = Column(String, nullable=True, comment="主URL")
domain = Column(String, nullable=True, comment="域名")
app_id = Column(String, nullable=True, comment="小程序AppID")
account_id = Column(String, nullable=True, comment="公众号ID")
description = Column(Text, nullable=True, comment="产品描述")
org_id = Column(String, default="default", nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"type": self.type,
"url": self.url,
"domain": self.domain,
"app_id": self.app_id,
"account_id": self.account_id,
"description": self.description,
"org_id": self.org_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class PromotionCampaign(Base):
"""推广活动"""
__tablename__ = "promotion_campaigns"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, nullable=False, comment="活动名称")
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=False)
status = Column(String, default="active", comment="状态: active/paused/completed")
keywords = Column(JSON, default=list, comment="目标关键词列表")
target_engines = Column(JSON, default=list, comment="目标搜索引擎列表")
notes = Column(Text, nullable=True, comment="备注")
org_id = Column(String, default="default", nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
product = relationship("ExternalProduct", backref="campaigns")
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"product_id": self.product_id,
"status": self.status,
"keywords": self.keywords or [],
"target_engines": self.target_engines or [],
"notes": self.notes,
"org_id": self.org_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class CampaignKeyword(Base):
"""推广关键词"""
__tablename__ = "campaign_keywords"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
campaign_id = Column(Integer, ForeignKey("promotion_campaigns.id"), nullable=False)
keyword = Column(String, nullable=False, comment="关键词")
search_volume = Column(Integer, nullable=True, comment="搜索量")
difficulty = Column(Float, nullable=True, comment="竞争难度 0-1")
current_rank = Column(Integer, nullable=True, comment="当前排名")
target_rank = Column(Integer, nullable=True, comment="目标排名")
best_rank = Column(Integer, nullable=True, comment="历史最佳排名")
last_checked = Column(DateTime(timezone=True), nullable=True, comment="最后检查时间")
org_id = Column(String, default="default", nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
campaign = relationship("PromotionCampaign", backref="campaign_keywords")
def to_dict(self):
return {
"id": self.id,
"campaign_id": self.campaign_id,
"keyword": self.keyword,
"search_volume": self.search_volume,
"difficulty": self.difficulty,
"current_rank": self.current_rank,
"target_rank": self.target_rank,
"best_rank": self.best_rank,
"last_checked": self.last_checked.isoformat() if self.last_checked else None,
"org_id": self.org_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class SEOAudit(Base):
"""SEO审计报告"""
__tablename__ = "seo_audits"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=False)
audit_type = Column(String, default="full", comment="审计类型: full/quick")
overall_score = Column(Float, nullable=True, comment="总分 0-100")
meta_score = Column(Float, nullable=True, comment="Meta标签评分")
heading_score = Column(Float, nullable=True, comment="标题结构评分")
content_score = Column(Float, nullable=True, comment="内容评分")
perf_score = Column(Float, nullable=True, comment="性能评分")
links_score = Column(Float, nullable=True, comment="链接评分")
mobile_score = Column(Float, nullable=True, comment="移动端评分")
raw_data = Column(JSON, default=dict, comment="审计详情JSON")
page_count = Column(Integer, nullable=True, comment="审计页面数")
issues_found = Column(Integer, nullable=True, comment="发现问题数")
org_id = Column(String, default="default", nullable=True)
checked_at = Column(DateTime(timezone=True), server_default=func.now())
product = relationship("ExternalProduct", backref="audits")
def to_dict(self):
return {
"id": self.id,
"product_id": self.product_id,
"audit_type": self.audit_type,
"overall_score": self.overall_score,
"meta_score": self.meta_score,
"heading_score": self.heading_score,
"content_score": self.content_score,
"perf_score": self.perf_score,
"links_score": self.links_score,
"mobile_score": self.mobile_score,
"page_count": self.page_count,
"issues_found": self.issues_found,
"org_id": self.org_id,
"checked_at": self.checked_at.isoformat() if self.checked_at else None,
}
class KeywordRanking(Base):
"""外部关键词多引擎排名"""
__tablename__ = "keyword_rankings"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
campaign_id = Column(Integer, ForeignKey("promotion_campaigns.id"), nullable=True)
keyword_id = Column(Integer, ForeignKey("campaign_keywords.id"), nullable=True)
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=True)
keyword = Column(String, nullable=False, comment="关键词")
search_engine = Column(String, nullable=False, comment="搜索引擎: baidu/360/sogou/wechat/bing/google")
rank = Column(Integer, nullable=True, comment="排名位置")
url_found = Column(String, nullable=True, comment="排名URL")
org_id = Column(String, default="default", nullable=True)
checked_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id,
"campaign_id": self.campaign_id,
"keyword_id": self.keyword_id,
"product_id": self.product_id,
"keyword": self.keyword,
"search_engine": self.search_engine,
"rank": self.rank,
"url_found": self.url_found,
"org_id": self.org_id,
"checked_at": self.checked_at.isoformat() if self.checked_at else None,
}
class OptimizationTask(Base):
"""优化建议任务"""
__tablename__ = "optimization_tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
audit_id = Column(Integer, ForeignKey("seo_audits.id"), nullable=False)
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=False)
category = Column(String, nullable=False, comment="分类: meta/heading/content/performance/links/mobile/other")
severity = Column(String, default="medium", comment="严重度: high/medium/low")
issue = Column(Text, nullable=False, comment="问题描述")
recommendation = Column(Text, nullable=True, comment="优化建议")
status = Column(String, default="open", comment="状态: open/resolved/ignored")
org_id = Column(String, default="default", nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
audit = relationship("SEOAudit", backref="optimization_tasks")
def to_dict(self):
return {
"id": self.id,
"audit_id": self.audit_id,
"product_id": self.product_id,
"category": self.category,
"severity": self.severity,
"issue": self.issue,
"recommendation": self.recommendation,
"status": self.status,
"org_id": self.org_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+171
View File
@@ -76,6 +76,7 @@ class TopicBase(BaseModel):
scoring_data: Dict[str, Any] = {} scoring_data: Dict[str, Any] = {}
lock_by: Optional[str] = None lock_by: Optional[str] = None
lock_at: Optional[datetime] = None lock_at: Optional[datetime] = None
series: Optional[str] = None
class TopicCreate(BaseModel): class TopicCreate(BaseModel):
@@ -90,6 +91,7 @@ class TopicCreate(BaseModel):
tags: List[str] = [] tags: List[str] = []
custom_data: Dict[str, Any] = {} custom_data: Dict[str, Any] = {}
scoring_data: Dict[str, Any] = {} scoring_data: Dict[str, Any] = {}
series: Optional[str] = None
class TopicAnalyzeRequest(BaseModel): class TopicAnalyzeRequest(BaseModel):
@@ -123,6 +125,7 @@ class TopicUpdate(BaseModel):
scoring_data: Optional[Dict[str, Any]] = None scoring_data: Optional[Dict[str, Any]] = None
priority_score: Optional[int] = None priority_score: Optional[int] = None
total_score: Optional[float] = None total_score: Optional[float] = None
series: Optional[str] = None
class TopicResponse(TopicBase): class TopicResponse(TopicBase):
@@ -135,6 +138,8 @@ class TopicResponse(TopicBase):
compliance_score: Optional[int] = None compliance_score: Optional[int] = None
platform_urls: Dict[str, str] = {} platform_urls: Dict[str, str] = {}
cases: List[Any] = [] cases: List[Any] = []
article_count: int = 0
articles: List[Dict[str, Any]] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -151,6 +156,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 +199,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 +237,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 +273,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 +349,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 +464,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
@@ -630,3 +641,163 @@ class MenuResponse(MenuBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# ========== 外部推广模块 ==========
class ExternalProductBase(BaseModel):
name: str
type: str = "website"
url: Optional[str] = None
domain: Optional[str] = None
app_id: Optional[str] = None
account_id: Optional[str] = None
description: Optional[str] = None
class ExternalProductCreate(ExternalProductBase):
pass
class ExternalProductUpdate(BaseModel):
name: Optional[str] = None
type: Optional[str] = None
url: Optional[str] = None
domain: Optional[str] = None
app_id: Optional[str] = None
account_id: Optional[str] = None
description: Optional[str] = None
class ExternalProductResponse(ExternalProductBase):
id: int
org_id: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class PromotionCampaignBase(BaseModel):
name: str
product_id: int
status: str = "active"
keywords: List[str] = []
target_engines: List[str] = []
notes: Optional[str] = None
class PromotionCampaignCreate(PromotionCampaignBase):
pass
class PromotionCampaignUpdate(BaseModel):
name: Optional[str] = None
status: Optional[str] = None
keywords: Optional[List[str]] = None
target_engines: Optional[List[str]] = None
notes: Optional[str] = None
class PromotionCampaignResponse(PromotionCampaignBase):
id: int
org_id: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class CampaignKeywordBase(BaseModel):
campaign_id: int
keyword: str
search_volume: Optional[int] = None
difficulty: Optional[float] = None
current_rank: Optional[int] = None
target_rank: Optional[int] = None
class CampaignKeywordCreate(CampaignKeywordBase):
pass
class CampaignKeywordUpdate(BaseModel):
search_volume: Optional[int] = None
difficulty: Optional[float] = None
current_rank: Optional[int] = None
target_rank: Optional[int] = None
class CampaignKeywordResponse(CampaignKeywordBase):
id: int
best_rank: Optional[int] = None
last_checked: Optional[datetime] = None
org_id: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class SEOAuditResponse(BaseModel):
id: int
product_id: int
audit_type: str
overall_score: Optional[float] = None
meta_score: Optional[float] = None
heading_score: Optional[float] = None
content_score: Optional[float] = None
perf_score: Optional[float] = None
links_score: Optional[float] = None
mobile_score: Optional[float] = None
page_count: Optional[int] = None
issues_found: Optional[int] = None
org_id: Optional[str] = None
checked_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class KeywordRankingResponse(BaseModel):
id: int
campaign_id: Optional[int] = None
keyword_id: Optional[int] = None
product_id: Optional[int] = None
keyword: str
search_engine: str
rank: Optional[int] = None
url_found: Optional[str] = None
org_id: Optional[str] = None
checked_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class OptimizationTaskBase(BaseModel):
category: str
severity: str = "medium"
issue: str
recommendation: Optional[str] = None
class OptimizationTaskCreate(OptimizationTaskBase):
audit_id: int
product_id: int
class OptimizationTaskUpdate(BaseModel):
status: Optional[str] = None
severity: Optional[str] = None
recommendation: Optional[str] = None
class OptimizationTaskResponse(OptimizationTaskBase):
id: int
audit_id: int
product_id: int
status: str
org_id: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+219 -1
View File
@@ -35,6 +35,7 @@
<el-button size="default" :type="activeTab === 'searchrankings' ? 'primary' : ''" @click="switchTab('searchrankings')">搜索排名</el-button> <el-button size="default" :type="activeTab === '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>
@@ -198,7 +199,8 @@
<div class="empty-text">暂无平台配置</div> <div class="empty-text">暂无平台配置</div>
</div> </div>
</template> </template>
<el-table v-else :data="platformConfigs" border stripe style="width:100%"> <template v-else>
<el-table class="data-table" :data="platformConfigs" border stripe style="width:100%">
<el-table-column prop="platform" label="标识" width="120"></el-table-column> <el-table-column prop="platform" label="标识" width="120"></el-table-column>
<el-table-column prop="name" label="名称" width="120"></el-table-column> <el-table-column prop="name" label="名称" width="120"></el-table-column>
<el-table-column prop="icon" label="图标" width="100"> <el-table-column prop="icon" label="图标" width="100">
@@ -229,6 +231,22 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="card-list-mobile">
<div v-for="pc in platformConfigs" :key="pc.platform" class="card-item">
<div class="card-row"><span class="card-label">标识</span><span class="card-value">{{ pc.platform }}</span></div>
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ pc.name }}</span></div>
<div class="card-row"><span class="card-label">图标</span><span class="card-value">{{ pc.icon || '—' }}</span></div>
<div class="card-row"><span class="card-label">网址</span><span class="card-value" style="word-break:break-all;">{{ pc.website_url || '—' }}</span></div>
<div class="card-row"><span class="card-label">字数</span><span class="card-value">{{ pc.min_words }} - {{ pc.max_words }}</span></div>
<div class="card-row"><span class="card-label">配图</span><span class="card-value">{{ pc.requires_image ? pc.image_count_min + '-' + pc.image_count_max + '张' : '不需要' }}</span></div>
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ pc.is_active ? '是' : '否' }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showPcDialogFn(pc)">编辑</el-button>
<el-button size="small" type="danger" @click="deletePlatformConfig(pc.platform)">删除</el-button>
</div>
</div>
</div>
</template>
<el-dialog v-model="showPcDialog" :title="pcDialogTitle" width="750px" :close-on-click-modal="false"> <el-dialog v-model="showPcDialog" :title="pcDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="pcForm" label-width="100px"> <el-form :model="pcForm" label-width="100px">
<el-row :gutter="16"> <el-row :gutter="16">
@@ -816,6 +834,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 +1599,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 +1686,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 +1747,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,
}; };
} }
}); });
+8 -2
View File
@@ -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) {
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - AI味检测</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="AI味检测" :username="currentUser.username" :is-admin="isAdmin" current-page="ai-slop" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> AI味检测 + 净化</h2>
</div>
<div class="filter-bar">
<el-input v-model="topicId" placeholder="输入选题 ID,如 B02" clearable size="default" style="width:280px;" @keyup.enter="runReport">
<template #prepend>选题</template>
</el-input>
<el-button type="primary" :loading="loadingReport" @click="runReport">检测</el-button>
</div>
<el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" style="margin-bottom:12px;"></el-alert>
<div v-if="!loadingReport && report && report.platforms.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
该选题暂无文章,请先创作内容。
</div>
<div v-for="p in (report ? report.platforms : [])" :key="p.platform" style="margin-bottom:20px;">
<el-card shadow="hover">
<template #header>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<strong style="font-size:16px;">{{ platformLabel(p.platform) }}</strong>
<el-tag :type="p.passed ? 'success' : 'danger'" size="small">{{ p.passed ? '通过' : '未通过' }}</el-tag>
<span style="font-size:13px;color:#909399;">合规分:<b :style="{color: p.score >= 80 ? '#67c23a' : (p.score >= 60 ? '#e6a23c' : '#f56c6c')}">{{ p.score }}</b></span>
<el-button size="small" type="warning" :loading="p.purifying" @click="purify(p)" style="margin-left:auto;">一键净化</el-button>
</div>
</template>
<div v-if="p.message" style="margin-bottom:10px;color:#67c23a;font-size:13px;">{{ p.message }}</div>
<div v-if="p.score_before !== null" style="margin-bottom:10px;font-size:13px;color:#606266;">
净化前分:<b>{{ p.score_before }}</b> &nbsp;&nbsp; 净化后分:<b :style="{color:'#67c23a'}">{{ p.score_after }}</b>
<span style="margin-left:12px;">问题数:{{ p.issues_before.length }} → {{ p.issues_after.length }}</span>
</div>
<div v-if="p.issues.length === 0" style="color:#67c23a;font-size:14px;">✅ 未检测到明显问题</div>
<template v-else>
<el-table class="card-table" :data="p.issues" size="small" stripe>
<el-table-column prop="type" label="类型" width="110">
<template #default="s"><el-tag :type="s.row.severity === 'high' ? 'danger' : 'warning'" size="small">{{ s.row.type }}</el-tag></template>
</el-table-column>
<el-table-column prop="category" label="类别" width="130" show-overflow-tooltip></el-table-column>
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="suggestion" label="建议" min-width="180" show-overflow-tooltip></el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="iss in p.issues" :key="iss.type + iss.detail" class="card-item">
<div class="card-row"><span class="card-label">类型</span><span class="card-value"><el-tag :type="iss.severity === 'high' ? 'danger' : 'warning'" size="small">{{ iss.type }}</el-tag></span></div>
<div class="card-row"><span class="card-label">类别</span><span class="card-value">{{ iss.category }}</span></div>
<div class="card-row"><span class="card-label">详情</span><span class="card-value">{{ iss.detail }}</span></div>
<div class="card-row"><span class="card-label">建议</span><span class="card-value">{{ iss.suggestion }}</span></div>
</div>
</div>
</template>
</el-card>
</div>
</div>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const AiSlopApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
topicId: '', loadingReport: false, report: null, errorMsg: ''
}
},
methods: {
getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) {
const token = this.getToken();
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/login.html', 1500); return null; }
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...(opts.headers || {}) }, ...opts });
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
return res.json();
},
async runReport() {
this.errorMsg = '';
if (!this.topicId.trim()) { this.$message.warning('请输入选题 ID'); return; }
this.loadingReport = true;
this.report = null;
try {
const data = await this.api('/api/ai-slop/report?topic_id=' + encodeURIComponent(this.topicId.trim()));
this.report = { topic_id: data.topic_id, platforms: (data.platforms || []).map(p => ({ ...p, purifying: false, message: '', score_before: null, score_after: null, issues_before: [], issues_after: [] })) };
} catch (error) {
this.errorMsg = error.message;
} finally { this.loadingReport = false; }
},
async purify(p) {
p.purifying = true; p.message = '';
try {
const data = await this.api('/api/ai-slop/purify', {
method: 'POST',
body: JSON.stringify({ topic_id: this.topicId.trim(), platform: p.platform })
});
p.score_before = data.score_before;
p.score_after = data.score_after;
p.issues_before = data.issues_before || [];
p.issues_after = data.issues_after || [];
p.issues = data.issues_after || [];
p.score = data.score_after;
p.passed = (data.score_after !== null && data.issues_after.length === 0) || (data.issues_after && data.issues_after.every(i => i.severity !== 'high')) || (p.score >= 80);
p.passed = data.score_after !== null ? (data.issues_after || []).every(i => i.severity !== 'high') : p.passed;
p.message = data.message || '净化完成';
this.$message.success('净化完成,合规分 ' + data.score_before + ' → ' + data.score_after);
} catch (error) {
this.$message.error('净化失败: ' + error.message);
} finally { p.purifying = false; }
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
const params = new URLSearchParams(window.location.search);
const tid = params.get('topic_id');
if (tid) this.topicId = tid;
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true;
if (tid) this.runReport();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(AiSlopApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
+129 -17
View File
@@ -24,10 +24,12 @@
</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="articles" @navigate="redirectToPage" @logout="handleLogout"> <uni-nav v-if="!isEmbed" title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav> </uni-nav>
<div class="main-content"> <div class="main-content">
<main class="content-area"> <main class="content-area">
<el-tabs v-model="activeTab">
<el-tab-pane label="文章管理" name="articles">
<div class="card page-fade"> <div class="card page-fade">
<div class="page-header"> <div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2> <h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
@@ -65,17 +67,14 @@
<el-table-column prop="word_count" label="字数" width="70"> <el-table-column prop="word_count" label="字数" width="70">
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template> <template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="配图" width="60">
<template #default="scope">
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
<span v-else style="color:#dcdfe6;"></span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column> <el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="130" fixed="right"> <el-table-column label="操作" width="210" fixed="right">
<template #default="scope"> <template #default="scope">
<div style="display: flex; gap: 4px;"> <div style="display: flex; gap: 4px; flex-wrap: wrap;">
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button> <el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
<el-button size="small" @click="editArticle(scope.row)" style="padding:5px 8px;">编辑</el-button>
<el-button size="small" type="warning" @click="openSlop(scope.row)" style="padding:5px 8px;">检测</el-button>
<el-button size="small" type="success" @click="goPublish()" style="padding:5px 8px;">发布</el-button>
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button> <el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
</div> </div>
</template> </template>
@@ -96,6 +95,9 @@
</div> </div>
<div class="article-card-actions"> <div class="article-card-actions">
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button> <el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
<el-button size="small" @click="editArticle(article)">编辑</el-button>
<el-button size="small" type="warning" @click="openSlop(article)">检测</el-button>
<el-button size="small" type="success" @click="goPublish()">发布</el-button>
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button> <el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
</div> </div>
</div> </div>
@@ -104,6 +106,48 @@
<el-icon><IconDocument /></el-icon> 暂无文章 <el-icon><IconDocument /></el-icon> 暂无文章
</div> </div>
</div> </div>
</el-tab-pane>
<el-tab-pane label="发布记录" name="publish">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconUpload /></el-icon> 发布记录</h2>
<el-tag type="info" size="small">AI 内容需真人校验后发布,系统不代发</el-tag>
</div>
<el-table :data="publishRecords" stripe v-loading="loadingRecords">
<el-table-column prop="topic_id" label="选题 ID" width="90"></el-table-column>
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<template #default="scope"><el-tag :type="scope.row.status === 'published' ? 'success' : 'warning'" size="small">{{ scope.row.status === 'published' ? '已发布' : '待人工发布' }}</el-tag></template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="100"></el-table-column>
<el-table-column prop="created_at" label="生成时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button v-if="scope.row.status === 'pending_manual'" size="small" type="success" @click="markRecord(scope.row)" style="padding:5px 8px;">标记已发布</el-button>
<span v-else style="color:#909399;font-size:12px;"></span>
</template>
</el-table-column>
</el-table>
<div class="card-list-mobile" v-if="publishRecords && publishRecords.length > 0">
<div v-for="rec in publishRecords" :key="rec.id" class="card-item">
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ rec.topic_title || rec.topic_id }}</span></div>
<div class="card-row"><span class="card-label">平台</span><span class="card-value">{{ platformLabel(rec.platform) }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="rec.status === 'published' ? 'success' : 'warning'" size="small">{{ rec.status === 'published' ? '已发布' : '待人工发布' }}</el-tag></span></div>
<div class="card-row"><span class="card-label">操作人</span><span class="card-value">{{ rec.operator || '-' }}</span></div>
<div class="card-actions" v-if="rec.status === 'pending_manual'">
<el-button size="small" type="success" @click="markRecord(rec)">标记已发布</el-button>
</div>
</div>
</div>
<div v-if="!loadingRecords && publishRecords.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
<el-icon><IconUpload /></el-icon> 暂无发布记录
</div>
</div>
</el-tab-pane>
</el-tabs>
</main> </main>
</div> </div>
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape :lock-scroll="false"> <el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape :lock-scroll="false">
@@ -136,19 +180,31 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="editVisible" title="编辑正文" width="85%" :close-on-click-modal="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:12px;">修改后保存即覆盖该平台文章,发布前请先完成 AI 味检测与人工校验。</el-alert>
<el-input type="textarea" :rows="20" v-model="editForm.html_content" style="font-family:monospace;"></el-input>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="savingEdit" @click="saveArticle">保存</el-button>
</template>
</el-dialog>
</div> </div>
<script src="vue.global.prod.js"></script> <script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script> <script src="icon-components.js"></script>
<script src="element-plus.full.js"></script> <script src="element-plus.full.js"></script>
<script> <script>
const ArticlesApp = { const ArticlesApp = {
data() { data() {
return { return {
isEmbed: false,
isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
activeTab: 'articles',
loadingTable: false, loadingTable: false,
articles: [], articles: [],
filterPlatform: '', filterStatus: '', searchQuery: '', filterPlatform: '', filterStatus: '', searchQuery: '',
previewVisible: false, previewArticleData: null, previewFullscreen: false, previewVisible: false, previewArticleData: null, previewFullscreen: false,
editVisible: false, savingEdit: false, editForm: { topic_id: '', platform: '', html_content: '' },
publishRecords: [], loadingRecords: false,
debounceTimer: null debounceTimer: null
} }
}, },
@@ -164,6 +220,9 @@ const ArticlesApp = {
return list; return list;
} }
}, },
watch: {
activeTab(v) { if (v === 'publish') this.fetchRecords(); }
},
methods: { methods: {
getToken() { return localStorage.getItem('authToken'); }, getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) { async api(url, opts = {}) {
@@ -188,6 +247,16 @@ const ArticlesApp = {
this.articles = []; this.articles = [];
} finally { this.loadingTable = false; } } finally { this.loadingTable = false; }
}, },
async fetchRecords() {
this.loadingRecords = true;
try {
const data = await this.api('/api/publishing/records');
this.publishRecords = data.records || [];
} catch (error) {
this.$message.error('加载发布记录失败: ' + error.message);
this.publishRecords = [];
} finally { this.loadingRecords = false; }
},
debouncedSearch() { debouncedSearch() {
clearTimeout(this.debounceTimer); clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400); this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
@@ -204,6 +273,48 @@ const ArticlesApp = {
this.previewVisible = false; this.previewVisible = false;
} }
}, },
async editArticle(article) {
this.editForm = { topic_id: article.topic_id, platform: article.platform, html_content: '' };
this.editVisible = true;
this.savingEdit = false;
try {
const data = await this.api('/api/articles/detail/' + article.id);
this.editForm.html_content = data.html_content || '';
} catch (error) {
this.$message.error('加载正文失败: ' + error.message);
this.editVisible = false;
}
},
async saveArticle() {
this.savingEdit = true;
try {
await this.api('/api/articles/' + encodeURIComponent(this.editForm.topic_id) + '/content', {
method: 'PUT',
body: JSON.stringify({ platform: this.editForm.platform, html_content: this.editForm.html_content })
});
this.$message.success('正文已保存');
this.editVisible = false;
await this.fetchArticles();
} catch (error) {
this.$message.error('保存失败: ' + error.message);
} finally { this.savingEdit = false; }
},
openSlop(article) {
window.open('ai-slop.html?topic_id=' + encodeURIComponent(article.topic_id), '_blank');
},
goPublish() { this.redirectToPage('topics.html'); },
async markRecord(rec) {
try {
await this.$confirm('确认已在各平台完成发布?', '标记发布', { type: 'warning' });
await this.api('/api/publishing/' + encodeURIComponent(rec.topic_id) + '/mark-published', { method: 'POST' });
this.$message.success('已标记为已发布');
await this.fetchRecords();
} catch (e) {
if (e === 'cancel') return;
const msg = e?.response?.data?.detail || e?.message || '未知错误';
this.$message.error('操作失败: ' + msg);
}
},
togglePreviewFullscreen() { togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen; this.previewFullscreen = !this.previewFullscreen;
this.$nextTick(() => { this.$nextTick(() => {
@@ -243,7 +354,7 @@ const ArticlesApp = {
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); } } catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
}, },
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; }, handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; }, redirectToPage(page) { const url = page.startsWith('/') ? page : '/' + page; if (this.isEmbed) window.top.location.href = url; else window.location.href = url; },
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; }, platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; }, statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; }, statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
@@ -256,17 +367,18 @@ const ArticlesApp = {
mounted() { mounted() {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; } if (!token) { window.location.href = '/login.html'; return; }
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } }) fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject()) .then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); }) .then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; }); .catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
} }
}; };
const app = Vue.createApp(ArticlesApp); const app = Vue.createApp(ArticlesApp);
app.use(ElementPlus); app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); } if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); } if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app'); app.mount('#app');
</script> </script>
<script src="ai-assistant.js"></script> <script src="ai-assistant.js"></script>
</body> </body>
+123
View File
@@ -0,0 +1,123 @@
<!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">
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="白标设置" :username="currentUser.username" :is-admin="isAdmin" current-page="branding" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 白标 / 私有化品牌设置</h2>
<el-tag type="info" size="small">仅管理员可见</el-tag>
</div>
<el-alert type="success" :closable="false" show-icon style="margin-bottom:16px;">
<template #title>私有化交付说明</template>
修改以下配置即可将本平台改名为贵司品牌(名称 / Logo / 主色 / 支持邮箱),保存后前台导航与品牌色即时生效。适合部署到客户内网或作为白标产品交付。
</el-alert>
<el-form label-width="110px" style="max-width:640px;">
<el-form-item label="平台名称">
<el-input v-model="form.brand_name" placeholder="如:星辰内容中台" maxlength="20" show-word-limit></el-input>
</el-form-item>
<el-form-item label="Logo 地址">
<el-input v-model="form.brand_logo" placeholder="https 图片 URL,留空则显示文字名称"></el-input>
</el-form-item>
<el-form-item label="主色">
<el-color-picker v-model="form.brand_primary_color"></el-color-picker>
<el-input v-model="form.brand_primary_color" style="width:120px;margin-left:12px;" placeholder="#2563eb"></el-input>
</el-form-item>
<el-form-item label="支持邮箱">
<el-input v-model="form.brand_support_email" placeholder="support@yourcompany.com"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存并应用</el-button>
<el-button @click="reset">重置为当前</el-button>
</el-form-item>
</el-form>
<el-divider>实时预览</el-divider>
<div :style="{ background: 'linear-gradient(135deg,' + (form.brand_primary_color || '#2563eb') + ',' + (form.brand_primary_color || '#2563eb') + 'cc)', color:'#fff', padding:'10px 16px', borderRadius:'8px', display:'flex', alignItems:'center', gap:'8px', maxWidth:'480px' }">
<img v-if="form.brand_logo" :src="form.brand_logo" style="height:22px;border-radius:4px;" />
<strong>{{ form.brand_name || '平台名称预览' }}</strong>
<span style="margin-left:auto;font-size:12px;opacity:.85;">v{{ version }}</span>
</div>
</div>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const BrandingApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
form: { brand_name: '', brand_logo: '', brand_primary_color: '#2563eb', brand_support_email: '' },
version: '0.1.0', saving: false,
}
},
methods: {
getToken() { return localStorage.getItem('authToken'); },
async load() {
try {
const data = await fetch('/api/branding').then(r => r.json());
this.form = {
brand_name: data.brand_name || '',
brand_logo: data.brand_logo || '',
brand_primary_color: data.brand_primary_color || '#2563eb',
brand_support_email: data.brand_support_email || '',
};
this.version = data.version || '0.1.0';
} catch (e) { this.$message.error('加载品牌配置失败'); }
},
async save() {
this.saving = true;
try {
const token = this.getToken();
const res = await fetch('/api/branding', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
if (!res.ok) throw new Error((await res.json()).detail || '保存失败');
this.$message.success('已保存,刷新其他页面即可看到新品牌');
setTimeout(() => this.load(), 300);
} catch (e) {
this.$message.error('保存失败:' + e.message);
} finally { this.saving = false; }
},
reset() { this.load(); },
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user; this.isAdmin = data.user.role === 'admin';
if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/index.html'; return; }
this.load();
})
.catch(() => { window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(BrandingApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
+14 -1
View File
@@ -186,7 +186,8 @@
<div class="empty-text">该日暂无日程</div> <div class="empty-text">该日暂无日程</div>
<p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p> <p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p>
</div> </div>
<el-table v-else :data="selectedDayEntries" stripe> <template v-else>
<el-table class="card-table" :data="selectedDayEntries" stripe>
<el-table-column prop="title" label="标题" min-width="120"></el-table-column> <el-table-column prop="title" label="标题" min-width="120"></el-table-column>
<el-table-column prop="platform" label="平台" width="100"> <el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}</template> <template #default="scope">{{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}</template>
@@ -201,6 +202,18 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="card-list-mobile">
<div v-for="entry in selectedDayEntries" :key="entry.id || entry.topic_id || entry.title" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ entry.title }}</span></div>
<div class="card-row"><span class="card-label">平台</span><span class="card-value">{{ entry._source === 'topic' ? (entry.status === 'published' ? '已发布' : '选题') : platformName(entry.platform) }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="statusType(entry.status)" size="small">{{ statusLabel(entry.status) }}</el-tag></span></div>
<div class="card-actions">
<el-button v-if="entry._source === 'topic'" size="small" type="primary" @click="openEntryDialog(entry)">查看</el-button>
<el-button v-else size="small" @click="openEntryDialog(entry)">编辑</el-button>
</div>
</div>
</div>
</template>
</div> </div>
</el-dialog> </el-dialog>
+652
View File
@@ -0,0 +1,652 @@
<!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>
[v-cloak] { display: none; }
.data-table { display: block; }
@media (max-width: 768px) { .data-table { display: none; } }
.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; }
.stat-card { background: #f8f9ff; border-radius: 10px; padding: 16px; text-align: center; }
.stat-card .num { font-size: 28px; font-weight: 700; color: #2563eb; }
.stat-card .label { font-size: 12px; color: #909399; margin-top: 4px; }
.search-engine-tag { margin: 2px; }
@media (max-width: 768px) {
.page-header { flex-direction: column; align-items: flex-start !important; gap: 8px; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 4px; }
.filter-bar .el-button { margin-left: 0 !important; }
.stat-cards { grid-template-columns: repeat(2, 1fr); }
.toolbar { flex-wrap: wrap; gap: 8px; }
}
</style>
<script src="uni-nav.js"></script>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script src="icon-components.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="外推营销" :username="currentUser.username" :is-admin="isAdmin" current-page="campaigns" @navigate="redirectToPage" @logout="logout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 外推营销</h2>
<div class="filter-bar">
<el-button size="default" :type="activeTab === 'overview' ? 'primary' : ''" @click="switchTab('overview')">概览</el-button>
<el-button size="default" :type="activeTab === 'products' ? 'primary' : ''" @click="switchTab('products')">产品管理</el-button>
<el-button size="default" :type="activeTab === 'campaigns' ? 'primary' : ''" @click="switchTab('campaigns')">活动管理</el-button>
<el-button size="default" :type="activeTab === 'rankings' ? 'primary' : ''" @click="switchTab('rankings')">关键词排名</el-button>
<el-button size="default" :type="activeTab === 'audits' ? 'primary' : ''" @click="switchTab('audits')">SEO审计</el-button>
<el-button size="default" :type="activeTab === 'tasks' ? 'primary' : ''" @click="switchTab('tasks')">优化建议</el-button>
</div>
</div>
<div v-if="activeTab === 'overview'">
<div v-if="loading" class="card-loading">加载中...</div>
<template v-else>
<div class="stat-cards">
<div class="stat-card"><div class="num">{{ overview.products || 0 }}</div><div class="label">推广产品</div></div>
<div class="stat-card"><div class="num">{{ overview.campaigns || 0 }}</div><div class="label">推广活动</div></div>
<div class="stat-card"><div class="num">{{ overview.active_campaigns || 0 }}</div><div class="label">进行中</div></div>
<div class="stat-card"><div class="num">{{ overview.audits || 0 }}</div><div class="label">SEO审计</div></div>
<div class="stat-card"><div class="num">{{ overview.keywords || 0 }}</div><div class="label">追踪关键词</div></div>
<div class="stat-card"><div class="num">{{ overview.open_tasks || 0 }}</div><div class="label">待处理建议</div></div>
</div>
<div v-if="overview.type_breakdown" style="margin-top:16px;">
<h4 style="font-size:14px;color:#303133;margin-bottom:8px;">产品类型分布</h4>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
<el-tag v-for="(cnt, type) in overview.type_breakdown" :key="type" size="large">{{ typeLabel(type) }}: {{ cnt }}</el-tag>
</div>
</div>
<div style="margin-top:20px;">
<el-button type="primary" @click="switchTab('products')">管理推广产品</el-button>
<el-button @click="switchTab('campaigns')">查看活动</el-button>
</div>
</template>
</div>
<div v-if="activeTab === 'products'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showProductDialog = true">新增产品</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ products.length }} 个</span>
</div>
<div v-if="loading" class="card-loading">加载中...</div>
<template v-else-if="products.length === 0">
<div class="empty-state">
<div class="empty-text">暂无推广产品,点击上方按钮添加</div>
</div>
</template>
<template v-else>
<el-table :data="products" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="产品名称" min-width="120"></el-table-column>
<el-table-column label="类型" width="110">
<template #default="s">{{ typeLabel(s.row.type) }}</template>
</el-table-column>
<el-table-column prop="url" label="URL" min-width="180">
<template #default="s"><a :href="s.row.url" target="_blank" style="color:#409eff;">{{ s.row.url || '-' }}</a></template>
</el-table-column>
<el-table-column prop="domain" label="域名" width="140"></el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="s">
<el-button size="small" @click="editProduct(s.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteProduct(s.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="p in products" :key="p.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ p.name }}</span></div>
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ typeLabel(p.type) }}</span></div>
<div class="card-row"><span class="card-label">URL</span><span class="card-value"><a :href="p.url" target="_blank" style="color:#409eff;word-break:break-all;">{{ p.url || '-' }}</a></span></div>
<div class="card-actions">
<el-button size="small" @click="editProduct(p)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteProduct(p)">删除</el-button>
</div>
</div>
</div>
<div v-if="products.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="products.length" :page-size="pageSize" :current-page="productPage" @current-change="productPage = $event"></el-pagination>
</div>
</template>
</div>
<div v-if="activeTab === 'campaigns'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCampaignDialog = true">新增活动</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ campaigns.length }} 个</span>
</div>
<div v-if="loading" class="card-loading">加载中...</div>
<template v-else-if="campaigns.length === 0">
<div class="empty-state"><div class="empty-text">暂无推广活动</div></div>
</template>
<template v-else>
<el-table :data="campaigns" border stripe class="data-table" style="width:100%">
<el-table-column prop="name" label="活动名称" min-width="120"></el-table-column>
<el-table-column prop="product_name" label="关联产品" width="120"></el-table-column>
<el-table-column label="状态" width="90">
<template #default="s">
<el-tag :type="s.row.status === 'active' ? 'success' : s.row.status === 'paused' ? 'warning' : 'info'" size="small">{{ { active: '进行中', paused: '已暂停', completed: '已完成' }[s.row.status] || s.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="目标引擎" min-width="140">
<template #default="s">
<el-tag v-for="e in (s.row.target_engines || [])" :key="e" size="small" class="search-engine-tag">{{ engineLabel(e) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="关键词数" width="80">
<template #default="s">{{ (s.row.keywords || []).length }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="s">
<el-button size="small" @click="viewCampaignKeywords(s.row)">关键词</el-button>
<el-button size="small" @click="editCampaign(s.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCampaign(s.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="c in campaigns" :key="c.id" class="card-item">
<div class="card-row"><span class="card-label">活动</span><span class="card-value">{{ c.name }}</span></div>
<div class="card-row"><span class="card-label">产品</span><span class="card-value">{{ c.product_name || '-' }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="c.status === 'active' ? 'success' : c.status === 'paused' ? 'warning' : 'info'" size="small">{{ { active: '进行中', paused: '已暂停', completed: '已完成' }[c.status] || c.status }}</el-tag></span></div>
<div class="card-row"><span class="card-label">引擎</span><span class="card-value"><el-tag v-for="e in (c.target_engines || [])" :key="e" size="small" class="search-engine-tag">{{ engineLabel(e) }}</el-tag></span></div>
<div class="card-actions">
<el-button size="small" @click="viewCampaignKeywords(c)">关键词</el-button>
<el-button size="small" @click="editCampaign(c)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCampaign(c)">删除</el-button>
</div>
</div>
</div>
<div v-if="campaigns.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="campaigns.length" :page-size="pageSize" :current-page="campaignPage" @current-change="campaignPage = $event"></el-pagination>
</div>
</template>
<div v-if="selectedCampaign" style="margin-top:20px;border-top:1px solid #ebeef5;padding-top:16px;">
<h4 style="font-size:14px;color:#303133;margin-bottom:8px;">
关键词详情:{{ selectedCampaign.name }}
<el-button size="small" type="primary" style="margin-left:8px;" @click="showKeywordDialog = true">添加关键词</el-button>
</h4>
<template v-if="campaignKeywords.length">
<el-table :data="campaignKeywords" border stripe class="data-table" style="width:100%">
<el-table-column prop="keyword" label="关键词" min-width="150"></el-table-column>
<el-table-column prop="search_volume" label="搜索量" width="80"></el-table-column>
<el-table-column label="难度" width="70">
<template #default="s"><el-tag :type="(s.row.difficulty || 0) > 0.7 ? 'danger' : (s.row.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ s.row.difficulty != null ? (s.row.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></template>
</el-table-column>
<el-table-column prop="current_rank" label="当前排名" width="80"></el-table-column>
<el-table-column prop="target_rank" label="目标排名" width="80"></el-table-column>
<el-table-column prop="best_rank" label="最佳排名" width="80"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="s">
<el-button size="small" type="danger" @click="deleteKeyword(s.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="kw in campaignKeywords" :key="kw.id" class="card-item">
<div class="card-row"><span class="card-label">关键词</span><span class="card-value">{{ kw.keyword }}</span></div>
<div class="card-row"><span class="card-label">搜索量</span><span class="card-value">{{ kw.search_volume || 0 }}</span></div>
<div class="card-row"><span class="card-label">难度</span><span class="card-value"><el-tag :type="(kw.difficulty || 0) > 0.7 ? 'danger' : (kw.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ kw.difficulty != null ? (kw.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></span></div>
<div class="card-row"><span class="card-label">排名</span><span class="card-value">当前 {{ kw.current_rank || '-' }} 目标 {{ kw.target_rank || '-' }}</span></div>
<div class="card-actions">
<el-button size="small" type="danger" @click="deleteKeyword(kw)">删除</el-button>
</div>
</div>
</div>
</template>
<div v-else style="text-align:center;padding:20px;color:#909399;font-size:13px;">暂无关键词数据</div>
</div>
</div>
<div v-if="activeTab === 'rankings'">
<div class="toolbar">
<span style="font-size:13px;color:#909399;">
共 {{ keywordRankings.length }} 条记录
<span v-if="keywordRankingOverview.with_rank != null"> · 有排名 {{ keywordRankingOverview.with_rank }} 条</span>
</span>
</div>
<div v-if="loading" class="card-loading">加载中...</div>
<template v-else-if="keywordRankings.length === 0">
<div class="empty-state"><div class="empty-text">暂无排名数据,创建活动和关键词后将自动追踪</div></div>
</template>
<template v-else>
<el-table :data="keywordRankings" border stripe class="data-table" style="width:100%">
<el-table-column prop="keyword" label="关键词" min-width="140"></el-table-column>
<el-table-column label="搜索引擎" width="100">
<template #default="s">{{ engineLabel(s.row.search_engine) }}</template>
</el-table-column>
<el-table-column label="排名" width="80">
<template #default="s">
<el-tag :type="s.row.rank == null ? 'info' : s.row.rank <= 10 ? 'success' : s.row.rank <= 30 ? 'warning' : 'info'" size="small">{{ s.row.rank || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="url_found" label="排名URL" min-width="200">
<template #default="s"><a :href="s.row.url_found" target="_blank" style="color:#409eff;font-size:12px;">{{ s.row.url_found ? s.row.url_found.substring(0, 50) + '...' : '-' }}</a></template>
</el-table-column>
<el-table-column label="检查时间" width="150">
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
</el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="r in keywordRankings" :key="r.id" class="card-item">
<div class="card-row"><span class="card-label">关键词</span><span class="card-value">{{ r.keyword }}</span></div>
<div class="card-row"><span class="card-label">引擎</span><span class="card-value">{{ engineLabel(r.search_engine) }}</span></div>
<div class="card-row"><span class="card-label">排名</span><span class="card-value"><el-tag :type="r.rank == null ? 'info' : r.rank <= 10 ? 'success' : r.rank <= 30 ? 'warning' : 'info'" size="small">{{ r.rank || '-' }}</el-tag></span></div>
<div class="card-row"><span class="card-label">时间</span><span class="card-value">{{ r.checked_at ? new Date(r.checked_at).toLocaleString('zh-CN') : '-' }}</span></div>
</div>
</div>
<div v-if="keywordRankings.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="keywordRankings.length" :page-size="pageSize" :current-page="rankingPage" @current-change="rankingPage = $event"></el-pagination>
</div>
</template>
</div>
<div v-if="activeTab === 'audits'">
<div class="toolbar">
<span style="font-size:13px;color:#909399;">共 {{ audits.length }} 条审计记录</span>
</div>
<div v-if="loading" class="card-loading">加载中...</div>
<template v-else-if="audits.length === 0">
<div class="empty-state"><div class="empty-text">暂无SEO审计记录</div></div>
</template>
<template v-else>
<el-table :data="audits" border stripe class="data-table" style="width:100%">
<el-table-column prop="product_name" label="产品" width="120"></el-table-column>
<el-table-column label="审计类型" width="80">
<template #default="s">{{ s.row.audit_type === 'full' ? '全面' : '快速' }}</template>
</el-table-column>
<el-table-column label="总分" width="80">
<template #default="s">
<el-tag :type="(s.row.overall_score || 0) >= 80 ? 'success' : (s.row.overall_score || 0) >= 60 ? 'warning' : 'danger'" size="small">{{ s.row.overall_score != null ? s.row.overall_score.toFixed(1) : '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Meta" width="65"><template #default="s">{{ s.row.meta_score != null ? s.row.meta_score.toFixed(0) : '-' }}</template></el-table-column>
<el-table-column label="标题" width="65"><template #default="s">{{ s.row.heading_score != null ? s.row.heading_score.toFixed(0) : '-' }}</template></el-table-column>
<el-table-column label="内容" width="65"><template #default="s">{{ s.row.content_score != null ? s.row.content_score.toFixed(0) : '-' }}</template></el-table-column>
<el-table-column label="性能" width="65"><template #default="s">{{ s.row.perf_score != null ? s.row.perf_score.toFixed(0) : '-' }}</template></el-table-column>
<el-table-column label="链接" width="65"><template #default="s">{{ s.row.links_score != null ? s.row.links_score.toFixed(0) : '-' }}</template></el-table-column>
<el-table-column label="移动端" width="65"><template #default="s">{{ s.row.mobile_score != null ? s.row.mobile_score.toFixed(0) : '-' }}</template></el-table-column>
<el-table-column prop="issues_found" label="问题数" width="65"></el-table-column>
<el-table-column label="审计时间" width="150">
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
</el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="a in audits" :key="a.id" class="card-item">
<div class="card-row"><span class="card-label">产品</span><span class="card-value">{{ a.product_name || '-' }}</span></div>
<div class="card-row"><span class="card-label">总分</span><span class="card-value"><el-tag :type="(a.overall_score || 0) >= 80 ? 'success' : (a.overall_score || 0) >= 60 ? 'warning' : 'danger'" size="small">{{ a.overall_score != null ? a.overall_score.toFixed(1) : '-' }}</el-tag></span></div>
<div class="card-row"><span class="card-label">评分</span><span class="card-value" style="font-size:12px;">M{{ a.meta_score || '-' }} H{{ a.heading_score || '-' }} C{{ a.content_score || '-' }} P{{ a.perf_score || '-' }} L{{ a.links_score || '-' }} Mob{{ a.mobile_score || '-' }}</span></div>
<div class="card-row"><span class="card-label">问题数</span><span class="card-value">{{ a.issues_found || 0 }}</span></div>
<div class="card-row"><span class="card-label">时间</span><span class="card-value">{{ a.checked_at ? new Date(a.checked_at).toLocaleString('zh-CN') : '-' }}</span></div>
</div>
</div>
</template>
</div>
<div v-if="activeTab === 'tasks'">
<div class="toolbar">
<el-button :type="taskFilter === 'open' ? 'primary' : ''" size="small" @click="taskFilter = 'open'; loadTasks()">待处理</el-button>
<el-button :type="taskFilter === 'resolved' ? 'primary' : ''" size="small" @click="taskFilter = 'resolved'; loadTasks()">已解决</el-button>
<el-button :type="taskFilter === null ? 'primary' : ''" size="small" @click="taskFilter = null; loadTasks()">全部</el-button>
</div>
<div v-if="loading" class="card-loading">加载中...</div>
<template v-else-if="optimizationTasks.length === 0">
<div class="empty-state"><div class="empty-text">暂无优化建议</div></div>
</template>
<template v-else>
<el-table :data="optimizationTasks" border stripe class="data-table" style="width:100%">
<el-table-column label="严重度" width="80">
<template #default="s">
<el-tag :type="s.row.severity === 'high' ? 'danger' : s.row.severity === 'medium' ? 'warning' : 'info'" size="small">{{ { high: '高', medium: '中', low: '低' }[s.row.severity] || s.row.severity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="90">
<template #default="s">{{ { meta: 'Meta', heading: '标题', content: '内容', performance: '性能', links: '链接', mobile: '移动端' }[s.row.category] || s.row.category }}</template>
</el-table-column>
<el-table-column prop="issue" label="问题" min-width="200"></el-table-column>
<el-table-column prop="recommendation" label="建议" min-width="200"></el-table-column>
<el-table-column label="状态" width="90">
<template #default="s">
<el-tag :type="s.row.status === 'resolved' ? 'success' : s.row.status === 'ignored' ? 'info' : 'warning'" size="small">{{ { open: '待处理', resolved: '已解决', ignored: '已忽略' }[s.row.status] || s.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="s">
<el-button v-if="s.row.status === 'open'" size="small" type="success" @click="updateTaskStatus(s.row, 'resolved')">解决</el-button>
<el-button size="small" @click="updateTaskStatus(s.row, 'ignored')">忽略</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="t in optimizationTasks" :key="t.id" class="card-item">
<div class="card-row"><span class="card-label">严重度</span><span class="card-value"><el-tag :type="t.severity === 'high' ? 'danger' : t.severity === 'medium' ? 'warning' : 'info'" size="small">{{ { high: '高', medium: '中', low: '低' }[t.severity] || t.severity }}</el-tag></span></div>
<div class="card-row"><span class="card-label">分类</span><span class="card-value">{{ { meta: 'Meta', heading: '标题', content: '内容', performance: '性能', links: '链接', mobile: '移动端' }[t.category] || t.category }}</span></div>
<div class="card-row"><span class="card-label">问题</span><span class="card-value" style="font-size:12px;">{{ t.issue }}</span></div>
<div class="card-row"><span class="card-label">建议</span><span class="card-value" style="font-size:12px;">{{ t.recommendation }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="t.status === 'resolved' ? 'success' : t.status === 'ignored' ? 'info' : 'warning'" size="small">{{ { open: '待处理', resolved: '已解决', ignored: '已忽略' }[t.status] || t.status }}</el-tag></span></div>
<div class="card-actions">
<el-button v-if="t.status === 'open'" size="small" type="success" @click="updateTaskStatus(t, 'resolved')">解决</el-button>
<el-button size="small" @click="updateTaskStatus(t, 'ignored')">忽略</el-button>
</div>
</div>
</div>
</template>
</div>
</div>
</main>
</div>
<!-- Product Dialog -->
<el-dialog v-model="showProductDialog" :title="editingProduct ? '编辑产品' : '新增产品'" width="500px">
<el-form label-width="100px">
<el-form-item label="产品名称"><el-input v-model="productForm.name" placeholder="例: 我的个人博客"></el-input></el-form-item>
<el-form-item label="产品类型">
<el-select v-model="productForm.type" style="width:100%">
<el-option label="网站" value="website"></el-option>
<el-option label="文章" value="article"></el-option>
<el-option label="公众号" value="wechat_account"></el-option>
<el-option label="小程序" value="miniprogram"></el-option>
</el-select>
</el-form-item>
<el-form-item label="URL"><el-input v-model="productForm.url" placeholder="https://example.com"></el-input></el-form-item>
<el-form-item label="域名"><el-input v-model="productForm.domain" placeholder="example.com"></el-input></el-form-item>
<el-form-item v-if="productForm.type === 'miniprogram'" label="AppID"><el-input v-model="productForm.app_id" placeholder="微信小程序AppID"></el-input></el-form-item>
<el-form-item v-if="productForm.type === 'wechat_account'" label="公众号ID"><el-input v-model="productForm.account_id" placeholder="公众号ID"></el-input></el-form-item>
<el-form-item label="描述"><el-input v-model="productForm.description" type="textarea" :rows="3"></el-input></el-form-item>
</el-form>
<template #footer>
<el-button @click="showProductDialog = false">取消</el-button>
<el-button type="primary" @click="saveProduct">{{ editingProduct ? '保存' : '创建' }}</el-button>
</template>
</el-dialog>
<!-- Campaign Dialog -->
<el-dialog v-model="showCampaignDialog" :title="editingCampaign ? '编辑活动' : '新增活动'" width="500px">
<el-form label-width="100px">
<el-form-item label="活动名称"><el-input v-model="campaignForm.name" placeholder="例: 百度SEO推广"></el-input></el-form-item>
<el-form-item label="关联产品">
<el-select v-model="campaignForm.product_id" style="width:100%" placeholder="选择推广产品">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="campaignForm.status" style="width:100%">
<el-option label="进行中" value="active"></el-option>
<el-option label="已暂停" value="paused"></el-option>
<el-option label="已完成" value="completed"></el-option>
</el-select>
</el-form-item>
<el-form-item label="目标引擎">
<el-checkbox-group v-model="campaignForm.target_engines">
<el-checkbox label="baidu">百度</el-checkbox>
<el-checkbox label="360">360</el-checkbox>
<el-checkbox label="sogou">搜狗</el-checkbox>
<el-checkbox label="wechat">微信搜索</el-checkbox>
<el-checkbox label="bing">Bing</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="关键词">
<el-select v-model="campaignForm.keywords" multiple filterable allow-create default-first-option style="width:100%" placeholder="输入关键词后回车添加">
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="campaignForm.notes" type="textarea" :rows="2"></el-input></el-form-item>
</el-form>
<template #footer>
<el-button @click="showCampaignDialog = false">取消</el-button>
<el-button type="primary" @click="saveCampaign">{{ editingCampaign ? '保存' : '创建' }}</el-button>
</template>
</el-dialog>
<!-- Keyword Dialog -->
<el-dialog v-model="showKeywordDialog" title="添加关键词" width="450px">
<el-form label-width="90px">
<el-form-item label="关键词"><el-input v-model="keywordForm.keyword" placeholder="输入关键词"></el-input></el-form-item>
<el-form-item label="搜索量"><el-input-number v-model="keywordForm.search_volume" :min="0" style="width:100%"></el-input-number></el-form-item>
<el-form-item label="竞争难度"><el-slider v-model="keywordForm.difficulty" :min="0" :max="1" :step="0.05" show-input style="width:100%"></el-slider></el-form-item>
<el-form-item label="目标排名"><el-input-number v-model="keywordForm.target_rank" :min="1" :max="100" style="width:100%"></el-input-number></el-form-item>
</el-form>
<template #footer>
<el-button @click="showKeywordDialog = false">取消</el-button>
<el-button type="primary" @click="saveKeyword">添加</el-button>
</template>
</el-dialog>
</div>
<script>
const { createApp, ref, computed, onMounted, nextTick } = Vue;
const { ElMessage } = ElementPlus;
// Auth check — redirect early if no token
const authToken = localStorage.getItem('authToken') || '';
if (!authToken) { window.location.href = '/login.html'; }
const app = createApp({
data() {
return {
currentUser: {},
isAdmin: false,
activeTab: 'overview',
loading: false,
pageSize: 10, productPage: 1, campaignPage: 1, rankingPage: 1,
overview: {},
products: [],
campaigns: [],
selectedCampaign: null,
campaignKeywords: [],
keywordRankings: [],
keywordRankingOverview: {},
audits: [],
optimizationTasks: [],
taskFilter: 'open',
showProductDialog: false,
editingProduct: null,
productForm: { name: '', type: 'website', url: '', domain: '', app_id: '', account_id: '', description: '' },
showCampaignDialog: false,
editingCampaign: null,
campaignForm: { name: '', product_id: null, status: 'active', target_engines: [], keywords: [], notes: '' },
showKeywordDialog: false,
keywordForm: { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 },
token: authToken,
};
},
computed: {
paginatedProducts() {
const start = (this.productPage - 1) * this.pageSize;
return this.products.slice(start, start + this.pageSize);
},
paginatedCampaigns() {
const start = (this.campaignPage - 1) * this.pageSize;
return this.campaigns.slice(start, start + this.pageSize);
},
paginatedRankings() {
const start = (this.rankingPage - 1) * this.pageSize;
return this.keywordRankings.slice(start, start + this.pageSize);
},
},
methods: {
typeLabel(t) {
return { website: '网站', article: '文章', wechat_account: '公众号', miniprogram: '小程序' }[t] || t;
},
engineLabel(e) {
return { baidu: '百度', bing: 'Bing', sogou: '搜狗', '360': '360', wechat: '微信搜索' }[e] || e;
},
api(path) { return '/api/external' + path; },
headers() { return { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }; },
async fetchJson(url, opts) {
const r = await fetch(url, opts || { headers: this.headers() });
const d = await r.json();
if (!d.ok) throw new Error(d.detail || '请求失败');
return d.data;
},
switchTab(tab) {
this.activeTab = tab;
this.selectedCampaign = null;
this.loadTabData();
},
async loadTabData() {
this.loading = true;
try {
if (this.activeTab === 'overview') {
this.overview = await this.fetchJson(this.api('/overview'));
} else if (this.activeTab === 'products') {
this.products = await this.fetchJson(this.api('/products'));
} else if (this.activeTab === 'campaigns') {
this.campaigns = await this.fetchJson(this.api('/campaigns'));
} else if (this.activeTab === 'rankings') {
await this.loadRankings();
} else if (this.activeTab === 'audits') {
this.audits = await this.fetchJson(this.api('/audits'));
} else if (this.activeTab === 'tasks') {
await this.loadTasks();
}
} catch (e) {
ElMessage.error(e.message);
} finally {
this.loading = false;
}
},
async loadRankings() {
this.keywordRankings = await this.fetchJson(this.api('/rankings'));
try { this.keywordRankingOverview = await this.fetchJson(this.api('/rankings/overview')); } catch (e) {}
},
async loadTasks() {
let url = this.api('/optimization-tasks');
if (this.taskFilter) url += '?status=' + this.taskFilter;
this.optimizationTasks = await this.fetchJson(url);
},
// Products
editProduct(p) {
this.editingProduct = p;
this.productForm = { ...p };
this.showProductDialog = true;
},
async saveProduct() {
const url = this.editingProduct
? this.api('/products/' + this.editingProduct.id)
: this.api('/products');
const method = this.editingProduct ? 'PUT' : 'POST';
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.productForm) });
const d = await r.json();
if (!d.ok) throw new Error(d.detail || '操作失败');
ElMessage.success(this.editingProduct ? '已更新' : '已创建');
this.showProductDialog = false;
this.editingProduct = null;
this.productForm = { name: '', type: 'website', url: '', domain: '', app_id: '', account_id: '', description: '' };
this.products = await this.fetchJson(this.api('/products'));
},
async deleteProduct(p) {
if (!confirm('确定删除产品「' + p.name + '」?')) return;
await this.fetchJson(this.api('/products/' + p.id), { method: 'DELETE', headers: this.headers() });
ElMessage.success('已删除');
this.products = await this.fetchJson(this.api('/products'));
},
// Campaigns
viewCampaignKeywords(c) {
this.selectedCampaign = c;
this.loadCampaignKeywords(c.id);
},
async loadCampaignKeywords(campaignId) {
try {
this.campaignKeywords = await this.fetchJson(this.api('/keywords?campaign_id=' + campaignId));
} catch (e) {
this.campaignKeywords = [];
}
},
editCampaign(c) {
this.editingCampaign = c;
this.campaignForm = { name: c.name, product_id: c.product_id, status: c.status, target_engines: c.target_engines || [], keywords: c.keywords || [], notes: c.notes || '' };
this.showCampaignDialog = true;
},
async saveCampaign() {
const url = this.editingCampaign
? this.api('/campaigns/' + this.editingCampaign.id)
: this.api('/campaigns');
const method = this.editingCampaign ? 'PUT' : 'POST';
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.campaignForm) });
const d = await r.json();
if (!d.ok) throw new Error(d.detail || '操作失败');
ElMessage.success(this.editingCampaign ? '已更新' : '已创建');
this.showCampaignDialog = false;
this.editingCampaign = null;
this.campaignForm = { name: '', product_id: null, status: 'active', target_engines: [], keywords: [], notes: '' };
this.campaigns = await this.fetchJson(this.api('/campaigns'));
},
async deleteCampaign(c) {
if (!confirm('确定删除活动「' + c.name + '」?')) return;
await this.fetchJson(this.api('/campaigns/' + c.id), { method: 'DELETE', headers: this.headers() });
ElMessage.success('已删除');
this.campaigns = await this.fetchJson(this.api('/campaigns'));
},
// Keywords
async saveKeyword() {
if (!this.selectedCampaign) { ElMessage.warning('请先选择活动'); return; }
this.keywordForm.campaign_id = this.selectedCampaign.id;
const r = await fetch(this.api('/keywords'), { method: 'POST', headers: this.headers(), body: JSON.stringify(this.keywordForm) });
const d = await r.json();
if (!d.ok) throw new Error(d.detail || '添加失败');
ElMessage.success('已添加');
this.showKeywordDialog = false;
this.keywordForm = { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 };
this.loadCampaignKeywords(this.selectedCampaign.id);
},
async deleteKeyword(kw) {
if (!confirm('确定删除关键词「' + kw.keyword + '」?')) return;
await this.fetchJson(this.api('/keywords/' + kw.id), { method: 'DELETE', headers: this.headers() });
ElMessage.success('已删除');
if (this.selectedCampaign) this.loadCampaignKeywords(this.selectedCampaign.id);
},
// Optimization Tasks
async updateTaskStatus(task, status) {
await this.fetchJson(this.api('/optimization-tasks/' + task.id), { method: 'PATCH', headers: this.headers(), body: JSON.stringify({ status: status }) });
ElMessage.success('已更新');
this.loadTasks();
},
redirectToPage(page) {
window.location.href = '/' + page;
},
logout() {
localStorage.removeItem('authToken');
window.location.href = '/login.html';
},
},
mounted() {
const token = this.token;
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function (r) { if (!r.ok) throw new Error('unauthorized'); return r.json(); })
.then(function (d) {
this.currentUser = d.user || d;
this.isAdmin = this.currentUser.role === 'admin';
}.bind(this))
.catch(function () { window.location.href = '/login.html'; });
this.loadTabData();
},
});
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+401 -56
View File
@@ -30,66 +30,274 @@
.plan-item:hover { border-color: var(--color-primary); } .plan-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');
+945
View File
@@ -0,0 +1,945 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 数据洞察</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
/* ===== Insight-specific overrides ===== */
.insight-hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-lg);
padding: 28px 32px;
color: white;
margin-bottom: var(--spacing-lg);
position: relative;
overflow: hidden;
}
.insight-hero::after {
content: '';
position: absolute;
top: -40%;
right: -10%;
width: 300px;
height: 300px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
}
.insight-hero::before {
content: '';
position: absolute;
bottom: -60%;
left: -5%;
width: 250px;
height: 250px;
border-radius: 50%;
background: rgba(255,255,255,0.05);
}
.insight-hero h2 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
position: relative;
z-index: 1;
}
.insight-hero p {
font-size: 13px;
opacity: 0.85;
position: relative;
z-index: 1;
}
.insight-summary {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-top: 20px;
position: relative;
z-index: 1;
}
.insight-summary-item {
display: flex;
flex-direction: column;
min-width: 100px;
}
.insight-summary-item .val {
font-size: 28px;
font-weight: 800;
line-height: 1.1;
}
.insight-summary-item .lbl {
font-size: 12px;
opacity: 0.8;
margin-top: 2px;
}
/* Score bar */
.score-bar-wrap {
width: 100%;
height: 10px;
background: #ebeef5;
border-radius: 5px;
overflow: hidden;
margin-top: 6px;
}
.score-bar {
height: 100%;
border-radius: 5px;
transition: width 0.6s var(--ease-out);
}
.score-bar.green { background: linear-gradient(90deg, #67c23a, #85ce61); }
.score-bar.yellow { background: linear-gradient(90deg, #e6a23c, #f5c543); }
.score-bar.red { background: linear-gradient(90deg, #f56c6c, #f78989); }
/* GEO factor checklist */
.geo-factor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
margin-top: 10px;
}
.geo-factor-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-bg);
border-radius: var(--radius-sm);
font-size: 13px;
}
.geo-factor-item .check {
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.geo-factor-item .check.pass { background: #67c23a; color: white; }
.geo-factor-item .check.fail { background: #f56c6c; color: white; }
/* Platform comparison bars */
.platform-bar-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.platform-bar-label {
width: 80px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-regular);
flex-shrink: 0;
text-align: right;
}
.platform-bar-track {
flex: 1;
height: 26px;
background: #f0f5ff;
border-radius: 6px;
overflow: hidden;
position: relative;
}
.platform-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.8s var(--ease-out);
display: flex;
align-items: center;
padding-left: 10px;
font-size: 12px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.platform-bar-fill.zhihu { background: linear-gradient(90deg, #667eea, #764ba2); }
.platform-bar-fill.wechat { background: linear-gradient(90deg, #07c160, #30b052); }
.platform-bar-fill.xiaohongshu { background: linear-gradient(90deg, #ff2442, #ff6b81); }
.platform-bar-value {
width: 70px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
flex-shrink: 0;
}
/* Engagement bar */
.engagement-bar-wrap {
width: 60px;
height: 6px;
background: #ebeef5;
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
}
.engagement-bar-fill {
height: 100%;
border-radius: 3px;
}
/* Position badge */
.pos-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
}
.pos-badge.top10 { background: #f0f9ff; color: #67c23a; }
.pos-badge.top30 { background: #fdf6ec; color: #e6a23c; }
.pos-badge.low30 { background: #fef0f0; color: #f56c6c; }
/* Tab styling */
.el-tabs__header { margin-bottom: 0; }
.el-tabs__nav-scroll { padding-left: 0; }
/* Sub-score mini card */
.subscore-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.subscore-chip {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-regular);
}
.subscore-chip strong {
color: var(--color-text-primary);
font-weight: 700;
}
@media (max-width: 768px) {
.insight-hero { padding: 20px; }
.insight-hero h2 { font-size: 18px; }
.insight-summary-item .val { font-size: 22px; }
.platform-bar-label { width: 60px; font-size: 12px; }
.platform-bar-value { width: 50px; font-size: 12px; }
}
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="数据洞察" :username="currentUser.username" :is-admin="isAdmin" current-page="insights" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<!-- Hero Header -->
<div class="insight-hero page-fade">
<h2><el-icon style="vertical-align:-2px;"><IconData /></el-icon> 数据洞察</h2>
<p>内容表现 · 搜索排名 · GEO 就绪度 · 平台对比</p>
<div class="insight-summary">
<div class="insight-summary-item">
<span class="val">{{ summary.total_published }}</span>
<span class="lbl">已发布</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.total_views }}</span>
<span class="lbl">总阅读</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.total_likes }}</span>
<span class="lbl">总点赞</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.avg_engagement }}%</span>
<span class="lbl">平均互动率</span>
</div>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" type="border-card" style="background:transparent;box-shadow:none;">
<!-- Tab 1: 内容概览 -->
<el-tab-pane label="内容概览" name="overview">
<div class="page-fade">
<!-- Summary cards -->
<el-row :gutter="16" style="margin-bottom:20px;">
<el-col :xs="12" :sm="8" :md="6" v-for="(item, key) in overviewCards" :key="key">
<el-card shadow="hover" style="margin-bottom:12px;text-align:center;border-radius:var(--radius-md);">
<div style="font-size:28px;font-weight:800;color:{{ item.color }};">{{ item.value }}</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:4px;">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- Top performing topics -->
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconStar /></el-icon> 热门选题 TOP10
</span>
</div>
</template>
<div v-if="topTopics.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconData /></el-icon>
暂无数据
</div>
<div v-else>
<div class="card-table">
<el-table :data="topTopics" stripe size="small">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="阅读" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_views) }}</template>
</el-table-column>
<el-table-column label="点赞" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_likes) }}</template>
</el-table-column>
<el-table-column label="互动率" width="100" align="center">
<template #default="scope">
<div style="display:flex;align-items:center;justify-content:center;gap:4px;">
<div class="engagement-bar-wrap">
<div class="engagement-bar-fill" :style="{width: Math.min(scope.row.engagement_pct || 0, 100)+'%', background: engagementColor(scope.row.engagement_pct)}"></div>
</div>
<span>{{ (scope.row.engagement_pct || 0).toFixed(1) }}%</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="(t, i) in topTopics" :key="t.topic_id" class="card-list-mobile card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:700;color:var(--color-primary);">#{{ i+1 }}</span>
<span style="font-size:13px;color:var(--color-text-secondary);">{{ formatNum(t.total_views) }} 阅读</span>
</div>
<div style="font-weight:600;font-size:14px;margin-bottom:8px;">{{ t.title }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>点赞 {{ formatNum(t.total_likes) }}</span>
<span>互动率 {{ (t.engagement_pct||0).toFixed(1) }}%</span>
</div>
</div>
</div>
</div>
</el-card>
<!-- Recent metrics -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconHistory /></el-icon> 最近数据
</span>
</template>
<div class="card-table">
<el-table :data="recentMetrics" stripe size="small">
<el-table-column label="选题" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.topic_id }}</template>
</el-table-column>
<el-table-column label="平台" width="100">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="阅读" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.views) }}</template>
</el-table-column>
<el-table-column label="点赞" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.likes) }}</template>
</el-table-column>
<el-table-column label="评论" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.comments) }}</template>
</el-table-column>
<el-table-column label="分享" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.shares) }}</template>
</el-table-column>
<el-table-column label="互动率" width="90" align="center">
<template #default="scope">{{ (scope.row.engagement_rate||0).toFixed(1) }}%</template>
</el-table-column>
<el-table-column label="更新时间" width="150">
<template #default="scope">{{ formatDate(scope.row.last_fetched) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="m in recentMetrics" :key="m.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">{{ m.topic_id }} · {{ platformName(m.platform) }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>阅读 {{ formatNum(m.views) }}</span>
<span>点赞 {{ formatNum(m.likes) }}</span>
<span>评论 {{ formatNum(m.comments) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-top:4px;">
<span>分享 {{ formatNum(m.shares) }}</span>
<span>互动率 {{ (m.engagement_rate||0).toFixed(1) }}%</span>
<span>{{ formatDate(m.last_fetched) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 2: 搜索排名 -->
<el-tab-pane label="搜索排名" name="rankings">
<div class="page-fade">
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 关键词排名追踪
</span>
<div style="display:flex;gap:10px;align-items:center;">
<el-select v-model="rankingEngineFilter" placeholder="搜索引擎" size="small" clearable style="width:140px;">
<el-option label="全部" value=""></el-option>
<el-option label="Bing" value="bing"></el-option>
<el-option label="Google" value="google"></el-option>
<el-option label="百度" value="baidu"></el-option>
<el-option label="Geo" value="geo"></el-option>
</el-select>
<el-button size="small" @click="fetchRankings" :loading="loadingRankings">刷新</el-button>
</div>
</div>
</template>
<!-- Overview mini stats -->
<div v-if="rankingStats.total > 0" style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:var(--color-primary);">{{ rankingStats.total }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">总记录</div>
</div>
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:#67c23a;">{{ rankingStats.top10 }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">Top 10</div>
</div>
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:#e6a23c;">{{ rankingStats.aiCited }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">AI 引用</div>
</div>
</div>
<div v-if="loadingRankings" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="rankings.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconSearch /></el-icon>
暂无排名数据
</div>
<div v-else class="card-table">
<el-table :data="filteredRankings" stripe size="small">
<el-table-column label="关键词" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.keyword }}</template>
</el-table-column>
<el-table-column label="引擎" width="80" align="center">
<template #default="scope">
<el-tag size="small" type="info">{{ engineShort(scope.row.search_engine) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="排名" width="80" align="center">
<template #default="scope">
<span v-if="scope.row.position !== null && scope.row.position !== undefined"
class="pos-badge"
:class="scope.row.position <= 10 ? 'top10' : scope.row.position <= 30 ? 'top30' : 'low30'">
#{{ scope.row.position }}
</span>
<span v-else style="color:var(--color-text-secondary);font-size:12px;">-</span>
</template>
</el-table-column>
<el-table-column label="AI 引用" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.ai_cited" size="small" type="success"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="100" show-overflow-tooltip>
<template #default="scope">{{ scope.row.ai_source || '-' }}</template>
</el-table-column>
<el-table-column label="检查日期" width="110">
<template #default="scope">{{ formatDate(scope.row.checked_at) }}</template>
</el-table-column>
</el-table>
</div>
<div v-else class="card-list-mobile">
<div v-for="r in filteredRankings" :key="r.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">{{ r.keyword }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>引擎: {{ engineShort(r.search_engine) }}</span>
<span>日期: {{ formatDate(r.checked_at) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>排名: <span class="pos-badge" :class="r.position<=10?'top10':r.position<=30?'top30':'low30'" v-if="r.position!=null">#{{ r.position }}</span><span v-else>-</span></span>
<span>AI: <el-tag size="small" :type="r.ai_cited?'success':'info'">{{ r.ai_cited?'是':'否' }}</el-tag></span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 3: GEO 就绪度 -->
<el-tab-pane label="GEO 就绪度" name="geo">
<div class="page-fade">
<!-- GEO Overview -->
<el-row :gutter="16" style="margin-bottom:20px;">
<el-col :xs="12" :sm="8" :md="6" v-for="(item, key) in geoSummaryCards" :key="key">
<el-card shadow="hover" style="margin-bottom:12px;text-align:center;border-radius:var(--radius-md);">
<div style="font-size:28px;font-weight:800;color:{{ item.color }};">{{ item.value }}</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:4px;">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- GEO Factors checklist -->
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconCheck /></el-icon> GEO 优化检查清单
</span>
</template>
<div class="geo-factor-grid">
<div class="geo-factor-item" v-for="factor in geoFactors" :key="factor.key">
<span class="check" :class="factor.pass ? 'pass' : 'fail'">{{ factor.pass ? '&#10003;' : '&times;' }}</span>
<span>{{ factor.label }}</span>
</div>
</div>
</el-card>
<!-- GEO Scores table -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconTrendCharts /></el-icon> 文章 GEO 评分
</span>
<el-radio-group v-model="geoSortBy" size="small" @change="fetchGeoScores">
<el-radio-button label="desc">最低优先</el-radio-button>
<el-radio-button label="asc">最高优先</el-radio-button>
</el-radio-group>
</div>
</template>
<div v-if="loadingGeo" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="geoScores.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconTrendCharts /></el-icon>
暂无 GEO 评分数据
</div>
<div v-else class="card-table">
<el-table :data="sortedGeoScores" stripe size="small">
<el-table-column label="文章" min-width="180" show-overflow-tooltip>
<template #default="scope">{{ scope.row.article_id || scope.row.topic_id || '-' }}</template>
</el-table-column>
<el-table-column label="平台" width="80" align="center">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="总分" width="100" align="center">
<template #default="scope">
<span :style="{fontWeight:700,color: scoreColor(scope.row.total_score)}">{{ scope.row.total_score }}/100</span>
</template>
</el-table-column>
<el-table-column label="评分条" width="140">
<template #default="scope">
<div class="score-bar-wrap">
<div class="score-bar" :class="scoreBarClass(scope.row.total_score)" :style="{width: (scope.row.total_score||0)+'%'}"></div>
</div>
</template>
</el-table-column>
<el-table-column label="Schema" width="70" align="center">
<template #default="scope">
<el-tag v-if="scope.row.has_schema" size="small" type="success"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</template>
</el-table-column>
<el-table-column label="可读性" width="90" align="center">
<template #default="scope">
<span>{{ scope.row.readability_score || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="标题结构" width="100" align="center">
<template #default="scope">
<span>{{ scope.row.heading_structure_score || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="检查时间" width="140">
<template #default="scope">{{ formatDate(scope.row.checked_at) }}</template>
</el-table-column>
</el-table>
</div>
<div v-else class="card-list-mobile">
<div v-for="g in sortedGeoScores" :key="g.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:700;font-size:14px;">{{ g.article_id || g.topic_id || '-' }}</span>
<span style="font-weight:800;font-size:16px;" :style="{color: scoreColor(g.total_score)}">{{ g.total_score }}/100</span>
</div>
<div class="score-bar-wrap" style="margin-bottom:8px;">
<div class="score-bar" :class="scoreBarClass(g.total_score)" :style="{width: (g.total_score||0)+'%'}"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);flex-wrap:wrap;gap:4px;">
<span>平台: {{ platformName(g.platform) }}</span>
<span>Schema: {{ g.has_schema?'有':'无' }}</span>
<span>可读性: {{ g.readability_score||0 }}</span>
<span>标题: {{ g.heading_structure_score||0 }}</span>
</div>
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:6px;">{{ formatDate(g.checked_at) }}</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 4: 平台对比 -->
<el-tab-pane label="平台对比" name="comparison">
<div class="page-fade">
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconMonitor /></el-icon> 平台数据对比
</span>
</template>
<div v-if="loadingComparison" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="platformData.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconMonitor /></el-icon>
暂无平台数据
</div>
<div v-else>
<!-- Bar chart comparison -->
<div style="margin-bottom:24px;">
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">阅读对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.total_views, maxViews)}">
{{ formatNum(p.total_views) }}
</div>
</div>
</div>
</div>
<div style="margin-bottom:24px;">
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">点赞对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.total_likes, maxLikes)}">
{{ formatNum(p.total_likes) }}
</div>
</div>
</div>
</div>
<div>
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">文章数对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.count, maxCount)}">
{{ p.count }} 篇
</div>
</div>
</div>
</div>
</div>
</el-card>
<!-- Detailed metrics table -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<span style="font-weight:600;font-size:15px;">详细指标</span>
</template>
<div class="card-table">
<el-table :data="platformData" stripe size="small">
<el-table-column label="平台" width="120">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="文章数" width="80" align="center">
<template #default="scope">{{ scope.row.count }}</template>
</el-table-column>
<el-table-column label="总阅读" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_views) }}</template>
</el-table-column>
<el-table-column label="平均阅读" width="100" align="right">
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
</el-table-column>
<el-table-column label="总点赞" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_likes) }}</template>
</el-table-column>
<el-table-column label="平均点赞" width="100" align="right">
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
</el-table-column>
<el-table-column label="总评论" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_comments || 0) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="p in platformData" :key="p.platform" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:700;font-size:15px;margin-bottom:10px;">{{ platformName(p.platform) }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>文章数</span><span style="font-weight:600;">{{ p.count }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>总阅读</span><span style="font-weight:600;">{{ formatNum(p.total_views) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>平均阅读</span><span style="font-weight:600;">{{ Math.round(p.avg_views||0) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>总点赞</span><span style="font-weight:600;">{{ formatNum(p.total_likes) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>平均点赞</span><span style="font-weight:600;">{{ Math.round(p.avg_likes||0) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const InsightsApp = {
data() {
return {
currentUser: { username: '' },
isAdmin: false,
activeTab: 'overview',
// Data
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
topTopics: [],
recentMetrics: [],
rankings: [],
rankingEngineFilter: '',
loadingRankings: false,
geoScores: [],
loadingGeo: false,
geoSortBy: 'desc',
platformData: [],
loadingComparison: false,
maxViews: 1,
maxLikes: 1,
maxCount: 1,
}
},
computed: {
summary() {
return {
total_published: this.dashboard.total_published || 0,
total_views: this.dashboard.total_views || 0,
total_likes: this.dashboard.total_likes || 0,
avg_engagement: (this.dashboard.avg_engagement_rate || 0).toFixed(1),
};
},
overviewCards() {
const s = this.summary;
return [
{ label: '已发布', value: s.total_published, color: 'var(--color-primary)' },
{ label: '总阅读', value: this.formatNum(s.total_views), color: '#67c23a' },
{ label: '总点赞', value: this.formatNum(s.total_likes), color: '#e6a23c' },
{ label: '平均互动率', value: s.avg_engagement + '%', color: '#f56c6c' },
];
},
filteredRankings() {
if (!this.rankingEngineFilter) return this.rankings;
return this.rankings.filter(r => r.search_engine === this.rankingEngineFilter);
},
rankingStats() {
const data = this.filteredRankings;
return {
total: data.length,
top10: data.filter(r => r.position != null && r.position <= 10).length,
aiCited: data.filter(r => r.ai_cited).length,
};
},
geoSummaryCards() {
const total = this.geoScores.length;
if (total === 0) return [
{ label: '总评分', value: '-', color: 'var(--color-primary)' },
{ label: '平均分', value: '-', color: '#67c23a' },
{ label: '高分(>70)', value: '0', color: '#67c23a' },
{ label: '低分(<40)', value: '0', color: '#f56c6c' },
];
const scores = this.geoScores.map(g => g.total_score || 0);
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const high = scores.filter(s => s > 70).length;
const low = scores.filter(s => s < 40).length;
return [
{ label: '总评分', value: total, color: 'var(--color-primary)' },
{ label: '平均分', value: avg.toFixed(1), color: '#67c23a' },
{ label: '高分(>70)', value: high, color: '#67c23a' },
{ label: '低分(<40)', value: low, color: '#f56c6c' },
];
},
geoFactors() {
if (this.geoScores.length === 0) return [];
const all = this.geoScores;
return [
{ key: 'schema', label: '结构化数据 (Schema)', pass: all.some(g => g.has_schema) },
{ key: 'faq', label: 'FAQ 格式', pass: all.some(g => g.has_faq_format) },
{ key: 'howto', label: 'HowTo 格式', pass: all.some(g => g.has_howto_format) },
{ key: 'citations', label: '数据引用', pass: all.some(g => g.has_citations) },
{ key: 'readability', label: '可读性 > 60', pass: all.some(g => (g.readability_score || 0) > 60) },
{ key: 'headings', label: '标题结构 > 60', pass: all.some(g => (g.heading_structure_score || 0) > 60) },
{ key: 'wordcount', label: '字数 > 1000', pass: all.some(g => (g.word_count || 0) > 1000) },
{ key: 'geoscore', label: 'GEO 总分 > 60', pass: all.some(g => (g.total_score || 0) > 60) },
];
},
sortedGeoScores() {
const arr = [...this.geoScores];
arr.sort((a, b) => this.geoSortBy === 'asc'
? (b.total_score || 0) - (a.total_score || 0)
: (a.total_score || 0) - (b.total_score || 0)
);
return arr;
},
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.loadAllTabs();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
async loadAllTabs() {
await Promise.all([
this.fetchDashboard(),
this.fetchRankings(),
this.fetchGeoScores(),
this.fetchPlatformData(),
]);
},
// --- Dashboard / Overview ---
async fetchDashboard() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/dashboard?days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.dashboard = await res.json();
this.topTopics = (this.dashboard.top_topics || []).map(t => {
const views = t.total_views || 0;
const likes = t.total_likes || 0;
return { ...t, engagement_pct: views > 0 ? ((likes / views) * 100) : 0 };
});
this.recentMetrics = this.dashboard.recent_metrics || [];
}
} catch (e) { console.error('Dashboard error:', e); }
},
// --- Rankings ---
async fetchRankings() {
this.loadingRankings = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/seo/rankings?limit=200&days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
const json = await res.json();
this.rankings = json.data || json.rankings || [];
}
} catch (e) { console.error('Rankings error:', e); }
finally { this.loadingRankings = false; }
},
// --- GEO ---
async fetchGeoScores() {
this.loadingGeo = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/seo/geo/readiness?limit=200&days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
const json = await res.json();
this.geoScores = json.data || [];
}
} catch (e) { console.error('GEO error:', e); }
finally { this.loadingGeo = false; }
},
// --- Platform Comparison ---
async fetchPlatformData() {
this.loadingComparison = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/by-platform', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.platformData = await res.json();
this.maxViews = Math.max(...this.platformData.map(p => p.total_views || 1), 1);
this.maxLikes = Math.max(...this.platformData.map(p => p.total_likes || 1), 1);
this.maxCount = Math.max(...this.platformData.map(p => p.count || 1), 1);
}
} catch (e) { console.error('Platform data error:', e); }
finally { this.loadingComparison = false; }
},
// --- Helpers ---
formatNum(n) {
if (n == null) return '0';
if (typeof n === 'string') n = parseInt(n) || 0;
if (n >= 10000) return (n / 10000).toFixed(1) + 'w';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
},
formatDate(dt) {
if (!dt) return '-';
try {
const d = new Date(dt);
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
} catch { return dt; }
},
platformName(p) {
return { zhihu: '知乎', wechat: '微信', xiaohongshu: '小红书', default: p }[p] || p;
},
engineShort(e) {
return { bing: 'Bing', google: 'Google', baidu: '百度', geo: 'Geo' }[e] || (e || '-');
},
engagementRate(rate) {
return rate || 0;
},
engagementColor(rate) {
if (rate >= 5) return '#67c23a';
if (rate >= 2) return '#e6a23c';
return '#f56c6c';
},
scoreColor(score) {
if (score >= 70) return '#67c23a';
if (score >= 40) return '#e6a23c';
return '#f56c6c';
},
scoreBarClass(score) {
if (score >= 70) return 'green';
if (score >= 40) return 'yellow';
return 'red';
},
barWidth(value, max) {
if (!max || max === 0) return '0%';
const pct = Math.round((value / max) * 100);
return Math.max(pct, 2) + '%';
},
},
mounted() {
this.checkAuth();
},
};
const app = Vue.createApp(InsightsApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+181 -47
View File
@@ -142,6 +142,32 @@
.cta a { display: inline-block; background: #fff; color: #2563eb !important; font-size: 16px; font-weight: 700; padding: 14px 40px; border-radius: 10px; transition: all 0.25s; position: relative; } .cta a { display: inline-block; background: #fff; color: #2563eb !important; font-size: 16px; font-weight: 700; padding: 14px 40px; border-radius: 10px; transition: all 0.25s; position: relative; }
.cta a:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.15); } .cta a:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.15); }
/* ===== Contact / Trial ===== */
.contact { background: #0f172a; color: #fff; padding: 80px 24px; }
.contact .section-title { color: #fff; }
.contact .section-subtitle { color: rgba(255,255,255,0.7); }
.contact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; max-width: 1000px; margin: 0 auto; align-items: start; }
.contact-info { display: flex; flex-direction: column; gap: 20px; }
.contact-info-item { display: flex; gap: 14px; align-items: flex-start; }
.ci-icon { width: 44px; height: 44px; border-radius: 12px; background: linear-gradient(135deg, #2563eb, #7c3aed); display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.ci-label { font-size: 0.8rem; color: rgba(255,255,255,0.6); }
.ci-value { font-size: 1rem; font-weight: 600; color: #fff; }
.ci-value a { color: #fff; }
.contact-form { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 28px; }
.contact-form h3 { font-size: 1.1rem; margin-bottom: 18px; color: #fff; }
.form-row { margin-bottom: 16px; }
.form-row label { display: block; font-size: 13px; color: rgba(255,255,255,0.7); margin-bottom: 6px; }
.form-row input, .form-row textarea, .form-row select { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.06); color: #fff; font-size: 14px; box-sizing: border-box; font-family: inherit; }
.form-row input:focus, .form-row textarea:focus, .form-row select:focus { outline: none; border-color: #2563eb; }
.form-row textarea { resize: vertical; min-height: 80px; }
.contact-form .landing-btn-primary { width: 100%; text-align: center; border: none; cursor: pointer; font-size: 15px; }
.contact-note { font-size: 0.8rem; color: rgba(255,255,255,0.5); margin-top: 12px; text-align: center; }
.contact-note a { color: #93c5fd; }
.wecom-qr { margin-top: 8px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 16px; text-align: center; }
.wecom-qr img { width: 160px; height: 160px; object-fit: contain; border-radius: 12px; background: #fff; margin: 0 auto 10px; display: block; }
.wecom-qr-cap { font-size: 0.85rem; color: rgba(255,255,255,0.7); }
@media (max-width: 768px) { .contact-grid { grid-template-columns: 1fr; gap: 32px; } }
/* ===== Footer ===== */ /* ===== Footer ===== */
.landing-footer { background: #0f172a; color: #94a3b8; padding: 64px 24px 32px; } .landing-footer { background: #0f172a; color: #94a3b8; padding: 64px 24px 32px; }
.landing-footer-inner { max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 40px; } .landing-footer-inner { max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 40px; }
@@ -192,7 +218,8 @@
</div> </div>
<div class="landing-nav-actions"> <div class="landing-nav-actions">
<a href="/login.html" class="landing-btn-login">登录</a> <a href="/login.html" class="landing-btn-login">登录</a>
<a href="/login.html" class="landing-btn-primary">免费使用 →</a> <a href="#contact" class="landing-btn-login">联系销售</a>
<a href="#pricing" class="landing-btn-primary">免费试用 →</a>
<button class="landing-hamburger" id="hamburger" aria-label="菜单"> <button class="landing-hamburger" id="hamburger" aria-label="菜单">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</button> </button>
@@ -203,10 +230,11 @@
<a href="#features" class="mobile-link">产品功能</a> <a href="#features" class="mobile-link">产品功能</a>
<a href="#how-it-works" class="mobile-link">工作流程</a> <a href="#how-it-works" class="mobile-link">工作流程</a>
<a href="#platforms" class="mobile-link">支持平台</a> <a href="#platforms" class="mobile-link">支持平台</a>
<a href="#pricing" class="mobile-link">定价</a> <a href="#pricing" class="mobile-link">合作模式</a>
<a href="#faq" class="mobile-link">常见问题</a> <a href="#faq" class="mobile-link">常见问题</a>
<a href="#contact" class="mobile-link">联系销售</a>
<a href="/login.html" class="landing-btn-login" style="text-align:center;padding:12px;margin-top:8px;">登录</a> <a href="/login.html" class="landing-btn-login" style="text-align:center;padding:12px;margin-top:8px;">登录</a>
<a href="/login.html" class="landing-btn-primary">免费使用 →</a> <a href="#pricing" class="landing-btn-primary">免费用 →</a>
</div> </div>
</nav> </nav>
@@ -218,8 +246,8 @@
<h1>让内容在 AI 时代<br><span>主动找到你的客户</span></h1> <h1>让内容在 AI 时代<br><span>主动找到你的客户</span></h1>
<p>宇之然是一站式 AI 内容获客系统。自动生成知乎深度文、公众号叙事文、小红书干货贴——三平台差异化分发。通过 AI 搜索推荐(GEO)和独立站 SEO,让零品牌基础的团队也能持续获得精准流量。</p> <p>宇之然是一站式 AI 内容获客系统。自动生成知乎深度文、公众号叙事文、小红书干货贴——三平台差异化分发。通过 AI 搜索推荐(GEO)和独立站 SEO,让零品牌基础的团队也能持续获得精准流量。</p>
<div class="hero-actions"> <div class="hero-actions">
<a href="/login.html" class="landing-btn-primary">免费开始使用 →</a> <a href="#pricing" class="landing-btn-primary">免费用 →</a>
<a href="#features" class="landing-btn-secondary">了解产品功能</a> <a href="#contact" class="landing-btn-secondary">预约演示 / 联系销售</a>
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
<div class="hero-stat"><div class="hero-stat-num">3</div><div class="hero-stat-label">覆盖平台</div></div> <div class="hero-stat"><div class="hero-stat-num">3</div><div class="hero-stat-label">覆盖平台</div></div>
@@ -330,57 +358,53 @@
<!-- Pricing --> <!-- Pricing -->
<section id="pricing" class="pricing"> <section id="pricing" class="pricing">
<div class="container"> <div class="container">
<div style="text-align:center;"><span class="section-label">定价</span></div> <div style="text-align:center;"><span class="section-label">合作模式</span></div>
<h2 class="section-title">灵活的获客方案</h2> <h2 class="section-title">免费试用 · 私有化部署 · 源码售卖</h2>
<p class="section-subtitle">小规模试验到团队级运营,总有适合你的计划</p> <p class="section-subtitle">零成本体验到整套买断,三种方式总有一种适合你</p>
<div class="pricing-grid"> <div class="pricing-grid">
<div class="pricing-card"> <div class="pricing-card">
<div class="pricing-name">入门版</div> <div class="pricing-name">免费试用</div>
<div class="pricing-price">¥299 <span>/月</span></div> <div class="pricing-price">¥0 <span></span></div>
<div class="pricing-desc">个人创作者试水 AI 内容获客</div> <div class="pricing-desc">零成本体验完整创作闭环</div>
<ul class="pricing-features"> <ul class="pricing-features">
<li>每月 20 篇文章生产</li> <li>私有化试用版 / 预约 1v1 演示</li>
<li>限 5 篇 / 月文章生产</li>
<li>三平台差异化内容</li> <li>三平台差异化内容</li>
<li>基础 SEO 元数据注入</li> <li>基础 AI 味检测</li>
<li>搜索排名追踪(Bing</li> <li>基础 GEO 就绪度评分</li>
<li>GEO 就绪度基础评分</li> <li>社区与文档支持</li>
<li>标准合规审查</li>
</ul> </ul>
<a href="/login.html" class="landing-btn-secondary">开始试用</a> <a href="#contact" class="landing-btn-secondary">申请试用</a>
</div> </div>
<div class="pricing-card featured"> <div class="pricing-card featured">
<div class="pricing-badge">推荐</div> <div class="pricing-badge">主力推荐</div>
<div class="pricing-name">专业版</div> <div class="pricing-name">私有化部署</div>
<div class="pricing-price">¥799 <span>/月</span></div> <div class="pricing-price">¥29,800 <span>起 / 买断</span></div>
<div class="pricing-desc">内容团队稳定获客的日常工具</div> <div class="pricing-desc">一套独立部署,数据不出域,可白标</div>
<ul class="pricing-features"> <ul class="pricing-features">
<li>每月 100 篇文章生产</li> <li>完整功能 + 无限文章生产</li>
<li>三平台差异化内容</li> <li>部署在你的自有服务器</li>
<li>高级 SEO + GEO 元数据</li> <li>品牌白标(名称 / Logo / 主色)</li>
<li>搜索排名追踪(Bing + AI</li> <li>AI 味检测 + 合规人工闸门</li>
<li>AI 搜索引用检测</li> <li>GEO / SEO 全维度优化</li>
<li>GEO 就绪度完整评分(6维度)</li> <li>多用户 / 多租户隔离</li>
<li>全自动定时流水线</li> <li>年运维与更新订阅</li>
<li>多用户协作</li>
</ul> </ul>
<a href="/login.html" class="landing-btn-primary">开始试用</a> <a href="#contact" class="landing-btn-primary">获取报价</a>
</div> </div>
<div class="pricing-card"> <div class="pricing-card">
<div class="pricing-name">企业版</div> <div class="pricing-name">源码售卖</div>
<div class="pricing-price">¥2,499 <span>/</span></div> <div class="pricing-price">面议 <span>/ 授权</span></div>
<div class="pricing-desc">品牌化内容运营的完整方案</div> <div class="pricing-desc">一次性授权,自主迭代可控</div>
<ul class="pricing-features"> <ul class="pricing-features">
<li>不限文章数量</li> <li>完整源代码交付</li>
<li>所有平台差异化内容</li> <li>永久授权 + 年订阅支持</li>
<li>全维度 SEO + GEO 优化</li> <li>可二次开发 / 集成</li>
<li>全平台搜索排名追踪</li> <li>含私有化部署包</li>
<li>AI 搜索引用深度分析</li> <li>技术文档 + 部署指导</li>
<li>数据看板 + 定制报告</li> <li>商务定制谈价</li>
<li>专属 LLM 模型配置</li>
<li>API 接入</li>
<li>专属技术支持</li>
</ul> </ul>
<a href="/login.html" class="landing-btn-secondary">联系销售</a> <a href="#contact" class="landing-btn-secondary">联系销售</a>
</div> </div>
</div> </div>
</div> </div>
@@ -417,6 +441,22 @@
<button class="faq-q">我的数据安全吗?</button> <button class="faq-q">我的数据安全吗?</button>
<div class="faq-a">系统支持多租户隔离(组织级数据隔离),用户只能看到自己组织的数据。部署在你的自有服务器上,数据不出你的网络。支持 JWT 认证、HTTPS、管理员权限分级管理。</div> <div class="faq-a">系统支持多租户隔离(组织级数据隔离),用户只能看到自己组织的数据。部署在你的自有服务器上,数据不出你的网络。支持 JWT 认证、HTTPS、管理员权限分级管理。</div>
</div> </div>
<div class="faq-item">
<button class="faq-q">什么是私有化部署?和 SaaS 有什么区别?</button>
<div class="faq-a">私有化部署把整套平台装在你自己的服务器(或云主机)上,数据完全由你掌控、不出域,可彻底去掉"宇之然"品牌换成你自己的(白标)。相比公有云 SaaS,它更适合对数据合规、品牌自主有要求的 MCN、企业市场部和政务 / 行业机构。</div>
</div>
<div class="faq-item">
<button class="faq-q">可以买源码自己改吗?</button>
<div class="faq-a">可以。源码售卖提供完整源代码(后端 FastAPI + 前端 Vue3 CDN + 部署脚本),一次性授权后你拥有永久使用权,可自主二次开发、接入内部系统,并享受年度订阅技术支持与版本更新。</div>
</div>
<div class="faq-item">
<button class="faq-q">免费试用怎么申请?</button>
<div class="faq-a">无需信用卡。你可以申请私有化试用版(功能完整、限文章数)自行验证,或预约一次 1v1 在线演示,由我们带你走通选题→生成→AI 味检测→合规发布的完整流程。点击页面底部"申请试用"留下联系方式即可。</div>
</div>
<div class="faq-item">
<button class="faq-q">AI 生成的内容合规吗?</button>
<div class="faq-a">合规责任在内容发布方。平台内置"不代发"原则:所有 AI 稿必须经过真人校验、手动标记发布;同时提供 AI 套话检测与软硬问题分级,确保内容可读、可溯源、符合《AI 生成合成内容标识办法》的要求。</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -425,8 +465,83 @@
<section class="cta"> <section class="cta">
<div class="container" style="position:relative;"> <div class="container" style="position:relative;">
<h2>准备好了吗?</h2> <h2>准备好了吗?</h2>
<p>立即开始使用宇之然,让 AI 为你的内容获取精准流量</p> <p>免费试用、私有化部署或源码采购——选一种方式,让 AI 为你的内容获取精准流量</p>
<a href="/login.html">免费开始使用</a> <a href="#contact">获取专属方案</a>
</div>
</section>
<!-- Contact / Trial -->
<section id="contact" class="contact">
<div class="container">
<div style="text-align:center;"><span class="section-label">开始合作</span></div>
<h2 class="section-title">选一种方式,和我们聊聊</h2>
<p class="section-subtitle">无论是免费试用、私有化部署还是源码采购,留下信息我们会在 1 个工作日内联系你</p>
<div class="contact-grid">
<div class="contact-info">
<div class="contact-info-item">
<div class="ci-icon"></div>
<div>
<div class="ci-label">商务邮箱</div>
<div class="ci-value"><a href="mailto:contact@yuzhiran.com">contact@yuzhiran.com</a></div>
</div>
</div>
<div class="contact-info-item">
<div class="ci-icon">💬</div>
<div>
<div class="ci-label">微信 / 商务对接</div>
<div class="ci-value">扫码上方企业微信二维码添加</div>
</div>
</div>
<div class="contact-info-item">
<div class="ci-icon">📦</div>
<div>
<div class="ci-label">交付形式</div>
<div class="ci-value">私有化实例 / 源码授权 / 试用版</div>
</div>
</div>
<div class="contact-info-item">
<div class="ci-icon">🛡</div>
<div>
<div class="ci-label">合规承诺</div>
<div class="ci-value">数据不出域 · 不代发 · 人工闸门</div>
</div>
</div>
<div class="wecom-qr">
<img src="/automation/images/wecom-qr.jpg" alt="企业微信二维码">
<div class="wecom-qr-cap">企业微信扫码添加 · 备注:私有化 / 源码</div>
</div>
</div>
<form class="contact-form" id="trialForm">
<h3>预约咨询 / 申请试用</h3>
<div class="form-row">
<label>姓名 *</label>
<input type="text" name="name" required placeholder="您的称呼">
</div>
<div class="form-row">
<label>公司 / 团队</label>
<input type="text" name="company" placeholder="选填">
</div>
<div class="form-row">
<label>合作方式 *</label>
<select name="need" required>
<option value="免费试用">免费试用(私有化试用版 / 演示)</option>
<option value="私有化部署">私有化部署(买断 + 运维)</option>
<option value="源码售卖">源码售卖(授权 + 支持)</option>
<option value="其他">其他 / 还不确定</option>
</select>
</div>
<div class="form-row">
<label>联系方式 *</label>
<input type="text" name="contact" required placeholder="邮箱 / 微信 / 电话">
</div>
<div class="form-row">
<label>需求说明</label>
<textarea name="remark" placeholder="简单描述您的场景与规模(选填)"></textarea>
</div>
<button type="submit" class="landing-btn-primary">提交,获取方案</button>
<div class="contact-note">提交将唤起邮件客户端发往 <a href="mailto:contact@yuzhiran.com">contact@yuzhiran.com</a></div>
</form>
</div>
</div> </div>
</section> </section>
@@ -446,8 +561,8 @@
<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:contact@yuzhiran.com">联系我们</a>
</div> </div>
<div> <div>
<h4>法律</h4> <h4>法律</h4>
@@ -487,6 +602,25 @@
if (!isOpen) item.classList.add('open'); if (!isOpen) item.classList.add('open');
}); });
}); });
// Trial / contact form -> mailto
var trialForm = document.getElementById('trialForm');
if (trialForm) {
trialForm.addEventListener('submit', function(e) {
e.preventDefault();
var fd = new FormData(trialForm);
var name = (fd.get('name') || '').toString().trim();
var company = (fd.get('company') || '').toString().trim();
var need = (fd.get('need') || '').toString().trim();
var contact = (fd.get('contact') || '').toString().trim();
var remark = (fd.get('remark') || '').toString().trim();
var subject = '【宇之然】' + need + ' - ' + (name || '客户');
var body = '姓名:' + name + '\n公司/团队:' + (company || '(未填)') +
'\n合作方式:' + need + '\n联系方式:' + contact + '\n需求说明:' + (remark || '(未填)');
window.location.href = 'mailto:contact@yuzhiran.com?subject=' +
encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
});
}
</script> </script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -13,7 +13,7 @@
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); } .stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
.stat-value { font-size: 32px; font-weight: 700; } .stat-value { font-size: 32px; font-weight: 700; }
.stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; } .stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 24px; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-bottom: 24px; }
.chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; } .chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; }
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } .platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 768px) { @media (max-width: 768px) {
+81
View File
@@ -0,0 +1,81 @@
<!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>
html, body { margin: 0; height: 100%; }
[v-cloak] { display: none; }
#app { display: flex; flex-direction: column; height: 100vh; }
.studio-body { flex: 1; display: flex; flex-direction: column; min-height: 0; padding-top: var(--nav-height); }
.studio-tabs { flex: 0 0 auto; padding: 0 16px; background: var(--color-bg-raised); border-bottom: 1px solid var(--color-border); }
.studio-tabs .el-tabs__header { margin-bottom: 0; }
.studio-iframe-wrap { flex: 1; position: relative; min-height: 0; background: var(--color-bg); }
.studio-iframe-wrap iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: none; }
@media (max-width: 768px) { .studio-tabs { padding: 0 8px; } }
</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="studio" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="studio-body">
<div class="studio-tabs">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="选题" name="topics"></el-tab-pane>
<el-tab-pane label="内容生成" name="factory"></el-tab-pane>
<el-tab-pane label="文章管理" name="articles"></el-tab-pane>
</el-tabs>
</div>
<div class="studio-iframe-wrap">
<iframe v-if="tabs.topics" src="topics.html?embed=1" frameborder="0"></iframe>
<iframe v-if="tabs.factory" src="factory.html?embed=1" frameborder="0"></iframe>
<iframe v-if="tabs.articles" src="articles.html?embed=1" frameborder="0"></iframe>
</div>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.js"></script>
<script src="element-plus-icons.js"></script>
<script>
const { createApp } = Vue;
const StudioApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
activeTab: 'topics',
tabs: { topics: true, factory: false, articles: false },
};
},
methods: {
onTabChange(name) { this.tabs[name] = true; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
handleLogout() {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
window.location.href = '/login.html';
},
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(StudioApp);
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>
+12 -5
View File
@@ -68,7 +68,10 @@
--content-max-width: 1400px; --content-max-width: 1400px;
} }
/* ========== Dark Mode ========== */ /* ========== Dark Mode ==========
注意Element Plus 组件库为亮色主题未同步切换会导致弹窗/卡片内
自定义文字在深色变量下不可读当前明确采用亮色主题以保证对比度
待引入 EP 暗色变量(dark theme vars)后再启用完整暗色模式
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--color-bg: #1a1a2e; --color-bg: #1a1a2e;
@@ -76,12 +79,10 @@
--color-bg-subtle: #1a1a3e; --color-bg-subtle: #1a1a3e;
--color-border: #2a2a4e; --color-border: #2a2a4e;
--color-border-hover: #4a4a6e; --color-border-hover: #4a4a6e;
--color-text-primary: #e0e6ed; --color-text-primary: #e0e6ed;
--color-text-regular: #a0a6b0; --color-text-regular: #a0a6b0;
--color-text-secondary: #6b7280; --color-text-secondary: #6b7280;
--color-text-placeholder: #4a4a5e; --color-text-placeholder: #4a4a5e;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3); --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 2px 12px rgba(0,0,0,0.3); --shadow-md: 0 2px 12px rgba(0,0,0,0.3);
--shadow-lg: 0 8px 28px rgba(0,0,0,0.4); --shadow-lg: 0 8px 28px rgba(0,0,0,0.4);
@@ -90,6 +91,7 @@
.card { background: var(--color-bg-raised); } .card { background: var(--color-bg-raised); }
.el-table { --el-table-tr-bg: var(--color-bg-raised); --el-table-header-bg: var(--color-bg-subtle); } .el-table { --el-table-tr-bg: var(--color-bg-raised); --el-table-header-bg: var(--color-bg-subtle); }
} }
*/
/* ========== Reset & Base ========== */ /* ========== Reset & Base ========== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -119,6 +121,12 @@ body {
margin: 0 auto; margin: 0 auto;
min-height: calc(100vh - var(--nav-height)); min-height: calc(100vh - var(--nav-height));
} }
/* 嵌入模式(被创作工作台 iframe 加载时):去掉顶部导航留白 */
body.embed .main-content {
padding-top: 0 !important;
min-height: 100vh;
}
.content-area { flex: 1; padding: var(--spacing-md); overflow-y: auto; } .content-area { flex: 1; padding: var(--spacing-md); overflow-y: auto; }
/* ========== Card ========== */ /* ========== Card ========== */
@@ -128,7 +136,6 @@ body {
padding: var(--spacing-lg); padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg); margin-bottom: var(--spacing-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
overflow-x: auto;
transition: all 0.3s var(--ease-out); transition: all 0.3s var(--ease-out);
} }
@@ -405,7 +412,7 @@ body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index
} }
@media (min-width: 769px) { @media (min-width: 769px) {
.preview-iframe { min-height: 400px; } .preview-iframe { min-height: 400px; }
.preview-dialog-custom { position: relative; left: 90px; } .preview-dialog-custom { position: relative; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
+86 -21
View File
@@ -33,7 +33,7 @@
</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="topics" @navigate="redirectToPage" @logout="handleLogout"> <uni-nav v-if="!isEmbed" title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav> </uni-nav>
<div class="main-content"> <div class="main-content">
<main class="content-area"> <main class="content-area">
@@ -56,6 +56,7 @@
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button> <el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button>
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button> <el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button> <el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
<el-button size="default" :type="filterStatus === 'pending_publish' ? 'primary' : ''" @click="filterStatus = 'pending_publish'; fetchTopics()">待人工发布 ({{ statusStats.pendingPublish }})</el-button>
</div> </div>
<div style="margin-bottom:10px;"> <div style="margin-bottom:10px;">
<span class="search-toggle" @click="showSearch = !showSearch">🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}</span> <span class="search-toggle" @click="showSearch = !showSearch">🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}</span>
@@ -64,29 +65,30 @@
<div v-if="showSearch" class="search-area"> <div v-if="showSearch" class="search-area">
<el-form :model="searchForm" size="small" label-width="70px" @submit.prevent> <el-form :model="searchForm" size="small" label-width="70px" @submit.prevent>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="6"><el-form-item label="选题ID"><el-input v-model="searchForm.id" placeholder="如 A07" clearable @input="doSearch"></el-input></el-form-item></el-col> <el-col :xs="24" :sm="12" :md="6"><el-form-item label="选题ID"><el-input v-model="searchForm.id" placeholder="如 A07" clearable @input="doSearch"></el-input></el-form-item></el-col>
<el-col :span="10"><el-form-item label="标题"><el-input v-model="searchForm.title" placeholder="关键词" clearable @input="doSearch"></el-input></el-form-item></el-col> <el-col :xs="24" :sm="12" :md="8"><el-form-item label="标题"><el-input v-model="searchForm.title" placeholder="关键词" clearable @input="doSearch"></el-input></el-form-item></el-col>
<el-col :span="8"><el-form-item label="领域"><el-input v-model="searchForm.field" placeholder="如 科技前沿" clearable @input="doSearch"></el-input></el-form-item></el-col> <el-col :xs="24" :sm="12" :md="5"><el-form-item label="领域"><el-input v-model="searchForm.field" placeholder="如 科技前沿" clearable @input="doSearch"></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="12" :md="5"><el-form-item label="栏目"><el-select v-model="searchForm.series" clearable placeholder="全部" style="width:100%;" @change="doSearch"><el-option v-for="s in seriesOptions" :key="s" :label="s" :value="s"></el-option></el-select></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"><el-form-item label="创建时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="创建时间">
<el-date-picker v-model="searchForm.createdStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.createdStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.createdEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.createdEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
</el-form-item></el-col> </el-form-item></el-col>
<el-col :span="12"><el-form-item label="创作时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="创作时间">
<el-date-picker v-model="searchForm.generatedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.generatedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.generatedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.generatedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
</el-form-item></el-col> </el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"><el-form-item label="审查时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="审查时间">
<el-date-picker v-model="searchForm.reviewedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.reviewedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.reviewedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.reviewedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
</el-form-item></el-col> </el-form-item></el-col>
<el-col :span="12"><el-form-item label="发布时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="发布时间">
<el-date-picker v-model="searchForm.publishedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.publishedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.publishedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.publishedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
@@ -108,6 +110,17 @@
<el-table-column prop="status" label="状态" width="90"> <el-table-column prop="status" label="状态" width="90">
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template> <template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
</el-table-column> </el-table-column>
<el-table-column label="文章" width="140">
<template #default="scope">
<div v-if="scope.row.article_count > 0" style="display:flex;gap:4px;flex-wrap:wrap;">
<el-tag v-for="a in (scope.row.articles || [])" :key="a.platform" :type="a.status === 'published' ? 'success' : 'warning'" size="small" style="cursor:pointer;" @click="openTopicArticlePreview(scope.row, a)">
{{ platformShort(a.platform) }}
<span v-if="a.word_count" style="margin-left:2px;opacity:0.7;">{{ fmtWordCount(a.word_count) }}</span>
</el-tag>
</div>
<span v-else style="color:#c0c4cc;font-size:12px;"></span>
</template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="90"> <el-table-column prop="compliance_score" label="合规分" width="90">
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></template> <template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></template>
</el-table-column> </el-table-column>
@@ -122,6 +135,8 @@
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button> <el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button> <el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button>
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button> <el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button>
<el-button v-if="isStatus(scope.row, 'pending_publish')" size="small" type="success" @click="markPublished(scope.row)" style="padding:5px 8px;">标记发布</el-button>
<el-button size="small" :disabled="(scope.row.article_count || 0) === 0" @click="openSlop(scope.row)" style="padding:5px 8px;">检测</el-button>
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button> <el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button>
</div> </div>
</template> </template>
@@ -144,6 +159,10 @@
<div class="topic-card-tags"> <div class="topic-card-tags">
<el-tag size="small" type="info">{{ topic.field }}</el-tag> <el-tag size="small" type="info">{{ topic.field }}</el-tag>
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag> <el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
<el-tag v-for="a in (topic.articles || [])" :key="a.platform" size="small" :type="a.status === 'published' ? 'success' : 'warning'" style="cursor:pointer;" @click="openTopicArticlePreview(topic, a)">
{{ platformShort(a.platform) }}{{ a.word_count ? ' ' + fmtWordCount(a.word_count) : '' }}
</el-tag>
<el-tag v-if="topic.article_count === 0" size="small" type="info" effect="plain">无文章</el-tag>
</div> </div>
<div class="topic-card-meta"> <div class="topic-card-meta">
<div>创建: {{ formatDate(topic.created_at) }}</div> <div>创建: {{ formatDate(topic.created_at) }}</div>
@@ -156,6 +175,8 @@
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button> <el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button> <el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button>
<el-button v-show="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button> <el-button v-show="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button>
<el-button v-if="isStatus(topic, 'pending_publish')" size="small" type="success" @click="markPublished(topic)">标记发布</el-button>
<el-button size="small" :disabled="(topic.article_count || 0) === 0" @click="openSlop(topic)">检测</el-button>
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button> <el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
</div> </div>
</div> </div>
@@ -240,6 +261,10 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false"> <el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px;">
<template #title>AI 内容需真人校验后发布</template>
系统仅生成各平台发布稿并置为「待人工发布」,<b>不代发</b>。请在对应平台粘贴、核对 AI 声明与合规后手动发布,再回来点「标记已发布」。
</el-alert>
<div v-if="publishTopic"> <div v-if="publishTopic">
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div> <div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
@@ -320,6 +345,7 @@
const TopicsApp = { const TopicsApp = {
data() { data() {
return { return {
isEmbed: false,
isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
loadingTable: false, selectedTopicIds: [], filterStatus: '', loadingTable: false, selectedTopicIds: [], filterStatus: '',
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 }, stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
@@ -333,7 +359,7 @@ const TopicsApp = {
currentPage: 1, pageSize: 10, currentPage: 1, pageSize: 10,
sortField: 'created_at', sortOrder: 'ascending', sortField: 'created_at', sortOrder: 'ascending',
showSearch: false, showSearch: false,
searchForm: { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null }, searchForm: { id: '', title: '', field: '', series: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null },
createDialogVisible: false, createStep: 1, analyzing: false, saving: false, createDialogVisible: false, createStep: 1, analyzing: false, saving: false,
fieldOptions: [], fieldOptions: [],
createForm: { raw_input: '', reference_links: '', field_id: null }, createForm: { raw_input: '', reference_links: '', field_id: null },
@@ -341,26 +367,32 @@ const TopicsApp = {
} }
}, },
computed: { computed: {
seriesOptions() {
const s = new Set();
for (const t of this.topics) { if (t.series) s.add(t.series); }
return [...s].sort();
},
searchActive() { searchActive() {
const f = this.searchForm; const f = this.searchForm;
return !!(f.id || f.title || f.field || f.createdStart || f.createdEnd || f.generatedStart || f.generatedEnd || f.reviewedStart || f.reviewedEnd || f.publishedStart || f.publishedEnd); return !!(f.id || f.title || f.field || f.series || f.createdStart || f.createdEnd || f.generatedStart || f.generatedEnd || f.reviewedStart || f.reviewedEnd || f.publishedStart || f.publishedEnd);
}, },
filteredTopics() { filteredTopics() {
let list; let list;
if (this.filterStatus === 'today') list = this.todayTopics; if (this.filterStatus === 'today') list = this.todayTopics;
else if (!this.filterStatus) list = this.topics; else if (!this.filterStatus) list = this.topics;
else { else {
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] }; const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] };
const allowed = map[this.filterStatus] || [this.filterStatus]; const allowed = map[this.filterStatus] || [this.filterStatus];
list = this.topics.filter(t => allowed.includes(t.status)); list = this.topics.filter(t => allowed.includes(t.status));
} }
// 搜索过滤 // 搜索过滤
const sf = this.searchForm; const sf = this.searchForm;
if (sf.id || sf.title || sf.field || sf.createdStart || sf.createdEnd || sf.generatedStart || sf.generatedEnd || sf.reviewedStart || sf.reviewedEnd || sf.publishedStart || sf.publishedEnd) { if (sf.id || sf.title || sf.field || sf.series || sf.createdStart || sf.createdEnd || sf.generatedStart || sf.generatedEnd || sf.reviewedStart || sf.reviewedEnd || sf.publishedStart || sf.publishedEnd) {
list = list.filter(t => { list = list.filter(t => {
if (sf.id && !t.id.toLowerCase().includes(sf.id.toLowerCase())) return false; if (sf.id && !t.id.toLowerCase().includes(sf.id.toLowerCase())) return false;
if (sf.title && !t.title.toLowerCase().includes(sf.title.toLowerCase())) return false; if (sf.title && !t.title.toLowerCase().includes(sf.title.toLowerCase())) return false;
if (sf.field && !(t.field || '').toLowerCase().includes(sf.field.toLowerCase())) return false; if (sf.field && !(t.field || '').toLowerCase().includes(sf.field.toLowerCase())) return false;
if (sf.series && (t.series || '') !== sf.series) return false;
const inRange = (val, start, end) => { const inRange = (val, start, end) => {
if (!val) return !start && !end; if (!val) return !start && !end;
const d = val.slice(0, 10); const d = val.slice(0, 10);
@@ -400,8 +432,8 @@ const TopicsApp = {
return this.filteredTopics.slice(start, start + this.pageSize); return this.filteredTopics.slice(start, start + this.pageSize);
}, },
statusStats() { statusStats() {
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0 }; const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0, pendingPublish: 0 };
const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] }; const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] };
this.topics.forEach(t => { for (const [k, v] of Object.entries(aliases)) { if (v.includes(t.status)) { s[k]++; break; } } }); this.topics.forEach(t => { for (const [k, v] of Object.entries(aliases)) { if (v.includes(t.status)) { s[k]++; break; } } });
return s; return s;
}, },
@@ -422,8 +454,7 @@ const TopicsApp = {
}, },
currentPreviewBody() { currentPreviewBody() {
const html = this.platformContents[this.previewPlatform]; const html = this.platformContents[this.previewPlatform];
if (!html) return ''; if (!html || html.length < 50) return '<div style="padding:40px;text-align:center;color:#909399;"><p style="font-size:36px;margin:0 0 12px;">📝</p><p>该平台内容暂未生成或内容为空</p><p style="font-size:13px;margin-top:8px;">可点击上方「批量创作」重新生成,或在编辑模式下手动添加内容</p></div>';
if (html.length < 50) return '<div style="padding:40px;text-align:center;color:#909399;"><p style="font-size:36px;margin:0 0 12px;">📝</p><p>该平台内容暂未生成或内容为空</p><p style="font-size:13px;margin-top:8px;">可点击上方「批量创作」重新生成,或在编辑模式下手动添加内容</p></div>';
try { try {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
@@ -527,6 +558,24 @@ const TopicsApp = {
}); });
}, },
platformName(platform) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[platform] || platform; }, platformName(platform) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[platform] || platform; },
platformShort(platform) { return { zhihu: '知', wechat: '公', xiaohongshu: '红' }[platform] || platform; },
fmtWordCount(wc) { if (!wc) return ''; if (wc >= 1000) return (wc / 1000).toFixed(wc >= 10000 ? 0 : 1) + 'k'; return wc + ''; },
async openTopicArticlePreview(topic, article) {
this.previewTopic = topic;
this.previewPlatform = article.platform;
this.previewVisible = true;
this.platformContents = {};
this.previewImages = {};
const token = this.getToken();
if (!token) return;
try {
const r = await fetch(`/api/articles/${topic.id}/preview?platform=${article.platform}`, { headers: { 'Authorization': 'Bearer ' + token } });
if (r.ok) {
const d = await r.json();
if (d && d.html) { this.platformContents[article.platform] = d.html; if (d.images) this.previewImages[article.platform] = d.images; }
}
} catch (e) { console.error(`加载${article.platform}预览失败:`, e); }
},
copyContent(platform) { copyContent(platform) {
const html = this.platformContents[platform]; const html = this.platformContents[platform];
if (!html) { this.$message.warning('暂无内容可复制'); return; } if (!html) { this.$message.warning('暂无内容可复制'); return; }
@@ -619,13 +668,28 @@ const TopicsApp = {
method: 'POST', method: 'POST',
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected }) body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
}); });
this.$message.success(`发布完成: ${this.publishTopic.title}`); this.$message.success(data.message || `已生成发布稿,请在各平台校验后手动发布`);
this.publishDialogVisible = false; this.publishDialogVisible = false;
await this.fetchTopics(); await this.fetchTopics();
await this.fetchTodayCount(); await this.fetchTodayCount();
} catch (error) { this.$message.error(`发布失败: ${error.message}`); } } catch (error) { this.$message.error(`发布失败: ${error.message}`); }
finally { this.publishing = false; } finally { this.publishing = false; }
}, },
openSlop(topic) {
window.open('ai-slop.html?topic_id=' + encodeURIComponent(topic.id), '_blank');
},
async markPublished(topic) {
try {
await this.$confirm('确认已在各平台完成发布?此操作将把选题标记为「已发布」并关闭发布记录。', '标记发布', { type: 'warning' });
await this.api('/api/publishing/' + encodeURIComponent(topic.id) + '/mark-published', { method: 'POST' });
this.$message.success('已标记为已发布');
await this.fetchTopics();
} catch (e) {
if (e === 'cancel') return;
const msg = e?.response?.data?.detail || e?.message || '未知错误';
this.$message.error('操作失败: ' + msg);
}
},
async deleteTopic(id) { async deleteTopic(id) {
try { try {
await this.$confirm('确定删除选题 ' + id + '?将同时删除相关文章、发布记录等关联数据。', '提示', { type: 'warning' }); await this.$confirm('确定删除选题 ' + id + '?将同时删除相关文章、发布记录等关联数据。', '提示', { type: 'warning' });
@@ -767,8 +831,8 @@ const TopicsApp = {
this.currentPage = 1; this.currentPage = 1;
}, },
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; }, handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; }, redirectToPage(page) { const url = page.startsWith('/') ? page : '/' + page; if (this.isEmbed) window.top.location.href = url; else window.location.href = url; },
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; }, getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布', 'pending_publish': '待人工发布' }[status] || status; },
toggleCheck(id) { toggleCheck(id) {
const idx = this.selectedTopicIds.indexOf(id); const idx = this.selectedTopicIds.indexOf(id);
if (idx >= 0) { if (idx >= 0) {
@@ -796,12 +860,13 @@ const TopicsApp = {
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch (e) { return dateStr; } catch (e) { return dateStr; }
}, },
getStatusType(status) { return { 'pending': 'warning', 'review': 'danger', 'ready': 'success', 'published': 'info' }[status] || 'primary'; }, getStatusType(status) { return { 'pending': 'warning', 'review': 'danger', 'ready': 'success', 'published': 'info', 'pending_publish': 'warning' }[status] || 'primary'; },
isStatus(row, status) { const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] }; return map[status] ? map[status].includes(row.status) : row.status === status; } isStatus(row, status) { const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] }; return map[status] ? map[status].includes(row.status) : row.status === status; }
}, },
mounted() { mounted() {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; } if (!token) { window.location.href = '/login.html'; return; }
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const urlFilter = params.get('filter'); const urlFilter = params.get('filter');
if (urlFilter) this.filterStatus = urlFilter; if (urlFilter) this.filterStatus = urlFilter;
+46 -15
View File
@@ -107,7 +107,7 @@
.uni-nav-dropdown-divider {\ .uni-nav-dropdown-divider {\
height: 1px; background: #f0f0f0; margin: 4px 0;\ height: 1px; background: #f0f0f0; margin: 4px 0;\
}\ }\
@media (max-width: 768px) {\ @media (max-width: 1000px) {\
.uni-nav-items { display: none; }\ .uni-nav-items { display: none; }\
.uni-nav-username { display: none; }\ .uni-nav-username { display: none; }\
.uni-nav-hamburger { display: inline-flex; }\ .uni-nav-hamburger { display: inline-flex; }\
@@ -121,23 +121,25 @@
function getCurrentPage() { function getCurrentPage() {
var path = window.location.pathname; var path = window.location.pathname;
if (path === '/index.html') return 'dashboard'; if (path === '/' || path === '/index.html') return 'index.html';
var m = path.match(/\/(\w+)\.html/); var m = path.match(/\/([\w-]+)\.html$/);
return m ? m[1] : 'dashboard'; return m ? m[1] + '.html' : 'index.html';
} }
function navItems(isAdmin) { function navItems(isAdmin) {
var items = [ var items = [
{ key: 'dashboard', label: '仪表盘', page: 'index.html' }, { key: 'dashboard', label: '仪表盘', page: 'index.html' },
{ key: 'topics', label: '选题', page: 'topics.html' }, { key: 'studio', label: '创作工作台', page: 'studio.html' },
{ key: 'metrics', label: '数据', page: 'metrics.html' }, { key: 'insights', label: '数据', page: 'metrics.html' },
{ key: 'calendar', label: '日历', page: 'calendar.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' }, { key: 'tasks', label: '任务', page: 'tasks.html' },
{ key: 'campaigns', label: '外推营销', page: 'campaigns.html' },
{ key: 'ai-slop', label: 'AI味检测', page: 'ai-slop.html' },
]; ];
if (isAdmin) { if (isAdmin) {
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true }); items.push({ key: 'branding', label: '白标设置', page: 'branding.html', admin: true });
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
} }
return items; return items;
} }
@@ -162,6 +164,9 @@
pwdForm: { old_password: '', new_password: '', confirm: '' }, pwdForm: { old_password: '', new_password: '', confirm: '' },
pwdSubmitting: false, pwdSubmitting: false,
serverMenus: null, serverMenus: null,
brandName: '',
brandLogo: '',
primaryColor: '',
}; };
}, },
computed: { computed: {
@@ -169,15 +174,19 @@
if (this.serverMenus) { if (this.serverMenus) {
return this.serverMenus return this.serverMenus
.filter(function (m) { return m.is_active !== false; }) .filter(function (m) { return m.is_active !== false; })
.map(function (m) { return { key: m.name, label: m.name, page: m.path }; }); .map(function (m) { return { key: m.name, label: m.name, page: (m.path || '').replace(/^\//, '') }; });
} }
return navItems(this.isAdmin); return navItems(this.isAdmin);
}, },
page: function () { page: function () {
return this.currentPage || getCurrentPage(); return getCurrentPage();
}, },
showTitle: function () { showTitle: function () {
return this.title || '宇之然'; return this.brandName || this.title || '宇之然';
},
navStyle: function () {
if (!this.primaryColor) return {};
return { background: 'linear-gradient(135deg, ' + this.primaryColor + ', ' + this.primaryColor + 'cc)' };
}, },
}, },
methods: { methods: {
@@ -191,6 +200,24 @@
}.bind(this)) }.bind(this))
.catch(function () {}); .catch(function () {});
}, },
fetchBranding: function () {
fetch('/api/branding')
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (d) {
if (d.brand_name) this.brandName = d.brand_name;
if (d.brand_logo) this.brandLogo = d.brand_logo;
if (d.brand_primary_color) {
this.primaryColor = d.brand_primary_color;
document.documentElement.style.setProperty('--color-primary', d.brand_primary_color);
var s = document.getElementById('uni-nav-brand-style');
if (!s) { s = document.createElement('style'); s.id = 'uni-nav-brand-style'; document.head.appendChild(s); }
s.textContent = '.uni-nav-dropdown-item:hover,.uni-nav-dropdown-item.active{color:' + d.brand_primary_color + ';}' +
'.uni-nav-dropdown-item.active{background:' + d.brand_primary_color + '14;}' +
'.uni-nav-item.active{background:rgba(255,255,255,0.18);}';
}
}.bind(this))
.catch(function () {});
},
navigate: function (page) { navigate: function (page) {
this.dropdownOpen = false; this.profileOpen = false; this.dropdownOpen = false; this.profileOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; } if (this.onNavigate) { this.onNavigate(page); return; }
@@ -248,17 +275,21 @@
mounted: function () { mounted: function () {
document.addEventListener('click', this.closeAll); document.addEventListener('click', this.closeAll);
this.fetchMenus(); this.fetchMenus();
this.fetchBranding();
}, },
beforeUnmount: function () { beforeUnmount: function () {
document.removeEventListener('click', this.closeAll); document.removeEventListener('click', this.closeAll);
}, },
template: '\ template: '\
<nav class="uni-nav" style="position:relative;">\ <nav class="uni-nav" :style="navStyle">\
<div class="uni-nav-inner">\ <div class="uni-nav-inner">\
<div class="uni-nav-brand">{{ showTitle }}</div>\ <div class="uni-nav-brand">\
<img v-if="brandLogo" :src="brandLogo" style="height:24px;width:auto;margin-right:6px;vertical-align:middle;border-radius:4px;" />\
<span>{{ showTitle }}</span>\
</div>\
<div class="uni-nav-items">\ <div class="uni-nav-items">\
<button v-for="item in items" :key="item.key"\ <button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-item\', { active: page === item.key }]"\ :class="['uni-nav-item', { active: page === item.page }]"\
@click="navigate(item.page)">{{ item.label }}</button>\ @click="navigate(item.page)">{{ item.label }}</button>\
</div>\ </div>\
<div class="uni-nav-right">\ <div class="uni-nav-right">\
@@ -283,7 +314,7 @@
</div>\ </div>\
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\ <div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
<button v-for="item in items" :key="item.key"\ <button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\ :class="[\'uni-nav-dropdown-item\', { active: page === item.page }]"\
@click="navigate(item.page)">\ @click="navigate(item.page)">\
{{ item.label }}\ {{ item.label }}\
</button>\ </button>\
+157
View File
@@ -0,0 +1,157 @@
# 宇之然 (YuZhiRan) 市场与竞争调研简报 — 2026
> 调研范围:中国 AI 内容创作工具格局、平台算法/政策、GEO/AI搜索、创作者变现痛点、未满足蓝海
> 方法:web_search 深度检索(2025–2026 公开报道、平台公告、行业白皮书、创作者社区帖)
> 注意:部分数据为行业机构/营销机构口径(GEO 服务商软文偏多),已在文中标注"存疑"。
---
## 一、关键趋势 (Key Trends)
1. **"AI 辅助"被鼓励,"AI 托管/批量"被严打** — 2026 年所有主流平台完成从"自愿声明"到"强制标识 + 全链路管控"的切换。9 月 1 日《人工智能生成合成内容标识办法》实施后,小红书、微信、抖音、快手、B站、视频号均上线 AI 内容主动声明/强制打标。微信 4 月新版《公众账号行为规范》明确:禁止用 AI/脚本/接口替代真人全流程运营,违者限流/删文/封号。
2. **"人味/原创增量"成为新的流量分配核心指标** — 平台不再简单打击"AI 率",而是打击"无人工痕迹+同质化+低价值增量"的内容。抖音把"原创识别率"纳入核心分发指标;重复率超 25% 即判低质。
3. **GEO(生成式引擎优化)从概念走向服务市场** — 豆包/文心/通义/DeepSeek/Kimi 已成决策入口。出现大量 GEO 服务商(Bugoo 布谷、余小铁、上海逐天、GEORANK 等),但行业鱼龙混杂、软文泛滥。
4. **一人公司 / Agent 化个体创业爆发** — 2026 被视为"一人公司狂飙"年,AI Agent 工作流(Dify/Coze/n8n/OpenClaw)使个体具备团队能力。
5. **"副业刚需"成为内容创作的核心驱动力** — 全球年轻人"多职劳动(Multiple Jobs)"激增,中国 26-35 岁焦虑青年把自媒体当"退路"与"睡后收入"来源。
6. **长文→短视频的转化工具成熟但割裂** — 即梦/可灵/剪映/即创打通"脚本→分镜→成片",但多为独立工具,少有"一篇文章自动拆成三平台+一个短视频"的一体化方案。
7. **本地生活 + 私域成为小红书/视频号新红利** — 小红书变"内容+交易"闭环,搜索流量(DSO)是低成本获客洼地,强调真实、就近、素人。
---
## 二、创作者痛点 (Top Pain Points, Ranked)
> 按"普遍度 × 严重程度 × 与宇之然定位的相关度"排序
1. **【最痛】AI 内容被限流/封号,且规则不清** — "写了 3 个月原创阅读不过千""AI 率 50%+ 被限流""小红书关于 AI 限流的笔记超 3 万篇"。平台边界模糊(哪些是 AI 托管、哪些算辅助),创作者"不知道红线在哪"。→ 宇之然的"合规检测/去套话/人味优化 + 三平台差异化"正好命中。
2. **同质化与"AI 套话"** — 同模型+同 prompt 产出高度雷同;平台同质化识别已迭代数轮。"同质化比 AI 检测更要命"。常见 AI 句式("首先/其次/最后""值得一提的是")被普遍反感。
3. **多平台分发但要"说人话且各说各话"** — 知乎/微信/小红书调性、字数、人称完全不同,手动改三遍成本极高。绝大多数工具只给"一篇通用稿",或只做简单改写。→ 宇之然"三平台独立展开"是差异化卖点。
4. **选题焦虑 + 流量焦虑** — 红海赛道拥挤、冷启动难、爆款不可复制。需要"有数据价值但竞争者少的细分方向"。
5. **变现难 + 账号权重脆弱** — 流量主/广告/知识付费门槛高;一个违规号可能前功尽弃。
6. **合规/敏感词/版权风险** — 《标识办法》+ 平台敏感词 + 版权模糊(AI 生成图/声音侵权案例频出,如配音演员维权)。
7. **长文→短视频的转化断层** — 公众号/知乎沉淀的长文,想转抖音/视频号缺少顺滑工作流;现有工具(即梦/剪映)需手动搬运。
8. **GEO/SEO 可见性失控** — 品牌/个人希望被豆包/DeepSeek 引用,但不知道"怎么喂、喂给谁、是否被引",缺乏可监测手段。
---
## 三、竞品动向 (Competitor Moves)
### AI 写作/创作工具(通用层,已饱和)
- **豆包(字节)**:月活 5.3 亿(2026.3 披露),日均 112 亿次对话;完全免费、多模态、生态闭环(抖音/头条/即梦)。2026 下半年可能启动付费。
- **DeepSeek**:月活 1.27 亿,中文写作性价比之王,开源,被引用逻辑偏"推理+技术链"。
- **Kimi(月之暗面)**:超长上下文(200 万字),长文档/资料整理最强;UGC/知乎信源占比高(约 70% 来自 UGC)。
- **通义千问(阿里)**:商务/文档/电商,引用偏权威媒体+阿里生态。
- **文心一言/文小言(百度)**:搜索基因,引用偏百度系(百家号/百科)+ 媒体软文。
- **秘塔写作猫**:中文纠错/润色/降重老牌,千万级用户,免费策略。
- **笔灵 AI**:模板化营销文案,批量标准化。
- **腾讯元宝**:微信生态闭环,公众号文章占信源约 50%。
### AI 视频/多模态(增长快,但非宇之然核心)
- 即梦(字节, Seedance 2.0)、可灵(快手)、LibTV、万镜一刻、剪映一键成片、即创(抖音带货)、腾讯智影、Runway/Sora(海外)。
- 趋势:文生视频从"玩具"变"生产力",但排队/成本/真人肖像限制仍痛。
### Agent/工作流平台(宇之然的间接竞品)
- **扣子 Coze(字节)**:运营人用得最多,抖音/小红书爆款生成工作流,Coze+DeepSeek/飞书联动。
- **Dify / n8n / OpenClaw**:个体创业者搭自动化内容生产线("一行指令出稿出图推送")。
### GEO 服务商(新兴市场,混乱)
- Bugoo 布谷、余小铁 GEO、上海逐天科技、GEORANK、熊猫出海 GEO、白杨 SEO 等。
- 打法:媒体发稿 + 自媒体宣发 + 信源投喂 + 引用率监测。报价中高,交付质量参差。
- **洞察**:GEO 目前高度"服务化/人力化",尚无标准化 SaaS;这正是宇之然可切入的"外部站 SEO/GEO"模块机会。
### 平台自身(既是合作方也是裁判)
- 小红书"点点"AI、微信元宝、抖音即梦——平台把 AI 工具内嵌进生态,目的是"把创作者绑在自己闭环里",同时严打跨平台批量搬运。
---
## 四、平台算法/政策 2025–2026 变化(要点)
| 平台 | 关键变化 | 对创作者的含义 |
|---|---|---|
| 全网 | 《AI生成合成内容标识办法》9/1 实施;显式+隐式标识,不得删除/篡改 | AI 内容必须可识别;标注≠免罚 |
| 小红书 | 强制 AI 标识(2025.8);封禁纯 AI 托管账号;"2万篇笔记种草公式"失效;转向"以人为本/超级小内容" | 批量素人宣发死亡;要真实、小需求、场景化 |
| 微信/公众号 | 4 月新规:禁 AI 全流程替代真人;限流/删文/封号 | AI 只能辅助选题/润色/查错,真人表达不可替代 |
| 抖音/快手 | "原创识别率"入核心指标;重复率>25% 判低质;禁批量同质化 | 原创增量是分发关键 |
| 知乎 | "知乎直答"存在感弱;Kimi/DeepSeek 大量引用知乎内容 | 知乎长文对 GEO 权重高(被 Kimi 引用 70% |
| 视频号 | 严打非真人出镜 AI 视频;限推荐/私信/评论 | 虚构真人/假图高风险 |
| 网文(晋江/起点/番茄) | 反 AI:起点/番茄禁 AI,晋江限边界;部分甲方拉黑用 AI 大纲者 | 虚构创作领域对 AI 最不友好 |
**"原创"的新定义**:不再是"查重率<X%",而是"有独特观点/可验证数据/真实案例/价值增量"。单纯换同义词、调语序仍被判低价值。平台建议 AI 辅助占比 ≤25–30%,每百字至少 1 个情绪钩子。
---
## 五、GEO / AI 搜索优化 2026
- **已成真,但是 B 端(企业)先行,C 端(个人创作者)滞后**。企业级 GEO 需求明确("不被 AI 引用=在 AI 世界失语"),服务商众多;个人创作者大多还停留在"SEO/发小红书"阶段。
- **中文术语认知**:GEO(生成式引擎优化)一词已被采用,但大众仍混用"AI搜索优化/AISEO"。 awareness 在营销圈高,在普通自媒体人里中等。
- **是否可 monetize**:对企业已是刚需(获客/品牌);对个人创作者尚处"锦上添花",但"被豆包/DeepSeek 提到"正成为个人 IP 的新护城河。
- **各平台引用逻辑差异(关键,决定 GEO 打法)**
- 豆包:字节生态优先(抖音/头条/抖音百科),外部补新闻媒;重生活化。
- DeepSeek:媒体+UGC(知乎类)为主,重推理/技术链。
- Kimi~70% UGC,知乎占比极高 → **知乎长文对 GEO 权重被低估**
- 文心/百度:百度系(百家号/百科)+媒体软文。
- 元宝/微信:公众号文章占 ~50% 信源。
- 通义:新闻门户(网易/腾讯/搜狐)+阿里生态。
- **实操共识**:结构化内容、跨平台信息一致、原创数据/白皮书、权威媒体分发、持续"投喂"。
- **数据存疑**GEO 服务商宣称"2026 Q1 中国 GEO 市场 480 亿、同比 11 倍""超 75% 用户决策首选 AI"等数字来自 IDC/艾瑞转引的软文,**未独立核实,谨慎引用**。
---
## 六、变现痛点 & 焦虑青年(26-35)画像
- **副业成刚需**:全球"多职劳动"激增(美国 5.3% 劳动力身兼数职,连续 21 个月>5%;Z 世代 70% 想保持≥2 收入源)。国内因 AI 失业焦虑+生活成本,副业从"兴趣"变"生存手段"。
- **26-35 岁核心恐惧**:35 岁职场危机、被 AI 替代、房贷/教育/医疗刚性支出、失业即断流。
- **他们要的内容**:① 可落地的副业/一人公司方法论;② AI 工具实操("用 AI 当外挂不被淘汰");③ 个人 IP 从 0 到 1;④ 健康/抗倦怠/数字排毒("职业倦怠高峰提前到 25 岁");⑤ 数字游民/地理套利;⑥ 可持续生活/智性消费(B站《2026 年轻人消费趋势报告》:"智性沸腾",不为标签为自我价值付费)。
- **内容调性偏好**:情绪价值、真实体验、具体数据、反套路、去 AI 味——与宇之然"三平台差异化+去套话"高度契合。
---
## 七、未满足的蓝海 (Underserved Niches)
> 综合"需求明确 × 现有 AI 工具少 × 与宇之然能力可衔接"
1. **【最强】三平台"真·差异化"自动生成** — 绝大多数工具只给通用稿或简单改写;能按知乎(数据分析/你)、微信(个人叙事/我)、小红书(干货/emoji)各自独立成稿且合规检测的产品稀缺。宇之然已具备,是核心壁垒。
2. **【强】GEO 化的内容生产(个人/中小品牌版)** — 企业 GEO 被服务商瓜分,但"帮个人创作者/小店产出能被豆包/DeepSeek 引用的结构化内容"尚无 SaaS。可把"外部站 SEO/GEO"做成标准模块。
3. **【强】长文→短视频脚本自动拆条** — 公众号/知乎长文一键生成抖音/视频号口播脚本+分镜+标题,目前需手动跨即梦/剪映。一体化缺口明显。
4. **本地生活商家内容** — 小红书/视频号"搜索流量+POI+素人"红利大,但中小门店缺内容生产能力;模板化"真实到店/本地避坑/套餐推荐"批量生成工具少。
5. **企业号/员工 KOS 内容** — "门店号+员工号双号运营"需求上升,但企业级多账号合规内容工厂稀缺。
6. **知识付费 IP 的内容中台** — 从选题→大纲→三平台→私域钩子→课程化,缺一站式。
7. **私域转化内容** — 公域内容如何带私域钩子(企微/社群话术),现有工具基本不碰。
8. **出海/跨境内容** — 小红书 2026 推跨境电商;英文/多语种内容生成+本地化是空白,且宇之然现有架构易扩展。
9. **合规/去标识安全层** — 随着《标识办法》落地,"发布前 AI 率+同质化+敏感词+暗限流预测"的检测与改写,会成为所有 AI 写作工具的标配前置环节(ContentAny 类已起量,但嵌入生成流的少)。
---
## 八、对宇之然的策略含义(一句话)
宇之然的"三平台差异化生成 + 去套话/合规检测 + 外部站 GEO + 长文转短视频"组合,正好卡在 2026 年最痛的三条裂缝上:**限流焦虑、同质化、跨平台+跨形态分发成本**。优先把"三平台真差异化+合规护城河"做成可感知的差异化卖点,再叠加 GEO 与短视频拆条作为第二增长曲线。
---
## 九、信息来源 (Sources)
- 互联网平台现状:鼓励AI,限制AI36氪/新莓)https://m.36kr.com/p/3259734457430277
- 小红书上线 AI 内容强制标识(网易/见实)https://www.163.com/dy/article/K86VAJIQ053144S4.html
- 从 AI 监管到内容生态重构:平台重新定价"真实"36氪/DoNewshttps://m.36kr.com/p/3765102752252424
- 警惕"AI托管"侵蚀内容生态(中国经济网/证券日报)http://m.ce.cn/bwzg/202603/t20260316_2829584.shtml
- 2026 AI 写作工具实测(AI-Pickhttps://ai-pick.cn/article-42.html
- 2026 AI 写作工具推荐 10 款(AI工具宝箱)https://www.aitoollab.cn/articles/ai-writing-tools-2026-guide
- Kimi、文心启动付费(澎湃)https://www.thepaper.cn/newsDetail_forward_33101613
- 2026 一人公司狂飙:AI Agent 重构个体创业(掘金)https://juejin.cn/post/7611761768110817332
- GEO2026 ChatGPT/DeepSeek/豆包等 AI 搜索优化(CSDN/DeepSeek 社区)https://deepseek.csdn.net/6a420c80662f9a54cb858452.html
- AI 搜索推荐机制拆解:豆包/通义/文心/DeepSeek 引用逻辑(CSDNhttps://blog.csdn.net/yunFAA667/article/details/159512346
- 豆包 AI 搜索优化 GEO 指南(cnblogs/GEORANKhttps://www.cnblogs.com/georank/p/20680805
- 上海 GEO 服务商评测(cnblogshttps://www.cnblogs.com/zwcxkj/articles/20039051
- DeepSeek/豆包/Kimi/通义 引用来源占比(白杨 SEO/淘江湖)https://jianghu.taobao.com/detail/47301_29034882
- 自媒体变现指南(U客直谈)https://www.ukezhitan.com/article-u8D5Au94B1u8D44u8BAF/12316
- 2026 小红书本地生活商家趋势(cnblogshttps://www.cnblogs.com/nbxc2021/p/19688862
- 一文讲透小红书 2026 平台趋势(数英)https://www.digitaling.com/articles/1443427.html
- B站 2026 年轻人消费趋势报告"智性沸腾" https://www.fxbaogao.com/detail/5235669
- 全球年轻人热衷于搞副业(新浪/新京报)https://finance.sina.cn/tech/2026-06-19/detail-inicxkay2951341.d.html
- 35 岁职场危机(新浪财经)https://cj.sina.com.cn/articles/view/7879848901/1d5acf3c501901iun6
- 即梦/可灵/剪映 AI 视频工具对比(老达 AI 博客)https://laodad.com/tools/6334.html
- 2026 国内外 19 款 AI 视频生成工具(知乎)https://zhuanlan.zhihu.com/p/684932281
- AI 写小红书/公众号为什么限流(阿里云开发者社区)https://developer.aliyun.com/article/1724689
- 公众号 AI 写作规则与生存指南(简书)https://www.jianshu.com/p/5e899e17bbd3
- 知乎写手避开 AI 检测(CSDNhttps://blog.csdn.net/2611_96189011/article/details/161697476
> 说明:GEO 市场规模/引用率等具体数字多出自 GEO 服务商软文,行业口径不一,建议内部使用时标注"行业预估,未独立核实"。2026 年部分平台月活/政策细节来自媒体报道,可能存在口径差异。
+28 -23
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
内容采集趋势抓取 选题生成 存入选题库 内容采集管道趋势抓取 分析 选题生成 存入选题库
收集热点趋势信息经LLM分析后生成选题建议并存入数据库 聚焦AI与科技人文领域从RSS搜索和Web搜索采集信息经LLM分析后生成选题
""" """
import os import os
@@ -42,14 +42,11 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_CHINA_PAINS = { DEFAULT_CHINA_PAINS = {
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范", "AI工具实测": "AI工具选择困难、免费版功能太受限、ChatGPT不如国产好用、学习曲线陡峭、不知道哪些AI工具真正值得付费、看了测评还是不知道怎么用",
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长", "AI前沿资讯": "AI更新太快跟不上、论文太多看不完、不知道哪些是真突破、被营销号带偏、每天刷AI新闻但感觉什么都没学到",
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变", "未来工作方式": "AI裁员焦虑加剧、远程协作效率打折、职业方向看不清、技能更新压力大、35岁学AI来得及吗、转行AI需要什么技能",
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨", "科技人文反思": "AI伦理边界模糊、数据隐私被侵蚀、数字依赖越来越重、人机关系困惑、孩子该不该用AI写作业、AI生成的内容能信吗",
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难", "AI职场应用": "AI替代担心、岗位技能要求变了、职场竞争加剧、缺乏AI实操指导、怎么用AI写周报、怎么用AI做数据分析、AI提示词怎么写才有效",
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证"
} }
_cached_china_pains = None _cached_china_pains = None
@@ -87,22 +84,22 @@ def _get_china_pain(category: str) -> str:
@dataclass @dataclass
class SustainabilitySource: class SustainabilitySource:
"""可持续性信息""" """内容采集"""
name: str name: str
type: str # rss, web_search, web, api, local type: str # rss, web_search, web, api, local
url: Optional[str] = None # RSS URL 或通用链接 url: Optional[str] = None # RSS URL 或通用链接
update_frequency: str = "daily" update_frequency: str = "daily"
credibility: str = "medium" credibility: str = "medium"
focus: str = "可持续性" focus: str = "AI与科技"
keywords: Optional[List[str]] = None # 源特定关键词 keywords: Optional[List[str]] = None # 源特定关键词
query: Optional[str] = None # 搜索查询词(w eb_search类型用) query: Optional[str] = None # 搜索查询词(w eb_search类型用)
@dataclass @dataclass
class SustainabilityCase: class SustainabilityCase:
"""可持续性案例""" """内容案例"""
id: str id: str
country: str country: str
category: str # 子领域:零浪费生活、循环消费 category: str # 类别:AI前沿资讯、AI工具实测
title: str title: str
core_idea: str core_idea: str
data_facts: str data_facts: str
@@ -118,7 +115,7 @@ class SustainabilityCase:
@dataclass @dataclass
class SustainabilityTopic: class SustainabilityTopic:
"""可持续性选题""" """AI与科技选题"""
id: str id: str
title: str title: str
cases: List[str] # 关联的案例ID列表 cases: List[str] # 关联的案例ID列表
@@ -128,7 +125,7 @@ class SustainabilityTopic:
mvp_actions: str mvp_actions: str
estimated_length: int estimated_length: int
priority_score: float priority_score: float
field: str = "可持续生活系统" # 内容领域 field: str = "AI与效率" # 内容领域
format: str = "趋势洞察 + 实操指南" # 内容形式 format: str = "趋势洞察 + 实操指南" # 内容形式
core_concept: str = "" # 核心理念 core_concept: str = "" # 核心理念
audience_pain: str = "" # 受众痛点 audience_pain: str = "" # 受众痛点
@@ -150,7 +147,7 @@ class SustainabilityTopic:
self.platform_urls = {} self.platform_urls = {}
class SustainabilityCollector: class SustainabilityCollector:
"""可持续性内容集器""" """AI与科技内容集器"""
def __init__(self): def __init__(self):
self.load_config() self.load_config()
@@ -191,7 +188,7 @@ class SustainabilityCollector:
url=s.url or '', url=s.url or '',
query=s.query or '', query=s.query or '',
credibility=s.credibility or 'medium', credibility=s.credibility or 'medium',
focus=s.focus or '可持续性', focus=s.focus or 'AI与科技',
)) ))
logger.info(f"从DB加载 {len(cats)} 个类别, {len(self.sources)} 个信息源") logger.info(f"从DB加载 {len(cats)} 个类别, {len(self.sources)} 个信息源")
db.close() db.close()
@@ -209,7 +206,7 @@ class SustainabilityCollector:
if 'base_url' in source_info and 'url' not in source_info: if 'base_url' in source_info and 'url' not in source_info:
source_info['url'] = source_info.pop('base_url') source_info['url'] = source_info.pop('base_url')
source_info.setdefault('update_frequency', 'daily') source_info.setdefault('update_frequency', 'daily')
source_info.setdefault('focus', '可持续性') source_info.setdefault('focus', 'AI与科技')
source_info.setdefault('keywords', None) source_info.setdefault('keywords', None)
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords', 'query'} allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords', 'query'}
filtered_info = {k: v for k, v in source_info.items() if k in allowed_keys} filtered_info = {k: v for k, v in source_info.items() if k in allowed_keys}
@@ -379,7 +376,7 @@ class SustainabilityCollector:
'source_name': source.name 'source_name': source.name
}) })
logger.info(f"{source.name} 获取到 {len(articles)}可持续性文章") logger.info(f"{source.name} 获取到 {len(articles)} 篇文章")
return articles return articles
except Exception as e: except Exception as e:
@@ -720,8 +717,16 @@ class SustainabilityCollector:
return topic return topic
def map_category_to_field(self, category: str) -> str: def map_category_to_field(self, category: str) -> str:
"""案例类别映射到内容领域的字段""" """采集类别映射到内容领域"""
return "可持续生活系统" mapping = {
"AI前沿资讯": "AI与效率",
"AI工具实测": "AI与效率",
"AI趋势分析": "AI与效率",
"AI职场应用": "未来工作方式",
"科技人文反思": "科技人文",
"未来工作方式": "未来工作方式",
}
return mapping.get(category, "AI与效率")
def save_results(self): def save_results(self):
"""保存收集结果""" """保存收集结果"""
@@ -851,7 +856,7 @@ class SustainabilityCollector:
def run(self): def run(self):
"""主运行流程""" """主运行流程"""
logger.info("开始可持续性内容收集") logger.info("开始AI与科技内容收集")
existing_titles = self._get_existing_titles() existing_titles = self._get_existing_titles()
+3
View File
@@ -246,12 +246,14 @@ def save_article(topic_id: str, platform: str, html_content: str, *, title: str
from app.models import Article from app.models import Article
article_id = f"{platform}_{topic_id}" article_id = f"{platform}_{topic_id}"
existing = db.query(Article).filter(Article.id == article_id).first() existing = db.query(Article).filter(Article.id == article_id).first()
word_count = len(content) if content else (len(html_content) if html_content else 0)
if existing: if existing:
existing.html_content = html_content existing.html_content = html_content
if title: if title:
existing.title = title existing.title = title
if content: if content:
existing.content = content existing.content = content
existing.word_count = word_count
else: else:
article = Article( article = Article(
id=article_id, id=article_id,
@@ -261,6 +263,7 @@ def save_article(topic_id: str, platform: str, html_content: str, *, title: str
title=title, title=title,
content=content, content=content,
html_content=html_content, html_content=html_content,
word_count=word_count,
status="draft", status="draft",
compliance_score=None compliance_score=None
) )
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
采集类别/信息源/案例库 市场热点迁移
基于 2026 Q1-Q2 中文内容平台真实热点数据重构
"""
import sys, json
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
TOKEN = None
BASE = "http://localhost:8001"
def api(method, path, data=None):
import urllib.request, urllib.parse
headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(f"{BASE}{path}", data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
err = e.read().decode()
print(f" ERROR {method} {path}: {e.code} {err[:200]}")
return None
def login():
global TOKEN
import urllib.request
data = json.dumps({"username": "admin", "password": "admin123"}).encode()
req = urllib.request.Request(f"{BASE}/api/auth/login", data=data,
headers={"Content-Type": "application/json"}, method="POST")
resp = json.loads(urllib.request.urlopen(req).read())
TOKEN = resp["token"]
print("✓ Login OK")
def migrate():
print("=" * 60)
print("1. 删除过时采集类别")
print("=" * 60)
old = api("GET", "/api/admin/collector/categories") or []
for cat in old:
if cat["name"] in ("循环消费", "低碳出行", "干净饮食", "零浪费生活",
"绿色家电与节能", "碳普惠", "环保科技产品"):
api("DELETE", f"/api/admin/collector/categories/{cat['id']}")
print(f" ✗ 删除: {cat['name']} (id={cat['id']})")
print("\n" + "=" * 60)
print("2. 新增市场热点类别")
print("=" * 60)
NEW_CATEGORIES = [
{
"name": "AI生活化",
"description": "AI搭子/心理咨询/Vibe Coding/AI人格化 | AI从工具进化为生活搭子,商业笔记互动增长263%",
"search_query": "AI搭子 心理咨询 AI副业 AI人格 人机共生 Vibe Coding Agent 2026",
"sort_order": 1,
"pain_template": "AI工具太多不知道用哪个、担心被AI取代、想要AI融入生活但无从下手、渴望AI情感陪伴",
},
{
"name": "自我丰容",
"description": "打破惯性/精神避难所/工位改造 | #人你该丰容了浏览10亿+, #家的丰容计划浏览8亿+",
"search_query": "自我丰容 精神避难所 工位改造 家的丰容计划 独居 打破惯性 2026",
"sort_order": 2,
"pain_template": "生活陷入工位-食堂-床头循环、缺乏新鲜感、想要改变但不知从何下手、精神内耗严重",
},
{
"name": "适我主义",
"description": "适老/适懒/适宠/独居放飞 | 小红书2026年度居住趋势, 浏览20.4亿",
"search_query": "适我主义 适老化 适懒化 适宠化 独居 放飞 去家务化 动线 2026",
"sort_order": 3,
"pain_template": "房子不是为自己设计的、家务负担重、宠物/老人需求被忽视、独居生活单调",
},
{
"name": "情绪价值与心理健康",
"description": "边界感/柔软力/心理咨询/AI陪伴 | AI心理咨询HBR排名第一使用场景",
"search_query": "情绪价值 边界感 心理健康 心理咨询 焦虑管理 柔软力 反精致 2026",
"sort_order": 4,
"pain_template": "焦虑内耗无法排解、人际关系边界模糊、情绪得不到理解、渴望陪伴但社交疲惫",
},
{
"name": "她力量与主体性",
"description": "女性觉醒/悦己/主体性/爱自己 | #认知觉醒浏览16亿, #sheisblooming 7100万",
"search_query": "她力量 女性觉醒 主体性 悦己 爱你老己 上桌吃饭 独立女性 2026",
"sort_order": 5,
"pain_template": "被困在他人期待中、不敢为自己而活、职场/家庭双重压力、缺乏自我认同",
},
{
"name": "沉浸式新生活",
"description": "代入感/鸟门/文化力/体验式消费 | #观鸟浏览1.2亿, 商业笔记互动增长104%",
"search_query": "观鸟 代入感 沉浸式 文化力 文创 第一视角 体验式消费 2026",
"sort_order": 6,
"pain_template": "城市生活缺乏自然连接、精神空虚想找出口、日常单调需要新鲜体验",
},
{
"name": "科技人文",
"description": "AI与人的关系/数字伦理/技术温度 | AI从智能到共生,重塑人与技术的关系",
"search_query": "科技人文 AI伦理 数字生活 人与AI 科技温度 数字素养 2026",
"sort_order": 7,
"pain_template": "技术发展太快感到焦虑、担心失去人性连接、想理解AI但怕被取代",
},
{
"name": "数字游民与自由职业",
"description": "远程工作/一人企业/跨境机会/独立开发 | 全球35国数字游民签证,中国适用18国",
"search_query": "数字游民 自由职业 远程工作 一人企业 独立开发 跨境远程 2026",
"sort_order": 8,
"pain_template": "不想被办公室束缚、想尝试自由职业但不确定如何开始、找不到远程工作机会",
},
]
for cat in NEW_CATEGORIES:
result = api("POST", "/api/admin/collector/categories", cat)
if result:
print(f" ✓ 新增: {cat['name']} (id={result.get('id')})")
else:
# 可能已存在(重名检测)
print(f" ? {cat['name']}: 可能已存在")
print("\n" + "=" * 60)
print("3. 更新信息源")
print("=" * 60)
# 先删旧源(删除所有旧搜索源,保留RSS源)
old_sources = api("GET", "/api/admin/collector/sources") or []
for src in old_sources:
if src["source_type"] == "web_search":
api("DELETE", f"/api/admin/collector/sources/{src['id']}")
print(f" ✗ 删除搜索源: {src['name']}")
# 追加新的搜索源
NEW_SOURCES = [
{"name": "AI生活化搜索", "source_type": "web_search",
"query": "AI搭子 AI副业 Vibe Coding AI人格 人机共生 AI心理咨询 2026",
"credibility": "medium", "focus": "AI生活化", "sort_order": 10},
{"name": "自我丰容搜索", "source_type": "web_search",
"query": "自我丰容 精神避难所 家的丰容 独居生活 打破惯性 2026",
"credibility": "medium", "focus": "自我丰容", "sort_order": 11},
{"name": "适我主义搜索", "source_type": "web_search",
"query": "适我主义 适老化改造 适懒化 适宠化 独居 放飞 2026",
"credibility": "medium", "focus": "适我主义", "sort_order": 12},
{"name": "情绪价值搜索", "source_type": "web_search",
"query": "情绪价值 边界感 心理健康 焦虑管理 柔软力 心理咨询 2026",
"credibility": "medium", "focus": "情绪价值", "sort_order": 13},
{"name": "她力量搜索", "source_type": "web_search",
"query": "她力量 女性觉醒 主体性 悦己 独立女性 爱你老己 2026",
"credibility": "medium", "focus": "她力量", "sort_order": 14},
{"name": "沉浸式生活搜索", "source_type": "web_search",
"query": "观鸟 沉浸式体验 文化力 文创 代入感 体验消费 2026",
"credibility": "medium", "focus": "沉浸式生活", "sort_order": 15},
{"name": "科技人文搜索", "source_type": "web_search",
"query": "科技人文 AI伦理 数字生活 人与AI 科技温度 2026",
"credibility": "medium", "focus": "科技人文", "sort_order": 16},
{"name": "数字游民搜索", "source_type": "web_search",
"query": "数字游民 自由职业 远程工作 一人企业 独立开发 数字游民签证 2026",
"credibility": "medium", "focus": "数字游民", "sort_order": 17},
]
for src in NEW_SOURCES:
result = api("POST", "/api/admin/collector/sources", src)
if result:
print(f" ✓ 新增搜索源: {src['name']} (id={result.get('id')})")
print("\n" + "=" * 60)
print("4. 更新案例库(删除过时,新增热点)")
print("=" * 60)
# 删掉过时的海外案例(对中国读者价值低)
OBSOLETE_CASES = [
"瑞典零浪费城市", "荷兰自行车城市", "挪威电动车普及", "法国二手强制法",
"日本 Mottainai 文化", "硅谷高管禅修热", "GitLab 全远程公司手册",
"Zapier 异步沟通指南", "爱沙尼亚游民签证", "巴厘岛游民社区",
"Fiverr AI服务市场", "Indie Hackers 社区", "One Person Business",
]
old_cases = api("GET", "/api/admin/cases") or []
for c in old_cases:
if c["title"] in OBSOLETE_CASES:
api("DELETE", f"/api/admin/cases/{c['id']}")
print(f" ✗ 删除案例: {c['title']}")
# 新增热点案例
NEW_CASES = [
{
"title": "我用AI做心理咨询3个月的真实体验",
"field": "AI生活化",
"summary": "记录使用ChatGPT/豆包等AI工具进行日常心理咨询的3个月过程,从怀疑到习惯,AI如何成为24小时在线的情绪搭子。AI在情感支持场景的渗透率年增200%+。",
"key_metrics": "AI心理咨询HBR排名#1使用场景, AI商业笔记互动增长263%, 用户满意度4.6/5",
"date": "2026",
"source": "哈佛商业评论AI使用调研",
"source_url": "https://hbr.org/",
"credibility_rating": "⭐⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "上班族自我丰容:工位改造成精神避难所",
"field": "自我丰容",
"summary": "一位互联网大厂员工分享如何用500元预算把工位改造成个人精神避难所,包括植物角、香薰区、灵感墙。带动#家的丰容计划 话题浏览超8亿。",
"key_metrics": "#人你该丰容了 浏览10亿+, 工位改造相关笔记增长300%",
"date": "2026",
"source": "小红书家居薯",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "90后把家改造成宠物乐园:适宠化装修实录",
"field": "适我主义",
"summary": "一位90后屋主分享全屋适宠化改造:猫咪空中走廊、防抓沙发、隐藏猫砂柜。适宠化搜索增长680%,成为家居改造新热点。",
"key_metrics": "适宠化搜索增长680%, 适我主义话题浏览20.4亿",
"date": "2026",
"source": "小红书家居薯年度报告",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "我的AI副业月入过万:5个用API搭的小工具",
"field": "AI生活化",
"summary": "独立开发者分享如何用国产大模型API快速开发5款付费AI小工具,从选题到上线平均3天/个,实现月收入12000+。",
"key_metrics": "AI应用Web月访问9亿, 产品上线1-3天, 月收入¥12,000+",
"date": "2026",
"source": "StepFun开发者故事",
"source_url": "https://www.stepfun.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "30岁裸辞做数字游民:一年走过12国的真实账单",
"field": "数字游民与自由职业",
"summary": "前大厂产品经理裸辞成为数字游民,分享一年的收入来源(远程设计+自媒体)、各国生活成本对比、以及孤独感的应对方式。全球35国数字游民签证,中国适用18国。",
"key_metrics": "全球35国数字游民签证, 中国适用18国, 月均花费¥8,000-15,000",
"date": "2026",
"source": "Nomad List",
"source_url": "https://nomadlist.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐",
},
{
"title": "适老化改造救了我的婚姻:和爸妈同住的第三年",
"field": "适我主义",
"summary": "记录和父母同住三年间对房子进行的适老化改造:防滑地板、扶手动线、智能呼救、适老厨房。适老化搜索增长149%,居家养老从「基础安全」走向「身心适配」。",
"key_metrics": "适老化搜索增长149%, 居家养老关注度翻倍",
"date": "2026",
"source": "小红书年度居住趋势报告",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "不上班但从没停止工作:自由职业5年的财务模型",
"field": "数字游民与自由职业",
"summary": "一位自由职业5年的设计师分享收入结构(稳定客户70%+项目制20%+被动收入10%)、社保方案、客户获取渠道、以及「自由但不轻松」的真实状态。2026年中国灵活就业者已超2亿。",
"key_metrics": "中国灵活就业者超2亿, 自由职业满意度7.2/10, 平均收入转型期6-12月",
"date": "2026",
"source": "中国灵活就业发展报告",
"source_url": "https://www.stats.gov.cn/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "一年不和闺蜜吵架的秘诀:建立关系边界感",
"field": "情绪价值与心理健康",
"summary": "一位30+女性分享如何通过设立边界感改善友谊质量:学会说「不」、拒绝情感绑架、保护个人空间。边界感成为2026年最受关注的人际关系话题。",
"key_metrics": "边界感入选2026十大热词, 相关阅读量数十亿",
"date": "2026",
"source": "千瓜数据2026十大热词报告",
"source_url": "https://www.qian-gua.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "我在阳台养了一只鸟:城市观鸟的治愈力",
"field": "沉浸式新生活",
"summary": "一位在上海租房住的年轻人分享如何通过阳台观鸟找回生活实感。小红书#观鸟 近90天浏览1.2亿+,笔记增长70%+。观鸟成为年轻人追捧的新户外方式。",
"key_metrics": "#观鸟浏览1.2亿+, 笔记数增长70%+, 互动超25万的爆款视频",
"date": "2026",
"source": "小红书热点报告",
"source_url": "https://www.xiaohongshu.com/",
"credibility_rating": "⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
{
"title": "从「上桌吃饭」到「爱你老己」:我的主体性觉醒之路",
"field": "她力量与主体性",
"summary": "一位女性的自述:从讨好型人格到学会爱自己,经历了辞职、独居、心理咨询、建立支持网络。#认知觉醒浏览16亿,主体性成为女性最重要的精神课题。",
"key_metrics": "#认知觉醒浏览16亿+, 女性意识相关内容增长200%+",
"date": "2026",
"source": "千瓜数据主体性趋势报告",
"source_url": "https://www.qian-gua.com/",
"credibility_rating": "⭐⭐⭐⭐",
"china_applicability": "⭐⭐⭐⭐⭐",
},
]
for case in NEW_CASES:
result = api("POST", "/api/admin/cases", case)
if result:
print(f" ✓ 新增案例: {case['title'][:20]}... (id={result.get('id')})")
else:
print(f" ? {case['title'][:20]}... 可能已存在")
print("\n" + "=" * 60)
print("迁移完成!")
print("=" * 60)
if __name__ == "__main__":
login()
migrate()
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""
内容聚焦迁移脚本Phase 2
目标将数据库从"6领域分散"迁移到"3领域聚焦(AI与效率、科技人文、未来工作方式)"
执行python3 scripts/migrate_content_focus.py
"""
import sys, json, logging, datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# 新领域配置(与 initial_data.py 对齐)
NEW_FIELDS = [
{"name": "AI与效率", "icon": "🤖", "color": "#764ba2", "description": "AI工具、AI工作流、效率方法、前沿资讯", "sort_order": 1},
{"name": "科技人文", "icon": "🔬", "color": "#f56c6c", "description": "AI伦理、科技反思、数字生活、人机关系", "sort_order": 2},
{"name": "未来工作方式", "icon": "💼", "color": "#667eea", "description": "远程工作、AI时代职业转型、一人企业", "sort_order": 3},
]
# 旧领域名称 → 新领域名称映射
FIELD_MIGRATION_MAP = {
"可持续生活系统": "AI与效率",
"可持续生活": "AI与效率",
"数字游民": "未来工作方式",
"个人成长": "科技人文",
"个人知识工厂": "AI与效率",
"科技人文交叉": "科技人文",
}
# 新采集分类
NEW_CATEGORIES = [
{"name": "AI前沿资讯", "search_query": "AI 人工智能 大模型 2026 前沿", "description": "AI行业动态、大模型发布、技术突破", "sort_order": 1, "is_active": True},
{"name": "AI工具实测", "search_query": "AI工具 效率提升 AI工作流 2026", "description": "AI工具评测、效率工作流、实操指南", "sort_order": 2, "is_active": True},
{"name": "AI与职场", "search_query": "AI 裁员 职业转型 AI技能 远程工作 2026", "description": "AI对就业影响、职业转型、技能升级", "sort_order": 3, "is_active": True},
{"name": "AI生活化", "search_query": "AI陪伴 AI心理咨询 AI生活助手 2026", "description": "AI心理咨询/生活搭子/AI人格化", "sort_order": 4, "is_active": True},
{"name": "科技人文", "search_query": "AI伦理 数字生活 科技反思 人机关系 2026", "description": "AI伦理/数字生活反思/科技温度", "sort_order": 5, "is_active": True},
]
# 旧分类名称(待停用)
OLD_CATEGORY_NAMES = ["循环消费", "低碳出行", "干净饮食", "零浪费生活", "绿色家电与节能", "碳普惠", "环保科技产品"]
# 新平台 tags
NEW_PLATFORM_TAGS = {
"zhihu": ["科技", "AI", "效率", "职场", "教育", "远程工作", "未来工作", "工具"],
"wechat": ["科技", "AI", "效率", "职场", "教育", "远程工作", "未来工作", "工具"],
"xiaohongshu": ["AI", "效率", "科技", "工具", "职场", "生活", "学习方法"],
}
def migrate():
try:
from app.database import SessionLocal
from app.models import TopicField, Topic, Article, CollectorCategory, CollectorSource, PlatformConfig
except ImportError as e:
logger.error(f"导入失败(确保在项目根目录执行): {e}")
sys.exit(1)
db = SessionLocal()
try:
# ========== 1. 处理 TopicField ==========
logger.info("--- 处理 TopicField ---")
existing_fields = {f.name: f for f in db.query(TopicField).all()}
field_id_map = {} # name → id
for nf in NEW_FIELDS:
if nf["name"] in existing_fields:
f = existing_fields[nf["name"]]
f.icon = nf["icon"]
f.color = nf["color"]
f.description = nf["description"]
f.sort_order = nf["sort_order"]
f.is_active = True
field_id_map[nf["name"]] = f.id
logger.info(f" 更新领域: {nf['name']} (id={f.id})")
else:
f = TopicField(**nf)
db.add(f)
db.flush()
field_id_map[nf["name"]] = f.id
logger.info(f" 创建领域: {nf['name']} (id={f.id})")
# 停用旧领域
for old_name in ["可持续生活系统", "可持续生活", "数字游民", "个人成长", "个人知识工厂"]:
if old_name in existing_fields and old_name not in [nf["name"] for nf in NEW_FIELDS]:
f = existing_fields[old_name]
f.is_active = False
logger.info(f" 停用旧领域: {old_name} (id={f.id})")
db.commit()
# ========== 2. 迁移 Topic ==========
logger.info("--- 迁移 Topic ---")
topics = db.query(Topic).all()
topic_count = len(topics)
migrated_count = 0
for t in topics:
old_field = t.field_name or ""
new_field = None
for old_k, new_v in FIELD_MIGRATION_MAP.items():
if old_k in old_field or old_field in old_k:
new_field = new_v
break
if not new_field and t.field_id:
# 按名称猜测
for old_k, new_v in FIELD_MIGRATION_MAP.items():
if old_k[:4] in old_field or old_field[:4] in old_k:
new_field = new_v
break
if new_field and new_field in field_id_map:
t.field_id = field_id_map[new_field]
t.field_name = new_field
migrated_count += 1
elif t.field_id and t.field_name not in [nf["name"] for nf in NEW_FIELDS]:
# 无法映射的旧领域,统一设为"AI与效率"
if "AI与效率" in field_id_map:
t.field_id = field_id_map["AI与效率"]
t.field_name = "AI与效率"
migrated_count += 1
db.commit()
logger.info(f"{topic_count} 个 Topic,已迁移 {migrated_count}")
# ========== 3. 清理旧 Article ==========
logger.info("--- 清理旧 Article ---")
all_articles = db.query(Article).all()
deleted_articles = []
for a in all_articles:
topic = db.query(Topic).filter(Topic.id == a.topic_id).first()
if topic:
# 只保留 topic 仍然活跃且在新领域内的
tf = db.query(TopicField).filter(TopicField.id == topic.field_id).first()
if tf and tf.is_active and tf.name in [nf["name"] for nf in NEW_FIELDS]:
continue
# 该 Article 关联的 topic 已失效,删除
deleted_articles.append(a.id)
db.delete(a)
db.commit()
logger.info(f" 已删除 {len(deleted_articles)} 篇旧 Article")
# ========== 4. 清理 CollectorCategory ==========
logger.info("--- 清理 CollectorCategory ---")
existing_cats = {c.name: c for c in db.query(CollectorCategory).all()}
# 创建新分类
for nc in NEW_CATEGORIES:
if nc["name"] in existing_cats:
c = existing_cats[nc["name"]]
c.search_query = nc["search_query"]
c.description = nc["description"]
c.sort_order = nc["sort_order"]
c.is_active = True
logger.info(f" 更新采集分类: {nc['name']}")
else:
db.add(CollectorCategory(**nc))
logger.info(f" 创建采集分类: {nc['name']}")
# 停用旧分类
for old_name in OLD_CATEGORY_NAMES:
if old_name in existing_cats:
c = existing_cats[old_name]
c.is_active = False
logger.info(f" 停用旧采集分类: {old_name}")
# 删除旧 '可持续生活' 和 '可持续发展' 变体
for cname, cobj in existing_cats.items():
if cname not in [nc["name"] for nc in NEW_CATEGORIES] and cname not in OLD_CATEGORY_NAMES:
if any(kw in cname for kw in ["可持续", "环保", "绿色", "低碳", "零浪费", "循环"]):
cobj.is_active = False
logger.info(f" 停用旧采集分类: {cname}")
db.commit()
# ========== 5. 清理 CollectorSource ==========
logger.info("--- 清理 CollectorSource ---")
all_sources = db.query(CollectorSource).all()
deactivated_sources = 0
for s in all_sources:
# 停用非 AI/科技 类采集源
if s.focus and any(kw in s.focus for kw in ["可持续", "环保", "绿色", "低碳", "零浪费"]):
s.is_active = False
deactivated_sources += 1
# 旧 URL 不再可访问的源
if s.url and any(domain in s.url for domain in ["greenbiz.com", "sustainablebrands.com", "treehugger.com"]):
s.is_active = False
deactivated_sources += 1
# 检查 category_id 是否对应已停用的分类
if s.category_id:
cat = db.query(CollectorCategory).filter(CollectorCategory.id == s.category_id).first()
if cat and not cat.is_active:
s.is_active = False
deactivated_sources += 1
# 新增源(由 initial_data.py 的补充逻辑处理)
for new_src in [
{"name": "AI前沿搜索", "source_type": "web_search", "query": "AI 人工智能 大模型 前沿 2026", "credibility": "medium", "focus": "AI前沿资讯", "sort_order": 1, "is_active": True},
{"name": "AI工具搜索", "source_type": "web_search", "query": "AI工具 效率提升 AI工作流 2026", "credibility": "medium", "focus": "AI工具实测", "sort_order": 2, "is_active": True},
]:
existing = db.query(CollectorSource).filter(CollectorSource.name == new_src["name"]).first()
if not existing:
db.add(CollectorSource(**new_src))
logger.info(f" 创建采集源: {new_src['name']}")
db.commit()
logger.info(f" 已停用 {deactivated_sources} 个旧采集源")
# ========== 6. 更新 PlatformConfig tags ==========
logger.info("--- 更新 PlatformConfig tags ---")
for p in db.query(PlatformConfig).all():
if p.platform in NEW_PLATFORM_TAGS:
rules = p.compliance_rules or {}
if isinstance(rules, dict) and "allowed_tags" in rules:
rules["allowed_tags"] = NEW_PLATFORM_TAGS[p.platform]
p.compliance_rules = rules
logger.info(f" 更新平台标签: {p.platform}")
db.commit()
# ========== 7. 更新 TrendFieldMapping ==========
logger.info("--- 更新 TrendFieldMapping ---")
from app.models import TrendFieldMapping
new_mappings = [
{"trend_keyword": "AI工具", "field_name": "AI与效率", "sort_order": 1},
{"trend_keyword": "AI创作", "field_name": "AI与效率", "sort_order": 2},
{"trend_keyword": "AI职场", "field_name": "AI与效率", "sort_order": 3},
{"trend_keyword": "效率工具", "field_name": "AI与效率", "sort_order": 4},
{"trend_keyword": "未来工作", "field_name": "未来工作方式", "sort_order": 5},
{"trend_keyword": "远程工作", "field_name": "未来工作方式", "sort_order": 6},
{"trend_keyword": "AI就业", "field_name": "未来工作方式", "sort_order": 7},
{"trend_keyword": "科技人文", "field_name": "科技人文", "sort_order": 8},
{"trend_keyword": "数字生活", "field_name": "科技人文", "sort_order": 9},
{"trend_keyword": "AI伦理", "field_name": "科技人文", "sort_order": 10},
{"trend_keyword": "AI情感", "field_name": "AI与效率", "sort_order": 11},
]
existing_mappings = {m.trend_keyword: m for m in db.query(TrendFieldMapping).all()}
# 标记旧的映射为停用
for keyword, mapping in existing_mappings.items():
if keyword not in [nm["trend_keyword"] for nm in new_mappings]:
mapping.is_active = False
# 创建/更新新映射
for nm in new_mappings:
if nm["trend_keyword"] in existing_mappings:
m = existing_mappings[nm["trend_keyword"]]
m.field_name = nm["field_name"]
m.sort_order = nm["sort_order"]
m.is_active = True
else:
db.add(TrendFieldMapping(**nm))
db.commit()
logger.info(f" 已同步 {len(new_mappings)} 条 TrendFieldMapping")
logger.info("=" * 50)
logger.info("✅ DB 迁移完成")
logger.info("=" * 50)
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"迁移失败: {e}")
db.rollback()
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
migrate()
+7 -7
View File
@@ -45,32 +45,32 @@ _PROMPT_DEFAULTS = {
"variables": ["gaps"], "variables": ["gaps"],
}, },
"section_expansion_zhihu": { "section_expansion_zhihu": {
"content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}\n笔记要点:\n{content}\n\n写400-800字,3-6段。每段围绕一个论点展开:先摆事实/数据,再分析原因,最后说清这跟读者有什么关系。\n\n注意:\n- 每个论点必须具体数据或案例(至少一个),不要空谈。引用研究、报告或行业事件\n- 用「你」称呼读者,像是在知乎回答里跟人讨论\n- 从具体问题切入,不要以「引言/总结/核心观点」这类标签开头\n- 回答「所以呢」——这条信息对读者有什么用\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些表达\n- 来源放在文末括号内\n- 纯段落,不要列表/标题/序号标记", "content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}\n笔记要点:\n{content}\n\n写400-800字,3-6段。每段围绕一个论点展开:先摆独家数据/一手案例,再分析深层原因,最后说清这跟读者有什么关系。\n\n注意:\n- 每个论点必须具体数据或独家案例支撑——自采数据、一手实验对比、行业报告引用优先。不要只用公开信息\n- 用「你」称呼读者,像是在知乎回答里跟人讨论\n- 从具体问题或反直觉现象切入,不要以「引言/总结/核心观点」这类标签开头\n- 不只是介绍这个工具/技术,而是探讨它如何改变了人的行为方式或认知\n- 回答「所以呢」——这条信息对读者有什么用,有什么可操作的启发\n- 如果这个话题有争议点,不要回避,亮出你的立场\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些表达\n- 来源放在文末括号内\n- 纯段落,不要列表/标题/序号标记",
"temperature": 0.78, "max_tokens": 4000, "temperature": 0.78, "max_tokens": 4000,
"variables": ["topic_title", "section_title", "date", "content"], "variables": ["topic_title", "section_title", "date", "content"],
}, },
"section_expansion_wechat": { "section_expansion_wechat": {
"content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}\n笔记要点:\n{content}\n\n写300-500字,用「我」的口吻叙述——像在跟朋友聊天,不是写文章\n\n注意:\n- 从个人经历或感受切入,不要讲道理\n- 每个观点背后要有一个具体故事或观察\n- 段与段之间有情绪的起承转合,不是逻辑推理\n- 读起来像一个人在说话,而不是一篇文章在论述\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要标题/列表/序号标记", "content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}\n笔记要点:\n{content}\n\n写300-500字,用「我」的口吻写一个真实的困惑、顿悟或观察——不是测评工具,而是记录这个技术/趋势如何改变了你的一种习惯、认知或生活方式\n\n注意:\n- 从个人经历或感受切入,不是介绍工具功能,而是讲这个工具让你想到了什么、改变了什么\n- 每个观点背后要有一个具体故事或观察,虚实结合\n- 段与段之间有情绪的起承转合,不是逻辑推理\n- 读起来像一个人在深夜和朋友聊天时的反思,不是一篇产品测评\n- 可以有困惑和不确定——真实比正确重要\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要标题/列表/序号标记",
"temperature": 0.8, "max_tokens": 3000, "temperature": 0.8, "max_tokens": 3000,
"variables": ["topic_title", "section_title", "date", "content"], "variables": ["topic_title", "section_title", "date", "content"],
}, },
"section_expansion_xiaohongshu": { "section_expansion_xiaohongshu": {
"content": "写「{topic_title}」笔记中「{section_title}」这一节。\n\n今天:{date}\n笔记要点:\n{content}\n\n写100-200字,1-3小段。每段1-2句,直接给干货。\n\n注意:\n- 直接给结论或方法,不要铺垫\n- 可以用emoji(1个/段,不要堆砌)\n- 像博主在分享亲测有效的经验\n- 不要讲道理,只讲「是什么」和「怎么做」\n- 用「你」或直接省略主语\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要列表/标题/序号标记", "content": "写「{topic_title}」笔记中「{section_title}」这一节。\n\n今天:{date}\n笔记要点:\n{content}\n\n写100-200字,1-3小段。每段1-2句,直接给干货。\n\n注意:\n- 直接给结论或方法,不要铺垫\n- 每个结论后面跟一个具体数据/对比/结果(亲测可用才有说服力)\n- 可以用emoji(1个/段,不要堆砌)\n- 像博主在分享亲测有效的经验,有真实的「我试过」感\n- 不要讲道理,只讲「是什么」和「怎么做」\n- 用「你」或直接省略主语\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要列表/标题/序号标记",
"temperature": 0.75, "max_tokens": 2000, "temperature": 0.75, "max_tokens": 2000,
"variables": ["topic_title", "section_title", "date", "content"], "variables": ["topic_title", "section_title", "date", "content"],
}, },
"title_optimize_zhihu": { "title_optimize_zhihu": {
"content": "你是知乎老用户,经常上热榜。给这篇文章起3个标题候选:\n\n文章主题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n感觉像在跟人推荐一篇值得辩论的文。信息要密、20字内、有明确立场、让人想点进来说两句。不要「如何」开头。\n\n每行一个,不要编号。", "content": "你是知乎老用户,经常上热榜。给这篇文章起3个标题候选:\n\n文章主题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n感觉像在跟人推荐一篇值得辩论的观点帖,不是在推荐一篇工具文。信息要密、20字内、有明确立场、有观点交锋感、让人想点进来说两句。不要「如何」开头。如果话题有争议性,在标题里亮出你的立场。\n\n每行一个,不要编号。",
"temperature": 0.8, "max_tokens": 1500, "temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core", "pain", "field"], "variables": ["title", "core", "pain", "field"],
}, },
"title_optimize_wechat": { "title_optimize_wechat": {
"content": "你是公众号编辑。这篇文章面向{field}领域的读者,帮起3个标题:\n\n文章主题:{title}\n核心观点:{core}\n\n要有身份代入感(打工人/30岁+/普通上班族)、有情绪钩子(焦虑/反常识/后悔)、含微信搜索关键词。15-25字,口语化,越具体越好。参考这种风格:「用了AI效率反而更低了?73%的人掉进了这个坑\n\n每行一个,不要编号。", "content": "你是公众号编辑,擅长写有温度、有人味的文章。这篇文章面向{field}领域的读者,帮起3个标题:\n\n文章主题:{title}\n核心观点:{core}\n\n标题方向是「科技时代的个人叙事」——不是推荐工具,而是分享一个真实的人与技术之间的故事。要有身份代入感(打工人/30岁+/普通上班族/家长)、有情绪钩子(困惑/反常识/顿悟/后悔)、含微信搜索关键词。15-25字,口语化,越具体越好。参考:「AI帮我写周报的第3个月,我终于发现哪里不对了\n\n每行一个,不要编号。",
"temperature": 0.8, "max_tokens": 1500, "temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core", "field"], "variables": ["title", "core", "field"],
}, },
"title_optimize_xhs": { "title_optimize_xhs": {
"content": "你是小红书博主。给这篇笔记起3个标题:\n\n笔记主题:{title}\n核心:{core}\n\n每条18字以内,有具体场景和结果感。可以加1个精确emoji(不要🔥💥❌❓)。参考:「用了两年AI工具,推荐这5个就够了」这种风格。\n\n每行一个,不要编号。", "content": "你是小红书博主,擅长写有数据、有结果感的干货笔记。给这篇笔记起3个标题:\n\n笔记主题:{title}\n核心:{core}\n\n每条18字以内,有具体场景和结果感(含数字/时间/效果)。可以加1个精确emoji(不要🔥💥❌❓)。参考:「两年换了8个AI工具,最后只留这3个」这种风格。\n\n每行一个,不要编号。",
"temperature": 0.8, "max_tokens": 1500, "temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core"], "variables": ["title", "core"],
}, },
@@ -95,7 +95,7 @@ _PROMPT_DEFAULTS = {
"variables": ["html"], "variables": ["html"],
}, },
"sources_optimization": { "sources_optimization": {
"content": "你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。\n\n当前配置的类别({n}个):\n{cat_names}\n\n当前配置的信息源({n2}个):\n{src_summary}\n\n请完成:\n1. 评估每个类别是否仍符合{year}年中国市场真实热点(保留/淘汰/合并)\n2. 评估每个信息源是否可在国内正常访问\n3. 建议新增的类别(最多2条)\n4. 建议新增的信息源搜索词(最多3条,含具体搜索词)\n\n输出 JSON\n{\n \"category_assessment\": [{\"name\": \"...\", \"status\": \"保留/淘汰/合并\", \"reason\": \"...\"}],\n \"source_assessment\": [{\"source\": \"...\", \"accessible\": true/false, \"note\": \"...\"}],\n \"new_categories\": [{\"name\": \"...\", \"reason\": \"...\"}],\n \"new_search_queries\": [{\"query\": \"...\", \"target\": \"...\"}]\n}\n只输出JSON。", "content": "你是一个内容策略分析师。分析当前中文互联网AI与科技人文领域的真实热点,与以下配置进行对比。\n\n当前配置的类别({n}个):\n{cat_names}\n\n当前配置的信息源({n2}个):\n{src_summary}\n\n请完成:\n1. 评估每个类别是否仍符合{year}年中国市场真实热点(保留/淘汰/合并)\n2. 评估每个信息源是否可在国内正常访问\n3. 建议新增的类别(最多2条)\n4. 建议新增的信息源搜索词(最多3条,含具体搜索词)\n\n输出 JSON\n{\n \"category_assessment\": [{\"name\": \"...\", \"status\": \"保留/淘汰/合并\", \"reason\": \"...\"}],\n \"source_assessment\": [{\"source\": \"...\", \"accessible\": true/false, \"note\": \"...\"}],\n \"new_categories\": [{\"name\": \"...\", \"reason\": \"...\"}],\n \"new_search_queries\": [{\"query\": \"...\", \"target\": \"...\"}]\n}\n只输出JSON。",
"temperature": 0.5, "max_tokens": 3000, "temperature": 0.5, "max_tokens": 3000,
"variables": ["n", "cat_names", "n2", "src_summary", "year"], "variables": ["n", "cat_names", "n2", "src_summary", "year"],
}, },
+48 -5
View File
@@ -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 '未上榜'}"
+416
View File
@@ -0,0 +1,416 @@
"""
SEO Auditor 外部网站 SEO 审计脚本
功能
- 抓取目标网站首页和关键页面
- 分析 Meta 标签标题结构内容质量性能链接移动端适配
- 生成评分报告0-100 /维度
- 自动生成优化建议
- 结果写入 DBSEOAudit + OptimizationTask
用法
python3 scripts/seo_auditor.py --product-id 1
python3 scripts/seo_auditor.py --url https://example.com --name "我的网站"
"""
import sys
import os
import re
import json
import logging
from datetime import datetime, timezone
from urllib.parse import urlparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'platform', 'backend'))
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def extract_meta(html):
"""分析 Meta 标签"""
score = 100
issues = []
title = re.search(r'<title[^>]*>(.*?)</title>', html, re.I | re.S)
if not title:
score -= 30
issues.append({"category": "meta", "severity": "high", "issue": "缺少 <title> 标签", "recommendation": "添加包含目标关键词的 title 标签,建议 50-60 字符"})
else:
t = title.group(1).strip()
if len(t) < 10:
score -= 15
issues.append({"category": "meta", "severity": "medium", "issue": f"title 过短 ({len(t)}字符)", "recommendation": "title 建议 50-60 字符"})
if len(t) > 80:
score -= 10
issues.append({"category": "meta", "severity": "low", "issue": f"title 过长 ({len(t)}字符)", "recommendation": "title 超过 80 字符可能在搜索结果中被截断"})
desc = re.search(r'<meta\s+name=["\']description["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
if not desc:
score -= 25
issues.append({"category": "meta", "severity": "high", "issue": "缺少 meta description", "recommendation": "添加包含关键词的 meta description,建议 120-160 字符"})
else:
d = desc.group(1).strip()
if len(d) < 50:
score -= 10
issues.append({"category": "meta", "severity": "medium", "issue": f"description 过短 ({len(d)}字符)", "recommendation": "description 建议 120-160 字符"})
keywords = re.search(r'<meta\s+name=["\']keywords["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
if not keywords:
score -= 5
issues.append({"category": "meta", "severity": "low", "issue": "缺少 meta keywords", "recommendation": "添加 meta keywords(虽然不是排名因素,但用于内容相关性提示)"})
og_title = re.search(r'<meta\s+property=["\']og:title["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
og_desc = re.search(r'<meta\s+property=["\']og:description["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
og_image = re.search(r'<meta\s+property=["\']og:image["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
if not og_title:
score -= 10
issues.append({"category": "meta", "severity": "medium", "issue": "缺少 og:title", "recommendation": "添加 Open Graph title 改善社交分享展示"})
if not og_desc:
score -= 5
issues.append({"category": "meta", "severity": "low", "issue": "缺少 og:description", "recommendation": "添加 og:description 改善社交分享展示"})
if not og_image:
score -= 5
issues.append({"category": "meta", "severity": "low", "issue": "缺少 og:image", "recommendation": "添加 og:image 让分享链接显示缩略图"})
return max(0, score), issues
def extract_headings(html):
"""分析标题结构"""
score = 100
issues = []
h1s = re.findall(r'<h1[^>]*>(.*?)</h1>', html, re.I | re.S)
if len(h1s) == 0:
score -= 30
issues.append({"category": "heading", "severity": "high", "issue": "页面缺少 H1 标签", "recommendation": "每个页面应当只有一个 H1,包含主要关键词"})
elif len(h1s) > 1:
score -= 15
issues.append({"category": "heading", "severity": "medium", "issue": f"存在 {len(h1s)} 个 H1 标签", "recommendation": "每个页面应当只有一个 H1 标签"})
else:
h1_text = re.sub(r'<[^>]+>', '', h1s[0]).strip()
if len(h1_text) < 5:
score -= 5
issues.append({"category": "heading", "severity": "low", "issue": "H1 内容过短", "recommendation": "H1 应清晰描述页面主题"})
h2s = re.findall(r'<h2[^>]*>(.*?)</h2>', html, re.I | re.S)
if len(h2s) == 0 and len(re.findall(r'<(h[2-6])', html, re.I)) > 0:
pass
elif len(h2s) == 0:
score -= 10
issues.append({"category": "heading", "severity": "medium", "issue": "缺少 H2 子标题", "recommendation": "使用 H2 组织内容结构,提升可读性和 SEO"})
# Check heading hierarchy
all_headings = re.findall(r'<h([1-6])[^>]*>', html, re.I)
if all_headings:
prev_level = int(all_headings[0])
for level in all_headings[1:]:
l = int(level)
if l > prev_level + 1:
score -= 5
issues.append({"category": "heading", "severity": "medium", "issue": f"标题层级跳级:H{prev_level} → H{l}", "recommendation": "标题层级应连续,不要跳级"})
break
prev_level = l
return max(0, score), issues
def extract_content(html):
"""分析内容质量"""
score = 100
issues = []
text = re.sub(r'<[^>]+>', ' ', html)
text = re.sub(r'\s+', ' ', text).strip()
word_count = len(text)
if word_count < 300:
score -= 30
issues.append({"category": "content", "severity": "high", "issue": f"内容过短({word_count}字符)", "recommendation": "正文建议至少 300 字符,优质内容建议 1000+ 字符"})
elif word_count < 1000:
score -= 15
issues.append({"category": "content", "severity": "medium", "issue": f"内容偏短({word_count}字符)", "recommendation": "增加内容深度,建议达到 1000+ 字符"})
# Check image alt
imgs = re.findall(r'<img[^>]+>', html, re.I)
no_alt = sum(1 for img in imgs if 'alt=' not in img.lower())
if imgs and no_alt == len(imgs):
score -= 10
issues.append({"category": "content", "severity": "medium", "issue": "所有图片缺少 alt 属性", "recommendation": "为图片添加描述性的 alt 文本"})
elif no_alt > 0:
score -= 5
issues.append({"category": "content", "severity": "low", "issue": f"{no_alt}/{len(imgs)} 张图片缺少 alt", "recommendation": "为剩余图片补全 alt 属性"})
return max(0, score), issues, {"word_count": word_count, "image_count": len(imgs)}
def extract_links(html, base_url):
"""分析链接"""
score = 100
issues = []
domain = urlparse(base_url).netloc
internal_links = re.findall(r'href=["\'](https?://[^"\']+)["\']', html, re.I)
external_links = [url for url in internal_links if urlparse(url).netloc != domain]
internal_links = [url for url in internal_links if urlparse(url).netloc == domain]
broken_keywords = re.findall(r'href=["\']([^"\']*(?:404|broken|dead)[^"\']*)["\']', html, re.I)
if len(internal_links) == 0:
score -= 15
issues.append({"category": "links", "severity": "medium", "issue": "页面没有内部链接", "recommendation": "添加指向站内其他页面的链接,改善爬虫抓取和用户导航"})
if len(external_links) == 0:
pass
ext_no_nofollow = re.findall(r'<a\s+[^>]*href=["\']https?://(?!' + re.escape(domain) + r')["\'][^>]*>', html, re.I)
if ext_no_nofollow:
score -= 5
issues.append({"category": "links", "severity": "low", "issue": "外部链接缺少 nofollow", "recommendation": "对外部链接添加 rel=\"nofollow noopener\" 属性"})
# Check for broken link patterns
broken = re.findall(r'href=["\'](?:https?://[^"\']*?(?:404|error|not-found)[^"\']*)["\']', html, re.I)
if broken:
score -= 10
issues.append({"category": "links", "severity": "high", "issue": f"发现 {len(broken)} 个疑似断链", "recommendation": "检查并修复或删除失效链接"})
return max(0, score), issues, {"internal": len(internal_links), "external": len(external_links)}
def extract_performance(html):
"""分析性能(基础版 — 仅分析可前端检测的指标)"""
score = 100
issues = []
# Check for render-blocking resources
css_links = re.findall(r'<link[^>]*href=["\'].*?\.css["\']', html, re.I)
js_scripts = re.findall(r'<script[^>]*src=["\']([^"\']+)["\']', html, re.I)
render_blocking = len(css_links) + len(js_scripts)
if render_blocking > 10:
score -= 10
issues.append({"category": "performance", "severity": "medium", "issue": f"存在 {render_blocking} 个渲染阻塞资源", "recommendation": "考虑异步加载非关键 CSS/JS,使用 defer 或 async"})
# Check image without dimensions
imgs_no_dim = re.findall(r'<img(?!\s*(?:width|height)=)', html, re.I)
if imgs_no_dim:
score -= 5
issues.append({"category": "performance", "severity": "low", "issue": f"存在 {len(imgs_no_dim)} 张无宽高属性的图片", "recommendation": "为图片添加 width/height 属性,减少布局偏移(CLS)"})
return max(0, score), issues
def extract_mobile(html):
"""分析移动端适配"""
score = 100
issues = []
viewport = re.search(r'<meta\s+name=["\']viewport["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
if not viewport:
score -= 40
issues.append({"category": "mobile", "severity": "high", "issue": "缺少 viewport meta 标签", "recommendation": "添加 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> 确保移动端正确渲染"})
# Check for responsive CSS
media_queries = re.findall(r'@media\s', html, re.I)
if not media_queries:
score -= 15
issues.append({"category": "mobile", "severity": "medium", "issue": "未检测到响应式 CSS", "recommendation": "实现响应式设计,使用媒体查询适配不同屏幕尺寸"})
# Check font size
font_small = re.findall(r'font-size\s*:\s*(?:10|11|12)px', html, re.I)
if font_small:
score -= 5
issues.append({"category": "mobile", "severity": "low", "issue": "检测到小字体 (≤12px)", "recommendation": "移动端正文字号建议至少 16px 防止 iOS 自动缩放"})
return max(0, score), issues
def audit_url(url, name=None):
"""对单个 URL 执行完整 SEO 审计"""
import urllib.request
import ssl
parsed = urlparse(url)
domain = parsed.netloc
page_name = name or domain
logger.info(f"审计: {page_name} ({url})")
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'zh-CN,zh;q=0.9',
})
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
html = resp.read().decode('utf-8', errors='ignore')
logger.info(f" 获取成功: {len(html)} bytes")
except Exception as e:
logger.error(f" 获取失败: {e}")
return None
meta_score, meta_issues = extract_meta(html)
heading_score, heading_issues = extract_headings(html)
content_score, content_issues, content_info = extract_content(html)
perf_score, perf_issues = extract_performance(html)
links_score, links_issues, links_info = extract_links(html, url)
mobile_score, mobile_issues = extract_mobile(html)
all_issues = meta_issues + heading_issues + content_issues + perf_issues + links_issues + mobile_issues
overall_score = round(
meta_score * 0.20 +
heading_score * 0.15 +
content_score * 0.25 +
perf_score * 0.10 +
links_score * 0.10 +
mobile_score * 0.20,
1
)
raw_data = {
"url": url,
"title": re.search(r'<title[^>]*>(.*?)</title>', html, re.I | re.S).group(1).strip() if re.search(r'<title[^>]*>(.*?)</title>', html, re.I | re.S) else None,
"content_info": content_info,
"links_info": links_info,
"issues_count": len(all_issues),
"issues_by_severity": {
"high": sum(1 for i in all_issues if i.get("severity") == "high"),
"medium": sum(1 for i in all_issues if i.get("severity") == "medium"),
"low": sum(1 for i in all_issues if i.get("severity") == "low"),
}
}
result = {
"url": url,
"page_name": page_name,
"overall_score": overall_score,
"meta_score": meta_score,
"heading_score": heading_score,
"content_score": content_score,
"perf_score": perf_score,
"links_score": links_score,
"mobile_score": mobile_score,
"issues_found": len(all_issues),
"raw_data": raw_data,
"issues": all_issues,
}
logger.info(f" 总分: {overall_score}/100 (Meta:{meta_score} 标题:{heading_score} 内容:{content_score} 性能:{perf_score} 链接:{links_score} 移动端:{mobile_score})")
logger.info(f" 发现问题: {len(all_issues)} ({raw_data['issues_by_severity']['high']}高/{raw_data['issues_by_severity']['medium']}中/{raw_data['issues_by_severity']['low']}低)")
return result
def save_audit_to_db(product_id, result):
"""将审计结果写入 DB"""
try:
from app.database import SessionLocal
from app.models import ExternalProduct, SEOAudit, OptimizationTask
db = SessionLocal()
try:
product = db.query(ExternalProduct).filter(ExternalProduct.id == product_id).first()
if not product:
logger.error(f"产品 {product_id} 不存在")
return False
audit = SEOAudit(
product_id=product_id,
audit_type="full",
overall_score=result["overall_score"],
meta_score=result["meta_score"],
heading_score=result["heading_score"],
content_score=result["content_score"],
perf_score=result["perf_score"],
links_score=result["links_score"],
mobile_score=result["mobile_score"],
raw_data=result.get("raw_data"),
page_count=1,
issues_found=result["issues_found"],
org_id=product.org_id,
)
db.add(audit)
db.flush()
for issue in result.get("issues", []):
task = OptimizationTask(
audit_id=audit.id,
product_id=product_id,
category=issue.get("category", "other"),
severity=issue.get("severity", "medium"),
issue=issue.get("issue", ""),
recommendation=issue.get("recommendation"),
status="open",
org_id=product.org_id,
)
db.add(task)
db.commit()
logger.info(f" 审计结果已保存: audit_id={audit.id}, tasks={len(result.get('issues', []))}")
return True
except Exception as e:
db.rollback()
logger.error(f" DB 保存失败: {e}")
return False
finally:
db.close()
except ImportError as e:
logger.error(f" 无法导入应用模块: {e}")
logger.info(" (审计结果仅输出到日志)")
return False
def run_for_product(product_id):
"""为指定产品执行 SEO 审计"""
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'platform', 'backend'))
try:
from app.database import SessionLocal
from app.models import ExternalProduct
db = SessionLocal()
try:
product = db.query(ExternalProduct).filter(ExternalProduct.id == product_id).first()
if not product:
logger.error(f"产品 {product_id} 不存在")
return
url = product.url
name = product.name
finally:
db.close()
except ImportError:
logger.error("无法连接到数据库,请确保在项目根目录运行")
return
if not url:
logger.error(f"产品 {name} 没有配置 URL")
return
result = audit_url(url, name)
if not result:
logger.error("审计失败")
return
save_audit_to_db(product_id, result)
return result
def run_for_url(url, name=None):
"""为指定 URL 执行审计(不保存到 DB)"""
result = audit_url(url, name)
return result
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="SEO Auditor - 外部网站 SEO 审计")
parser.add_argument("--product-id", type=int, help="产品 ID(从 DB 读取 URL")
parser.add_argument("--url", help="直接指定 URL")
parser.add_argument("--name", help="页面名称")
args = parser.parse_args()
if args.product_id:
run_for_product(args.product_id)
elif args.url:
result = run_for_url(args.url, args.name)
if result:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
parser.print_help()
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
长文 短视频脚本改写器
读取某选题的长文知乎/公众号 markdown生成 N 条差异化短视频口播/分镜脚本
"""
import argparse
import datetime
import json
import logging
import sys
from pathlib import Path
from typing import Dict, List, Optional
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from db_helper import get_topic_by_id, get_articles_by_topic, save_article
from prompt_loader import get_prompt, get_prompt_params
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
except ImportError:
HAVE_LLM = False
DATA_DIR = PROJECT_ROOT / "automation" / "data"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOGS_DIR / f"shortvideo_{TODAY}.log"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = "你是短视频脚本编剧,擅长把长文改成高完播率口播脚本"
_VARIANT_ANGLES = [
("观点冲突型", "从一个反直觉/有争议的观点切入,制造认知冲突,让观众想反驳或认同"),
("干货清单型", "提炼 3-5 条可立刻上手的干货要点,节奏快、信息密度高"),
("故事共鸣型", "用一个真实人物/生活场景的故事开头,引发情绪共鸣,再带出观点"),
]
def _build_prompt(source_text: str, angle_name: str, angle_desc: str) -> str:
return f"""你是一个短视频脚本编剧。下面是一篇长文的内容,请把它改写成一条短视频脚本。
改写角度{angle_name}{angle_desc}
长文素材
{source_text}
请严格按以下结构输出 markdown各级标题不要省略
## 标题(爆款钩子)
1 15-25 字的短视频标题带强钩子悬念/冲突/数字/结果不要标题党到离谱
## 口播文案
60-90 秒的口语化口播稿 200-350 像真人对着镜头说话有停顿标记停顿标注换气/节奏点不要书面语不要首先其次最后套路
## 分镜建议
给出 3-5 个镜头每个镜头包含
- 画面具体拍什么景别/动作/场景
- 字幕屏幕上打出的关键词
- 时长几秒
用列表或表格呈现每个镜头一行
## 适配平台
说明这条脚本分别适配 抖音 / 视频号 / 小红书视频 时的微调建议 1 句话
只输出上面的 markdown不要其他说明"""
def _template_markdown(topic_title: str, angle_name: str) -> str:
return f"""## 标题(爆款钩子)
待生成{topic_title} - {angle_name}角度
## 口播文案
LLM 不可用使用模板占位请稍后手动补充 60-90 秒口播稿并用停顿标记节奏
## 分镜建议
- 画面占位 字幕占位 时长占位
## 适配平台
- 抖音占位
- 视频号占位
- 小红书视频占位
"""
def _load_source(topic: Dict, articles: List[Dict]) -> str:
for platform in ("zhihu", "wechat"):
for a in articles:
if a.get("platform") == platform and a.get("html_content"):
return a["html_content"]
if topic.get("core_concept"):
return f"# {topic.get('title', '')}\n\n{topic.get('core_concept')}"
return topic.get("title", "")
def _generate_one(source: str, idx: int, topic_title: str) -> str:
angle_name, angle_desc = _VARIANT_ANGLES[(idx - 1) % len(_VARIANT_ANGLES)]
if HAVE_LLM:
try:
params = get_prompt_params("shortvideo_script") or {}
temperature = params.get("temperature", 0.8)
max_tokens = params.get("max_tokens", 2000)
prompt = _build_prompt(source, angle_name, angle_desc)
result = call_llm(
prompt,
temperature=temperature,
max_tokens=max_tokens,
system_prompt=SYSTEM_PROMPT,
)
if result and result.strip():
return result.strip()
except Exception as e:
logger.warning("LLM 调用失败(variant %s):%s", idx, e)
logger.warning("LLM 不可用或失败,写入模板占位(variant %s", idx)
return _template_markdown(topic_title, angle_name)
def _extract_title(markdown: str, fallback: str) -> str:
for line in markdown.splitlines():
line = line.strip()
if line.startswith("## 标题") or line.startswith("# 标题"):
rest = line.split("标题", 1)[-1].strip("(): ")
if rest:
return rest[:60]
first = next((l.strip("# ").strip() for l in markdown.splitlines() if l.strip()), "")
return first[:60] if first else fallback
def main():
parser = argparse.ArgumentParser(description="长文转短视频脚本")
parser.add_argument("--topic-id", required=True, help="选题 ID")
parser.add_argument("--count", type=int, default=3, help="生成脚本数量(默认 3")
args = parser.parse_args()
topic_id = args.topic_id
count = max(1, args.count)
files: List[str] = []
out_dir = DATA_DIR / "shortvideos" / TODAY
out_dir.mkdir(parents=True, exist_ok=True)
topic = get_topic_by_id(topic_id)
if not topic:
logger.error("选题不存在:%s", topic_id)
print(json.dumps({"ok": False, "topic_id": topic_id, "count": 0, "files": []}, ensure_ascii=False))
sys.exit(1)
articles = get_articles_by_topic(topic_id)
source = _load_source(topic, articles)
topic_title = topic.get("title", topic_id)
ok = True
for i in range(1, count + 1):
markdown = _generate_one(source, i, topic_title)
title = _extract_title(markdown, f"{topic_title}_短视频{i}")
file_path = out_dir / f"{topic_id}_shortvideo_{i}.md"
try:
file_path.write_text(markdown, encoding="utf-8")
files.append(str(file_path))
logger.info("已写出脚本文件:%s", file_path)
except Exception as e:
logger.error("写文件失败:%s", e)
ok = False
continue
try:
save_article(topic_id, f"shortvideo_{i}", html_content=None, title=title, content=markdown)
logger.info("已写入 DBshortvideo_%s / %s", i, topic_id)
except Exception as e:
logger.warning("DB 保存失败(文件已落盘):%s", e)
summary = {"ok": ok, "topic_id": topic_id, "count": count, "files": files}
print(json.dumps(summary, ensure_ascii=False))
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+16 -9
View File
@@ -24,18 +24,25 @@ TODAY = __import__('datetime').datetime.now().strftime("%Y-%m-%d")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_TREND_DOMAIN_MAP = { DEFAULT_TREND_DOMAIN_MAP = {
"远程工作": "未来工作方式",
"AI工具": "AI与效率", "AI工具": "AI与效率",
"可持续生活": "可持续生活系统",
"知识管理": "个人知识工厂",
"数字生活": "科技人文交叉",
"科技人文": "科技人文交叉",
"个人成长": "个人成长",
"副业": "个人成长",
"AI创作": "AI与效率", "AI创作": "AI与效率",
"未来工作": "未来工作方式", "AI职场": "AI与效率",
"效率工具": "AI与效率", "效率工具": "AI与效率",
"家庭教育": "科技人文交叉", "Prompt": "AI与效率",
"提示词": "AI与效率",
"AI编程": "AI与效率",
"AI写作": "AI与效率",
"未来工作": "未来工作方式",
"远程工作": "未来工作方式",
"AI就业": "未来工作方式",
"一人企业": "未来工作方式",
"副业": "未来工作方式",
"科技人文": "科技人文",
"数字生活": "科技人文",
"AI伦理": "科技人文",
"AI情感": "科技人文",
"数据隐私": "科技人文",
"AI教育": "科技人文",
} }
_cached_trend_domain_map = None _cached_trend_domain_map = None

Some files were not shown because too many files have changed in this diff Show More