/* ============================================================
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')
}
];
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 ]
`;
// ─── 全局 Lightbox · 任意页面可用 Shell._openLightbox(src, name) ──
const lightboxHtml = `
`;
// [DEPRECATED · 弹窗已废弃,创建商品直接进 product-create.html]
const _deprecatedModalHtml = `
没有图? 让 AI 帮我生成
上传您的商品的多角度白底图和使用图
我的上传
( 0 / 5 )
点击或拖拽上传图片
// 支持多选 · 最多 5 张 · JPG / PNG / WEBP
`;
// 装订线 SVG 准星 · V2.1 签名元素(圆弧内凹的"+")
const cornerSvg = ``;
const cornerMarks = `
`;
const app = document.createElement('div');
app.className = 'app';
app.innerHTML = sidebar + `${decorations}${topbar}${cornerMarks}
`;
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.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
document.getElementById('global-search')?.focus();
}
});
this.enhanceSelects(document);
},
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 => `
`).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);
})();