7 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
58 changed files with 3082 additions and 364 deletions
@@ -1,11 +1,11 @@
{
"sessionID": "ses_1dede8a7affeG6DxtfPUOviXrp",
"updatedAt": "2026-06-17T08:18:52.213Z",
"updatedAt": "2026-07-04T02:48:32.809Z",
"sources": {
"background-task": {
"state": "active",
"reason": "2 background task(s) active",
"updatedAt": "2026-06-17T08:18:52.213Z"
"reason": "1 background task(s) active",
"updatedAt": "2026-07-04T02:48:32.809Z"
}
}
}
+204 -1
View File
@@ -3,7 +3,7 @@
> 本文件为项目进度唯一真理源,所有进度信息以此为准。
> 其他文档中的进度描述一律以本文为准。
**最后更新**2026-06-23 (v25)
**最后更新**2026-07-04 (v26)
---
@@ -356,6 +356,21 @@
| `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),无任何悬空引用。
---
@@ -637,6 +652,53 @@ admin.html 新增「GEO 就绪度」标签页包含:
---
## 十三、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` 成功
- 注:本次为代码升级,**未重启服务/未跑全量测试套件**;发布/净化/拆条需运营在真实平台按声明手动完成
---
## 十二、规范说明
### 规划文档
@@ -664,3 +726,144 @@ admin.html 新增「GEO 就绪度」标签页包含:
如需 → 同步 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,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翻车”大字,正文加#人类直觉 #韦东奕 标签,文案侧重“普通人如何训练自己的直觉”。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+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(一次性授权)+ 年订阅支持 | 可二次开发,含部署指导 |
+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,
)
+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)
+56 -1
View File
@@ -1,4 +1,10 @@
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
@@ -6,10 +12,18 @@ 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, org_filter
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)
@@ -467,3 +481,44 @@ def external_overview(
"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,
}
+104 -14
View File
@@ -2,13 +2,13 @@
"""发布管理 API"""
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone, timedelta, date
from typing import Optional, List
from ..database import get_db
from ..models import Topic, PublishRecord, User
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
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
@@ -40,9 +40,9 @@ async def create_publish_record(
req: MultiPublishRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
current_user: User = Depends(get_current_user)
):
"""多平台发布选题(管理员"""
"""多平台发布选题(运营/管理员,按组织隔离"""
try:
q = db.query(Topic).filter(Topic.id == req.topic_id)
of = org_filter(current_user, Topic)
@@ -66,17 +66,17 @@ async def create_publish_record(
record = PublishRecord(
topic_id=req.topic_id,
platform=platform,
action='publish',
status='success',
action='stage',
status='pending_manual',
operator=operator,
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}",
description=f"选题 {req.topic_id} 已生成 {PLATFORM_LABELS.get(platform, platform)} 发布稿,待人工校验后发布",
org_id=topic.org_id
)
db.add(record)
results.append(PublishResult(
platform=platform,
platform_label=PLATFORM_LABELS.get(platform, platform),
status='success'
status='pending_manual'
))
except Exception as e:
results.append(PublishResult(
@@ -86,16 +86,17 @@ async def create_publish_record(
error_msg=str(e)
))
topic.status = '已发布'
# 诚实状态流转:本项目无真实自动发布器(且按《AI生成内容标识办法》AI 稿需真人把关),
# 故仅将选题置为「待人工发布」,待运营在对应平台校验声明后手动发布。
topic.status = 'pending_publish'
_now = datetime.now(timezone(timedelta(hours=8)))
topic.updated_at = _now
topic.published_at = _now.date()
db.commit()
db.expire_all()
db.refresh(topic)
audit_log(
action="publish",
action="publish_stage",
user=current_user,
resource_type="topic",
resource_id=req.topic_id,
@@ -105,15 +106,104 @@ async def create_publish_record(
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(
ok=success_count > 0,
ok=staged == len(results),
topic_id=req.topic_id,
results=results,
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)"
message=(
f"选题 {req.topic_id} 已生成 {staged}/{len(results)} 个平台的发布稿并置为「待人工发布」。"
f"AI 生成内容须由真人校验声明后手动发布,系统不代发。"
)
)
except HTTPException:
raise
except Exception as e:
db.rollback()
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} 已标记为「已发布」,发布记录同步更新。"
)
+57 -1
View File
@@ -66,7 +66,63 @@ def list_topics(
else:
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")
+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()
if existing:
existing.html_content = html
existing.word_count = len(html)
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()
for platform_dir in ["zhihu", "wechat", "xiaohongshu"]:
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)]
if topic_ids:
cmd.extend(["--topic-ids", ','.join(topic_ids)])
else:
cmd.append("--today-only")
# 不再使用 --today-only,避免三平台串行撰写时小红书文章错过审查窗口
return cmd
def run_optimizer(topic_ids: List[str] = None):
+83 -33
View File
@@ -58,6 +58,7 @@ MODULES = {
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
"scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"},
"scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"},
"scheduled_shortvideo": {"name": "🎬 短视频拆条", "cron": "08:00"},
}
LOG_FILE_MAP = {
@@ -71,6 +72,7 @@ LOG_FILE_MAP = {
"scheduled_task_monitor": "task_monitor",
"scheduled_rank_tracker": "rank_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):
@@ -188,6 +190,7 @@ class TaskScheduler:
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"),
("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"),
("scheduled_shortvideo", self._run_shortvideo, "短视频拆条"),
]
for module_id, fn, name in MODULE_JOBS:
@@ -418,41 +421,48 @@ class TaskScheduler:
count = 0
for topic in topics:
platform_urls = topic.platform_urls or {}
zhihu_url = platform_urls.get("zhihu", "")
if not zhihu_url:
continue
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', zhihu_url)
if not m:
continue
post_id = m.group(1)
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
try:
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
if resp.status_code != 200:
for platform, url in platform_urls.items():
if not url:
continue
if platform == "zhihu":
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', url)
if not m:
continue
post_id = m.group(1)
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
try:
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
if resp.status_code != 200:
continue
raw = resp.json()
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic.id,
ContentMetrics.platform == "zhihu"
).first()
metric_data = {
"views": raw.get("voteup_count", raw.get("views_count", 0)),
"likes": raw.get("voteup_count", 0),
"favorites": raw.get("favorite_count", 0),
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0),
"last_fetched": datetime.now(),
"publish_url": url,
"data_snapshot": raw,
}
if existing:
for k, v in metric_data.items():
setattr(existing, k, v)
else:
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
count += 1
except Exception:
continue
elif platform in ("wechat_mp", "xiaohongshu"):
logger.info("平台 %s 无公开指标 API,跳过", platform)
continue
raw = resp.json()
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic.id,
ContentMetrics.platform == "zhihu"
).first()
metric_data = {
"views": raw.get("voteup_count", raw.get("views_count", 0)),
"likes": raw.get("voteup_count", 0),
"favorites": raw.get("favorite_count", 0),
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0),
"last_fetched": datetime.now(),
"publish_url": zhihu_url,
"data_snapshot": raw,
}
if existing:
for k, v in metric_data.items():
setattr(existing, k, v)
else:
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
count += 1
except Exception:
continue
logger.info("平台 %s 暂无指标同步支持,跳过", platform)
continue
if count:
db.commit()
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))
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):
"""返回当前所有定时任务的状态"""
jobs = []
+6
View File
@@ -129,8 +129,14 @@ def init_db():
("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:
+51 -14
View File
@@ -76,6 +76,17 @@ def import_initial_data():
for cfg in default_system_configs:
if db.query(SystemConfig).filter(SystemConfig.key == cfg["key"]).first() is None:
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:
first_llm = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if first_llm:
@@ -294,21 +305,47 @@ def import_initial_data():
db.commit()
print("✅ 插入默认角色")
# 初始化默认菜单(与 uni-nav.js 对齐)
if db.query(Menu).count() == 0:
default_menus = [
{"name": "仪表盘", "path": "/", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
{"name": "选题", "path": "topics.html", "icon": "IconTopic", "sort_order": 1, "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": "assets.html", "icon": "IconFolder", "sort_order": 4, "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"]},
]
for m in default_menus:
db.add(Menu(**m))
# 归一化首页菜单路径,避免 index.html 与 /index.html 产生重复入口
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()
print("✅ 插入默认菜单")
elif idx_plain and idx_slash:
db.delete(idx_plain)
db.commit()
# 初始化默认菜单:幂等按 path 对齐(已有时更新排序/角色,缺失时插入)。
# 运行时会用 DB 菜单覆盖 uni-nav.js 的 fallback,因此这里必须保证文章管理/内容工厂/白标设置等入口存在。
default_menus = [
{"name": "工作台", "path": "/index.html", "icon": "IconHome", "sort_order": 0, "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": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "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": "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:
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))
# 原独立的选题/内容工厂/文章管理已合并进「创作工作台」,从主导航隐藏(页面仍可被 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()
print("✅ 菜单已对齐(创作工作台合并选题/内容工厂/文章管理)")
# 同步 PostgreSQL 自增序列
if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
+3 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db
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, external_promotion
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 .core.scheduler import scheduler
@@ -106,6 +106,8 @@ app.include_router(menu_configs.public_router)
app.include_router(search_providers.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
+2
View File
@@ -138,6 +138,8 @@ class TopicResponse(TopicBase):
compliance_score: Optional[int] = None
platform_urls: Dict[str, str] = {}
cases: List[Any] = []
article_count: int = 0
articles: List[Dict[str, Any]] = []
model_config = ConfigDict(from_attributes=True)
+18 -1
View File
@@ -199,7 +199,8 @@
<div class="empty-text">暂无平台配置</div>
</div>
</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="name" label="名称" width="120"></el-table-column>
<el-table-column prop="icon" label="图标" width="100">
@@ -230,6 +231,22 @@
</template>
</el-table-column>
</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-form :model="pcForm" label-width="100px">
<el-row :gutter="16">
+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>
+305 -193
View File
@@ -24,86 +24,130 @@
</head>
<body>
<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>
<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;"><IconDocument /></el-icon> 文章管理</h2>
</div>
<div class="filter-bar">
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部平台" value=""></el-option>
<el-option label="知乎" value="zhihu"></el-option>
<el-option label="微信公众号" value="wechat"></el-option>
<el-option label="小红书" value="xiaohongshu"></el-option>
</el-select>
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部状态" value=""></el-option>
<el-option label="草稿" value="draft"></el-option>
<el-option label="已审查" value="reviewed"></el-option>
<el-option label="已发布" value="published"></el-option>
</el-select>
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
</el-input>
</div>
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
<el-table-column prop="topic_id" label="选题 ID" width="80"></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="90">
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="80">
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
</el-table-column>
<el-table-column prop="word_count" label="字数" width="70">
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
</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 label="操作" width="130" fixed="right">
<template #default="scope">
<div style="display: flex; gap: 4px;">
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
<el-tabs v-model="activeTab">
<el-tab-pane label="文章管理" name="articles">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
</div>
<div class="filter-bar">
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部平台" value=""></el-option>
<el-option label="知乎" value="zhihu"></el-option>
<el-option label="微信公众号" value="wechat"></el-option>
<el-option label="小红书" value="xiaohongshu"></el-option>
</el-select>
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部状态" value=""></el-option>
<el-option label="草稿" value="draft"></el-option>
<el-option label="已审查" value="reviewed"></el-option>
<el-option label="已发布" value="published"></el-option>
</el-select>
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
</el-input>
</div>
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
<el-table-column prop="topic_id" label="选题 ID" width="80"></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="90">
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="80">
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
</el-table-column>
<el-table-column prop="word_count" label="字数" width="70">
<template #default="scope">{{ scope.row.word_count ?? '-' }}</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="210" fixed="right">
<template #default="scope">
<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" @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>
</div>
</template>
</el-table-column>
</el-table>
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
<div class="article-card-header">
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
</div>
<div class="article-card-meta">
<div>ID: {{ article.id }}</div>
<div>平台: {{ platformLabel(article.platform) }}</div>
<div>合规: {{ article.compliance_score ?? '-' }}</div>
<div>字数: {{ article.word_count ?? '-' }}</div>
<div>创建: {{ formatDate(article.created_at) }}</div>
</div>
<div class="article-card-actions">
<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>
</div>
</div>
</template>
</el-table-column>
</el-table>
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
<div class="article-card-header">
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
</div>
<div class="article-card-meta">
<div>ID: {{ article.id }}</div>
<div>平台: {{ platformLabel(article.platform) }}</div>
<div>合规: {{ article.compliance_score ?? '-' }}</div>
<div>字数: {{ article.word_count ?? '-' }}</div>
<div>创建: {{ formatDate(article.created_at) }}</div>
</div>
<div class="article-card-actions">
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
<el-icon><IconDocument /></el-icon> 暂无文章
</div>
</div>
</div>
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
<el-icon><IconDocument /></el-icon> 暂无文章
</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>
</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">
@@ -136,137 +180,205 @@
</div>
</template>
</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>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const ArticlesApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
loadingTable: false,
articles: [],
filterPlatform: '', filterStatus: '', searchQuery: '',
previewVisible: false, previewArticleData: null, previewFullscreen: false,
debounceTimer: null
}
},
computed: {
filteredArticles() {
let list = this.articles;
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
}
return list;
}
},
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 fetchArticles() {
this.loadingTable = true;
try {
const params = new URLSearchParams();
if (this.filterPlatform) params.set('platform', this.filterPlatform);
if (this.filterStatus) params.set('status', this.filterStatus);
const qs = params.toString();
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
this.articles = data.articles || [];
} catch (error) {
console.error('获取文章列表失败:', error);
this.$message.error('获取文章列表失败: ' + error.message);
this.articles = [];
} finally { this.loadingTable = false; }
},
debouncedSearch() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
},
async previewArticle(article) {
this.previewArticleData = null;
this.previewVisible = true;
this.previewFullscreen = false;
try {
const data = await this.api('/api/articles/detail/' + article.id);
this.previewArticleData = data;
} catch (error) {
this.$message.error('加载文章详情失败: ' + error.message);
this.previewVisible = false;
const ArticlesApp = {
data() {
return {
isEmbed: false,
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
activeTab: 'articles',
loadingTable: false,
articles: [],
filterPlatform: '', filterStatus: '', searchQuery: '',
previewVisible: false, previewArticleData: null, previewFullscreen: false,
editVisible: false, savingEdit: false, editForm: { topic_id: '', platform: '', html_content: '' },
publishRecords: [], loadingRecords: false,
debounceTimer: null
}
},
togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen;
this.$nextTick(() => {
const overlays = document.querySelectorAll('.el-overlay');
const overlay = overlays[overlays.length - 1];
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
const dialog = document.querySelector('.preview-dialog-custom');
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
});
computed: {
filteredArticles() {
let list = this.articles;
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
}
return list;
}
},
copyPreviewHtml() {
const html = this.previewArticleData?.html_content;
if (!html) { this.$message.warning('暂无内容可复制'); return; }
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const titleEl = doc.querySelector('h1');
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
const body = doc.body;
if (body) body.querySelectorAll('script, style, nav, footer, .interaction, .ad, aside, .comment').forEach(el => el.remove());
const container = doc.createElement('div');
if (titleEl) { const h1 = doc.createElement('h1'); h1.textContent = title; container.appendChild(h1); }
body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => container.appendChild(el.cloneNode(true)));
const cleanHtml = container.innerHTML;
const blob = new Blob([cleanHtml], { type: 'text/html' });
const plainText = container.textContent;
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([plainText], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this.$message.success('✅ 已复制(含格式和配图),Ctrl+V 粘贴')).catch(() => {
navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
});
watch: {
activeTab(v) { if (v === 'publish') this.fetchRecords(); }
},
async deleteArticle(article) {
try {
await this.$confirm(`确定删除文章 [${article.id}]`, '提示', { type: 'warning' });
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
this.$message.success('删除成功');
await this.fetchArticles();
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
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 fetchArticles() {
this.loadingTable = true;
try {
const params = new URLSearchParams();
if (this.filterPlatform) params.set('platform', this.filterPlatform);
if (this.filterStatus) params.set('status', this.filterStatus);
const qs = params.toString();
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
this.articles = data.articles || [];
} catch (error) {
console.error('获取文章列表失败:', error);
this.$message.error('获取文章列表失败: ' + error.message);
this.articles = [];
} 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() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
},
async previewArticle(article) {
this.previewArticleData = null;
this.previewVisible = true;
this.previewFullscreen = false;
try {
const data = await this.api('/api/articles/detail/' + article.id);
this.previewArticleData = data;
} catch (error) {
this.$message.error('加载文章详情失败: ' + error.message);
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() {
this.previewFullscreen = !this.previewFullscreen;
this.$nextTick(() => {
const overlays = document.querySelectorAll('.el-overlay');
const overlay = overlays[overlays.length - 1];
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
const dialog = document.querySelector('.preview-dialog-custom');
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
});
},
copyPreviewHtml() {
const html = this.previewArticleData?.html_content;
if (!html) { this.$message.warning('暂无内容可复制'); return; }
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const titleEl = doc.querySelector('h1');
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
const body = doc.body;
if (body) body.querySelectorAll('script, style, nav, footer, .interaction, .ad, aside, .comment').forEach(el => el.remove());
const container = doc.createElement('div');
if (titleEl) { const h1 = doc.createElement('h1'); h1.textContent = title; container.appendChild(h1); }
body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => container.appendChild(el.cloneNode(true)));
const cleanHtml = container.innerHTML;
const blob = new Blob([cleanHtml], { type: 'text/html' });
const plainText = container.textContent;
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([plainText], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this.$message.success('✅ 已复制(含格式和配图),Ctrl+V 粘贴')).catch(() => {
navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
});
},
async deleteArticle(article) {
try {
await this.$confirm(`确定删除文章 [${article.id}]`, '提示', { type: 'warning' });
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
this.$message.success('删除成功');
await this.fetchArticles();
} 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'; },
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; },
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch (e) { return dateStr; }
}
},
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; },
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch (e) { return dateStr; }
mounted() {
const token = localStorage.getItem('authToken');
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 } })
.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(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(ArticlesApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
};
const app = Vue.createApp(ArticlesApp);
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>
+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>
<p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p>
</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="platform" label="平台" width="100">
<template #default="scope">{{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}</template>
@@ -201,6 +202,18 @@
</template>
</el-table-column>
</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>
</el-dialog>
+1 -1
View File
@@ -210,7 +210,7 @@
<div class="toolbar">
<span style="font-size:13px;color:#909399;">
共 {{ keywordRankings.length }} 条记录
<span v-if="rankingOverview.with_rank != null"> · 有排名 {{ rankingOverview.with_rank }} 条</span>
<span v-if="keywordRankingOverview.with_rank != null"> · 有排名 {{ keywordRankingOverview.with_rank }} 条</span>
</span>
</div>
<div v-if="loading" class="card-loading">加载中...</div>
+8 -4
View File
@@ -195,7 +195,7 @@
/* ========== Topic Cards (待创作) ========== */
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: var(--spacing-md);
}
.topic-card {
@@ -565,7 +565,7 @@
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="内容工厂" :username="currentUser.username" :is-admin="isAdmin" current-page="factory" @navigate="redirectToPage" @logout="handleLogout">
<uni-nav v-if="!isEmbed" title="内容工厂" :username="currentUser.username" :is-admin="isAdmin" current-page="factory" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
@@ -838,6 +838,7 @@
const FactoryApp = {
data() {
return {
isEmbed: false,
isLoggedIn: false,
isAdmin: false,
currentUser: { username: '' },
@@ -1037,7 +1038,7 @@ const FactoryApp = {
});
},
goToArticles() {
window.location.href = '/articles.html';
this.redirectToPage('/articles.html');
},
handleLogout() {
localStorage.removeItem('authToken');
@@ -1046,7 +1047,9 @@ const FactoryApp = {
window.location.href = '/login.html';
},
redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + page;
const url = page.startsWith('/') ? page : '/' + page;
if (this.isEmbed) window.top.location.href = url;
else window.location.href = url;
}
},
mounted() {
@@ -1055,6 +1058,7 @@ const FactoryApp = {
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 }
})
+180 -46
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: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 ===== */
.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; }
@@ -192,7 +218,8 @@
</div>
<div class="landing-nav-actions">
<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="菜单">
<span></span><span></span><span></span>
</button>
@@ -203,10 +230,11 @@
<a href="#features" class="mobile-link">产品功能</a>
<a href="#how-it-works" 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="#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-primary">免费使用 →</a>
<a href="#pricing" class="landing-btn-primary">免费用 →</a>
</div>
</nav>
@@ -218,8 +246,8 @@
<h1>让内容在 AI 时代<br><span>主动找到你的客户</span></h1>
<p>宇之然是一站式 AI 内容获客系统。自动生成知乎深度文、公众号叙事文、小红书干货贴——三平台差异化分发。通过 AI 搜索推荐(GEO)和独立站 SEO,让零品牌基础的团队也能持续获得精准流量。</p>
<div class="hero-actions">
<a href="/login.html" class="landing-btn-primary">免费开始使用 →</a>
<a href="#features" class="landing-btn-secondary">了解产品功能</a>
<a href="#pricing" class="landing-btn-primary">免费用 →</a>
<a href="#contact" class="landing-btn-secondary">预约演示 / 联系销售</a>
</div>
<div class="hero-stats">
<div class="hero-stat"><div class="hero-stat-num">3</div><div class="hero-stat-label">覆盖平台</div></div>
@@ -330,57 +358,53 @@
<!-- Pricing -->
<section id="pricing" class="pricing">
<div class="container">
<div style="text-align:center;"><span class="section-label">定价</span></div>
<h2 class="section-title">灵活的获客方案</h2>
<p class="section-subtitle">小规模试验到团队级运营,总有适合你的计划</p>
<div style="text-align:center;"><span class="section-label">合作模式</span></div>
<h2 class="section-title">免费试用 · 私有化部署 · 源码售卖</h2>
<p class="section-subtitle">零成本体验到整套买断,三种方式总有一种适合你</p>
<div class="pricing-grid">
<div class="pricing-card">
<div class="pricing-name">入门版</div>
<div class="pricing-price">¥299 <span>/月</span></div>
<div class="pricing-desc">个人创作者试水 AI 内容获客</div>
<div class="pricing-name">免费试用</div>
<div class="pricing-price">¥0 <span></span></div>
<div class="pricing-desc">零成本体验完整创作闭环</div>
<ul class="pricing-features">
<li>每月 20 篇文章生产</li>
<li>私有化试用版 / 预约 1v1 演示</li>
<li>限 5 篇 / 月文章生产</li>
<li>三平台差异化内容</li>
<li>基础 SEO 元数据注入</li>
<li>搜索排名追踪(Bing</li>
<li>GEO 就绪度基础评分</li>
<li>标准合规审查</li>
<li>基础 AI 味检测</li>
<li>基础 GEO 就绪度评分</li>
<li>社区与文档支持</li>
</ul>
<a href="/login.html" class="landing-btn-secondary">开始试用</a>
<a href="#contact" class="landing-btn-secondary">申请试用</a>
</div>
<div class="pricing-card featured">
<div class="pricing-badge">推荐</div>
<div class="pricing-name">专业版</div>
<div class="pricing-price">¥799 <span>/月</span></div>
<div class="pricing-desc">内容团队稳定获客的日常工具</div>
<div class="pricing-badge">主力推荐</div>
<div class="pricing-name">私有化部署</div>
<div class="pricing-price">¥29,800 <span>起 / 买断</span></div>
<div class="pricing-desc">一套独立部署,数据不出域,可白标</div>
<ul class="pricing-features">
<li>每月 100 篇文章生产</li>
<li>三平台差异化内容</li>
<li>高级 SEO + GEO 元数据</li>
<li>搜索排名追踪(Bing + AI</li>
<li>AI 搜索引用检测</li>
<li>GEO 就绪度完整评分(6维度)</li>
<li>全自动定时流水线</li>
<li>多用户协作</li>
<li>完整功能 + 无限文章生产</li>
<li>部署在你的自有服务器</li>
<li>品牌白标(名称 / Logo / 主色)</li>
<li>AI 味检测 + 合规人工闸门</li>
<li>GEO / SEO 全维度优化</li>
<li>多用户 / 多租户隔离</li>
<li>年运维与更新订阅</li>
</ul>
<a href="/login.html" class="landing-btn-primary">开始试用</a>
<a href="#contact" class="landing-btn-primary">获取报价</a>
</div>
<div class="pricing-card">
<div class="pricing-name">企业版</div>
<div class="pricing-price">¥2,499 <span>/</span></div>
<div class="pricing-desc">品牌化内容运营的完整方案</div>
<div class="pricing-name">源码售卖</div>
<div class="pricing-price">面议 <span>/ 授权</span></div>
<div class="pricing-desc">一次性授权,自主迭代可控</div>
<ul class="pricing-features">
<li>不限文章数量</li>
<li>所有平台差异化内容</li>
<li>全维度 SEO + GEO 优化</li>
<li>全平台搜索排名追踪</li>
<li>AI 搜索引用深度分析</li>
<li>数据看板 + 定制报告</li>
<li>专属 LLM 模型配置</li>
<li>API 接入</li>
<li>专属技术支持</li>
<li>完整源代码交付</li>
<li>永久授权 + 年订阅支持</li>
<li>可二次开发 / 集成</li>
<li>含私有化部署包</li>
<li>技术文档 + 部署指导</li>
<li>商务定制谈价</li>
</ul>
<a href="/login.html" class="landing-btn-secondary">联系销售</a>
<a href="#contact" class="landing-btn-secondary">联系销售</a>
</div>
</div>
</div>
@@ -417,6 +441,22 @@
<button class="faq-q">我的数据安全吗?</button>
<div class="faq-a">系统支持多租户隔离(组织级数据隔离),用户只能看到自己组织的数据。部署在你的自有服务器上,数据不出你的网络。支持 JWT 认证、HTTPS、管理员权限分级管理。</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>
</section>
@@ -425,8 +465,83 @@
<section class="cta">
<div class="container" style="position:relative;">
<h2>准备好了吗?</h2>
<p>立即开始使用宇之然,让 AI 为你的内容获取精准流量</p>
<a href="/login.html">免费开始使用</a>
<p>免费试用、私有化部署或源码采购——选一种方式,让 AI 为你的内容获取精准流量</p>
<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>
</section>
@@ -447,7 +562,7 @@
<h4>支持</h4>
<a href="#faq">常见问题</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>
<h4>法律</h4>
@@ -487,6 +602,25 @@
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>
</body>
</html>
+1 -1
View File
@@ -13,7 +13,7 @@
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
.stat-value { font-size: 32px; font-weight: 700; }
.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; }
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@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;
}
/* ========== Dark Mode ========== */
/* ========== Dark Mode ==========
注意:Element Plus 组件库为亮色主题,未同步切换会导致弹窗/卡片内
自定义文字在深色变量下不可读。当前明确采用亮色主题以保证对比度,
待引入 EP 暗色变量(dark theme vars)后再启用完整暗色模式。
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a2e;
@@ -76,12 +79,10 @@
--color-bg-subtle: #1a1a3e;
--color-border: #2a2a4e;
--color-border-hover: #4a4a6e;
--color-text-primary: #e0e6ed;
--color-text-regular: #a0a6b0;
--color-text-secondary: #6b7280;
--color-text-placeholder: #4a4a5e;
--shadow-sm: 0 1px 3px 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);
@@ -90,6 +91,7 @@
.card { background: var(--color-bg-raised); }
.el-table { --el-table-tr-bg: var(--color-bg-raised); --el-table-header-bg: var(--color-bg-subtle); }
}
*/
/* ========== Reset & Base ========== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -119,6 +121,12 @@ body {
margin: 0 auto;
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; }
/* ========== Card ========== */
@@ -128,7 +136,6 @@ body {
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
box-shadow: var(--shadow-md);
overflow-x: auto;
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) {
.preview-iframe { min-height: 400px; }
.preview-dialog-custom { position: relative; left: 90px; }
.preview-dialog-custom { position: relative; }
}
@media (max-width: 480px) {
+77 -19
View File
@@ -33,7 +33,7 @@
</head>
<body>
<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>
<div class="main-content">
<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 === '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 === 'pending_publish' ? 'primary' : ''" @click="filterStatus = 'pending_publish'; fetchTopics()">待人工发布 ({{ statusStats.pendingPublish }})</el-button>
</div>
<div style="margin-bottom:10px;">
<span class="search-toggle" @click="showSearch = !showSearch">🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}</span>
@@ -64,30 +65,30 @@
<div v-if="showSearch" class="search-area">
<el-form :model="searchForm" size="small" label-width="70px" @submit.prevent>
<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 :span="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="5"><el-form-item label="领域"><el-input v-model="searchForm.field" placeholder="如 科技前沿" clearable @input="doSearch"></el-input></el-form-item></el-col>
<el-col :span="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-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 :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 :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 :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>
<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-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>
<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-form-item></el-col>
</el-row>
<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>
<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-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>
<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>
@@ -109,6 +110,17 @@
<el-table-column prop="status" label="状态" width="90">
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
</el-table-column>
<el-table-column 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">
<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>
@@ -123,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="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, '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>
</div>
</template>
@@ -145,6 +159,10 @@
<div class="topic-card-tags">
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
<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 class="topic-card-meta">
<div>创建: {{ formatDate(topic.created_at) }}</div>
@@ -157,6 +175,8 @@
<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 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>
</div>
</div>
@@ -241,6 +261,10 @@
</template>
</el-dialog>
<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 style="margin-bottom:16px;">
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
@@ -321,6 +345,7 @@
const TopicsApp = {
data() {
return {
isEmbed: false,
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
loadingTable: false, selectedTopicIds: [], filterStatus: '',
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
@@ -356,7 +381,7 @@ const TopicsApp = {
if (this.filterStatus === 'today') list = this.todayTopics;
else if (!this.filterStatus) list = this.topics;
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];
list = this.topics.filter(t => allowed.includes(t.status));
}
@@ -407,8 +432,8 @@ const TopicsApp = {
return this.filteredTopics.slice(start, start + this.pageSize);
},
statusStats() {
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0 };
const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
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','已发布'], 'pending_publish': ['pending_publish','待人工发布'] };
this.topics.forEach(t => { for (const [k, v] of Object.entries(aliases)) { if (v.includes(t.status)) { s[k]++; break; } } });
return s;
},
@@ -429,8 +454,7 @@ const TopicsApp = {
},
currentPreviewBody() {
const html = this.platformContents[this.previewPlatform];
if (!html) return '';
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>';
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>';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
@@ -534,6 +558,24 @@ const TopicsApp = {
});
},
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) {
const html = this.platformContents[platform];
if (!html) { this.$message.warning('暂无内容可复制'); return; }
@@ -626,13 +668,28 @@ const TopicsApp = {
method: 'POST',
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
});
this.$message.success(`发布完成: ${this.publishTopic.title}`);
this.$message.success(data.message || `已生成发布稿,请在各平台校验后手动发布`);
this.publishDialogVisible = false;
await this.fetchTopics();
await this.fetchTodayCount();
} catch (error) { this.$message.error(`发布失败: ${error.message}`); }
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) {
try {
await this.$confirm('确定删除选题 ' + id + '?将同时删除相关文章、发布记录等关联数据。', '提示', { type: 'warning' });
@@ -774,8 +831,8 @@ const TopicsApp = {
this.currentPage = 1;
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
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': '已发布', 'pending_publish': '待人工发布' }[status] || status; },
toggleCheck(id) {
const idx = this.selectedTopicIds.indexOf(id);
if (idx >= 0) {
@@ -803,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' }); }
catch (e) { return dateStr; }
},
getStatusType(status) { return { 'pending': 'warning', 'review': 'danger', 'ready': 'success', 'published': 'info' }[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; }
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','已发布'], 'pending_publish': ['pending_publish','待人工发布'] }; return map[status] ? map[status].includes(row.status) : row.status === status; }
},
mounted() {
const token = localStorage.getItem('authToken');
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 urlFilter = params.get('filter');
if (urlFilter) this.filterStatus = urlFilter;
+50 -17
View File
@@ -107,7 +107,7 @@
.uni-nav-dropdown-divider {\
height: 1px; background: #f0f0f0; margin: 4px 0;\
}\
@media (max-width: 768px) {\
@media (max-width: 1000px) {\
.uni-nav-items { display: none; }\
.uni-nav-username { display: none; }\
.uni-nav-hamburger { display: inline-flex; }\
@@ -121,20 +121,24 @@
function getCurrentPage() {
var path = window.location.pathname;
if (path === '/index.html') return 'workspace';
var m = path.match(/\/(\w+)\.html/);
return m ? m[1] : 'workspace';
if (path === '/' || path === '/index.html') return 'index.html';
var m = path.match(/\/([\w-]+)\.html$/);
return m ? m[1] + '.html' : 'index.html';
}
function navItems(isAdmin) {
var items = [
{ key: 'workspace', label: '工作台', page: 'index.html' },
{ key: 'factory', label: '内容工厂', page: 'factory.html' },
{ key: 'insights', label: '数据洞察', page: 'insights.html' },
{ key: 'assets', label: '资产库', page: 'assets.html' },
{ key: 'dashboard', label: '仪表盘', page: 'index.html' },
{ key: 'studio', label: '创作工作台', page: 'studio.html' },
{ key: 'insights', label: '数据', page: 'metrics.html' },
{ key: 'calendar', label: '日历', page: 'calendar.html' },
{ key: 'assets', label: '素材', page: 'assets.html' },
{ key: 'tasks', label: '任务', page: 'tasks.html' },
{ key: 'campaigns', label: '外推营销', page: 'campaigns.html' },
{ key: 'ai-slop', label: 'AI味检测', page: 'ai-slop.html' },
];
if (isAdmin) {
items.push({ key: 'branding', label: '白标设置', page: 'branding.html', admin: true });
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
}
return items;
@@ -160,6 +164,9 @@
pwdForm: { old_password: '', new_password: '', confirm: '' },
pwdSubmitting: false,
serverMenus: null,
brandName: '',
brandLogo: '',
primaryColor: '',
};
},
computed: {
@@ -167,15 +174,19 @@
if (this.serverMenus) {
return this.serverMenus
.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);
},
page: function () {
return this.currentPage || getCurrentPage();
return getCurrentPage();
},
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: {
@@ -189,6 +200,24 @@
}.bind(this))
.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) {
this.dropdownOpen = false; this.profileOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; }
@@ -246,18 +275,22 @@
mounted: function () {
document.addEventListener('click', this.closeAll);
this.fetchMenus();
this.fetchBranding();
},
beforeUnmount: function () {
document.removeEventListener('click', this.closeAll);
},
template: '\
<nav class="uni-nav" style="position:relative;">\
<nav class="uni-nav" :style="navStyle">\
<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">\
<button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-item\', { active: page === item.key }]"\
@click="navigate(item.page)">{{ item.label }}</button>\
<button v-for="item in items" :key="item.key"\
:class="['uni-nav-item', { active: page === item.page }]"\
@click="navigate(item.page)">{{ item.label }}</button>\
</div>\
<div class="uni-nav-right">\
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
@@ -281,7 +314,7 @@
</div>\
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
<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)">\
{{ item.label }}\
</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 年部分平台月活/政策细节来自媒体报道,可能存在口径差异。
+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
article_id = f"{platform}_{topic_id}"
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:
existing.html_content = html_content
if title:
existing.title = title
if content:
existing.content = content
existing.word_count = word_count
else:
article = Article(
id=article_id,
@@ -261,6 +263,7 @@ def save_article(topic_id: str, platform: str, html_content: str, *, title: str
title=title,
content=content,
html_content=html_content,
word_count=word_count,
status="draft",
compliance_score=None
)
+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()
+2 -1
View File
@@ -193,7 +193,8 @@ def fetch_baidu_hot() -> List[Dict]:
word = item.get("query", "").strip() or item.get("word", "").strip()
if not word:
continue
hot_score = item.get("hotScore", 0) or item.get("heat", 0)
hot_score_raw = item.get("hotScore", 0) or item.get("heat", 0)
hot_score = int(hot_score_raw) if hot_score_raw else 0
desc = item.get("desc", "")
results.append({
"domain": _guess_domain(word, desc),
+73 -4
View File
@@ -69,6 +69,33 @@ def _load_platform_config() -> dict:
PLATFORM_CONFIG = _load_platform_config()
_AI_DISCLOSURE_TEXT = {
"zhihu": "本文由 AI 辅助创作,核心观点与数据经宇之然人工审核校对。",
"wechat": "本文由 AI 辅助创作,核心观点与数据经宇之然人工审核校对。",
"xiaohongshu": "✨ 本文由 AI 辅助生成,内容已人工核对~",
}
def _load_ai_disclosure_enabled() -> bool:
"""根据《人工智能生成合成内容标识办法》(2025-09-01 施行),AI 生成内容须主动声明。
默认开启可通过 SystemConfig(disclose_ai=false) 或环境变量 YZR_DISCLOSE_AI=0 关闭"""
try:
if os.environ.get("YZR_DISCLOSE_AI", "").strip() == "0":
return False
from app.database import SessionLocal
from app.models import SystemConfig
db = SessionLocal()
try:
sc = db.query(SystemConfig).filter(SystemConfig.key == "disclose_ai").first()
if sc and str(sc.value).strip().lower() in ("false", "0", "no"):
return False
finally:
db.close()
except Exception:
pass
return True
AI_DISCLOSURE_ENABLED = _load_ai_disclosure_enabled()
PLATFORM_NAMES = {
"zhihu": "知乎专栏",
"wechat": "微信公众号",
@@ -235,10 +262,40 @@ def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags
return html
def inject_ai_disclosure(html: str, platform: str) -> str:
"""按《人工智能生成合成内容标识办法》注入 AI 生成声明。
显式文首/文末标注AI 生成隐式head 注入 ai-generated meta AIGC 隐式标识字段"""
if not AI_DISCLOSURE_ENABLED:
return html
text = _AI_DISCLOSURE_TEXT.get(platform, _AI_DISCLOSURE_TEXT["zhihu"])
disclosure_block = f'\n<blockquote class="ai-disclosure">🤖 {text}</blockquote>\n'
# 文末声明(置于正文末尾,封面/互动钩子之前不影响阅读)
if "<!-- AI_DISCLOSURE -->" in html:
html = html.replace("<!-- AI_DISCLOSURE -->", disclosure_block.strip())
else:
# 在 </body> 前插入文末声明
html = html.replace("</body>", f"{disclosure_block}</body>")
# 隐式标识:head 注入声明 meta + AIGC 元数据字段(符合标识办法附录 E)
head_meta = (
'<meta name="ai-generated" content="true">\n'
'<meta name="ai-disclosure" content="本文含人工智能生成合成内容">\n'
'<script type="application/ld+json">\n'
'{"AIGC":{"Label":"1","ContentProducer":"宇之然","ProduceID":"yzr-' + platform + '",'
'"ContentPropagator":"宇之然","PropagateID":"yzr-' + platform + '"}}\n'
'</script>\n'
)
html = html.replace("</head>", head_meta + "</head>")
return html
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
self.topic = self._load_topic()
self._degraded = False
outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md"
if not outline_file.exists():
raise FileNotFoundError(f"Outline not found: {outline_file}")
@@ -334,6 +391,9 @@ class Writer:
except Exception as e:
logger.warning(f"LLM 扩写失败 [{platform}]: {e}")
# LLM 不可用或返回为空 → 降级为大纲要点拼接(非真正成稿)
self._degraded = True
logger.warning(f"⚠️ [{platform}] 章节「{section['title']}」LLM 扩写缺失,降级为大纲要点拼接(内容未达发布质量)")
lines = [l.strip() for l in content.split('\n') if not self._is_outline_noise(l)]
if lines:
sentences = []
@@ -557,7 +617,7 @@ class Writer:
line = line.strip()
if not line:
continue
line = re.sub(r'^\d+[.、)\s]+', '', line)
line = re.sub(r'^\d+[.、)]\s*', '', line)
line = line.strip('*#- \t"\'"''"')
# 跳过思考/建议类输出(如"不如:"、"或者:"、"建议方案"等)
if re.match(r'^(不如|或者|建议|推荐|参考|方案[一二三]|第[一二三]种|以[下是]|标题[一二三]|选项)', line):
@@ -608,8 +668,11 @@ class Writer:
html = html.replace("<!-- TAGS -->", tags_html)
else:
html = html.replace("<!-- TAGS -->", "")
html = inject_geo_metadata(html, title, adapted, platform, tags_html)
# 强制 AI 生成声明(合规要求,可配置关闭)
html = inject_ai_disclosure(html, platform)
return html
def save_html(self, html: str, platform: str, *, title: str = "", content: str = "") -> str:
@@ -634,8 +697,14 @@ class Writer:
html = self.generate_platform_html(markdown, platform)
results[platform] = str(self.save_html(html, platform, title=title, content=markdown))
self.mark_draft()
logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results}
if self._degraded:
logger.error(
"❌ 撰写完成但存在 LLM 降级章节(大纲要点拼接,未达发布质量)。"
"选题保留为待审查,请检查 LLM 提供商连通性后重跑,勿直接发布降级稿。"
)
else:
logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results, "degraded": self._degraded}
def main():
import argparse