Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Development Log

## 2026-08-15 片段审核与发送中心分组全选

- 片段审核页在候选列表顶部新增吸顶式“全选当前列表”:默认全部筛选下可一次启用或取消全部候选,使用其他筛选时只操作当前显示片段;右侧实时显示“已启用 N / M 条”,单独调整后会显示半选状态。
- 全选只修改页面中的审核选择,不会自动写库;用户仍需点击“保存修改”“生成切片”或“保存并同步发送中心”,继续复用原有批量保存与切片同步校验。
- 发送中心每个“原始处理任务”组头新增“全选本任务”,只选择该任务、当前抖音或 B站平台下可见的内容;不同任务互不影响,单条取消后显示半选状态,并继续联动现有“已选 N 条”批量栏。
- 切换平台仍会清空选择,隐藏、失效和另一平台记录不会被任务级全选带入;没有新增 API、数据库字段,也没有改变排期、调度器或真实投稿边界。
- 手机宽度下同步整理任务组头与底部批量栏:账号选择和批量按钮改为两列响应式布局,不再出现文字竖排或横向滚动。
- JavaScript 语法检查通过;新增片段审核渲染/脚本契约、任务分组渲染/选择状态及发送中心 Playwright 回归。专项测试 `11 passed`;本机真实数据只做前端勾选回归,未保存审核、未修改排期、未触发投稿。

## 2026-08-13 GitHub 主页任务详情与发送中心图片更新

- 中文、英文 GitHub 主页的“任务详情 / Task detail”和“发送中心 / Publishing center”预览已分别改为用户提供的对应实机 PNG 图片。
Expand Down
8 changes: 8 additions & 0 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Next Steps

## 2026-08-15 两处全选功能检查方法

1. 重启 NiuMa Studio 后打开片段审核页,按 `Ctrl + F5` 强制刷新;候选列表第一张卡片上方应显示“全选当前列表”和“已启用 N / M 条”。
2. 点击“全选当前列表”应一次勾选当前筛选结果,再次点击应全部取消;手动取消其中一条时,全选框应显示半选状态。只有点击“保存修改”“生成切片”或“保存并同步发送中心”后才会写入数据库。
3. 打开发送中心“内容准备”,每个“原始处理任务”标题右侧应显示“全选本任务”;点击后只勾选该任务在当前平台下的内容,页面底部“已选 N 条”数量同步变化。
4. 再选择另一任务或取消其中一条,确认两个任务的全选状态各自独立;切换抖音/B站后选择应自动清空,不允许跨平台混合操作。
5. 手机宽度下,任务组操作应分成清楚的两行,底部批量栏应以两列按钮展示且没有横向滚动。功能验收只需检查勾选状态,除非确实要保存或排期,否则不要点击保存、设置排期或真实发送。

## 2026-08-13 GitHub 主页任务详情与发送中心图片检查方法

1. 打开本次 PR 的 `README.md` 预览,滚动到“产品预览”,确认“任务详情”显示任务详情图、“发送中心”显示发送中心图,且两张画面完整、位置正确。
Expand Down
86 changes: 85 additions & 1 deletion app/static/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,37 @@ fieldset input.visually-hidden-file {
gap: 10px;
}

.clip-bulk-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
position: sticky;
top: 76px;
z-index: 4;
padding: 10px 12px;
border: 1px solid rgba(31, 111, 255, 0.18);
border-radius: 12px;
background: rgba(247, 250, 255, 0.94);
box-shadow: 0 8px 24px rgba(31, 77, 143, 0.08);
backdrop-filter: blur(14px);
}

.clip-bulk-select-control {
min-height: 32px;
padding: 4px 8px;
border: 0;
background: transparent;
cursor: pointer;
font-weight: 750;
}

.clip-bulk-select-count {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}

