2026-10-22 16:49:25 +08:00
|
|
|
|
'use strict';
|
|
|
|
|
|
const $=id=>document.querySelector(id);
|
|
|
|
|
|
const html=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
2026-10-23 12:31:03 +08:00
|
|
|
|
const editorTools=window.SiteForgeEditorHelpers;
|
|
|
|
|
|
let libraryOpen=false;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
let sharedPreview=null;
|
|
|
|
|
|
let siteNavDraft=[],siteDesignReviewToken=null;
|
|
|
|
|
|
let selectedChildId=null;
|
2026-10-26 11:18:45 +08:00
|
|
|
|
let advancedMode=false,themePreviewSeq=0,themePreviewAbort=null;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
let inspectorTab='content',paletteSearch='',blockSearch='',previewRequest=0,previewController=null,savePromise=null,lastContinuousEdit=0,insertAfterId=null;
|
2026-10-23 12:31:03 +08:00
|
|
|
|
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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}
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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()}
|
2026-10-22 16:49:25 +08:00
|
|
|
|
const Studio={
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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)}},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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()},
|
2026-10-26 11:18:45 +08:00
|
|
|
|
closeThemePreview(){themePreviewSeq++;themePreviewAbort?.abort();const dialog=$('#themePreviewModal');if(dialog?.open)dialog.close();const frame=$('#themePreviewFrame');if(frame){frame.removeAttribute('src');frame.removeAttribute('srcdoc')}},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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)));
|
|
|
|
|
|
},
|
2026-10-26 11:18:45 +08:00
|
|
|
|
async updateThemePreview(){
|
2026-10-26 09:43:17 +08:00
|
|
|
|
const theme=$('#themePreviewTheme')?.value,page=$('#themePreviewPage')?.value;
|
|
|
|
|
|
const url=window.SiteForgeStudioExperience.previewUrl(theme,page);
|
|
|
|
|
|
if(!url)return;
|
2026-10-26 11:18:45 +08:00
|
|
|
|
const frame=$('#themePreviewFrame'),status=$('#themePreviewStatus'),retry=$('#themePreviewRetry');if(!frame)return;
|
|
|
|
|
|
const seq=++themePreviewSeq;
|
|
|
|
|
|
themePreviewAbort?.abort();themePreviewAbort=new AbortController();
|
|
|
|
|
|
status.textContent='正在检查主题预览…';retry.hidden=true;frame.removeAttribute('src');frame.removeAttribute('srcdoc');
|
|
|
|
|
|
try{
|
|
|
|
|
|
const response=await fetch(url,{credentials:'same-origin',signal:themePreviewAbort.signal,cache:'no-store'});
|
|
|
|
|
|
if(!response.ok||!(response.headers.get('content-type')||'').includes('text/html'))throw Error(response.status===401?'登录已过期':`服务返回 ${response.status}`);
|
|
|
|
|
|
const content=await response.text();
|
|
|
|
|
|
if(seq!==themePreviewSeq||!$('#themePreviewModal')?.open)return;
|
|
|
|
|
|
// srcdoc keeps the preview in an opaque sandboxed origin: no allow-same-origin,
|
|
|
|
|
|
// no repeated HTML request, no frame-header conflict, and no access to editor data.
|
|
|
|
|
|
const base=new URL(url,['http:','https:'].includes(location.protocol)?location.href:'https://siteforge.invalid').href;
|
|
|
|
|
|
const safeBase=html(base);
|
|
|
|
|
|
const withBase=/<head(?:\s[^>]*)?>/i.test(content)?content.replace(/<head(\s[^>]*)?>/i,(match)=>match+`<base href=\"${safeBase}\">`):`<base href=\"${safeBase}\">`+content;
|
|
|
|
|
|
status.textContent='正在渲染主题页面…';
|
|
|
|
|
|
frame.onload=()=>{if(seq===themePreviewSeq&&$('#themePreviewModal')?.open)status.textContent='预览已显示 · 可切换主题和设备';};
|
|
|
|
|
|
frame.srcdoc=withBase;
|
|
|
|
|
|
}catch(error){
|
|
|
|
|
|
if(seq!==themePreviewSeq||error.name==='AbortError')return;
|
|
|
|
|
|
status.textContent='预览失败:'+error.message+'。请点击重新加载或在完整主题图库中打开。';retry.hidden=false;
|
|
|
|
|
|
}
|
2026-10-26 09:43:17 +08:00
|
|
|
|
}, 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()},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
showHelp(){const dlg=$('#sfGuideDialog');if(dlg&&!dlg.open)dlg.showModal()},
|
2026-10-26 11:18:45 +08:00
|
|
|
|
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);const note=$('#sfNextAction');if(note)note.textContent=dirty?'更改尚未保存:点击右上角「保存草稿」':libraryOpen?'选择一种内容,添加后直接修改文字或图片':selected>=0?'正在编辑「'+(schema?.blocks?.[blocks[selected]?.type]?.name||'当前区块')+'」,右侧可直接修改':'先在左侧添加区块或选中页面内容';},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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}},
|
2026-10-26 11:18:45 +08:00
|
|
|
|
toggleAdvanced(){advancedMode=!advancedMode;document.body.classList.toggle('sf-advanced-mode',advancedMode);const button=$('#studioAdvancedButton');button?.setAttribute('aria-pressed',String(advancedMode));if(button)button.textContent=advancedMode?'收起高级设置':'更多设计设置';const detail=$('#sfModeExplanation');if(detail)detail.textContent=advancedMode?'可调整布局、样式和手机显示':'当前显示常用设置';if(!advancedMode){inspectorTab='content';Studio.applyInspectorTab()}},
|
|
|
|
|
|
closePane(){document.body.classList.remove('show-inspector','show-structure')},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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');},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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}},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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')}},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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()},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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')},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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()},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
field(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();Studio.draw()},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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()},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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'}},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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('已应用区块外观,保存草稿前可以撤销');
|
|
|
|
|
|
},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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">`;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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;
|
2026-10-22 16:49:25 +08:00
|
|
|
|
out+=`<div class="prop"><label>${html(label)}</label>`;
|
2026-10-23 12:31:03 +08:00
|
|
|
|
if(field==='items'||field==='images')out+=Studio.arrayEditor(field,value,b.type);
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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>';
|
|
|
|
|
|
}
|
2026-10-26 11:18:45 +08:00
|
|
|
|
const mediaTarget=selectedChildId?editorTools.findChildInRoot(blocks,b.id,selectedChildId)?.child:b;
|
|
|
|
|
|
const mediaFields=schema.blocks[mediaTarget?.type]?.fields||[];
|
|
|
|
|
|
if(mediaTarget&&(['gallery','carousel'].includes(mediaTarget.type)||mediaFields.includes('image')))out+=`<div class="sf-photo-action"><strong>图片设置</strong><button class="ui-btn ghost" type="button" onclick="Studio.chooseMedia()">从图片素材库选择</button><label class="sf-direct-upload">或上传一张新图片<input aria-label="上传新图片" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0]);this.value=''"></label><small>预览先显示在草稿里,点击保存后才会保留。</small></div>`;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
if(b.type==='layout')out+=Studio.childQuickEditor(b);
|
2026-10-23 12:31:03 +08:00
|
|
|
|
out+='</div><div data-inspector-section="responsive">';
|
2026-10-22 16:49:25 +08:00
|
|
|
|
if(b.type==='layout'){
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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>`;
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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>';
|
|
|
|
|
|
});
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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>`;
|
2026-10-22 16:49:25 +08:00
|
|
|
|
});
|
|
|
|
|
|
out+='<button class="ui-btn ghost" onclick="Studio.addColumn()">+ 添加列</button>';
|
|
|
|
|
|
}
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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>`;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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>`;
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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>';
|
2026-10-23 12:31:03 +08:00
|
|
|
|
target.innerHTML=out;Studio.applyInspectorTab();
|
2026-10-22 16:49:25 +08:00
|
|
|
|
},
|
|
|
|
|
|
undo(){restoreSnapshot(snapshotCursor-1)},
|
|
|
|
|
|
redo(){restoreSnapshot(snapshotCursor+1)},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
frame.onload=()=>Studio.highlightCanvas();frame.srcdoc=doc;$('#previewState').textContent='实时预览 · 已更新';$('#previewDot').classList.remove('loading','error');
|
2026-10-23 12:31:03 +08:00
|
|
|
|
}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)},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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)}},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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)}},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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()},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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()},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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}}
|
2026-10-26 09:43:17 +08:00
|
|
|
|
siteNavDraft=structuredClone(siteDesign.nav||[]);Studio.drawSiteNav();
|
2026-10-23 12:31:03 +08:00
|
|
|
|
form.elements.namedItem('mobile_tabs_enabled').checked=Boolean(siteDesign.mobile_tabs?.enabled);
|
|
|
|
|
|
Studio.drawMobileTabs(siteDesign.mobile_tabs?.items||[]);
|
|
|
|
|
|
Studio.toggleMobileTabsForm();
|
2026-10-22 16:49:25 +08:00
|
|
|
|
$('#siteDesignStatus').textContent=result.state==='draft'?`待发布的全站草稿 v${siteDesignRevision}${result.conflict?' · 网站已被其他人修改':''}`:'当前为已发布设计';
|
|
|
|
|
|
$('#siteDesignModal').showModal();
|
|
|
|
|
|
}catch(e){toast('全站设计读取失败:'+e.message,true)}},
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
collectSiteDesign(){const form=$('#siteDesignForm');if(!siteDesign)throw Error('请先打开全站设计');
|
|
|
|
|
|
const v=(n)=>form.elements.namedItem(n).value;
|
|
|
|
|
|
const yes=(n)=>form.elements.namedItem(n).checked;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
const nav=structuredClone(siteNavDraft);
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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'),
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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}},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
async publishSiteDesign(){
|
2026-10-22 16:49:25 +08:00
|
|
|
|
if(!await Studio.saveSiteDesign())return;
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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)}
|
|
|
|
|
|
},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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()},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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()},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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)}},
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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)}},
|
2026-10-22 16:49:25 +08:00
|
|
|
|
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=>{
|
2026-10-26 09:43:17 +08:00
|
|
|
|
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);
|
2026-10-22 16:49:25 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 12:31:03 +08:00
|
|
|
|
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()}
|
|
|
|
|
|
});
|