diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md
index fbbe5dc..5ce1273 100644
--- a/DEVELOPMENT_LOG.md
+++ b/DEVELOPMENT_LOG.md
@@ -1,5 +1,14 @@
# Development Log
+## 2026-08-26 发送中心 AI 文案操作分层
+
+- 明确区分“同步遗漏切片”和“AI 重写文案”:前者继续固定使用 `use_ai=false`,只为遗漏成片补建发布草稿与默认封面;后者只修改已存在草稿的标题、话题和简介。
+- 原页面顶栏“补充缺失任务”移入内容准备的维护区并改名为“同步遗漏切片”,维护区明确标注“不调用 AI、不修改已有文案”;账号管理继续留在页面顶栏。
+- 单条 AI 操作移动到发布文案字段上方并命名为“AI 重写本条文案”;勾选后的批量入口改为“AI 重写已选文案”,切换到排期计划或执行记录后隐藏。
+- 移除 `/publish` 页面加载时静默调用旧草稿全局 AI 升级的行为;后端兼容接口继续保留,但 AI 文案只在用户明确点击单条或已选批量入口后执行。
+- 发布任务、调度器、Publisher、账号、排期及执行记录状态机均未修改。
+- 定向发送中心回归 `52 passed`、完整回归 `794 passed`;Python Compileall、JavaScript 语法、Ruff 和 `git diff --check` 均通过,测试使用隔离数据与 Mock,未调用真实 AI、封面生成、排期或平台发送。
+
## 2026-08-26 片段审核累计统计精简
- `/clips` 顶部统计从“待 AI 分析、待检查、可生成切片、已完成、异常任务”五项过程指标,精简为“累计审核任务、已通过视频、已完成任务”三项累计结果。
diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md
index 6e997ed..57c2ffb 100644
--- a/NEXT_STEPS.md
+++ b/NEXT_STEPS.md
@@ -1,5 +1,13 @@
# Next Steps
+## 2026-08-26 发送中心 AI 文案操作检查
+
+1. 打开 `http://127.0.0.1:8001/publish`:页面顶栏只保留“账号管理”,“同步遗漏切片”和“补齐抖音缺失封面”位于内容准备的维护区。
+2. “同步遗漏切片”只为尚未进入发送中心的成片补建草稿和默认封面,不调用 AI,也不会改写已有文案;平时自动同步正常时无需点击。
+3. 需要改一条文案时,使用内容卡上方的“AI 重写本条文案”;需要改多条时先勾选,再使用底部“AI 重写已选文案”。AI 结果会立即保存到对应草稿。
+4. 排期计划只管理时间,执行记录只核对发送结果,这两个区域不再提供 AI 文案按钮。刷新页面不会自动重写旧草稿。
+5. 本轮验收不需要点击任何 AI、封面、排期或发送按钮,也不会触发真实抖音/B站投稿。
+
## 2026-08-26 片段审核累计统计检查
1. 打开 `http://127.0.0.1:8001/clips`,顶部应只显示“累计审核任务、已通过视频、已完成任务”三张卡片,不再出现待 AI、待检查、可生成切片和异常任务统计。
diff --git a/app/static/css/styles.css b/app/static/css/styles.css
index 6174240..763c45b 100644
--- a/app/static/css/styles.css
+++ b/app/static/css/styles.css
@@ -5309,7 +5309,29 @@ td a,
flex-wrap: wrap;
}
-.publish-content-header [data-backfill-covers][data-loading="true"] {
+.publish-content-maintenance {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border: 1px solid rgba(126, 151, 187, 0.18);
+ border-radius: 14px;
+ background: rgba(247, 250, 255, 0.78);
+}
+
+.publish-content-maintenance > div:first-child {
+ display: grid;
+ gap: 3px;
+}
+
+.publish-content-maintenance small,
+.publish-copy-toolbar small {
+ color: var(--muted);
+}
+
+.publish-content-maintenance [data-backfill-covers][data-loading="true"] {
cursor: wait;
opacity: 0.72;
}
@@ -5414,6 +5436,23 @@ td a,
flex-wrap: wrap;
gap: 10px;
}
+
+.publish-copy-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 12px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: rgba(238, 245, 255, 0.7);
+}
+
+.publish-copy-toolbar > div {
+ display: grid;
+ gap: 2px;
+}
+
.publish-schedule-badge {
display: inline-flex;
align-items: center;
@@ -5805,6 +5844,12 @@ td a,
}
@media (max-width: 760px) {
+ .publish-content-maintenance,
+ .publish-copy-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .publish-copy-toolbar .secondary-button { width: 100%; }
.publish-content-header-actions {
width: 100%;
justify-content: stretch;
diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js
index f283f11..be4ac43 100644
--- a/app/static/js/publish-center.js
+++ b/app/static/js/publish-center.js
@@ -100,7 +100,7 @@ if (publishCenterRoot) {
backfillCoversButton.disabled = loading || count === 0;
backfillCoversButton.textContent = loading
? `正在补充${platformLabel()} ${count} 条封面…`
- : `一键补充${platformLabel()}缺失封面${count ? `(${count})` : ""}`;
+ : `补齐${platformLabel()}缺失封面${count ? `(${count})` : ""}`;
}
function beijingDatetimeValue(timestamp) {
@@ -1128,6 +1128,8 @@ if (publishCenterRoot) {
panel.hidden = !active;
panel.classList.toggle("active", active);
});
+ const batchAiButton = document.querySelector("[data-batch-ai]");
+ if (batchAiButton) batchAiButton.hidden = tab !== "content";
if (tab === "history") void refreshHistory({ calendar: true, records: true });
}
@@ -1400,24 +1402,6 @@ if (publishCenterRoot) {
}
}
- async function upgradePendingDouyinMetadata() {
- try {
- const data = await window.apiFetch("/api/publish/jobs/metadata/upgrade-pending-douyin", { method: "POST" });
- (data.jobs || []).forEach((job) => {
- const row = document.querySelector(
- `[data-publish-row][data-section="content"][data-job-id="${CSS.escape(job.id || "")}"]`,
- );
- applyGeneratedMetadataToForm(row, job);
- updateRowFromJob(job);
- });
- if (Number(data.upgraded_count || 0) > 0 || Number(data.failed_count || 0) > 0) {
- showMessage(data.message || "抖音旧草稿文案升级完成。", data.failed_count ? "error" : "success");
- }
- } catch (error) {
- showMessage(`旧草稿文案升级已中止:${error.message}`, "error");
- }
- }
-
async function refreshAccounts() {
try {
const data = await window.apiFetch("/api/publish/accounts");
@@ -1866,8 +1850,8 @@ if (publishCenterRoot) {
const data = await window.apiFetch(`/api/publish/jobs/${jobId}/metadata?use_ai=true`, { method: "POST" });
applyGeneratedMetadataToForm(row, data.job);
updateRowFromJob(data.job);
- showMessage("AI 文案已重写并同步到最终发送字段。", "success");
- } catch (error) { showMessage(`AI 重写失败:${error.message}`, "error"); }
+ showMessage("本条 AI 文案已重写并保存到最终发送字段。", "success");
+ } catch (error) { showMessage(`本条 AI 重写失败:${error.message}`, "error"); }
finally { metadataButton.disabled = false; }
return;
}
@@ -2052,7 +2036,7 @@ if (publishCenterRoot) {
}
}
showMessage(
- `批量 AI 重写完成:成功 ${succeeded} 条,失败 ${failed} 条;失败项已保留原文。`,
+ `已选文案 AI 重写完成:成功 ${succeeded} 条,失败 ${failed} 条;失败项已保留原文。`,
failed ? "error" : "success",
);
});
@@ -2200,8 +2184,8 @@ if (publishCenterRoot) {
const button = event.currentTarget; button.disabled = true;
try {
const data = await window.apiFetch(`/api/publish/queue/refresh?use_ai=false&platform=${encodeURIComponent(activePlatform)}`, { method: "POST" });
- showMessage(data.message || "缺失任务已补充,请稍后查看内容准备区。", "success");
- } catch (error) { showMessage(`补充任务失败:${error.message}`, "error"); }
+ showMessage(data.message || "遗漏切片已同步,请在内容准备区核对。", "success");
+ } catch (error) { showMessage(`同步遗漏切片失败:${error.message}`, "error"); }
finally { button.disabled = false; }
});
@@ -2216,7 +2200,6 @@ if (publishCenterRoot) {
if (scheduleForm?.elements.start_at_local) scheduleForm.elements.start_at_local.value = beijingDatetimeValue(Date.now() + 10 * 60 * 1000);
document.querySelectorAll('[data-publish-row][data-section="schedule"], [data-publish-row][data-section="history"]').forEach((row) => applyRowReadiness(row));
updateSelectionUi(); applyHistoryFilter(); refreshScheduleViews(); updateBackfillCoversButton();
- void upgradePendingDouyinMetadata();
if (focus?.dataset.taskId) {
const group = document.querySelector(
`[data-publish-task-group][data-task-id="${CSS.escape(focus.dataset.taskId)}"]`,
diff --git a/app/templates/publish.html b/app/templates/publish.html
index 28a9107..ba870cf 100644
--- a/app/templates/publish.html
+++ b/app/templates/publish.html
@@ -33,6 +33,13 @@
+
-
@@ -145,7 +151,7 @@
>
Publish Center
发送中心
先准备内容,再按北京时间排期,最后在执行记录中确认平台结果。
-
+
@@ -183,17 +189,30 @@
{% set initial_platform = "douyin" %}
{% set initial_missing_cover_count = missing_cover_counts.get(initial_platform, 0) %}
+
{% for group in publish_task_groups %}
@@ -318,7 +337,7 @@ 全部执行记录
仅限 抖音
-
+
diff --git a/docs/TASK_FLOW.md b/docs/TASK_FLOW.md
index 2e92901..26b9af4 100644
--- a/docs/TASK_FLOW.md
+++ b/docs/TASK_FLOW.md
@@ -236,7 +236,7 @@ output_clip 生成成功
└─ opencli_publish → 显式兼容开关 → PUBLISHED / FAILED / NEED_REVIEW
```
-`platform` 只能是 `douyin` / `bilibili`;`publish_mode` 只能表示执行方式,禁止互相混用。发送中心的“补充缺失任务”只补缺,不覆盖已有任务的执行方式。
+`platform` 只能是 `douyin` / `bilibili`;`publish_mode` 只能表示执行方式,禁止互相混用。发送中心内容准备区的“同步遗漏切片”固定使用 `use_ai=false`,只补建尚未进入发送中心的草稿与默认封面,不覆盖已有任务的执行方式或文案。AI 文案仅由内容卡“AI 重写本条文案”或明确勾选后的“AI 重写已选文案”触发;页面加载、排期计划和执行记录不会调用 AI 重写。
### 发送任务状态(publish_jobs.status)
diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md
index e6eef52..9cc2ebf 100644
--- a/docs/UI_REFERENCE.md
+++ b/docs/UI_REFERENCE.md
@@ -1,5 +1,14 @@
# UI 参考说明
+## 2026-08-26 更新:发送中心 AI 文案操作分层
+
+- 页面顶栏只保留账号管理;原“补充缺失任务”改名为“同步遗漏切片”,与“补齐抖音缺失封面”共同放入内容准备维护区。
+- 维护区使用独立浅色条明确说明:同步动作只补建遗漏草稿和默认封面,不调用 AI、不修改已有文案。
+- 每条内容卡的 AI 按钮紧贴发布文案字段,命名为“AI 重写本条文案”;勾选内容后的固定操作栏使用“AI 重写已选文案”,范围一眼可见。
+- 批量 AI 仅在内容准备标签页显示;排期计划只处理时间,执行记录只处理结果和人工复核。
+- 页面打开和刷新不再静默执行旧草稿全局 AI 升级,AI 文案必须由用户明确点击触发。后端兼容接口保留,不改变历史数据或发布状态机。
+- 窄屏下维护区与单条文案工具栏改为纵向排列,按钮使用整行宽度。
+
## 2026-08-26 更新:片段审核累计统计
- 片段审核工作台顶部只保留三项结果指标:“累计审核任务、已通过视频、已完成任务”,移除待 AI、待检查、可生成切片和异常任务等过程型统计。
@@ -80,8 +89,8 @@
- 发送中心全局平台卡只保留抖音;`platform=bilibili` 深链参数会被忽略。B站历史记录、API 和 Publisher 继续保留在后端,但当前前台不展示 B站任务或发送入口。
- 内容卡账号状态直接复用统一发送预检:唯一正常抖音账号自动选中并显示“内容完整”,多账号显示“缺少:发布账号”,登录失效显示“账号需登录”。
- 每个原始处理任务组头增加“全选本任务 / 取消全选”复选框;只作用于本组当前可操作的抖音视频,支持部分选择状态,任务组折叠时仍可使用。
-- 标题输入限制 30 字并显示实时计数;标签提示 4~6 个、每个 2~3 字;简介限制 15~35 字并显示实时计数。单条和批量按钮统一命名为“AI 重写文案”,成功后立即刷新三个输入框。
-- 页面首次打开会异步调用幂等旧草稿升级;成功项原地更新,失败项提示保留原文。页面仍保持 Apple 风格浅色卡片、玻璃感和响应式布局,不引入前端框架。
+- 标题输入限制 30 字并显示实时计数;标签提示 4~6 个、每个 2~3 字;简介限制 15~35 字并显示实时计数。单条与批量入口分别命名为“AI 重写本条文案”和“AI 重写已选文案”,成功后立即刷新三个输入框。
+- 旧草稿升级接口继续保留后台兼容,但页面不再自动触发;AI 文案只通过单条或明确勾选后的批量入口执行。页面仍保持 Apple 风格浅色卡片、玻璃感和响应式布局,不引入前端框架。
## v2.1 界面总览
diff --git a/docs/agent_tasks/2026-08-26-publish-ai-action-placement.md b/docs/agent_tasks/2026-08-26-publish-ai-action-placement.md
new file mode 100644
index 0000000..e5a2538
--- /dev/null
+++ b/docs/agent_tasks/2026-08-26-publish-ai-action-placement.md
@@ -0,0 +1,64 @@
+# 发送中心 AI 文案操作分层
+
+## 背景
+
+发送中心顶部的“补充缺失任务”和勾选内容后的“批量 AI 重写文案”位置接近、说明不足,容易被理解成重复操作。实际前者通过 `use_ai=false` 补建遗漏的发布草稿并生成默认封面,后者会逐条调用 AI 并覆盖已选草稿的标题、话题和简介。页面加载时还会静默触发一次旧草稿全局 AI 升级,不符合 AI 必须由用户明确选择后执行的交互边界。
+
+## 目标
+
+- 将补建草稿与 AI 文案操作按“维护动作 / 内容动作”清晰分层。
+- AI 重写只保留在内容准备中的单条和显式勾选批量入口。
+- 排期计划和执行记录不展示 AI 重写。
+- 页面打开不再自动调用旧草稿全局 AI 升级。
+
+## 允许修改范围
+
+- `app/templates/publish.html`
+- `app/static/css/styles.css`
+- `app/static/js/publish-center.js`
+- `tests/test_publish_center_cleanup.py`
+- `tests/test_publish_center_browser.py`(仅相关断言需要时)
+- `DEVELOPMENT_LOG.md`
+- `NEXT_STEPS.md`
+- `docs/UI_REFERENCE.md`
+- `docs/TASK_FLOW.md`
+
+## 禁止修改范围
+
+- 发布任务、调度器、Publisher 和账号的后端状态机。
+- AI Prompt、Provider、模型、认证或计费配置。
+- SQLite Schema、迁移和活动数据。
+- 真实 AI、封面生成、排期或平台发布操作。
+
+## 已确定实现要求
+
+- 页面顶栏只保留“账号管理”;“补充缺失任务”移入内容准备的维护区,改名为“同步遗漏切片”。
+- 维护区明确说明:只补建遗漏草稿和默认封面,不调用 AI、不修改已有文案。
+- 单条按钮放在发布文案字段上方,命名为“AI 重写本条文案”,明确会立即生成并保存本条标题、话题和简介。
+- 勾选后的按钮命名为“AI 重写已选文案”,仅在内容准备标签页显示;排期与执行记录不显示 AI 重写。
+- 移除页面加载时对 `/api/publish/jobs/metadata/upgrade-pending-douyin` 的自动调用;保留后端兼容接口,不删除历史能力。
+- 补建遗漏草稿继续固定使用 `use_ai=false`,不改变后端语义。
+
+## 验收标准
+
+- 顶部“账号管理”旁不再出现含糊的“补充缺失任务”。
+- 内容准备区能看到“同步遗漏切片”和清晰的无 AI 说明。
+- 每条内容卡只显示“AI 重写本条文案”;勾选后显示“AI 重写已选文案”。
+- 切换到排期计划或执行记录后,批量 AI 按钮隐藏。
+- 打开或刷新 `/publish` 不请求旧草稿全局 AI 升级接口。
+- 现有单条、批量、补建、封面和排期后端接口保持不变。
+- 桌面与 390px 窄屏布局可读,无横向溢出。
+
+## 测试命令
+
+- `.venv\Scripts\python.exe -m pytest tests/test_publish_center_cleanup.py tests/test_publish_center_browser.py tests/test_publish_copy_rules.py tests/test_publish_scheduler.py tests/test_publish_task_grouping.py tests/test_publish_api_flow.py -q`
+- `.venv\Scripts\python.exe -m pytest -q`
+- `.venv\Scripts\python.exe -m compileall -q app tests`
+- `node --check app/static/js/publish-center.js`
+- `.venv\Scripts\python.exe -m ruff check tests/test_publish_center_cleanup.py tests/test_publish_center_browser.py`
+- `git diff --check`
+- 浏览器验证 `/publish` 的桌面与 390px 窄屏按钮层级,并通过前端请求路径断言确认刷新时没有自动 AI 升级调用。
+
+## 返回格式
+
+报告两个动作的最终区别、AI 按钮放置、修改文件、测试与浏览器证据、分支、提交、推送和 PR;不得把本地页面验证描述为真实 AI 或平台发布验证。
diff --git a/tests/test_publish_center_browser.py b/tests/test_publish_center_browser.py
index d0f40b8..38e8b20 100644
--- a/tests/test_publish_center_browser.py
+++ b/tests/test_publish_center_browser.py
@@ -253,8 +253,9 @@ def fulfill_metadata(route):
group_select.check()
assert first_content.locator("[data-publish-select]").is_checked()
assert second_content.locator("[data-publish-select]").is_checked()
+ assert page.locator("[data-batch-ai]").inner_text() == "AI 重写已选文案"
page.locator("[data-batch-ai]").click()
- page.locator("#send-center-message").filter(has_text="批量 AI 重写完成:成功 2 条").wait_for()
+ page.locator("#send-center-message").filter(has_text="已选文案 AI 重写完成:成功 2 条").wait_for()
assert first_content.locator('[name="description"]').input_value() == "陈汉典刚说自己像潘玮柏,小S立刻给出另一答案"
assert second_content.locator('[name="tags"]').input_value() == "综艺,高光,小S,反转"
first_content.locator("[data-publish-select]").uncheck()
@@ -272,6 +273,7 @@ def fulfill_metadata(route):
group_select.uncheck()
page.locator('[data-center-tab="schedule"]').click()
+ assert page.locator("[data-batch-ai]").is_hidden()
assert page.locator('[data-schedule-calendar] .publish-calendar-day').count() == 42
assert "抖音" in page.locator("[data-calendar-title]").inner_text()
assert page.locator(
diff --git a/tests/test_publish_center_cleanup.py b/tests/test_publish_center_cleanup.py
index c69eb24..a0c20b9 100644
--- a/tests/test_publish_center_cleanup.py
+++ b/tests/test_publish_center_cleanup.py
@@ -14,9 +14,33 @@ def test_selection_bar_has_no_legacy_batch_send_but_keeps_scheduling() -> None:
assert "data-open-schedule-drawer" in selection_bar
assert "data-apply-batch-target" in selection_bar
assert "data-batch-ai" in selection_bar
+ assert "AI 重写已选文案" in selection_bar
assert "data-publish-now" in template
+def test_publish_ai_and_maintenance_actions_are_scoped_to_content_preparation() -> None:
+ template = (PROJECT_ROOT / "app/templates/publish.html").read_text(encoding="utf-8")
+ publish_script = (PROJECT_ROOT / "app/static/js/publish-center.js").read_text(encoding="utf-8")
+ page_heading = template.split('", 1
+ )[0]
+ content_panel = template.split('data-center-panel="content"', 1)[1].split(
+ 'data-center-panel="schedule"', 1
+ )[0]
+ content_row_macro = template.split("{% macro content_row(job) %}", 1)[1].split(
+ "{% endmacro %}", 1
+ )[0]
+
+ assert "data-supplement-publish-jobs" not in page_heading
+ assert "data-supplement-publish-jobs" in content_panel
+ assert "同步遗漏切片" in content_panel
+ assert "不调用 AI,也不修改已有文案" in content_panel
+ assert "AI 重写本条文案" in content_row_macro
+ assert "use_ai=false" in publish_script
+ assert "/api/publish/jobs/metadata/upgrade-pending-douyin" not in publish_script
+ assert 'batchAiButton.hidden = tab !== "content"' in publish_script
+
+
def test_legacy_publish_frontend_handlers_are_removed() -> None:
global_script = (PROJECT_ROOT / "app/static/js/app.js").read_text(encoding="utf-8")
publish_script = (PROJECT_ROOT / "app/static/js/publish-center.js").read_text(encoding="utf-8")