Polish editor timeline interactions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s

This commit is contained in:
iye 2026-05-27 17:59:55 +08:00
parent 134778dde8
commit 5edfa05369
8 changed files with 511 additions and 126 deletions

View File

@ -1,6 +1,7 @@
(function () {
const PATHS = {
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 10v10h14V10"/><path d="M9 20v-6h6v6"/>',
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
boxes: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
@ -8,7 +9,8 @@
video: '<path d="m16 13 5.2 3.1a.5.5 0 0 0 .8-.4V8.3a.5.5 0 0 0-.8-.4L16 11"/><rect x="2" y="6" width="14" height="12" rx="2"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
library: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
folder: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
library: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
wallet: '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M16 14h2"/>',
creditCard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>',
@ -50,10 +52,11 @@
};
const ALIASES = {
dashboard: 'home',
dashboard: 'layoutDashboard',
products: 'package',
projects: 'clapperboard',
assetFactory: 'sparkles',
library: 'folder',
account: 'creditCard',
billing: 'creditCard',
team: 'users',

View File

@ -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',

View File

@ -306,7 +306,7 @@
</div>
<div class="actions">
<button class="btn" type="button" id="lib-manage-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>
<span class="lib-manage-label">管理资产</span>
</button>
<button class="btn btn-primary" id="open-upload-btn" type="button">

View File

@ -1267,14 +1267,19 @@
.queue-bar .bar-wrap { flex: 1; height: 6px; background: var(--background-lighter); overflow: hidden; }
.queue-bar .bar-wrap > span { display: block; height: 100%; background: var(--heat); }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; align-content: start; align-items: start; }
.video-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base); }
.video-card:hover { border-color: var(--heat-40); }
.video-thumb { aspect-ratio: 9/16; max-height: 320px; position: relative; border-radius: var(--r-md) var(--r-md) 0 0; }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(196px, 216px)); gap: 14px; align-content: start; align-items: start; justify-content: start; }
.video-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), background var(--t-base); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.video-card:hover { border-color: var(--heat-40); background: var(--background-lighter); }
.video-thumb { width: 100%; aspect-ratio: 9/16; position: relative; border-radius: var(--r-md) var(--r-md) 0 0; overflow: hidden; }
.video-thumb .play { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.05); cursor: pointer; opacity: 0; transition: opacity .15s; }
.video-thumb:hover .play { opacity: 1; }
.video-thumb .btn-play { width: 36px; height: 36px; background: rgba(0,0,0,.7); color: var(--accent-white); border-radius: 50%; display: grid; place-items: center; }
.video-card .body { padding: 10px 12px; }
.video-card .body { padding: 12px 12px 14px; flex: 1 1 auto; min-height: 118px; display: flex; flex-direction: column; }
.video-card-head { display: flex; align-items: flex-start; gap: 8px; }
.video-card-title { min-width: 0; flex: 1 1 auto; font-size: 13px; line-height: 1.4; font-weight: 600; color: var(--accent-black); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.video-card-head .pill { flex: 0 0 auto; }
.video-meta { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 5px; }
.video-actions { margin-top: auto; padding-top: 12px; display: flex; align-items: center; gap: 10px; }
/* 视频详情 modal 大视频 + 历史版本 */
.vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; }
@ -1334,7 +1339,7 @@
.input-mini { width: 90px; padding: 0 10px; height: 28px; font-size: 12px; border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--black-alpha-12); }
/* ── 时间轴 · 剪映风格(Restraint 浅色规范) ── */
.timeline { grid-column: 1 / -1; padding: 14px 16px; background: var(--background-base); }
.timeline { position: relative; grid-column: 1 / -1; padding: 14px 16px; background: var(--background-base); }
/* 工具栏 */
.tl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-faint); }
@ -1383,6 +1388,26 @@
pointer-events: none; opacity: .55;
border-radius: inherit;
}
.tl-track .lane.is-snapping .clip,
.tl-track .lane.is-reordering .clip:not(.dragging) { transition: left .16s ease, width .16s ease; }
.tl-align-guide {
position: absolute;
width: 1.5px;
background: var(--accent-forest);
opacity: 0;
pointer-events: none;
z-index: 9;
}
.tl-align-guide.show { opacity: 1; }
.tl-insert-ghost {
position: absolute; top: 3px; bottom: 3px;
border: 1px dashed var(--heat);
background: var(--heat-12);
border-radius: 4px;
pointer-events: none;
box-sizing: border-box;
z-index: 2;
}
/* 片段公共 · 绝对定位 · left/width 由 data 驱动 */
.clip {
@ -1391,7 +1416,7 @@
padding: 0 8px; font-size: 11px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer; overflow: hidden; white-space: nowrap; user-select: none;
cursor: grab; overflow: hidden; white-space: nowrap; user-select: none;
box-sizing: border-box;
}
.clip:hover { filter: brightness(1.04); }
@ -1403,7 +1428,9 @@
background: var(--heat-12); border-color: var(--heat-40); color: var(--heat);
}
.clip.video .frames {
position: absolute; inset: 0;
position: absolute; top: 0; bottom: 0; left: 0;
width: var(--src-width, 100%);
transform: translateX(var(--src-offset, 0%));
display: flex; gap: 0;
pointer-events: none; z-index: 0;
border-radius: inherit;
@ -1489,15 +1516,26 @@
}
/* 拖拽时片段移动光标 */
.clip { cursor: pointer; }
.clip { cursor: grab; }
.clip.selected { cursor: grab; }
.clip:active,
.clip.selected:active { cursor: grabbing; }
.clip.dragging { cursor: grabbing; opacity: .9; z-index: 7 !important; }
/* Playhead · 顶到时间尺、贯穿三条轨 · 可拖拽 */
.playhead {
position: absolute; top: -90px; bottom: -44px;
width: 1.5px; background: var(--heat);
width: 18px; transform: translateX(-50%);
background: transparent;
z-index: 10;
pointer-events: auto;
cursor: ew-resize;
touch-action: none;
}
.playhead::after {
content: ''; position: absolute; top: 0; bottom: 0; left: 50%;
transform: translateX(-50%);
width: 1.5px; background: var(--heat);
pointer-events: none;
}
.playhead::before {
@ -1510,11 +1548,11 @@
}
.playhead .ph-grab {
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
width: 18px; height: 18px;
width: 24px; height: 24px;
cursor: ew-resize; pointer-events: auto;
border-radius: 50%;
}
.playhead.is-dragging { background: var(--heat); }
.playhead.is-dragging::after { background: var(--heat); }
.timeline.is-dragging-playhead { cursor: ew-resize; user-select: none; }
/* Ruler 可点击 seek */
@ -1882,11 +1920,12 @@
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 1 · 深夜办公桌</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">15s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>↻ 重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
<div class="video-card-head"><strong class="video-card-title">场 1 · 深夜办公桌</strong><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="video-meta">15s · 1080×1920 · ¥0.45</div>
<div class="video-actions">
<button class="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
</div>
</div>
</div>
@ -1896,11 +1935,12 @@
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 2 · 面膜包装/特写</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">12s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>↻ 重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
<div class="video-card-head"><strong class="video-card-title">场 2 · 面膜包装/特写</strong><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="video-meta">12s · 1080×1920 · ¥0.45</div>
<div class="video-actions">
<button class="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
</div>
</div>
</div>
@ -1910,11 +1950,12 @@
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 3 · 化妆台/产品定格</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">13s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>↻ 重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
<div class="video-card-head"><strong class="video-card-title">场 3 · 化妆台/产品定格</strong><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="video-meta">13s · 1080×1920 · ¥0.45</div>
<div class="video-actions">
<button class="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
</div>
</div>
</div>
@ -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();
})();

