/* ============================================================ Airshelf · Shell renderer V2.1 渲染 sidebar / topbar / 网格背景装饰 / Toast / Modal helpers 每个页面调用 Shell.render({ active, crumbs, balance, topActions }) V2.1 变化: - sidebar 搜索 ⌘K → "Ctrl K" Inter Bold 平铺(无 kbd 边框) ============================================================ */ const ShellIcon = (name, opts) => window.IconKit ? window.IconKit.svg(name, opts) : ''; const SHELL_SIDEBAR_COLLAPSED_KEY = 'airshelf:sidebar-collapsed'; const NAV = [ { id: 'dashboard', label: '工作台', href: 'index.html', icon: ShellIcon('dashboard') }, { id: 'products', label: '商品库', href: 'products.html', badge: '7', icon: ShellIcon('package') }, { id: 'projects', label: '视频项目', href: 'projects.html', badge: '8', icon: ShellIcon('clapperboard') }, { id: 'asset-factory', label: '图片生成', href: 'asset-factory.html', icon: ShellIcon('sparkles') }, { id: 'library', label: '资产库', href: 'library.html', icon: ShellIcon('library') }, { id: 'team', label: '团队', href: 'team.html', icon: ShellIcon('users') }, { id: 'account', label: '消费', href: 'account.html', icon: ShellIcon('creditCard') }, { id: 'settings', label: '设置', href: 'settings.html', icon: ShellIcon('settings') } ]; const SHELL_COMMANDS = [ { id: 'dashboard', group: '导航', label: '工作台', sub: '任务队列、今日消耗、项目进度', href: 'index.html', icon: 'dashboard', key: 'D' }, { id: 'products', group: '导航', label: '商品库', sub: '管理 SKU、商品图册、卖点信息', href: 'products.html', icon: 'package', key: 'P' }, { id: 'projects', group: '导航', label: '视频项目', sub: '查看五阶段短视频流水线', href: 'projects.html', icon: 'clapperboard', key: 'V' }, { id: 'asset-factory', group: '导航', label: '图片生成', sub: '模特上身图、平台套图、图片创作', href: 'asset-factory.html', icon: 'sparkles', key: 'I' }, { id: 'library', group: '导航', label: '资产库', sub: '素材、人物、场景、成片统一管理', href: 'library.html', icon: 'folder', key: 'A' }, { id: 'team', group: '导航', label: '团队', sub: '成员、权限、额度、协作记录', href: 'team.html', icon: 'users' }, { id: 'account', group: '导航', label: '消费', sub: '余额、充值、账单流水', href: 'account.html', icon: 'creditCard' }, { id: 'settings', group: '导航', label: '设置', sub: '个人信息、通知、安全、偏好', href: 'settings.html', icon: 'settings' }, { id: 'messages', group: '常用动作', label: '消息中心', sub: '任务提醒、协作评论、系统通知', href: 'messages.html', icon: 'bell', key: 'M' }, { id: 'new-product', group: '常用动作', label: '新建商品', sub: '从商品信息开始生成素材与视频', href: 'product-create.html', icon: 'productPlus' }, { id: 'new-project', group: '常用动作', label: '新建视频项目', sub: '选择商品并进入脚本配置', href: 'projects-new.html', icon: 'plus' }, { id: 'model-photo', group: '常用动作', label: '生成模特上身图', sub: '快速生成 3:4 商品展示素材', href: 'model-photo.html', icon: 'users' }, { id: 'platform-cover', group: '常用动作', label: '生成平台套图', sub: '适配电商平台封面与详情图', href: 'platform-cover.html', icon: 'images' }, { id: 'image-optimize', group: '常用动作', label: '图片创作', sub: '对话式生成、编辑、加入资产库', href: 'image-optimize.html', icon: 'image' }, ]; window.Shell = { render({ active = '', crumbs = [], balance = '¥327.40', topActions = '' } = {}) { const navHtml = NAV.map(n => ` ${n.icon} ${n.label} ${n.badge ? `${n.badge}` : ''} `).join(''); const sidebar = ` `; const crumbHtml = crumbs.length ? `
${crumbs.map((c, i) => { const last = i === crumbs.length - 1; const sep = i > 0 ? '/' : ''; if (last) return `${sep}${c.label}`; return `${sep}${c.label}`; }).join('')}
` : ''; const topbar = `
${crumbHtml}
${ShellIcon('creditCard')} 余额 ${balance}
${topActions}
`; const decorations = `
`; const toastHtml = `
${ShellIcon('check')}
操作成功[ 200 OK ]
`; const commandHtml = ` `; const accountMenuHtml = ` `; // ─── 全局 Lightbox · 任意页面可用 Shell._openLightbox(src, name) ── const lightboxHtml = `
`; // [DEPRECATED · 弹窗已废弃,创建商品直接进 product-create.html] const _deprecatedModalHtml = `
`; // 装订线 SVG 准星 · V2.1 签名元素(圆弧内凹的"+") const cornerSvg = ``; const cornerMarks = ` ${cornerSvg} ${cornerSvg} ${cornerSvg} ${cornerSvg} `; const app = document.createElement('div'); app.className = 'app'; app.innerHTML = sidebar + `
${decorations}${topbar}
${cornerMarks}
`; this.applySidebarCollapse(this.isSidebarCollapsed()); const src = document.getElementById('page'); document.body.prepend(app); this.applySidebarCollapse(this.isSidebarCollapsed()); if (src) { // 把页面 body 内容追加到 .content,保留 4 个 corner-mark SVG document.getElementById('page-content').insertAdjacentHTML('beforeend', src.innerHTML); src.remove(); } document.body.insertAdjacentHTML('beforeend', toastHtml); document.body.insertAdjacentHTML('beforeend', lightboxHtml); document.body.insertAdjacentHTML('beforeend', commandHtml); document.body.insertAdjacentHTML('beforeend', accountMenuHtml); document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); Shell.openCommandPalette(); } }); this.enhanceSelects(document); this._bindGlobalChrome(); }, isSidebarCollapsed() { return localStorage.getItem(SHELL_SIDEBAR_COLLAPSED_KEY) === '1'; }, applySidebarCollapse(collapsed) { document.body.classList.toggle('sidebar-collapsed', !!collapsed); const btn = document.querySelector('.sidebar-toggle'); if (!btn) return; const label = collapsed ? '展开导航' : '收窄导航'; btn.setAttribute('aria-label', label); btn.setAttribute('title', label); btn.setAttribute('aria-pressed', collapsed ? 'true' : 'false'); }, toggleSidebarCollapse() { const next = !this.isSidebarCollapsed(); localStorage.setItem(SHELL_SIDEBAR_COLLAPSED_KEY, next ? '1' : '0'); this.applySidebarCollapse(next); }, _esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); }, _bindGlobalChrome() { const input = document.getElementById('global-search'); if (input && !input.dataset.shellBound) { input.dataset.shellBound = '1'; input.addEventListener('focus', () => { input.blur(); Shell.openCommandPalette(); }); input.addEventListener('keydown', e => { e.preventDefault(); Shell.openCommandPalette(); }); } const bg = document.getElementById('shell-command-bg'); const closeBtn = document.getElementById('shell-command-close'); const cmdInput = document.getElementById('shell-command-input'); if (bg && !bg.dataset.shellBound) { bg.dataset.shellBound = '1'; bg.addEventListener('click', e => { if (e.target === bg) Shell.closeCommandPalette(); }); closeBtn?.addEventListener('click', () => Shell.closeCommandPalette()); cmdInput?.addEventListener('input', () => Shell.renderCommandList(cmdInput.value)); cmdInput?.addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); Shell.closeCommandPalette(); return; } if (e.key !== 'Enter') return; const first = document.querySelector('#shell-command-list .shell-command-item'); if (first) { e.preventDefault(); Shell.runCommand(first.dataset.command); } }); } const accountMenu = document.getElementById('shell-account-menu'); if (accountMenu && !accountMenu.dataset.shellBound) { accountMenu.dataset.shellBound = '1'; accountMenu.addEventListener('click', e => { const item = e.target.closest('[data-account-act]'); if (!item) return; Shell.closeAccountMenu(); const act = item.dataset.accountAct; const hrefs = { settings: 'settings.html', messages: 'messages.html', team: 'team.html', account: 'account.html', logout: 'login.html', }; if (act === 'logout') { Shell.toast('已退出登录', '返回登录页'); setTimeout(() => { location.href = hrefs.logout; }, 220); return; } if (hrefs[act]) location.href = hrefs[act]; }); } if (!this._globalChromeDocBound) { this._globalChromeDocBound = true; document.addEventListener('click', e => { if (!e.target.closest('.shell-account-menu') && !e.target.closest('.topbar-avatar') && !e.target.closest('.user')) { Shell.closeAccountMenu(); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { Shell.closeCommandPalette(); Shell.closeAccountMenu(); } }); } this.renderCommandList(''); }, openCommandPalette(query = '') { const bg = document.getElementById('shell-command-bg'); const input = document.getElementById('shell-command-input'); if (!bg || !input) return; this.closeAccountMenu(); const wasOpen = bg.classList.contains('show'); bg.classList.add('show'); bg.setAttribute('aria-hidden', 'false'); input.value = query; this.renderCommandList(query); if (!wasOpen) this.lockScroll(); requestAnimationFrame(() => input.focus()); }, closeCommandPalette() { const bg = document.getElementById('shell-command-bg'); if (!bg || !bg.classList.contains('show')) return; bg.classList.remove('show'); bg.setAttribute('aria-hidden', 'true'); this.unlockScroll(); }, renderCommandList(query = '') { const list = document.getElementById('shell-command-list'); const count = document.getElementById('shell-command-count'); if (!list) return; const q = String(query || '').trim().toLowerCase(); const items = SHELL_COMMANDS.filter(cmd => { if (!q) return true; return [cmd.label, cmd.sub, cmd.group, cmd.id].join(' ').toLowerCase().includes(q); }); if (count) count.textContent = items.length + ' 项'; if (!items.length) { list.innerHTML = `
${ShellIcon('search')}没有匹配的入口// 换个关键词试试
`; return; } let lastGroup = ''; list.innerHTML = items.map((cmd, i) => { const section = cmd.group !== lastGroup ? `
${this._esc(cmd.group)}
` : ''; lastGroup = cmd.group; return section + ` `; }).join(''); list.querySelectorAll('.shell-command-item').forEach(btn => { btn.addEventListener('click', () => Shell.runCommand(btn.dataset.command)); }); }, runCommand(id) { const cmd = SHELL_COMMANDS.find(item => item.id === id); if (!cmd) return; this.closeCommandPalette(); if (cmd.href) { location.href = cmd.href; return; } Shell.toast('已执行', cmd.label); }, toggleAccountMenu(event) { event?.stopPropagation?.(); const menu = document.getElementById('shell-account-menu'); const anchor = event?.currentTarget; if (!menu || !anchor) return; const shouldOpen = !menu.classList.contains('show'); this.closeCommandPalette(); if (!shouldOpen) { this.closeAccountMenu(); return; } const rect = anchor.getBoundingClientRect(); menu.classList.add('show'); menu.setAttribute('aria-hidden', 'false'); const width = menu.offsetWidth || 232; const height = menu.offsetHeight || 260; let left = rect.right - width; if (left < 12) left = rect.left; left = Math.min(Math.max(12, left), window.innerWidth - width - 12); let top = rect.bottom + 8; if (top + height > window.innerHeight - 12) top = Math.max(12, rect.top - height - 8); menu.style.left = left + 'px'; menu.style.top = top + 'px'; }, closeAccountMenu() { const menu = document.getElementById('shell-account-menu'); if (!menu) return; menu.classList.remove('show'); menu.setAttribute('aria-hidden', 'true'); }, enhanceSelects(root = document) { const scope = root || document; const nodes = []; if (scope.matches?.('select')) nodes.push(scope); scope.querySelectorAll?.('select:not([data-rs-select-bound])').forEach(el => nodes.push(el)); const selects = [...new Set(nodes)].filter(select => select.tagName === 'SELECT' && !select.multiple && Number(select.size || 0) <= 1 && !select.closest('.rs-select') && select.dataset.nativeSelect !== 'true' ); if (!selects.length) { this._bindCustomSelectInfrastructure(); return; } const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const checkSvg = ShellIcon('check', { className: 'mi-check', size: 13 }); const caretSvg = ShellIcon('chevronDown', { size: 12 }); selects.forEach(select => { const wrap = document.createElement('span'); wrap.className = 'rs-select'; if (select.classList.contains('v-edit')) wrap.classList.add('v-edit'); if (select.hidden) wrap.hidden = true; if ( select.classList.contains('select') || select.classList.contains('v-select') || select.classList.contains('nm-select') || select.closest('.field') || select.closest('.form-row') ) wrap.classList.add('rs-select-fill'); if (select.closest('.filter-bar')) wrap.classList.add('rs-select-filter'); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'rs-select-btn'; btn.setAttribute('aria-haspopup', 'listbox'); btn.setAttribute('aria-expanded', 'false'); const menu = document.createElement('div'); menu.className = 'rs-select-menu'; menu.setAttribute('role', 'listbox'); select.parentNode.insertBefore(wrap, select); wrap.appendChild(select); wrap.appendChild(btn); wrap.appendChild(menu); select.dataset.rsSelectBound = '1'; select.tabIndex = -1; const sync = () => { const selected = select.selectedOptions[0] || select.options[select.selectedIndex] || select.options[0]; btn.disabled = select.disabled; btn.innerHTML = `${esc(selected?.textContent || '')}${caretSvg}`; menu.innerHTML = [...select.options].map((opt, i) => ` `).join(''); }; btn.addEventListener('click', e => { e.stopPropagation(); if (select.disabled) return; const willOpen = !wrap.classList.contains('open'); this.closeCustomSelects(wrap); wrap.classList.toggle('open', willOpen); btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); if (willOpen) { menu.classList.remove('align-right'); const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth - 12) menu.classList.add('align-right'); } }); btn.addEventListener('keydown', e => { if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowDown') return; e.preventDefault(); btn.click(); }); menu.addEventListener('click', e => { const item = e.target.closest('.rs-select-option'); if (!item || item.disabled) return; const idx = Number(item.dataset.index); if (!Number.isNaN(idx) && select.selectedIndex !== idx) { select.selectedIndex = idx; select.dispatchEvent(new Event('change', { bubbles: true })); } sync(); this.closeCustomSelects(); btn.focus(); }); select.addEventListener('change', sync); select._rsSelectSync = sync; sync(); }); this._bindCustomSelectInfrastructure(); }, refreshCustomSelects(root = document) { root.querySelectorAll?.('select[data-rs-select-bound="1"]').forEach(select => { if (typeof select._rsSelectSync === 'function') select._rsSelectSync(); }); }, closeCustomSelects(except) { document.querySelectorAll('.rs-select.open').forEach(wrap => { if (except && wrap === except) return; wrap.classList.remove('open'); wrap.querySelector('.rs-select-btn')?.setAttribute('aria-expanded', 'false'); }); }, _bindCustomSelectInfrastructure() { if (this._rsSelectInfrastructureBound) return; this._rsSelectInfrastructureBound = true; document.addEventListener('click', e => { if (!e.target.closest('.rs-select')) this.closeCustomSelects(); requestAnimationFrame(() => this.refreshCustomSelects(document)); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') this.closeCustomSelects(); }); this._rsSelectObserver = new MutationObserver(records => { if (!records.some(r => [...r.addedNodes].some(n => n.nodeType === 1 && (n.matches?.('select') || n.querySelector?.('select')) ))) return; requestAnimationFrame(() => this.enhanceSelects(document)); }); if (document.body) this._rsSelectObserver.observe(document.body, { childList: true, subtree: true }); }, // [DEPRECATED] 新建商品弹窗 · 已废弃,创建商品改为直跳 product-create.html _bindNewProductModal_DEPRECATED() { const modal = document.getElementById('new-product-bg'); if (!modal) return; // 上传图册的内存状态 (供 collect 和 AI CTA 用) const uploads = []; // { id, dataUrl, name, type, size } // 收集表单状态(供 AI CTA 和创建按钮用) const collect = () => { const get = (id) => document.getElementById(id)?.value?.trim() || ''; return { name: get('np-name'), cat: get('np-cat'), target: get('np-target'), bullets: [...document.querySelectorAll('#np-bullets .bl-item .bl-text')].map(el => el.textContent), uploadedCount: uploads.length, uploads: uploads.map(u => ({ name: u.name, type: u.type })), }; }; const MAX_UPLOADS = 5; // 创建按钮: 商品名 + 至少 1 张图 const nameInput = document.getElementById('np-name'); const createBtn = document.getElementById('np-create'); const updateCreateBtn = () => { const nameOk = (nameInput.value || '').trim().length > 0; const uploadOk = uploads.length >= 1; const ok = nameOk && uploadOk; createBtn.disabled = !ok; createBtn.style.opacity = ok ? '' : '.5'; createBtn.style.cursor = ok ? '' : 'not-allowed'; // 提示用户缺什么 if (!nameOk) createBtn.title = '请填写商品名称'; else if (!uploadOk) createBtn.title = '请至少上传 1 张商品图'; else createBtn.title = ''; }; nameInput.addEventListener('input', updateCreateBtn); createBtn.addEventListener('click', () => { if (createBtn.disabled) return; const s = collect(); Shell.toast('商品已创建', `+ ${s.name}`); Shell.closeModal('new-product-bg'); }); // 核心卖点: bullet-list const list = document.getElementById('np-bullets'); const addInput = list.querySelector('.bl-add .bl-input'); const xSvg = ''; const renumber = () => { [...list.querySelectorAll('.bl-item')].forEach((li, i) => { li.querySelector('.num').textContent = i + 1; }); }; const bindX = (x) => { x.addEventListener('click', () => { const li = x.closest('li'); li.style.transition = 'opacity .15s, transform .15s'; li.style.opacity = 0; li.style.transform = 'translateX(-8px)'; setTimeout(() => { li.remove(); renumber(); }, 150); }); }; const addBullet = (text) => { const t = (text || '').trim(); if (!t) return; const li = document.createElement('li'); li.className = 'bl-item'; li.innerHTML = `0${t.replace(/[<>&]/g, c => ({ '<':'<','>':'>','&':'&' })[c])}${xSvg}`; list.querySelector('.bl-add').before(li); bindX(li.querySelector('.bl-x')); renumber(); }; addInput?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addBullet(addInput.value); addInput.value = ''; } }); // 上传组件: 批量上传(MAX 5) + 5 固定槽 + 预览/删除 + AI CTA 联动 const zone = document.getElementById('np-zone'); const zoneText = document.getElementById('np-zone-text'); const grid = document.getElementById('np-grid'); const fileInput = document.getElementById('np-file'); const aiCtaEl = document.getElementById('np-ai-cta'); const aiLabel = document.getElementById('np-ai-label'); const uploadCount = document.getElementById('np-upload-count'); const syncAiCta = () => { if (uploads.length === 0) { aiCtaEl.classList.add('primary'); aiLabel.textContent = '没有图? 让 AI 帮我生成'; } else { aiCtaEl.classList.remove('primary'); aiLabel.textContent = '用 AI 加工 / 生成模特上身图'; } }; const syncZone = () => { const full = uploads.length >= MAX_UPLOADS; zone.classList.toggle('full', full); zoneText.textContent = full ? `已达上限 (${MAX_UPLOADS} / ${MAX_UPLOADS})` : '点击或拖拽上传图片'; }; const syncCounter = () => { uploadCount.textContent = uploads.length; }; const thumbXSvg = ''; const esc = (s) => s.replace(/[<>&"]/g, c => ({ '<':'<','>':'>','&':'&','"':'"' })[c]); const renderGrid = () => { const filledHtml = uploads.map(u => `
${esc(u.name)}
`).join(''); const emptyCount = MAX_UPLOADS - uploads.length; const emptyHtml = Array.from({ length: emptyCount }, () => `
无预览
`).join(''); grid.innerHTML = filledHtml + emptyHtml; // 已填充槽: 点击预览 + X 删除 grid.querySelectorAll('.up-thumb').forEach(thumb => { const id = thumb.dataset.id; thumb.querySelector('.slot-x').addEventListener('click', (e) => { e.stopPropagation(); const idx = uploads.findIndex(u => u.id === id); if (idx >= 0) { const removed = uploads.splice(idx, 1)[0]; Shell.toast('已删除', removed.name); refreshAll(); } }); thumb.addEventListener('click', () => { const u = uploads.find(x => x.id === id); if (u) Shell._openLightbox(u.dataUrl, u.name); }); }); // 空槽: 点击触发上传 grid.querySelectorAll('[data-action="add"]').forEach(slot => { slot.addEventListener('click', () => fileInput.click()); }); }; const refreshAll = () => { renderGrid(); syncAiCta(); syncZone(); syncCounter(); updateCreateBtn(); }; const addFiles = (fileList) => { const remaining = MAX_UPLOADS - uploads.length; if (remaining <= 0) { Shell.toast('已达上传上限', `${MAX_UPLOADS} / ${MAX_UPLOADS} 张`); return; } const incoming = [...fileList].filter(f => f.type.startsWith('image/')); if (!incoming.length) return; const accepted = incoming.slice(0, remaining); const overflow = incoming.length - accepted.length; let added = 0; accepted.forEach(f => { const reader = new FileReader(); reader.onload = (e) => { uploads.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), dataUrl: e.target.result, name: f.name, type: f.type, size: f.size, }); added++; if (added === accepted.length) { refreshAll(); const msg = overflow > 0 ? `+ ${added} 张 · 超出 ${overflow} 张已忽略` : `+ ${added} 张 · 共 ${uploads.length} / ${MAX_UPLOADS}`; Shell.toast('图片已上传', msg); } }; reader.readAsDataURL(f); }); }; fileInput.addEventListener('change', (e) => { addFiles(e.target.files); e.target.value = ''; }); zone.addEventListener('click', () => { if (uploads.length < MAX_UPLOADS) fileInput.click(); }); zone.addEventListener('dragover', e => { e.preventDefault(); if (uploads.length < MAX_UPLOADS) zone.style.borderColor = 'var(--heat)'; }); zone.addEventListener('dragleave', () => { zone.style.borderColor = ''; }); zone.addEventListener('drop', e => { e.preventDefault(); zone.style.borderColor = ''; if (e.dataTransfer?.files?.length) addFiles(e.dataTransfer.files); }); refreshAll(); // AI CTA: 把已填表单 + 上传图册存到 sessionStorage,跳到 AI 工作台 const aiCta = document.getElementById('np-ai-cta'); aiCta.addEventListener('click', (e) => { e.preventDefault(); const s = collect(); sessionStorage.setItem('pending-product', JSON.stringify(s)); Shell.toast('正在跳转 AI 工作台', `带入 ${s.uploadedCount} 张图`); setTimeout(() => { location.href = 'product-create.html'; }, 350); }); updateCreateBtn(); }, _openLightbox(src, name) { const lb = document.getElementById('np-lightbox'); const img = document.getElementById('np-lightbox-img'); const nm = document.getElementById('np-lightbox-name'); if (!lb || !img || lb.classList.contains('show')) return; img.src = src; if (nm) nm.textContent = name || ''; lb.classList.add('show'); this.lockScroll(); }, _closeLightbox() { const lb = document.getElementById('np-lightbox'); if (!lb || !lb.classList.contains('show')) return; lb.classList.remove('show'); this.unlockScroll(); }, toast(text, mono) { const t = document.getElementById('__toast'); const txt = document.getElementById('__toast-txt'); if (!t || !txt) return; txt.innerHTML = text + (mono ? `[ ${mono} ]` : ''); t.classList.add('show'); clearTimeout(this._tt); this._tt = setTimeout(() => t.classList.remove('show'), 2400); }, /* ─── Body scroll lock (引用计数 · 多 overlay 叠加安全) ─── */ _scrollLockCount: 0, _scrollLockSnapshot: null, lockScroll() { if (++this._scrollLockCount > 1) return; const docEl = document.documentElement; const sbw = window.innerWidth - docEl.clientWidth; this._scrollLockSnapshot = { bodyOverflow: document.body.style.overflow, bodyPaddingRight: document.body.style.paddingRight, htmlOverflow: docEl.style.overflow, }; document.body.style.overflow = 'hidden'; docEl.style.overflow = 'hidden'; if (sbw > 0) document.body.style.paddingRight = sbw + 'px'; }, unlockScroll() { if (--this._scrollLockCount > 0) return; this._scrollLockCount = 0; const s = this._scrollLockSnapshot; if (s) { document.body.style.overflow = s.bodyOverflow; document.body.style.paddingRight = s.bodyPaddingRight; document.documentElement.style.overflow = s.htmlOverflow; this._scrollLockSnapshot = null; } }, openModal(id) { const el = document.getElementById(id); if (!el || el.classList.contains('show')) return; // 把 modal-bg 挪到 body 直接子节点,绕开 .content 的层叠上下文(z-index:1)困住模态、让 topbar 漏出来的问题 if (el.parentNode !== document.body) document.body.appendChild(el); el.classList.add('show'); this.lockScroll(); if (!this._modalEsc) { this._modalEsc = (e) => { if (e.key !== 'Escape') return; const open = document.querySelector('.modal-bg.show'); if (open) this.closeModal(open.id); }; document.addEventListener('keydown', this._modalEsc); } }, closeModal(id) { const el = document.getElementById(id); if (!el || !el.classList.contains('show')) return; el.classList.remove('show'); this.unlockScroll(); }, openDrawer(id) { const el = document.getElementById(id); const bg = document.getElementById(id + '-bg'); if (!el || el.classList.contains('show')) return; el.classList.add('show'); if (bg) bg.classList.add('show'); this.lockScroll(); }, closeDrawer(id) { const el = document.getElementById(id); const bg = document.getElementById(id + '-bg'); if (!el || !el.classList.contains('show')) return; el.classList.remove('show'); if (bg) bg.classList.remove('show'); this.unlockScroll(); } }; (function loadMockMedia() { if (window.__AIRSHELF_DISABLE_MOCK_MEDIA__) return; if (document.querySelector('script[data-mock-media]')) return; const script = document.createElement('script'); script.src = 'assets/mock-media.js?v=2026052703'; script.dataset.mockMedia = '1'; document.head.appendChild(script); })();