.clip-card {
gap: 11px;
padding: 12px;
Expand Down Expand Up @@ -5182,6 +5213,26 @@ td a,
}
.publish-task-group-identity small { color: var(--muted); }
.publish-task-group-actions { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
.publish-task-group-select {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
padding: 7px 10px;
border: 1px solid rgba(31, 111, 255, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.76);
color: var(--blue);
cursor: pointer;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.publish-task-group-select input { width: auto; min-height: 0; margin: 0; }
.publish-task-group-select:has(input:checked) {
border-color: rgba(31, 111, 255, 0.34);
background: var(--blue-soft);
}
.publish-task-group-body { display: grid; gap: 12px; }
.publish-task-group-body[hidden] { display: none; }

Expand Down Expand Up @@ -5633,7 +5684,15 @@ td a,
.publish-content-grid .span-2, .publish-bilibili-fields.span-2, .publish-bilibili-fields .span-2 { grid-column: 1; }
.publish-card-heading { display: grid; }
.publish-task-group-header { align-items: flex-start; flex-direction: column; }
.publish-task-group-actions { width: 100%; justify-content: space-between; }
.publish-task-group-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
gap: 8px;
}
.publish-task-group-select { justify-self: start; }
.publish-task-group-actions .text-button { align-self: center; white-space: nowrap; }
.publish-task-group-actions [data-task-group-toggle] { grid-column: 1 / -1; width: 100%; }
.publish-task-group-identity p strong { max-width: 70vw; }
.publish-card-heading .publish-card-heading-actions { justify-content: flex-start; }
.publish-plan-row, .publish-execution-row { grid-template-columns: 28px minmax(0, 1fr); }
Expand All @@ -5653,4 +5712,29 @@ td a,
.publish-history-view-switch { width: 100%; }
.publish-history-view-switch .secondary-button { flex: 1; }
.publish-history-toolbar-actions .compact-filter { width: 100%; }
.publish-selection-bar {
left: 14px;
right: 14px;
bottom: 14px;
width: auto;
max-height: calc(100vh - 28px);
transform: none;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow-x: hidden;
overflow-y: auto;
padding: 12px;
}
.publish-selection-bar select {
grid-column: 1 / -1;
width: 100%;
min-width: 0;
}
.publish-selection-bar button {
width: 100%;
min-width: 0;
height: auto;
white-space: normal;
}
.publish-selection-bar [data-clear-selection] { grid-column: 1 / -1; }
}
43 changes: 43 additions & 0 deletions app/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,9 @@ const clipReviewForm = document.querySelector("#clip-review-form");
const saveClipsButton = document.querySelector("#save-clips-button");
const generateClipsButton = document.querySelector("#generate-clips-button");
const clipReviewMessage = document.querySelector("#clip-review-message");
const clipSelectAll = document.querySelector("[data-clip-select-all]");
const clipSelectAllLabel = document.querySelector("[data-clip-select-all-label]");
const clipSelectCount = document.querySelector("[data-clip-select-count]");
const clipPreviewVideo = document.querySelector("#clip-preview-video");
const clipPreviewDock = document.querySelector("#clip-preview-dock");
const clipPreviewCaption = document.querySelector("#clip-preview-caption");
Expand Down Expand Up @@ -786,10 +789,30 @@ function getClipReviewCards() {
return Array.from(document.querySelectorAll("[data-clip-card]"));
}

function getClipEnableCheckboxes() {
return getClipReviewCards()
.map((card) => card.querySelector("[name='enabled']"))
.filter(Boolean);
}

function updateClipSelectAllUi() {
const checkboxes = getClipEnableCheckboxes();
const enabledCount = checkboxes.filter((checkbox) => checkbox.checked).length;
const allEnabled = checkboxes.length > 0 && enabledCount === checkboxes.length;
if (clipSelectAll) {
clipSelectAll.disabled = checkboxes.length === 0;
clipSelectAll.checked = allEnabled;
clipSelectAll.indeterminate = enabledCount > 0 && !allEnabled;
}
if (clipSelectAllLabel) clipSelectAllLabel.textContent = allEnabled ? "取消全选" : "全选当前列表";
if (clipSelectCount) clipSelectCount.textContent = `已启用 ${enabledCount} / ${checkboxes.length} 条`;
}