View File

@ -626,7 +626,7 @@
</div>
<div class="actions">
<button class="btn btn-edit-toggle" type="button" id="edit-toggle-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>
<span class="btn-edit-label">管理商品</span>
</button>
<button class="btn btn-primary btn-create" type="button" id="open-new-product">
@ -1321,7 +1321,7 @@ function enterEditMode() {
function exitEditMode() {
document.body.classList.remove('edit-mode');
editToggleBtn.classList.remove('active');
editLabel.textContent = '编辑商品';
editLabel.textContent = '管理商品';
document.querySelectorAll('.product-card.selected').forEach(c => c.classList.remove('selected'));
}
editToggleBtn.addEventListener('click', () => {

View File

@ -8,7 +8,14 @@
<style>
.wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 36px; align-items: start; max-width: 1400px; }
@media (max-width: 1180px) { .wizard { grid-template-columns: 200px minmax(0, 1fr); } }
.steps { position: sticky; top: 24px; align-self: start; max-height: calc(100vh - 48px); overflow-y: auto; }
.steps {
position: sticky;
top: calc(64px + 24px);
align-self: start;
max-height: calc(100vh - 64px - 48px);
overflow-y: auto;
z-index: 2;
}
.wiz-preview { display: none !important; }
/* 顶部胶囊 + 商品横向单行 */
.pick-actions { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }

View File

@ -132,7 +132,7 @@
</div>
<div class="actions">
<button class="btn" type="button" id="proj-manage-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>
<span class="proj-manage-label">管理项目</span>
</button>
<a class="btn btn-primary btn-lg btn-create" href="projects-new.html">

View File

@ -11,23 +11,38 @@
.settings-nav { position: sticky; top: 16px; }
.settings-nav .nav-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }
.settings-nav a { display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background var(--t-base), border-color var(--t-base); position: relative; }
.settings-nav a:hover { background: var(--background-lighter); }
.settings-nav a:focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
.settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; font: inherit; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; cursor: pointer; text-decoration: none; text-align: left; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); position: relative; }
.settings-nav :where(a, button):hover { background: var(--background-lighter); }
.settings-nav :where(a, button):focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
.settings-nav a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.settings-nav a svg { width: 16px; height: 16px; stroke-width: 1.5; }
.settings-nav :where(a, button) svg { width: 16px; height: 16px; stroke-width: 1.5; flex: 0 0 auto; }
.settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }
.settings-nav a.active .nav-badge { color: var(--heat); background: var(--accent-white); border-color: var(--heat-20); }
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
.settings-nav a.has-changes .nav-dot { display: block; }
.settings-nav a.active .nav-dot { right: -4px; }
.settings-nav .logout-pill {
width: calc(100% - 24px);
height: 38px;
margin: 4px 12px 0;
justify-content: center;
border-radius: var(--r-pill);
background: var(--accent-black);
border-color: var(--accent-black);
color: var(--accent-white);
font-weight: 500;
}
.settings-nav .logout-pill:hover,
.settings-nav .logout-pill:focus-visible {
background: var(--black-alpha-88);
border-color: var(--black-alpha-88);
color: var(--accent-white);
}
/* ─── pane ─── */
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.pane .pane-desc { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-bottom: 18px; }
.pane.danger { border-color: rgba(180,30,30,.25); background: rgba(180,30,30,.03); }
.pane.danger h3 { color: var(--accent-crimson); }
/* ─── form row ─── */
.form-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-faint); align-items: center; }
@ -96,6 +111,39 @@
.av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 11px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }
.av-up-rules .li { display: flex; gap: 8px; }
.av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
.logout-confirm-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }
.logout-confirm-copy { margin: 0 0 12px; color: var(--black-alpha-72); }
.logout-confirm-points {
display: grid;
gap: 8px;
padding: 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
}
.logout-confirm-points .li {
display: flex;
gap: 8px;
font-size: 12.5px;
line-height: 1.55;
color: var(--black-alpha-64);
}
.logout-confirm-points .li::before {
content: '//';
flex: 0 0 auto;
font-family: var(--font-mono);
color: var(--black-alpha-32);
}
.logout-unsaved-note {
margin-top: 12px;
padding: 9px 11px;
border: 1px solid var(--heat-20);
border-radius: var(--r-md);
background: var(--heat-12);
color: var(--heat);
font-size: 12.5px;
line-height: 1.6;
}
</style>
</head>
<body>
@ -120,38 +168,38 @@
<aside class="settings-nav" role="tablist" aria-label="设置分区">
<div class="nav-h">个人</div>
<a href="#sec-profile" class="active" data-jump="sec-profile" role="tab" aria-controls="sec-profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="7.5" r="3.5"/><path d="M5 21a7 7 0 0 1 14 0"/></svg>
<span>个人信息</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-security" data-jump="sec-security" role="tab" aria-controls="sec-security">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>
<span>安全</span>
<span class="nav-badge" data-count-source="sec-security">3</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-notify" data-jump="sec-notify" role="tab" aria-controls="sec-notify">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>
<span>通知</span>
<span class="nav-badge" data-count-source="sec-notify">5</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<div class="nav-h" style="margin-top: 16px;">偏好</div>
<a href="#sec-pref" data-jump="sec-pref" role="tab" aria-controls="sec-pref">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 9-4 9 4-9 4-9-4z"/><path d="m3 12 9 4 9-4"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/></svg>
<span>创作默认</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-display" data-jump="sec-display" role="tab" aria-controls="sec-display">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 10h20"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
<span>显示</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<div class="nav-h" style="margin-top: 16px;">账号</div>
<a href="#sec-danger" data-jump="sec-danger" role="tab" aria-controls="sec-danger" style="color: var(--accent-crimson);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>
<span>危险操作</span>
</a>
<button class="logout-pill" type="button" id="settings-logout-btn" onclick="logoutCurrentDevice()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
<span>退出登录</span>
</button>
</aside>
<!-- 右侧内容 -->
@ -405,40 +453,40 @@
</div>
</section>
<!-- ─── 危险操作 ─── -->
<section class="pane danger" id="sec-danger">
<h3>危险操作</h3>
<div class="pane-desc">// 这些操作不可撤销,请确认后再执行</div>
<div class="form-row">
<div class="lbl">导出我的数据<div class="lbl-sub">// 项目 + 资产元数据</div></div>
<div class="val">
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-56);">// 准备时间约 24 小时,完成后邮件通知</span>
<button class="btn btn-sm" style="margin-left: auto;" onclick="Shell.toast('已申请导出', '约 24 小时后邮件发送')">申请导出</button>
</div>
</div>
<div class="form-row">
<div class="lbl">退出登录</div>
<div class="val">
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-56);">// 仅退出当前设备,数据保留</span>
<button class="btn btn-sm" style="margin-left: auto; color: var(--accent-crimson); border-color: var(--accent-crimson);" onclick="if(confirm('确定退出登录?')){Shell.toast('已退出','正在跳转登录页');setTimeout(()=>location.href='login.html',600);}">退出登录</button>
</div>
</div>
<div class="form-row">
<div class="lbl">注销账号</div>
<div class="val">
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-56);">// 团队余额清零、所有项目作为孤儿归档</span>
<button class="btn btn-sm" style="margin-left: auto; color: var(--accent-crimson); border-color: var(--accent-crimson);" onclick="if(confirm('彻底注销当前账号? 此操作不可恢复')) Shell.toast('已提交注销申请', '24 小时内人工复核')">注销账号</button>
</div>
</div>
</section>
<div style="text-align: center; padding: 24px 0 8px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;">
// Airshelf · v2.1 · build 20260521
</div>
</main>
</div>
<!-- ─── 退出登录确认 modal ─── -->
<div class="modal-bg" id="logout-confirm-bg" onclick="if(event.target===this)Shell.closeModal('logout-confirm-bg')">
<div class="modal logout-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="logout-confirm-title" aria-describedby="logout-confirm-desc">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<div class="modal-h">
<div class="ic-m">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
</div>
<div class="ti" id="logout-confirm-title">退出当前账号<span>// LOG OUT CURRENT SESSION</span></div>
</div>
<div class="modal-b" id="logout-confirm-desc">
<p class="logout-confirm-copy">确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。</p>
<div class="logout-confirm-points">
<div class="li">项目、资产、团队成员与余额数据都会保留</div>
<div class="li">仅影响当前浏览器会话,不会下线其他设备</div>
</div>
<div class="logout-unsaved-note" id="logout-unsaved-note" hidden>当前有未保存的设置变更,退出后这些变更不会保存。</div>
</div>
<div class="modal-f">
<button class="btn" type="button" id="logout-confirm-cancel" onclick="Shell.closeModal('logout-confirm-bg')">取消</button>
<button class="btn btn-primary" type="button" id="logout-confirm-ok">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
确认退出
</button>
</div>
</div>
</div>
<!-- ─── 上传头像 modal ─── -->
<div class="modal-bg" id="avatar-up-bg" onclick="if(event.target===this)Shell.closeModal('avatar-up-bg')">
<div class="modal av-up-modal">
@ -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 = '';
}