fix(deploy): 把 V2.1 设计稿挪进 电商AI平台/ · Docker 构建上下文是这里

This commit is contained in:
UI 设计
2026-05-21 16:30:37 +08:00
parent e293aa43be
commit e7c0a14f75
22 changed files with 23431 additions and 2283 deletions
+578
View File
@@ -0,0 +1,578 @@
/* ============================================================
新建商品 · 共享 Drawer 模块
----------------------------------------------------------
在任意页面只需 <script src="assets/new-product-drawer.js"> 引入,
然后调用 NewProductDrawer.open({ onSave: fn }) 即可在当前页之上
弹出右侧 Drawer。点击遮罩 / X / 取消 / ESC 关闭后,用户停在原页面。
提供:
window.NewProductDrawer.open(opts?)
window.NewProductDrawer.close()
opts 字段:
onSave(product) — 保存时回调,product = { id, name, cat, target,
points: string[], images: { id, dataUrl, name }[] }
============================================================ */
(function () {
'use strict';
if (window.NewProductDrawer) return; // idempotent
const DRAWER_ID = 'npd-drawer';
const DRAWER_BG_ID = 'npd-drawer-bg';
/* ---------- 注入样式(独立 namespace 以免与 products.html 冲突) ---------- */
const CSS = `
/* drawer base (相同尺寸/动画) */
#${DRAWER_BG_ID} {
position: fixed; inset: 0;
background: rgba(21, 20, 15, .32);
display: none; z-index: 1100;
}
#${DRAWER_BG_ID}.show { display: block; }
#${DRAWER_ID} {
position: fixed; right: 0; top: 0; bottom: 0;
width: 820px; max-width: 100vw;
background: var(--surface);
border-left: 1px solid var(--border-faint);
z-index: 1101;
transform: translateX(100%);
transition: transform .25s cubic-bezier(.32, .72, 0, 1);
display: flex; flex-direction: column;
box-shadow: -4px 0 24px rgba(21, 20, 15, .04);
}
#${DRAWER_ID}.show { transform: translateX(0); }
#${DRAWER_ID} .drawer-h {
padding: 20px 24px;
border-bottom: 1px solid var(--border-faint);
display: flex; align-items: center;
}
#${DRAWER_ID} .drawer-h h3 { font-size: 16px; font-weight: 600; color: var(--accent-black); }
#${DRAWER_ID} .drawer-h .x {
margin-left: auto; width: 32px; height: 32px;
border-radius: var(--r-md);
display: grid; place-items: center;
color: var(--black-alpha-56); cursor: pointer;
background: transparent; border: 0;
transition: background var(--t-base);
}
#${DRAWER_ID} .drawer-h .x:hover { background: var(--black-alpha-4); color: var(--accent-black); }
#${DRAWER_ID} .drawer-b { padding: 24px 28px; overflow-y: auto; flex: 1; overscroll-behavior: contain; }
#${DRAWER_ID} .drawer-f {
padding: 14px 24px;
border-top: 1px solid var(--border-faint);
display: flex; gap: 10px; align-items: center;
background: var(--surface);
}
#${DRAWER_ID} .drawer-f .btn-guide {
margin-right: auto;
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--black-alpha-56);
background: transparent; border: 0; cursor: pointer;
padding: 8px 10px; border-radius: var(--r-md);
font-family: inherit;
transition: background var(--t-base), color var(--t-base);
}
#${DRAWER_ID} .drawer-f .btn-guide:hover { color: var(--accent-black); background: var(--black-alpha-4); }
#${DRAWER_ID} .drawer-f .btn-guide svg { width: 14px; height: 14px; }
/* form-card */
#${DRAWER_ID} .form-h {
font-size: 15px; font-weight: 600; color: var(--accent-black);
margin-bottom: 18px; padding-bottom: 12px;
border-bottom: 1px solid var(--border-faint);
}
#${DRAWER_ID} .field { margin-bottom: 16px; }
#${DRAWER_ID} .field:last-child { margin-bottom: 0; }
#${DRAWER_ID} .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; }
#${DRAWER_ID} .field-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--accent-black); margin-bottom: 6px;
}
#${DRAWER_ID} .field-label .req { color: var(--heat); margin-left: 2px; }
#${DRAWER_ID} .field-label .opt {
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
}
#${DRAWER_ID} .input,
#${DRAWER_ID} .select {
width: 100%; height: 38px;
background: var(--background-lighter);
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-md);
padding: 0 14px;
font-size: 13.5px; color: var(--accent-black);
outline: none; font-family: inherit;
transition: border-color var(--t-base);
}
#${DRAWER_ID} .input:focus,
#${DRAWER_ID} .select:focus {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
/* upload */
#${DRAWER_ID} .pf-upload-row {
display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px; align-items: stretch;
}
#${DRAWER_ID} .pf-upload-zone {
border: 1.5px dashed var(--black-alpha-24);
border-radius: var(--r-md);
padding: 28px 20px;
background: var(--background-lighter);
cursor: pointer; text-align: center;
transition: border-color var(--t-base), background var(--t-base);
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 180px;
}
#${DRAWER_ID} .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
#${DRAWER_ID} .pf-upload-zone .uz-ic {
width: 44px; height: 44px;
margin: 0 auto 10px;
background: var(--surface);
border: 1px solid var(--heat-20);
border-radius: var(--r-md);
color: var(--heat);
display: grid; place-items: center;
}
#${DRAWER_ID} .pf-upload-zone .uz-ic svg { width: 20px; height: 20px; }
#${DRAWER_ID} .pf-upload-zone .uz-t { font-size: 14px; color: var(--accent-black); font-weight: 500; }
#${DRAWER_ID} .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
#${DRAWER_ID} .pf-upload-zone .uz-d {
margin-top: 8px;
font-family: var(--font-mono); font-size: 11.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
#${DRAWER_ID} .pf-example {
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 16px;
display: flex; flex-direction: column; gap: 10px;
}
#${DRAWER_ID} .pf-example .ex-h { font-size: 13px; font-weight: 600; color: var(--accent-black); }
#${DRAWER_ID} .pf-example .ex-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb {
aspect-ratio: 1;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
display: grid; place-items: center;
color: var(--black-alpha-32);
}
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb svg { width: 22px; height: 22px; }
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
pointer-events: none;
}
#${DRAWER_ID} .pf-example .ex-d { font-size: 12px; color: var(--black-alpha-56); line-height: 1.5; }
#${DRAWER_ID} .pf-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px; margin-top: 12px;
}
#${DRAWER_ID} .pf-grid:empty { display: none; }
#${DRAWER_ID} .pf-thumb {
aspect-ratio: 1;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
position: relative; overflow: hidden; cursor: pointer;
}
#${DRAWER_ID} .pf-thumb img { width: 100%; height: 100%; object-fit: cover; }
#${DRAWER_ID} .pf-thumb .pf-x {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px;
background: rgba(0,0,0,.7); color: var(--accent-white);
border: 0; border-radius: 50%; cursor: pointer;
display: grid; place-items: center;
opacity: 0; transition: opacity var(--t-base);
}
#${DRAWER_ID} .pf-thumb:hover .pf-x { opacity: 1; }
#${DRAWER_ID} .pf-thumb .pf-x svg { width: 11px; height: 11px; }
/* bullet-list */
#${DRAWER_ID} .bullet-list { list-style: none; padding: 0; margin: 0; }
#${DRAWER_ID} .bullet-list .bl-item,
#${DRAWER_ID} .bullet-list .bl-add {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
margin-bottom: 6px;
font-size: 13.5px;
}
#${DRAWER_ID} .bullet-list .bl-add { background: transparent; border-style: dashed; }
#${DRAWER_ID} .bullet-list .num {
width: 22px; height: 22px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 11px; color: var(--heat); font-weight: 700;
display: grid; place-items: center; flex-shrink: 0;
}
#${DRAWER_ID} .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
#${DRAWER_ID} .bullet-list .bl-input {
flex: 1; background: transparent; border: 0; outline: none;
font-size: 13.5px; color: var(--accent-black); font-family: inherit;
}
#${DRAWER_ID} .bullet-list .bl-x {
width: 22px; height: 22px;
color: var(--black-alpha-48);
cursor: pointer; display: grid; place-items: center;
border-radius: var(--r-sm);
transition: color var(--t-base), background var(--t-base);
}
#${DRAWER_ID} .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
#${DRAWER_ID} .bullet-list .bl-x svg { width: 11px; height: 11px; }
@media (max-width: 900px) {
#${DRAWER_ID} .pf-upload-row { grid-template-columns: 1fr; }
}
`;
const HTML = `
<div id="${DRAWER_BG_ID}"></div>
<aside id="${DRAWER_ID}" role="dialog" aria-label="新建商品" aria-hidden="true">
<div class="drawer-h">
<h3>新建商品</h3>
<button class="x" type="button" data-act="close" aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg>
</button>
</div>
<div class="drawer-b">
<div class="form-card">
<div class="form-h">基础信息</div>
<div class="field">
<label class="field-label">商品名称<span class="req">*</span></label>
<input class="input" data-f="name" placeholder="请输入商品名称(必填)" maxlength="100">
</div>
<div class="field-row">
<div>
<label class="field-label">品类<span class="req">*</span></label>
<select class="select" data-f="cat">
<option>美妆个护</option>
<option>服饰内衣</option>
<option>食品饮料</option>
<option>家居家电</option>
<option>数码 3C</option>
<option>个护清洁</option>
<option>运动户外</option>
<option>母婴亲子</option>
</select>
</div>
<div>
<label class="field-label">目标人群<span class="opt">(选填)</span></label>
<input class="input" data-f="target" placeholder="例: 22-32 岁女性、敏感肌、办公室通勤">
</div>
</div>
<div class="field">
<label class="field-label">商品主图<span class="req">*</span></label>
<input type="file" data-f="file" accept="image/*" multiple hidden>
<div class="pf-upload-row">
<div class="pf-upload-zone" data-act="upload-zone">
<div 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>
</div>
<div class="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
<div class="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
</div>
<div class="pf-example">
<div class="ex-h">示例图</div>
<div class="ex-grid">
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4h10l1 4v12H6V8l1-4z"/><path d="M9 4v3M15 4v3M9 11h6M9 14h6"/></svg></div>
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="5" width="12" height="15" rx="2"/><path d="M9 9h6M9 12h6M9 15h4"/></svg></div>
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3h8l1 5v12H7V8l1-5z"/><circle cx="12" cy="13" r="2.5"/></svg></div>
</div>
<div class="ex-d">优质的商品图有助于生成更好的素材效果</div>
</div>
</div>
<div class="pf-grid" data-f="grid"></div>
</div>
<div class="field" style="margin-bottom: 0;">
<label class="field-label">核心卖点<span class="req">*</span></label>
<ul class="bullet-list" data-f="bullets">
<li class="bl-add"><span class="num">+</span><input class="bl-input" placeholder="添加新卖点 · 回车确认"></li>
</ul>
</div>
</div>
</div>
<div class="drawer-f">
<button class="btn-guide" type="button" data-act="guide">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01"/></svg>
使用指南
</button>
<button class="btn" type="button" data-act="cancel">取消</button>
<button class="btn btn-primary" type="button" data-act="save">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
创建商品
</button>
</div>
</aside>
`;
/* ---------- DOM refs (populated by ensureInjected) ---------- */
let injected = false;
let bg, drawer, $f, $grid, $bullets, $blInput;
let currentOpts = {};
const PF_MAX = 5;
let pfFiles = []; // { id, dataUrl, name }
const blXSvg = '<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>';
function esc(s) { return String(s == null ? '' : s).replace(/[<>&"]/g, c => ({ '<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;' })[c]); }
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
function toast(msg, sub) {
if (typeof Shell !== 'undefined' && Shell && Shell.toast) Shell.toast(msg, sub);
}
function ensureInjected() {
if (injected) return;
// style
const styleEl = document.createElement('style');
styleEl.textContent = CSS;
document.head.appendChild(styleEl);
// html
const wrap = document.createElement('div');
wrap.innerHTML = HTML;
while (wrap.firstChild) document.body.appendChild(wrap.firstChild);
bg = document.getElementById(DRAWER_BG_ID);
drawer = document.getElementById(DRAWER_ID);
$f = {
name: drawer.querySelector('[data-f="name"]'),
cat: drawer.querySelector('[data-f="cat"]'),
target: drawer.querySelector('[data-f="target"]'),
file: drawer.querySelector('[data-f="file"]'),
};
$grid = drawer.querySelector('[data-f="grid"]');
$bullets = drawer.querySelector('[data-f="bullets"]');
$blInput = $bullets.querySelector('.bl-add .bl-input');
bindEvents();
injected = true;
}
function bindEvents() {
// 关闭交互
bg.addEventListener('click', close);
drawer.addEventListener('click', e => {
const a = e.target.closest('[data-act]');
if (!a) return;
const act = a.dataset.act;
if (act === 'close') return close();
if (act === 'cancel') return close();
if (act === 'guide') return toast('使用指南', '点击查看完整填写指南');
if (act === 'save') return save();
if (act === 'upload-zone') return openFilePicker();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && drawer.classList.contains('show')) close();
});
// 上传
$f.file.addEventListener('change', e => { addFiles(e.target.files); e.target.value = ''; });
const zone = drawer.querySelector('[data-act="upload-zone"]');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.style.borderColor = 'var(--heat)'; });
zone.addEventListener('dragleave', () => { zone.style.borderColor = ''; });
zone.addEventListener('drop', e => {
e.preventDefault(); zone.style.borderColor = '';
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
});
// 卖点 bullet-list
$blInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); blAdd($blInput.value); $blInput.value = ''; }
});
}
function openFilePicker() { if (pfFiles.length < PF_MAX) $f.file.click(); }
function pfRender() {
$grid.innerHTML = pfFiles.map(u => `
<div class="pf-thumb" data-id="${u.id}">
<img src="${u.dataUrl}" alt="${esc(u.name)}">
<button class="pf-x" type="button" title="删除">
<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>
</button>
</div>
`).join('');
$grid.querySelectorAll('.pf-thumb .pf-x').forEach(b => {
b.onclick = e => {
e.stopPropagation();
const id = b.closest('.pf-thumb').dataset.id;
const i = pfFiles.findIndex(f => f.id === id);
if (i >= 0) { pfFiles.splice(i, 1); pfRender(); }
};
});
}
function addFiles(fileList) {
const room = PF_MAX - pfFiles.length;
if (room <= 0) { toast('已达上限', PF_MAX + ' / ' + PF_MAX + ' 张'); return; }
const incoming = [...fileList].filter(f => f.type.startsWith('image/')).slice(0, room);
let done = 0;
incoming.forEach(f => {
const r = new FileReader();
r.onload = e => {
pfFiles.push({ id: uid(), dataUrl: e.target.result, name: f.name });
if (++done === incoming.length) {
pfRender();
toast('已上传', '+ ' + done + ' 张 · 共 ' + pfFiles.length + ' / ' + PF_MAX);
}
};
r.readAsDataURL(f);
});
}
function blRenumber() {
[...$bullets.querySelectorAll('.bl-item')].forEach((li, i) => {
li.querySelector('.num').textContent = i + 1;
});
}
function blAdd(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">' + esc(t) + '</span><span class="bl-x" title="删除">' + blXSvg + '</span>';
$bullets.querySelector('.bl-add').before(li);
li.querySelector('.bl-x').addEventListener('click', () => {
li.style.transition = 'opacity .15s, transform .15s';
li.style.opacity = 0;
li.style.transform = 'translateX(-8px)';
setTimeout(() => { li.remove(); blRenumber(); }, 150);
});
blRenumber();
}
function getBullets() {
return [...$bullets.querySelectorAll('.bl-item .bl-text')].map(t => t.textContent.trim()).filter(Boolean);
}
/* ---------- API ---------- */
function resetForm() {
$f.name.value = '';
$f.cat.value = $f.cat.options[0].value;
$f.target.value = '';
pfFiles = [];
pfRender();
[...$bullets.querySelectorAll('.bl-item')].forEach(li => li.remove());
$blInput.value = '';
}
function lockBody() {
// 优先用 Shell 的引用计数实现(避免多 overlay 互相解锁)
if (typeof Shell !== 'undefined' && Shell && typeof Shell.lockScroll === 'function') {
Shell.lockScroll();
return;
}
// 兜底: Shell 未加载时本地锁
const docEl = document.documentElement;
const sbw = window.innerWidth - docEl.clientWidth;
drawer._lockSnap = {
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';
}
function unlockBody() {
if (typeof Shell !== 'undefined' && Shell && typeof Shell.unlockScroll === 'function') {
Shell.unlockScroll();
return;
}
const s = drawer._lockSnap;
if (s) {
document.body.style.overflow = s.bodyOverflow;
document.body.style.paddingRight = s.bodyPaddingRight;
document.documentElement.style.overflow = s.htmlOverflow;
drawer._lockSnap = null;
}
}
function open(opts) {
ensureInjected();
if (drawer.classList.contains('show')) return; // 已开则不重复锁
currentOpts = opts || {};
resetForm();
bg.classList.add('show');
drawer.classList.add('show');
drawer.setAttribute('aria-hidden', 'false');
lockBody();
setTimeout(() => $f.name.focus(), 280);
}
function close() {
if (!injected) return;
if (!drawer.classList.contains('show')) return; // 已关则不重复解锁
bg.classList.remove('show');
drawer.classList.remove('show');
drawer.setAttribute('aria-hidden', 'true');
unlockBody();
if (typeof currentOpts.onClose === 'function') currentOpts.onClose();
}
function save() {
const name = ($f.name.value || '').trim();
const cat = $f.cat.value;
const target = ($f.target.value || '').trim();
const points = getBullets();
const images = pfFiles.slice();
if (!name) {
toast('请填写商品名称');
$f.name.focus();
return;
}
if (images.length === 0) {
toast('请上传商品主图', '至少 1 张');
return;
}
if (points.length === 0) {
toast('请添加核心卖点', '至少 1 条');
$blInput.focus();
return;
}
const product = {
id: 'np-' + uid(),
name, cat, target,
points,
images,
imgs: images.length,
};
toast('商品已创建', '+ ' + name);
if (typeof currentOpts.onSave === 'function') currentOpts.onSave(product);
close();
}
window.NewProductDrawer = { open, close };
/* ---------- sessionStorage 自动打开钩子 ---------- */
// 任何页面只要在跳转前 sessionStorage.setItem('npd-auto-open','1') 即可,
// 落地页加载完模块后,会自动 open() 一次并清掉 flag。
// 用于:product-create.html 重定向后让落地页弹出 drawer,而不是用户重新点击。
function checkAutoOpen() {
try {
if (sessionStorage.getItem('npd-auto-open') === '1') {
sessionStorage.removeItem('npd-auto-open');
// 延后一拍,确保宿主页面自己的 init 已经跑完
setTimeout(() => open(), 50);
}
} catch (e) { /* sessionStorage 不可用就静默放弃 */ }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', checkAutoOpen);
} else {
checkAutoOpen();
}
})();