From 5edfa053694a1530923055fa936b844236a9e943 Mon Sep 17 00:00:00 2001
From: iye <1713042409@qq.com>
Date: Wed, 27 May 2026 17:59:55 +0800
Subject: [PATCH] Polish editor timeline interactions
---
电商AI平台/assets/icons.js | 7 +-
电商AI平台/assets/shell.js | 4 +-
电商AI平台/library.html | 2 +-
电商AI平台/pipeline.html | 454 +++++++++++++++++++++++++++++------
电商AI平台/products.html | 4 +-
电商AI平台/projects-new.html | 9 +-
电商AI平台/projects.html | 2 +-
电商AI平台/settings.html | 155 ++++++++----
8 files changed, 511 insertions(+), 126 deletions(-)
diff --git a/电商AI平台/assets/icons.js b/电商AI平台/assets/icons.js
index c5b55b4..44ff598 100644
--- a/电商AI平台/assets/icons.js
+++ b/电商AI平台/assets/icons.js
@@ -1,6 +1,7 @@
(function () {
const PATHS = {
home: '',
+ layoutDashboard: '',
package: '',
boxes: '',
clapperboard: '',
@@ -8,7 +9,8 @@
video: '',
sparkles: '',
images: '',
- library: '',
+ folder: '',
+ library: '',
users: '',
wallet: '',
creditCard: '',
@@ -50,10 +52,11 @@
};
const ALIASES = {
- dashboard: 'home',
+ dashboard: 'layoutDashboard',
products: 'package',
projects: 'clapperboard',
assetFactory: 'sparkles',
+ library: 'folder',
account: 'creditCard',
billing: 'creditCard',
team: 'users',
diff --git a/电商AI平台/assets/shell.js b/电商AI平台/assets/shell.js
index da9f0ed..ee32507 100644
--- a/电商AI平台/assets/shell.js
+++ b/电商AI平台/assets/shell.js
@@ -12,7 +12,7 @@ const ShellIcon = (name, opts) => window.IconKit ? window.IconKit.svg(name, opts
const NAV = [
{
id: 'dashboard', label: '工作台', href: 'index.html',
- icon: ShellIcon('home')
+ icon: ShellIcon('dashboard')
},
{
id: 'products', label: '商品库', href: 'products.html', badge: '7',
@@ -28,7 +28,7 @@ const NAV = [
},
{
id: 'library', label: '资产库', href: 'library.html',
- icon: ShellIcon('images')
+ icon: ShellIcon('library')
},
{
id: 'team', label: '团队', href: 'team.html',
diff --git a/电商AI平台/library.html b/电商AI平台/library.html
index 011a6f8..070402b 100644
--- a/电商AI平台/library.html
+++ b/电商AI平台/library.html
@@ -306,7 +306,7 @@
-
场 1 · 深夜办公桌完成
-
15s · 1080×1920 · ¥0.45
-
-
-
+
场 1 · 深夜办公桌完成
+
15s · 1080×1920 · ¥0.45
+
+
+
+
@@ -1896,11 +1935,12 @@
-
场 2 · 面膜包装/特写完成
-
12s · 1080×1920 · ¥0.45
-
-
-
+
场 2 · 面膜包装/特写完成
+
12s · 1080×1920 · ¥0.45
+
+
+
+
@@ -1910,11 +1950,12 @@
-
场 3 · 化妆台/产品定格完成
-
13s · 1080×1920 · ¥0.45
-
-
-
+
场 3 · 化妆台/产品定格完成
+
13s · 1080×1920 · ¥0.45
+
+
+
+
@@ -4497,15 +4538,19 @@ function openTriLightbox(ver, isAdopted, prodName) {
const sec = s - m * 60;
return String(m).padStart(2,'0') + ':' + sec.toFixed(2).padStart(5,'0');
}
- // 磁吸时间轴 · 仅 data-dur (当前时长 · 秒) + data-max (源最大 · 可恢复至此)
+ // 时间轴数据 · data-start(时间线起点) + data-dur(当前时长) + data-max(源最大) + data-in(源素材入点)
const MIN_DUR = 0.2;
const $laneB = document.querySelector('#ed-timeline .bgm-track .lane');
const lanes = { video: $laneV, subtitle: $laneS, bgm: $laneB };
+ const ALIGN_EPS = 0.12;
+ const $alignGuide = document.createElement('span');
+ $alignGuide.className = 'tl-align-guide';
+ $tl.appendChild($alignGuide);
const D = (c) => Number(c.dataset.dur || 1);
const M = (c) => Number(c.dataset.max || 1);
- const setD = (c, v) => { c.dataset.dur = String(Math.max(MIN_DUR, Math.min(M(c), v))); };
+ const IN = (c) => Number(c.dataset.in || 0);
function clipDur(c) { return D(c); }
- function clipStart(c) {
+ function compactStart(c) {
let acc = 0;
for (const sib of c.parentElement.querySelectorAll('.clip')) {
if (sib === c) return acc;
@@ -4513,19 +4558,171 @@ function openTriLightbox(ver, isAdopted, prodName) {
}
return 0;
}
+ function clipStart(c) {
+ const start = Number(c.dataset.start);
+ return Number.isFinite(start) ? start : compactStart(c);
+ }
function clipEnd(c) { return clipStart(c) + D(c); }
- // 磁吸布局:每轨片段按 DOM 顺序紧贴排列 · 无 gap
+ function sourceDur(c) { return Math.max(M(c), D(c), MIN_DUR); }
+ function clampSourceIn(c, sourceIn = IN(c)) {
+ const maxIn = Math.max(0, sourceDur(c) - D(c));
+ return Math.max(0, Math.min(maxIn, sourceIn));
+ }
+ function syncVideoPreview(c) {
+ if (!c.classList.contains('video')) return;
+ const dur = Math.max(D(c), MIN_DUR);
+ const srcDur = Math.max(sourceDur(c), dur);
+ const sourceIn = clampSourceIn(c);
+ c.dataset.in = String(sourceIn);
+ c.style.setProperty('--src-width', (srcDur / dur * 100) + '%');
+ c.style.setProperty('--src-offset', (srcDur ? (-(sourceIn / srcDur) * 100) : 0) + '%');
+ }
+ function freezeLaneStarts(lane) {
+ let acc = 0;
+ for (const clip of lane.querySelectorAll('.clip')) {
+ const explicit = Number(clip.dataset.start);
+ const start = Number.isFinite(explicit) ? explicit : acc;
+ clip.dataset.start = String(start);
+ acc = start + D(clip);
+ }
+ }
+ function compactLane(lane) {
+ let acc = 0;
+ for (const clip of lane.querySelectorAll('.clip')) {
+ clip.dataset.start = String(acc);
+ acc += D(clip);
+ }
+ layoutLane(lane);
+ }
+ function snapLane(lane) {
+ lane.classList.add('is-snapping');
+ compactLane(lane);
+ window.setTimeout(() => lane.classList.remove('is-snapping'), 180);
+ }
+ function settleLane(lane) {
+ if (lane === lanes.video) snapLane(lane);
+ else layoutLane(lane);
+ }
+ // 绝对布局:默认紧贴排列;trim 后允许保留被裁剪端的可见空隙
function layoutLane(lane) {
let acc = 0;
for (const clip of lane.querySelectorAll('.clip')) {
const dur = D(clip);
- clip.style.left = (acc / TOTAL * 100) + '%';
+ const explicit = Number(clip.dataset.start);
+ const start = Number.isFinite(explicit) ? explicit : acc;
+ clip.style.left = (start / TOTAL * 100) + '%';
clip.style.width = (dur / TOTAL * 100) + '%';
- acc += dur;
+ syncVideoPreview(clip);
+ acc = start + dur;
}
}
- function layoutAll() {
- Object.values(lanes).forEach(l => l && layoutLane(l));
+ function videoBoundaries() {
+ const points = [];
+ $laneV.querySelectorAll('.clip.video').forEach(c => {
+ points.push(clipStart(c), clipEnd(c));
+ });
+ return points;
+ }
+ function nearestVideoBoundary(edges) {
+ let best = null;
+ const points = videoBoundaries();
+ edges.forEach((edge, edgeIndex) => {
+ points.forEach(time => {
+ const diff = Math.abs(edge - time);
+ if (diff <= ALIGN_EPS && (!best || diff < best.diff)) {
+ best = { edge, edgeIndex, time, diff };
+ }
+ });
+ });
+ return best;
+ }
+ function showAlignGuideAt(time) {
+ const laneRect = $laneV.getBoundingClientRect();
+ const tlRect = $tl.getBoundingClientRect();
+ const rulerRect = $ruler.getBoundingClientRect();
+ const lastLane = $laneB || $laneS || $laneV;
+ const lastRect = lastLane.getBoundingClientRect();
+ $alignGuide.style.left = (laneRect.left + (time / TOTAL) * laneRect.width - tlRect.left) + 'px';
+ $alignGuide.style.top = (rulerRect.top - tlRect.top) + 'px';
+ $alignGuide.style.height = (lastRect.bottom - rulerRect.top) + 'px';
+ $alignGuide.classList.add('show');
+ }
+ function hideAlignGuide() {
+ $alignGuide.classList.remove('show');
+ }
+ function guideEdgeToVideo(edge) {
+ const match = nearestVideoBoundary([edge]);
+ if (!match) {
+ hideAlignGuide();
+ return null;
+ }
+ showAlignGuideAt(match.time);
+ return match.time;
+ }
+ function guideClipToVideo(start, dur) {
+ const safeDur = Math.max(MIN_DUR, Math.min(dur, TOTAL));
+ const clampedStart = Math.max(0, Math.min(TOTAL - safeDur, start));
+ const match = nearestVideoBoundary([clampedStart, clampedStart + safeDur]);
+ if (!match) {
+ hideAlignGuide();
+ return clampedStart;
+ }
+ const nextStart = match.edgeIndex === 0 ? match.time : match.time - safeDur;
+ showAlignGuideAt(match.time);
+ return Math.max(0, Math.min(TOTAL - safeDur, nextStart));
+ }
+ function ensureDropGhost(lane) {
+ let ghost = lane.querySelector('.tl-insert-ghost');
+ if (!ghost) {
+ ghost = document.createElement('span');
+ ghost.className = 'tl-insert-ghost';
+ lane.appendChild(ghost);
+ }
+ return ghost;
+ }
+ function removeDropGhost(lane) {
+ lane.querySelector('.tl-insert-ghost')?.remove();
+ }
+ function placeDropGhost(ghost, start, dur) {
+ ghost.style.left = (start / TOTAL * 100) + '%';
+ ghost.style.width = (dur / TOTAL * 100) + '%';
+ }
+ function insertIndexForCenter(siblings, centerT) {
+ let acc = 0;
+ for (let i = 0; i < siblings.length; i++) {
+ const mid = acc + D(siblings[i]) / 2;
+ if (centerT < mid) return i;
+ acc += D(siblings[i]);
+ }
+ return siblings.length;
+ }
+ function previewReorder(lane, dragged, insertIndex) {
+ const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== dragged);
+ const ghost = ensureDropGhost(lane);
+ let acc = 0;
+ for (let i = 0; i <= siblings.length; i++) {
+ if (i === insertIndex) {
+ placeDropGhost(ghost, acc, D(dragged));
+ acc += D(dragged);
+ }
+ const sibling = siblings[i];
+ if (sibling) {
+ sibling.dataset.start = String(acc);
+ sibling.style.left = (acc / TOTAL * 100) + '%';
+ sibling.style.width = (D(sibling) / TOTAL * 100) + '%';
+ syncVideoPreview(sibling);
+ acc += D(sibling);
+ }
+ }
+ }
+ function commitReorder(lane, dragged, insertIndex) {
+ const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== dragged);
+ dragged.remove();
+ if (insertIndex >= siblings.length) lane.appendChild(dragged);
+ else lane.insertBefore(dragged, siblings[insertIndex]);
+ }
+ function compactAll() {
+ Object.values(lanes).forEach(l => l && compactLane(l));
}
function clipAtTimeOnTrack(track, t) {
const lane = lanes[track];
@@ -4601,8 +4798,10 @@ function openTriLightbox(ver, isAdopted, prodName) {
if (_bodyDragMoved) return; // 拖动重排刚结束 · 抑制 click
e.stopPropagation();
selectClip(clip);
- currentTime = clipStart(clip);
- updateTimeUI();
+ if (clip.dataset.track === 'video') {
+ currentTime = clipStart(clip);
+ updateTimeUI();
+ }
});
});
}
@@ -4615,7 +4814,6 @@ function openTriLightbox(ver, isAdopted, prodName) {
updateTimeUI();
}
$laneV.addEventListener('click', e => laneSeek($laneV, e));
- $laneS.addEventListener('click', e => laneSeek($laneS, e));
$ruler.addEventListener('click', (e) => {
const rect = $ruler.getBoundingClientRect();
@@ -4625,23 +4823,35 @@ function openTriLightbox(ver, isAdopted, prodName) {
});
let dragging = false;
- $playhead.querySelector('.ph-grab').addEventListener('mousedown', (e) => {
+ let playheadDragOffset = 0;
+ function playheadCenterX() {
+ const rect = $laneS.getBoundingClientRect();
+ return rect.left + (currentTime / TOTAL) * rect.width;
+ }
+ function seekPlayhead(clientX) {
+ const rect = $laneS.getBoundingClientRect();
+ const pct = Math.max(0, Math.min(1, (clientX - playheadDragOffset - rect.left) / rect.width));
+ currentTime = pct * TOTAL;
+ updateTimeUI();
+ }
+ function startPlayheadDrag(e) {
e.preventDefault();
+ e.stopPropagation();
dragging = true;
+ playheadDragOffset = e.clientX - playheadCenterX();
if (playing) pause();
$playhead.classList.add('is-dragging');
$tl.classList.add('is-dragging-playhead');
- });
+ }
+ $playhead.addEventListener('mousedown', startPlayheadDrag);
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
- const rect = $laneS.getBoundingClientRect();
- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- currentTime = pct * TOTAL;
- updateTimeUI();
+ seekPlayhead(e.clientX);
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
+ playheadDragOffset = 0;
$playhead.classList.remove('is-dragging');
$tl.classList.remove('is-dragging-playhead');
});
@@ -4707,12 +4917,14 @@ function openTriLightbox(ver, isAdopted, prodName) {
function deleteSelected() {
if (!selectedEl) return;
+ const lane = selectedEl.parentElement;
const next = selectedEl.nextElementSibling?.classList.contains('clip')
? selectedEl.nextElementSibling
: selectedEl.previousElementSibling?.classList.contains('clip')
? selectedEl.previousElementSibling
: null;
selectedEl.remove();
+ settleLane(lane);
if (next) selectClip(next);
else { selectedEl = null; updateInspector(); updateActionButtons(); }
renumberVideo();
@@ -4727,6 +4939,10 @@ function openTriLightbox(ver, isAdopted, prodName) {
dup.querySelectorAll('[data-trim]').forEach(t => t.remove());
delete dup.dataset.boundClick;
selectedEl.after(dup);
+ if (selectedEl.parentElement !== lanes.video) {
+ dup.dataset.start = String(Math.max(0, Math.min(TOTAL - D(dup), clipEnd(selectedEl))));
+ }
+ settleLane(selectedEl.parentElement);
bindClipClicks();
renumberVideo();
selectClip(dup);
@@ -4743,12 +4959,16 @@ function openTriLightbox(ver, isAdopted, prodName) {
// 左半:dur=max=leftDur(切开后双方各自独立 · 不能再恢复跨越切点)
selectedEl.dataset.dur = String(leftDur);
selectedEl.dataset.max = String(leftDur);
+ selectedEl.dataset.in = '0';
+ selectedEl.dataset.start = String(s);
const right = selectedEl.cloneNode(true);
right.classList.remove('selected');
right.querySelectorAll('[data-trim]').forEach(t => t.remove());
delete right.dataset.boundClick;
right.dataset.dur = String(rightDur);
right.dataset.max = String(rightDur);
+ right.dataset.in = '0';
+ right.dataset.start = String(s + leftDur);
// 右半若是视频片段 · 重新生成胶卷帧条数量(按时长成比例)
if (right.classList.contains('video')) {
const oldFrames = right.querySelector('.frames');
@@ -4776,32 +4996,93 @@ function openTriLightbox(ver, isAdopted, prodName) {
});
}
- // Trim 把手 · 三轨通用 · 磁吸:邻居自动跟随前移/后退
+ // Trim 把手 · 三轨通用 · 左右边界按真实裁剪方向反馈
document.addEventListener('mousedown', (e) => {
const handle = e.target.closest('[data-trim]');
if (!handle || !selectedEl) return;
e.preventDefault(); e.stopPropagation();
const side = handle.dataset.trim;
const lane = selectedEl.parentElement;
+ const isVideoTrack = lane === lanes.video;
+ freezeLaneStarts(lane);
const laneRect = lane.getBoundingClientRect();
const startMouseX = e.clientX;
+ const startStart = clipStart(selectedEl);
const startDur = D(selectedEl);
+ const startEnd = startStart + startDur;
+ const startIn = clampSourceIn(selectedEl);
+ const startSourceDur = sourceDur(selectedEl);
+ const startOut = Math.max(0, startSourceDur - startIn - startDur);
+ const prevClip = selectedEl.previousElementSibling?.classList.contains('clip') ? selectedEl.previousElementSibling : null;
+ const nextClip = selectedEl.nextElementSibling?.classList.contains('clip') ? selectedEl.nextElementSibling : null;
+ const prevEnd = prevClip ? clipEnd(prevClip) : 0;
+ const nextStart = nextClip ? clipStart(nextClip) : TOTAL;
+ let didTrim = false;
function onMove(ev) {
const dx = ev.clientX - startMouseX;
+ if (Math.abs(dx) > 1) didTrim = true;
const dt = (dx / laneRect.width) * TOTAL;
- // 左把手:右拖缩短 / 左拖恢复(直至 max)
- // 右把手:右拖扩长(直至 max)/ 左拖缩短
- const newDur = side === 'l' ? (startDur - dt) : (startDur + dt);
- setD(selectedEl, newDur);
+ if (!isVideoTrack) {
+ if (side === 'l') {
+ const maxStart = startEnd - MIN_DUR;
+ let newStart = Math.max(0, Math.min(maxStart, startStart + dt));
+ const guidedStart = guideEdgeToVideo(newStart);
+ if (guidedStart !== null) newStart = Math.max(0, Math.min(maxStart, guidedStart));
+ selectedEl.dataset.start = String(newStart);
+ selectedEl.dataset.dur = String(Math.max(MIN_DUR, startEnd - newStart));
+ } else {
+ const minEnd = startStart + MIN_DUR;
+ let newEnd = Math.max(minEnd, Math.min(TOTAL, startEnd + dt));
+ const guidedEnd = guideEdgeToVideo(newEnd);
+ if (guidedEnd !== null) newEnd = Math.max(minEnd, Math.min(TOTAL, guidedEnd));
+ selectedEl.dataset.start = String(startStart);
+ selectedEl.dataset.dur = String(Math.max(MIN_DUR, newEnd - startStart));
+ }
+ layoutLane(lane);
+ updateInspector();
+ updateActionButtons();
+ return;
+ }
+ hideAlignGuide();
+ if (side === 'l') {
+ const minStart = Math.max(prevEnd, startStart - startIn);
+ const maxStart = startEnd - MIN_DUR;
+ const newStart = Math.max(minStart, Math.min(maxStart, startStart + dt));
+ const newDur = Math.max(MIN_DUR, startEnd - newStart);
+ const newIn = Math.max(0, Math.min(startSourceDur - newDur, startIn + (newStart - startStart)));
+ selectedEl.dataset.start = String(newStart);
+ selectedEl.dataset.dur = String(newDur);
+ selectedEl.dataset.in = String(newIn);
+ } else {
+ const minEnd = startStart + MIN_DUR;
+ const maxEnd = Math.min(nextStart, startEnd + startOut, TOTAL);
+ const newEnd = Math.max(minEnd, Math.min(maxEnd, startEnd + dt));
+ selectedEl.dataset.start = String(startStart);
+ selectedEl.dataset.dur = String(newEnd - startStart);
+ selectedEl.dataset.in = String(startIn);
+ }
layoutLane(lane);
updateInspector();
+ updateActionButtons();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
- updateTimeUI();
+ hideAlignGuide();
+ if (isVideoTrack) {
+ snapLane(lane);
+ if (didTrim && selectedEl) {
+ currentTime = side === 'l' ? clipStart(selectedEl) : clipEnd(selectedEl);
+ }
+ updateInspector();
+ updateTimeUI();
+ } else {
+ layoutLane(lane);
+ updateInspector();
+ updateActionButtons();
+ }
}
document.body.style.cursor = 'ew-resize';
document.addEventListener('mousemove', onMove);
@@ -4812,49 +5093,78 @@ function openTriLightbox(ver, isAdopted, prodName) {
let _bodyDragMoved = false;
document.addEventListener('mousedown', (e) => {
const clip = e.target.closest('#ed-timeline .clip');
- if (!clip || !clip.classList.contains('selected')) return;
+ if (!clip) return;
if (e.target.closest('[data-trim]')) return;
if (e.target.closest('.ph-grab')) return;
e.preventDefault();
_bodyDragMoved = false;
+ if (clip !== selectedEl) selectClip(clip);
const lane = clip.parentElement;
+ const isVideoTrack = lane === lanes.video;
+ freezeLaneStarts(lane);
const laneRect = lane.getBoundingClientRect();
const startMouseX = e.clientX;
const startStartT = clipStart(clip);
+ const clipDuration = D(clip);
+ let dropIndex = null;
function onMove(ev) {
const dx = ev.clientX - startMouseX;
if (!_bodyDragMoved && Math.abs(dx) < 5) return;
_bodyDragMoved = true;
- clip.style.transform = 'translateX(' + dx + 'px)';
- clip.style.zIndex = '6';
- clip.style.opacity = '.88';
+ clip.classList.add('dragging');
+ const dt = (dx / laneRect.width) * TOTAL;
+ if (!isVideoTrack) {
+ const previewStart = guideClipToVideo(startStartT + dt, clipDuration);
+ clip.dataset.start = String(previewStart);
+ clip.style.left = (previewStart / TOTAL * 100) + '%';
+ clip.style.width = (clipDuration / TOTAL * 100) + '%';
+ updateInspector();
+ updateActionButtons();
+ document.body.style.cursor = 'grabbing';
+ return;
+ }
+ hideAlignGuide();
+ lane.classList.add('is-reordering');
+ const newCenter = Math.max(clipDuration / 2, Math.min(TOTAL - clipDuration / 2, startStartT + clipDuration / 2 + dt));
+ const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);
+ dropIndex = insertIndexForCenter(siblings, newCenter);
+ previewReorder(lane, clip, dropIndex);
+ const previewStart = Math.max(0, Math.min(TOTAL - clipDuration, newCenter - clipDuration / 2));
+ clip.dataset.start = String(previewStart);
+ clip.style.left = (previewStart / TOTAL * 100) + '%';
+ clip.style.width = (clipDuration / TOTAL * 100) + '%';
+ syncVideoPreview(clip);
document.body.style.cursor = 'grabbing';
}
function onUp(ev) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
- clip.style.transform = '';
- clip.style.zIndex = '';
- clip.style.opacity = '';
+ removeDropGhost(lane);
+ lane.classList.remove('is-reordering');
+ clip.classList.remove('dragging');
document.body.style.cursor = '';
+ hideAlignGuide();
if (!_bodyDragMoved) return;
- const dx = ev.clientX - startMouseX;
- const dt = (dx / laneRect.width) * TOTAL;
- const newCenter = startStartT + D(clip) / 2 + dt;
- const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);
- let acc = 0;
- let insertBefore = null;
- for (const s of siblings) {
- const mid = acc + D(s) / 2;
- if (newCenter < mid) { insertBefore = s; break; }
- acc += D(s);
+ if (!isVideoTrack) {
+ layoutLane(lane);
+ selectClip(clip);
+ updateInspector();
+ updateActionButtons();
+ setTimeout(() => { _bodyDragMoved = false; }, 50);
+ return;
}
- clip.remove();
- if (insertBefore) lane.insertBefore(clip, insertBefore);
- else lane.appendChild(clip);
- layoutLane(lane);
+ if (dropIndex === null) {
+ const dx = ev.clientX - startMouseX;
+ const dt = (dx / laneRect.width) * TOTAL;
+ const newCenter = Math.max(clipDuration / 2, Math.min(TOTAL - clipDuration / 2, startStartT + clipDuration / 2 + dt));
+ const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);
+ dropIndex = insertIndexForCenter(siblings, newCenter);
+ }
+ commitReorder(lane, clip, dropIndex);
+ snapLane(lane);
if (lane === lanes.video) renumberVideo();
+ selectClip(clip);
updateTimeUI();
setTimeout(() => { _bodyDragMoved = false; }, 50);
}
@@ -4863,7 +5173,7 @@ function openTriLightbox(ver, isAdopted, prodName) {
});
// 初始化:磁吸布局 + 绑定
- layoutAll();
+ compactAll();
bindClipClicks();
updateTimeUI();
})();
diff --git a/电商AI平台/products.html b/电商AI平台/products.html
index b63ffe6..9b838ee 100644
--- a/电商AI平台/products.html
+++ b/电商AI平台/products.html
@@ -626,7 +626,7 @@
-
-
- 危险操作
- // 这些操作不可撤销,请确认后再执行
-
-
-
-
-
-
// Airshelf · v2.1 · build 20260521
+
+
+
+
+
+
+
退出当前账号// LOG OUT CURRENT SESSION
+
+
+
确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。
+
+
项目、资产、团队成员与余额数据都会保留
+
仅影响当前浏览器会话,不会下线其他设备
+
+
当前有未保存的设置变更,退出后这些变更不会保存。
+
+
+
+
+
@@ -494,8 +542,25 @@ Shell.render({
});
/* ─── 配置 ─── */
-const SECTIONS = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display', 'sec-danger'];
+const SECTIONS = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display'];
const navLinks = document.querySelectorAll('.settings-nav a[data-jump]');
+let suppressLeavePrompt = false;
+
+function logoutCurrentDevice() {
+ const note = document.getElementById('logout-unsaved-note');
+ if (note) note.hidden = !(typeof dirtyFields !== 'undefined' && dirtyFields.size > 0);
+ Shell.openModal('logout-confirm-bg');
+ requestAnimationFrame(() => document.getElementById('logout-confirm-cancel')?.focus());
+}
+
+function confirmLogout() {
+ suppressLeavePrompt = true;
+ Shell.closeModal('logout-confirm-bg');
+ Shell.toast('已退出', '正在跳转登录页');
+ setTimeout(() => location.href = 'login.html', 600);
+}
+
+document.getElementById('logout-confirm-ok')?.addEventListener('click', confirmLogout);
/* ─── 1. 点击 nav → 只显示对应 section,其余隐藏 + 同步 URL hash ─── */
function showSection(id) {
@@ -651,7 +716,7 @@ cancelBtn.addEventListener('click', () => {
/* ─── 8. 离开页面前提醒(有未保存变更时)─── */
window.addEventListener('beforeunload', e => {
- if (dirtyFields.size > 0) {
+ if (!suppressLeavePrompt && dirtyFields.size > 0) {
e.preventDefault();
e.returnValue = '';
}