'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); const editorTools=window.SiteForgeEditorHelpers; let libraryOpen=false; let inspectorTab='content',blockSearch='',previewRequest=0,previewController=null,savePromise=null,lastContinuousEdit=0; 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);$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');Studio.updateHistoryButtons();Studio.draw();Studio.refreshPreview()} const Studio={ 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=>``).join('');for(let [k,v] of Object.entries(schema.page_templates))$('#pageTemplate').innerHTML+=``;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;$('#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=>``).join('')||'暂无保存的组件'}catch(e){console.warn(e)}}, async saveReusable(){const block=blocks[selected];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){const saved=reusables.find(x=>x.id===id);if(!saved)return;const copy=editorTools.duplicate(saved.block,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();}, renderPalette(){let p=$('#palette');p.innerHTML=Object.entries(schema.blocks).map(([key,b])=>``).join('')}, 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})=>`
无需填写 JSON:按顺序编辑每一项,修改会自动显示在预览中。
`+ rows.map((row,i)=>`先到左侧选择一个区块,或点击“添加内容区块”。
分别调整平板/手机的列数、顺序、跨度与显隐。保存草稿后在设备预览切换查看效果。
`; b.columns.forEach((column,ci)=>{ out+=`当前页面尚无保存记录。
'; }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()}, 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}} form.elements.namedItem('nav').value=JSON.stringify(siteDesign.nav,null,2); 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; let nav;try{nav=JSON.parse(v('nav'))}catch(e){throw Error('导航必须是合法 JSON:'+e.message)} if(!Array.isArray(nav))throw Error('导航必须是数组'); 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(!confirm('发布全站设计会影响该站所有页面的下一次静态构建。确定继续?'))return; if(!await Studio.saveSiteDesign())return; try{await api(`/api/sites/${siteId}/site-design/publish`,req('POST',{expected_revision:siteDesignRevision})); siteDesignRevision=0;$('#siteDesignStatus').textContent='全站设计已发布,请重新构建网站'; $('#siteDesignModal').close();await Studio.refreshPreview();toast('全站设计已发布;现有上线网站仍不改变'); }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')return;b.columns[i].width=Math.max(.5,Math.min(4,Number(value)||1));Studio.modified()}, 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 b=blocks[selected];if(!b)return;if(b.type==='gallery'){b.images=[...(b.images||[]),{url,alt:''}]}else if(b.type==='carousel'){b.items=[...(b.items||[]),{image:url,alt:'',caption:''}]}else{b.image=url}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.field('image',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-select-block'){ if(typeof data.blockId!=='string'||data.blockId.length>150)return; const findTop=rows=>{for(let i=0;i