function updateClipReviewActionState() {
const hasCards = getClipReviewCards().length > 0;
if (saveClipsButton) saveClipsButton.disabled = !hasCards;
if (generateClipsButton) generateClipsButton.disabled = !hasCards;
updateClipSelectAllUi();
}

function collectClipReviewPayload() {
Expand Down Expand Up @@ -1173,6 +1196,26 @@ document.querySelectorAll("[data-clip-card] input[name='start_time'], [data-clip
input.addEventListener("change", () => updateCardTimeDataset(input.closest("[data-clip-card]")));
});

if (clipSelectAll) {
clipSelectAll.addEventListener("change", () => {
const shouldEnable = clipSelectAll.checked;
getClipEnableCheckboxes().forEach((checkbox) => { checkbox.checked = shouldEnable; });
updateClipSelectAllUi();
showClipReviewMessage(
shouldEnable
? "已全选当前列表。确认无误后,请点击“保存修改”写入数据库。"
: "已取消当前列表的全部选择。确认无误后,请点击“保存修改”写入数据库。",
"info",
);
});
}

clipReviewForm?.addEventListener("change", (event) => {
if (event.target.matches("[data-clip-card] input[name='enabled']")) updateClipSelectAllUi();
});

updateClipSelectAllUi();

if (saveClipsButton && clipReviewForm) {
saveClipsButton.addEventListener("click", async () => {
const taskId = clipReviewForm.dataset.taskId;
Expand Down
32 changes: 32 additions & 0 deletions app/static/js/publish-center.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ if (publishCenterRoot) {
group.hidden = visibleCount === 0;
if (visibleCount > 0) visibleGroups.push(group);
if (visibleCount === 0) setTaskGroupExpanded(group, false);
syncTaskGroupSelectionUi(group);
});
if (visibleGroups.length && !visibleGroups.some((group) => group.dataset.expanded === "true")) {
setTaskGroupExpanded(visibleGroups[0], true);
Expand Down Expand Up @@ -1020,6 +1021,26 @@ if (publishCenterRoot) {
queueHistoryRefresh(true);
}

function visibleTaskGroupRows(group) {
if (!group) return [];
return Array.from(group.querySelectorAll('[data-publish-row][data-section="content"]')).filter(
(row) => !row.hidden && row.dataset.platform === activePlatform,
);
}

function syncTaskGroupSelectionUi(group) {
const checkbox = group?.querySelector("[data-task-group-select]");
if (!checkbox) return;
const rows = visibleTaskGroupRows(group);
const selectedCount = rows.filter((row) => selectedJobIds.has(row.dataset.jobId)).length;
const allSelected = rows.length > 0 && selectedCount === rows.length;
checkbox.disabled = rows.length === 0;
checkbox.checked = allSelected;
checkbox.indeterminate = selectedCount > 0 && !allSelected;
const label = group.querySelector("[data-task-group-select-label]");
if (label) label.textContent = allSelected ? "取消全选" : "全选本任务";
}

function updateSelectionUi() {
Array.from(selectedJobIds).forEach((jobId) => {
const row = document.querySelector(`[data-publish-row][data-job-id="${CSS.escape(jobId)}"]`);
Expand All @@ -1032,6 +1053,7 @@ if (publishCenterRoot) {
if (selectedCountNode) selectedCountNode.textContent = String(count);
if (drawerCount) drawerCount.textContent = String(count);
if (selectionBar) selectionBar.hidden = count === 0;
document.querySelectorAll("[data-publish-task-group]").forEach(syncTaskGroupSelectionUi);
}

function setActivePlatform(nextPlatform) {
Expand Down Expand Up @@ -1507,6 +1529,16 @@ if (publishCenterRoot) {
updateHistorySelectionUi();
return;
}
const taskGroupCheckbox = event.target.closest("[data-task-group-select]");
if (taskGroupCheckbox) {
const group = taskGroupCheckbox.closest("[data-publish-task-group]");
visibleTaskGroupRows(group).forEach((row) => {
if (taskGroupCheckbox.checked) selectedJobIds.add(row.dataset.jobId);
else selectedJobIds.delete(row.dataset.jobId);
});
updateSelectionUi();
return;
}
const checkbox = event.target.closest("[data-publish-select]");
if (checkbox) {
const row = checkbox.closest("[data-publish-row]");
Expand Down
7 changes: 7 additions & 0 deletions app/templates/clip_review.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ <h1>片段审核 · {{ task.title }}</h1>
<section class="review-layout">
<form class="clip-list" id="clip-review-form" data-task-id="{{ task.id }}" data-max-clip-seconds="{{ task.max_clip_duration * 60 }}">
{% if clips %}
<div class="clip-bulk-select" data-clip-bulk-select>
<label class="toggle-line clip-bulk-select-control">
<input type="checkbox" data-clip-select-all aria-label="全选当前显示的候选片段">
<span data-clip-select-all-label>全选当前列表</span>
</label>
<span class="clip-bulk-select-count" data-clip-select-count>已启用 {{ clips|selectattr('enabled')|list|length }} / {{ clips|length }} 条</span>
</div>
{% for clip in clips %}
<article
class="panel clip-card"
Expand Down
4 changes: 4 additions & 0 deletions app/templates/publish.html
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ <h3>{{ group.task_name }}</h3>
<small>创建于 {{ group.task_created_at_display }} · <span data-task-group-count>0 条待准备</span></small>
</div>
<div class="publish-task-group-actions">
<label class="publish-task-group-select">
<input type="checkbox" data-task-group-select aria-label="全选任务“{{ group.task_name }}”当前可见内容">
<span data-task-group-select-label>全选本任务</span>
</label>
<a class="text-button" href="/tasks/{{ group.task_id }}">查看任务详情</a>
<button type="button" class="secondary-button" data-task-group-toggle aria-expanded="{{ 'true' if loop.first else 'false' }}">
<span data-task-group-action>{{ '收起' if loop.first else '展开' }}</span>
Expand Down
8 changes: 8 additions & 0 deletions docs/UI_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
- 发送中心固定分为“内容准备、排期计划、执行记录”:内容准备核对素材和文案,排期计划管理北京时间与月历,执行记录保留成功、失败、取消、导出和人工复核证据。
- v2.0 继续使用 Apple 风格浅色界面、蓝色主强调、轻量玻璃卡片和充足留白;状态颜色必须服务于“处理中、完成、警告 / 人工处理”,不能只做装饰。

## 2026-08-15 更新:片段与发送任务分组全选

- `/tasks/{task_id}/clips/review` 在候选列表顶部使用浅蓝吸顶操作条,左侧是“全选当前列表 / 取消全选”,右侧显示当前页面已启用数量;部分启用时使用原生半选状态。
- 片段全选只作用于当前筛选结果并停留在前端草稿状态,保存、切片和同步继续使用右侧既有操作,避免一次勾选立即改动数据库。
- `/publish` 的每个原始处理任务组头增加独立“全选本任务”,范围严格限制为当前任务、当前平台和当前可见内容;选中后复用页面底部批量栏,不增加第二套批量业务逻辑。
- 任务组全部选中时文案切换为“取消全选”,部分选中时显示半选;切换平台继续清空全部选择,折叠任务组不影响已选状态。
- 小屏下任务组操作改为两行,底部批量栏使用两列网格并让账号选择占满整行,避免按钮文字竖排和横向滚动。

## 2026-08-03 更新:排期日期详情与时间升序

- `/publish` 的“排期计划”月历保留每格前两条预览和“另有 N 条”,有排期的日期格整体增加悬停、键盘焦点和蓝色选中状态,空日期保持不可操作。
Expand Down
Loading
Loading