Files
cms-SiteForge/app/static/editor.js
T
浅念 8884e100f9 upgrade: adopt V8.31.1 (theme 1.3/1.4 redesigns, editor deep-canvas, guided launch, component library)
Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme,
registry tests, and design docs. Re-applied full patch set on identical
anchors (discovery/env_for/blocks.css/type-mapping/locale-override/
theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins.

Verified: theme suites 35/35; dual-baseline full run 415 passed vs
pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails).
Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact,
trade-pro build 203 pages 0 errors.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 09:43:17 +08:00

644 lines
73 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const $=id=>document.querySelector(id);
const html=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pathParts=location.pathname.split('/').filter(Boolean);
const siteId=pathParts[1],initialPage=pathParts[2];
let csrf='',site=null,pages=[],schema=null,reusables=[],pageId=initialPage,blocks=[],kind='standard',layoutMode='append',selected=-1,dirty=false,timer=null;
const api=async(p,opt={})=>{if(!['GET','HEAD','OPTIONS'].includes((opt.method||'GET').toUpperCase()))opt.headers={...(opt.headers||{}),'X-SiteForge-CSRF':csrf};let r=await fetch(p,opt);if(r.status===401){location.assign('/login');throw Error('会话已过期')}let t=r.headers.get('content-type')||'';let body=t.includes('json')?await r.json():await r.text();if(!r.ok)throw Error(body.detail||body);return body};
const req=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const toast=(s,err=false)=>{const t=$('#toast');t.textContent=s;t.style.background=err?'#a23c44':'#1c3555';t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3400)};
const activePage=()=>pages.find(e=>e.id===pageId);
const editorTools=window.SiteForgeEditorHelpers;
let libraryOpen=false;
let sharedPreview=null;
let siteNavDraft=[],siteDesignReviewToken=null;
let selectedChildId=null;
let inspectorTab='content',paletteSearch='',blockSearch='',previewRequest=0,previewController=null,savePromise=null,lastContinuousEdit=0,insertAfterId=null;
let snapshots=[],snapshotCursor=-1,draggedChild=null,draftRevision=0,siteDesign=null,siteDesignRevision=0,siteDesignPreview=null;
function recordSnapshot(){const current=JSON.stringify({blocks,kind,layoutMode});if(snapshotCursor>=0 && snapshots[snapshotCursor]===current)return;snapshots=snapshots.slice(0,snapshotCursor+1);snapshots.push(current);if(snapshots.length>75)snapshots.shift();snapshotCursor=snapshots.length-1}
function restoreSnapshot(index){if(index<0||index>=snapshots.length)return;snapshotCursor=index;const state=JSON.parse(snapshots[index]);blocks=state.blocks;kind=state.kind;layoutMode=state.layoutMode;selected=Math.min(selected,blocks.length-1);selectedChildId=null;$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');Studio.updateHistoryButtons();Studio.draw();Studio.refreshPreview()}
const Studio={
highlightCanvas(){const block=blocks[selected];const frame=$('#sitePreview');if(block&&frame?.contentWindow)frame.contentWindow.postMessage({source:'siteforge-studio-host',action:'highlight',blockId:block.id,childId:selectedChildId},'*')},
showInsertHint(){const box=$('#sfInsertHint');if(!box)return;const item=blocks.find(x=>x.id===insertAfterId);box.hidden=!item;box.textContent=item?'正在「'+(item.title||schema?.blocks[item.type]?.name||'当前区块')+'」下方插入:选择一种内容。':''},
beginAddAfter(blockId){if(editorTools.findTopIndex(blocks,blockId)<0)return;insertAfterId=blockId;Studio.showInsertHint();Studio.toggleLibrary(true);document.body.classList.add('show-structure');document.body.classList.remove('show-inspector');$('#sfAddBlock').scrollIntoView({behavior:'smooth',block:'nearest'})},
canvasCommand(command){
if(String(command?.action||'').startsWith('child-')){Studio.childCanvasCommand(command);return;}
if(command?.action==='resize-columns'){
const nestedLayout=editorTools.findLayout(blocks,command.blockId);
if(!nestedLayout)return;
const widths=editorTools.adjacentWidths(nestedLayout.layout,command.widths);
if(!widths)return;
if(nestedLayout.layout.columns.every((c,k)=>Math.abs(c.width-widths[k])<.001))return;
nestedLayout.layout.columns.forEach((c,k)=>c.width=widths[k]);
selected=nestedLayout.rootIndex;
selectedChildId=nestedLayout.depth?command.blockId:null;
Studio.modified();Studio.draw();return;
}
if(!editorTools.safeCanvasCommand(command,blocks,schema.blocks))return;
const i=editorTools.findTopIndex(blocks,command.blockId),block=blocks[i];
if(command.action==='select'||command.action==='edit'){Studio.select(i);if(command.action==='edit'){inspectorTab='content';Studio.applyInspectorTab();}return;}
if(command.action==='up'||command.action==='down'){Studio.moveBlock(i,command.action==='up'?-1:1);return;}
if(command.action==='add-after'){Studio.beginAddAfter(command.blockId);return;}
if(command.action==='duplicate'){Studio.duplicate(i);return;}
if(command.action==='delete'){Studio.remove(i);return;}
if(command.action==='reorder'){
const to=editorTools.findTopIndex(blocks,command.targetId);
const next=editorTools.reorderById(blocks,command.blockId,command.targetId);
if(!next)return;blocks=next;selected=to;Studio.modified();Studio.draw();return;
}
if(command.action==='resize-columns'){
const widths=editorTools.adjacentWidths(block,command.widths);
if(!widths)return;
if(block.columns.every((c,k)=>Math.abs(c.width-widths[k])<.001))return;
block.columns.forEach((col,j)=>col.width=widths[j]);selected=i;
Studio.modified();Studio.inspector();return;
}
if(command.action==='inline-text'){
if(block[command.field]===command.value)return;
block[command.field]=command.value;selected=i;Studio.modified();Studio.draw();
toast('文字已修改,记得保存草稿');
}
},
childCanvasCommand(command){
if(!editorTools.safeNestedCanvasCommand(command,blocks,schema.blocks))return;
const found=editorTools.findNestedChild(blocks,command.blockId,command.childId);
if(!found)return;
const {parentIndex,parent,columnIndex,childIndex,child}=found;
if(command.action==='child-select'||command.action==='child-edit'){
selected=parentIndex;selectedChildId=child.id;inspectorTab='content';Studio.draw();Studio.highlightCanvas();
const card=document.querySelector('#sf-nested-active');card?.scrollIntoView({block:'nearest'});
if(window.matchMedia('(max-width:1060px)').matches)document.body.classList.add('show-inspector');
return;
}
if(command.action==='child-inline-text'){
if(child[command.field]===command.value)return;
child[command.field]=command.value;selected=parentIndex;selectedChildId=child.id;
Studio.modified();Studio.draw();return;
}
if(command.action==='child-delete'){
if(!confirm('确定删除这一列中的组件?'))return;
parent.columns[columnIndex].blocks.splice(childIndex,1);selectedChildId=null;
}else if(command.action==='child-duplicate'){
const copy=editorTools.duplicate(child,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));
parent.columns[columnIndex].blocks.splice(childIndex+1,0,copy);selectedChildId=copy.id;
}else{
const next=editorTools.moveNested(parent,columnIndex,childIndex,command.action==='child-up'?-1:1);
if(!next)return;parent.columns[columnIndex].blocks=next;selectedChildId=child.id;
}
selected=parentIndex;Studio.modified();Studio.draw();
},
setLayoutGap(value){const block=blocks[selected];if(!block||block.type!=='layout')return;block.gap=Math.max(0,Math.min(64,Math.round(Number(value)||0)));Studio.modified();Studio.inspector()},
setLayoutPreset(preset){
const block=blocks[selected];const widths=editorTools.layoutPreset(block,preset);
if(!widths)return toast('当前列数不支持这种预设',true);
block.columns.forEach((col,i)=>col.width=widths[i]);Studio.modified();Studio.inspector();
},
// One inspector for both levels, with a readable path back to the parent.
childQuickEditor(layout){
if(layout.type!=='layout')return '';
const cards=[];
const walk=(container,trail=[],depth=0)=>{
if(depth>2)return;
container.columns.forEach((column,ci)=>(column.blocks||[]).forEach((child,ri)=>{
const active=selectedChildId===child.id;
const label=schema.blocks[child.type]?.name||child.type;
const breadcrumb=[...trail,`第${ci+1}列`].join(' › ');
cards.push(`<div class="sf-nested-card ${active?'active':''}" style="margin-left:${Math.min(depth,1)*12}px" ${active?'id="sf-nested-active"':''}><div class="sf-nested-heading"><button type="button" onclick="Studio.selectChild('${html(child.id)}')">${active?'✓ ':''}${html(breadcrumb)} · ${html(label)}</button><span><button type="button" title="上移" ${ri===0?'disabled':''} onclick="Studio.childCanvasCommand({action:'child-up',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">↑</button><button type="button" title="下移" ${ri===column.blocks.length-1?'disabled':''} onclick="Studio.childCanvasCommand({action:'child-down',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">↓</button></span></div>`);
if(active){
if(child.type==='layout'){
cards.push(`<div class="sf-nested-field">内层布局 · ${child.columns.length} 列</div><div class="sf-nested-actions"><button type="button" onclick="Studio.setNestedLayoutPreset('${html(child.id)}','equal')">均分列宽</button>${child.columns.length===2?`<button type="button" onclick="Studio.setNestedLayoutPreset('${html(child.id)}','left-wide')">左宽右窄</button><button type="button" onclick="Studio.setNestedLayoutPreset('${html(child.id)}','right-wide')">左窄右宽</button>`:''}</div><label class="sf-nested-field">列间距(像素)<input type="number" min="0" max="64" value="${Number(child.gap)||0}" onchange="Studio.setNestedLayoutGap('${html(child.id)}',this.value)"></label><div class="sf-nested-actions"><label>手机列数 <select onchange="Studio.setNestedDeviceColumns('${html(child.id)}','mobile',this.value)"><option value="1" ${child.mobile_columns===1?'selected':''}>1 列</option><option value="2" ${child.mobile_columns===2?'selected':''}>2 列</option></select></label><label>平板列数 <select onchange="Studio.setNestedDeviceColumns('${html(child.id)}','tablet',this.value)">${[1,2,3].map(n=>`<option value="${n}" ${child.tablet_columns===n?'selected':''}>${n} 列</option>`).join('')}</select></label></div>`);
}else{
const fields=(schema.blocks[child.type]?.fields||[]).filter(k=>['title','subtitle','description','image','button','href'].includes(k));
fields.forEach(key=>cards.push(`<label class="sf-nested-field">${html(({title:'标题',subtitle:'副标题',description:'描述',image:'图片地址',button:'按钮文字',href:'跳转链接'})[key])}<input type="text" value="${html(child[key]??'')}" oninput="Studio.childFieldById('${html(child.id)}','${key}',this.value)"></label>`));
}
if(child.type==='layout'){
child.columns.forEach((col,index)=>cards.push(`<div class="sf-nested-actions" role="group" aria-label="内层第${index+1}列添加内容"><span>第 ${index+1} 列</span>${['richtext','image_text','features','cta'].map(t=>`<button type="button" onclick="Studio.addNestedChild('${html(child.id)}',${index},'${t}')">+ ${html(schema.blocks[t]?.name||t)}</button>`).join('')}</div>`));
}
cards.push(`<button type="button" class="ui-btn ghost" onclick="Studio.saveReusable()">☆ 保存当前组件到组件库</button>`);
if(schema.blocks[child.type]?.fields?.includes('image')){
cards.push(`<button type="button" class="ui-btn ghost" onclick="Studio.chooseMedia()">🖼 从素材库更换选中图片</button>`);
}
cards.push(`<div class="sf-nested-actions"><button type="button" onclick="Studio.childCanvasCommand({action:'child-duplicate',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">复制组件</button><button type="button" onclick="Studio.childCanvasCommand({action:'child-delete',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">删除组件</button></div>`);
}
cards.push('</div>');
if(child.type==='layout')walk(child,[...trail,`第${ci+1}列 · 内层布局`],depth+1);
}));
};
walk(layout);
return `<div class="sf-nested-edit"><div class="subheading">画布组件 · 选择任意一层内容直接编辑</div>${cards.join('')||'<p class="prop-tip">当前没有子组件,请添加内容。</p>'}</div>`;
},
setNestedLayoutPreset(layoutId,preset){
const found=editorTools.findLayout(blocks,layoutId);
if(!found||found.rootIndex!==selected||found.depth!==1)return;
const widths=editorTools.layoutPreset(found.layout,preset);
if(!widths)return toast('当前内层列数不支持此预设',true);
found.layout.columns.forEach((c,k)=>c.width=widths[k]);selectedChildId=layoutId;
Studio.modified();Studio.draw();
},
setNestedLayoutGap(layoutId,value){
const found=editorTools.findLayout(blocks,layoutId);
if(!found||found.rootIndex!==selected||found.depth!==1)return;
found.layout.gap=Math.round(Math.max(0,Math.min(64,Number(value)||0)));
selectedChildId=layoutId;Studio.modified();Studio.draw();
},
addNestedChild(layoutId,columnIndex,type){
const found=editorTools.findLayout(blocks,layoutId);
if(!found||found.rootIndex!==selected||found.depth!==1)return;
// A second-level layout cannot contain another layout. Use only the safe
// declarative blocks offered in the UI and validate limits before mutation.
if(!['richtext','image_text','features','cta'].includes(type)||
!Number.isInteger(columnIndex)||columnIndex<0||columnIndex>=found.layout.columns.length)return;
const column=found.layout.columns[columnIndex];
if(column.blocks.length>=60)return toast('这一列的组件数量已达到限制',true);
const item=structuredClone(schema.blocks[type].defaults);
item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);
item.type=type;item.visible=true;
column.blocks.push(item);selectedChildId=item.id;
Studio.modified();Studio.draw();
toast('已添加内层组件,请在右侧修改内容');
},
setNestedDeviceColumns(layoutId,device,value){
if(!['mobile','tablet'].includes(device))return;
const found=editorTools.findLayout(blocks,layoutId);
if(!found||found.rootIndex!==selected||found.depth!==1)return;
const max=device==='mobile'?2:3;
const count=Math.max(1,Math.min(max,Math.round(Number(value)||1)));
found.layout[device+'_columns']=count;
found.layout.columns.forEach(c=>c[device+'_span']=Math.min(count,c[device+'_span']||1));
selectedChildId=layoutId;Studio.modified();Studio.draw();
},
selectChild(childId){const root=blocks[selected];const found=editorTools.findChildInRoot(blocks,root?.id,childId);if(!found)return;Studio.childCanvasCommand({action:'child-select',blockId:found.parent.id,childId})},
childFieldById(childId,key,value){
const parent=blocks[selected];const found=editorTools.findChildInRoot(blocks,parent?.id,childId);
if(!found||!schema.blocks[found.child.type]?.fields?.includes(key)||!['title','subtitle','description','image','button','href'].includes(key))return;
found.child[key]=String(value).slice(0,1500);selectedChildId=childId;Studio.modified(true);
},
async init(){try{csrf=(await api('/api/auth/me')).csrf;site=await api(`/api/sites/${siteId}`);pages=await api(`/api/sites/${siteId}/entries`);schema=await api('/api/editor/blocks');$('#siteName').textContent=site.name;const select=$('#pageSelect');select.innerHTML=pages.map(e=>`<option value="${html(e.id)}">${html(e.title)} (${html(e.route)})</option>`).join('');for(let [k,v] of Object.entries(schema.page_templates))$('#pageTemplate').innerHTML+=`<option value="${k}">${html(v.name)}</option>`;Studio.renderPalette();await Studio.loadReusables();await Studio.choosePage(pageId||pages[0]?.id)}catch(e){toast('初始化失败:'+e.message,true);console.error(e)}},
async choosePage(id){if(savePromise){toast('保存仍在进行,请稍后切换页面',true);$('#pageSelect').value=pageId;return}if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;selectedChildId=null;insertAfterId=null;Studio.showInsertHint();$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/studio-draft`);draftRevision=data.revision;blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;snapshots=[];snapshotCursor=-1;lastContinuousEdit=0;blockSearch='';$('#blockSearch').value='';recordSnapshot();Studio.updateHistoryButtons();$('#saveStatus').textContent=draftRevision?'草稿 v'+draftRevision:'未修改';$('#saveStatus').classList.remove('dirty');Studio.draw();Studio.refreshPreview()},
async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<div class="sf-shared-palette-row"><strong>${html(x.name)} <small>v${x.version}</small></strong><div><button type="button" onclick="Studio.insertReusable('${html(x.id)}')" title="复制后独立编辑">复制</button><button type="button" onclick="Studio.insertReusable('${html(x.id)}',true)" title="可以在检查后选择性同步的公共组件">关联插入</button></div></div>`).join('')||'<span style="font-size:12px;opacity:.55;padding:10px">暂无保存的组件</span>'}catch(e){console.warn(e)}},
async saveReusable(){
const root=blocks[selected];
const found=selectedChildId?editorTools.findChildInRoot(blocks,root?.id,selectedChildId):null;
if(selectedChildId&&!found)return toast('当前子组件已失效,请重新选中后保存',true);
const block=found?.child||root;
if(!block)return toast('请先选择需要保存的组件',true);
const name=prompt('为此组件命名',block.title||schema.blocks[block.type]?.name);
if(!name?.trim())return;
try{await api(`/api/sites/${siteId}/components`,req('POST',{name,block}));
await Studio.loadReusables();toast('已保存独立组件副本,其他页面不会自动更新');
}catch(e){toast('保存组件失败:'+e.message,true)}
},
insertReusable(id,linked=false){
const saved=reusables.find(x=>x.id===id);if(!saved)return;
const copy=editorTools.duplicate(saved.block,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));
if(linked)copy.shared_ref={id:saved.id,version:saved.version,hash:saved.snapshot_hash};
const root=blocks[selected];
const found=selectedChildId?editorTools.findChildInRoot(blocks,root?.id,selectedChildId):null;
if(selectedChildId&&!found)return toast('当前子组件已失效,请重新选中后插入',true);
// A nested reusable must never introduce a third layout container level.
// Layout templates remain insertable as top-level blocks.
if(found && copy.type!=='layout'){
const parent=found.child.type==='layout'?found.child:found.parent;
const column=found.child.type==='layout'?parent.columns[0]:parent.columns[found.columnIndex];
if(!column||column.blocks.length>=60)return toast('此列已达到组件数量限制',true);
const after=found.child.type==='layout'?column.blocks.length:found.childIndex+1;
column.blocks.splice(after,0,copy);
selected=found.parentIndex;selectedChildId=copy.id;
}else{
const foundIndex=insertAfterId?editorTools.findTopIndex(blocks,insertAfterId):-1;
const at=foundIndex>=0?foundIndex+1:blocks.length;
if(blocks.length>=60)return toast('页面顶层最多60个组件',true);
blocks.splice(at,0,copy);selected=at;selectedChildId=null;
}
insertAfterId=null;Studio.showInsertHint();Studio.modified();Studio.draw();Studio.toggleLibrary(false);
toast(linked?'已关联公共组件:修改源组件后仍需要审核并选择同步':'已插入独立组件副本;不会自动跟随更新');
},
detachShared(){
const item=selectedChildId?editorTools.findChildInRoot(blocks,blocks[selected]?.id,selectedChildId)?.child:blocks[selected];
if(!item?.shared_ref)return toast('当前区块未关联公共组件',true);
if(!confirm('解除关联后,本区块仍保留当前内容,但以后不会参与公共组件同步。确定吗?'))return;
delete item.shared_ref;Studio.modified();Studio.draw();toast('已解除关联。请保存草稿');
},
async showSharedComponents(){
if(!reusables.length)await Studio.loadReusables();
const dlg=$('#sharedComponentModal');if(!dlg.open)dlg.showModal();sharedPreview=null;
$('#sharedComponentBody').innerHTML=reusables.map(x=>`<article class="sf-shared-card"><div><strong>${html(x.name)}</strong><small>当前版本 v${x.version} · 不会自动更新页面</small></div><div class="sf-shared-actions"><button type="button" onclick="Studio.sharedUsage('${html(x.id)}')">查看引用与同步</button><button type="button" onclick="Studio.sharedVersions('${html(x.id)}')">版本记录</button><button type="button" onclick="Studio.sharedReplace('${html(x.id)}')">用当前选中区块创建新版</button></div></article>`).join('')||'<p>暂无公共组件。先在页面里选择一个区块,再通过左侧「保存当前组件」创建。</p>';
},
async sharedVersions(id){
try{const r=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/versions`);
$('#sharedComponentDetail').innerHTML='<h3>组件历史版本</h3><p>恢复历史不会覆盖已有版本,而是创建新的源组件版本。</p>'+r.items.map(x=>`<div class="sf-shared-history"><span>v${x.version} · ${html(x.created_at)} · SHA ${html(x.snapshot_hash.slice(0,12))}</span><button type="button" class="btn ui-btn ghost" onclick="Studio.sharedRestore('${html(id)}',${x.version})">恢复为新版本</button></div>`).join('');
}catch(e){toast(e.message,true)}
},
async sharedRestore(id,version){
const current=reusables.find(x=>x.id===id);
if(!current)return toast('组件信息已过期,请重新加载',true);
if(!confirm(`将历史 v${version} 的内容恢复为全新的组件版本?所有网站页面仍保持不变。`))return;
try{const snapshot=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/versions/${version}`);
await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}`,req('PUT',{
name:current.name,expected_version:current.version,block:snapshot.block}));
await Studio.loadReusables();await Studio.showSharedComponents();await Studio.sharedVersions(id);
toast('历史组件已恢复为新的源版本,请检查页面后再同步');
}catch(e){toast('恢复失败:'+e.message,true)}
},
async sharedReplace(id){
const chosen=selectedChildId?editorTools.findChildInRoot(blocks,blocks[selected]?.id,selectedChildId)?.child:blocks[selected];
const current=reusables.find(x=>x.id===id);if(!chosen||!current)return toast('请先选择页面中的区块',true);
if(!confirm(`将当前选中的区块保存为「${current.name}」的新版本?已发布页面不会变化。`))return;
try{await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}`,req('PUT',{
name:current.name,expected_version:current.version,block:chosen}));
await Studio.loadReusables();await Studio.showSharedComponents();toast('新版本已保存。请检查引用页面后选择性同步');
}catch(e){toast(e.message,true)}
},
async sharedUsage(id){
try{sharedPreview=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/usage`);
const preview=sharedPreview;
$('#sharedComponentDetail').innerHTML=`<h3>${html(preview.component.name)} · 同步前检查</h3><p>只同步勾选的页面,并新建草稿;已发布网站不会立即变动。</p>`+
(preview.references.length?preview.references.map(x=>`<label class="sf-shared-usage"><input type="checkbox" class="sf-shared-select" value="${html(x.entry_id)}" ${x.eligible?'':'disabled'}><span><strong>${html(x.title)}</strong><small>${html(x.route)} · v${html(x.current_version)} → v${html(preview.component.version)} ${x.reason?' · '+html(x.reason):''}</small></span></label>`).join(''):'<p>尚无关联页面。插入时请选择「关联插入」。</p>')+
'<button type="button" class="ui-btn primary" onclick="Studio.sharedApply()">确认并同步勾选的页面草稿</button>';
}catch(e){toast('读取引用失败:'+e.message,true)}
},
async sharedApply(){
if(!sharedPreview)return;
if(dirty)return toast('请先保存当前页面修改,再进行组件同步',true);
const ids=[...new Set([...document.querySelectorAll('.sf-shared-select:checked')].map(x=>x.value))];
if(!ids.length)return toast('请先选择可以同步的页面',true);
if(!confirm(`确认更新 ${ids.length} 个页面的设计草稿?已发布页面不变,稍后需要逐页审核发布。`))return;
try{const id=sharedPreview.component.id;
const resp=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/sync`,req('POST',{plan_token:sharedPreview.plan_token,entry_ids:ids}));
$('#sharedComponentModal').close();sharedPreview=null;
if(ids.includes(pageId))await Studio.choosePage(pageId);
toast(`已为 ${resp.updated.length} 个页面保存新草稿;请分别检查并发布`);
}catch(e){toast('同步取消:'+e.message,true);await Studio.sharedUsage(sharedPreview.component.id)}
},
renderPalette(){
const panel=$('#palette'),summary=$('#paletteSummary');if(!panel||!schema?.blocks)return;
const grouped=window.SiteForgeStudioExperience.groupBlocks(schema.blocks,paletteSearch);
const order=[['basic','常用内容'],['visual','图片与展示'],['business','产品与业务'],['advanced','复杂布局']];
panel.innerHTML=order.map(([type,title])=>{
const items=grouped[type]||[];if(!items.length)return '';
return `<section class="sf-palette-group" aria-label="${title}"><h4>${title}</h4><div class="sf-palette-grid">${items.map(({key,block})=>`<button type="button" onclick="Studio.add('${html(key)}')"><span aria-hidden="true">${html(block.icon)}</span>${html(block.name)}</button>`).join('')}</div></section>`;
}).join('')||'<p class="sf-palette-empty">没有找到对应组件,试试“文字”或“图片”。</p>';
summary.textContent='找到 '+Object.values(grouped).reduce((n,x)=>n+x.length,0)+' 个可添加组件';
},
filterPalette(value){paletteSearch=String(value||'').slice(0,70);Studio.renderPalette()},
clearPaletteSearch(){paletteSearch='';const inp=$('#paletteSearch');if(inp){inp.value='';inp.focus()}Studio.renderPalette()},
showThemePreview(){const dialog=$('#themePreviewModal');if(!dialog)return;const current=['automotive','industrial','corporate','minimal'].includes(site?.theme)?site.theme:'corporate';$('#themePreviewTheme').value=current;if(!dialog.open)dialog.showModal();Studio.themeDevice('desktop');Studio.updateThemePreview()},
closeThemePreview(){const dialog=$('#themePreviewModal');if(dialog?.open)dialog.close();const frame=$('#themePreviewFrame');if(frame)frame.removeAttribute('src')},
themeDevice(device){
const width={desktop:'100%',tablet:'780px',phone:'390px'}[device];if(!width)return;
$('#themePreviewFrameShell').style.width=width;
document.querySelectorAll('[data-theme-device]').forEach(btn=>btn.setAttribute('aria-pressed',String(btn.dataset.themeDevice===device)));
},
updateThemePreview(){
const theme=$('#themePreviewTheme')?.value,page=$('#themePreviewPage')?.value;
const url=window.SiteForgeStudioExperience.previewUrl(theme,page);
if(!url)return;
const frame=$('#themePreviewFrame'),status=$('#themePreviewStatus');if(!frame)return;
status.textContent='正在加载主题示例…(不影响已保存的页面)';
frame.onload=()=>{if($('#themePreviewModal')?.open)status.textContent='已加载示例。可切换主题、页面类型和设备继续比较。'};
frame.onerror=()=>{status.textContent='示例加载失败,请稍后重试。'};
frame.src=url;
}, toggleLibrary(force){libraryOpen=typeof force==='boolean'?force:!libraryOpen;const el=$('#sfLibraryPanel'),btn=$('#sfAddBlock');if(el)el.hidden=!libraryOpen;if(btn){btn.setAttribute('aria-expanded',String(libraryOpen));btn.textContent=libraryOpen?'× 收起区块库':'+ 添加内容区块'};Studio.updateGuidance()},
showHelp(){const dlg=$('#sfGuideDialog');if(dlg&&!dlg.open)dlg.showModal()},
updateGuidance(){const one=$('#sfStep1'),two=$('#sfStep2'),three=$('#sfStep3');if(!one||!two||!three)return;one.classList.toggle('active',selected<0||libraryOpen);two.classList.toggle('active',selected>=0&&!dirty&&!libraryOpen);three.classList.toggle('active',dirty);},
draw(){
const list=$('#blockList');$('#blockCount').textContent=blocks.length+' 区块';
const found=editorTools.filterBlocks(blocks,blockSearch,schema.blocks);
list.innerHTML=found.map(({block:b,index:i})=>`<div class="block-row ${selected===i?'selected':''} ${b.visible===false?'is-hidden':''}" role="listitem" tabindex="0" draggable="true" data-index="${i}" ondragstart="Studio.drag(event,${i})" ondragend="this.classList.remove('dragging')" ondragover="event.preventDefault()" ondrop="Studio.drop(event,${i})" onkeydown="Studio.blockKeydown(event,${i})" onclick="Studio.select(${i})"><div class="drag-handle" title="拖动排序" aria-hidden="true">⠿</div><div class="block-icon">${html(schema.blocks[b.type]?.icon||'▣')}</div><div class="block-copy"><strong>${html(schema.blocks[b.type]?.name||b.type)}</strong><small>${html(b.title||b.subtitle||b.type)}</small></div><div class="row-actions"><button type="button" aria-label="向上移动区块" title="上移" ${i===0?'disabled':''} onclick="event.stopPropagation();Studio.moveBlock(${i},-1)">↑</button><button type="button" aria-label="向下移动区块" title="下移" ${i===blocks.length-1?'disabled':''} onclick="event.stopPropagation();Studio.moveBlock(${i},1)">↓</button><button type="button" title="${b.visible===false?'显示':'隐藏'}" aria-label="切换区块可见性" onclick="event.stopPropagation();Studio.toggleVisible(${i})">${b.visible===false?'◌':'◉'}</button><button type="button" title="复制" aria-label="复制区块" onclick="event.stopPropagation();Studio.duplicate(${i})">⧉</button><button type="button" title="删除" aria-label="删除区块" onclick="event.stopPropagation();Studio.remove(${i})">×</button></div></div>`).join('')||`<div class="no-selection">${blockSearch?'没有找到匹配区块,试试清除搜索。':'当前没有区块,点击下方按钮添加。'}</div>`;
Studio.inspector();Studio.updateGuidance();
},
filter(value){blockSearch=String(value||'').slice(0,100);Studio.draw()},
clearFilter(){blockSearch='';$('#blockSearch').value='';Studio.draw();$('#blockSearch').focus()},
moveBlock(i,offset){const target=i+offset;const next=editorTools.reorder(blocks,i,target);if(!next||target===i)return;blocks=next;selected=target;Studio.modified();Studio.draw();},
toggleVisible(i){if(!blocks[i])return;blocks[i].visible=blocks[i].visible===false;Studio.modified();Studio.draw()},
blockKeydown(event,i){if(event.target!==event.currentTarget)return;if(event.key==='Enter'||event.key===' '){event.preventDefault();Studio.select(i)}else if(event.altKey && (event.key==='ArrowUp'||event.key==='ArrowDown')){event.preventDefault();Studio.moveBlock(i,event.key==='ArrowUp'?-1:1)}},
changeInspectorTab(tab){if(!['content','style','responsive'].includes(tab))return;inspectorTab=tab;Studio.applyInspectorTab()},
applyInspectorTab(){for(const key of ['content','style','responsive']){const active=key===inspectorTab;const btn=$('#tab'+key.charAt(0).toUpperCase()+key.slice(1));btn?.setAttribute('aria-selected',String(active));const pane=$('[data-inspector-section="'+key+'"]');if(pane)pane.hidden=!active}},
togglePane(which){const type=which==='left'?'show-structure':'show-inspector';document.body.classList.toggle(type);if(type==='show-structure')document.body.classList.remove('show-inspector');else document.body.classList.remove('show-structure');},
updateHistoryButtons(){const state=editorTools.historyState(snapshots,snapshotCursor);for(const id of ['#studioUndo','#quickUndo']){const el=$(id);if(el)el.disabled=!state.canUndo}for(const id of ['#studioRedo','#quickRedo']){const el=$(id);if(el)el.disabled=!state.canRedo}},
select(i){selected=i;selectedChildId=null;Studio.draw();if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
add(type){let data=structuredClone(schema.blocks[type].defaults);data.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);data.type=type;data.visible=true;const found=insertAfterId?editorTools.findTopIndex(blocks,insertAfterId):-1;const at=found>=0?found+1:blocks.length;blocks.splice(at,0,data);selected=at;insertAfterId=null;Studio.showInsertHint();Studio.modified();Studio.draw();Studio.toggleLibrary(false);if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
duplicate(i){const b=editorTools.duplicate(blocks[i],()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));blocks.splice(i+1,0,b);selected=i+1;Studio.modified();Studio.draw()},
remove(i){if(!confirm('确定删除这个区块?'))return;blocks.splice(i,1);selected=Math.min(i,blocks.length-1);Studio.modified();Studio.draw()},
drag(e,i){e.dataTransfer.setData('text/plain',String(i));e.dataTransfer.effectAllowed='move';e.currentTarget.classList.add('dragging')},
drop(e,i){e.preventDefault();let src=Number(e.dataTransfer.getData('text/plain'));if(!Number.isInteger(src)||src<0||src>=blocks.length||src===i)return;const moved=editorTools.reorder(blocks,src,i);if(!moved)return;blocks=moved;selected=i;Studio.modified();Studio.draw()},
field(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();Studio.draw()},
fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified(true);const row=$('#blockList').querySelector(`[data-index="${selected}"]`);if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type},
arrayEditor(field,value,type){
const rows=Array.isArray(value)?value:[];
const labels={title:'标题',text:'文字介绍',value:'数字',label:'名称',question:'问题',answer:'回答',url:'图片地址',alt:'图片说明',image:'图片地址',caption:'图片说明',name:'名称',price:'价格',features:'方案包含的服务',href:'按钮跳转路径'};
const spec=schema.blocks[type];const sample=(spec?.defaults?.[field]||[])[0]||{url:'',alt:''};
const keys=Object.keys(sample);
return `<div class="sf-array-editor"><p class="sf-array-hint">无需填写 JSON:按顺序编辑每一项,修改会自动显示在预览中。</p>`+
rows.map((row,i)=>`<div class="sf-array-card"><div class="sf-array-card-head"><b>第 ${i+1} 项</b><div><button type="button" title="上移" ${i===0?'disabled':''} onclick="Studio.moveItem('${field}',${i},-1)">↑</button><button type="button" title="下移" ${i===rows.length-1?'disabled':''} onclick="Studio.moveItem('${field}',${i},1)">↓</button><button type="button" title="删除这一项" onclick="Studio.removeItem('${field}',${i})">删除</button></div></div>`+
keys.map(key=>`<label class="sf-array-field">${html(labels[key]||key)}${key==='text'||key==='answer'||key==='features'?`<textarea oninput="Studio.itemField('${field}',${i},'${key}',this.value)">${html(Array.isArray(row?.[key])?(row[key]||[]).join('\n'):row?.[key]??'')}</textarea>`:`<input type="text" value="${html(row?.[key]??'')}" oninput="Studio.itemField('${field}',${i},'${key}',this.value)">`}</label>`).join('')+
`</div>`).join('')+`<button type="button" class="sf-array-add" onclick="Studio.addItem('${field}')">+ 添加${field==='images'?'图片':'一项'}</button></div>`;
},
itemField(field,index,key,value){const block=blocks[selected],row=block?.[field]?.[index];if(!row)return;row[key]=key==='features'?String(value).split('\n').map(x=>x.trim()).filter(Boolean):String(value).slice(0,1500);Studio.modified(true)},
addItem(field){const block=blocks[selected];if(!block)return;const rows=block[field];if(!Array.isArray(rows))return;const limit=field==='images'?24:16;if(rows.length>=limit)return toast(`最多添加 ${limit} 项`,true);const sample=schema.blocks[block.type]?.defaults?.[field]?.[0]||{url:'',alt:''};const next={};for(const [key,val] of Object.entries(sample))next[key]=Array.isArray(val)?[]:'';rows.push(next);Studio.modified();Studio.inspector()},
removeItem(field,index){const rows=blocks[selected]?.[field];if(!Array.isArray(rows)||!rows[index])return;rows.splice(index,1);Studio.modified();Studio.inspector()},
moveItem(field,index,by){const rows=blocks[selected]?.[field],target=index+by;if(!Array.isArray(rows)||target<0||target>=rows.length)return;[rows[index],rows[target]]=[rows[target],rows[index]];Studio.modified();Studio.inspector()},
jsonField(key,value){try{let data=JSON.parse(value);if(!Array.isArray(data))throw Error('必须为数组');Studio.fieldQuiet(key,data);$('#jsonError').textContent='✓ JSON 格式有效';$('#jsonError').style.color='#238460'}catch(e){$('#jsonError').textContent='JSON 格式有误:'+e.message;$('#jsonError').style.color='#bf5a58'}},
styleField(key,value){const b=blocks[selected];if(!b)return;b.style={...(b.style||{}),[key]:value};Studio.modified()},
applyStylePreset(preset){
const block=blocks[selected];if(!block)return;
const style=window.SiteForgeStudioExperience.stylePreset(site?.theme,preset);
if(!style)return;
block.style={...(block.style||{}),...style};
Studio.modified();Studio.inspector();
toast('已应用区块外观,保存草稿前可以撤销');
},
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection"><b>还没有选中内容</b><p>先到左侧选择一个区块,或点击“添加内容区块”。</p><button type="button" class="ui-btn primary" onclick="Studio.toggleLibrary(true)">+ 添加内容</button></div>';return}const spec=schema.blocks[b.type];let out=`<div class="inspector-title"><h3>${html(spec.name)}</h3><label class="toggle"><input type="checkbox" ${b.visible!==false?'checked':''} onchange="Studio.field('visible',this.checked)">显示</label></div><div data-inspector-section="content">`;
for(const field of spec.fields.filter(f=>b.type!=='layout'||!['columns','gap','tablet_columns','mobile_columns'].includes(f))){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度'})[field]||field;
out+=`<div class="prop"><label>${html(label)}</label>`;
if(field==='items'||field==='images')out+=Studio.arrayEditor(field,value,b.type);
else if(field==='source')out+=`<select onchange="Studio.field('${field}',this.value)">${['product','post','page','vehicle','brand','case','service','other','all'].map(t=>`<option value="${t}" ${t===value?'selected':''}>${({product:'产品',post:'文章',page:'普通页面',vehicle:'车型',brand:'品牌',case:'案例',service:'服务',other:'其他',all:'全部类型'})[t]}</option>`).join('')}</select><span class="prop-tip">网站已有内容将自动绑定并生成卡片。</span>`;
else if(field==='image_side'||field==='align')out+=`<select onchange="Studio.field('${field}',this.value)">${(field==='align'?['left','center']:['left','right']).map(v=>`<option value="${v}" ${value===v?'selected':''}>${v==='left'?'左侧':v==='right'?'右侧':'居中'}</option>`).join('')}</select>`;
else if(field==='body'||field==='description')out+=`<textarea onchange="Studio.fieldQuiet('${field}',this.value)">${html(value)}</textarea>`;
else if(['limit','columns','height'].includes(field))out+=`<input type="number" min="1" max="120" value="${html(value)}" onchange="Studio.fieldQuiet('${field}',Number(this.value))">`;
else out+=`<input type="text" value="${html(value)}" oninput="Studio.fieldQuiet('${field}',this.value)">`;
if(['title','subtitle','description','image','body'].includes(field))out+='<small class="prop-tip">可输入 {{site.name}}、{{page.title}} 等变量。</small>';
out+='</div>';
}
if(b.type==='layout')out+=Studio.childQuickEditor(b);
out+='</div><div data-inspector-section="responsive">';
if(b.type==='layout'){
out+=`<div class="subheading">快捷布局</div><p class="prop-tip">常用排版一键设置,也可直接拖动画布中的列宽分隔线。</p><div class="sf-layout-presets"><button type="button" onclick="Studio.setLayoutPreset('equal')">均分列宽</button><button type="button" ${b.columns.length===2?'':'disabled'} onclick="Studio.setLayoutPreset('left-wide')">左宽右窄</button><button type="button" ${b.columns.length===2?'':'disabled'} onclick="Studio.setLayoutPreset('right-wide')">左窄右宽</button></div><div class="prop"><label>列间距(0–64px)</label><input type="number" min="0" max="64" value="${html(b.gap??24)}" onchange="Studio.setLayoutGap(this.value)"></div><div class="subheading">手机与平板</div><p class="prop-tip">选 1 列更适合手机阅读。以下设置只影响对应设备,不更改桌面列宽。</p><div class="prop"><label>平板每行列数 <input type="number" min="1" max="3" value="${html(b.tablet_columns||2)}" onchange="Studio.setDeviceColumns('tablet',this.value)"></label><label>手机每行列数 <input type="number" min="1" max="2" value="${html(b.mobile_columns||1)}" onchange="Studio.setDeviceColumns('mobile',this.value)"></label></div>`;
b.columns.forEach((column,ci)=>{
out+=`<div class="layout-editor-col" ondragover="event.preventDefault()" ondrop="Studio.dropChild(event,${ci})"><div class="layout-editor-title"><strong>第${ci+1}列</strong><label>宽度 <input type="number" min="0.5" max="4" step="0.5" value="${html(column.width)}" onchange="Studio.setColumnWidth(${ci},this.value)"></label><button onclick="Studio.removeColumn(${ci})">删除列</button></div>`;
out+=`<div class="prop"><label>平板排序 <input type="number" min="0" max="20" value="${html(column.tablet_order??(ci+1))}" onchange="Studio.setColumnDevice(${ci},'tablet_order',Number(this.value))"></label><label>手机排序 <input type="number" min="0" max="20" value="${html(column.mobile_order??(ci+1))}" onchange="Studio.setColumnDevice(${ci},'mobile_order',Number(this.value))"></label><label>平板跨列 <input type="number" min="1" max="${html(b.tablet_columns||2)}" value="${html(column.tablet_span||1)}" onchange="Studio.setColumnDevice(${ci},'tablet_span',Number(this.value))"></label><label>手机跨列 <input type="number" min="1" max="${html(b.mobile_columns||1)}" value="${html(column.mobile_span||1)}" onchange="Studio.setColumnDevice(${ci},'mobile_span',Number(this.value))"></label><label><input type="checkbox" ${column.hide_tablet?'checked':''} onchange="Studio.setColumnDevice(${ci},'hide_tablet',this.checked)"> 平板隐藏此列</label><label><input type="checkbox" ${column.hide_mobile?'checked':''} onchange="Studio.setColumnDevice(${ci},'hide_mobile',this.checked)"> 手机隐藏此列</label></div>`;
column.blocks.forEach((child,j)=>{out+=`<div class="layout-child" draggable="true" ondragstart="Studio.dragChild(event,${ci},${j})"><strong>${html(schema.blocks[child.type]?.name||child.type)}</strong><button onclick="Studio.removeChild(${ci},${j})" title="删除">×</button>`;
for(const key of (schema.blocks[child.type]?.fields||[]).filter(x=>['title','subtitle','description','image','body','href','button'].includes(x))){out+=`<label>${html(key)}<input value="${html(child[key]??'')}" onchange="Studio.childField(${ci},${j},'${key}',this.value)"></label>`}
if(b.columns.length>1){out+=`<select onchange="Studio.moveChild(${ci},${j},Number(this.value))"><option value="">移动到其他列</option>${b.columns.map((c,ki)=>ki===ci?'':`<option value="${ki}">第${ki+1}列</option>`).join('')}</select>`}
out+='</div>';
});
out+=`<div class="layout-child-toolbar">${['richtext','image_text','collection','cta','features','layout'].map(t=>`<button onclick="Studio.addChild(${ci},'${t}')">+${t==='layout'?'内层布局':html(schema.blocks[t].name)}</button>`).join('')}</div></div>`;
});
out+='<button class="ui-btn ghost" onclick="Studio.addColumn()">+ 添加列</button>';
}
out+=`<div class="subheading">响应式样式</div><div class="prop"><label>平板上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.tablet_padding??50)}" onchange="Studio.styleField('tablet_padding',Number(this.value))"></div><div class="prop"><label>手机上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.mobile_padding??28)}" onchange="Studio.styleField('mobile_padding',Number(this.value))"></div><div class="prop"><label><input type="checkbox" ${b.style?.hide_mobile?'checked':''} onchange="Studio.styleField('hide_mobile',this.checked)"> 手机上隐藏区块</label></div>`;
out+=`</div><div data-inspector-section="style"><div class="subheading">快速设置外观</div><p class="prop-tip">不必先调色值:选择样式后会立即更新预览,也可以撤销。</p><div class="sf-style-presets"><button type="button" onclick="Studio.applyStylePreset('clean')">清爽白底</button><button type="button" onclick="Studio.applyStylePreset('soft')">柔和浅底</button><button type="button" onclick="Studio.applyStylePreset('feature')">重点深底</button></div><div class="subheading">自定义外观</div><div class="prop"><label>背景颜色</label><input type="color" value="${html(b.style?.background||'#ffffff')}" onchange="Studio.styleField('background',this.value)"></div><div class="prop"><label>上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.padding??82)}" onchange="Studio.styleField('padding',Number(this.value))"></div><div class="prop"><label>从图库选择图片</label><button type="button" class="ui-btn ghost" onclick="Studio.chooseMedia()">打开本站素材库</button></div><div class="prop"><label>上传图片</label><input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0])"></div></div>`;
const sharing=selectedChildId?editorTools.findChildInRoot(blocks,b.id,selectedChildId)?.child?.shared_ref:b.shared_ref;
if(sharing)out+=`<div class="sf-share-info"><strong>已关联公共组件 · v${html(sharing.version)}</strong><p>此区块将来可以按需同步新版。直接修改后会被视为本地定制。</p><button type="button" class="ui-btn ghost" onclick="Studio.detachShared()">解除关联,保留当前内容</button></div>`;
out+='<div style="display:flex;gap:8px;margin-bottom:22px"><button class="small-btn" onclick="Studio.duplicate('+selected+')">复制区块</button><button class="small-btn red-btn" onclick="Studio.remove('+selected+')">删除区块</button></div>';
target.innerHTML=out;Studio.applyInspectorTab();
},
undo(){restoreSnapshot(snapshotCursor-1)},
redo(){restoreSnapshot(snapshotCursor+1)},
modified(continuous=false){
const now=Date.now(),snap=JSON.stringify({blocks,kind,layoutMode});
if(continuous&&lastContinuousEdit&&now-lastContinuousEdit<850&&snapshotCursor>0&&snapshotCursor===snapshots.length-1){snapshots[snapshotCursor]=snap;}
else recordSnapshot();
lastContinuousEdit=continuous?now:0;
dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');
Studio.updateHistoryButtons();Studio.updateGuidance();clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),550);
},
async refreshPreview(){
if(!pageId)return;
const page=pageId,seq=++previewRequest;
if(previewController)previewController.abort();previewController=new AbortController();
const frame=$('#sitePreview');$('#previewState').textContent='正在更新预览…';$('#previewDot').classList.add('loading');
try{
const doc=await api(`/api/sites/${siteId}/pages/${page}/editor-preview`,{...req('POST',{blocks,page_template:kind,layout_mode:layoutMode,site_design:siteDesignPreview}),signal:previewController.signal});
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest))return;
frame.onload=()=>Studio.highlightCanvas();frame.srcdoc=doc;$('#previewState').textContent='实时预览 · 已更新';$('#previewDot').classList.remove('loading','error');
}catch(e){
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest)||e.name==='AbortError')return;
$('#previewState').textContent='预览更新失败';$('#previewDot').classList.remove('loading');$('#previewDot').classList.add('error');toast('预览失败:'+e.message,true);
}
},
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);$('#deviceDetails').textContent=({desktop:'桌面端 · 自适应宽度',tablet:'平板端 · 780px',phone:'手机端 · 390px'})[type]||'桌面端';for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)},
changeTemplateKind(t){kind=t;Studio.modified()},
setLayoutMode(v){layoutMode=v;Studio.modified()},
async resetPreset(){if(!confirm('用当前主题的预设布局替换草稿?不会更改正式页面。'))return;try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/preset?page_template=${encodeURIComponent(kind)}`);blocks=result.blocks;selected=blocks.length?0:-1;Studio.modified();Studio.draw();toast('已加载预设到草稿,记得保存')}catch(e){toast(e.message,true)}},
async showHistory(){
if(!pageId)return toast('请先选择页面',true);
$('#studioHistoryModal').showModal();$('#studioHistoryItems').textContent='读取历史记录中…';
try{const data=await api(`/api/sites/${siteId}/pages/${pageId}/studio-history?limit=50`);
$('#studioHistoryItems').innerHTML=(data.items||[]).map(x=>`<div style="padding:12px 0;border-bottom:1px solid #e2e8f0;display:flex;gap:12px;align-items:center;justify-content:space-between"><div><strong>${x.kind==='published'?'已发布':'草稿'} · v${html(x.revision)}</strong><div style="font-size:12px;color:#64748b">${html(x.created_at)} · ${html(x.actor)} · SHA ${html(x.snapshot_hash.slice(0,12))}</div></div><button class="ui-btn ghost" type="button" onclick="Studio.restoreHistory('${html(x.id)}')">恢复为新草稿</button></div>`).join('')||'<p>当前页面尚无保存记录。</p>';
}catch(e){$('#studioHistoryItems').textContent='读取失败:'+e.message}
},
async restoreHistory(id){
if(dirty&&!confirm('当前有未保存的本地修改。恢复历史记录将舍弃这些本地修改,继续吗?'))return;
if(!confirm('确定以这个历史快照创建一个新的设计草稿?已发布的页面不会立即改变。'))return;
try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-history/${encodeURIComponent(id)}/restore`,req('POST',{expected_revision:draftRevision}));
dirty=false;$('#studioHistoryModal').close();await Studio.choosePage(pageId);
toast('历史版本已恢复为新草稿。核对后再点击发布设计。');
}catch(e){toast('历史恢复失败:'+e.message,true)}
},
async save(){
if(savePromise)return savePromise;
const page=pageId;const snapshot=JSON.stringify({blocks,kind,layoutMode});const frozen=JSON.parse(snapshot);
$('#saveStatus').textContent='保存中…';
savePromise=(async()=>{
try{
const result=await api(`/api/sites/${siteId}/pages/${page}/studio-draft`,req('PUT',{blocks:frozen.blocks,page_template:frozen.kind,layout_mode:frozen.layoutMode,expected_revision:draftRevision}));
if(page!==pageId)return;
draftRevision=result.revision;
const unchanged=snapshot===JSON.stringify({blocks,kind,layoutMode});
dirty=!unchanged;$('#saveStatus').textContent=unchanged?'草稿 v'+draftRevision:'有新修改待保存';
$('#saveStatus').classList.toggle('dirty',dirty);
toast(unchanged?'设计草稿已保存,正式构建不受影响':'草稿已保存,但有新修改,需再次保存');
}catch(e){dirty=true;$('#saveStatus').textContent='保存失败';$('#saveStatus').classList.add('dirty');toast('草稿保存失败:'+e.message,true)}
finally{savePromise=null;Studio.updateGuidance()}
})();return savePromise;
},
async publishDesign(){if(dirty){await Studio.save();if(dirty)return}if(!draftRevision)return toast('当前没有待发布草稿');if(!confirm('发布设计将更新内容库中的页面布局,下次静态构建才会生效。是否发布?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/publish`,req('POST',{expected_revision:draftRevision}));draftRevision=0;$('#saveStatus').textContent='已发布到内容库';Studio.updateGuidance();toast('设计已发布到内容库。要更新线上网站,请在“更多”中构建并部署。')}catch(e){toast('发布失败:'+e.message,true)}},
async discardDraft(){if(!draftRevision)return;if(!confirm('确定放弃草稿?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft`,{method:'DELETE'});dirty=false;await Studio.choosePage(pageId);toast('草稿已放弃')}catch(e){toast(e.message,true)}},
dragChild(event,col,index){draggedChild={container:selected,col,index};event.dataTransfer.setData('application/siteforge-child','1');event.dataTransfer.effectAllowed='move';event.stopPropagation()},
dropChild(event,destCol){if(!draggedChild)return;event.preventDefault();event.stopPropagation();const drag=draggedChild;draggedChild=null;const src=blocks[drag.container],dest=blocks[selected];if(!src||!dest||src.type!=='layout'||dest.type!=='layout')return;const moved=src.columns?.[drag.col]?.blocks.splice(drag.index,1)[0];if(!moved)return;dest.columns[destCol].blocks.push(moved);Studio.modified();Studio.draw()},
drawSiteNav(){
const root=$('#siteNavRows');root.replaceChildren();
const draw=(items,depth=0)=>items.forEach((node,index)=>{
const line=document.createElement('div');line.className='sf-nav-item';line.style.marginLeft=`${depth*16}px`;
const title=document.createElement('input');title.placeholder='菜单名称';title.maxLength=48;title.value=node.title||'';title.setAttribute('aria-label','菜单名称');title.oninput=()=>node.title=title.value;
const route=document.createElement('input');route.placeholder='/products/';route.value=node.route||'/';route.setAttribute('aria-label','站内路径');route.oninput=()=>node.route=route.value;
line.append(title,route);
const op=(label,fn)=>{const button=document.createElement('button');button.type='button';button.className='ui-btn ghost sf-nav-action';button.textContent=label;button.onclick=fn;line.append(button)};
if(depth<2)op('+ 子菜单',()=>{node.children=node.children||[];node.children.push({title:'新菜单',route:'/',children:[]});Studio.drawSiteNav()});
if(index)op('↑',()=>{[items[index-1],items[index]]=[items[index],items[index-1]];Studio.drawSiteNav()});
if(index<items.length-1)op('↓',()=>{[items[index+1],items[index]]=[items[index],items[index+1]];Studio.drawSiteNav()});
op('删除',()=>{items.splice(index,1);Studio.drawSiteNav()});
root.append(line);if(node.children?.length)draw(node.children,depth+1);
});
draw(siteNavDraft);
},
addSiteNav(){siteNavDraft.push({title:'新菜单',route:'/',children:[]});Studio.drawSiteNav()},
async showSiteDesign(){try{
const result=await api(`/api/sites/${siteId}/site-design`);
siteDesign=result.config;siteDesignRevision=result.revision;siteDesignPreview=structuredClone(siteDesign);
const form=$('#siteDesignForm'),h=siteDesign.header,f=siteDesign.footer,r=siteDesign.responsive;
for(const [name,value] of Object.entries(h)){const el=form.elements.namedItem(name);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}}
for(const [name,value] of Object.entries(f)){const key='footer_'+name,el=form.elements.namedItem(key);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}}
for(const [name,value] of Object.entries(r)){const el=form.elements.namedItem(name);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}}
siteNavDraft=structuredClone(siteDesign.nav||[]);Studio.drawSiteNav();
form.elements.namedItem('mobile_tabs_enabled').checked=Boolean(siteDesign.mobile_tabs?.enabled);
Studio.drawMobileTabs(siteDesign.mobile_tabs?.items||[]);
Studio.toggleMobileTabsForm();
$('#siteDesignStatus').textContent=result.state==='draft'?`待发布的全站草稿 v${siteDesignRevision}${result.conflict?' · 网站已被其他人修改':''}`:'当前为已发布设计';
$('#siteDesignModal').showModal();
}catch(e){toast('全站设计读取失败:'+e.message,true)}},
drawMobileTabs(items){const root=$('#mobileTabsRows');root.replaceChildren();items.forEach((item,i)=>Studio.addMobileTab(item,i));},
toggleMobileTabsForm(){const on=$('#siteDesignForm').elements.namedItem('mobile_tabs_enabled').checked;$('#mobileTabsEditor').hidden=!on;},
addMobileTab(item={title:'新导航',icon:'grid',route:'/',action:'link'},index){
const root=$('#mobileTabsRows');if(root.children.length>=5){toast('底部最多支持 5 个 Tab',true);return}
const n=root.children.length;
const row=document.createElement('div');row.className='mobile-tab-row';row.style.cssText='display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border:1px solid #dce3ed;border-radius:10px;background:#fafcff';
const label=document.createElement('label');label.textContent='按钮名称';const title=document.createElement('input');title.className='mt-title';title.maxLength=12;title.value=item.title||'';label.append(title);
const iconLabel=document.createElement('label');iconLabel.textContent='按钮图标';const icon=document.createElement('select');icon.className='mt-icon';
for(const [v,t] of Object.entries({home:'⌂ 首页',grid:'▦ 栏目',products:'▤ 产品',news:'☷ 文章',contact:'✉ 联系',phone:'☎ 电话',info:'ⓘ 关于',search:'⌕ 搜索',menu:'☰ 菜单',star:'☆ 收藏'})){const opt=document.createElement('option');opt.value=v;opt.textContent=t;icon.append(opt)}icon.value=item.icon||'grid';iconLabel.append(icon);
const actionLabel=document.createElement('label');actionLabel.textContent='点击操作';const action=document.createElement('select');action.className='mt-action';for(const [v,t] of [['link','打开站内页面'],['menu','打开导航抽屉']]){const o=document.createElement('option');o.value=v;o.textContent=t;action.append(o)}action.value=item.action||'link';actionLabel.append(action);
const routeLabel=document.createElement('label');routeLabel.textContent='链接地址';const route=document.createElement('input');route.className='mt-route';route.placeholder='/products/、https://wa.me/... 或 tel:+...';route.value=item.route||'/';routeLabel.append(route);
function updateRoute(){routeLabel.hidden=action.value==='menu'}action.addEventListener('change',updateRoute);updateRoute();
const tools=document.createElement('div');tools.style.cssText='grid-column:1/-1;display:flex;gap:7px;justify-content:flex-end';
for(const [name,fn] of [['↑ 上移',()=>{const prev=row.previousElementSibling;if(prev)root.insertBefore(row,prev)}],['↓ 下移',()=>{const next=row.nextElementSibling;if(next)root.insertBefore(next,row)}],['删除',()=>row.remove()]]){const b=document.createElement('button');b.type='button';b.className='ui-btn ghost';b.textContent=name;b.addEventListener('click',fn);tools.append(b)}
row.append(label,iconLabel,actionLabel,routeLabel,tools);root.append(row);
},
collectSiteDesign(){const form=$('#siteDesignForm');if(!siteDesign)throw Error('请先打开全站设计');
const v=(n)=>form.elements.namedItem(n).value;
const yes=(n)=>form.elements.namedItem(n).checked;
const nav=structuredClone(siteNavDraft);
const tabs=[...$('#mobileTabsRows').children].map(row=>({title:row.querySelector('.mt-title').value.trim(),icon:row.querySelector('.mt-icon').value,route:row.querySelector('.mt-route').value.trim(),action:row.querySelector('.mt-action').value}));
if(yes('mobile_tabs_enabled')&&!tabs.length)throw Error('开启底部 Tabs 后,请至少添加一个按钮');
return {tokens:structuredClone(siteDesign.tokens),mobile_tabs:{enabled:yes('mobile_tabs_enabled'),items:tabs},header:{announcement:v('announcement'),show_announcement:yes('show_announcement'),show_search:yes('show_search'),
show_cta:yes('show_cta'),cta_text:v('cta_text'),cta_href:v('cta_href')},
footer:{title:v('footer_title'),description:v('footer_description'),email:v('footer_email'),show_nav:yes('footer_show_nav')},nav,
responsive:{tablet_gap:Number(v('tablet_gap')),mobile_gap:Number(v('mobile_gap')),
mobile_container_padding:Number(v('mobile_container_padding')),mobile_hide_announcement:yes('mobile_hide_announcement'),mobile_hide_footer_links:yes('mobile_hide_footer_links')}};
},
async previewSiteDesign(){try{siteDesignPreview=Studio.collectSiteDesign();await Studio.refreshPreview();
$('#siteDesignStatus').textContent='编辑器正在预览未发布的全站设置;静态网站保持不变';toast('已在编辑器预览全站设计')
}catch(e){toast(e.message,true)}},
async saveSiteDesign(){try{const config=Studio.collectSiteDesign();let saved=await api(`/api/sites/${siteId}/site-design`,req('PUT',{config,expected_revision:siteDesignRevision}));
siteDesignRevision=saved.revision;siteDesign=config;siteDesignPreview=structuredClone(config);
$('#siteDesignStatus').textContent=`草稿 v${siteDesignRevision} 已保存,正式构建保持不变`;toast('全站设计草稿保存成功');return true;
}catch(e){toast('全站草稿保存失败:'+e.message,true);return false}},
async publishSiteDesign(){
if(!await Studio.saveSiteDesign())return;
try{
const review=await api(`/api/sites/${siteId}/site-design/review`);
siteDesignReviewToken=review.plan_token;
const box=$('#siteDesignReviewBody');box.replaceChildren();
const lead=document.createElement('p');lead.textContent=`草稿 v${review.revision} · ${review.change_count} 处字段变更 · 影响本站下一次构建的全部页面`;box.append(lead);
if(!review.change_count){const empty=document.createElement('p');empty.textContent='没有任何实际配置变化。可以返回编辑。';box.append(empty);}
review.changed_fields.forEach(change=>{
const article=document.createElement('article');article.className='sf-review-change';
const label=document.createElement('strong');label.textContent=Studio.designFieldLabel(change.field);article.append(label);
const old=document.createElement('p');old.textContent='原配置:'+Studio.formatDesignDifference(change.before,change.field);
const newer=document.createElement('p');newer.textContent='新配置:'+Studio.formatDesignDifference(change.after,change.field);
article.append(old,newer);box.append(article);
});
$('#siteDesignReviewModal').showModal();
}catch(e){toast('审核预览失败:'+e.message,true)}
},
designFieldLabel(key){const names={header:'页眉',footer:'页脚',nav:'网站导航',mobile_tabs:'手机底部导航',tokens:'全站风格',responsive:'响应式布局',announcement:'提示文字',show_announcement:'显示提示栏',show_search:'显示搜索',show_cta:'显示联系按钮',cta_text:'按钮文字',cta_href:'按钮地址',description:'介绍文字',email:'邮箱',title:'标题',items:'导航项目',enabled:'是否启用'};return key.split('.').map(v=>names[v]||v).join(' · ')},
formatDesignDifference(value,field=''){
if(typeof value==='boolean')return value?'已开启':'已关闭';
if(value==null)return '未设置';
if(field==='nav'&&Array.isArray(value)){
const parts=[];const walk=(items,depth)=>items.forEach(n=>{parts.push(' '.repeat(depth)+(n.title||'未命名')+' → '+(n.route||'/'));walk(n.children||[],depth+1)});walk(value,0);
return parts.length?parts.join('\n'):'(未配置导航)';
}
if(field==='mobile_tabs'&&value&&typeof value==='object'){
return value.enabled?'已启用:'+(value.items||[]).map(v=>v.title+' → '+(v.action==='menu'?'打开菜单':v.route)).join(' · '):'未启用';
}
if(Array.isArray(value))return value.map(v=>typeof v==='object'?(v.title||JSON.stringify(v)):v).join('、')||'空列表';
const text=typeof value==='string'?value:JSON.stringify(value);
return text.length>440?text.slice(0,440)+'…':text;
},
async confirmReviewedSiteDesign(){
if(!siteDesignReviewToken)return toast('请先查看发布差异',true);
try{
await api(`/api/sites/${siteId}/site-design/publish-reviewed`,req('POST',{
expected_revision:siteDesignRevision,plan_token:siteDesignReviewToken}));
siteDesignReviewToken=null;siteDesignRevision=0;siteDesignPreview=null;
$('#siteDesignReviewModal').close();$('#siteDesignModal').close();
$('#siteDesignStatus').textContent='全站设计已发布到内容库,下一次构建后生效';
await Studio.refreshPreview();toast('全站设计已审核并发布,请构建及部署网站。');
}catch(e){toast('发布失败:'+e.message,true)}
},
async showSiteDesignHistory(){
try{
const items=await api(`/api/sites/${siteId}/site-design/publications`),box=$('#siteDesignHistoryBody');box.replaceChildren();
if(!items.length){box.textContent='尚无发布记录';}
for(const item of items){
const row=document.createElement('div');row.className='sf-review-change';
const title=document.createElement('strong');title.textContent=`${item.published_at} · 版本 ${item.revision}`;row.append(title);
const show=document.createElement('button');show.type='button';show.className='ui-btn ghost';show.textContent='查看变更';
show.onclick=async()=>{try{const detail=await api(`/api/sites/${siteId}/site-design/publications/${item.id}`);const p=document.createElement('p');p.textContent=detail.changes.map(x=>x.field).join('、')||'无字段变化';row.append(p);show.disabled=true;}catch(e){toast(e.message,true)}};
const restore=document.createElement('button');restore.type='button';restore.className='ui-btn ghost';restore.textContent='恢复为新草稿';
restore.onclick=async()=>{
if(!confirm('确认以这个历史版本生成新的全站设计草稿?不会直接更改线上页面。'))return;
try{const live=await api(`/api/sites/${siteId}/site-design/live-hash`);
await api(`/api/sites/${siteId}/site-design/publications/${item.id}/restore`,req('POST',{
snapshot:'after',expected_revision:siteDesignRevision,expected_live_hash:live.live_hash}));
$('#siteDesignHistoryModal').close();await Studio.showSiteDesign();toast('历史版本已恢复为新草稿,请检查后发布');
}catch(e){toast(e.message,true)}
};
row.append(show,restore);box.append(row);
}
$('#siteDesignHistoryModal').showModal();
}catch(e){toast(e.message,true)}
},
async discardSiteDesign(){if(!confirm('确定删除尚未发布的全站草稿?'))return;
try{await api(`/api/sites/${siteId}/site-design`,{method:'DELETE'});siteDesignPreview=null;
await Studio.showSiteDesign();await Studio.refreshPreview();toast('全站草稿已放弃')
}catch(e){toast(e.message,true)}},
async showTokens(){try{let current=await api(`/api/sites/${siteId}/site-design`);siteDesign=current.config;siteDesignRevision=current.revision;const tokens=siteDesign.tokens;const modal=$('#designModal');for(const [k,v] of Object.entries(tokens)){const field=modal.querySelector(`[name="${k}"]`);if(field)field.value=v}modal.showModal()}catch(e){toast(e.message,true)}},
async saveTokens(){const form=$('#designForm'),tokens={};for(const input of form.querySelectorAll('[name]'))tokens[input.name]=input.type==='number'?Number(input.value):input.value;try{let r=await api(`/api/sites/${siteId}/site-design`);siteDesign=r.config;siteDesignRevision=r.revision;siteDesign.tokens=tokens;let saved=await api(`/api/sites/${siteId}/site-design`,req('PUT',{config:siteDesign,expected_revision:siteDesignRevision}));siteDesignRevision=saved.revision;siteDesignPreview=structuredClone(siteDesign);$('#designModal').close();Studio.refreshPreview();toast('设计规范已保存为全站草稿;请在“全站页眉与页脚”中发布')}catch(e){toast(e.message,true)}},
setDeviceColumns(device,value){const b=blocks[selected];if(!b||b.type!=='layout')return;const max=device==='tablet'?3:2;const next=Math.min(max,Math.max(1,Number(value)||1));b[device+'_columns']=next;b.columns.forEach(c=>c[device+'_span']=Math.min(next,c[device+'_span']||1));Studio.modified();Studio.inspector()},
setColumnDevice(i,key,value){const b=blocks[selected];if(!b||b.type!=='layout')return;const col=b.columns[i];if(!col)return;col[key]=value;Studio.modified();Studio.inspector()},
addColumn(){const b=blocks[selected];if(!b||b.type!=='layout'||b.columns.length>=4)return;b.columns.push({width:1,blocks:[]});Studio.modified();Studio.inspector()},
removeColumn(i){const b=blocks[selected];if(!b||b.type!=='layout'||b.columns.length<=1)return;if(b.columns[i].blocks.length&&!confirm('这一列含有组件,确认删除?'))return;b.columns.splice(i,1);Studio.modified();Studio.inspector()},
setColumnWidth(i,value){const b=blocks[selected];if(!b||b.type!=='layout'||!b.columns[i])return;b.columns[i].width=Math.round(Math.max(.5,Math.min(4,Number(value)||1))*100)/100;Studio.modified();Studio.inspector()},
addChild(i,type){const b=blocks[selected];if(!b||b.type!=='layout')return;const item=structuredClone(schema.blocks[type].defaults);item.type=type;item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);item.visible=true;b.columns[i].blocks.push(item);Studio.modified();Studio.inspector()},
removeChild(i,j){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks.splice(j,1);Studio.modified();Studio.inspector()},
childField(i,j,key,value){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks[j][key]=value;Studio.modified()},
moveChild(ci,ri,target){const b=blocks[selected];if(!b||b.type!=='layout'||target<0||target>=b.columns.length)return;let [component]=b.columns[ci].blocks.splice(ri,1);b.columns[target].blocks.push(component);Studio.modified();Studio.inspector()},
async chooseMedia(){try{const data=await api(`/api/sites/${siteId}/media`);const grid=$('#siteMediaGrid');grid.replaceChildren();if(!data.length){grid.textContent='暂无上传图片。可先在当前组件中上传图片。'}for(const item of data){const button=document.createElement('button');button.type='button';button.style.cssText='border:1px solid #e2e8f0;border-radius:9px;background:white;padding:7px;cursor:pointer';const img=document.createElement('img');img.alt=item.name;img.loading='lazy';img.style.cssText='width:100%;height:105px;object-fit:contain';img.src=`/local-media/${encodeURIComponent(siteId)}/${encodeURIComponent(item.name)}`;const label=document.createElement('small');label.textContent=item.name;label.style.cssText='display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';button.append(img,label);button.addEventListener('click',()=>Studio.applyMedia(item.url));grid.append(button)}$('#siteMediaModal').showModal()}catch(e){toast(e.message,true)}},
applyMedia(url){
const root=blocks[selected];if(!root)return;
const found=selectedChildId?editorTools.findChildInRoot(blocks,root.id,selectedChildId):null;
if(selectedChildId&&!found)return toast('当前子组件已失效,请重新选中后添加图片',true);
const block=found?.child||root;
const fields=schema?.blocks?.[block.type]?.fields||[];
if(block.type==='gallery')block.images=[...(block.images||[]),{url,alt:''}];
else if(block.type==='carousel')block.items=[...(block.items||[]),{image:url,alt:'',caption:''}];
else if(fields.includes('image'))block.image=url;
else return toast('选中的组件不支持图片,请先选择一个图片组件',true);
Studio.modified();Studio.draw();$('#siteMediaModal').close();toast('已应用素材到草稿');
},
async uploadImage(file){if(!file)return;try{const form=new FormData();form.append('file',file);let r=await api(`/api/sites/${siteId}/media`,{method:'POST',body:form});Studio.applyMedia(r.url);toast('图片已上传,保存页面后参与构建')}catch(e){toast(e.message,true)}},
async buildSite(){if(dirty||draftRevision||siteDesignRevision)return toast('有尚未发布的页面或全站草稿,请先发布,再构建网站',true);try{
toast('已提交后台构建任务');const task=await api(`/api/sites/${siteId}/builds`,req('POST',{mirror_images:false}));
for(let i=0;i<240;i++){
await new Promise(resolve=>setTimeout(resolve,1500));
const status=await api(`/api/enterprise/sites/${siteId}/jobs/${task.id}`);
if(status.state==='done'){const result=JSON.parse(status.result_json||'{}');toast('构建完成:'+result.pages+' 个页面');if(result.version)window.open(`/preview/${siteId}/${result.version}/`,'_blank','noopener');return}
if(['failed','cancelled'].includes(status.state)){toast('构建'+status.state+':'+(status.error||''),true);return}
}toast('后台任务仍在运行,请稍后查看构建中心');
}catch(e){toast('构建失败:'+e.message,true)}},
};
window.Studio=Studio;
window.addEventListener('DOMContentLoaded',()=>Studio.init());
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
window.addEventListener('message',event=>{
const frame=document.querySelector('#sitePreview');
if(!frame||event.source!==frame.contentWindow)return;
const data=event.data;
if(data?.source==='siteforge-studio-canvas')Studio.canvasCommand(data);
});
window.addEventListener('keydown',event=>{
if(!(event.ctrlKey||event.metaKey)||event.altKey)return;
const key=event.key.toLowerCase();
if(key==='s'){event.preventDefault();Studio.save();return}
const el=document.activeElement;
if(el&&(['INPUT','TEXTAREA'].includes(el.tagName)||el.isContentEditable))return;
if(key==='z'){event.preventDefault();event.shiftKey?Studio.redo():Studio.undo()}
else if(key==='y'){event.preventDefault();Studio.redo()}
});