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 市场调研简报
This commit is contained in:
yuzhiran
2026-07-11 12:36:01 +08:00
parent 3d596275d5
commit 7e953afbd2
39 changed files with 2418 additions and 311 deletions
@@ -1,10 +1,11 @@
{ {
"sessionID": "ses_1dede8a7affeG6DxtfPUOviXrp", "sessionID": "ses_1dede8a7affeG6DxtfPUOviXrp",
"updatedAt": "2026-06-23T00:31:03.722Z", "updatedAt": "2026-07-04T02:48:32.809Z",
"sources": { "sources": {
"background-task": { "background-task": {
"state": "idle", "state": "active",
"updatedAt": "2026-06-23T00:31:03.722Z" "reason": "1 background task(s) active",
"updatedAt": "2026-07-04T02:48:32.809Z"
} }
} }
} }
+163 -1
View File
@@ -3,7 +3,7 @@
> 本文件为项目进度唯一真理源,所有进度信息以此为准。 > 本文件为项目进度唯一真理源,所有进度信息以此为准。
> 其他文档中的进度描述一律以本文为准。 > 其他文档中的进度描述一律以本文为准。
**最后更新**2026-06-23 (v25) **最后更新**2026-07-04 (v26)
--- ---
@@ -652,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` 成功
- 注:本次为代码升级,**未重启服务/未跑全量测试套件**;发布/净化/拆条需运营在真实平台按声明手动完成
---
## 十二、规范说明 ## 十二、规范说明
### 规划文档 ### 规划文档
@@ -679,3 +726,118 @@ admin.html 新增「GEO 就绪度」标签页包含:
如需 → 同步 README.md 摘要 如需 → 同步 README.md 摘要
``` ```
---
## 第十四章:前端布局修复 + 盈利模式(私有化/白标)升级
**日期**2026-07-11
**目标**:① 修复前端布局/样式不合理项;② 将项目从"自用中台"升级为"可私有化部署 / 白标转售"产品。
### 一、前端布局/样式修复
- **A1 导航未固定 + 顶部双空隙**:`uni-nav.js` 删除 `<nav>` 内联 `position:relative`,恢复 CSS `position:fixed`。(同步修复 A3 移动端下拉菜单脱离)
- **A2 平板导航裁切**:汉堡菜单断点由 `max-width:768px` 提升至 `1000px`,769–1000px 链接改走下拉,不再静默丢失。
- **A4 预览弹窗偏右**`theme-modern.css` 移除 `@media(min-width:769px)``.preview-dialog-custom{left:90px}`,对话框恢复居中(影响 articles.html / factory.html)。
- **A6 卡片横向滚动条**`.card` 去掉 `overflow-x:auto`(el-table 自带内部滚动,重复滚动条消除,阴影不再被裁)。
- **A5 暗色模式半成品(对比度不可读)**:暂禁用 `prefers-color-scheme:dark` 块,统一亮色主题,待引入 EP 暗色变量后启用。
- **topics.html 筛选表单响应式**6 个 `el-col``:xs/:sm/:md` 断点,移动端堆叠不溢出。
- **metrics.html 统计卡**`.stats-grid``repeat(4,1fr)` 改为 `repeat(auto-fit,minmax(180px,1fr))`,平板自适应列数。
- **factory.html**`.topic-grid``minmax(min(340px,100%),1fr)`,≤320px 屏不溢出。
### 二、盈利模式升级:白标 + 私有化部署
- **白标配置后端**:新增 `api/branding.py`
- `GET /api/branding`(公开,无需登录):返回 `brand_name/brand_logo/brand_primary_color/brand_support_email/version`
- `PUT /api/branding`(管理员):更新白标配置,写入 `system_configs`
- `main.py` 注册 `branding.router`
- **白标种子**`initial_data.py` 写入 `brand_name/brand_logo/brand_primary_color/brand_support_email` 默认。
- **前端应用品牌**`uni-nav.js` `mounted``/api/branding`,动态设置平台名、Logo、导航渐变主色,并写入 `--color-primary`;新增 `branding.html` 白标设置页(管理员,含实时预览)。
- **导航入口**`initial_data.py` 菜单加「白标设置」(admin),`uni-nav.js` 默认 admin 项补充。
- **部署包 `deploy/`**
- `Dockerfile`python:3.11-slim,默认 SQLite,零依赖)
- `docker-compose.yml`(含可选 PostgreSQL 服务与数据卷)
- `.env.example`(管理员密码 / LLM Key / 数据库)
- `README.md`(私有化 + 白标交付指南、定价建议)
### 验证
- 服务 `yzr-platform.service` 重启成功,无启动错误。
- `GET /api/branding` 返回种子默认值;`PUT` 管理员保存后持久化生效,重置回默认。
- `node --check uni-nav.js``ast.parse(branding.py)` 均通过。
### 待办(已解决,见第十六章)
- ~~`init_db()` 的 SQLite `topics.series` 迁移缺口(测试 32/33)~~:经复跑为陈旧 `test.db` 所致,当前 33/33 全过;迁移 fallback 对 SQLite 正常。
- 菜单幂等播种缺陷(见第十六章):`initial_data.py``if Menu.count()==0` 全有/全无逻辑导致已在库时章节四新增的"文章管理/内容工厂/白标设置"未入库,导航不可达,已修复。
---
## 第十五章:移动端响应式补全
**日期**2026-07-11
**目标**:审计并补全全部前端的移动端(≤420px)适配。
### 审计结论
- 已良好适配:topics / articles / campaigns / tasks / metrics / insights / platforms / assets / factory / index / landing / login / users / logs(表格均有 card-list 兜底或本就是卡片布局;固定宽弹窗由全局 `≤768px` 全宽规则兜底)。
- 真实破裂点(3 处 el-table 无移动端兜底):
1. `ai-slop.html` 问题明细表(line 47
2. `calendar.html` 日程弹窗内 `selectedDayEntries` 表(line 189
3. `admin.html` 平台配置表(line 202,缺 `data-table` 类,全局隐藏规则未覆盖)
### 修复
- 三处统一改为「`el-table`(`.card-table`/`.data-table`) + `.card-list-mobile` 卡片列表」的 `v-if`/`v-else` 兜底,沿用 `theme-modern.css``.card-list-mobile` 全局样式(≤768px 自动切换)。
- 桌面端表格保留,移动端自动转为可点列表,操作按钮(编辑/删除/查看)一并带入卡片。
- 校验:三页结构 `v-if`/`v-else` 配对正确,HTTP 200 正常服务。
### 结论
前端现已**完整支持移动端响应式**:所有数据表在窄屏均以卡片列表呈现,导航/筛选/弹窗/统计网格均自适应,无横向溢出。
---
## 第十六章:菜单播种缺陷修复(导航可达性)
**日期**2026-07-11
**触发**:生产服务重启后核对 `/api/menus/active`,发现导航缺少"文章管理 / 内容工厂 / 白标设置",而 `articles.html`(章节四重写)、`factory.html``branding.html` 均为真实页面 → 这些入口被"孤立",用户无法从导航进入。
### 根因
- `initial_data.py` 菜单播种原逻辑为 `if db.query(Menu).count() == 0:` —— **仅当菜单表完全为空时才播种**
- 生产库此前已有旧菜单集,故章节四新增的三个菜单从未插入;`uni-nav.js` 运行时以 DB 菜单覆盖 fallback,于是三个入口从导航消失。
- 另:旧库首页菜单 `path="index.html"`(无斜杠)与新种子 `"/index.html"` 不匹配,初次修复导致"工作台"重复出现(count 13)。
### 修复
- 菜单播种改为**按 path 幂等对齐**:已存在则更新 `sort_order/roles/icon/is_active`(保留原名称,避免意外重命名),缺失则插入。
- 新增首页路径归一化:若同时存在 `index.html``/index.html` 则删除旧的;若仅 `index.html` 则改路径为 `/index.html`,消除重复入口。
- `init_db()` 在每次启动执行,重启后自动对齐。
### 验证
- 重启 `yzr-platform.service``/api/menus/active` 返回 **12 个菜单**,顺序正确、无重复:工作台→选题→文章管理→内容工厂→数据→日历→素材→任务→外推营销→AI味检测→白标设置→系统。
- 12 个对应 HTML 页面均 HTTP 200。
- `python3 tests/test_new_features.py`:**33/33 通过**(菜单播种变更未破坏 seeding)。
### 当前完整导航(运营闭环)
选题 → 内容工厂生成 → 文章管理编辑/AI味检测 → 发布记录标记已发布(合规:系统不代发,真人校验后手动发布)。
---
## 第十七章:创作流程统一(导航去碎片化)
**日期**2026-07-11
**目标**:将分散的「选题 / 内容工厂 / 文章管理」三个独立导航入口合并为单一「创作工作台」,减少导航碎片化。
### 方案
采用 **`studio.html` 单一入口 + `el-tabs` 内嵌三个已有页面(`?embed=1` 模式)**,而非重写三套逻辑——复用已验证页面,零逻辑重复、风险最低。
### 实现
- **新增 `studio.html`「创作工作台」**`el-tabs` 三个标签页(选题 / 内容生成 / 文章管理),各以 `<iframe>` 懒加载对应页面(`topics.html?embed=1` 等);首次切到才加载,保留各页状态。整页 flex 布局填满视口,iframe `position:absolute; inset:0` 自适应。
- **嵌入模式 `?embed=1`**`topics.html` / `factory.html` / `articles.html``mounted` 检测 `embed` 参数 → `isEmbed=true``<body class="embed">`
- `<uni-nav>``v-if="!isEmbed"` 隐藏子页面自身导航(避免双层导航);
- `theme-modern.css` 新增 `body.embed .main-content { padding-top:0 }` 去掉顶部留白;
- 跨页链接(`redirectToPage` / `goToArticles`)在 embed 下改用 `window.top.location.href`,整页跳转而非 iframe 内嵌套。
- **导航收敛**`initial_data.py` 菜单种子以「创作工作台→studio.html」替换原三入口,并将 `topics.html/factory.html/articles.html``is_active=False`(页面仍可被 studio 内嵌与直接 URL 访问,不丢失功能);`uni-nav.js` fallback 同步更新。
- 排序:工作台(0) → 创作工作台(1) → 数据(2) → 日历(3) → 素材(4) → 任务(5) → 外推营销(6) → AI味检测(7) → 白标设置(8,admin) → 系统(9,admin)。
### 验证
- 重启 `yzr-platform.service``/api/menus/active` 返回 **10 个菜单**,含「创作工作台(studio.html)」,原三入口已不在主导航。
- `studio.html / topics.html / factory.html / articles.html` 均 HTTP 200studio 内 iframe `src``embed=1`
- 三个页面内联脚本 `node --check` 通过;`initial_data.py` 编译通过。
- `python3 tests/test_new_features.py`**33/33 通过**。
### 当前导航(10 项,创作闭环归于单一入口)
工作台 → **创作工作台(选题·生成·文章)** → 数据 / 日历 / 素材 / 任务 / 外推营销 / AI味检测 / 白标设置 / 系统。
@@ -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个要点图,口播语速放慢。
+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"]
+68
View File
@@ -0,0 +1,68 @@
# 宇之然内容创作平台 — 私有化 / 白标部署包
# 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/ # 本部署包
```
## 许可与交付建议
- 本包以「私有化实例」方式交付,每个客户一套独立部署(数据隔离)。
- 建议定价:私有化买断 + 年运维/更新订阅;或按实例数授权。
- 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:
+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 fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import desc from sqlalchemy import desc
from typing import Optional from typing import Optional
@@ -6,10 +12,18 @@ from datetime import datetime, timezone
from ..database import get_db from ..database import get_db
from ..models import ExternalProduct, PromotionCampaign, CampaignKeyword, SEOAudit, KeywordRanking, OptimizationTask, User 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"]) 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): def _org_filtered_query(db, model, current_user):
query = db.query(model) query = db.query(model)
@@ -467,3 +481,44 @@ def external_overview(
"type_breakdown": type_breakdown, "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""" """发布管理 API"""
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta, date
from typing import Optional, List from typing import Optional, List
from ..database import get_db from ..database import get_db
from ..models import Topic, PublishRecord, User from ..models import Topic, PublishRecord, User
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth import get_current_admin, org_filter from .auth import get_current_user, org_filter
from ..core.audit_logger import audit_log from ..core.audit_logger import audit_log
router = APIRouter(prefix="/api/publishing", tags=["publishing"]) router = APIRouter(prefix="/api/publishing", tags=["publishing"])
@@ -40,9 +40,9 @@ async def create_publish_record(
req: MultiPublishRequest, req: MultiPublishRequest,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin) current_user: User = Depends(get_current_user)
): ):
"""多平台发布选题(管理员""" """多平台发布选题(运营/管理员,按组织隔离"""
try: try:
q = db.query(Topic).filter(Topic.id == req.topic_id) q = db.query(Topic).filter(Topic.id == req.topic_id)
of = org_filter(current_user, Topic) of = org_filter(current_user, Topic)
@@ -66,17 +66,17 @@ async def create_publish_record(
record = PublishRecord( record = PublishRecord(
topic_id=req.topic_id, topic_id=req.topic_id,
platform=platform, platform=platform,
action='publish', action='stage',
status='success', status='pending_manual',
operator=operator, operator=operator,
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}", description=f"选题 {req.topic_id} 已生成 {PLATFORM_LABELS.get(platform, platform)} 发布稿,待人工校验后发布",
org_id=topic.org_id org_id=topic.org_id
) )
db.add(record) db.add(record)
results.append(PublishResult( results.append(PublishResult(
platform=platform, platform=platform,
platform_label=PLATFORM_LABELS.get(platform, platform), platform_label=PLATFORM_LABELS.get(platform, platform),
status='success' status='pending_manual'
)) ))
except Exception as e: except Exception as e:
results.append(PublishResult( results.append(PublishResult(
@@ -86,16 +86,17 @@ async def create_publish_record(
error_msg=str(e) error_msg=str(e)
)) ))
topic.status = '已发布' # 诚实状态流转:本项目无真实自动发布器(且按《AI生成内容标识办法》AI 稿需真人把关),
# 故仅将选题置为「待人工发布」,待运营在对应平台校验声明后手动发布。
topic.status = 'pending_publish'
_now = datetime.now(timezone(timedelta(hours=8))) _now = datetime.now(timezone(timedelta(hours=8)))
topic.updated_at = _now topic.updated_at = _now
topic.published_at = _now.date()
db.commit() db.commit()
db.expire_all() db.expire_all()
db.refresh(topic) db.refresh(topic)
audit_log( audit_log(
action="publish", action="publish_stage",
user=current_user, user=current_user,
resource_type="topic", resource_type="topic",
resource_id=req.topic_id, resource_id=req.topic_id,
@@ -105,15 +106,104 @@ async def create_publish_record(
db=db db=db
) )
success_count = sum(1 for r in results if r.status == 'success') staged = sum(1 for r in results if r.status == 'pending_manual')
return MultiPublishResponse( return MultiPublishResponse(
ok=success_count > 0, ok=staged == len(results),
topic_id=req.topic_id, topic_id=req.topic_id,
results=results, results=results,
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)" message=(
f"选题 {req.topic_id} 已生成 {staged}/{len(results)} 个平台的发布稿并置为「待人工发布」。"
f"AI 生成内容须由真人校验声明后手动发布,系统不代发。"
)
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/records")
def list_publish_records(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""列出发布记录(按组织隔离),用于运营查看「待人工发布 / 已发布」状态"""
q = db.query(PublishRecord)
of = org_filter(current_user, PublishRecord)
if of is not True:
q = q.filter(of)
recs = q.order_by(PublishRecord.created_at.desc()).all()
result = []
for r in recs:
topic = db.query(Topic).filter(Topic.id == r.topic_id).first()
result.append({
"id": r.id,
"topic_id": r.topic_id,
"topic_title": topic.title if topic else None,
"platform": r.platform,
"action": r.action,
"status": r.status,
"operator": r.operator,
"description": r.description,
"url": r.url,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
})
return {"records": result}
class MarkPublishedResponse(BaseModel):
ok: bool
topic_id: str
message: str
@router.post("/{topic_id}/mark-published", response_model=MarkPublishedResponse)
def mark_published(
topic_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""运营在对应平台手动发布后,回填「已发布」闭环状态"""
q = db.query(Topic).filter(Topic.id == topic_id)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
topic = q.first()
if not topic:
raise HTTPException(status_code=404, detail=f"选题 {topic_id} 不存在")
if topic.status != 'pending_publish':
raise HTTPException(status_code=400, detail=f"选题 {topic_id} 当前状态为「{topic.status}」,无需标记发布")
_now = datetime.now(timezone(timedelta(hours=8)))
topic.status = 'published'
topic.published_at = date.today()
topic.updated_at = _now
recs = db.query(PublishRecord).filter(
PublishRecord.topic_id == topic_id,
PublishRecord.status == 'pending_manual'
)
for r in recs:
r.status = 'published'
r.updated_at = _now
db.commit()
audit_log(
action="publish_confirm",
user=current_user,
resource_type="topic",
resource_id=topic_id,
details={"operator": current_user.username},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return MarkPublishedResponse(
ok=True,
topic_id=topic_id,
message=f"选题 {topic_id} 已标记为「已发布」,发布记录同步更新。"
)
+1 -2
View File
@@ -25,8 +25,7 @@ def _get_cmd(topic_ids: List[str] = None):
cmd = [str(venv_python), str(script_path)] if venv_python.exists() else ["python3", str(script_path)] cmd = [str(venv_python), str(script_path)] if venv_python.exists() else ["python3", str(script_path)]
if topic_ids: if topic_ids:
cmd.extend(["--topic-ids", ','.join(topic_ids)]) cmd.extend(["--topic-ids", ','.join(topic_ids)])
else: # 不再使用 --today-only,避免三平台串行撰写时小红书文章错过审查窗口
cmd.append("--today-only")
return cmd return cmd
def run_optimizer(topic_ids: List[str] = None): def run_optimizer(topic_ids: List[str] = None):
+54 -4
View File
@@ -58,6 +58,7 @@ MODULES = {
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"}, "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
"scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"}, "scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"},
"scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"}, "scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"},
"scheduled_shortvideo": {"name": "🎬 短视频拆条", "cron": "08:00"},
} }
LOG_FILE_MAP = { LOG_FILE_MAP = {
@@ -71,6 +72,7 @@ LOG_FILE_MAP = {
"scheduled_task_monitor": "task_monitor", "scheduled_task_monitor": "task_monitor",
"scheduled_rank_tracker": "rank_tracker", "scheduled_rank_tracker": "rank_tracker",
"scheduled_geo_tracker": "geo_tracker", "scheduled_geo_tracker": "geo_tracker",
"scheduled_shortvideo": "shortvideo",
} }
def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None): def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None):
@@ -188,6 +190,7 @@ class TaskScheduler:
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"), ("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"), ("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"),
("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"), ("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"),
("scheduled_shortvideo", self._run_shortvideo, "短视频拆条"),
] ]
for module_id, fn, name in MODULE_JOBS: for module_id, fn, name in MODULE_JOBS:
@@ -418,10 +421,11 @@ class TaskScheduler:
count = 0 count = 0
for topic in topics: for topic in topics:
platform_urls = topic.platform_urls or {} platform_urls = topic.platform_urls or {}
zhihu_url = platform_urls.get("zhihu", "") for platform, url in platform_urls.items():
if not zhihu_url: if not url:
continue continue
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', zhihu_url) if platform == "zhihu":
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', url)
if not m: if not m:
continue continue
post_id = m.group(1) post_id = m.group(1)
@@ -442,7 +446,7 @@ class TaskScheduler:
"comments": raw.get("comment_count", raw.get("comments_count", 0)), "comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0), "shares": raw.get("share_count", 0),
"last_fetched": datetime.now(), "last_fetched": datetime.now(),
"publish_url": zhihu_url, "publish_url": url,
"data_snapshot": raw, "data_snapshot": raw,
} }
if existing: if existing:
@@ -453,6 +457,12 @@ class TaskScheduler:
count += 1 count += 1
except Exception: except Exception:
continue continue
elif platform in ("wechat_mp", "xiaohongshu"):
logger.info("平台 %s 无公开指标 API,跳过", platform)
continue
else:
logger.info("平台 %s 暂无指标同步支持,跳过", platform)
continue
if count: if count:
db.commit() db.commit()
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count) logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
@@ -661,6 +671,46 @@ class TaskScheduler:
started_at=started, finished_at=datetime.now(timezone.utc)) started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[GeoTracker] AI 搜索引用追踪失败: %s", e) logger.exception("[GeoTracker] AI 搜索引用追踪失败: %s", e)
def _run_shortvideo(self):
"""每日将前一日「待发布」选题的长文拆条为短视频脚本"""
started = datetime.now(timezone.utc)
log_id = _log_task("scheduled_shortvideo", "running", started_at=started)
try:
from ..database import SessionLocal
from ..models import Topic
db = SessionLocal()
try:
topics = db.query(Topic).filter(
Topic.status.in_(["ready", "待发布", "pending_publish"])
).order_by(Topic.ready_at.desc().nullslast()).limit(3).all()
finally:
db.close()
if not topics:
_log_task("scheduled_shortvideo", "success", log_id=log_id,
message="无待拆条选题", started_at=started, finished_at=datetime.now(timezone.utc))
return
import subprocess
total = 0
for t in topics:
res = subprocess.run(
[sys.executable, str(PROJECT_ROOT / "scripts" / "shortvideo.py"), "--topic-id", t.id, "--count", "3"],
capture_output=True, text=True, timeout=600
)
if res.returncode == 0:
total += 1
_log_task("scheduled_shortvideo", "success", log_id=log_id,
message=f"拆条完成: {total}/{len(topics)} 选题",
result_data={"topics": len(topics), "done": total},
started_at=started, finished_at=datetime.now(timezone.utc))
logger.info("[ShortVideo] 拆条完成: %d/%d", total, len(topics))
except Exception as e:
import traceback
_log_task("scheduled_shortvideo", "failed", log_id=log_id,
message=str(e),
error_trace=traceback.format_exc(),
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[ShortVideo] 拆条失败: %s", e)
def get_jobs(self): def get_jobs(self):
"""返回当前所有定时任务的状态""" """返回当前所有定时任务的状态"""
jobs = [] jobs = []
+43 -6
View File
@@ -76,6 +76,17 @@ def import_initial_data():
for cfg in default_system_configs: for cfg in default_system_configs:
if db.query(SystemConfig).filter(SystemConfig.key == cfg["key"]).first() is None: if db.query(SystemConfig).filter(SystemConfig.key == cfg["key"]).first() is None:
db.add(SystemConfig(**cfg)) db.add(SystemConfig(**cfg))
# 白标默认值(私有化部署/卖给他人时可改)
default_branding = [
{"key": "brand_name", "value": "宇之然内容创作平台", "description": "白标:平台名称"},
{"key": "brand_logo", "value": "", "description": "白标:Logo URL(留空用文字)"},
{"key": "brand_primary_color", "value": "#2563eb", "description": "白标:主色(导航/按钮)"},
{"key": "brand_support_email", "value": "", "description": "白标:支持邮箱"},
]
for b in default_branding:
if db.query(SystemConfig).filter(SystemConfig.key == b["key"]).first() is None:
db.add(SystemConfig(**b))
if db.query(SystemConfig).filter(SystemConfig.key == "review_llm_id").first() is None: if db.query(SystemConfig).filter(SystemConfig.key == "review_llm_id").first() is None:
first_llm = db.query(LLMConfig).filter(LLMConfig.is_active == True).first() first_llm = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if first_llm: if first_llm:
@@ -294,21 +305,47 @@ def import_initial_data():
db.commit() db.commit()
print("✅ 插入默认角色") print("✅ 插入默认角色")
# 初始化默认菜单(与 uni-nav.js 对齐) # 归一化首页菜单路径,避免 index.html 与 /index.html 产生重复入口
if db.query(Menu).count() == 0: idx_plain = db.query(Menu).filter(Menu.path == "index.html").first()
idx_slash = db.query(Menu).filter(Menu.path == "/index.html").first()
if idx_plain and not idx_slash:
idx_plain.path = "/index.html"
db.commit()
elif idx_plain and idx_slash:
db.delete(idx_plain)
db.commit()
# 初始化默认菜单:幂等按 path 对齐(已有时更新排序/角色,缺失时插入)。
# 运行时会用 DB 菜单覆盖 uni-nav.js 的 fallback,因此这里必须保证文章管理/内容工厂/白标设置等入口存在。
default_menus = [ default_menus = [
{"name": "仪表盘", "path": "/", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]}, {"name": "工作台", "path": "/index.html", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
{"name": "选题", "path": "topics.html", "icon": "IconTopic", "sort_order": 1, "roles": ["admin", "editor"]}, {"name": "创作工作台", "path": "studio.html", "icon": "IconEdit", "sort_order": 1, "roles": ["admin", "editor"]},
{"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]}, {"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]},
{"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]}, {"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]},
{"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]}, {"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]},
{"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]}, {"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]},
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 6, "roles": ["admin"]}, {"name": "外推营销", "path": "campaigns.html", "icon": "IconStar", "sort_order": 6, "roles": ["admin", "editor"]},
{"name": "AI味检测", "path": "ai-slop.html", "icon": "IconSearch", "sort_order": 7, "roles": ["admin", "editor"]},
{"name": "白标设置", "path": "branding.html", "icon": "IconSetting", "sort_order": 8, "roles": ["admin"]},
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 9, "roles": ["admin"]},
] ]
for m in default_menus: for m in default_menus:
found = db.query(Menu).filter(Menu.path == m["path"]).first()
if found:
found.sort_order = m["sort_order"]
found.roles = m["roles"]
found.icon = m["icon"]
found.is_active = True
# 保留原名称,避免意外重命名
else:
db.add(Menu(**m)) db.add(Menu(**m))
# 原独立的选题/内容工厂/文章管理已合并进「创作工作台」,从主导航隐藏(页面仍可被 studio 内嵌与直接访问)
for legacy in ["topics.html", "factory.html", "articles.html"]:
legacy_menu = db.query(Menu).filter(Menu.path == legacy).first()
if legacy_menu:
legacy_menu.is_active = False
db.commit() db.commit()
print("插入默认菜单") print("✅ 菜单已对齐(创作工作台合并选题/内容工厂/文章管理)")
# 同步 PostgreSQL 自增序列 # 同步 PostgreSQL 自增序列
if os.getenv('USE_POSTGRES', 'true').lower() == 'true': if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
+3 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db from .database import engine, get_db, init_db
from .models import Base from .models import Base
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers, search_rankings, 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 .initial_data import import_initial_data
from .core.scheduler import scheduler 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_providers.router)
app.include_router(search_rankings.router) app.include_router(search_rankings.router)
app.include_router(external_promotion.router) app.include_router(external_promotion.router)
app.include_router(ai_slop.router)
app.include_router(branding.router)
# 挂载自动生成的图片(必须先于前端根挂载) # 挂载自动生成的图片(必须先于前端根挂载)
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
+18 -1
View File
@@ -199,7 +199,8 @@
<div class="empty-text">暂无平台配置</div> <div class="empty-text">暂无平台配置</div>
</div> </div>
</template> </template>
<el-table v-else :data="platformConfigs" border stripe style="width:100%"> <template v-else>
<el-table class="data-table" :data="platformConfigs" border stripe style="width:100%">
<el-table-column prop="platform" label="标识" width="120"></el-table-column> <el-table-column prop="platform" label="标识" width="120"></el-table-column>
<el-table-column prop="name" label="名称" width="120"></el-table-column> <el-table-column prop="name" label="名称" width="120"></el-table-column>
<el-table-column prop="icon" label="图标" width="100"> <el-table-column prop="icon" label="图标" width="100">
@@ -230,6 +231,22 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="card-list-mobile">
<div v-for="pc in platformConfigs" :key="pc.platform" class="card-item">
<div class="card-row"><span class="card-label">标识</span><span class="card-value">{{ pc.platform }}</span></div>
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ pc.name }}</span></div>
<div class="card-row"><span class="card-label">图标</span><span class="card-value">{{ pc.icon || '—' }}</span></div>
<div class="card-row"><span class="card-label">网址</span><span class="card-value" style="word-break:break-all;">{{ pc.website_url || '—' }}</span></div>
<div class="card-row"><span class="card-label">字数</span><span class="card-value">{{ pc.min_words }} - {{ pc.max_words }}</span></div>
<div class="card-row"><span class="card-label">配图</span><span class="card-value">{{ pc.requires_image ? pc.image_count_min + '-' + pc.image_count_max + '张' : '不需要' }}</span></div>
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ pc.is_active ? '是' : '否' }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showPcDialogFn(pc)">编辑</el-button>
<el-button size="small" type="danger" @click="deletePlatformConfig(pc.platform)">删除</el-button>
</div>
</div>
</div>
</template>
<el-dialog v-model="showPcDialog" :title="pcDialogTitle" width="750px" :close-on-click-modal="false"> <el-dialog v-model="showPcDialog" :title="pcDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="pcForm" label-width="100px"> <el-form :model="pcForm" label-width="100px">
<el-row :gutter="16"> <el-row :gutter="16">
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - AI味检测</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="AI味检测" :username="currentUser.username" :is-admin="isAdmin" current-page="ai-slop" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> AI味检测 + 净化</h2>
</div>
<div class="filter-bar">
<el-input v-model="topicId" placeholder="输入选题 ID,如 B02" clearable size="default" style="width:280px;" @keyup.enter="runReport">
<template #prepend>选题</template>
</el-input>
<el-button type="primary" :loading="loadingReport" @click="runReport">检测</el-button>
</div>
<el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" style="margin-bottom:12px;"></el-alert>
<div v-if="!loadingReport && report && report.platforms.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
该选题暂无文章,请先创作内容。
</div>
<div v-for="p in (report ? report.platforms : [])" :key="p.platform" style="margin-bottom:20px;">
<el-card shadow="hover">
<template #header>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<strong style="font-size:16px;">{{ platformLabel(p.platform) }}</strong>
<el-tag :type="p.passed ? 'success' : 'danger'" size="small">{{ p.passed ? '通过' : '未通过' }}</el-tag>
<span style="font-size:13px;color:#909399;">合规分:<b :style="{color: p.score >= 80 ? '#67c23a' : (p.score >= 60 ? '#e6a23c' : '#f56c6c')}">{{ p.score }}</b></span>
<el-button size="small" type="warning" :loading="p.purifying" @click="purify(p)" style="margin-left:auto;">一键净化</el-button>
</div>
</template>
<div v-if="p.message" style="margin-bottom:10px;color:#67c23a;font-size:13px;">{{ p.message }}</div>
<div v-if="p.score_before !== null" style="margin-bottom:10px;font-size:13px;color:#606266;">
净化前分:<b>{{ p.score_before }}</b> &nbsp;&nbsp; 净化后分:<b :style="{color:'#67c23a'}">{{ p.score_after }}</b>
<span style="margin-left:12px;">问题数:{{ p.issues_before.length }} → {{ p.issues_after.length }}</span>
</div>
<div v-if="p.issues.length === 0" style="color:#67c23a;font-size:14px;">✅ 未检测到明显问题</div>
<template v-else>
<el-table class="card-table" :data="p.issues" size="small" stripe>
<el-table-column prop="type" label="类型" width="110">
<template #default="s"><el-tag :type="s.row.severity === 'high' ? 'danger' : 'warning'" size="small">{{ s.row.type }}</el-tag></template>
</el-table-column>
<el-table-column prop="category" label="类别" width="130" show-overflow-tooltip></el-table-column>
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="suggestion" label="建议" min-width="180" show-overflow-tooltip></el-table-column>
</el-table>
<div class="card-list-mobile">
<div v-for="iss in p.issues" :key="iss.type + iss.detail" class="card-item">
<div class="card-row"><span class="card-label">类型</span><span class="card-value"><el-tag :type="iss.severity === 'high' ? 'danger' : 'warning'" size="small">{{ iss.type }}</el-tag></span></div>
<div class="card-row"><span class="card-label">类别</span><span class="card-value">{{ iss.category }}</span></div>
<div class="card-row"><span class="card-label">详情</span><span class="card-value">{{ iss.detail }}</span></div>
<div class="card-row"><span class="card-label">建议</span><span class="card-value">{{ iss.suggestion }}</span></div>
</div>
</div>
</template>
</el-card>
</div>
</div>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const AiSlopApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
topicId: '', loadingReport: false, report: null, errorMsg: ''
}
},
methods: {
getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) {
const token = this.getToken();
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/login.html', 1500); return null; }
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...(opts.headers || {}) }, ...opts });
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
return res.json();
},
async runReport() {
this.errorMsg = '';
if (!this.topicId.trim()) { this.$message.warning('请输入选题 ID'); return; }
this.loadingReport = true;
this.report = null;
try {
const data = await this.api('/api/ai-slop/report?topic_id=' + encodeURIComponent(this.topicId.trim()));
this.report = { topic_id: data.topic_id, platforms: (data.platforms || []).map(p => ({ ...p, purifying: false, message: '', score_before: null, score_after: null, issues_before: [], issues_after: [] })) };
} catch (error) {
this.errorMsg = error.message;
} finally { this.loadingReport = false; }
},
async purify(p) {
p.purifying = true; p.message = '';
try {
const data = await this.api('/api/ai-slop/purify', {
method: 'POST',
body: JSON.stringify({ topic_id: this.topicId.trim(), platform: p.platform })
});
p.score_before = data.score_before;
p.score_after = data.score_after;
p.issues_before = data.issues_before || [];
p.issues_after = data.issues_after || [];
p.issues = data.issues_after || [];
p.score = data.score_after;
p.passed = (data.score_after !== null && data.issues_after.length === 0) || (data.issues_after && data.issues_after.every(i => i.severity !== 'high')) || (p.score >= 80);
p.passed = data.score_after !== null ? (data.issues_after || []).every(i => i.severity !== 'high') : p.passed;
p.message = data.message || '净化完成';
this.$message.success('净化完成,合规分 ' + data.score_before + ' → ' + data.score_after);
} catch (error) {
this.$message.error('净化失败: ' + error.message);
} finally { p.purifying = false; }
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
const params = new URLSearchParams(window.location.search);
const tid = params.get('topic_id');
if (tid) this.topicId = tid;
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true;
if (tid) this.runReport();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(AiSlopApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
+129 -17
View File
@@ -24,10 +24,12 @@
</head> </head>
<body> <body>
<div id="app" v-cloak> <div id="app" v-cloak>
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout"> <uni-nav v-if="!isEmbed" title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav> </uni-nav>
<div class="main-content"> <div class="main-content">
<main class="content-area"> <main class="content-area">
<el-tabs v-model="activeTab">
<el-tab-pane label="文章管理" name="articles">
<div class="card page-fade"> <div class="card page-fade">
<div class="page-header"> <div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2> <h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
@@ -65,17 +67,14 @@
<el-table-column prop="word_count" label="字数" width="70"> <el-table-column prop="word_count" label="字数" width="70">
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template> <template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="配图" width="60">
<template #default="scope">
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
<span v-else style="color:#dcdfe6;"></span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column> <el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="130" fixed="right"> <el-table-column label="操作" width="210" fixed="right">
<template #default="scope"> <template #default="scope">
<div style="display: flex; gap: 4px;"> <div style="display: flex; gap: 4px; flex-wrap: wrap;">
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button> <el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
<el-button size="small" @click="editArticle(scope.row)" style="padding:5px 8px;">编辑</el-button>
<el-button size="small" type="warning" @click="openSlop(scope.row)" style="padding:5px 8px;">检测</el-button>
<el-button size="small" type="success" @click="goPublish()" style="padding:5px 8px;">发布</el-button>
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button> <el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
</div> </div>
</template> </template>
@@ -96,6 +95,9 @@
</div> </div>
<div class="article-card-actions"> <div class="article-card-actions">
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button> <el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
<el-button size="small" @click="editArticle(article)">编辑</el-button>
<el-button size="small" type="warning" @click="openSlop(article)">检测</el-button>
<el-button size="small" type="success" @click="goPublish()">发布</el-button>
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button> <el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
</div> </div>
</div> </div>
@@ -104,6 +106,48 @@
<el-icon><IconDocument /></el-icon> 暂无文章 <el-icon><IconDocument /></el-icon> 暂无文章
</div> </div>
</div> </div>
</el-tab-pane>
<el-tab-pane label="发布记录" name="publish">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconUpload /></el-icon> 发布记录</h2>
<el-tag type="info" size="small">AI 内容需真人校验后发布,系统不代发</el-tag>
</div>
<el-table :data="publishRecords" stripe v-loading="loadingRecords">
<el-table-column prop="topic_id" label="选题 ID" width="90"></el-table-column>
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<template #default="scope"><el-tag :type="scope.row.status === 'published' ? 'success' : 'warning'" size="small">{{ scope.row.status === 'published' ? '已发布' : '待人工发布' }}</el-tag></template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="100"></el-table-column>
<el-table-column prop="created_at" label="生成时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button v-if="scope.row.status === 'pending_manual'" size="small" type="success" @click="markRecord(scope.row)" style="padding:5px 8px;">标记已发布</el-button>
<span v-else style="color:#909399;font-size:12px;"></span>
</template>
</el-table-column>
</el-table>
<div class="card-list-mobile" v-if="publishRecords && publishRecords.length > 0">
<div v-for="rec in publishRecords" :key="rec.id" class="card-item">
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ rec.topic_title || rec.topic_id }}</span></div>
<div class="card-row"><span class="card-label">平台</span><span class="card-value">{{ platformLabel(rec.platform) }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="rec.status === 'published' ? 'success' : 'warning'" size="small">{{ rec.status === 'published' ? '已发布' : '待人工发布' }}</el-tag></span></div>
<div class="card-row"><span class="card-label">操作人</span><span class="card-value">{{ rec.operator || '-' }}</span></div>
<div class="card-actions" v-if="rec.status === 'pending_manual'">
<el-button size="small" type="success" @click="markRecord(rec)">标记已发布</el-button>
</div>
</div>
</div>
<div v-if="!loadingRecords && publishRecords.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
<el-icon><IconUpload /></el-icon> 暂无发布记录
</div>
</div>
</el-tab-pane>
</el-tabs>
</main> </main>
</div> </div>
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape :lock-scroll="false"> <el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape :lock-scroll="false">
@@ -136,19 +180,31 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="editVisible" title="编辑正文" width="85%" :close-on-click-modal="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:12px;">修改后保存即覆盖该平台文章,发布前请先完成 AI 味检测与人工校验。</el-alert>
<el-input type="textarea" :rows="20" v-model="editForm.html_content" style="font-family:monospace;"></el-input>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="savingEdit" @click="saveArticle">保存</el-button>
</template>
</el-dialog>
</div> </div>
<script src="vue.global.prod.js"></script> <script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script> <script src="icon-components.js"></script>
<script src="element-plus.full.js"></script> <script src="element-plus.full.js"></script>
<script> <script>
const ArticlesApp = { const ArticlesApp = {
data() { data() {
return { return {
isEmbed: false,
isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
activeTab: 'articles',
loadingTable: false, loadingTable: false,
articles: [], articles: [],
filterPlatform: '', filterStatus: '', searchQuery: '', filterPlatform: '', filterStatus: '', searchQuery: '',
previewVisible: false, previewArticleData: null, previewFullscreen: false, previewVisible: false, previewArticleData: null, previewFullscreen: false,
editVisible: false, savingEdit: false, editForm: { topic_id: '', platform: '', html_content: '' },
publishRecords: [], loadingRecords: false,
debounceTimer: null debounceTimer: null
} }
}, },
@@ -164,6 +220,9 @@ const ArticlesApp = {
return list; return list;
} }
}, },
watch: {
activeTab(v) { if (v === 'publish') this.fetchRecords(); }
},
methods: { methods: {
getToken() { return localStorage.getItem('authToken'); }, getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) { async api(url, opts = {}) {
@@ -188,6 +247,16 @@ const ArticlesApp = {
this.articles = []; this.articles = [];
} finally { this.loadingTable = false; } } finally { this.loadingTable = false; }
}, },
async fetchRecords() {
this.loadingRecords = true;
try {
const data = await this.api('/api/publishing/records');
this.publishRecords = data.records || [];
} catch (error) {
this.$message.error('加载发布记录失败: ' + error.message);
this.publishRecords = [];
} finally { this.loadingRecords = false; }
},
debouncedSearch() { debouncedSearch() {
clearTimeout(this.debounceTimer); clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400); this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
@@ -204,6 +273,48 @@ const ArticlesApp = {
this.previewVisible = false; this.previewVisible = false;
} }
}, },
async editArticle(article) {
this.editForm = { topic_id: article.topic_id, platform: article.platform, html_content: '' };
this.editVisible = true;
this.savingEdit = false;
try {
const data = await this.api('/api/articles/detail/' + article.id);
this.editForm.html_content = data.html_content || '';
} catch (error) {
this.$message.error('加载正文失败: ' + error.message);
this.editVisible = false;
}
},
async saveArticle() {
this.savingEdit = true;
try {
await this.api('/api/articles/' + encodeURIComponent(this.editForm.topic_id) + '/content', {
method: 'PUT',
body: JSON.stringify({ platform: this.editForm.platform, html_content: this.editForm.html_content })
});
this.$message.success('正文已保存');
this.editVisible = false;
await this.fetchArticles();
} catch (error) {
this.$message.error('保存失败: ' + error.message);
} finally { this.savingEdit = false; }
},
openSlop(article) {
window.open('ai-slop.html?topic_id=' + encodeURIComponent(article.topic_id), '_blank');
},
goPublish() { this.redirectToPage('topics.html'); },
async markRecord(rec) {
try {
await this.$confirm('确认已在各平台完成发布?', '标记发布', { type: 'warning' });
await this.api('/api/publishing/' + encodeURIComponent(rec.topic_id) + '/mark-published', { method: 'POST' });
this.$message.success('已标记为已发布');
await this.fetchRecords();
} catch (e) {
if (e === 'cancel') return;
const msg = e?.response?.data?.detail || e?.message || '未知错误';
this.$message.error('操作失败: ' + msg);
}
},
togglePreviewFullscreen() { togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen; this.previewFullscreen = !this.previewFullscreen;
this.$nextTick(() => { this.$nextTick(() => {
@@ -243,7 +354,7 @@ const ArticlesApp = {
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); } } catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
}, },
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; }, handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; }, redirectToPage(page) { const url = page.startsWith('/') ? page : '/' + page; if (this.isEmbed) window.top.location.href = url; else window.location.href = url; },
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; }, platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; }, statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; }, statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
@@ -256,17 +367,18 @@ const ArticlesApp = {
mounted() { mounted() {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; } if (!token) { window.location.href = '/login.html'; return; }
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } }) fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject()) .then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); }) .then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; }); .catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
} }
}; };
const app = Vue.createApp(ArticlesApp); const app = Vue.createApp(ArticlesApp);
app.use(ElementPlus); app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); } if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); } if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app'); app.mount('#app');
</script> </script>
<script src="ai-assistant.js"></script> <script src="ai-assistant.js"></script>
</body> </body>
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 白标设置</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="白标设置" :username="currentUser.username" :is-admin="isAdmin" current-page="branding" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 白标 / 私有化品牌设置</h2>
<el-tag type="info" size="small">仅管理员可见</el-tag>
</div>
<el-alert type="success" :closable="false" show-icon style="margin-bottom:16px;">
<template #title>私有化交付说明</template>
修改以下配置即可将本平台改名为贵司品牌(名称 / Logo / 主色 / 支持邮箱),保存后前台导航与品牌色即时生效。适合部署到客户内网或作为白标产品交付。
</el-alert>
<el-form label-width="110px" style="max-width:640px;">
<el-form-item label="平台名称">
<el-input v-model="form.brand_name" placeholder="如:星辰内容中台" maxlength="20" show-word-limit></el-input>
</el-form-item>
<el-form-item label="Logo 地址">
<el-input v-model="form.brand_logo" placeholder="https 图片 URL,留空则显示文字名称"></el-input>
</el-form-item>
<el-form-item label="主色">
<el-color-picker v-model="form.brand_primary_color"></el-color-picker>
<el-input v-model="form.brand_primary_color" style="width:120px;margin-left:12px;" placeholder="#2563eb"></el-input>
</el-form-item>
<el-form-item label="支持邮箱">
<el-input v-model="form.brand_support_email" placeholder="support@yourcompany.com"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存并应用</el-button>
<el-button @click="reset">重置为当前</el-button>
</el-form-item>
</el-form>
<el-divider>实时预览</el-divider>
<div :style="{ background: 'linear-gradient(135deg,' + (form.brand_primary_color || '#2563eb') + ',' + (form.brand_primary_color || '#2563eb') + 'cc)', color:'#fff', padding:'10px 16px', borderRadius:'8px', display:'flex', alignItems:'center', gap:'8px', maxWidth:'480px' }">
<img v-if="form.brand_logo" :src="form.brand_logo" style="height:22px;border-radius:4px;" />
<strong>{{ form.brand_name || '平台名称预览' }}</strong>
<span style="margin-left:auto;font-size:12px;opacity:.85;">v{{ version }}</span>
</div>
</div>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const BrandingApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
form: { brand_name: '', brand_logo: '', brand_primary_color: '#2563eb', brand_support_email: '' },
version: '0.1.0', saving: false,
}
},
methods: {
getToken() { return localStorage.getItem('authToken'); },
async load() {
try {
const data = await fetch('/api/branding').then(r => r.json());
this.form = {
brand_name: data.brand_name || '',
brand_logo: data.brand_logo || '',
brand_primary_color: data.brand_primary_color || '#2563eb',
brand_support_email: data.brand_support_email || '',
};
this.version = data.version || '0.1.0';
} catch (e) { this.$message.error('加载品牌配置失败'); }
},
async save() {
this.saving = true;
try {
const token = this.getToken();
const res = await fetch('/api/branding', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
if (!res.ok) throw new Error((await res.json()).detail || '保存失败');
this.$message.success('已保存,刷新其他页面即可看到新品牌');
setTimeout(() => this.load(), 300);
} catch (e) {
this.$message.error('保存失败:' + e.message);
} finally { this.saving = false; }
},
reset() { this.load(); },
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user; this.isAdmin = data.user.role === 'admin';
if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/index.html'; return; }
this.load();
})
.catch(() => { window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(BrandingApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
+14 -1
View File
@@ -186,7 +186,8 @@
<div class="empty-text">该日暂无日程</div> <div class="empty-text">该日暂无日程</div>
<p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p> <p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p>
</div> </div>
<el-table v-else :data="selectedDayEntries" stripe> <template v-else>
<el-table class="card-table" :data="selectedDayEntries" stripe>
<el-table-column prop="title" label="标题" min-width="120"></el-table-column> <el-table-column prop="title" label="标题" min-width="120"></el-table-column>
<el-table-column prop="platform" label="平台" width="100"> <el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}</template> <template #default="scope">{{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}</template>
@@ -201,6 +202,18 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="card-list-mobile">
<div v-for="entry in selectedDayEntries" :key="entry.id || entry.topic_id || entry.title" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ entry.title }}</span></div>
<div class="card-row"><span class="card-label">平台</span><span class="card-value">{{ entry._source === 'topic' ? (entry.status === 'published' ? '已发布' : '选题') : platformName(entry.platform) }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="statusType(entry.status)" size="small">{{ statusLabel(entry.status) }}</el-tag></span></div>
<div class="card-actions">
<el-button v-if="entry._source === 'topic'" size="small" type="primary" @click="openEntryDialog(entry)">查看</el-button>
<el-button v-else size="small" @click="openEntryDialog(entry)">编辑</el-button>
</div>
</div>
</div>
</template>
</div> </div>
</el-dialog> </el-dialog>
+8 -4
View File
@@ -195,7 +195,7 @@
/* ========== Topic Cards (待创作) ========== */ /* ========== Topic Cards (待创作) ========== */
.topic-grid { .topic-grid {
display: 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); gap: var(--spacing-md);
} }
.topic-card { .topic-card {
@@ -565,7 +565,7 @@
</head> </head>
<body> <body>
<div id="app" v-cloak> <div id="app" v-cloak>
<uni-nav title="内容工厂" :username="currentUser.username" :is-admin="isAdmin" current-page="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> </uni-nav>
<div class="main-content"> <div class="main-content">
@@ -838,6 +838,7 @@
const FactoryApp = { const FactoryApp = {
data() { data() {
return { return {
isEmbed: false,
isLoggedIn: false, isLoggedIn: false,
isAdmin: false, isAdmin: false,
currentUser: { username: '' }, currentUser: { username: '' },
@@ -1037,7 +1038,7 @@ const FactoryApp = {
}); });
}, },
goToArticles() { goToArticles() {
window.location.href = '/articles.html'; this.redirectToPage('/articles.html');
}, },
handleLogout() { handleLogout() {
localStorage.removeItem('authToken'); localStorage.removeItem('authToken');
@@ -1046,7 +1047,9 @@ const FactoryApp = {
window.location.href = '/login.html'; window.location.href = '/login.html';
}, },
redirectToPage(page) { 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() { mounted() {
@@ -1055,6 +1058,7 @@ const FactoryApp = {
window.location.href = '/login.html'; window.location.href = '/login.html';
return; return;
} }
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
fetch('/api/auth/me', { fetch('/api/auth/me', {
headers: { 'Authorization': 'Bearer ' + token } headers: { 'Authorization': 'Bearer ' + token }
}) })
+1 -1
View File
@@ -13,7 +13,7 @@
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); } .stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
.stat-value { font-size: 32px; font-weight: 700; } .stat-value { font-size: 32px; font-weight: 700; }
.stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; } .stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 24px; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-bottom: 24px; }
.chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; } .chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; }
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } .platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 768px) { @media (max-width: 768px) {
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 创作工作台</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
html, body { margin: 0; height: 100%; }
[v-cloak] { display: none; }
#app { display: flex; flex-direction: column; height: 100vh; }
.studio-body { flex: 1; display: flex; flex-direction: column; min-height: 0; padding-top: var(--nav-height); }
.studio-tabs { flex: 0 0 auto; padding: 0 16px; background: var(--color-bg-raised); border-bottom: 1px solid var(--color-border); }
.studio-tabs .el-tabs__header { margin-bottom: 0; }
.studio-iframe-wrap { flex: 1; position: relative; min-height: 0; background: var(--color-bg); }
.studio-iframe-wrap iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: none; }
@media (max-width: 768px) { .studio-tabs { padding: 0 8px; } }
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="创作工作台" :username="currentUser.username" :is-admin="isAdmin" current-page="studio" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="studio-body">
<div class="studio-tabs">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="选题" name="topics"></el-tab-pane>
<el-tab-pane label="内容生成" name="factory"></el-tab-pane>
<el-tab-pane label="文章管理" name="articles"></el-tab-pane>
</el-tabs>
</div>
<div class="studio-iframe-wrap">
<iframe v-if="tabs.topics" src="topics.html?embed=1" frameborder="0"></iframe>
<iframe v-if="tabs.factory" src="factory.html?embed=1" frameborder="0"></iframe>
<iframe v-if="tabs.articles" src="articles.html?embed=1" frameborder="0"></iframe>
</div>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.js"></script>
<script src="element-plus-icons.js"></script>
<script>
const { createApp } = Vue;
const StudioApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
activeTab: 'topics',
tabs: { topics: true, factory: false, articles: false },
};
},
methods: {
onTabChange(name) { this.tabs[name] = true; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
handleLogout() {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
window.location.href = '/login.html';
},
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(StudioApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+12 -5
View File
@@ -68,7 +68,10 @@
--content-max-width: 1400px; --content-max-width: 1400px;
} }
/* ========== Dark Mode ========== */ /* ========== Dark Mode ==========
注意:Element Plus 组件库为亮色主题,未同步切换会导致弹窗/卡片内
自定义文字在深色变量下不可读。当前明确采用亮色主题以保证对比度,
待引入 EP 暗色变量(dark theme vars)后再启用完整暗色模式。
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--color-bg: #1a1a2e; --color-bg: #1a1a2e;
@@ -76,12 +79,10 @@
--color-bg-subtle: #1a1a3e; --color-bg-subtle: #1a1a3e;
--color-border: #2a2a4e; --color-border: #2a2a4e;
--color-border-hover: #4a4a6e; --color-border-hover: #4a4a6e;
--color-text-primary: #e0e6ed; --color-text-primary: #e0e6ed;
--color-text-regular: #a0a6b0; --color-text-regular: #a0a6b0;
--color-text-secondary: #6b7280; --color-text-secondary: #6b7280;
--color-text-placeholder: #4a4a5e; --color-text-placeholder: #4a4a5e;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3); --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 2px 12px rgba(0,0,0,0.3); --shadow-md: 0 2px 12px rgba(0,0,0,0.3);
--shadow-lg: 0 8px 28px rgba(0,0,0,0.4); --shadow-lg: 0 8px 28px rgba(0,0,0,0.4);
@@ -90,6 +91,7 @@
.card { background: var(--color-bg-raised); } .card { background: var(--color-bg-raised); }
.el-table { --el-table-tr-bg: var(--color-bg-raised); --el-table-header-bg: var(--color-bg-subtle); } .el-table { --el-table-tr-bg: var(--color-bg-raised); --el-table-header-bg: var(--color-bg-subtle); }
} }
*/
/* ========== Reset & Base ========== */ /* ========== Reset & Base ========== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
@@ -119,6 +121,12 @@ body {
margin: 0 auto; margin: 0 auto;
min-height: calc(100vh - var(--nav-height)); min-height: calc(100vh - var(--nav-height));
} }
/* 嵌入模式(被创作工作台 iframe 加载时):去掉顶部导航留白 */
body.embed .main-content {
padding-top: 0 !important;
min-height: 100vh;
}
.content-area { flex: 1; padding: var(--spacing-md); overflow-y: auto; } .content-area { flex: 1; padding: var(--spacing-md); overflow-y: auto; }
/* ========== Card ========== */ /* ========== Card ========== */
@@ -128,7 +136,6 @@ body {
padding: var(--spacing-lg); padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg); margin-bottom: var(--spacing-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
overflow-x: auto;
transition: all 0.3s var(--ease-out); transition: all 0.3s var(--ease-out);
} }
@@ -405,7 +412,7 @@ body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index
} }
@media (min-width: 769px) { @media (min-width: 769px) {
.preview-iframe { min-height: 400px; } .preview-iframe { min-height: 400px; }
.preview-dialog-custom { position: relative; left: 90px; } .preview-dialog-custom { position: relative; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
+43 -17
View File
@@ -33,7 +33,7 @@
</head> </head>
<body> <body>
<div id="app" v-cloak> <div id="app" v-cloak>
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout"> <uni-nav v-if="!isEmbed" title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav> </uni-nav>
<div class="main-content"> <div class="main-content">
<main class="content-area"> <main class="content-area">
@@ -56,6 +56,7 @@
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button> <el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button>
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button> <el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button> <el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
<el-button size="default" :type="filterStatus === 'pending_publish' ? 'primary' : ''" @click="filterStatus = 'pending_publish'; fetchTopics()">待人工发布 ({{ statusStats.pendingPublish }})</el-button>
</div> </div>
<div style="margin-bottom:10px;"> <div style="margin-bottom:10px;">
<span class="search-toggle" @click="showSearch = !showSearch">🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}</span> <span class="search-toggle" @click="showSearch = !showSearch">🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}</span>
@@ -64,30 +65,30 @@
<div v-if="showSearch" class="search-area"> <div v-if="showSearch" class="search-area">
<el-form :model="searchForm" size="small" label-width="70px" @submit.prevent> <el-form :model="searchForm" size="small" label-width="70px" @submit.prevent>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="6"><el-form-item label="选题ID"><el-input v-model="searchForm.id" placeholder="如 A07" clearable @input="doSearch"></el-input></el-form-item></el-col> <el-col :xs="24" :sm="12" :md="6"><el-form-item label="选题ID"><el-input v-model="searchForm.id" placeholder="如 A07" clearable @input="doSearch"></el-input></el-form-item></el-col>
<el-col :span="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="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 :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 :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="5"><el-form-item label="栏目"><el-select v-model="searchForm.series" clearable placeholder="全部" style="width:100%;" @change="doSearch"><el-option v-for="s in seriesOptions" :key="s" :label="s" :value="s"></el-option></el-select></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"><el-form-item label="创建时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="创建时间">
<el-date-picker v-model="searchForm.createdStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.createdStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.createdEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.createdEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
</el-form-item></el-col> </el-form-item></el-col>
<el-col :span="12"><el-form-item label="创作时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="创作时间">
<el-date-picker v-model="searchForm.generatedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.generatedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.generatedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.generatedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
</el-form-item></el-col> </el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"><el-form-item label="审查时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="审查时间">
<el-date-picker v-model="searchForm.reviewedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.reviewedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.reviewedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.reviewedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
</el-form-item></el-col> </el-form-item></el-col>
<el-col :span="12"><el-form-item label="发布时间"> <el-col :xs="24" :sm="24" :md="12"><el-form-item label="发布时间">
<el-date-picker v-model="searchForm.publishedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.publishedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
<span style="margin:0 4px;color:#909399;">~</span> <span style="margin:0 4px;color:#909399;">~</span>
<el-date-picker v-model="searchForm.publishedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker> <el-date-picker v-model="searchForm.publishedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
@@ -134,6 +135,8 @@
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button> <el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button> <el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button>
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button> <el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button>
<el-button v-if="isStatus(scope.row, 'pending_publish')" size="small" type="success" @click="markPublished(scope.row)" style="padding:5px 8px;">标记发布</el-button>
<el-button size="small" :disabled="(scope.row.article_count || 0) === 0" @click="openSlop(scope.row)" style="padding:5px 8px;">检测</el-button>
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button> <el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button>
</div> </div>
</template> </template>
@@ -172,6 +175,8 @@
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button> <el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button> <el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button>
<el-button v-show="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button> <el-button v-show="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button>
<el-button v-if="isStatus(topic, 'pending_publish')" size="small" type="success" @click="markPublished(topic)">标记发布</el-button>
<el-button size="small" :disabled="(topic.article_count || 0) === 0" @click="openSlop(topic)">检测</el-button>
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button> <el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
</div> </div>
</div> </div>
@@ -256,6 +261,10 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false"> <el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px;">
<template #title>AI 内容需真人校验后发布</template>
系统仅生成各平台发布稿并置为「待人工发布」,<b>不代发</b>。请在对应平台粘贴、核对 AI 声明与合规后手动发布,再回来点「标记已发布」。
</el-alert>
<div v-if="publishTopic"> <div v-if="publishTopic">
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div> <div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
@@ -336,6 +345,7 @@
const TopicsApp = { const TopicsApp = {
data() { data() {
return { return {
isEmbed: false,
isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
loadingTable: false, selectedTopicIds: [], filterStatus: '', loadingTable: false, selectedTopicIds: [], filterStatus: '',
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 }, stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
@@ -371,7 +381,7 @@ const TopicsApp = {
if (this.filterStatus === 'today') list = this.todayTopics; if (this.filterStatus === 'today') list = this.todayTopics;
else if (!this.filterStatus) list = this.topics; else if (!this.filterStatus) list = this.topics;
else { else {
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] }; const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] };
const allowed = map[this.filterStatus] || [this.filterStatus]; const allowed = map[this.filterStatus] || [this.filterStatus];
list = this.topics.filter(t => allowed.includes(t.status)); list = this.topics.filter(t => allowed.includes(t.status));
} }
@@ -422,8 +432,8 @@ const TopicsApp = {
return this.filteredTopics.slice(start, start + this.pageSize); return this.filteredTopics.slice(start, start + this.pageSize);
}, },
statusStats() { statusStats() {
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0 }; const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0, pendingPublish: 0 };
const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] }; const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] };
this.topics.forEach(t => { for (const [k, v] of Object.entries(aliases)) { if (v.includes(t.status)) { s[k]++; break; } } }); this.topics.forEach(t => { for (const [k, v] of Object.entries(aliases)) { if (v.includes(t.status)) { s[k]++; break; } } });
return s; return s;
}, },
@@ -658,13 +668,28 @@ const TopicsApp = {
method: 'POST', method: 'POST',
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected }) body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
}); });
this.$message.success(`发布完成: ${this.publishTopic.title}`); this.$message.success(data.message || `已生成发布稿,请在各平台校验后手动发布`);
this.publishDialogVisible = false; this.publishDialogVisible = false;
await this.fetchTopics(); await this.fetchTopics();
await this.fetchTodayCount(); await this.fetchTodayCount();
} catch (error) { this.$message.error(`发布失败: ${error.message}`); } } catch (error) { this.$message.error(`发布失败: ${error.message}`); }
finally { this.publishing = false; } finally { this.publishing = false; }
}, },
openSlop(topic) {
window.open('ai-slop.html?topic_id=' + encodeURIComponent(topic.id), '_blank');
},
async markPublished(topic) {
try {
await this.$confirm('确认已在各平台完成发布?此操作将把选题标记为「已发布」并关闭发布记录。', '标记发布', { type: 'warning' });
await this.api('/api/publishing/' + encodeURIComponent(topic.id) + '/mark-published', { method: 'POST' });
this.$message.success('已标记为已发布');
await this.fetchTopics();
} catch (e) {
if (e === 'cancel') return;
const msg = e?.response?.data?.detail || e?.message || '未知错误';
this.$message.error('操作失败: ' + msg);
}
},
async deleteTopic(id) { async deleteTopic(id) {
try { try {
await this.$confirm('确定删除选题 ' + id + '?将同时删除相关文章、发布记录等关联数据。', '提示', { type: 'warning' }); await this.$confirm('确定删除选题 ' + id + '?将同时删除相关文章、发布记录等关联数据。', '提示', { type: 'warning' });
@@ -806,8 +831,8 @@ const TopicsApp = {
this.currentPage = 1; this.currentPage = 1;
}, },
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; }, handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; }, redirectToPage(page) { const url = page.startsWith('/') ? page : '/' + page; if (this.isEmbed) window.top.location.href = url; else window.location.href = url; },
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; }, getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布', 'pending_publish': '待人工发布' }[status] || status; },
toggleCheck(id) { toggleCheck(id) {
const idx = this.selectedTopicIds.indexOf(id); const idx = this.selectedTopicIds.indexOf(id);
if (idx >= 0) { if (idx >= 0) {
@@ -835,12 +860,13 @@ const TopicsApp = {
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch (e) { return dateStr; } catch (e) { return dateStr; }
}, },
getStatusType(status) { return { 'pending': 'warning', 'review': 'danger', 'ready': 'success', 'published': 'info' }[status] || 'primary'; }, getStatusType(status) { return { 'pending': 'warning', 'review': 'danger', 'ready': 'success', 'published': 'info', 'pending_publish': 'warning' }[status] || 'primary'; },
isStatus(row, status) { const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] }; return map[status] ? map[status].includes(row.status) : row.status === status; } isStatus(row, status) { const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] }; return map[status] ? map[status].includes(row.status) : row.status === status; }
}, },
mounted() { mounted() {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; } if (!token) { window.location.href = '/login.html'; return; }
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const urlFilter = params.get('filter'); const urlFilter = params.get('filter');
if (urlFilter) this.filterStatus = urlFilter; if (urlFilter) this.filterStatus = urlFilter;
+48 -15
View File
@@ -107,7 +107,7 @@
.uni-nav-dropdown-divider {\ .uni-nav-dropdown-divider {\
height: 1px; background: #f0f0f0; margin: 4px 0;\ height: 1px; background: #f0f0f0; margin: 4px 0;\
}\ }\
@media (max-width: 768px) {\ @media (max-width: 1000px) {\
.uni-nav-items { display: none; }\ .uni-nav-items { display: none; }\
.uni-nav-username { display: none; }\ .uni-nav-username { display: none; }\
.uni-nav-hamburger { display: inline-flex; }\ .uni-nav-hamburger { display: inline-flex; }\
@@ -121,20 +121,24 @@
function getCurrentPage() { function getCurrentPage() {
var path = window.location.pathname; var path = window.location.pathname;
if (path === '/index.html') return 'workspace'; if (path === '/' || path === '/index.html') return 'index.html';
var m = path.match(/\/(\w+)\.html/); var m = path.match(/\/([\w-]+)\.html$/);
return m ? m[1] : 'workspace'; return m ? m[1] + '.html' : 'index.html';
} }
function navItems(isAdmin) { function navItems(isAdmin) {
var items = [ var items = [
{ key: 'workspace', label: '工作台', page: 'index.html' }, { key: 'dashboard', label: '仪表盘', page: 'index.html' },
{ key: 'factory', label: '内容工厂', page: 'factory.html' }, { key: 'studio', label: '创作工作台', page: 'studio.html' },
{ key: 'insights', label: '数据洞察', page: 'insights.html' }, { key: 'insights', label: '数据', page: 'metrics.html' },
{ key: 'assets', label: '资产库', page: 'assets.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: 'campaigns', label: '外推营销', page: 'campaigns.html' },
{ key: 'ai-slop', label: 'AI味检测', page: 'ai-slop.html' },
]; ];
if (isAdmin) { if (isAdmin) {
items.push({ key: 'branding', label: '白标设置', page: 'branding.html', admin: true });
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true }); items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
} }
return items; return items;
@@ -160,6 +164,9 @@
pwdForm: { old_password: '', new_password: '', confirm: '' }, pwdForm: { old_password: '', new_password: '', confirm: '' },
pwdSubmitting: false, pwdSubmitting: false,
serverMenus: null, serverMenus: null,
brandName: '',
brandLogo: '',
primaryColor: '',
}; };
}, },
computed: { computed: {
@@ -167,15 +174,19 @@
if (this.serverMenus) { if (this.serverMenus) {
return this.serverMenus return this.serverMenus
.filter(function (m) { return m.is_active !== false; }) .filter(function (m) { return m.is_active !== false; })
.map(function (m) { return { key: m.name, label: m.name, page: m.path }; }); .map(function (m) { return { key: m.name, label: m.name, page: (m.path || '').replace(/^\//, '') }; });
} }
return navItems(this.isAdmin); return navItems(this.isAdmin);
}, },
page: function () { page: function () {
return this.currentPage || getCurrentPage(); return getCurrentPage();
}, },
showTitle: function () { showTitle: function () {
return this.title || '宇之然'; return this.brandName || this.title || '宇之然';
},
navStyle: function () {
if (!this.primaryColor) return {};
return { background: 'linear-gradient(135deg, ' + this.primaryColor + ', ' + this.primaryColor + 'cc)' };
}, },
}, },
methods: { methods: {
@@ -189,6 +200,24 @@
}.bind(this)) }.bind(this))
.catch(function () {}); .catch(function () {});
}, },
fetchBranding: function () {
fetch('/api/branding')
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (d) {
if (d.brand_name) this.brandName = d.brand_name;
if (d.brand_logo) this.brandLogo = d.brand_logo;
if (d.brand_primary_color) {
this.primaryColor = d.brand_primary_color;
document.documentElement.style.setProperty('--color-primary', d.brand_primary_color);
var s = document.getElementById('uni-nav-brand-style');
if (!s) { s = document.createElement('style'); s.id = 'uni-nav-brand-style'; document.head.appendChild(s); }
s.textContent = '.uni-nav-dropdown-item:hover,.uni-nav-dropdown-item.active{color:' + d.brand_primary_color + ';}' +
'.uni-nav-dropdown-item.active{background:' + d.brand_primary_color + '14;}' +
'.uni-nav-item.active{background:rgba(255,255,255,0.18);}';
}
}.bind(this))
.catch(function () {});
},
navigate: function (page) { navigate: function (page) {
this.dropdownOpen = false; this.profileOpen = false; this.dropdownOpen = false; this.profileOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; } if (this.onNavigate) { this.onNavigate(page); return; }
@@ -246,17 +275,21 @@
mounted: function () { mounted: function () {
document.addEventListener('click', this.closeAll); document.addEventListener('click', this.closeAll);
this.fetchMenus(); this.fetchMenus();
this.fetchBranding();
}, },
beforeUnmount: function () { beforeUnmount: function () {
document.removeEventListener('click', this.closeAll); document.removeEventListener('click', this.closeAll);
}, },
template: '\ template: '\
<nav class="uni-nav" style="position:relative;">\ <nav class="uni-nav" :style="navStyle">\
<div class="uni-nav-inner">\ <div class="uni-nav-inner">\
<div class="uni-nav-brand">{{ showTitle }}</div>\ <div class="uni-nav-brand">\
<img v-if="brandLogo" :src="brandLogo" style="height:24px;width:auto;margin-right:6px;vertical-align:middle;border-radius:4px;" />\
<span>{{ showTitle }}</span>\
</div>\
<div class="uni-nav-items">\ <div class="uni-nav-items">\
<button v-for="item in items" :key="item.key"\ <button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-item\', { active: page === item.key }]"\ :class="['uni-nav-item', { active: page === item.page }]"\
@click="navigate(item.page)">{{ item.label }}</button>\ @click="navigate(item.page)">{{ item.label }}</button>\
</div>\ </div>\
<div class="uni-nav-right">\ <div class="uni-nav-right">\
@@ -281,7 +314,7 @@
</div>\ </div>\
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\ <div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
<button v-for="item in items" :key="item.key"\ <button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\ :class="[\'uni-nav-dropdown-item\', { active: page === item.page }]"\
@click="navigate(item.page)">\ @click="navigate(item.page)">\
{{ item.label }}\ {{ item.label }}\
</button>\ </button>\
+157
View File
@@ -0,0 +1,157 @@
# 宇之然 (YuZhiRan) 市场与竞争调研简报 — 2026
> 调研范围:中国 AI 内容创作工具格局、平台算法/政策、GEO/AI搜索、创作者变现痛点、未满足蓝海
> 方法:web_search 深度检索(2025–2026 公开报道、平台公告、行业白皮书、创作者社区帖)
> 注意:部分数据为行业机构/营销机构口径(GEO 服务商软文偏多),已在文中标注"存疑"。
---
## 一、关键趋势 (Key Trends)
1. **"AI 辅助"被鼓励,"AI 托管/批量"被严打** — 2026 年所有主流平台完成从"自愿声明"到"强制标识 + 全链路管控"的切换。9 月 1 日《人工智能生成合成内容标识办法》实施后,小红书、微信、抖音、快手、B站、视频号均上线 AI 内容主动声明/强制打标。微信 4 月新版《公众账号行为规范》明确:禁止用 AI/脚本/接口替代真人全流程运营,违者限流/删文/封号。
2. **"人味/原创增量"成为新的流量分配核心指标** — 平台不再简单打击"AI 率",而是打击"无人工痕迹+同质化+低价值增量"的内容。抖音把"原创识别率"纳入核心分发指标;重复率超 25% 即判低质。
3. **GEO(生成式引擎优化)从概念走向服务市场** — 豆包/文心/通义/DeepSeek/Kimi 已成决策入口。出现大量 GEO 服务商(Bugoo 布谷、余小铁、上海逐天、GEORANK 等),但行业鱼龙混杂、软文泛滥。
4. **一人公司 / Agent 化个体创业爆发** — 2026 被视为"一人公司狂飙"年,AI Agent 工作流(Dify/Coze/n8n/OpenClaw)使个体具备团队能力。
5. **"副业刚需"成为内容创作的核心驱动力** — 全球年轻人"多职劳动(Multiple Jobs)"激增,中国 26-35 岁焦虑青年把自媒体当"退路"与"睡后收入"来源。
6. **长文→短视频的转化工具成熟但割裂** — 即梦/可灵/剪映/即创打通"脚本→分镜→成片",但多为独立工具,少有"一篇文章自动拆成三平台+一个短视频"的一体化方案。
7. **本地生活 + 私域成为小红书/视频号新红利** — 小红书变"内容+交易"闭环,搜索流量(DSO)是低成本获客洼地,强调真实、就近、素人。
---
## 二、创作者痛点 (Top Pain Points, Ranked)
> 按"普遍度 × 严重程度 × 与宇之然定位的相关度"排序
1. **【最痛】AI 内容被限流/封号,且规则不清** — "写了 3 个月原创阅读不过千""AI 率 50%+ 被限流""小红书关于 AI 限流的笔记超 3 万篇"。平台边界模糊(哪些是 AI 托管、哪些算辅助),创作者"不知道红线在哪"。→ 宇之然的"合规检测/去套话/人味优化 + 三平台差异化"正好命中。
2. **同质化与"AI 套话"** — 同模型+同 prompt 产出高度雷同;平台同质化识别已迭代数轮。"同质化比 AI 检测更要命"。常见 AI 句式("首先/其次/最后""值得一提的是")被普遍反感。
3. **多平台分发但要"说人话且各说各话"** — 知乎/微信/小红书调性、字数、人称完全不同,手动改三遍成本极高。绝大多数工具只给"一篇通用稿",或只做简单改写。→ 宇之然"三平台独立展开"是差异化卖点。
4. **选题焦虑 + 流量焦虑** — 红海赛道拥挤、冷启动难、爆款不可复制。需要"有数据价值但竞争者少的细分方向"。
5. **变现难 + 账号权重脆弱** — 流量主/广告/知识付费门槛高;一个违规号可能前功尽弃。
6. **合规/敏感词/版权风险** — 《标识办法》+ 平台敏感词 + 版权模糊(AI 生成图/声音侵权案例频出,如配音演员维权)。
7. **长文→短视频的转化断层** — 公众号/知乎沉淀的长文,想转抖音/视频号缺少顺滑工作流;现有工具(即梦/剪映)需手动搬运。
8. **GEO/SEO 可见性失控** — 品牌/个人希望被豆包/DeepSeek 引用,但不知道"怎么喂、喂给谁、是否被引",缺乏可监测手段。
---
## 三、竞品动向 (Competitor Moves)
### AI 写作/创作工具(通用层,已饱和)
- **豆包(字节)**:月活 5.3 亿(2026.3 披露),日均 112 亿次对话;完全免费、多模态、生态闭环(抖音/头条/即梦)。2026 下半年可能启动付费。
- **DeepSeek**:月活 1.27 亿,中文写作性价比之王,开源,被引用逻辑偏"推理+技术链"。
- **Kimi(月之暗面)**:超长上下文(200 万字),长文档/资料整理最强;UGC/知乎信源占比高(约 70% 来自 UGC)。
- **通义千问(阿里)**:商务/文档/电商,引用偏权威媒体+阿里生态。
- **文心一言/文小言(百度)**:搜索基因,引用偏百度系(百家号/百科)+ 媒体软文。
- **秘塔写作猫**:中文纠错/润色/降重老牌,千万级用户,免费策略。
- **笔灵 AI**:模板化营销文案,批量标准化。
- **腾讯元宝**:微信生态闭环,公众号文章占信源约 50%。
### AI 视频/多模态(增长快,但非宇之然核心)
- 即梦(字节, Seedance 2.0)、可灵(快手)、LibTV、万镜一刻、剪映一键成片、即创(抖音带货)、腾讯智影、Runway/Sora(海外)。
- 趋势:文生视频从"玩具"变"生产力",但排队/成本/真人肖像限制仍痛。
### Agent/工作流平台(宇之然的间接竞品)
- **扣子 Coze(字节)**:运营人用得最多,抖音/小红书爆款生成工作流,Coze+DeepSeek/飞书联动。
- **Dify / n8n / OpenClaw**:个体创业者搭自动化内容生产线("一行指令出稿出图推送")。
### GEO 服务商(新兴市场,混乱)
- Bugoo 布谷、余小铁 GEO、上海逐天科技、GEORANK、熊猫出海 GEO、白杨 SEO 等。
- 打法:媒体发稿 + 自媒体宣发 + 信源投喂 + 引用率监测。报价中高,交付质量参差。
- **洞察**GEO 目前高度"服务化/人力化",尚无标准化 SaaS;这正是宇之然可切入的"外部站 SEO/GEO"模块机会。
### 平台自身(既是合作方也是裁判)
- 小红书"点点"AI、微信元宝、抖音即梦——平台把 AI 工具内嵌进生态,目的是"把创作者绑在自己闭环里",同时严打跨平台批量搬运。
---
## 四、平台算法/政策 2025–2026 变化(要点)
| 平台 | 关键变化 | 对创作者的含义 |
|---|---|---|
| 全网 | 《AI生成合成内容标识办法》9/1 实施;显式+隐式标识,不得删除/篡改 | AI 内容必须可识别;标注≠免罚 |
| 小红书 | 强制 AI 标识(2025.8);封禁纯 AI 托管账号;"2万篇笔记种草公式"失效;转向"以人为本/超级小内容" | 批量素人宣发死亡;要真实、小需求、场景化 |
| 微信/公众号 | 4 月新规:禁 AI 全流程替代真人;限流/删文/封号 | AI 只能辅助选题/润色/查错,真人表达不可替代 |
| 抖音/快手 | "原创识别率"入核心指标;重复率>25% 判低质;禁批量同质化 | 原创增量是分发关键 |
| 知乎 | "知乎直答"存在感弱;Kimi/DeepSeek 大量引用知乎内容 | 知乎长文对 GEO 权重高(被 Kimi 引用 70% |
| 视频号 | 严打非真人出镜 AI 视频;限推荐/私信/评论 | 虚构真人/假图高风险 |
| 网文(晋江/起点/番茄) | 反 AI:起点/番茄禁 AI,晋江限边界;部分甲方拉黑用 AI 大纲者 | 虚构创作领域对 AI 最不友好 |
**"原创"的新定义**:不再是"查重率<X%",而是"有独特观点/可验证数据/真实案例/价值增量"。单纯换同义词、调语序仍被判低价值。平台建议 AI 辅助占比 ≤25–30%,每百字至少 1 个情绪钩子。
---
## 五、GEO / AI 搜索优化 2026
- **已成真,但是 B 端(企业)先行,C 端(个人创作者)滞后**。企业级 GEO 需求明确("不被 AI 引用=在 AI 世界失语"),服务商众多;个人创作者大多还停留在"SEO/发小红书"阶段。
- **中文术语认知**:GEO(生成式引擎优化)一词已被采用,但大众仍混用"AI搜索优化/AISEO"。 awareness 在营销圈高,在普通自媒体人里中等。
- **是否可 monetize**:对企业已是刚需(获客/品牌);对个人创作者尚处"锦上添花",但"被豆包/DeepSeek 提到"正成为个人 IP 的新护城河。
- **各平台引用逻辑差异(关键,决定 GEO 打法)**:
- 豆包:字节生态优先(抖音/头条/抖音百科),外部补新闻媒;重生活化。
- DeepSeek:媒体+UGC(知乎类)为主,重推理/技术链。
- Kimi~70% UGC,知乎占比极高 → **知乎长文对 GEO 权重被低估**
- 文心/百度:百度系(百家号/百科)+媒体软文。
- 元宝/微信:公众号文章占 ~50% 信源。
- 通义:新闻门户(网易/腾讯/搜狐)+阿里生态。
- **实操共识**:结构化内容、跨平台信息一致、原创数据/白皮书、权威媒体分发、持续"投喂"。
- **数据存疑**GEO 服务商宣称"2026 Q1 中国 GEO 市场 480 亿、同比 11 倍""超 75% 用户决策首选 AI"等数字来自 IDC/艾瑞转引的软文,**未独立核实,谨慎引用**。
---
## 六、变现痛点 & 焦虑青年(26-35)画像
- **副业成刚需**:全球"多职劳动"激增(美国 5.3% 劳动力身兼数职,连续 21 个月>5%;Z 世代 70% 想保持≥2 收入源)。国内因 AI 失业焦虑+生活成本,副业从"兴趣"变"生存手段"。
- **26-35 岁核心恐惧**:35 岁职场危机、被 AI 替代、房贷/教育/医疗刚性支出、失业即断流。
- **他们要的内容**:① 可落地的副业/一人公司方法论;② AI 工具实操("用 AI 当外挂不被淘汰");③ 个人 IP 从 0 到 1;④ 健康/抗倦怠/数字排毒("职业倦怠高峰提前到 25 岁");⑤ 数字游民/地理套利;⑥ 可持续生活/智性消费(B站《2026 年轻人消费趋势报告》:"智性沸腾",不为标签为自我价值付费)。
- **内容调性偏好**:情绪价值、真实体验、具体数据、反套路、去 AI 味——与宇之然"三平台差异化+去套话"高度契合。
---
## 七、未满足的蓝海 (Underserved Niches)
> 综合"需求明确 × 现有 AI 工具少 × 与宇之然能力可衔接"
1. **【最强】三平台"真·差异化"自动生成** — 绝大多数工具只给通用稿或简单改写;能按知乎(数据分析/你)、微信(个人叙事/我)、小红书(干货/emoji)各自独立成稿且合规检测的产品稀缺。宇之然已具备,是核心壁垒。
2. **【强】GEO 化的内容生产(个人/中小品牌版)** — 企业 GEO 被服务商瓜分,但"帮个人创作者/小店产出能被豆包/DeepSeek 引用的结构化内容"尚无 SaaS。可把"外部站 SEO/GEO"做成标准模块。
3. **【强】长文→短视频脚本自动拆条** — 公众号/知乎长文一键生成抖音/视频号口播脚本+分镜+标题,目前需手动跨即梦/剪映。一体化缺口明显。
4. **本地生活商家内容** — 小红书/视频号"搜索流量+POI+素人"红利大,但中小门店缺内容生产能力;模板化"真实到店/本地避坑/套餐推荐"批量生成工具少。
5. **企业号/员工 KOS 内容** — "门店号+员工号双号运营"需求上升,但企业级多账号合规内容工厂稀缺。
6. **知识付费 IP 的内容中台** — 从选题→大纲→三平台→私域钩子→课程化,缺一站式。
7. **私域转化内容** — 公域内容如何带私域钩子(企微/社群话术),现有工具基本不碰。
8. **出海/跨境内容** — 小红书 2026 推跨境电商;英文/多语种内容生成+本地化是空白,且宇之然现有架构易扩展。
9. **合规/去标识安全层** — 随着《标识办法》落地,"发布前 AI 率+同质化+敏感词+暗限流预测"的检测与改写,会成为所有 AI 写作工具的标配前置环节(ContentAny 类已起量,但嵌入生成流的少)。
---
## 八、对宇之然的策略含义(一句话)
宇之然的"三平台差异化生成 + 去套话/合规检测 + 外部站 GEO + 长文转短视频"组合,正好卡在 2026 年最痛的三条裂缝上:**限流焦虑、同质化、跨平台+跨形态分发成本**。优先把"三平台真差异化+合规护城河"做成可感知的差异化卖点,再叠加 GEO 与短视频拆条作为第二增长曲线。
---
## 九、信息来源 (Sources)
- 互联网平台现状:鼓励AI,限制AI36氪/新莓)https://m.36kr.com/p/3259734457430277
- 小红书上线 AI 内容强制标识(网易/见实)https://www.163.com/dy/article/K86VAJIQ053144S4.html
- 从 AI 监管到内容生态重构:平台重新定价"真实"36氪/DoNewshttps://m.36kr.com/p/3765102752252424
- 警惕"AI托管"侵蚀内容生态(中国经济网/证券日报)http://m.ce.cn/bwzg/202603/t20260316_2829584.shtml
- 2026 AI 写作工具实测(AI-Pickhttps://ai-pick.cn/article-42.html
- 2026 AI 写作工具推荐 10 款(AI工具宝箱)https://www.aitoollab.cn/articles/ai-writing-tools-2026-guide
- Kimi、文心启动付费(澎湃)https://www.thepaper.cn/newsDetail_forward_33101613
- 2026 一人公司狂飙:AI Agent 重构个体创业(掘金)https://juejin.cn/post/7611761768110817332
- GEO2026 ChatGPT/DeepSeek/豆包等 AI 搜索优化(CSDN/DeepSeek 社区)https://deepseek.csdn.net/6a420c80662f9a54cb858452.html
- AI 搜索推荐机制拆解:豆包/通义/文心/DeepSeek 引用逻辑(CSDNhttps://blog.csdn.net/yunFAA667/article/details/159512346
- 豆包 AI 搜索优化 GEO 指南(cnblogs/GEORANKhttps://www.cnblogs.com/georank/p/20680805
- 上海 GEO 服务商评测(cnblogshttps://www.cnblogs.com/zwcxkj/articles/20039051
- DeepSeek/豆包/Kimi/通义 引用来源占比(白杨 SEO/淘江湖)https://jianghu.taobao.com/detail/47301_29034882
- 自媒体变现指南(U客直谈)https://www.ukezhitan.com/article-u8D5Au94B1u8D44u8BAF/12316
- 2026 小红书本地生活商家趋势(cnblogshttps://www.cnblogs.com/nbxc2021/p/19688862
- 一文讲透小红书 2026 平台趋势(数英)https://www.digitaling.com/articles/1443427.html
- B站 2026 年轻人消费趋势报告"智性沸腾" https://www.fxbaogao.com/detail/5235669
- 全球年轻人热衷于搞副业(新浪/新京报)https://finance.sina.cn/tech/2026-06-19/detail-inicxkay2951341.d.html
- 35 岁职场危机(新浪财经)https://cj.sina.com.cn/articles/view/7879848901/1d5acf3c501901iun6
- 即梦/可灵/剪映 AI 视频工具对比(老达 AI 博客)https://laodad.com/tools/6334.html
- 2026 国内外 19 款 AI 视频生成工具(知乎)https://zhuanlan.zhihu.com/p/684932281
- AI 写小红书/公众号为什么限流(阿里云开发者社区)https://developer.aliyun.com/article/1724689
- 公众号 AI 写作规则与生存指南(简书)https://www.jianshu.com/p/5e899e17bbd3
- 知乎写手避开 AI 检测(CSDNhttps://blog.csdn.net/2611_96189011/article/details/161697476
> 说明:GEO 市场规模/引用率等具体数字多出自 GEO 服务商软文,行业口径不一,建议内部使用时标注"行业预估,未独立核实"。2026 年部分平台月活/政策细节来自媒体报道,可能存在口径差异。
+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()
+71 -2
View File
@@ -69,6 +69,33 @@ def _load_platform_config() -> dict:
PLATFORM_CONFIG = _load_platform_config() 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 = { PLATFORM_NAMES = {
"zhihu": "知乎专栏", "zhihu": "知乎专栏",
"wechat": "微信公众号", "wechat": "微信公众号",
@@ -235,10 +262,40 @@ def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags
return html 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: class Writer:
def __init__(self, topic_id: str): def __init__(self, topic_id: str):
self.topic_id = topic_id self.topic_id = topic_id
self.topic = self._load_topic() self.topic = self._load_topic()
self._degraded = False
outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md" outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md"
if not outline_file.exists(): if not outline_file.exists():
raise FileNotFoundError(f"Outline not found: {outline_file}") raise FileNotFoundError(f"Outline not found: {outline_file}")
@@ -334,6 +391,9 @@ class Writer:
except Exception as e: except Exception as e:
logger.warning(f"LLM 扩写失败 [{platform}]: {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)] lines = [l.strip() for l in content.split('\n') if not self._is_outline_noise(l)]
if lines: if lines:
sentences = [] sentences = []
@@ -608,8 +668,11 @@ class Writer:
html = html.replace("<!-- TAGS -->", tags_html) html = html.replace("<!-- TAGS -->", tags_html)
else: else:
html = html.replace("<!-- TAGS -->", "") html = html.replace("<!-- TAGS -->", "")
html = inject_geo_metadata(html, title, adapted, platform, tags_html) html = inject_geo_metadata(html, title, adapted, platform, tags_html)
# 强制 AI 生成声明(合规要求,可配置关闭)
html = inject_ai_disclosure(html, platform)
return html return html
def save_html(self, html: str, platform: str, *, title: str = "", content: str = "") -> str: 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) html = self.generate_platform_html(markdown, platform)
results[platform] = str(self.save_html(html, platform, title=title, content=markdown)) results[platform] = str(self.save_html(html, platform, title=title, content=markdown))
self.mark_draft() self.mark_draft()
if self._degraded:
logger.error(
"❌ 撰写完成但存在 LLM 降级章节(大纲要点拼接,未达发布质量)。"
"选题保留为待审查,请检查 LLM 提供商连通性后重跑,勿直接发布降级稿。"
)
else:
logger.info(f"撰写完成,状态已更新为待审查") logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results} return {"ok": True, "files": results, "degraded": self._degraded}
def main(): def main():
import argparse import argparse