1003 lines
43 KiB
JavaScript
1003 lines
43 KiB
JavaScript
/* ============================================================
|
|
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 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 => `
|
|
<a href="${n.href}" class="${active === n.id ? 'active' : ''}">
|
|
${n.icon}
|
|
<span>${n.label}</span>
|
|
${n.badge ? `<span class="pill-mini">${n.badge}</span>` : ''}
|
|
</a>
|
|
`).join('');
|
|
|
|
const sidebar = `
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<img class="brand-logo" src="assets/logo.png" alt="Airshelf">
|
|
</div>
|
|
<div class="search-box" onclick="Shell.openCommandPalette()">
|
|
${ShellIcon('search')}
|
|
<input id="global-search" placeholder="搜索" readonly aria-label="打开全局搜索"/>
|
|
<span class="kbd">Ctrl K</span>
|
|
</div>
|
|
<div class="nav-section">主要</div>
|
|
<nav>${navHtml}</nav>
|
|
<div class="aside-foot">
|
|
<div class="user" onclick="Shell.toggleAccountMenu(event)">
|
|
<div class="av">李</div>
|
|
<div class="em">小李的店</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
`;
|
|
|
|
const crumbHtml = crumbs.length ? `
|
|
<div class="crumbs">
|
|
${crumbs.map((c, i) => {
|
|
const last = i === crumbs.length - 1;
|
|
const sep = i > 0 ? '<span class="sep">/</span>' : '';
|
|
if (last) return `${sep}<span class="here">${c.label}</span>`;
|
|
return `${sep}<a href="${c.href || '#'}">${c.label}</a>`;
|
|
}).join('')}
|
|
</div>
|
|
` : '';
|
|
|
|
const topbar = `
|
|
<header class="topbar">
|
|
${crumbHtml}
|
|
<div class="right">
|
|
<span class="balance-chip" onclick="location.href='account.html'">
|
|
${ShellIcon('creditCard')}
|
|
余额 <strong>${balance}</strong>
|
|
</span>
|
|
<button class="queue-chip" onclick="Shell.toast('任务队列', '3 个进行中')" hidden>
|
|
${ShellIcon('list')}
|
|
任务队列
|
|
<span class="count">3</span>
|
|
</button>
|
|
<button class="icon-btn" onclick="location.href='messages.html'" title="消息中心">
|
|
${ShellIcon('bell')}
|
|
<span class="count-noti">12</span>
|
|
</button>
|
|
<div class="topbar-avatar" onclick="Shell.toggleAccountMenu(event)" title="账户">
|
|
<span>李</span>
|
|
</div>
|
|
${topActions}
|
|
</div>
|
|
</header>
|
|
`;
|
|
|
|
const decorations = `
|
|
<div class="grid-bg"></div>
|
|
<span class="sq-mark" style="top:238px;left:478px"></span>
|
|
<span class="sq-mark" style="top:478px;left:1198px"></span>
|
|
<span class="sq-mark" style="bottom:300px;left:238px"></span>
|
|
<span class="sq-mark" style="top:718px;right:240px"></span>
|
|
`;
|
|
|
|
const toastHtml = `
|
|
<div class="toast" id="__toast">
|
|
<div class="ic-t">${ShellIcon('check')}</div>
|
|
<div class="txt" id="__toast-txt">操作成功<span class="mono">[ 200 OK ]</span></div>
|
|
</div>
|
|
`;
|
|
|
|
const commandHtml = `
|
|
<div class="shell-command-bg" id="shell-command-bg" aria-hidden="true">
|
|
<div class="shell-command" role="dialog" aria-modal="true" aria-labelledby="shell-command-title">
|
|
<div class="shell-command-h">
|
|
<span class="ic">${ShellIcon('search')}</span>
|
|
<input id="shell-command-input" autocomplete="off" placeholder="搜索页面、动作、项目入口" aria-label="搜索命令">
|
|
<button class="shell-command-close" type="button" id="shell-command-close">ESC</button>
|
|
</div>
|
|
<div class="shell-command-list" id="shell-command-list"></div>
|
|
<div class="shell-command-foot">
|
|
<span id="shell-command-title">COMMAND</span>
|
|
<span>// Enter 打开 · Ctrl K 唤起</span>
|
|
<span class="spacer"></span>
|
|
<span id="shell-command-count">0 项</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const accountMenuHtml = `
|
|
<div class="shell-account-menu" id="shell-account-menu" aria-hidden="true">
|
|
<div class="shell-account-head">
|
|
<span class="av">李</span>
|
|
<span>
|
|
<span class="nm">小李的店</span>
|
|
<span class="mail">li@shop.com</span>
|
|
</span>
|
|
</div>
|
|
<button type="button" data-account-act="settings">${ShellIcon('settings')}个人设置</button>
|
|
<button type="button" data-account-act="messages">${ShellIcon('bell')}消息中心</button>
|
|
<button type="button" data-account-act="team">${ShellIcon('users')}团队管理</button>
|
|
<button type="button" data-account-act="account">${ShellIcon('creditCard')}消费与余额</button>
|
|
<div class="sep"></div>
|
|
<button type="button" data-account-act="logout">${ShellIcon('arrowRight')}退出登录</button>
|
|
</div>
|
|
`;
|
|
|
|
// ─── 全局 Lightbox · 任意页面可用 Shell._openLightbox(src, name) ──
|
|
const lightboxHtml = `
|
|
<div class="np-lightbox" id="np-lightbox" onclick="Shell._closeLightbox()">
|
|
<button class="lb-x" type="button" onclick="event.stopPropagation();Shell._closeLightbox()">
|
|
${ShellIcon('x')}
|
|
</button>
|
|
<img id="np-lightbox-img" alt="">
|
|
<span class="lb-name" id="np-lightbox-name"></span>
|
|
</div>
|
|
`;
|
|
|
|
// [DEPRECATED · 弹窗已废弃,创建商品直接进 product-create.html]
|
|
const _deprecatedModalHtml = `
|
|
<div class="modal-bg" id="new-product-bg" onclick="if(event.target===this)Shell.closeModal('new-product-bg')">
|
|
<div class="modal new-product-modal">
|
|
<span class="corner-tr"></span>
|
|
<span class="corner-bl"></span>
|
|
|
|
<div class="np-header">
|
|
<div class="np-title-ic">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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-4M3 17l9 4 9-4"/></svg>
|
|
</div>
|
|
<h2>新建商品</h2>
|
|
<span class="np-mode-pill">[ UPLOAD MODE ]</span>
|
|
<button class="np-x" type="button" onclick="Shell.closeModal('new-product-bg')">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="np-body">
|
|
<div class="np-body-grid">
|
|
|
|
<!-- 左栏: 图片 -->
|
|
<div class="np-left">
|
|
<!-- AI CTA: 独立全宽区域,放在 商品图册 上方 -->
|
|
<a class="np-ai-cta primary" id="np-ai-cta" title="不上传也能用 AI 生成图">
|
|
<span class="ai-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.5 4.5L18 8l-4.5 1.5L12 14l-1.5-4.5L6 8l4.5-1.5L12 2zM19 14l.9 2.7 2.6.8-2.6.8L19 21l-.9-2.7-2.6-.8 2.6-.8L19 14zM5 14l.7 2.1L7.8 17l-2.1.7L5 20l-.7-2.3-2.1-.7 2.1-.7L5 14z"/></svg>
|
|
</span>
|
|
<span class="ai-label" id="np-ai-label">没有图? 让 AI 帮我生成</span>
|
|
<span class="ai-arrow">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
</span>
|
|
</a>
|
|
|
|
<div class="field" style="margin-bottom: 12px;">
|
|
<label class="field-label">商品图册<span class="req">*</span></label>
|
|
<input type="file" id="np-file" accept="image/*" multiple hidden>
|
|
|
|
<!-- 图片案例 (静态示例) -->
|
|
<div class="np-section-h">
|
|
<span class="check-ic"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8 7 12 13 4"/></svg></span>
|
|
图片案例
|
|
</div>
|
|
<div class="np-section-sub">上传您的商品的多角度白底图和使用图</div>
|
|
<div class="np-examples">
|
|
<div class="ex"><span>白底主图</span><span class="ex-badge"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 21h4V9H2v12zM23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg></span></div>
|
|
<div class="ex"><span>多角度</span><span class="ex-badge"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 21h4V9H2v12zM23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg></span></div>
|
|
<div class="ex"><span>细节图</span><span class="ex-badge"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 21h4V9H2v12zM23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg></span></div>
|
|
<div class="ex"><span>使用图</span><span class="ex-badge"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 21h4V9H2v12zM23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg></span></div>
|
|
<div class="ex"><span>包装图</span><span class="ex-badge"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 21h4V9H2v12zM23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg></span></div>
|
|
</div>
|
|
|
|
<!-- 我的上传 (动态 5 槽) -->
|
|
<div class="np-section-h">
|
|
我的上传
|
|
<span class="counter">( <span class="num" id="np-upload-count">0</span> / 5 )</span>
|
|
</div>
|
|
<div class="upload-grid" id="np-grid"></div>
|
|
|
|
<!-- Dropzone (放在 grid 之后, 满 5 张时禁用) -->
|
|
<div class="upload-zone" id="np-zone" style="margin-top: 10px;">
|
|
<span class="uz-ic">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
|
</span>
|
|
<span id="np-zone-text">点击或拖拽上传图片</span>
|
|
<span class="uz-hint">// 支持多选 · 最多 5 张 · JPG / PNG / WEBP</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右栏: 文案 -->
|
|
<div class="np-right">
|
|
<div class="field">
|
|
<label class="field-label">商品名称<span class="req">*</span></label>
|
|
<input class="input" id="np-name" placeholder="例: 透真玻尿酸补水面膜">
|
|
</div>
|
|
<div class="field">
|
|
<label class="field-label">品类</label>
|
|
<select class="select" id="np-cat">
|
|
<option>美妆个护</option>
|
|
<option>服饰内衣</option>
|
|
<option>食品饮料</option>
|
|
<option>家居家电</option>
|
|
<option>数码 3C</option>
|
|
<option>个护清洁</option>
|
|
<option>运动户外</option>
|
|
<option>母婴亲子</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field-label">核心卖点<span class="req">*</span></label>
|
|
<ul class="bullet-list" id="np-bullets" data-bl>
|
|
<li class="bl-add"><span class="num">+</span><input class="bl-input" placeholder="添加新卖点 · 回车确认"></li>
|
|
</ul>
|
|
</div>
|
|
<div class="field" style="margin-bottom:0;">
|
|
<label class="field-label">目标人群</label>
|
|
<input class="input" id="np-target" placeholder="例: 22-32 岁女性、敏感肌、办公室通勤">
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div class="np-footer">
|
|
<span class="np-meta">// 上传模式 · <span class="accent">不消耗 token</span> · 一次性创建</span>
|
|
<button class="btn" type="button" onclick="Shell.closeModal('new-product-bg')">取消</button>
|
|
<button class="btn btn-primary" type="button" id="np-create" disabled style="opacity:.5;cursor:not-allowed;">
|
|
<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="M4 12l5 5L20 6"/></svg>
|
|
创建商品
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lightbox · 缩略图全屏预览 -->
|
|
<div class="np-lightbox" id="np-lightbox" onclick="Shell._closeLightbox()">
|
|
<button class="lb-x" type="button" onclick="event.stopPropagation();Shell._closeLightbox()">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
<img id="np-lightbox-img" alt="">
|
|
<span class="lb-name" id="np-lightbox-name"></span>
|
|
</div>
|
|
`;
|
|
|
|
// 装订线 SVG 准星 · V2.1 签名元素(圆弧内凹的"+")
|
|
const cornerSvg = `<path d="M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z" fill="currentColor"/>`;
|
|
const cornerMarks = `
|
|
<span class="corner-mark tl"><svg viewBox="0 0 22 21" fill="none">${cornerSvg}</svg></span>
|
|
<span class="corner-mark tr"><svg viewBox="0 0 22 21" fill="none">${cornerSvg}</svg></span>
|
|
<span class="corner-mark bl"><svg viewBox="0 0 22 21" fill="none">${cornerSvg}</svg></span>
|
|
<span class="corner-mark br"><svg viewBox="0 0 22 21" fill="none">${cornerSvg}</svg></span>
|
|
`;
|
|
|
|
const app = document.createElement('div');
|
|
app.className = 'app';
|
|
app.innerHTML = sidebar + `<main>${decorations}${topbar}<div class="content" id="page-content">${cornerMarks}</div></main>`;
|
|
|
|
const src = document.getElementById('page');
|
|
document.body.prepend(app);
|
|
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();
|
|
},
|
|
|
|
_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 = `<div class="shell-command-empty">${ShellIcon('search')}<span>没有匹配的入口</span><span class="shell-command-section">// 换个关键词试试</span></div>`;
|
|
return;
|
|
}
|
|
let lastGroup = '';
|
|
list.innerHTML = items.map((cmd, i) => {
|
|
const section = cmd.group !== lastGroup ? `<div class="shell-command-section">${this._esc(cmd.group)}</div>` : '';
|
|
lastGroup = cmd.group;
|
|
return section + `
|
|
<button class="shell-command-item${i === 0 ? ' active' : ''}" type="button" data-command="${this._esc(cmd.id)}">
|
|
<span class="cmd-ic">${ShellIcon(cmd.icon)}</span>
|
|
<span class="cmd-main">
|
|
<span class="cmd-title">${this._esc(cmd.label)}</span>
|
|
<span class="cmd-sub">${this._esc(cmd.sub)}</span>
|
|
</span>
|
|
${cmd.key ? `<span class="cmd-key">${this._esc(cmd.key)}</span>` : ''}
|
|
</button>
|
|
`;
|
|
}).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 = `<span class="rs-select-label">${esc(selected?.textContent || '')}</span>${caretSvg}`;
|
|
menu.innerHTML = [...select.options].map((opt, i) => `
|
|
<button class="rs-select-option${i === select.selectedIndex ? ' selected' : ''}" type="button" role="option" data-index="${i}" aria-selected="${i === select.selectedIndex ? 'true' : 'false'}" ${opt.disabled ? 'disabled' : ''}>
|
|
${checkSvg}
|
|
<span>${esc(opt.textContent)}</span>
|
|
</button>
|
|
`).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 = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
|
|
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 = `<span class="num">0</span><span class="bl-text">${t.replace(/[<>&]/g, c => ({ '<':'<','>':'>','&':'&' })[c])}</span><span class="bl-x" title="删除">${xSvg}</span>`;
|
|
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 = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
|
|
const esc = (s) => s.replace(/[<>&"]/g, c => ({ '<':'<','>':'>','&':'&','"':'"' })[c]);
|
|
|
|
const renderGrid = () => {
|
|
const filledHtml = uploads.map(u => `
|
|
<div class="up-thumb" data-id="${u.id}">
|
|
<img src="${u.dataUrl}" alt="${esc(u.name)}">
|
|
<button class="slot-x" type="button" title="删除">${thumbXSvg}</button>
|
|
</div>
|
|
`).join('');
|
|
const emptyCount = MAX_UPLOADS - uploads.length;
|
|
const emptyHtml = Array.from({ length: emptyCount }, () => `<div class="up-slot-empty" data-action="add">无预览</div>`).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 ? `<span class="mono">[ ${mono} ]</span>` : '');
|
|
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);
|
|
})();
|