Polish editor timeline interactions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s
This commit is contained in:
parent
134778dde8
commit
5edfa05369
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
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 = '';
|
||||
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;
|
||||
if (!isVideoTrack) {
|
||||
layoutLane(lane);
|
||||
selectClip(clip);
|
||||
updateInspector();
|
||||
updateActionButtons();
|
||||
setTimeout(() => { _bodyDragMoved = false; }, 50);
|
||||
return;
|
||||
}
|
||||
if (dropIndex === null) {
|
||||
const dx = ev.clientX - startMouseX;
|
||||
const dt = (dx / laneRect.width) * TOTAL;
|
||||
const newCenter = startStartT + D(clip) / 2 + dt;
|
||||
const newCenter = Math.max(clipDuration / 2, Math.min(TOTAL - clipDuration / 2, startStartT + clipDuration / 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);
|
||||
dropIndex = insertIndexForCenter(siblings, newCenter);
|
||||
}
|
||||
clip.remove();
|
||||
if (insertBefore) lane.insertBefore(clip, insertBefore);
|
||||
else lane.appendChild(clip);
|
||||
layoutLane(lane);
|
||||
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();
|
||||
})();
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user