Files
cms-SiteForge/app/static/editor.js
T
浅念 3c57c167f2 chore: import SiteForge Factory V5 source (baseline)
导入 SiteForge Factory V5 基线源码:
- FastAPI + SQLite + Docker Compose(Web + Worker)架构
- 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照)
- 四种建站模式骨架 + 迁移审计 + 发布门禁
- 52 项 pytest 测试
- 补全计划主文档 docs/superpowers/plans/

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 10:42:19 +08:00

91 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const $=id=>document.querySelector(id);
const html=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pathParts=location.pathname.split('/').filter(Boolean);
const siteId=pathParts[1],initialPage=pathParts[2];
let csrf='',site=null,pages=[],schema=null,reusables=[],pageId=initialPage,blocks=[],kind='standard',layoutMode='append',selected=-1,dirty=false,timer=null;
const api=async(p,opt={})=>{if(!['GET','HEAD','OPTIONS'].includes((opt.method||'GET').toUpperCase()))opt.headers={...(opt.headers||{}),'X-SiteForge-CSRF':csrf};let r=await fetch(p,opt);if(r.status===401){location.assign('/login');throw Error('会话已过期')}let t=r.headers.get('content-type')||'';let body=t.includes('json')?await r.json():await r.text();if(!r.ok)throw Error(body.detail||body);return body};
const req=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const toast=(s,err=false)=>{const t=$('#toast');t.textContent=s;t.style.background=err?'#a23c44':'#1c3555';t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3400)};
const activePage=()=>pages.find(e=>e.id===pageId);
const 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=>`<option value="${html(e.id)}">${html(e.title)} (${html(e.route)})</option>`).join('');for(let [k,v] of Object.entries(schema.page_templates))$('#pageTemplate').innerHTML+=`<option value="${k}">${html(v.name)}</option>`;Studio.renderPalette();await Studio.loadReusables();await Studio.choosePage(pageId||pages[0]?.id)}catch(e){toast('初始化失败:'+e.message,true);console.error(e)}},
async choosePage(id){if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/blueprint`);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;Studio.draw();Studio.refreshPreview()},
async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<button onclick="Studio.insertReusable('${html(x.id)}')">✦ ${html(x.name)} <small>v${x.version}</small></button>`).join('')||'<span style="font-size:12px;opacity:.55;padding:10px">暂无保存的组件</span>'}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=structuredClone(saved.block);const refreshIds=item=>{item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);if(item.type==='layout')(item.columns||[]).forEach(c=>(c.blocks||[]).forEach(refreshIds))};refreshIds(copy);blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();},
renderPalette(){let p=$('#palette');p.innerHTML=Object.entries(schema.blocks).map(([key,b])=>`<button onclick="Studio.add('${key}')"><span>${html(b.icon)}</span>${html(b.name)}</button>`).join('')},
draw(){let list=$('#blockList');$('#blockCount').textContent=blocks.length+' 区块';list.innerHTML=blocks.map((b,i)=>`<div class="block-row ${selected===i?'selected':''}" draggable="true" data-index="${i}" ondragstart="Studio.drag(event,${i})" ondragover="event.preventDefault()" ondrop="Studio.drop(event,${i})" onclick="Studio.select(${i})"><div class="drag-handle" title="拖动排序">⠿</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 title="复制" onclick="event.stopPropagation();Studio.duplicate(${i})">⧉</button><button title="删除" onclick="event.stopPropagation();Studio.remove(${i})">×</button></div></div>`).join('')||'<div class="no-selection">当前没有区块,点击下方按钮添加。</div>';Studio.inspector()},
select(i){selected=i;Studio.draw()},
add(type){let data=structuredClone(schema.blocks[type].defaults);data.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);data.type=type;data.visible=true;blocks.push(data);selected=blocks.length-1;Studio.modified();Studio.draw()},
duplicate(i){let b=structuredClone(blocks[i]);b.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);blocks.splice(i+1,0,b);selected=i+1;Studio.modified();Studio.draw()},
remove(i){if(!confirm('确定删除这个区块?'))return;blocks.splice(i,1);selected=Math.min(i,blocks.length-1);Studio.modified();Studio.draw()},
drag(e,i){e.dataTransfer.setData('text/plain',String(i));e.dataTransfer.effectAllowed='move';e.currentTarget.classList.add('dragging')},
drop(e,i){e.preventDefault();let src=Number(e.dataTransfer.getData('text/plain'));if(!Number.isInteger(src)||src<0||src>=blocks.length||src===i)return;let [b]=blocks.splice(src,1);blocks.splice(i,0,b);selected=i;Studio.modified();Studio.draw()},
field(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();Studio.draw()},
fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();const row=$('#blockList').children[selected];if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type},
jsonField(key,value){try{let data=JSON.parse(value);if(!Array.isArray(data))throw Error('必须为数组');Studio.fieldQuiet(key,data);$('#jsonError').textContent='✓ JSON 格式有效';$('#jsonError').style.color='#238460'}catch(e){$('#jsonError').textContent='JSON 格式有误:'+e.message;$('#jsonError').style.color='#bf5a58'}},
styleField(key,value){const b=blocks[selected];b.style={...(b.style||{}),[key]:value};Studio.modified()},
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection">请从左侧选择区块并编辑属性。</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>`;
for(const field of spec.fields){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度'})[field]||field;
out+=`<div class="prop"><label>${html(label)}</label>`;
if(field==='items'||field==='images')out+=`<textarea spellcheck="false" onchange="Studio.jsonField('${field}',this.value)">${html(JSON.stringify(value,null,2))}</textarea><span id="jsonError" class="prop-tip">请输入 JSON 数组,可编辑多个项目</span>`;
else if(field==='source')out+=`<select onchange="Studio.field('${field}',this.value)">${['product','post','page','vehicle','brand','case','service','other','all'].map(t=>`<option value="${t}" ${t===value?'selected':''}>${({product:'产品',post:'文章',page:'普通页面',vehicle:'车型',brand:'品牌',case:'案例',service:'服务',other:'其他',all:'全部类型'})[t]}</option>`).join('')}</select><span class="prop-tip">网站已有内容将自动绑定并生成卡片。</span>`;
else if(field==='image_side'||field==='align')out+=`<select onchange="Studio.field('${field}',this.value)">${(field==='align'?['left','center']:['left','right']).map(v=>`<option value="${v}" ${value===v?'selected':''}>${v==='left'?'左侧':v==='right'?'右侧':'居中'}</option>`).join('')}</select>`;
else if(field==='body'||field==='description')out+=`<textarea onchange="Studio.fieldQuiet('${field}',this.value)">${html(value)}</textarea>`;
else if(['limit','columns','height'].includes(field))out+=`<input type="number" min="1" max="120" value="${html(value)}" onchange="Studio.fieldQuiet('${field}',Number(this.value))">`;
else out+=`<input type="text" value="${html(value)}" oninput="Studio.fieldQuiet('${field}',this.value)">`;
if(['title','subtitle','description','image','body'].includes(field))out+='<small class="prop-tip">可输入 {{site.name}}、{{page.title}} 等变量。</small>';
out+='</div>';
}
if(b.type==='layout'){
out+=`<div class="subheading">列内组件 · 无代码布局</div><p class="prop-tip">一行最多四列,拖动整个容器可改变行顺序。列在手机上自动变为单列。</p>`;
b.columns.forEach((column,ci)=>{
out+=`<div class="layout-editor-col"><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>`;
column.blocks.forEach((child,j)=>{out+=`<div class="layout-child"><strong>${html(schema.blocks[child.type]?.name||child.type)}</strong><button onclick="Studio.removeChild(${ci},${j})" title="删除">×</button>`;
for(const key of (schema.blocks[child.type]?.fields||[]).filter(x=>['title','subtitle','description','image','body','href','button'].includes(x))){out+=`<label>${html(key)}<input value="${html(child[key]??'')}" onchange="Studio.childField(${ci},${j},'${key}',this.value)"></label>`}
if(b.columns.length>1){out+=`<select onchange="Studio.moveChild(${ci},${j},Number(this.value))"><option value="">移动到其他列</option>${b.columns.map((c,ki)=>ki===ci?'':`<option value="${ki}">第${ki+1}列</option>`).join('')}</select>`}
out+='</div>';
});
out+=`<div class="layout-child-toolbar">${['richtext','image_text','collection','cta','features'].map(t=>`<button onclick="Studio.addChild(${ci},'${t}')">+${html(schema.blocks[t].name)}</button>`).join('')}</div></div>`;
});
out+='<button class="ui-btn ghost" onclick="Studio.addColumn()">+ 添加列</button>';
}
out+=`<div class="subheading">区块设计(按设备覆盖)</div><div class="prop"><label>背景颜色</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>平板上下间距(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><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>`;
out+='<div style="display:flex;gap:8px;margin-bottom:22px"><button class="small-btn" onclick="Studio.duplicate('+selected+')">复制区块</button><button class="small-btn red-btn" onclick="Studio.remove('+selected+')">删除区块</button></div>';
target.innerHTML=out;
},
modified(){dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),500)},
async refreshPreview(){if(!pageId)return;try{let frame=$('#sitePreview');let doc=await api(`/api/sites/${siteId}/pages/${pageId}/editor-preview`,req('POST',{blocks,page_template:kind,layout_mode:layoutMode}));frame.srcdoc=doc}catch(e){toast('预览失败:'+e.message,true)}},
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)},
changeTemplateKind(t){kind=t;Studio.modified()},
setLayoutMode(v){layoutMode=v;Studio.modified()},
async resetPreset(){if(!confirm('使用预设区块将覆盖当前页面布局,是否继续?'))return;try{let result=await api(`/api/sites/${siteId}/pages/${pageId}/blueprint/preset`,req('POST',{page_template:kind}));blocks=result.blocks;selected=0;dirty=false;Studio.draw();Studio.refreshPreview();toast('已加载页面预设并保存')}catch(e){toast(e.message,true)}},
async save(){try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/blueprint`,req('PUT',{blocks,page_template:kind,layout_mode:layoutMode}));blocks=result.blocks;dirty=false;$('#saveStatus').textContent='已保存';$('#saveStatus').classList.remove('dirty');toast('页面蓝图已保存,重新构建后会生成正式静态页面')}catch(e){toast('保存失败:'+e.message,true)}},
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 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){await Studio.save();if(dirty)return}try{toast('开始构建网站……');let result=await api(`/api/sites/${siteId}/builds`,req('POST',{mirror_images:false}));toast('构建完成:'+result.pages+' 个页面');window.open(`/preview/${siteId}/${result.version}/`,'_blank','noopener')}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-inline-editor')return;
const findChild=rows=>{for(const row of rows){if(row.id===data.blockId)return {block:row,parent:row};if(row.type==='layout'){for(const col of row.columns||[]){const child=findChild(col.blocks||[]);if(child)return {block:child.block,parent:row};}}}return null};
const found=findChild(blocks),b=found?.block;
if(!b||!['title','description'].includes(data.field)||typeof data.value!=='string')return;
b[data.field]=data.value.slice(0,500);
selected=blocks.indexOf(found.parent);Studio.modified();Studio.draw();
toast('已修改文字,点击保存页面写入数据库');
});