diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 5ce1273..1d5b667 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,16 @@ # Development Log +## 2026-08-26 全站克制动效与减少动态效果支持 + +- 调研 Motion、AutoAnimate、Animate.css、AOS、GSAP 和 View Transitions 后,按当前原生 HTML/CSS/JavaScript + Jinja 架构实现本地轻量动效层;没有引入 npm、CDN、React/Vue 或第三方运行时依赖。 +- 工作台周统计与七日柱图首次出现时播放一次短动效;新建任务只在长直播设置展开和文件选择确认时反馈;任务详情只在进度、阶段或数值真实变化时反馈;片段审核、字幕和发送中心复用进度、弹窗、抽屉、标签和主动新增内容动效。 +- 任务列表、完整转写、系统静态状态、运行日志、字幕虚拟行、波形、视频、42 格日历和 5 秒发布记录刷新不添加批量入场或循环动画,避免为凑数量造成闪烁、错位和阅读干扰。 +- `prefers-reduced-motion` 同时关闭非必要 CSS 动画/过渡和 JavaScript 平滑滚动;脚本失败或禁用时不隐藏任何业务内容,动画不改变按钮状态、请求或页面语义。 +- 实时文本观察器只在文本值确实变化时高亮,不会因 3/5 秒轮询重复闪烁,也不覆盖状态标签原有背景色。 +- 窄屏顺带补齐片段审核网格和卡片的 `min-width: 0` 约束,390px 验收横向溢出由 49px 收口为 0;其他检查页面也无整页横向溢出。 +- 本轮仅修改呈现与可访问性,不触发 AI、转写、切片、字幕烧录、封面生成、排期、数据库写入或真实平台发布。 +- 动效与关联前端定向回归 `62 passed`、完整回归 `799 passed`;Python Compileall、三份 JavaScript 语法检查、Ruff 和 `git diff --check` 均通过,10 条提示均为既有弃用告警。 + ## 2026-08-26 发送中心 AI 文案操作分层 - 明确区分“同步遗漏切片”和“AI 重写文案”:前者继续固定使用 `use_ai=false`,只为遗漏成片补建发布草稿与默认封面;后者只修改已存在草稿的标题、话题和简介。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 57c2ffb..40f71d1 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,13 @@ # Next Steps +## 2026-08-26 页面动效检查 + +1. 打开工作台,刷新后只会看到周统计和七日柱图播放一次短动效;最近任务表、导航和静态内容不会反复晃动。 +2. 新建任务页选择“长直播高光(语言类)”,两项长直播设置应平滑出现;任务详情只有进度或状态真的变化时才短暂提示,停留轮询不会每 3 秒闪一次。 +3. 发送中心切换“内容准备 / 排期计划 / 执行记录”,以及打开账号或排期抽屉时应有轻微过渡;系统不会给每 5 秒刷新的记录和 42 格日历重复套动画。 +4. Windows 11 可在“设置 → 辅助功能 → 视觉效果 → 动画效果”关闭动画后刷新页面,所有内容应立即显示,抽屉和定位改为即时反馈。 +5. 本轮动效检查不需要点击 AI、切片、字幕烧录、封面、排期确认或发送按钮,也不会触发真实抖音/B站投稿。 + ## 2026-08-26 发送中心 AI 文案操作检查 1. 打开 `http://127.0.0.1:8001/publish`:页面顶栏只保留“账号管理”,“同步遗漏切片”和“补齐抖音缺失封面”位于内容准备的维护区。 diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 763c45b..1916856 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -5023,6 +5023,33 @@ td a, .system-chip { display: none; } + + .review-layout { + grid-template-columns: minmax(0, 1fr); + } + + .review-layout > * { + min-width: 0; + } + + .clip-list > * { + min-width: 0; + } + + .clip-card { + grid-template-columns: minmax(0, 1fr); + } + + .clip-card > * { + min-width: 0; + max-width: 100%; + } + + .preview-panel { + position: static; + max-height: none; + overflow: visible; + } } @media (max-width: 860px) { @@ -6441,3 +6468,188 @@ td a, min-height: 92px; } } + +/* Motion system: page-specific, local-only and safe to ignore when JS is unavailable. */ +body.motion-enabled { + --motion-duration: 420ms; + --motion-fast: 260ms; + --motion-ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +.motion-enabled .motion-pending { + opacity: 0; + will-change: opacity, transform; +} + +.motion-enabled .motion-pending.motion-rise { + transform: translate3d(0, 10px, 0); +} + +.motion-enabled .motion-pending.motion-soft { + transform: translate3d(0, 4px, 0); +} + +.motion-enabled .motion-pending.motion-in { + animation: motion-rise-in var(--motion-duration) var(--motion-ease) both; + animation-delay: calc(min(var(--motion-order, 0), 9) * 36ms); +} + +.motion-enabled .motion-pending.motion-soft.motion-in { + animation-name: motion-soft-in; + animation-duration: 360ms; +} + +.motion-enabled[data-motion-page="dashboard"] .weekly-bar-column[data-motion-seen="true"] .weekly-bar-fill { + animation: motion-chart-grow 560ms var(--motion-ease) both; + animation-delay: calc(120ms + min(var(--motion-order, 0), 9) * 38ms); + transform-origin: center bottom; +} + +.motion-enabled :is(.progress, .large-progress) > span, +.motion-enabled [data-task-live-progress-bar], +.motion-enabled #cut-job-progress-bar, +.motion-enabled #subtitle-batch-progress-bar { + transition: width 360ms var(--motion-ease); +} + +.motion-enabled :is(.metro-step, .status-pill, .publish-calendar-day, .publish-plan-row, .publish-content-card) { + transition: + color var(--motion-fast) ease, + background-color var(--motion-fast) ease, + border-color var(--motion-fast) ease, + box-shadow var(--motion-fast) ease; +} + +.motion-enabled :is(.primary-button, .secondary-button, .icon-button, .text-button, .link-button):not(:disabled):active { + transform: translateY(0) scale(0.985); +} + +.motion-enabled .motion-data-updated { + animation: motion-data-highlight 620ms ease-out both; +} + +.motion-enabled .motion-item-added { + animation: motion-item-added 360ms var(--motion-ease) both; + animation-delay: calc(min(var(--motion-order, 0), 7) * 28ms); +} + +.motion-enabled .motion-confirm { + animation: motion-confirm 480ms var(--motion-ease) both; +} + +.motion-enabled .motion-panel-activated, +.motion-enabled.motion-settled .publish-calendar-day-detail:not([hidden]), +.motion-enabled.motion-settled .subtitle-batch-job:not([hidden]) { + animation: motion-panel-swap 300ms var(--motion-ease) both; +} + +.motion-enabled .clip-card.is-removing { + transition: opacity 180ms ease, transform 180ms ease; + transform: scale(0.995); +} + +@media (hover: hover) { + .motion-enabled :is(.review-task-row, table tbody tr) { + transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; + } + + .motion-enabled .review-task-row:hover { + border-color: rgba(37, 111, 255, 0.2); + box-shadow: 0 10px 28px rgba(24, 39, 75, 0.07); + transform: translateY(-1px); + } + + .motion-enabled table tbody tr:hover { + background: rgba(234, 242, 255, 0.38); + } +} + +.motion-enabled.motion-settled .modal-backdrop:not([hidden]), +.motion-enabled.motion-settled .source-monitor-backdrop:not([hidden]), +.motion-enabled.motion-settled .schedule-drawer-backdrop:not([hidden]) { + animation: motion-overlay-in 220ms ease-out both; +} + +.motion-enabled.motion-settled .modal-backdrop:not([hidden]) > .modal-panel, +.motion-enabled.motion-settled .source-monitor-backdrop:not([hidden]) > .source-monitor-panel { + animation: motion-dialog-in 300ms var(--motion-ease) both; +} + +.motion-enabled.motion-settled .transcript-drawer:not([hidden]), +.motion-enabled.motion-settled .schedule-drawer:not([hidden]) { + animation: motion-drawer-in 320ms var(--motion-ease) both; +} + +.motion-enabled.motion-settled details[open] > :not(summary) { + animation: motion-soft-in 240ms ease-out both; +} + +@keyframes motion-rise-in { + from { opacity: 0; transform: translate3d(0, 10px, 0); } + to { opacity: 1; transform: translate3d(0, 0, 0); } +} + +@keyframes motion-soft-in { + from { opacity: 0; transform: translate3d(0, 4px, 0); } + to { opacity: 1; transform: translate3d(0, 0, 0); } +} + +@keyframes motion-chart-grow { + from { opacity: 0.2; transform: scaleY(0.08); } + to { opacity: 1; transform: scaleY(1); } +} + +@keyframes motion-data-highlight { + 0% { box-shadow: 0 0 0 4px rgba(37, 111, 255, 0.12); filter: saturate(1.12); } + 100% { box-shadow: 0 0 0 0 rgba(37, 111, 255, 0); filter: saturate(1); } +} + +@keyframes motion-item-added { + from { opacity: 0; transform: translate3d(0, 7px, 0); } + to { opacity: 1; transform: translate3d(0, 0, 0); } +} + +@keyframes motion-confirm { + 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 160, 107, 0); } + 45% { border-color: rgba(34, 160, 107, 0.42); box-shadow: 0 0 0 5px rgba(34, 160, 107, 0.1); } +} + +@keyframes motion-panel-swap { + from { opacity: 0; transform: translate3d(0, 5px, 0); } + to { opacity: 1; transform: translate3d(0, 0, 0); } +} + +@keyframes motion-overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes motion-dialog-in { + from { opacity: 0; transform: translate3d(0, 10px, 0) scale(0.985); } + to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } +} + +@keyframes motion-drawer-in { + from { opacity: 0; transform: translate3d(18px, 0, 0); } + to { opacity: 1; transform: translate3d(0, 0, 0); } +} + +@media print, (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 1ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + } + + .send-publishing-spinner { + animation: none !important; + } +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 31c09b6..8c3cfa4 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -27,6 +27,12 @@ async function apiFetch(url, options = {}) { window.apiFetch = apiFetch; +function preferredScrollBehavior() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; +} + +window.preferredScrollBehavior = preferredScrollBehavior; + const newTaskForm = document.querySelector("#new-task-form"); const newTaskAutoMode = newTaskForm?.querySelector("input[name='auto_mode']"); const newTaskSubmitButton = document.querySelector("#new-task-submit-button"); @@ -1355,7 +1361,7 @@ function ensureClipPreviewVisible() { const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; const isVisible = rect.top >= 0 && rect.top < viewportHeight * 0.72 && rect.bottom > Math.min(120, viewportHeight); if (isVisible) return; - previewTarget.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" }); + previewTarget.scrollIntoView({ behavior: preferredScrollBehavior(), block: "start", inline: "nearest" }); } function closeTranscriptDrawer() { diff --git a/app/static/js/motion.js b/app/static/js/motion.js new file mode 100644 index 0000000..c056cbd --- /dev/null +++ b/app/static/js/motion.js @@ -0,0 +1,209 @@ +(() => { + "use strict"; + + const body = document.body; + const mainPanel = document.querySelector(".main-panel"); + if (!body || !mainPanel) return; + + const path = window.location.pathname.replace(/\/+$/, "") || "/"; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const profiles = [ + { + name: "dashboard", + matches: () => path === "/", + reveals: [ + [".dashboard-stat-item", "soft", 5], + [".weekly-bar-column", "soft", 7], + ], + }, + { + name: "new-task", + matches: () => path === "/tasks/new", + reveals: [], + }, + { + name: "tasks", + matches: () => path === "/tasks", + reveals: [], + }, + { + name: "transcript", + matches: () => /^\/tasks\/[^/]+\/transcript$/.test(path), + reveals: [], + }, + { + name: "clip-review", + matches: () => /^\/tasks\/[^/]+\/clips(?:\/review)?$/.test(path), + reveals: [], + }, + { + name: "task-detail", + matches: () => /^\/tasks\/[^/]+$/.test(path), + reveals: [], + }, + { + name: "clips", + matches: () => path === "/clips", + reveals: [], + }, + { + name: "subtitle-task", + matches: () => /^\/subtitles\/[^/]+$/.test(path), + reveals: [], + }, + { + name: "subtitles", + matches: () => path === "/subtitles", + reveals: [], + }, + { + name: "publish", + matches: () => path === "/publish", + reveals: [], + }, + { + name: "system", + matches: () => path === "/system", + reveals: [], + }, + ]; + + const profile = profiles.find((item) => item.matches()) || { + name: body.dataset.page || "static", + reveals: [], + }; + + body.dataset.motionPage = profile.name; + body.dataset.motion = reduceMotion ? "reduced" : "full"; + if (reduceMotion) return; + + body.classList.add("motion-enabled"); + + const canObserveViewport = "IntersectionObserver" in window; + const revealObserver = canObserveViewport + ? new IntersectionObserver((entries, observer) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const node = entry.target; + node.classList.add("motion-in"); + node.dataset.motionSeen = "true"; + observer.unobserve(node); + }); + }, { rootMargin: "0px 0px -4% 0px", threshold: 0.04 }) + : null; + + const isVisible = (node) => !node.hidden && !node.closest("[hidden]"); + const registered = new Set(); + + profile.reveals.forEach(([selector, kind, limit], groupIndex) => { + const nodes = Array.from(document.querySelectorAll(selector)) + .filter((node) => isVisible(node) && !registered.has(node)) + .slice(0, limit); + nodes.forEach((node, index) => { + registered.add(node); + node.classList.add("motion-pending", `motion-${kind}`); + node.style.setProperty("--motion-order", String(Math.min(groupIndex + index, 9))); + const finishInitialMotion = (event) => { + if (event.target !== node) return; + node.classList.remove("motion-pending", "motion-rise", "motion-soft", "motion-in"); + node.removeEventListener("animationend", finishInitialMotion); + }; + node.addEventListener("animationend", finishInitialMotion); + if (revealObserver) { + revealObserver.observe(node); + } else { + node.classList.add("motion-in"); + } + }); + }); + + const replayClass = (node, className) => { + if (!node || reduceMotion || !isVisible(node)) return; + node.classList.remove(className); + window.requestAnimationFrame(() => { + node.classList.add(className); + const finishReplay = (event) => { + if (event.target !== node) return; + node.classList.remove(className); + node.removeEventListener("animationend", finishReplay); + }; + node.addEventListener("animationend", finishReplay); + }); + }; + + const liveValueSelectors = [ + "[data-task-live-header-status]", + "[data-task-live-progress-number]", + "[data-task-live-operation-label]", + "[data-task-live-operation-progress]", + "[data-task-live-candidate-count]", + "[data-task-live-output-count]", + "[data-worker-status]", + "[data-scheduler-runtime]", + "#subtitle-save-state", + ]; + + liveValueSelectors.forEach((selector) => { + document.querySelectorAll(selector).forEach((node) => { + let lastValue = node.textContent; + const observer = new MutationObserver(() => { + const nextValue = node.textContent; + if (nextValue === lastValue) return; + lastValue = nextValue; + replayClass(node, "motion-data-updated"); + }); + observer.observe(node, { childList: true, characterData: true, subtree: true }); + }); + }); + + const dynamicSelector = [ + ".ai-analysis-result-item", + ".ai-history-item", + ".schedule-preview-list > div", + ].join(","); + + const animateAddedContent = (root) => { + if (!(root instanceof Element)) return; + const candidates = []; + if (root.matches(dynamicSelector)) candidates.push(root); + candidates.push(...root.querySelectorAll(dynamicSelector)); + candidates.slice(0, 8).forEach((node, index) => { + if (node.dataset.motionDynamicSeen === "true" || !isVisible(node)) return; + node.dataset.motionDynamicSeen = "true"; + node.style.setProperty("--motion-order", String(Math.min(index, 7))); + replayClass(node, "motion-item-added"); + }); + }; + + const dynamicObserver = new MutationObserver((records) => { + records.forEach((record) => record.addedNodes.forEach(animateAddedContent)); + }); + dynamicObserver.observe(mainPanel, { childList: true, subtree: true }); + + const selectionProfile = document.querySelector("#selection-profile"); + const longLiveSettings = document.querySelector("#long-live-settings"); + selectionProfile?.addEventListener("change", () => { + if (longLiveSettings && !longLiveSettings.hidden) replayClass(longLiveSettings, "motion-item-added"); + }); + + const videoFileInput = document.querySelector("#video-file-input"); + const uploadPanel = document.querySelector("#upload-source-panel"); + videoFileInput?.addEventListener("change", () => replayClass(uploadPanel, "motion-confirm")); + + document.querySelectorAll("[data-center-tab]").forEach((button) => { + button.addEventListener("click", () => { + const panel = document.querySelector(`[data-center-panel="${CSS.escape(button.dataset.centerTab || "")}"]`); + replayClass(panel, "motion-panel-activated"); + }); + }); + + document.querySelectorAll("[data-prompt-preset-tab] input").forEach((input) => { + input.addEventListener("change", () => { + const card = document.querySelector(`[data-prompt-preset-card][data-preset-id="${CSS.escape(input.value)}"]`); + replayClass(card, "motion-panel-activated"); + }); + }); + + window.setTimeout(() => body.classList.add("motion-settled"), 620); +})(); diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js index be4ac43..1c656f6 100644 --- a/app/static/js/publish-center.js +++ b/app/static/js/publish-center.js @@ -270,7 +270,7 @@ if (publishCenterRoot) { `[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(jobId || "")}"]`, ); if (!row || row.hidden) return; - row.scrollIntoView({ behavior: "smooth", block: "center" }); + row.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); row.classList.add("is-calendar-focus"); window.setTimeout(() => row.classList.remove("is-calendar-focus"), 1600); } @@ -1339,7 +1339,7 @@ if (publishCenterRoot) { const readiness = effectiveReadiness(row); if (readiness.action === "start_worker") { document.querySelector("[data-worker-help]")?.removeAttribute("hidden"); - document.querySelector("[data-scheduler-health]")?.scrollIntoView({ behavior: "smooth", block: "center" }); + document.querySelector("[data-scheduler-health]")?.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); await refreshSchedulerHealth(true); return; } @@ -1377,7 +1377,7 @@ if (publishCenterRoot) { } switchTab("content"); const editorRow = document.querySelector(`[data-publish-row][data-section="content"][data-job-id="${CSS.escape(row.dataset.jobId)}"]`); - editorRow?.scrollIntoView({ behavior: "smooth", block: "center" }); + editorRow?.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); editorRow?.querySelector("[data-account-select]")?.focus(); showMessage("请在内容准备中选择本次使用的同平台账号并保存。", "error"); return; @@ -1386,7 +1386,7 @@ if (publishCenterRoot) { switchTab("content"); const editorRow = document.querySelector(`[data-publish-row][data-section="content"][data-job-id="${CSS.escape(row.dataset.jobId)}"]`); if (editorRow && !editorRow.hidden) { - editorRow.scrollIntoView({ behavior: "smooth", block: "center" }); + editorRow.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); editorRow.querySelector("input, textarea, select")?.focus(); } showMessage(readiness.message || "请先补齐发布内容并保存。", "error"); @@ -1480,7 +1480,7 @@ if (publishCenterRoot) { if (!cell) return; selectedCalendarDate = cell.dataset.calendarDate || ""; renderCalendar(); - calendarDayDetail?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + calendarDayDetail?.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "nearest" }); }); calendarNode?.addEventListener("keydown", (event) => { const cell = event.target.closest("[data-calendar-date]"); @@ -1814,7 +1814,7 @@ if (publishCenterRoot) { ); setTaskGroupExpanded(contentRow?.closest("[data-publish-task-group]"), true); switchTab("content"); - contentRow?.scrollIntoView({ behavior: "smooth", block: "center" }); + contentRow?.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); showMessage(data.message || "已取消发送并返回内容准备。", "success"); } catch (error) { showMessage(`取消发送失败:${error.message}`, "error"); @@ -2206,7 +2206,7 @@ if (publishCenterRoot) { ); if (group && !group.hidden) { setTaskGroupExpanded(group, true); - group.scrollIntoView({ behavior: "smooth", block: "start" }); + group.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "start" }); } else { showMessage("已定位到该处理任务,但当前没有可准备的抖音新版本内容。可返回任务页重新同步。"); } diff --git a/app/templates/base.html b/app/templates/base.html index e4db3ae..ed88c20 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,10 +8,10 @@ - + {% block extra_head %}{% endblock %} -
+