chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests) - Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR imports keep post/product types instead of collapsing to page - Docs: trade-pro theme port spec (R3) and implementation plan Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
const $=(q,el=document)=>el.querySelector(q);
|
||||
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const ATTR=s=>E(s);
|
||||
const cfg={automotive:{label:'汽车与出行',desc:'高级质感 · 深色车型展厅',prev:'auto',title:'Move beyond.'},industrial:{label:'工业制造',desc:'专业理性 · 工业产品展示',prev:'ind',title:'Built to last.'},corporate:{label:'企业品牌',desc:'现代明亮 · 集团企业官网',prev:'corp',title:'Ideas become impact.'},minimal:{label:'极简设计',desc:'编辑式排版 · 极简作品展示',prev:'min',title:'Less, but better.'}};
|
||||
let csrfToken='';
|
||||
const state={sites:[],siteId:null,site:null,entries:[],builds:[],tab:'overview',presets:[],query:'',typ:''};
|
||||
const api=async(path,opt={})=>{
|
||||
if(!['GET','HEAD','OPTIONS'].includes((opt.method||'GET').toUpperCase())) {opt.headers={...(opt.headers||{}),'X-SiteForge-CSRF':csrfToken}}
|
||||
const res=await fetch(path,opt);if(res.status===401){location.assign('/login');throw new Error('登录已过期')}let data;try{data=await res.json()}catch{data={detail:'请求失败,响应不是 JSON'}}
|
||||
if(!res.ok)throw new Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail||data));return data;
|
||||
};
|
||||
const json=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
|
||||
const notice=(s,bad=false)=>{const t=$('#toast');t.innerText=s;t.style.background=bad?'#a03e45':'#243952';t.classList.add('show');clearTimeout(t._timer);t._timer=setTimeout(()=>t.classList.remove('show'),4300)};
|
||||
const onError=e=>{console.error(e);notice(e.message||String(e),true)};
|
||||
const simpleDate=s=>s?new Date(s).toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'—';
|
||||
const b=(text,fn,extra='')=>`<button class="btn light small" ${extra} onclick="${fn}">${text}</button>`;
|
||||
const tabs=[['overview','工作概览'],['entries','内容管理'],['templates','模板与设计'],['design','可视化页面设计'],['models','内容字段模型'],['import','旧站 / 数据导入'],['seo','SEO / URL'],['builds','构建与导出']];
|
||||
function topTitle(name){$('#topTitle').textContent=name}
|
||||
function hero(title,desc,buttons='',eyebrow='SITEFORGE / WEBSITE FACTORY'){return `<div class="page-head"><div><span class="eyebrow">${E(eyebrow)}</span><h1>${E(title)}</h1><p>${E(desc)}</p></div><div class="top-actions">${buttons}</div></div>`}
|
||||
function themeCard(key,action){let t=cfg[key];return `<div class="template-card"><div class="template-art preview-${t.prev}${key==='minimal'?' min':''}"><span>THEME / ${E(key.toUpperCase())}</span><strong>${E(t.title)}</strong></div><div class="detail"><div><h3>${E(t.label)}</h3><p>${E(t.desc)}</p></div>${action}</div></div>`}
|
||||
const App={
|
||||
async init(){try{const me=await api('/api/auth/me');csrfToken=me.csrf;state.sites=await api('/api/sites');state.presets=await api('/api/presets');App.renderSiteNav();App.openDashboard()}catch(e){onError(e)}},
|
||||
async logout(){try{await api('/api/auth/logout',{method:'POST'});location.assign('/login')}catch(e){onError(e)}},
|
||||
async reload(){state.sites=await api('/api/sites');App.renderSiteNav();if(state.siteId){state.site=await api('/api/sites/'+state.siteId);state.entries=await api('/api/sites/'+state.siteId+'/entries');state.builds=await api('/api/sites/'+state.siteId+'/builds')}},
|
||||
renderSiteNav(){$('#siteNav').innerHTML=state.sites.map(s=>`<button class="${s.id===state.siteId?'active':''}" onclick="App.openSite('${s.id}')"><span class="site-dot">${E((s.name||'S')[0])}</span><span title="${ATTR(s.name)}">${E(s.name)}</span></button>`).join('')||'<div style="padding:10px 12px;font-size:11px;color:#7185a2">暂无网站项目</div>'},
|
||||
openDashboard(){state.siteId=null;topTitle('网站总览');App.renderSiteNav();document.querySelectorAll('.side-link').forEach(x=>x.classList.remove('selected'));$('#nav-dashboard').classList.add('selected');
|
||||
let n=state.sites.length, counts=state.sites.reduce((x,s)=>x+s.entries_count,0),builds=state.sites.reduce((x,s)=>x+s.build_count,0);
|
||||
$('#view').innerHTML=hero('网站生产工作台','从零创建、迁移旧站、导入内容、更换模板,统一生产独立静态网站。',`<button class="btn primary" onclick="App.openNewSite()">+ 创建网站</button>`,'WELCOME TO YOUR FACTORY')+
|
||||
`<div class="metrics-grid"><div class="metric"><small>网站总数</small><strong>${n}</strong><span>独立站点项目</span></div><div class="metric"><small>内容条目</small><strong>${counts}</strong><span>页面、产品、文章等</span></div><div class="metric"><small>构建版本</small><strong>${builds}</strong><span>可预览与下载</span></div><div class="metric"><small>可用主题</small><strong>4</strong><span>支持每站独立风格</span></div></div>`+
|
||||
`<div class="page-head" style="margin-top:35px"><div><h1 style="font-size:21px">我的网站</h1><p>选择任意站点,继续编辑、导入数据或构建版本</p></div></div><div class="cards">`+
|
||||
state.sites.map(s=>`<article class="site-card" onclick="App.openSite('${s.id}')"><div class="card-preview preview-${cfg[s.theme]?.prev||'corp'}"><span class="card-tag">${E(cfg[s.theme]?.label||'企业站')}</span><div class="brandmark">${E(s.name)}</div></div><div class="site-card-body"><h3>${E(s.name)}</h3><p>${E(s.domain||'待绑定域名')} · ${E(s.tagline||'尚未填写站点简介')}</p><div class="site-card-meta"><span>${s.entries_count} 条内容</span><span>${s.build_count} 次构建 →</span></div></div></article>`).join('')+`<article class="add-card" onclick="App.openNewSite()"><div class="add-circle">+</div><h3>创建新网站</h3><p>旧站迁移 / 新建站点 / 导入内容</p></article></div>`+
|
||||
(!state.sites.length?`<div class="panel" style="margin-top:26px"><div class="panel-title"><div><h3>第一次体验 SiteForge?</h3><p>生成演示网站,并立即构建一个可独立下载的纯静态网站。</p></div><button class="btn light" onclick="App.seedDemo()">快速创建演示网站 →</button></div></div>`:'');
|
||||
},
|
||||
async openSite(id,tab='overview'){try{state.siteId=id;state.tab=tab;await App.reload();topTitle(state.site.name);App.renderSiteNav();App.renderSite()}catch(e){onError(e)}},
|
||||
renderSite(){const s=state.site;let head=hero(s.name,s.domain||'独立网站 · 尚未设置域名',`<button class="btn light" onclick="App.openEntry()">+ 添加内容</button><button class="btn primary" onclick="App.build()">↗ 构建网站</button>`,'WEBSITE PROJECT / '+s.theme.toUpperCase());
|
||||
let navigation=`<div class="tabs">${tabs.map(([id,name])=>`<button class="tab ${state.tab===id?'current':''}" onclick="App.changeTab('${id}')">${name}</button>`).join('')}</div>`;
|
||||
$('#view').innerHTML=head+`<div class="metrics-grid"><div class="metric"><small>页面 / 内容</small><strong>${state.entries.length}</strong><span>统一内容数据中心</span></div><div class="metric"><small>主题</small><strong style="font-size:17px;padding:9px 0">${E(cfg[s.theme]?.label)}</strong><span>可随时切换</span></div><div class="metric"><small>构建次数</small><strong>${state.builds.length}</strong><span>独立构建版本</span></div><div class="metric"><small>SEO 模式</small><strong style="font-size:17px;padding:9px 0">${s.mode==='migration'?'原址保留':'灵活新建'}</strong><span>路由独立管理</span></div></div>`+navigation+`<div id="tab-content"></div>`;App.renderTab();
|
||||
},
|
||||
changeTab(tab){state.tab=tab;App.renderSite()},
|
||||
renderTab(){let html='';switch(state.tab){case'overview':html=App.overview();break;case'entries':html=App.entriesView();break;case'templates':html=App.templateView();break;case'design':html=App.designView();break;case'models':html=App.modelsView();break;case'import':html=App.importView();break;case'seo':html=App.seoView();break;case'builds':html=App.buildView();break;}$('#tab-content').innerHTML=html;if(state.tab==='import')App.loadMedia();if(state.tab==='seo')App.loadRedirects();if(state.tab==='models')App.loadModels()},
|
||||
overview(){let s=state.site, latest=state.builds[0];return `<div class="panel"><div class="panel-title"><div><h3>网站生产流程</h3><p>每次修改内容、主题或域名后都可以重新构建</p></div></div><div class="steps"><div class="step"><span>STEP 01</span><strong>创建网站</strong><p>选择主题,完成独立站点初始化</p></div><div class="step"><span>STEP 02</span><strong>添加或导入内容</strong><p>手动录入、旧站采集、文件批量导入</p></div><div class="step"><span>STEP 03</span><strong>主题与 SEO</strong><p>自定义视觉样式、路由及页面元信息</p></div><div class="step"><span>STEP 04</span><strong>构建并导出</strong><p>生成静态 HTML 文件,预览并下载 ZIP</p></div></div></div><div class="split"><div class="panel"><div class="panel-title"><div><h3>网站基本资料</h3><p>设置域名、品牌和原网站来源</p></div></div>${App.settingsForm()}</div><div class="panel"><div class="panel-title"><div><h3>最近构建版本</h3><p>构建文件与管理后台彼此独立</p></div></div>${latest?`<div class="pill green">静态构建完成</div><h2 style="margin:16px 0">${latest.total_pages} 个 HTML 页面</h2><p style="color:#8292a8">${simpleDate(latest.created_at)}</p><div class="panel-footer" style="justify-content:start"><a class="btn light" target="_blank" href="/preview/${s.id}/${latest.id}/">查看预览 ↗</a><a class="btn primary" href="/api/sites/${s.id}/builds/${latest.id}/download">下载 ZIP</a></div>`:`<div class="empty"><div>⌁</div><h3>还没有构建版本</h3><p>保存内容并点击「构建网站」,即可生成首个网站。</p><button class="btn primary" onclick="App.build()">开始第一次构建</button></div>`}</div></div>`},
|
||||
settingsForm(){let s=state.site;return `<form id="settingsForm" onsubmit="App.saveSettings(event)"><div class="setting-grid"><label class="full">网站名称<input name="name" value="${ATTR(s.name)}" required></label><label class="full">正式网站地址<input name="domain" value="${ATTR(s.domain)}" placeholder="https://domain.com"></label><label class="full">网站简介 / 标语<textarea name="tagline" rows="2">${E(s.tagline)}</textarea></label><label class="full">源站地址(旧站导入)<input name="origin" value="${ATTR(s.settings.origin||'')}" placeholder="https://oldsite.com"></label><label>品牌色<input name="accent" type="color" value="${ATTR(s.accent)}" style="height:42px;padding:3px"></label><label>站点模式<select name="mode"><option value="new" ${s.mode==='new'?'selected':''}>新建</option><option value="migration" ${s.mode==='migration'?'selected':''}>迁移</option><option value="hybrid" ${s.mode==='hybrid'?'selected':''}>混合</option></select></label><label class="full">Logo 图片 URL<input name="logo" value="${ATTR(s.logo||'')}"></label></div><div class="panel-footer"><button class="btn primary" type="submit">保存网站设置</button></div></form>`},
|
||||
async saveSettings(ev){ev.preventDefault();try{let data=Object.fromEntries(new FormData(ev.target));state.site=await api('/api/sites/'+state.siteId,json('PATCH',data));await App.reload();notice('已保存站点设置');App.renderSite()}catch(e){onError(e)}},
|
||||
entriesView(){let arr=state.entries.filter(e=>(!state.typ||e.type===state.typ)&&(!state.query||e.title.toLowerCase().includes(state.query.toLowerCase())||e.route.toLowerCase().includes(state.query.toLowerCase())));return `<div class="panel"><div class="panel-title"><div><h3>内容库</h3><p>页面 / 产品 / 文章 / 自定义内容,支持独立 URL 和 SEO</p></div><button class="btn primary small" onclick="App.openEntry()">+ 新建内容</button></div><div class="toolbar"><input id="searchEntry" placeholder="搜索标题或 URL" value="${ATTR(state.query)}" oninput="App.filterContent()"><select id="typeFilter" onchange="App.filterContent()"><option value="">全部类型</option>${['page','product','post','service','case','vehicle','brand','other'].map(t=>`<option value="${t}" ${state.typ===t?'selected':''}>${{page:'页面',product:'产品',post:'文章',service:'服务',case:'案例',vehicle:'车型',brand:'品牌',other:'自定义'}[t]}</option>`).join('')}</select><span class="muted">${arr.length} 条结果</span></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>标题</th><th>类型</th><th>路由</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead><tbody>${arr.map(e=>`<tr><td><strong>${E(e.title)}</strong><div style="font-size:11px;color:#9da5b2;max-width:240px;overflow:hidden;text-overflow:ellipsis">${E(e.summary||'')}</div></td><td>${E(e.type)}</td><td class="mono">${E(e.route)}</td><td><span class="pill ${e.status==='published'?'green':'yellow'}">${e.status==='published'?'已发布':'草稿'}</span></td><td>${simpleDate(e.updated_at)}</td><td><div class="row-actions"><a class="btn light tiny" href="/editor/${state.siteId}/${e.id}" target="_blank">页面设计</a><button class="btn light tiny" onclick="App.openEntry('${e.id}')">编辑内容</button><button class="btn light tiny" onclick="App.deleteEntry('${e.id}')">删除</button></div></td></tr>`).join('')}</tbody></table>${arr.length?'':'<div class="empty"><h3>尚无匹配内容</h3><p>新建内容或使用导入中心批量导入</p></div>'}</div></div>`},
|
||||
filterContent(){const active=document.activeElement?.id;const caret=active==='searchEntry'?$('#searchEntry').selectionStart:null;state.query=$('#searchEntry')?.value||'';state.typ=$('#typeFilter')?.value||'';$('#tab-content').innerHTML=App.entriesView();if(active==='searchEntry'){const input=$('#searchEntry');input.focus();if(caret!==null)input.setSelectionRange(caret,caret)}},
|
||||
async updateNavigation(ev){ev.preventDefault();try{const raw=$('#navRoutes').value.split('\n').map(x=>x.trim()).filter(Boolean);const entries=raw.map(line=>{const sep=line.indexOf('|');if(sep<0)throw new Error('每行必须为:导航文字 | /路径/');return {title:line.slice(0,sep).trim(),route:line.slice(sep+1).trim()}});let path='/api/sites/'+state.siteId+'/site-design';let current=await api(path);current.config.nav=entries;await api(path,json('PUT',{config:current.config,expected_revision:current.revision}));notice('导航已保存为全站草稿:'+entries.length+'项,请在设计器中发布全站设计')}catch(e){onError(e)}},
|
||||
async scaffold(){if(!confirm('创建关于我们、联系我们和服务页面?已有同路径页面不会覆盖。'))return;try{let r=await api('/api/sites/'+state.siteId+'/scaffold',{method:'POST'});await App.reload();App.renderSite();notice('创建 '+r.created.length+' 个页面,跳过 '+r.skipped.length+' 个')}catch(e){onError(e)}},
|
||||
modelsView(){return `<div class="panel"><div class="panel-title"><div><h3>内容字段模型</h3><p>自定义产品、车型、品牌、文章等数据字段。字段保存在每个站点自己的配置内,不影响其他站点。</p></div></div><div class="toolbar"><label>内容类型 <select id="modelKind" onchange="App.renderModelFields()">${['product','vehicle','post','brand','case','service','other'].map(x=>`<option value="${x}">${x}</option>`).join('')}</select></label><button class="btn primary" onclick="App.addModelField()">+ 添加字段</button></div><div id="modelFields"><p>正在读取字段定义……</p></div><div class="panel-footer"><button class="btn primary" onclick="App.saveModelFields()">保存字段模型</button></div></div>`},
|
||||
async loadModels(){try{state.models=await api('/api/sites/'+state.siteId+'/schemas');App.renderModelFields()}catch(e){onError(e)}},
|
||||
renderModelFields(){let el=$('#modelFields');if(!el||!state.models)return;let kind=$('#modelKind').value;let defs=state.models[kind]||[];el.innerHTML=`<table class="data-table"><thead><tr><th>字段键名</th><th>显示名称</th><th>字段类型</th><th></th></tr></thead><tbody id="fieldRows">${defs.map(d=>App.modelRow(d)).join('')}</tbody></table>`},
|
||||
modelRow(d={}){return `<tr class="field-row"><td><input class="field-key" value="${ATTR(d.name||'')}" placeholder="例如:wheelbase" style="width:100%"></td><td><input class="field-label" value="${ATTR(d.label||'')}" placeholder="字段名称" style="width:100%"></td><td><select class="field-type">${['text','textarea','number','date','url','boolean'].map(t=>`<option value="${t}" ${d.type===t?'selected':''}>${t}</option>`).join('')}</select></td><td><button class="btn light tiny" onclick="this.closest('tr').remove()">删除</button></td></tr>`},
|
||||
addModelField(){if(!$('#fieldRows'))return;$('#fieldRows').insertAdjacentHTML('beforeend',App.modelRow())},
|
||||
async saveModelFields(){try{const fields=[...document.querySelectorAll('.field-row')].map(tr=>({name:tr.querySelector('.field-key').value.trim(),label:tr.querySelector('.field-label').value.trim(),type:tr.querySelector('.field-type').value}));let kind=$('#modelKind').value;let result=await api('/api/sites/'+state.siteId+'/schemas/'+kind,json('PUT',fields));state.models[kind]=result.fields;notice('已保存 '+kind+' 字段模型')}catch(e){onError(e)}},
|
||||
designView(){const s=state.site;return `<div class="panel"><div class="panel-title"><div><h3>Visual Studio · 页面可视化工作台</h3><p>拖动区块顺序、修改属性、自动绑定内容,并在同一个界面预览 PC 和移动端。</p></div></div><div class="cards">${state.entries.filter(e=>e.type==='page'||e.type==='product'||e.type==='post').slice(0,36).map(e=>`<div class="site-card" style="cursor:default;padding:20px"><div class="pill">${E(e.type)}</div><h3>${E(e.title)}</h3><p style="color:#8999a9">${E(e.route)}</p><a class="btn primary small" href="/editor/${s.id}/${e.id}" target="_blank">打开可视化编辑器 ↗</a></div>`).join('')}</div><p style="margin-top:22px;color:#7f90a2">页面编辑后点击构建,系统生成对应的独立静态页面,不会丢失原有 URL 和 SEO。 <button class="btn light small" style="margin-left:15px" onclick="App.scaffold()">一键补齐企业常用页面</button></p></div><div class="panel"><div class="panel-title"><div><h3>顶部导航配置</h3><p>每行:标题 | /原始路径/,不填写则按网站已有栏目自动生成导航。</p></div></div><form onsubmit="App.updateNavigation(event)"><textarea id="navRoutes" style="width:100%;min-height:115px;border:1px solid #dce3ed;padding:15px;border-radius:10px">${E((state.site.settings.nav||[]).map(n=>n.title+' | '+n.route).join('\n'))}</textarea><div class="panel-footer"><button class="btn primary" type="submit">保存导航结构</button></div></form></div>`},
|
||||
async applyTemplates(mode){if(!confirm(mode==='overwrite'?'将用当前主题的默认布局覆盖所有页面区块,内容和SEO不变,确定继续?':'将给尚未设置布局的页面自动套用当前主题,确定继续?'))return;try{let r=await api('/api/sites/'+state.siteId+'/apply-theme',json('POST',{mode}));await App.reload();notice('已装配 '+r.updated+' 个页面,跳过 '+r.skipped+' 个');App.renderSite()}catch(e){onError(e)}},
|
||||
templateView(){let s=state.site;return `<div class="panel"><div class="panel-title"><div><h3>主题样式库</h3><p>切换后内容、原有 URL 和 SEO 数据不变,重新构建立即产生全新视觉版本</p></div></div><div class="template-grid">${Object.keys(cfg).map(t=>themeCard(t,`<button class="btn ${s.theme===t?'light':'primary'} small" ${s.theme===t?'disabled':''} onclick="App.setTheme('${t}')">${s.theme===t?'✓ 正在使用':'使用主题'}</button>`)).join('')}</div></div><div class="panel"><div class="panel-title"><div><h3>模板工厂</h3><p>保存当前主题与首页区块配置,以后创建其他网站时可以直接套用。</p></div></div><div class="toolbar"><input id="presetName" placeholder="例如:HESU 汽车企业基础模板" style="max-width:400px"><button class="btn primary" onclick="App.savePreset()">保存为新模板</button><button class="btn light" onclick="App.cloneSite()">克隆整个站点</button></div><div class="toolbar" style="margin:16px 0"><button class="btn primary" onclick="App.applyTemplates('missing')">一键把已有内容装入新主题(保留已设计的页面)</button><button class="btn light" onclick="App.applyTemplates('overwrite')">全部应用当前主题预设布局</button></div><div class="tip">当前版本支持页面区块拖拽排序、PC / 手机预览、动态内容绑定。完全自由定位的像素级编辑仍不支持。</div></div>`},
|
||||
importView(){return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>批量导入文件</h3><p>支持 WordPress XML、CSV、Excel XLSX、JSON</p></div></div><form id="importForm" onsubmit="App.importFile(event)"><label class="field">选择内容数据文件<input type="file" name="file" required accept=".xml,.csv,.json,.xlsx"></label><label class="field" style="margin-top:18px">遇到相同 URL 时<select name="on_conflict"><option value="skip">跳过原有页面(安全默认)</option><option value="update">覆盖当前内容(请先备份)</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">开始批量导入</button></div></form><div class="tip" style="margin-top:18px">建议 CSV 字段:type, title, route, summary, body, cover, seo_title, seo_description。</div></div><div class="panel"><div class="panel-title"><div><h3>旧站公开页面采集</h3><p>支持 WordPress、普通 HTML 与其他 CMS 的公开内容</p></div></div><form id="crawlForm" onsubmit="App.crawl(event)"><label class="field">旧网站首页 URL<input name="url" type="url" required value="${ATTR(state.site.settings.origin||'')}" placeholder="https://oldsite.com/"></label><label class="field" style="margin-top:18px">最大抓取页面数<select name="max_pages"><option value="10">10 页(快速验证)</option><option value="20" selected>20 页</option><option value="50">50 页</option></select></label><label class="field" style="margin-top:18px">冲突策略<select name="on_conflict"><option value="skip">保留本地内容</option><option value="update">用采集内容更新</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">扫描并导入公开页面</button></div></form><div class="tip warning" style="margin-top:18px">公开爬取无法保证包含私有字段、插件数据或复杂动态页面。推荐尽量使用原站完整数据库/导出文件。</div></div></div><div class="panel"><div class="panel-title"><div><h3>素材中心</h3><p>上传图片,生成本地媒体路径,内容页可直接引用。</p></div><label class="btn light small">上传图片<input type="file" accept="image/*" hidden onchange="App.uploadMedia(this.files[0])"></label></div><div class="tip">上传文件 URL 将返回类似 media/abc123.jpg,静态站构建时自动携带。</div><div class="toolbar" style="margin-top:18px"><label class="btn light small">批量导入 WordPress 媒体 ZIP<input type="file" accept=".zip" hidden onchange="App.uploadMediaArchive(this.files[0])"></label></div><div id="mediaGrid" style="padding-top:12px"></div></div>`},
|
||||
seoView(){return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>SEO 保留策略</h3><p>不因改版随意改变页面 URL</p></div></div><div class="feature-option"><div class="feature-icon">↗</div><b>页面 URL Registry</b><p>所有现有 URL 由内容库的 route 精确定义;冲突直接禁止保存。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">◇</div><b>SEO Metadata</b><p>编辑内容可以设置 Title、Description、Canonical 和 Robots。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">▤</div><b>Sitemap + 服务器规则</b><p>构建时生成 sitemap.xml、robots.txt、Nginx snippet 和 .htaccess。</p></div></div><div class="panel"><div class="panel-title"><div><h3>301 / 302 重定向管理</h3><p>原 URL 不能保留时,人工审批后配置对应跳转。</p></div></div><form id="redirectForm" onsubmit="App.createRedirect(event)"><label class="field">原 URL Path<input name="old_path" required placeholder="/old-product/"/></label><label class="field" style="margin-top:14px">目标 URL Path<input name="new_path" required placeholder="/new-product/"/></label><label class="field" style="margin-top:14px">HTTP 状态码<select name="status_code"><option value="301">301 永久</option><option value="302">302 临时</option><option value="307">307 临时保留方法</option><option value="308">308 永久保留方法</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">添加重定向</button></div></form><div id="redirectList"></div></div></div>`},
|
||||
buildView(){return `<div class="panel"><div class="panel-title"><div><h3>静态构建中心</h3><p>将当前内容、模板和 SEO 路由组合成完全独立的 HTML 文件。</p></div></div><div class="split"><div><div class="feature-option"><div class="feature-icon">▦</div><b>静态 HTML / CSS / JS</b><p>无需 PHP、WordPress 或中心 CMS,就能独立部署访问。</p></div></div><div><div class="feature-option"><div class="feature-icon">▰</div><b>可回溯构建版本</b><p>每次构建产生独立 ZIP 和版本报告,不会覆盖旧版本。</p></div></div></div><div class="toolbar" style="margin-top:22px"><label style="display:flex;align-items:center;gap:9px"><input id="mirrorChoice" type="checkbox" style="width:18px;height:18px">打包外部图片(较慢,外部资源可能下载失败)</label></div><div class="panel-footer"><button class="btn primary" onclick="App.build()">↗ 生成新的静态网站版本</button></div></div><div class="panel"><div class="panel-title"><div><h3>历史构建</h3><p>选择版本查看预览、下载 ZIP、检查 SEO/媒体报告</p></div></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>版本 ID</th><th>构建时间</th><th>页面数量</th><th>报告</th><th>操作</th></tr></thead><tbody>${state.builds.map(x=>{let r=JSON.parse(x.report_json||'{}');return `<tr><td class="mono">${x.id}</td><td>${simpleDate(x.created_at)}</td><td>${x.total_pages}</td><td><span class="pill ${r.quality_status==='warning'?'yellow':'green'}">${r.quality_status==='warning'?'有告警':'已通过基础检查'}</span></td><td><div class="row-actions"><a class="btn light tiny" target="_blank" href="/preview/${state.siteId}/${x.id}/">预览 ↗</a><a class="btn light tiny" href="/api/sites/${state.siteId}/builds/${x.id}/download">ZIP ↓</a><button class="btn light tiny" onclick="App.showReport('${x.id}')">报告</button><button class="btn light tiny" onclick="App.publishLocal('${x.id}')" title="发布到 Docker 持久化发布目录">发布</button></div></td></tr>`}).join('')}</tbody></table>${state.builds.length?'':'<div class="empty"><div>⌘</div><h3>尚无构建记录</h3><p>完成内容编辑后,点击上方按钮生成版本。</p></div>'}</div><div id="reportBox"></div></div>`},
|
||||
async openTemplates(){state.siteId=null;topTitle('模板资源库');App.renderSiteNav();document.querySelectorAll('.side-link').forEach(x=>x.classList.remove('selected'));$('#nav-template').classList.add('selected');$('#view').innerHTML=hero('模板与主题资源库','从四套行业视觉风格开始,逐步沉淀企业自己的模板库。',`<button class="btn primary" onclick="App.openNewSite()">使用模板建站 →</button>`)+`<div class="template-grid">${Object.keys(cfg).map(t=>themeCard(t,`<button class="btn primary small" onclick="App.openNewSite('${t}')">基于模板创建</button>`)).join('')}</div><div class="panel" style="margin-top:20px"><div class="panel-title"><div><h3>已保存的模板预设</h3><p>由已有网站保存,复用主题及首页区块结构。</p></div></div>${state.presets.length?state.presets.map(p=>`<div class="feature-option" style="margin-bottom:9px"><b>${E(p.name)}</b><p>${E(cfg[p.theme]?.label||p.theme)} · ${simpleDate(p.created_at)}</p><button class="btn light tiny" style="margin-top:11px" onclick="App.openNewSite('${p.theme}','${p.id}')">使用此模板建站</button></div>`).join(''):'<div class="empty"><h3>暂未保存企业自定义模板</h3><p>从任意网站的「模板与设计」中保存为模板。</p></div>'}</div>`},
|
||||
openNewSite(theme='',preset=''){const f=$('#siteForm');f.reset();if(theme)f.elements.theme.value=theme;$('#presetSelector').innerHTML='<option value="">不使用自定义模板</option>'+state.presets.map(p=>`<option value="${p.id}">${E(p.name)} · ${E(p.theme)}</option>`).join('');if(preset)f.elements.preset_id.value=preset;$('#siteDialog').showModal()},
|
||||
closeDialog(id){$('#'+id).close()},
|
||||
async createSite(ev){ev.preventDefault();try{let values=Object.fromEntries(new FormData(ev.target));let site=await api('/api/sites',json('POST',values));App.closeDialog('siteDialog');notice('网站已创建,可以开始添加内容');await App.openSite(site.id)}catch(e){onError(e)}},
|
||||
openEntry(id=''){let f=$('#entryForm');f.reset();let e=state.entries.find(x=>x.id===id);f.elements.entry_id.value=id;$('#entryHeading').textContent=id?'编辑内容':'新建内容';if(e){for(let key of ['title','route','summary','body','cover','seo_title','seo_description','canonical','type','status','robots'])if(f.elements[key])f.elements[key].value=e[key]??'';f.elements.meta.value=JSON.stringify(e.meta||{},null,2)}else{f.elements.meta.value='{}';f.elements.type.value='page';f.elements.status.value='published'}$('#entryDialog').showModal();App.loadEntryFields()},
|
||||
async loadEntryFields(){try{state.models=await api('/api/sites/'+state.siteId+'/schemas');App.renderEntryFields()}catch(e){onError(e)}},
|
||||
renderEntryFields(){let el=$('#dynamicContentFields');if(!el)return;let kind=$('#entryType').value;let list=(state.models||{})[kind]||[];let parsed={};try{parsed=JSON.parse($('#entryMeta').value||'{}')}catch{};const values=parsed.fields||parsed;el.innerHTML=list.length?`<div class="panel-title"><div><h3>业务字段 · ${E(kind)}</h3><p>来自当前网站的字段模型,保存时同步写入内容数据。</p></div></div><div class="setting-grid">${list.map(f=>`<label>${E(f.label||f.name)}<input class="sf-data-field" data-key="${ATTR(f.name)}" type="${['number','date','url'].includes(f.type)?f.type:'text'}" value="${ATTR(values[f.name]||'')}"></label>`).join('')}</div>`:''},
|
||||
addBlock(type){const el=$('#entryMeta');let parsed={};try{parsed=JSON.parse(el.value||'{}')}catch{return notice('当前 JSON 格式不合法,先修复后再添加区块',true)}if(!Array.isArray(parsed.blocks))parsed.blocks=[];let obj=({hero:{type:'hero',title:'核心主标题',subtitle:'OUR VISION',description:'可编辑的首页主视觉描述'},features:{type:'features',title:'核心优势',items:[{title:'专业实力',text:'填写优势简介'},{title:'可靠交付',text:'填写优势简介'},{title:'服务网络',text:'填写优势简介'}]},stats:{type:'stats',items:[{value:'30+',label:'合作品牌'},{value:'70+',label:'服务市场'}]},cta:{type:'cta',title:'期待与您合作',subtitle:'联系我们,了解更多服务',button:'联系我们',href:'/contact/'}})[type];parsed.blocks.push(obj);el.value=JSON.stringify(parsed,null,2);notice('已添加区块,请编辑 JSON 中的标题与内容')},
|
||||
async saveEntry(ev){ev.preventDefault();try{let v=Object.fromEntries(new FormData(ev.target)),id=v.entry_id;delete v.entry_id;v.meta=JSON.parse(v.meta||'{}');const fieldInputs=[...document.querySelectorAll('#dynamicContentFields .sf-data-field')];if(fieldInputs.length){let fields={...(v.meta.fields||{})};for(const input of fieldInputs)fields[input.dataset.key]=input.value;v.meta.fields=fields}if(!id&&!v.route)delete v.route;let endpoint='/api/sites/'+state.siteId+'/entries'+(id?'/'+id:'');await api(endpoint,json(id?'PATCH':'POST',v));App.closeDialog('entryDialog');await App.openSite(state.siteId,'entries');notice(id?'已更新内容':'已创建内容')}catch(e){onError(e)}},
|
||||
async deleteEntry(id){if(!confirm('确定删除此内容吗?删除之后重新构建才会影响线上静态网站。'))return;try{await api('/api/sites/'+state.siteId+'/entries/'+id,{method:'DELETE'});await App.openSite(state.siteId,'entries');notice('内容已删除')}catch(e){onError(e)}},
|
||||
async setTheme(theme){if(!confirm('切换到「'+cfg[theme].label+'」?内容和 URL 保持不变,需重新构建才能更新站点。'))return;try{await api('/api/sites/'+state.siteId,json('PATCH',{theme}));await App.openSite(state.siteId,'templates');notice('主题已更换,请重新构建预览')}catch(e){onError(e)}},
|
||||
async savePreset(){let name=$('#presetName')?.value?.trim();if(!name)return notice('请输入新模板名称',true);try{let result=await api('/api/sites/'+state.siteId+'/save-preset',json('POST',{name}));state.presets=await api('/api/presets');notice('已保存模板:'+result.name)}catch(e){onError(e)}},
|
||||
async cloneSite(){let name=prompt('新站名称:',state.site.name+' 副本');if(!name)return;try{let newsite=await api('/api/sites/'+state.siteId+'/clone',json('POST',{name}));notice('已克隆全部内容到新网站');await App.openSite(newsite.id)}catch(e){onError(e)}},
|
||||
async importFile(ev){ev.preventDefault();let btn=$('button[type=submit]',ev.target);btn.disabled=true;btn.innerText='导入中...';try{let form=new FormData(ev.target);let result=await api('/api/sites/'+state.siteId+'/import',{method:'POST',body:form});await App.openSite(state.siteId,'import');notice(`导入完成:新增 ${result.imported},更新 ${result.updated},跳过 ${result.skipped},错误 ${result.errors?.length||0}`);if(result.errors?.length)alert(JSON.stringify(result.errors.slice(0,15),null,2))}catch(e){onError(e)}finally{btn.disabled=false;btn.innerText='开始批量导入'}},
|
||||
async crawl(ev){ev.preventDefault();if(!confirm('确认对该公开网站进行限定范围采集?请仅采集你拥有或获授权迁移的站点。'))return;let btn=$('button[type=submit]',ev.target);btn.disabled=true;btn.innerText='正在采集...';try{const input=Object.fromEntries(new FormData(ev.target));input.max_pages=+input.max_pages;const r=await api('/api/sites/'+state.siteId+'/crawl',json('POST',input));await App.openSite(state.siteId,'import');notice(`采集完成:发现 ${r.discovered} 页,新增 ${r.imported} 页,页面获取失败 ${r.fetch_errors?.length||0}`)}catch(e){onError(e)}finally{btn.disabled=false;btn.innerText='扫描并导入公开页面'}},
|
||||
async uploadMediaArchive(file){if(!file)return;try{let form=new FormData();form.append('file',file);notice('正在解压媒体文件...');let r=await api('/api/sites/'+state.siteId+'/media-archive',{method:'POST',body:form});notice(`媒体导入:${r.imported} 文件,跳过 ${r.skipped} 文件`)}catch(e){onError(e)}},
|
||||
async uploadMedia(file){if(!file)return;try{let fd=new FormData();fd.append('file',file);let r=await api('/api/sites/'+state.siteId+'/media',{method:'POST',body:fd});notice('图片已上传:'+r.url);App.loadMedia()}catch(e){onError(e)}},
|
||||
async uploadCover(file){if(!file)return;try{let fd=new FormData();fd.append('file',file);let r=await api('/api/sites/'+state.siteId+'/media',{method:'POST',body:fd});$('#coverInput').value=r.url;notice('封面上传成功')}catch(e){onError(e)}},
|
||||
async loadMedia(){if(state.tab!=='import')return;try{let data=await api('/api/sites/'+state.siteId+'/media');$('#mediaGrid').innerHTML=data.map(x=>`<div style="display:inline-flex;flex-direction:column;gap:4px;border:1px solid #ddd;padding:9px;margin:5px"><img style="width:95px;height:70px;object-fit:contain" src="/local-media/${state.siteId}/${E(x.name)}"><button class="btn light tiny" onclick="navigator.clipboard.writeText('${x.url}');notice('已复制图片地址')">复制路径</button></div>`).join('')||'<p class="muted">暂无本地素材</p>'}catch(e){onError(e)}},
|
||||
async createRedirect(ev){ev.preventDefault();let value=Object.fromEntries(new FormData(ev.target));value.status_code=+value.status_code;try{await api('/api/sites/'+state.siteId+'/redirects',json('POST',value));ev.target.reset();notice('已创建重定向规则');App.loadRedirects()}catch(e){onError(e)}},
|
||||
async loadRedirects(){if(state.tab!=='seo')return;try{let data=await api('/api/sites/'+state.siteId+'/redirects');$('#redirectList').innerHTML=data.length?'<h4 style="margin-top:24px">已有重定向</h4>'+data.map(x=>`<p class="mono" style="display:flex;justify-content:space-between;gap:12px">${E(x.old_path)} ${x.status_code} → ${E(x.new_path)} <button class="btn light tiny" onclick="App.deleteRedirect('${x.id}')">删除</button></p>`).join(''):'<p style="margin-top:18px;color:#9aa5b2;font-size:12px">当前没有重定向规则。</p>'}catch(e){onError(e)}},
|
||||
async deleteRedirect(id){if(!confirm('确认删除这条规则?'))return;try{await api('/api/sites/'+state.siteId+'/redirects/'+id,{method:'DELETE'});App.loadRedirects()}catch(e){onError(e)}},
|
||||
async publishLocal(id){if(!confirm('将此版本发布到 Docker 持久化目录?请确认配置好独立站点服务器。'))return;try{const x=await api('/api/sites/'+state.siteId+'/builds/'+id+'/publish-local',{method:'POST'});notice('已发布:'+x.release+'(发布文件位于 Docker 数据卷)')}catch(e){onError(e)}},
|
||||
async build(){let s=state.siteId;if(!s)return;let mirror=$('#mirrorChoice')?.checked||false;
|
||||
try{let job=await api('/api/sites/'+s+'/builds',{...json('POST',{mirror_images:mirror}),headers:{'Content-Type':'application/json','Idempotency-Key':crypto.randomUUID()}});
|
||||
notice('已进入后台构建队列:'+job.id+',关闭浏览器也可继续执行');
|
||||
for(let i=0;i<240;i++){await new Promise(resolve=>setTimeout(resolve,1500));
|
||||
let status=await api('/api/enterprise/sites/'+s+'/jobs/'+job.id);
|
||||
if(status.state==='done'){let result=JSON.parse(status.result_json||'{}');await App.openSite(s,'builds');notice('构建完成:'+(result.pages||0)+' 个页面');if(result.version)App.showReport(result.version);return}
|
||||
if(status.state==='failed'||status.state==='cancelled'){notice('任务'+status.state+': '+(status.error||''),true);return}
|
||||
}notice('构建仍在后台运行,请在任务中心查看状态');
|
||||
}catch(e){onError(e)}},
|
||||
async showReport(id){try{const r=await api('/api/sites/'+state.siteId+'/builds/'+id+'/report');let box=$('#reportBox');if(!box){alert(JSON.stringify(r,null,2));return}box.innerHTML=`<div class="panel" style="margin-top:20px;background:#fafdff"><div class="panel-title"><div><h3>质量报告:${E(id)}</h3><p>本报告只覆盖当前实现的基础结构与资源检查,不代表 SEO 排名保证。</p></div></div><div class="metrics-grid"><div class="metric"><small>静态 HTML 数</small><strong>${r.pages}</strong></div><div class="metric"><small>重定向数</small><strong>${r.redirects}</strong></div><div class="metric"><small>已镜像图片</small><strong>${r.mirrored_images}</strong></div><div class="metric"><small>质量告警</small><strong>${r.warnings.length}</strong></div></div>${r.warnings.length?`<div class="tip warning">${r.warnings.map(x=>`• ${E(x)}`).join('<br>')}</div>`:'<div class="tip">✓ 当前规则下未发现告警</div>'}<h4>详细报告</h4><pre class="pre">${E(JSON.stringify(r,null,2))}</pre></div>`;box.scrollIntoView({behavior:'smooth',block:'nearest'})}catch(e){onError(e)}},
|
||||
async seedDemo(){try{const s=await api('/api/sites',json('POST',{name:'HESU Global Motors · 演示站',tagline:'全球汽车采购、出口与交付服务',theme:'automotive',domain:'https://demo.example.com',mode:'new'}));let base='/api/sites/'+s.id+'/entries';for(let v of [
|
||||
{type:'page',title:'关于我们',route:'/about/',summary:'专注全球汽车出口与本地交付',body:'<p>这里是示例公司介绍。请在内容管理中替换为你的真实资料。</p>',seo_title:'About us'},
|
||||
{type:'product',title:'ROX 01',route:'/product/rox-01/',summary:'豪华全地形 SUV · 示例数据,可在后台编辑',body:'<h2>产品概览</h2><p>此条目为演示内容,不代表实际库存或车型参数。</p>'},
|
||||
{type:'product',title:'BYD ATTO 3',route:'/product/byd-atto-3/',summary:'纯电 SUV · 示例车型信息',body:'<p>可以从 WordPress、Excel 或 JSON 重新导入真正的车型数据。</p>'},
|
||||
{type:'post',title:'网站动态示例',route:'/news/first-news/',summary:'一个独立的新网站如何快速发布',body:'<p>编辑内容、切换主题、点击构建,即可导出静态网站。</p>'}
|
||||
])await api(base,json('POST',v));await App.openSite(s.id);await App.build();notice('演示网站已创建并成功构建,可直接预览或下载 ZIP')}catch(e){onError(e)}},
|
||||
};
|
||||
window.App=App;
|
||||
$('#siteForm').addEventListener('submit',App.createSite);
|
||||
$('#entryForm').addEventListener('submit',App.saveEntry);
|
||||
window.addEventListener('DOMContentLoaded',()=>App.init());
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Portable SiteForge blocks, loaded with every theme (all styles scoped to sf-). */
|
||||
.sf-section{background:var(--sf-bg,transparent);color:var(--sf-fg,inherit);padding:var(--sf-pad,82px) 0;margin:0}.sf-section:nth-of-type(even){background:var(--sf-bg,#f4f6f9)}.sf-container{max-width:1200px;margin:auto;padding:0 32px}.sf-section h1,.sf-section h2,.sf-section h3,.sf-section p{color:inherit;line-height:1.3}.sf-section h1{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}.sf-section h2{font-size:clamp(27px,3.2vw,44px);font-weight:760;letter-spacing:-.037em}.sf-kicker{display:block;font-size:12px;letter-spacing:.17em;text-transform:uppercase;font-weight:740;color:var(--accent,#3569d3);margin-bottom:18px}.sf-hero-grid,.sf-image-text{display:grid;grid-template-columns:1.08fr .92fr;gap:68px;align-items:center}.sf-hero-copy p{font-size:18px;line-height:1.75;max-width:520px;opacity:.75}.sf-hero-img,.sf-image-side{overflow:hidden;border-radius:9px;min-height:380px;background:linear-gradient(130deg,#d5e1ee,#eef3f9);display:grid;place-items:center}.sf-hero-img img,.sf-image-side img{width:100%;height:100%;max-height:580px;object-fit:cover}.sf-hero-abstract{font-size:110px;color:#a7bdd4}.sf-button{display:inline-flex;align-items:center;justify-content:center;gap:28px;background:var(--accent,#315ee2);color:white!important;padding:16px 26px;border-radius:6px;font-size:14px;text-decoration:none;font-weight:720;margin-top:24px}.sf-section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:32px;gap:24px}.sf-section-head h2{margin:0}.sf-section-head>a,.sf-link{color:var(--accent,#3569d3);font-weight:700;text-decoration:none}.sf-prose{font-size:16px;line-height:1.85;max-width:930px}.sf-prose p{line-height:1.85}.sf-features,.sf-collection,.sf-stats{display:grid;gap:20px}.sf-features{grid-template-columns:repeat(3,minmax(0,1fr))}.sf-feature{border:1px solid #e0e6eb;padding:32px 28px;border-radius:8px;background:#fff;color:#253247}.sf-feature>span{font-size:12px;font-weight:800;color:var(--accent,#346add)}.sf-feature h3{font-size:22px;margin:30px 0 8px}.sf-feature p{font-size:14px;line-height:1.7;color:#647181}.sf-stats{grid-template-columns:repeat(4,1fr);padding:40px 0;border-top:1px solid #b7c0cd66;border-bottom:1px solid #b7c0cd66}.sf-stats>div{display:flex;flex-direction:column;gap:8px}.sf-stats strong{font-size:clamp(28px,4vw,53px);letter-spacing:-.04em}.sf-stats span{font-size:12px;opacity:.65;letter-spacing:.04em}.sf-collection{grid-template-columns:repeat(var(--sf-cols,3),minmax(0,1fr))}.sf-item{display:block;text-decoration:none;color:#233044;background:#fff;border:1px solid #e8edf1;border-radius:8px;overflow:hidden;transition:transform .2s,box-shadow .2s}.sf-item:hover{transform:translateY(-4px);box-shadow:0 18px 38px #344d7018}.sf-item-img{height:210px;overflow:hidden;background:#e4eaf0}.sf-item-img img{height:100%;width:100%;object-fit:cover}.sf-placeholder{display:grid;place-items:center;width:100%;height:100%;min-height:190px;background:linear-gradient(130deg,#e2eaf2,#c9d7e6);font-size:72px;color:#8399b2;font-weight:800}.sf-large{min-height:360px}.sf-item-copy{padding:25px}.sf-item-copy small{text-transform:uppercase;letter-spacing:.13em;color:var(--accent,#376edc)}.sf-item-copy h3{font-size:22px;margin:12px 0 7px}.sf-item-copy p{font-size:14px;line-height:1.6;color:#7a8796;min-height:42px}.sf-item-copy span{font-size:12px;color:var(--accent,#376edc);font-weight:800}.sf-image-text>div>p{font-size:17px;line-height:1.8;max-width:520px;opacity:.7}.sf-reverse>div:first-child{order:2}.sf-gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.sf-gallery img{width:100%;height:230px;object-fit:cover;border-radius:6px}.sf-faq{max-width:920px}.sf-faq details{border-bottom:1px solid #dce3eb;padding:22px 0}.sf-faq summary{font-size:17px;cursor:pointer;font-weight:700}.sf-faq p{color:#647181;line-height:1.75}.sf-cta-wrap{display:flex;justify-content:space-between;align-items:center;gap:24px}.sf-cta-wrap h2{margin:0}.sf-cta-wrap p{font-size:16px;opacity:.75}.sf-section a{cursor:pointer}.sf-muted{opacity:.65}
|
||||
@media(max-width:820px){.sf-container{padding:0 20px}.sf-section{padding:50px 0}.sf-hero-grid,.sf-image-text{grid-template-columns:1fr;gap:26px}.sf-reverse>div:first-child{order:initial}.sf-hero-img,.sf-image-side{min-height:250px}.sf-features,.sf-collection{grid-template-columns:repeat(2,minmax(0,1fr))}.sf-stats{grid-template-columns:repeat(2,1fr)}.sf-cta-wrap{align-items:flex-start;flex-direction:column}}
|
||||
@media(max-width:540px){.sf-features,.sf-collection,.sf-gallery{grid-template-columns:1fr}.sf-section-head{display:block}.sf-section-head>a{display:block;margin-top:13px}.sf-item-img{height:220px}.sf-hero-grid{gap:22px}.sf-hero-copy p{font-size:16px}.sf-stats{gap:28px}}
|
||||
|
||||
.sf-attributes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 32px;border-top:1px solid #e5eaf0}.sf-attribute{display:flex;justify-content:space-between;align-items:center;gap:18px;border-bottom:1px solid #e5eaf0;padding:19px 10px;font-size:14px}.sf-attribute span{color:#748497}.sf-attribute strong{font-weight:720;text-align:right}@media(max-width:640px){.sf-attributes{grid-template-columns:1fr}}
|
||||
|
||||
/* SiteForge responsive no-code grid container */
|
||||
.sf-layout-grid{display:grid;grid-template-columns:var(--sf-layout-cols,1fr 1fr);gap:var(--sf-layout-gap,24px);align-items:stretch}
|
||||
.sf-layout-column{min-width:0}.sf-layout-column>.sf-section{padding:0;margin:0 0 18px}.sf-layout-column .sf-container{width:100%;padding:0}
|
||||
.sf-layout-column>.sf-section:last-child{margin-bottom:0}
|
||||
@media(max-width:820px){.sf-layout-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
|
||||
@media(max-width:540px){.sf-layout-grid{grid-template-columns:minmax(0,1fr)!important}}
|
||||
|
||||
/* V8.6: accessible, script-free commercial design blocks */
|
||||
.sf-slides{display:flex;gap:18px;overflow-x:auto;scroll-snap-type:x mandatory;padding:6px 0 18px;scrollbar-width:thin}
|
||||
.sf-slide{flex:0 0 min(78%,720px);min-width:220px;margin:0;scroll-snap-align:start}
|
||||
.sf-slide img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px}
|
||||
.sf-slide figcaption{padding:10px 2px;opacity:.7;font-size:14px}
|
||||
.sf-video{aspect-ratio:16/9;max-width:1000px;background:#ebf0f5;border-radius:10px;overflow:hidden}
|
||||
.sf-video iframe{width:100%;height:100%;border:0}
|
||||
.sf-tab{padding:19px 0;border-bottom:1px solid #dde4ec;max-width:960px}
|
||||
.sf-tab summary{font-size:18px;font-weight:700;cursor:pointer}.sf-tab p{max-width:850px;line-height:1.8}
|
||||
.sf-prices{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
|
||||
.sf-price{padding:28px;background:#fff;border:1px solid #dee5ef;border-radius:10px;color:#29394b}
|
||||
.sf-price strong{display:block;font-size:27px;margin:14px 0}.sf-price li{margin:11px 0}.sf-price ul{padding-left:19px}
|
||||
.sf-map-address{font-style:normal;font-size:18px;white-space:pre-line;line-height:1.8}
|
||||
@media (prefers-reduced-motion:reduce){.sf-slides{scroll-behavior:auto}.sf-item{transition:none}}
|
||||
@media(max-width:540px){.sf-slide{flex-basis:92%}}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* SiteForge commercial theme UX layer: accessibility, layout rhythm, media, responsive navigation. */
|
||||
:root{--sf-shadow:0 20px 55px rgba(17,33,55,.08);--sf-shadow-hover:0 26px 65px rgba(17,33,55,.15)}
|
||||
html{scroll-padding-top:104px}body{font-feature-settings:"kern";text-rendering:optimizeLegibility}
|
||||
a,button,input,select,textarea{-webkit-tap-highlight-color:transparent}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid var(--pro-accent);outline-offset:4px}
|
||||
.pro-wrap{width:100%;max-width:min(var(--pro-wide),100%)}.pro-header{isolation:isolate;box-shadow:0 1px 0 rgba(15,35,60,.035)}.pro-head-inner{gap:clamp(15px,2vw,29px)}.pro-logo{min-width:0;flex-shrink:0;gap:12px}.pro-brand-logo{max-width:155px;max-height:46px;width:auto;height:auto;object-fit:contain}.pro-logo-name{overflow-wrap:anywhere}.pro-navigation{gap:clamp(11px,1.6vw,24px)}.pro-header-cta{white-space:nowrap}
|
||||
.pro-mobile-toggle,.pro-catalog-toggle,.pro-nav-disclose{border:0;background:transparent;color:inherit;line-height:1.2}
|
||||
.pro-mobile-toggle{min-height:44px;min-width:44px;border:1px solid currentColor;border-radius:6px;font-size:19px}.pro-nav-group{position:relative;display:flex;align-items:center;gap:2px}
|
||||
.pro-nav-disclose{padding:9px;font-size:13px}.pro-nav-children{position:absolute;top:100%;left:0;min-width:215px;max-width:330px;display:grid;gap:2px;padding:13px;box-shadow:var(--sf-shadow);border-radius:10px;z-index:45;background:var(--pro-background);border:1px solid var(--pro-border);color:var(--pro-text)}
|
||||
.pro-nav-children[hidden],.pro-mega[hidden]{display:none!important}.pro-nav-children a{display:block;font-size:13px;white-space:normal;padding:10px;border-radius:5px}.pro-nav-children a:hover{background:var(--pro-surface)}
|
||||
.pro-catalog-toggle{padding:11px 3px;white-space:nowrap;font-size:12px;font-weight:700}.pro-mega{position:absolute;top:100%;left:50%;transform:translateX(-50%);width:min(100%,var(--pro-wide));box-shadow:var(--sf-shadow);z-index:41;display:grid;grid-template-columns:215px 1fr;background:var(--pro-background);color:var(--pro-text);border:1px solid var(--pro-border);border-radius:0 0 15px 15px;padding:29px;align-items:start}.pro-mega>span{font-size:12px;letter-spacing:.06em}.pro-mega>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px 22px;overflow:visible}.pro-mega>div a{white-space:normal;padding:10px 5px;border-bottom:1px solid var(--pro-border)}
|
||||
.pro-home-hero{overflow:hidden}.pro-home-hero .pro-home-grid{min-height:660px;gap:5%}.pro-home-hero .pro-hero-copy{position:relative;z-index:2}.pro-home-hero .pro-hero-copy h1{font-size:clamp(46px,5.7vw,92px);line-height:1.06;letter-spacing:-.064em;max-width:15ch}.pro-home-hero .pro-hero-copy h1 em{font-style:normal;display:block}.pro-home-hero .pro-hero-copy p{font-size:clamp(15px,1.3vw,19px);line-height:1.75;max-width:44ch}.pro-home-hero .pro-hero-art{min-width:0;min-height:425px;position:relative;isolation:isolate}.pro-home-hero .pro-hero-art>img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0}.pro-hero-art:has(.pro-abstract-car),.pro-hero-art:has(.pro-technical),.pro-hero-art:has(.pro-corporate-shape){background-position:center;background-size:cover;background-repeat:no-repeat}.pro-abstract-car,.pro-technical,.pro-corporate-shape{position:relative;z-index:1;pointer-events:none}
|
||||
.pro-hero-actions{display:flex;flex-wrap:wrap;align-items:center;gap:15px}.pro-button,.pro-header-cta,.pro-outline-button{min-height:48px;transition:transform .24s ease,box-shadow .24s ease,opacity .24s ease}.pro-button:hover,.pro-header-cta:hover,.pro-outline-button:hover{transform:translateY(-2px);box-shadow:0 10px 22px rgba(0,0,0,.10)}
|
||||
.pro-section{padding-top:clamp(76px,7vw,112px)!important;padding-bottom:clamp(76px,7vw,112px)!important}.pro-section-head{gap:16px}.pro-section-head h2{font-size:clamp(32px,3.5vw,53px);max-width:22ch}.pro-section-head .pro-text-link{align-self:end}
|
||||
.pro-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.pro-card{min-width:0;display:flex;flex-direction:column;transition:transform .28s ease,box-shadow .28s ease;border:1px solid var(--pro-border);background:var(--pro-background);overflow:hidden}.pro-card:hover{transform:translateY(-6px);box-shadow:var(--sf-shadow-hover)}.pro-card-cover{aspect-ratio:1.42;overflow:hidden;background:var(--pro-surface);position:relative}.pro-card-cover .pro-photo{width:100%;height:100%;object-fit:cover;transition:transform .45s ease}.pro-card:hover .pro-photo{transform:scale(1.045)}.pro-card-body{display:flex;flex-direction:column;flex:1}.pro-card-body h3{font-size:clamp(19px,1.6vw,24px);line-height:1.26}.pro-card-body p{font-size:14px}.pro-card-link{margin-top:auto;padding-top:14px;font-size:13px;font-weight:700}
|
||||
.pro-art{min-height:100%;width:100%;position:relative;overflow:hidden;display:grid;place-items:center;isolation:isolate}.pro-art-ring{width:60%;aspect-ratio:1;border:1px solid currentColor;opacity:.13;border-radius:50%;transform:rotate(20deg);box-shadow:0 0 0 23px #ffffff10,0 0 0 57px #ffffff07}.pro-art-center{font-size:clamp(38px,5vw,80px);position:absolute;font-weight:800;letter-spacing:-.065em;opacity:.65}.pro-art-label{position:absolute;bottom:19px;left:22px;font-size:10px;letter-spacing:.22em;opacity:.7}
|
||||
.pro-breadcrumb{display:flex;gap:11px;flex-wrap:wrap;font-size:12px;align-items:center}.pro-breadcrumb a{text-decoration:underline;text-underline-offset:4px}.pro-product-hero{align-items:start;gap:clamp(28px,5vw,80px)}.pro-product-media{aspect-ratio:1.12;background:var(--pro-surface);overflow:hidden}.pro-product-media>img,.pro-product-media>.pro-art{width:100%;height:100%;object-fit:contain}.pro-product-summary{padding:clamp(12px,3vw,50px) 0}.pro-product-summary h1{font-size:clamp(36px,4.3vw,72px)}.pro-detail-layout{border-top:1px solid var(--pro-border);padding-top:50px}.pro-specs>div{display:grid;grid-template-columns:minmax(100px,40%) 1fr;gap:10px;align-items:baseline;padding:15px 0;border-bottom:1px solid var(--pro-border)}.pro-specs strong{overflow-wrap:anywhere}.pro-gallery-thumbs button{flex-basis:96px;height:76px}.pro-gallery-lightbox{backdrop-filter:blur(6px)}
|
||||
.pro-filter-panel{display:flex;align-items:end;flex-wrap:wrap;gap:16px;background:var(--pro-surface);border:1px solid var(--pro-border);padding:20px;margin-bottom:25px;border-radius:var(--pro-radius)}.pro-filter-panel label{display:grid;gap:7px;min-width:180px;flex:1}.pro-filter-panel input,.pro-filter-panel select{width:100%;background:var(--pro-background);border:1px solid var(--pro-border);padding:12px 14px;color:var(--pro-text);border-radius:6px}.pro-filter-count{white-space:nowrap;font-size:13px;padding:12px 0}.pro-card[hidden]{display:none!important}.pro-filter-empty{padding:38px;border:1px dashed var(--pro-border);text-align:center;grid-column:1/-1}
|
||||
.pro-inquiry input,.pro-inquiry textarea{min-height:46px}.pro-inquiry textarea{min-height:150px}.pro-inquiry-wrap{gap:clamp(32px,7vw,110px)}.pro-inquiry .pro-button{border:0;justify-self:start}.pro-footer-links a{padding-block:5px;display:inline-block}
|
||||
.pro-end-cta{overflow:hidden}.pro-end-cta h2{font-size:clamp(30px,4vw,64px)}
|
||||
.pro-rich{line-height:1.85}.pro-rich img{border-radius:var(--pro-radius);margin-block:24px}.pro-rich table{width:100%;display:block;overflow:auto;border-collapse:collapse}.pro-rich td,.pro-rich th{border:1px solid var(--pro-border);padding:10px 14px}.pro-rich a{text-decoration:underline;text-underline-offset:3px}
|
||||
@media(min-width:1025px){.pro-navigation>.pro-nav-group:hover .pro-nav-children:not([hidden]){visibility:visible}}
|
||||
@media(max-width:1150px){.pro-head-inner{gap:15px}.pro-navigation{gap:12px}.pro-logo-name{font-size:15px}.pro-header-cta{padding:13px 16px}}
|
||||
@media(max-width:1024px){.pro-head-inner{min-height:72px;flex-wrap:wrap}.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto}.pro-navigation{display:none;width:100%;flex-basis:100%;flex-direction:column;align-items:stretch;gap:0;order:5;margin:0;padding:8px 0 20px;border-top:1px solid var(--pro-border)}.pro-navigation.is-open{display:flex}.pro-navigation>a,.pro-nav-group>a{padding:14px 8px;font-size:15px}.pro-nav-group{display:flex;flex-wrap:wrap;justify-content:space-between}.pro-nav-children{position:static;flex-basis:100%;max-width:none;width:100%;box-shadow:none;border-radius:0;border-left:2px solid var(--pro-accent);margin:0 0 12px 8px}.pro-nav-disclose{min-width:44px;min-height:44px}.pro-catalog-toggle{margin-left:auto}.pro-mega{top:100%;display:block;padding:20px}.pro-mega>div{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:12px}.pro-home-hero .pro-home-grid{min-height:560px}.pro-home-hero .pro-hero-art{min-height:325px}.pro-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:700px){.pro-wrap{padding-left:20px;padding-right:20px}.pro-utility{display:none}.pro-head-inner{min-height:66px}.pro-logo-name{max-width:140px;font-size:14px}.pro-brand-logo{max-width:125px}.pro-header-cta{display:none}.pro-catalog-toggle{font-size:11px;padding-inline:7px;margin-left:0}.pro-home-hero .pro-home-grid{display:flex;flex-direction:column;gap:36px;padding-top:48px;padding-bottom:48px}.pro-home-hero .pro-hero-copy h1{font-size:clamp(43px,10vw,68px)}.pro-home-hero .pro-hero-copy p{font-size:15px}.pro-home-hero .pro-hero-art{width:100%;min-height:290px}.pro-home-hero .pro-hero-bottom{display:none}.pro-section{padding-top:68px!important;padding-bottom:68px!important}.pro-section-head{align-items:flex-start}.pro-grid{grid-template-columns:1fr;gap:18px}.pro-card-cover{aspect-ratio:1.45}.pro-product-hero,.pro-detail-layout,.pro-info-grid,.pro-inquiry-wrap{display:grid;grid-template-columns:1fr;gap:30px}.pro-product-summary{padding:0}.pro-specs>div{grid-template-columns:1fr 1fr}.pro-mega{top:100%;max-height:65vh;overflow-y:auto}.pro-mega>div{grid-template-columns:1fr}.pro-filter-panel{display:grid;grid-template-columns:1fr;gap:12px}.pro-filter-panel label{min-width:0}.pro-footer-top{display:grid;grid-template-columns:1fr;gap:27px}.pro-footer-bottom{gap:12px;flex-wrap:wrap}}
|
||||
@media(max-width:410px){.pro-logo-name{max-width:115px}.pro-logo-symbol{font-size:28px}.pro-catalog-toggle{font-size:10px}.pro-mobile-toggle{min-width:42px}}
|
||||
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
||||
|
||||
/* Accessible static catalogue pagination for large inventories. */
|
||||
.pro-pages-nav{display:flex;gap:1rem;justify-content:center;align-items:center;margin:2rem 0;flex-wrap:wrap}
|
||||
.pro-pages-nav a{padding:.7rem 1.1rem;border:1px solid currentColor;border-radius:.4rem;text-decoration:none}
|
||||
.pro-pages-nav a:focus-visible{outline:3px solid var(--accent,#2563eb);outline-offset:3px}
|
||||
.pro-pages-nav span{font-size:.875rem}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,155 @@
|
||||
'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);
|
||||
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='未保存更改';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=>`<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}/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;recordSnapshot();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><div class="prop"><label>平板每行列数 <input type="number" min="1" max="3" value="${html(b.tablet_columns||2)}" onchange="Studio.setDeviceColumns('tablet',this.value)"></label><label>手机每行列数 <input type="number" min="1" max="2" value="${html(b.mobile_columns||1)}" onchange="Studio.setDeviceColumns('mobile',this.value)"></label></div>`;
|
||||
b.columns.forEach((column,ci)=>{
|
||||
out+=`<div class="layout-editor-col" ondragover="event.preventDefault()" ondrop="Studio.dropChild(event,${ci})"><div class="layout-editor-title"><strong>第${ci+1}列</strong><label>宽度 <input type="number" min="0.5" max="4" step="0.5" value="${html(column.width)}" onchange="Studio.setColumnWidth(${ci},this.value)"></label><button onclick="Studio.removeColumn(${ci})">删除列</button></div>`;
|
||||
out+=`<div class="prop"><label>平板排序 <input type="number" min="0" max="20" value="${html(column.tablet_order??(ci+1))}" onchange="Studio.setColumnDevice(${ci},'tablet_order',Number(this.value))"></label><label>手机排序 <input type="number" min="0" max="20" value="${html(column.mobile_order??(ci+1))}" onchange="Studio.setColumnDevice(${ci},'mobile_order',Number(this.value))"></label><label>平板跨列 <input type="number" min="1" max="${html(b.tablet_columns||2)}" value="${html(column.tablet_span||1)}" onchange="Studio.setColumnDevice(${ci},'tablet_span',Number(this.value))"></label><label>手机跨列 <input type="number" min="1" max="${html(b.mobile_columns||1)}" value="${html(column.mobile_span||1)}" onchange="Studio.setColumnDevice(${ci},'mobile_span',Number(this.value))"></label><label><input type="checkbox" ${column.hide_tablet?'checked':''} onchange="Studio.setColumnDevice(${ci},'hide_tablet',this.checked)"> 平板隐藏此列</label><label><input type="checkbox" ${column.hide_mobile?'checked':''} onchange="Studio.setColumnDevice(${ci},'hide_mobile',this.checked)"> 手机隐藏此列</label></div>`;
|
||||
column.blocks.forEach((child,j)=>{out+=`<div class="layout-child" draggable="true" ondragstart="Studio.dragChild(event,${ci},${j})"><strong>${html(schema.blocks[child.type]?.name||child.type)}</strong><button onclick="Studio.removeChild(${ci},${j})" title="删除">×</button>`;
|
||||
for(const key of (schema.blocks[child.type]?.fields||[]).filter(x=>['title','subtitle','description','image','body','href','button'].includes(x))){out+=`<label>${html(key)}<input value="${html(child[key]??'')}" onchange="Studio.childField(${ci},${j},'${key}',this.value)"></label>`}
|
||||
if(b.columns.length>1){out+=`<select onchange="Studio.moveChild(${ci},${j},Number(this.value))"><option value="">移动到其他列</option>${b.columns.map((c,ki)=>ki===ci?'':`<option value="${ki}">第${ki+1}列</option>`).join('')}</select>`}
|
||||
out+='</div>';
|
||||
});
|
||||
out+=`<div class="layout-child-toolbar">${['richtext','image_text','collection','cta','features'].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><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>`;
|
||||
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;
|
||||
},
|
||||
undo(){restoreSnapshot(snapshotCursor-1)},
|
||||
redo(){restoreSnapshot(snapshotCursor+1)},
|
||||
modified(){recordSnapshot();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,site_design:siteDesignPreview}));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{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/preset?page_template=${encodeURIComponent(kind)}`);blocks=result.blocks;selected=blocks.length?0:-1;Studio.modified();Studio.draw();toast('已加载预设到草稿,记得保存')}catch(e){toast(e.message,true)}},
|
||||
async save(){try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft`,req('PUT',{blocks,page_template:kind,layout_mode:layoutMode,expected_revision:draftRevision}));draftRevision=result.revision;dirty=false;$('#saveStatus').textContent='草稿 v'+draftRevision;$('#saveStatus').classList.remove('dirty');toast('设计草稿已保存,正式构建不受影响')}catch(e){toast('草稿保存失败:'+e.message,true)}},
|
||||
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='已发布到内容库';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);
|
||||
$('#siteDesignStatus').textContent=result.state==='draft'?`待发布的全站草稿 v${siteDesignRevision}${result.conflict?' · 网站已被其他人修改':''}`:'当前为已发布设计';
|
||||
$('#siteDesignModal').showModal();
|
||||
}catch(e){toast('全站设计读取失败:'+e.message,true)}},
|
||||
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('导航必须是数组');
|
||||
return {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-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('已修改文字,点击保存页面写入数据库');
|
||||
});
|
||||
|
||||
window.addEventListener('keydown',event=>{if(!(event.ctrlKey||event.metaKey)||event.altKey)return;const el=document.activeElement;if(el&&['INPUT','TEXTAREA'].includes(el.tagName))return;if(event.key.toLowerCase()==='z'){event.preventDefault();event.shiftKey?Studio.redo():Studio.undo()}else if(event.key.toLowerCase()==='y'){event.preventDefault();Studio.redo()}});
|
||||
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
// Enterprise pages extend the existing reliable Docker Web console without requiring a CDN.
|
||||
tabs.push(['design-system','设计规范'],['migration-qa','迁移验收'],['operations','任务 / 版本'],['permissions','成员 / 审计']);
|
||||
const sfEnterpriseApi=async (path,method='GET',data)=>api(path,method==='GET'?{}:json(method,data));
|
||||
const sfRoot='/api/enterprise';
|
||||
const _oldRenderTab=App.renderTab.bind(App);
|
||||
App.renderTab=function(){
|
||||
if(!['design-system','migration-qa','operations','permissions'].includes(state.tab))return _oldRenderTab();
|
||||
const el=document.getElementById('tab-content');
|
||||
el.innerHTML='<div class="panel"><p class="muted">正在读取站点工作数据...</p></div>';
|
||||
if(state.tab==='design-system')EnterpriseUI.design().catch(onError);
|
||||
if(state.tab==='migration-qa')EnterpriseUI.migration().catch(onError);
|
||||
if(state.tab==='operations')EnterpriseUI.operations().catch(onError);
|
||||
if(state.tab==='permissions')EnterpriseUI.permissions().catch(onError);
|
||||
};
|
||||
const EnterpriseUI={
|
||||
section(name,caption='',actions=''){return `<div class="panel-title"><div><h3>${E(name)}</h3><p>${E(caption)}</p></div>${actions}</div>`},
|
||||
async design(){
|
||||
const design=await sfEnterpriseApi(`/api/sites/${state.siteId}/site-design`);const x=design.config.tokens;
|
||||
const fields=[['font','全站字体','select',['system','serif','modern']],['heading_scale','标题比例','number'],['radius','圆角半径(px)','number'],['section_spacing','板块上下间距(px)','number'],['container_width','内容最大宽度(px)','number'],['button_style','按钮形状','select',['rounded','pill','square']],['header_style','导航密度','select',['standard','compact']]];
|
||||
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('全站设计规范','同一个主题的不同网站可拥有完全不同的字体、间距、圆角和排版节奏。')}<form id="tokensForm" onsubmit="EnterpriseUI.saveDesign(event)"><div class="setting-grid">${fields.map(([k,name,type,options])=>`<label>${E(name)}${type==='select'?`<select name="${k}">${options.map(o=>`<option ${o===x[k]?'selected':''} value="${o}">${o}</option>`).join('')}</select>`:`<input type="number" name="${k}" step="${k==='heading_scale'?'0.05':'1'}" value="${ATTR(x[k])}">`}</label>`).join('')}</div><div class="panel-footer"><button class="btn primary" type="submit">保存设计规范</button></div></form></div>`;
|
||||
},
|
||||
async saveDesign(event){event.preventDefault();try{
|
||||
const raw=Object.fromEntries(new FormData(event.target));
|
||||
for(const k of ['heading_scale','radius','section_spacing','container_width'])raw[k]=Number(raw[k]);
|
||||
const path=`/api/sites/${state.siteId}/site-design`;const design=await sfEnterpriseApi(path);design.config.tokens=raw;await sfEnterpriseApi(path,'PUT',{config:design.config,expected_revision:design.revision});
|
||||
notice('设计规范已保存为草稿;进入可视化编辑器的全站设计面板发布后才能用于构建');
|
||||
}catch(err){onError(err)}},
|
||||
async migration(){
|
||||
const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/baseline/report`);
|
||||
const badge=r.errors?`<span class="pill">${r.errors} 项 URL 错误</span>`:'<span class="pill green">无 URL 缺失</span>';
|
||||
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('旧站 URL / SEO 基线验收','迁移模式发布前必须建立旧站基线,杜绝静默漏页。',`<button class="btn light small" onclick="EnterpriseUI.snapshot()">从当前内容建立基线</button>`)}<div class="metrics-grid"><div class="metric"><small>旧 URL 清单</small><strong>${r.baseline_count}</strong></div><div class="metric"><small>当前页面</small><strong>${r.current_count}</strong></div><div class="metric"><small>URL 缺失</small><strong>${r.errors}</strong></div><div class="metric"><small>SEO 差异</small><strong>${r.warnings}</strong></div></div><div class="panel-footer" style="justify-content:space-between">${badge}<a class="btn light" href="${sfRoot}/sites/${state.siteId}/baseline/export.csv">下载旧站 URL 清单</a></div><p class="muted" style="margin-top:15px">注意:最可靠的做法是在导入旧站后、换模板和调整内容前建立基线。也可调用 API 批量导入旧站已知 URL 清单。</p><div style="max-height:420px;overflow:auto">${r.issues.map(x=>`<div class="tip ${x.severity==='error'?'warning':''}"><strong>${E(x.route)} · ${E(x.field)}</strong><div style="overflow-wrap:anywhere">${E(x.from)} → ${E(x.to)}</div></div>`).join('')||'<div class="tip">暂无 URL 或 SEO 差异。</div>'}</div></div>`;
|
||||
},
|
||||
async snapshot(){if(!confirm('这会用当前站点的 URL 与 SEO 覆盖旧基线。推荐在旧站导入完成后、进行改版前操作。是否继续?'))return;
|
||||
try{const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/baseline/snapshot`,'POST',{});notice(`已记录 ${r.captured} 个原始页面`);await this.migration()}catch(err){onError(err)}},
|
||||
async operations(){
|
||||
const jobs=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs`);
|
||||
const releases=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/releases`);
|
||||
const builds=state.builds;
|
||||
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('异步构建任务','后台 Worker 负责执行耗时构建,关闭浏览器也不影响任务。',`<button class="btn primary small" onclick="EnterpriseUI.queueBuild()">+ 提交异步构建</button>`)}<table class="sf-table" style="width:100%"><thead><tr><th>任务 ID</th><th>状态</th><th>尝试次数</th><th>创建时间</th><th>操作</th></tr></thead><tbody>${jobs.map(j=>`<tr><td><code>${E(j.id)}</code></td><td>${E(j.state)}${j.cancel_requested?' (取消中)':''}</td><td>${j.attempts}/${j.max_attempts}</td><td>${simpleDate(j.created_at)}</td><td>${['queued','running','retrying'].includes(j.state)?`<button class="btn light tiny" onclick="EnterpriseUI.cancelJob('${j.id}')">取消</button>`:''}</td></tr>`).join('')||'<tr><td colspan="5">暂无任务</td></tr>'}</tbody></table></div><div class="panel" style="margin-top:22px">${this.section('构建版本与质量检测','检查 HTML / 站内链接 / 图片 ALT / SEO,再决定是否发布。')}<div class="setting-grid">${builds.slice(0,15).map(b=>`<div class="tip"><strong>${E(b.id)} · ${b.total_pages} 页</strong><p>${simpleDate(b.created_at)}</p><button class="btn light small" onclick="EnterpriseUI.quality('${b.id}')">质量检测</button> <button class="btn light small" onclick="EnterpriseUI.rollback('${b.id}')">发布 / 回滚至此版本</button></div>`).join('')||'<p>暂无构建,请先提交任务。</p>'}</div><div id="sf-quality-result"></div></div><div class="panel" style="margin-top:22px">${this.section('本地发布记录','发布记录保留全部构建版本,方便核对和恢复。')}${releases.slice(0,30).map(r=>`<p>${E(r.action)} · <code>${E(r.build_id)}</code> · ${simpleDate(r.created_at)}</p>`).join('')||'<p>暂无发布历史</p>'}</div>`;
|
||||
},
|
||||
async queueBuild(){try{const j=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs`,'POST',{kind:'build'});notice('构建任务已提交 '+j.id);await this.operations()}catch(err){onError(err)}},
|
||||
async cancelJob(id){if(!confirm('确认取消任务 '+id+'?'))return;try{await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs/${id}/cancel`,'POST',{});notice('已发送取消请求');await this.operations()}catch(err){onError(err)}},
|
||||
async quality(id){try{const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/builds/${id}/quality`);const el=document.getElementById('sf-quality-result');if(el)el.innerHTML=`<div class="tip ${r.errors?'warning':''}"><h4>检查结果 · ${r.files} 页 · ${r.errors} 错误 · ${r.warnings} 警告</h4>${r.issues.slice(0,60).map(i=>`<p><strong>${E(i.file)}</strong> ${E(i.type)} ${E(i.target||'')}</p>`).join('')||'通过基础静态质量检查'}</div>`}catch(err){onError(err)}},
|
||||
async rollback(id){if(!confirm('确认将此构建版本设为当前本地发布版本?迁移站发布前必须通过 URL 基线检查。'))return;
|
||||
try{const result=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/releases/${id}/rollback`,'POST',{});notice(result.published?'发布成功':'发布失败');await this.operations()}catch(err){onError(err)}},
|
||||
async permissions(){
|
||||
const logs=await sfEnterpriseApi(`${sfRoot}/audit?site_id=${state.siteId}&limit=50`);
|
||||
let members=[];try{members=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/members`)}catch{}
|
||||
let users=[];try{users=await sfEnterpriseApi(`${sfRoot}/team/users`)}catch{}
|
||||
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('网站团队与角色','Owner 可以管理成员,Editor 可以修改内容,Reviewer 和 Viewer 为只读角色。')}<div class="setting-grid">${members.map(m=>`<div class="tip"><strong>${E(m.display_name||m.username)}</strong><p>${E(m.username)} · ${E(m.role)}</p></div>`).join('')||'<p>尚未添加团队成员。系统管理员拥有全部权限。</p>'}</div>${users.length?`<form onsubmit="EnterpriseUI.assign(event)" class="setting-grid"><label>已有账号<select name="user_id">${users.map(u=>`<option value="${u.id}">${E(u.username)}</option>`).join('')}</select></label><label>角色<select name="role"><option>owner</option><option>editor</option><option>reviewer</option><option>viewer</option></select></label><div><button class="btn primary" type="submit">授予站点访问权限</button></div></form>`:''}</div><div class="panel" style="margin-top:22px">${this.section('操作审计日志','记录修改 API 的操作者、时间、路径与响应状态。')}<div style="max-height:450px;overflow:auto">${logs.map(l=>`<div class="tip"><strong>${E(l.actor)} · ${E(l.operation)} · ${l.status}</strong><p style="overflow-wrap:anywhere">${E(l.path)} · ${simpleDate(l.created_at)}</p></div>`).join('')||'<p>暂无操作日志</p>'}</div></div>`;
|
||||
},
|
||||
async assign(event){event.preventDefault();try{await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/members`,'PUT',Object.fromEntries(new FormData(event.target)));notice('权限已更新');await this.permissions()}catch(err){onError(err)}},
|
||||
async team(){try{const users=await sfEnterpriseApi(`${sfRoot}/team/users`);state.siteId=null;topTitle('团队账号');
|
||||
document.getElementById('view').innerHTML=hero('团队与权限','创建协作者,再为每个网站分配独立角色。')+`<div class="split"><div class="panel">${this.section('新增团队账号','至少12位密码,使用 scrypt 加盐哈希存储。')}<form onsubmit="EnterpriseUI.addUser(event)" class="setting-grid"><label>账号<input required name="username" placeholder="editor_01"></label><label>显示名称<input required name="display_name" placeholder="运营编辑"></label><label class="full">临时密码<input required type="password" name="password" minlength="12" autocomplete="new-password"></label><div><button class="btn primary">创建账号</button></div></form></div><div class="panel">${this.section('现有成员','站点权限可在各个网站的“成员 / 审计”中配置。')}${users.map(u=>`<div class="tip"><strong>${E(u.username)}</strong><p>${E(u.display_name)} · ${u.disabled?'停用':'启用'}</p></div>`).join('')||'<p>尚无协作者。</p>'}</div></div>`;
|
||||
}catch(err){onError(err)}},
|
||||
async addUser(event){event.preventDefault();try{await sfEnterpriseApi(`${sfRoot}/team/users`,'POST',Object.fromEntries(new FormData(event.target)));notice('团队账号创建成功');await this.team()}catch(err){onError(err)}}
|
||||
};
|
||||
window.EnterpriseUI=EnterpriseUI;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
const $=s=>document.querySelector(s), H=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const state={sites:[],themes:[],site:null,tab:'overview',csrf:'',importRows:[],fields:[],mapping:{},kind:'product'};
|
||||
const toast=(s,error=false)=>{let el=$('#alert');el.textContent=s;el.className='on';el.style.background=error?'#ffe6e6':'#e3f7ed';el.style.color=error?'#b13445':'#176e50';setTimeout(()=>el.className='',5500)};
|
||||
async function api(url,method='GET',data=null){const opt={method,credentials:'same-origin',headers:{}};if(!['GET','HEAD'].includes(method))opt.headers['X-SiteForge-CSRF']=state.csrf;
|
||||
if(data instanceof FormData)opt.body=data;else if(data!==null){opt.headers['Content-Type']='application/json';opt.body=JSON.stringify(data)}
|
||||
const r=await fetch(url,opt);if(r.status===401){location.href='/login';throw Error('请登录')};let body=(r.headers.get('content-type')||'').includes('json')?await r.json():await r.text();if(!r.ok)throw Error(body.detail||body);return body;}
|
||||
const siteUrl=path=>`/api/sites/${state.site.id}${path}`;
|
||||
const factoryUrl=path=>`/api/factory/sites/${state.site.id}${path}`;
|
||||
function selected(){if(!state.site){$('#panel').innerHTML='<div class="section"><h2>创建第一个网站</h2><p>选择主题后即可开始添加新内容,或导入旧网站的数据。</p><button class="primary" onclick="openCreate()">创建网站</button></div>';return false}return true;}
|
||||
function setTab(tab){state.tab=tab;$$('#navtabs button').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));render().catch(e=>toast(e.message,true))}
|
||||
function $$(s){return [...document.querySelectorAll(s)]}
|
||||
function setHeading(h,desc){$('#heading').textContent=h;$('#description').textContent=desc;$('#currentTab').textContent=h;}
|
||||
async function init(){try{state.csrf=(await api('/api/auth/me')).csrf;await refresh();document.querySelectorAll('#navtabs button').forEach(btn=>btn.addEventListener('click',()=>setTab(btn.dataset.tab)));
|
||||
$('#sitePicker').onchange=()=>{state.site=state.sites.find(s=>s.id===$('#sitePicker').value);render().catch(e=>toast(e.message,true))};$('#reload').onclick=refresh;$('#newSite').onclick=openCreate;$('#closeCreate').onclick=()=>$('#createDialog').close();$('#createForm').onsubmit=create;
|
||||
$('#buildQuick').onclick=()=>buildNow();await render()}catch(e){toast('初始化失败:'+e.message,true)}}
|
||||
async function refresh(){state.sites=await api('/api/sites');state.themes=await api('/api/factory/themes');let old=state.site?.id;state.site=state.sites.find(x=>x.id===old)||state.sites[0]||null;
|
||||
$('#sitePicker').innerHTML=state.sites.map(s=>`<option value="${H(s.id)}">${H(s.name)}</option>`).join('');if(state.site)$('#sitePicker').value=state.site.id;
|
||||
$('#newTheme').innerHTML=state.themes.map(t=>`<option value="${H(t.id||t.theme_id)}">${H(t.name)}</option>`).join('');}
|
||||
function openCreate(){$('#createDialog').showModal()}
|
||||
async function create(e){e.preventDefault();try{let d=Object.fromEntries(new FormData(e.target));let result=await api('/api/sites','POST',d);state.site=result;await api(`/api/factory/sites/${result.id}/bootstrap`,'POST',{});$('#createDialog').close();toast('新网站已创建并生成基础页面结构');await refresh();await render()}catch(err){toast(err.message,true)}}
|
||||
async function render(){$('#openEditor').href=state.site?`/editor/${state.site.id}`:'#';if(!selected())return;
|
||||
const maps={overview:['网站总览','查看生产进度、页面数量和建站方式'],themes:['主题与版本','安装主题、切换主题、固定版本以及批量套版'],content:['内容与自动装配','导入 CSV/JSON、自动识别字段、预览映射并建立新内容'],migration:['迁移与 SEO 验收','核对旧站 URL、媒体资源和 SEO 差异'],build:['构建与发布','生成独立 HTML 站点、检测问题、下载与版本管理']};setHeading(...maps[state.tab]);
|
||||
const fn={overview:overview,themes:themes,content:content,migration:migration,build:buildPanel}[state.tab];$('#panel').innerHTML='<div class="loading">加载中……</div>';await fn();}
|
||||
async function overview(){let [entries,builds,source]=await Promise.all([api(siteUrl('/entries')),api(siteUrl('/builds')),api(factoryUrl('/theme'))]);let s=state.site;
|
||||
$('#panel').innerHTML=`<div class="cards"><div class="card"><span class="overline">当前站点</span><strong>${H(s.name)}</strong><small>${H(s.mode==='migration'?'旧站迁移':'新站搭建')} · ${H(s.theme)}</small></div><div class="card"><span class="overline">内容总数</span><strong>${entries.length}</strong><small>页面/产品/品牌/文章</small></div><div class="card"><span class="overline">构建版本</span><strong>${builds.length}</strong><small>静态网站历史记录</small></div><div class="card"><span class="overline">主题版本</span><strong>${H(source.version)}</strong><small>已固定版本可追踪</small></div></div>
|
||||
<div class="section"><h2>网站工厂生产流程</h2><div class="steps"><div class="step">01<b>网站结构</b><small>首页、栏目页和导航</small></div><div class="step">02<b>内容自动装配</b><small>导入新数据或旧内容</small></div><div class="step">03<b>设计编辑</b><small>切换主题、可视化编辑</small></div><div class="step">04<b>静态站交付</b><small>构建、验收、发布</small></div></div></div>
|
||||
<div class="columns"><div class="section"><div class="toolbar"><h2>网站页面</h2><button class="outline" onclick="bootstrap()">补齐标准页面</button></div><div class="scroll"><table><thead><tr><th>页面/内容</th><th>类型</th><th>URL</th><th>操作</th></tr></thead><tbody>${entries.slice(0,25).map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.type)}</td><td>${H(x.route)}</td><td><a href="/editor/${H(s.id)}/${H(x.id)}">设计页面 ↗</a></td></tr>`).join('')}</tbody></table></div></div><div class="section"><h2>联系信息与表单服务</h2><p>邮箱会出现在主题的联系页与页脚。外部表单地址为空时,不显示假提交按钮。</p><label class="field">联系邮箱<input id="configEmail" type="email" value="${H(s.settings?.email||'')}"></label><label class="field">HTTPS 表单接收地址(可选)<input id="configForm" value="${H(s.settings?.form_endpoint||'')}"></label><label class="field">页脚说明<input id="configFooter" value="${H(s.settings?.footer_text||'')}"></label><button class="outline" onclick="saveContactSettings()">保存网站联系配置</button></div><div class="section"><h2>常用功能</h2><div class="stack"><button class="outline" onclick="setTab('themes')">切换完整主题 →</button><button class="outline" onclick="setTab('content')">导入内容 / 字段映射 →</button><button class="outline" onclick="setTab('migration')">检查 SEO 和旧站路径 →</button><button class="primary" onclick="buildNow()">生成网站 HTML →</button></div><p>当没有真实产品时,主题不会虚构业务数据;可先完善基础页面,再逐步导入。</p></div></div>`;}
|
||||
async function bootstrap(){try{let r=await api(factoryUrl('/bootstrap'),'POST',{});toast(`新增 ${r.created.length} 个基础页面;不会覆盖原 URL`);await render()}catch(e){toast(e.message,true)}}
|
||||
async function themes(){let cur=await api(factoryUrl('/theme'));const schemes={automotive:['#172840','#cfab79','MOVE BEYOND'],industrial:['#dcebf4','#14517d','BUILD FURTHER'],corporate:['#e7e9fb','#535bb4','CREATE IMPACT'],minimal:['#e9e5dc','#3d3b35','LESS IS MORE']};
|
||||
$('#panel').innerHTML=`<div class="section"><div class="toolbar"><h2>可安装、可版本化的主题包</h2><span class="badge">当前主题:${H(cur.theme)} · v${H(cur.version)}</span></div><div class="gridthemes">${state.themes.map(t=>{let id=t.id||t.theme_id,p=schemes[id]||['#e9eff8','#246c9e','YOUR BRAND'];return `<article class="theme-card"><div class="theme-visual" style="background:${p[0]};color:${p[1]}"><div class="mocknav"></div><div class="mocktitle">${H(p[2])}</div><div class="mockbtn"></div></div><div class="theme-info"><h3>${H(t.name)} <small class="muted">v${H(t.version)}</small></h3><p>${H(t.description)}</p><div class="actions"><button class="outline" onclick="chooseTheme('${H(id)}','${H(t.version)}')">${id===cur.theme?'重新应用':'应用主题'}</button><button class="outline" onclick="showVersions('${H(id)}')">版本管理</button><button class="outline" onclick="checkTheme('${H(id)}','${H(t.version)}')">兼容检查</button><a class="outline" target="_blank" href="/theme-gallery">全部页面预览</a><a class="outline" target="_blank" href="/api/factory/themes/${H(id)}/preview?version=${H(t.version)}">首页预览</a></div></div></article>`}).join('')}</div></div>
|
||||
<div class="columns"><div class="section"><h2>安装新主题 ZIP</h2><p>主题包由 manifest.json、theme.css、page-presets.json 组成。只能安装受控的页面组件和 CSS,不执行上传的 JavaScript 或模板逻辑。</p><label class="field">上传 ZIP 文件<input type="file" id="themePackage" accept=".zip"></label><button class="primary" onclick="uploadTheme()">安装并登记版本</button></div><div class="section"><h2>批量重装配现有内容</h2><p>会按页面类型给已有内容应用当前主题;保留现有 URL、SEO 和正文。选择「只补缺失」可避免覆盖已手工设计的区块。</p><button class="outline" onclick="applyTheme('missing')">仅装配缺失页面</button> <button class="outline" onclick="applyTheme('overwrite')">覆盖全部布局(谨慎)</button><div id="themeVersions" class="muted" style="margin-top:18px"></div></div></div>`;}
|
||||
async function chooseTheme(id,version){if(!confirm(`为当前网站选择主题 ${id} v${version}?将保留网站内容。`))return;try{await api(factoryUrl('/theme'),'PUT',{theme:id,version});await refresh();toast('主题切换完成。需要重新构建才能更新正式网站');await render()}catch(e){toast(e.message,true)}}
|
||||
async function showVersions(id){let list=await api(`/api/factory/themes/${id}/versions`);$('#themeVersions').textContent='已安装版本:'+list.map(x=>x.version).join(' / ')}
|
||||
async function checkTheme(id,version){try{const r=await api(`/api/factory/themes/${id}/compatibility?version=${encodeURIComponent(version)}`);$('#themeVersions').textContent=`${r.valid?'✓ 主题结构可用':'✗ 存在阻断'} · ${r.checked_page_types||0} 类页面 · ${[...(r.errors||[]),...(r.warnings||[])].join(';')||'无结构警告'}。正式交付前仍需实际浏览器验收。`;}catch(e){toast(e.message,true)}}
|
||||
async function uploadTheme(){let f=$('#themePackage').files?.[0];if(!f)return toast('请选择主题 ZIP',true);let data=new FormData();data.append('file',f);try{let r=await api('/api/factory/themes/install','POST',data);toast(`已安装:${r.name} v${r.version}`);await refresh();await render()}catch(e){toast(e.message,true)}}
|
||||
async function applyTheme(mode){if(mode==='overwrite'&&!confirm('覆盖已有页面设计(内容和 SEO 不变),继续吗?'))return;try{let r=await api(siteUrl('/apply-theme'),'POST',{mode});toast(`套版完成:更新 ${r.updated} 个页面`)}catch(e){toast(e.message,true)}}
|
||||
async function content(){let entries=await api(siteUrl('/entries'));$('#panel').innerHTML=`<div class="columns"><div class="section"><h2>新内容 / 旧内容导入装配</h2><p>上传 Excel、JSON 数组或 CSV;系统自动建议字段对应关系,例如「车型名称 → 标题」「品牌 → 产品品牌」。映射会经过预览与冲突检查。</p><label class="field">文件<input id="dataFile" type="file" accept=".csv,.json,.xlsx"></label><label class="field">导入内容类型<select id="mapKind"><option value="product">产品</option><option value="vehicle">车型</option><option value="brand">品牌</option><option value="post">新闻</option><option value="case">案例</option><option value="service">服务</option><option value="page">页面</option></select></label><button class="primary" onclick="readData()">读取文件并识别字段</button><div id="mappingUI"></div><div class="toolbar"><button class="outline" onclick="previewRelations()">预览产品-品牌关系</button><button class="outline" onclick="assembleRelations()">生成缺失的品牌页面</button></div><div id="relationReport"></div></div><div class="section"><h2>本站内容统计</h2>${['page','product','vehicle','brand','post','case','service'].map(t=>`<div class="row"><strong>${H(({page:'页面',product:'产品',vehicle:'车型',brand:'品牌',post:'新闻',case:'案例',service:'服务'})[t])}</strong><span class="badge">${entries.filter(e=>e.type===t).length}</span></div>`).join('')}<p>其他导入方式(WordPress XML/REST、Excel、媒体 ZIP)可以在原版站点管理工作台使用,使用相同的数据存储。</p><a class="outline" href="/">打开完整内容管理 →</a></div></div>`;}
|
||||
function parseCSV(text){let rows=[],row=[],cell='',quoted=false;for(let i=0;i<text.length;i++){let ch=text[i];if(ch==='"'){if(quoted&&text[i+1]==='"'){cell+='"';i++}else quoted=!quoted}else if(ch===','&&!quoted){row.push(cell);cell=''}else if((ch==='\n'||ch==='\r')&&!quoted){if(ch==='\r'&&text[i+1]==='\n')i++;row.push(cell);if(row.some(x=>x.trim()))rows.push(row);row=[];cell=''}else cell+=ch;}row.push(cell);if(row.some(x=>x.trim()))rows.push(row);let cols=rows.shift()?.map(s=>s.trim())||[];return rows.map(r=>Object.fromEntries(cols.map((c,i)=>[c,r[i]||''])))}
|
||||
async function readData(){let f=$('#dataFile').files?.[0];if(!f)return toast('请选择 Excel、CSV 或 JSON 文件',true);
|
||||
try{let data=new FormData();data.append('file',f);const parsed=await api(factoryUrl('/mapping/parse-file'),'POST',data);state.importRows=parsed.rows;state.fields=parsed.columns;state.mapping=parsed.suggested;drawMapping();}
|
||||
catch(e){toast(e.message,true)}}
|
||||
async function previewRelations(){try{const r=await api(factoryUrl('/relationships'));$('#relationReport').innerHTML=`<div class="card"><b>产品-品牌关联检查</b><p>已有品牌 ${r.brands.length} 个、分类 ${r.categories.length} 个;缺少独立品牌页 ${r.brand_pages_missing.length} 个</p><p>${H(r.brand_pages_missing.map(x=>x.brand).join(' / ')||'无')}</p>${r.issues.slice(0,10).map(x=>`<p>${H(x.route)} · ${H(x.message)}</p>`).join('')}</div>`;}catch(e){toast(e.message,true)}}
|
||||
async function assembleRelations(){if(!confirm('依据真实产品品牌字段创建缺少的品牌页面?不会自动生成虚构文案。'))return;try{const r=await api(factoryUrl('/relationships/assemble'),'POST',{});toast(`已创建 ${r.count} 个品牌页面`);await previewRelations()}catch(e){toast(e.message,true)}}
|
||||
|
||||
function drawMapping(){let fields=['title','route','summary','body','cover','brand','category','model','energy','range','gallery','specs','sku','price','seo_title','seo_description','canonical'];let labels={title:'标题(必填)',route:'旧站 URL',summary:'摘要',body:'详情正文',cover:'封面',brand:'品牌',category:'分类',seo_title:'SEO标题',seo_description:'SEO描述',canonical:'Canonical',model:'型号',energy:'动力形式',range:'续航',gallery:'图库(JSON数组或分号分隔)',specs:'技术参数',sku:'SKU',price:'价格'};
|
||||
$('#mappingUI').innerHTML=`<div style="margin-top:27px"><h2>字段映射(已读取 ${state.importRows.length} 行)</h2>${fields.map(t=>`<div class="rule"><strong>${H(labels[t])}</strong><select data-target="${t}" class="mapfield"><option value="">不映射</option>${state.fields.map(x=>`<option value="${H(x)}" ${state.mapping[t]===x?'selected':''}>${H(x)}</option>`).join('')}</select></div>`).join('')}<button class="outline" onclick="mappingPreview()">预览数据和错误</button><button class="primary" onclick="mappingCommit()">确认导入</button><div id="mappingReport" style="margin-top:16px"></div></div>`;}
|
||||
function gather(){let rules={};$$('.mapfield').forEach(x=>{if(x.value)rules[x.dataset.target]=x.value});return {rows:state.importRows,rules,content_type:$('#mapKind').value}}
|
||||
async function mappingPreview(){try{let r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:true});$('#mappingReport').innerHTML=`<div class="card"><b>预览:${r.mapped} 条可映射</b><p>错误 ${r.errors.length} 条,URL 冲突 ${r.conflicts.length} 条</p><div class="scroll"><table><thead><tr><th>标题</th><th>新 URL</th><th>内容类型</th></tr></thead><tbody>${r.sample.map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.route)}</td><td>${H(x.type)}</td></tr>`).join('')}</tbody></table></div>${r.errors.length?'<p class="error">'+H(JSON.stringify(r.errors.slice(0,6)))+'</p>':''}</div>`}catch(e){toast(e.message,true)}}
|
||||
async function mappingCommit(){if(!confirm('确认将已映射的数据写入当前网站?'))return;try{let r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:false,on_conflict:'skip'});toast(`新增 ${r.created} 条,跳过 ${r.skipped} 条,错误 ${r.errors.length} 条`);await render()}catch(e){toast(e.message,true)}}
|
||||
async function migration(){let [data,assets,builds]=await Promise.all([api(factoryUrl('/migration/readiness')),api(factoryUrl('/assets/inventory')),api(siteUrl('/builds'))]);
|
||||
let audit=builds.length?await api(`/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report`).catch(()=>null):null;const gate=data.gates||[];$('#panel').innerHTML=`<div class="cards"><div class="card"><span class="overline">旧站 URL 基线</span><strong>${data.baseline.baseline_count}</strong><small>应保持有效的历史路径</small></div><div class="card"><span class="overline">缺失 URL</span><strong>${data.baseline.errors}</strong><small>发布阻断项</small></div><div class="card"><span class="overline">SEO 差异</span><strong>${data.baseline.warnings}</strong><small>需要人工核查</small></div><div class="card"><span class="overline">资源引用</span><strong>${assets.references}</strong><small>实际已识别的内容图片</small></div></div><div class="columns"><div class="section"><h2>迁移验收门禁</h2>${gate.length?gate.map(g=>`<div class="row"><div><span class="badge ${g.level==='block'?'danger':'warn'}">${g.level==='block'?'阻止发布':'需人工复核'}</span><p>${H(g.message)}</p></div></div>`).join(''):'<p class="success">当前已检测范围内未发现阻断项。</p>'}<div class="toolbar"><label>上传原站清单(WXR / Sitemap XML / CSV / JSON)<input type="file" id="baselineFile" accept=".xml,.csv,.json"></label><button class="primary" onclick="uploadBaseline()">导入原站 URL 基线</button></div><div class="toolbar"><button class="outline" onclick="snapshot()">用当前内容建立 URL 基线(仅限新站)</button><a class="outline" href="${factoryUrl('/assets/inventory')}" target="_blank">资源清单 JSON</a><button class="outline" onclick="openLatestAudit()">构建后查看完整差异报告</button></div><div id="baselineImportResult"></div><p>提示:完整旧站 URL 基线应来自迁移前的原网站清单,不要用迁移后不完整的网站替代。</p></div><div class="section"><h2>实际生成 HTML 与旧站对比</h2>${audit?`<p>旧站 ${audit.baseline_count} 条 / 已匹配 ${audit.matched_pages||0} 条;阻断 ${audit.errors} 项;待复核 ${audit.unreviewed_warnings||0} 项</p><a class="outline" href="/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report.csv">下载差异 CSV</a><div class="scroll"><table><thead><tr><th>问题</th><th>路径</th><th>操作</th></tr></thead><tbody>${audit.issues.slice(0,40).map(i=>`<tr><td>${H(i.field)} · ${H(i.message)}</td><td>${H(i.route)}</td><td>${i.severity==='warning'&&!i.reviewed?`<button class="outline" onclick="approveAuditWarning('${H(builds[0].id)}','${H(i.issue_id)}')">复核接受</button>`:i.reviewed?'已复核':'必须修复'}</td></tr>`).join('')}</tbody></table></div>`:'<p>请先构建新站,再与旧站基线对比。</p>'}</div><div class="section"><h2>前 12 项差异</h2>${data.baseline.issues.slice(0,12).map(x=>`<div class="row"><div><strong>${H(x.route)}</strong><small>${H(x.field)} · ${H(x.severity)}</small></div></div>`).join('')||'<p>没有基线差异。</p>'}</div></div>`;}
|
||||
async function saveContactSettings(){try{await api(siteUrl('/contact-settings'),'PUT',{email:$('#configEmail').value,form_endpoint:$('#configForm').value,footer_text:$('#configFooter').value});toast('网站联系配置已保存');await refresh()}catch(e){toast(e.message,true)}}
|
||||
async function approveAuditWarning(bid,issue){const reason=prompt('填写核查原因及处理依据(至少10字,审核后对本构建生效):','');if(!reason)return;try{await api(`/api/audit/sites/${state.site.id}/builds/${bid}/review-warning`,'POST',{issue_id:issue,reason});toast('已记录审查决定');await render()}catch(e){toast(e.message,true)}}
|
||||
async function uploadBaseline(){const file=$('#baselineFile')?.files?.[0];if(!file)return toast('请选择原站清单文件',true);const data=new FormData();data.append('file',file);
|
||||
try{let r=await api(`/api/audit/sites/${state.site.id}/import-baseline`,'POST',data);if(r.errors?.length){$('#baselineImportResult').textContent=JSON.stringify(r.errors.slice(0,15));toast('源站清单存在错误,未写入基线',true);return}toast(`已导入 ${r.imported} 个旧站 URL`);await render()}catch(e){toast(e.message,true)}}
|
||||
async function openLatestAudit(){try{let builds=await api(siteUrl('/builds'));if(!builds.length)return toast('请先构建网站',true);window.open(`/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report`,'_blank','noopener')}catch(e){toast(e.message,true)}}
|
||||
async function snapshot(){if(!confirm('仅适用于尚未迁移的测试网站。真实迁移应导入旧站原始 URL 清单!继续吗?'))return;try{let r=await api(`/api/enterprise/sites/${state.site.id}/baseline/snapshot`,'POST',{});toast('已建立 '+r.captured+' 条当前内容基线');await render()}catch(e){toast(e.message,true)}}
|
||||
async function buildPanel(){let [builds,ready,releaseRequests]=await Promise.all([api(siteUrl('/builds')),api(factoryUrl('/migration/readiness')),api(factoryUrl('/release-requests'))]);$('#panel').innerHTML=`<div class="columns"><div class="section"><h2>静态构建版本</h2><div class="toolbar"><button class="primary" onclick="buildNow()">+ 构建新版本</button><span class="badge ${ready.can_publish?'':'danger'}">${ready.can_publish?'可进入发布审查':'发布前有阻断项'}</span></div><div class="scroll"><table><thead><tr><th>版本</th><th>页面数</th><th>生成时间</th><th>交付</th></tr></thead><tbody>${builds.slice(0,15).map(b=>`<tr><td>${H(b.id)}</td><td>${b.total_pages}</td><td>${H(b.created_at)}</td><td><a target="_blank" href="/preview/${state.site.id}/${b.id}/">预览</a> · <a href="/api/sites/${state.site.id}/builds/${b.id}/download">下载</a> · <button class="outline" onclick="requestRelease('${b.id}')">提交审批</button> · <button class="outline" onclick="publishRelease('${b.id}')">发布</button></td></tr>`).join('')}</tbody></table></div></div><div class="section"><h2>交付说明</h2><p>静态网站包含 HTML、CSS、JavaScript、独立搜索索引、站点地图和 Nginx 重定向示例配置。</p><p>通过实际发布前,需要校验 SEO 基线、静态资源和功能;当前本地发布仅适用于事先配置的发布目录,公网 DNS/CDN 连接器仍需另行开发。</p><div class="stack">${ready.gates.map(g=>`<div class="row"><span class="badge ${g.level==='block'?'danger':'warn'}">${H(g.level)}</span><p>${H(g.message)}</p></div>`).join('')}</div><h2 style="margin-top:24px">发布审批记录</h2>${releaseRequests.slice(0,8).map(r=>`<div class="row"><div><strong>${H(r.build_id)}</strong><small>${H(r.requested_by)} · ${H(r.state)}</small></div><div>${r.state==='pending'?`<button class="outline" onclick="reviewRelease('${r.id}','approved')">通过</button> <button class="outline" onclick="reviewRelease('${r.id}','rejected')">驳回</button>`:''}</div></div>`).join('')||'<p>暂无发布审批记录。</p>'}</div></div>`;}
|
||||
async function requestRelease(build){try{let r=await api(factoryUrl('/release-requests'),'POST',{build_id:build});toast('审批申请已提交:'+r.id);await render()}catch(e){toast(e.message,true)}}
|
||||
async function reviewRelease(id,status){try{let note=prompt('请输入审批说明(可留空)','')||'';await api(factoryUrl('/release-requests/'+id+'/review'),'POST',{state:status,note});toast('审批状态已更新');await render()}catch(e){toast(e.message,true)}}
|
||||
async function publishRelease(build){if(!confirm('确认将此版本发布到预先配置的本地持久化站点目录?'))return;try{let r=await api(siteUrl('/builds/'+build+'/publish-local'),'POST',{});toast('已发布:'+r.release);await render()}catch(e){toast(e.message,true)}}
|
||||
async function buildNow(){if(!state.site)return;try{
|
||||
toast('构建已提交后台队列……');const sid=state.site.id;
|
||||
const job=await api(siteUrl('/builds'),'POST',{mirror_images:false});
|
||||
toast('任务 '+job.id+' 排队中;关闭窗口不会中断');
|
||||
for(let i=0;i<240;i++){
|
||||
await new Promise(resolve=>setTimeout(resolve,1500));
|
||||
const status=await api(`/api/enterprise/sites/${sid}/jobs/${job.id}`);
|
||||
if(status.state==='done'){const r=JSON.parse(status.result_json||'{}');toast(`构建成功,${r.pages} 个页面`);await render();if(r.version)window.open(`/preview/${sid}/${r.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.addEventListener('DOMContentLoaded',init);
|
||||
window.requestRelease=requestRelease;window.reviewRelease=reviewRelease;window.publishRelease=publishRelease;window.bootstrap=bootstrap;window.chooseTheme=chooseTheme;window.checkTheme=checkTheme;window.showVersions=showVersions;window.uploadTheme=uploadTheme;window.applyTheme=applyTheme;window.readData=readData;window.uploadBaseline=uploadBaseline;window.approveAuditWarning=approveAuditWarning;window.saveContactSettings=saveContactSettings;window.openLatestAudit=openLatestAudit;window.previewRelations=previewRelations;window.assembleRelations=assembleRelations;window.mappingPreview=mappingPreview;window.mappingCommit=mappingCommit;window.snapshot=snapshot;window.buildNow=buildNow;window.setTab=setTab;window.openCreate=openCreate;
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SiteForge trusted global shell. Site themes remain visually independent. */
|
||||
:root{--sf-shell:#12243d;--sf-shell-accent:#dcae71;--sf-shell-soft:#edf2f8;--sf-shell-fg:#173049}
|
||||
html{scroll-behavior:smooth}body{overflow-x:hidden}body>.header,body>.utility,body>.top,body>header,body>footer{display:none!important}
|
||||
.sf-theme-industrial{--sf-shell:#114e79;--sf-shell-accent:#92d5ed;--sf-shell-soft:#ebf6fa;--sf-shell-fg:#0c4267}
|
||||
.sf-theme-corporate{--sf-shell:#232d62;--sf-shell-accent:#b8bdff;--sf-shell-soft:#f3f3fa;--sf-shell-fg:#333468}
|
||||
.sf-theme-minimal{--sf-shell:#1e1e1c;--sf-shell-accent:#ded6c5;--sf-shell-soft:#f6f4ef;--sf-shell-fg:#252520}
|
||||
.sf-site-head{position:relative;z-index:25;background:var(--sf-shell);color:#fff;font-family:system-ui,-apple-system,sans-serif}
|
||||
.sf-head-inner{max-width:1240px;margin:0 auto;min-height:78px;padding:0 28px;display:flex;align-items:center;gap:28px}
|
||||
.sf-logo{display:flex;align-items:center;gap:11px;font-size:18px;text-decoration:none!important;color:#fff!important;white-space:nowrap}
|
||||
.sf-logo-mark{width:36px;height:36px;display:grid;place-items:center;background:var(--sf-shell-accent);color:var(--sf-shell);font-size:22px;font-weight:900;border-radius:9px}
|
||||
.sf-primary-links{display:flex;gap:23px;margin-left:auto;align-items:center}
|
||||
.sf-primary-links a,.sf-mega a{color:#fff!important;text-decoration:none!important;font-size:13px;font-weight:650;white-space:nowrap}
|
||||
.sf-primary-links a:hover{color:var(--sf-shell-accent)!important}
|
||||
.sf-head-cta{font-size:13px;font-weight:750;text-decoration:none;background:var(--sf-shell-accent);color:var(--sf-shell)!important;padding:13px 18px;border-radius:4px;white-space:nowrap}
|
||||
.sf-menu-toggle{display:none;margin-left:auto;color:white;border:1px solid #ffffff55;background:none;border-radius:4px;padding:8px 12px;font-size:19px;cursor:pointer}
|
||||
.sf-mega{display:flex;gap:18px;max-width:1240px;margin:auto;padding:0 28px 9px;overflow-x:auto}
|
||||
.sf-mega a{display:block;min-width:120px;border-top:1px solid #ffffff2e;padding:12px 8px}.sf-mega small{display:block;color:#ffffff99;max-width:160px;overflow:hidden;text-overflow:ellipsis;font-size:10px;margin-top:6px}
|
||||
.sf-site-foot{background:var(--sf-shell);color:white!important;font:14px/1.7 system-ui,-apple-system,sans-serif;padding:72px 28px 26px}
|
||||
.sf-foot-grid{max-width:1200px;margin:auto;display:grid;grid-template-columns:2fr 1fr 1fr;gap:70px}.sf-foot-grid h2{font-size:25px;color:white;margin:0 0 17px}.sf-foot-grid h4{font-size:13px;color:var(--sf-shell-accent);letter-spacing:.07em;margin:0 0 17px}.sf-foot-grid p{color:#ffffffae;max-width:380px}.sf-foot-grid a{display:block;width:fit-content;color:#fff!important;text-decoration:none;margin-bottom:8px}.sf-foot-lower{border-top:1px solid #ffffff33;max-width:1200px;margin:45px auto 0;padding-top:20px;display:flex;justify-content:space-between;color:#ffffff9e;font-size:12px}
|
||||
.sf-filters{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin:0 0 30px}.sf-filters input,.sf-filters select{padding:11px 13px;border-radius:6px;border:1px solid #cfd9e4;background:#fff;color:#263a51;max-width:280px}.sf-filter-empty{padding:22px;color:#778598}
|
||||
.sf-search-page{min-height:76vh;max-width:1060px;padding:90px 24px;margin:auto;font-family:system-ui,sans-serif;color:var(--sf-shell-fg)}.sf-search-page h1{font-size:clamp(35px,5vw,62px);margin:20px 0}.sf-search-page>label{display:block;margin:18px 0 8px}.sf-search-page input{display:block;width:100%;padding:17px;font-size:18px;border:1px solid #b7c6d7;border-radius:8px}.sf-search-page #sf-search-count{margin:22px 0;color:#607589}.sf-search-result{padding:22px 0;border-bottom:1px solid #dae1ea}.sf-search-result a{color:var(--sf-shell-fg);font-size:21px;font-weight:750;text-decoration:none}.sf-search-result small{display:block;color:#7992a7}.sf-search-result p{margin:7px 0;color:#657487}
|
||||
@media(max-width:1050px){.sf-head-inner{gap:13px}.sf-primary-links{gap:12px}.sf-primary-links a{font-size:12px}.sf-head-cta{padding:10px}}
|
||||
@media(max-width:850px){.sf-head-inner{min-height:68px}.sf-menu-toggle{display:block}.sf-head-cta{display:none}.sf-primary-links{display:none;position:absolute;top:68px;left:0;right:0;margin:0;background:var(--sf-shell);padding:20px 28px;flex-direction:column;align-items:stretch;box-shadow:0 20px 24px #0002}.sf-primary-links.is-open{display:flex}.sf-mega{display:none}.sf-foot-grid{grid-template-columns:1fr 1fr;gap:35px}}
|
||||
@media(max-width:560px){.sf-foot-grid{grid-template-columns:1fr}.sf-foot-lower{display:block}.sf-site-foot{padding-top:48px}.sf-head-inner{padding:0 16px}}
|
||||
|
||||
.sf-page-intro{padding:80px 28px;background:var(--sf-shell-soft);color:var(--sf-shell-fg);font-family:system-ui,sans-serif}.sf-page-intro>div{max-width:1200px;margin:auto}.sf-page-intro p{font-size:11px;font-weight:850;letter-spacing:.19em;text-transform:uppercase;color:var(--sf-shell-fg);opacity:.58}.sf-page-intro h1{font-size:clamp(38px,5vw,67px);letter-spacing:-.04em;line-height:1.08;margin:13px 0}.sf-page-intro span{font-size:16px;opacity:.72}
|
||||
.sf-item[hidden]{display:none!important}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1 @@
|
||||
:root{color-scheme:light;--ink:#142237;--muted:#68778b;--stroke:#e4e8f0;--blue:#3263c9;--bg:#f5f7fb}*{box-sizing:border-box}body{margin:0;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;color:var(--ink);background:var(--bg)}a{color:inherit;text-decoration:none}.layout{min-height:100vh;display:grid;grid-template-columns:240px minmax(0,1fr)}aside{background:#15233b;color:#dbe7fb;padding:24px 15px;display:flex;flex-direction:column;gap:7px}aside>a:not(.wordmark){padding:13px 14px;border-radius:9px;font-size:13px}aside>a.active,aside>a:not(.wordmark):hover{background:#2a4569}.wordmark{display:flex;gap:11px;align-items:center;padding:8px 12px 30px}.wordmark b{display:grid;place-items:center;width:39px;height:39px;border-radius:10px;background:#3b6ee8;font-size:22px}.wordmark strong{display:block;font-size:18px}.wordmark small{font-size:10px;letter-spacing:1px;color:#93a9c8}.eyebrow{font-size:10px;letter-spacing:2px;color:#9eafd0;padding:0 14px}.sidefoot{margin-top:auto;border-top:1px solid #344761;padding:20px 12px;font-size:12px;color:#8ca0bf}main{padding:32px;max-width:1640px;width:100%;margin:0 auto}header{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:25px}header h1{font-size:30px;letter-spacing:-.7px;margin:3px 0 4px}header p,.muted{color:var(--muted);margin:0}.crumb{color:var(--blue);font-size:12px;font-weight:700}.right{display:flex;align-items:end;gap:9px}.right label{font-size:12px;color:var(--muted)}.right select{display:block;min-width:230px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.metrics>div,section{border:1px solid var(--stroke);border-radius:14px;background:#fff;box-shadow:0 4px 18px #172c5008}.metrics>div{padding:19px 21px}.metrics span{display:block;color:var(--muted);font-size:12px}.metrics strong{font-size:28px;margin-top:6px;display:block}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin-bottom:18px}section{padding:24px}.section-head{display:flex;justify-content:space-between;align-items:baseline;gap:10px;border-bottom:1px solid var(--stroke);padding-bottom:14px;margin-bottom:17px}h2{font-size:17px;margin:0}.section-head small{color:#8494a9}h3{font-size:14px;margin:24px 0 9px}input,select,button{font:inherit}input[type=file],select{width:100%;border:1px solid #dce3ed;border-radius:8px;background:#fff;padding:10px;color:var(--ink);margin-top:5px;min-height:41px}label{display:block;font-size:12px;color:#53637a}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.actions{display:flex;gap:9px;margin:17px 0;flex-wrap:wrap}button,.link{border:0;background:var(--blue);color:#fff;border-radius:8px;padding:10px 15px;font-weight:600;cursor:pointer;display:inline-block}.secondary,button.secondary{color:#253952;background:#edf2fa;border:1px solid #dce6f3}button:disabled{opacity:.5;cursor:not-allowed}button:hover:not(:disabled){filter:brightness(.95)}.report,.job-list{border:1px solid #e9edf4;background:#f9fbfe;border-radius:9px;padding:13px;color:#51627b;font-size:12px;overflow-wrap:anywhere;min-height:60px}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 0;border-bottom:1px solid #e5ebf4}.row:last-child{border-bottom:0}.row b{display:block;color:var(--ink)}.row small{color:#8493a8}.row button{font-size:11px;padding:6px 8px}.flag{display:inline-block;border-radius:6px;padding:2px 6px;background:#e1ebfe;color:#2b5cac}.flag.warn{background:#fff2da;color:#936006}#notice{min-height:0;font-weight:600;color:#1f6947;padding:0 0 12px}footer{color:#8c9bae;font-size:12px;padding:16px 0}@media(max-width:1120px){.two{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:760px){.layout{display:block}aside{padding:10px;flex-direction:row;flex-wrap:wrap}.wordmark{padding:5px}.eyebrow,.sidefoot{display:none}main{padding:18px}header{align-items:stretch;flex-direction:column}.right,.fields{grid-template-columns:1fr}.fields{display:grid}.metrics{gap:9px}section{padding:18px}}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
const $=s=>document.querySelector(s), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
let csrf='',sites=[],siteId='',jobs=[],builds=[],releases=[],lastPreview=null;
|
||||
const notify=(message,error=false)=>{$('#notice').textContent=message;$('#notice').style.color=error?'#ac3d45':'#256448'};
|
||||
async function request(url,{method='GET',body=null,upload=false}={}){
|
||||
const headers={};if(method!=='GET')headers['X-SiteForge-CSRF']=csrf;
|
||||
if(body&&!upload)headers['Content-Type']='application/json';
|
||||
const res=await fetch(url,{method,credentials:'same-origin',headers,body:body?(upload?body:JSON.stringify(body)):undefined});
|
||||
if(res.status===401){location.assign('/login');throw Error('登录已过期')}
|
||||
const result=await res.json();if(!res.ok)throw Error(result.detail||'请求失败');return result;
|
||||
}
|
||||
const post=(url,body)=>request(url,{method:'POST',body});
|
||||
function formatJobs(list){return list.length?list.map(j=>`<div class="row"><div><b>${esc(j.kind||'build')} <span class="flag">${esc(j.state)}</span></b><small>${esc(j.id)} · 尝试 ${esc(j.attempts||0)} 次</small></div>${['queued','running','retrying'].includes(j.state)?`<button class="secondary" onclick="cancelJob('${esc(j.id)}')">取消</button>`:''}</div>`).join(''):'当前没有任务';}
|
||||
async function chooseSite(){siteId=$('#site').value;lastPreview=null;$('#commitImport').disabled=true;await reload()}
|
||||
async function init(){try{csrf=(await request('/api/auth/me')).csrf;sites=await request('/api/sites');$('#site').innerHTML=sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');siteId=sites[0]?.id||'';$('#site').onchange=chooseSite;$('#reload').onclick=reload;$('#previewImport').onclick=()=>importJSON(false);$('#commitImport').onclick=()=>importJSON(true);$('#build').onclick=buildSite;$('#deploy').onclick=deploySite;$('#refreshRelease').onclick=reload;await reload()}catch(e){notify(e.message,true)}}
|
||||
async function reload(){if(!siteId)return;try{
|
||||
const [list,jobsData,buildData,approvals,history,reviewData]=await Promise.all([
|
||||
request(`/api/sites/${siteId}`),request(`/api/enterprise/sites/${siteId}/jobs`),request(`/api/sites/${siteId}/builds`),request(`/api/factory/sites/${siteId}/release-requests`),request(`/api/enterprise/sites/${siteId}/releases`),request(`/api/migrations/sites/${siteId}/reviews`)]);
|
||||
jobs=jobsData;builds=buildData;releases=history;$('#statBuild').textContent=builds.length;$('#statJobs').textContent=jobs.filter(j=>['queued','running','retrying'].includes(j.state)).length;$('#statApprovals').textContent=approvals.filter(j=>j.state==='pending').length;$('#statMode').textContent=list.mode==='migration'?'迁移':'新建';$('#jobList').innerHTML=formatJobs(jobs);
|
||||
$('#buildList').innerHTML=builds.length?builds.slice(0,14).map(b=>`<div class="row"><div><b>${esc(b.id)}</b><small>${esc(b.created_at)} · ${esc(b.total_pages)} 页</small></div><button class="secondary" onclick="requestApproval('${esc(b.id)}')">提交审批</button></div>`).join(''):'当前没有构建';
|
||||
$('#releaseBuild').innerHTML=builds.map(b=>`<option value="${esc(b.id)}">${esc(b.id)} · ${esc(b.total_pages)} 页</option>`).join('');
|
||||
$('#approvals').innerHTML=approvals.length?approvals.slice(0,12).map(a=>`<div class="row"><div><b>${esc(a.build_id)} <span class="flag ${a.state==='pending'?'warn':''}">${esc(a.state)}</span></b><small>由 ${esc(a.requested_by)} 提交</small></div>${a.state==='pending'?`<button class="secondary" onclick="approve('${esc(a.id)}')">批准</button>`:''}</div>`).join(''):'暂无审批记录';
|
||||
$('#reviews').innerHTML=reviewData.items.length?reviewData.items.map(x=>`<div class="row"><div><b>${esc(x.platform)} · 第 ${esc(x.row_number)} 条</b><small>${esc(x.issue)}</small></div><button class="secondary" onclick="resolveReview('${esc(x.id)}')">已检查并处理</button></div>`).join(''):'暂无待复核事项';
|
||||
$('#releases').innerHTML=history.length?history.slice(0,10).map(x=>`<div class="row"><div><b>${esc(x.build_id)} · ${esc(x.action)}</b><small>${esc(x.created_at)}</small></div></div>`).join(''):'暂无部署记录';
|
||||
const entries=await request(`/api/sites/${siteId}/entries`);const entry=entries.find(p=>p.route==='/')||entries[0];$('#edit').href=entry?`/editor/${siteId}/${entry.id}`:'/factory-studio';
|
||||
}catch(e){notify(e.message,true)}}
|
||||
async function importJSON(commit){const file=$('#file').files?.[0];if(!file)return notify('请选择 JSON 文件',true);if(!siteId)return;const platform=$('#platform').value;
|
||||
if(commit&&!lastPreview)return;const data=new FormData();data.append('platform',platform);data.append('file',file);if(commit)data.append('conflict','skip');
|
||||
try{const result=await request(`/api/migrations/sites/${siteId}/${commit?'import':'preview'}`,{method:'POST',body:data,upload:true});if(commit){notify(`导入完成:${result.result.imported} 条,人工复核 ${result.migration_warnings.length} 项`);lastPreview=null;$('#commitImport').disabled=true;await reload()}else{lastPreview=result;$('#commitImport').disabled=result.errors.length>0;$('#report').innerHTML=`<b>输入 ${result.input} 条 · 可映射 ${result.mapped} 条 · 错误 ${result.errors.length} 条 · 复核项 ${result.warnings.length} 条</b><div>${result.errors.slice(0,12).map(x=>`<p>第${x.row}条:${esc(x.error)}</p>`).join('')}${result.warnings.slice(0,12).map(x=>`<p>待复核 第${x.row}条:${esc(x.issue)}</p>`).join('')}</div>`}}
|
||||
catch(e){notify(e.message,true)}}
|
||||
async function resolveReview(id){
|
||||
const answer=prompt('请详细填写对照旧站完成检查/修复的说明(至少12个字符)。未经核查不要批准。');
|
||||
if(answer===null)return;
|
||||
try{await post(`/api/migrations/sites/${siteId}/reviews/${id}/resolve`,{resolution:answer});notify('复核记录已保存');await reload()}catch(e){notify(e.message,true)}
|
||||
}
|
||||
async function buildSite(){try{const task=await post(`/api/sites/${siteId}/builds`,{mirror_images:false});notify(`任务 ${task.id} 已进入 Worker 队列`);await reload()}catch(e){notify(e.message,true)}}
|
||||
async function cancelJob(id){try{await post(`/api/enterprise/sites/${siteId}/jobs/${id}/cancel`,{});await reload()}catch(e){notify(e.message,true)}}
|
||||
async function requestApproval(build){try{await post(`/api/factory/sites/${siteId}/release-requests`,{build_id:build});notify('已提交发布审批');await reload()}catch(e){notify(e.message,true)}}
|
||||
async function approve(id){if(!confirm('审批人必须与申请人不同。确定审批通过吗?'))return;try{await post(`/api/factory/sites/${siteId}/release-requests/${id}/review`,{state:'approved'});notify('审批已完成');await reload()}catch(e){notify(e.message,true)}}
|
||||
async function deploySite(){if(!confirm('请确保目标环境已配置 HTTPS 和发布 Agent。继续发布?'))return;try{const data=await post(`/api/production/sites/${siteId}/deploy`,{build_id:$('#releaseBuild').value,target:$('#target').value});notify(`已发布:${data.remote.release}`);await reload()}catch(e){notify(e.message,true)}}
|
||||
window.resolveReview=resolveReview;window.cancelJob=cancelJob;window.requestApproval=requestApproval;window.approve=approve;
|
||||
window.addEventListener('DOMContentLoaded',init);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
'use strict';(()=>{
|
||||
const menu=document.querySelector('[data-pro-menu]'); const nav=document.querySelector('#pro-navigation');
|
||||
if(menu&&nav){menu.addEventListener('click',()=>{const open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));nav.classList.toggle('is-open',open);menu.setAttribute('aria-label',open?'关闭导航':'打开导航');});}
|
||||
const megaButton=document.querySelector('[data-pro-catalog]'), mega=document.querySelector('[data-pro-mega]');
|
||||
if(megaButton&&mega){function setMega(open){mega.hidden=!open;mega.classList.toggle('is-open',open);megaButton.setAttribute('aria-expanded',String(open));}
|
||||
megaButton.addEventListener('click',()=>setMega(mega.hidden));
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')setMega(false);});
|
||||
document.addEventListener('click',e=>{if(!mega.hidden&&!mega.contains(e.target)&&!megaButton.contains(e.target))setMega(false);});
|
||||
}
|
||||
document.querySelectorAll('.pro-nav-disclose').forEach(button=>{button.addEventListener('click',()=>{let panel=button.parentElement.querySelector('.pro-nav-children');let visible=button.getAttribute('aria-expanded')==='true';button.setAttribute('aria-expanded',String(!visible));panel.hidden=visible;});});
|
||||
document.querySelectorAll('.pro-gallery-thumbs').forEach(container=>{const main=container.parentElement.querySelector('.pro-product-media img');const buttons=[...container.querySelectorAll('[data-gallery-image]')];
|
||||
buttons.forEach(b=>{b.addEventListener('click',()=>{buttons.forEach(x=>x.setAttribute('aria-pressed',String(x===b)));if(main){main.src=b.dataset.galleryImage;main.alt=b.querySelector('img')?.alt||'';}});});
|
||||
if(!main)return;main.tabIndex=0;main.style.cursor='zoom-in';const open=()=>{const pane=document.createElement('div');pane.className='pro-gallery-lightbox';pane.setAttribute('role','dialog');pane.setAttribute('aria-label','产品大图预览');let img=document.createElement('img');img.src=main.src;img.alt=main.alt;const close=document.createElement('button');close.type='button';close.textContent='关闭 ×';pane.append(img,close);document.body.append(pane);const done=()=>{pane.remove();document.removeEventListener('keydown',esc);main.focus();};const esc=e=>{if(e.key==='Escape')done();};document.addEventListener('keydown',esc);close.addEventListener('click',done);pane.addEventListener('click',e=>{if(e.target===pane)done();});close.focus();};main.addEventListener('click',open);main.addEventListener('keydown',e=>{if(e.key==='Enter')open();});
|
||||
});
|
||||
document.querySelectorAll('.pro-filter-scope').forEach(scope=>{
|
||||
const panel=scope.previousElementSibling;if(!panel||!panel.classList.contains('pro-filter-panel'))return;
|
||||
const cards=Array.from(scope.querySelectorAll('.pro-card'));
|
||||
const blank=document.createElement('div');blank.className='pro-filter-empty';blank.hidden=true;blank.textContent='没有找到符合条件的内容,请调整关键词或筛选条件。';scope.append(blank);
|
||||
const q=panel.querySelector('[data-pro-filter-search]'),b=panel.querySelector('[data-pro-filter-brand]'),c=panel.querySelector('[data-pro-filter-category]'),count=panel.querySelector('[data-pro-filter-count]');
|
||||
function options(sel,key){const vals=[...new Set(cards.map(x=>x.dataset[key]||'').filter(Boolean))].sort();vals.forEach(x=>sel.add(new Option(x,x)));if(!vals.length)sel.closest('label').hidden=true;}
|
||||
options(b,'brand');options(c,'category');
|
||||
function run(){const str=q.value.trim().toLocaleLowerCase();let visible=0;
|
||||
cards.forEach(el=>{const hit=el.textContent.toLocaleLowerCase().includes(str)&&(!b.value||el.dataset.brand===b.value)&&(!c.value||el.dataset.category===c.value);el.hidden=!hit;visible+=Number(hit);});count.textContent=`共 ${visible} 条`;blank.hidden=visible!==0;}
|
||||
[q,b,c].forEach(x=>x.addEventListener(x===q?'input':'change',run));run();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
(()=>{
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const menu=$('.sf-menu-toggle');if(menu){menu.addEventListener('click',()=>{let open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));$('#sf-primary-links')?.classList.toggle('is-open',open);});}
|
||||
// Filter actual static content cards, no API is needed at runtime.
|
||||
$$('.sf-collection').forEach(grid=>{
|
||||
const cards=$$('.sf-item',grid);
|
||||
if(cards.length<2)return;
|
||||
const toolbar=document.createElement('div');toolbar.className='sf-filters';
|
||||
const search=document.createElement('input');search.type='search';search.placeholder='筛选当前列表';search.setAttribute('aria-label','筛选当前列表');
|
||||
const status=document.createElement('span');status.setAttribute('aria-live','polite');status.style.fontSize='12px';
|
||||
const brand=document.createElement('select');brand.setAttribute('aria-label','按品牌筛选');
|
||||
const category=document.createElement('select');category.setAttribute('aria-label','按分类筛选');
|
||||
function fill(el,field,all){el.append(new Option(all,''));const vals=[...new Set(cards.map(x=>x.dataset[field]||'').filter(Boolean))].sort();vals.forEach(v=>el.append(new Option(v,v)));if(!vals.length)el.hidden=true;}
|
||||
fill(brand,'brand','全部品牌');fill(category,'category','全部分类');
|
||||
toolbar.append(search,brand,category,status);grid.parentNode.insertBefore(toolbar,grid);
|
||||
function apply(){let val=search.value.trim().toLocaleLowerCase();let visible=0;cards.forEach(card=>{let hit=card.textContent.toLocaleLowerCase().includes(val)&&(!brand.value||card.dataset.brand===brand.value)&&(!category.value||card.dataset.category===category.value);card.hidden=!hit;if(hit)visible++;});status.textContent=`共 ${visible} 项`;}
|
||||
search.addEventListener('input',apply);brand.addEventListener('change',apply);category.addEventListener('change',apply);apply();
|
||||
});
|
||||
const input=$('#sf-search'),root=$('#sf-search-results'),count=$('#sf-search-count');
|
||||
if(input&&root){
|
||||
let data=[];const params=new URLSearchParams(location.search);input.value=params.get('q')||'';
|
||||
let debounce=null;
|
||||
const render=()=>{let q=input.value.trim().toLocaleLowerCase();let found=q?data.filter(d=>`${d.title} ${d.summary} ${d.brand} ${d.type}`.toLocaleLowerCase().includes(q)).slice(0,100):data.slice(0,30);
|
||||
root.replaceChildren();count.textContent=`${found.length} 条结果${!q?'(显示前30条)':''}`;
|
||||
found.forEach(d=>{let article=document.createElement('article'),a=document.createElement('a'),small=document.createElement('small'),p=document.createElement('p');article.className='sf-search-result';a.textContent=d.title;a.href=d.url.replace(/^\//,'');small.textContent=d.type;p.textContent=d.summary;article.append(a,small,p);root.append(article);});
|
||||
};const base=document.querySelector('base')?.href||location.href;
|
||||
fetch(new URL('search-index.json',base)).then(r=>{if(!r.ok)throw Error('index unavailable');return r.json();}).then(v=>{data=v;render()}).catch(()=>{count.textContent='搜索索引加载失败,请检查站点文件部署路径';});
|
||||
input.addEventListener('input',()=>{clearTimeout(debounce);debounce=setTimeout(render,100)});
|
||||
}
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
/* SiteForge simplified workspace: native APIs, no parallel business backend. */
|
||||
const $=(s,root=document)=>root.querySelector(s);
|
||||
const esc=x=>String(x??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||||
const themeName={automotive:'汽车与出行',industrial:'工业与制造',corporate:'企业品牌',minimal:'极简展示'};
|
||||
const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',brand:'品牌',case:'案例',service:'服务'};
|
||||
const fmtDate=t=>{if(!t)return '—';const d=new Date(t);return Number.isNaN(d.getTime())?String(t).slice(0,16):d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})};
|
||||
const state={sites:[],site:null,entries:[],builds:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null};
|
||||
const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',settings:'网站设置'};
|
||||
async function api(url,method='GET',data=null,extraHeaders={}){
|
||||
const headers={...extraHeaders};if(method!=='GET'&&method!=='HEAD')headers['X-SiteForge-CSRF']=state.csrf;
|
||||
let body=data;if(data!=null && !(data instanceof FormData)){headers['Content-Type']='application/json';body=JSON.stringify(data)}
|
||||
const res=await fetch(url,{method,headers,body,credentials:'same-origin'});
|
||||
if(res.status===401){location.href='/login';throw Error('登录已过期,请重新登录')}
|
||||
let value;try{value=await res.json()}catch{throw Error('服务器响应异常(HTTP '+res.status+')')}
|
||||
if(!res.ok)throw Error(typeof value.detail==='string'?value.detail:JSON.stringify(value.detail||value));return value;
|
||||
}
|
||||
function toast(msg,bad=false){const el=$('#toast');el.textContent=msg;el.classList.toggle('bad',bad);el.classList.add('show');clearTimeout(el.timer);el.timer=setTimeout(()=>el.classList.remove('show'),4000)}
|
||||
function notifyError(error){console.error(error);toast(error?.message||String(error),true)}
|
||||
function htmlHeading(title,desc,actions=''){return `<div class="page-heading"><div><p class="overline">SITEFORGE / ${esc(headings[state.view]||'WORKSPACE')}</p><h1>${esc(title)}</h1><p>${esc(desc)}</p></div><div class="inline-actions">${actions}</div></div>`}
|
||||
function actionButton(text,action=''){return `<button type="button" class="btn btn-primary" data-action="${action}">${esc(text)}</button>`}
|
||||
function stat(title,num,icon){return `<div class="stat"><span class="glyph">${icon}</span><small>${title}</small><strong class="value">${esc(num)}</strong></div>`}
|
||||
function emptyState(title,description,button=''){return `<div class="empty"><div class="empty-symbol">◇</div><h3>${esc(title)}</h3><p>${esc(description)}</p>${button}</div>`}
|
||||
function nav(button){return `data-action="navigate" data-view="${button}"`}
|
||||
function siteCard(s){return `<button class="site-card" data-action="select-site" data-id="${esc(s.id)}"><span class="site-icon ${esc(s.theme)}">${esc((s.name||'S').slice(0,1))}</span><span><h3>${esc(s.name)}</h3><p>${esc(s.domain||'尚未设置正式域名')}</p><span class="site-badges"><span>${esc(themeName[s.theme]||'企业网站')}</span><span>·</span><span>${Number(s.entries_count)||0} 条内容</span><span>·</span><span>${Number(s.build_count)||0} 次构建</span></span></span><span class="arrow">›</span></button>`}
|
||||
function overviewShortcut(view,icon,title,desc){return `<button class="quick-card" ${nav(view)}><span class="quick-ico">${icon}</span><span><h3>${title}</h3><p>${desc}</p><span class="quick-more">前往操作 →</span></span></button>`}
|
||||
function home(){const sites=state.sites;const total=sites.reduce((n,s)=>n+(+s.entries_count||0),0);const builds=sites.reduce((n,s)=>n+(+s.build_count||0),0);
|
||||
return htmlHeading('欢迎回来','管理和制作企业网站,从这里开始。',actionButton('+ 创建新网站','new-site'))+
|
||||
`<div class="stats">${stat('网站项目',sites.length,'▤')}${stat('内容总数',total,'▧')}${stat('构建版本',builds,'↗')}${stat('可用主题',4,'◈')}</div>`+
|
||||
`<div class="section-heading"><div><h2>您想先做什么?</h2><p>按使用目的进入对应页面,不需要了解技术术语。</p></div></div><div class="quick-grid">`+
|
||||
overviewShortcut('sites','+','创建或管理网站','从零搭建网站,或者接着制作已有网站。')+
|
||||
overviewShortcut('import','⇩','迁移旧站 / 导入数据','导入 WordPress 或 Excel 产品资料。')+
|
||||
overviewShortcut('design','◈','更换网站设计','选择行业主题,编辑网页与全站外观。')+
|
||||
`</div><div class="section-heading"><div><h2>最近的网站</h2><p>点击网站继续制作。</p></div><button class="btn-link" ${nav('sites')}>查看全部网站 →</button></div>`+
|
||||
(sites.length?`<div class="site-list">${sites.slice(0,4).map(siteCard).join('')}</div>`:emptyState('还没有网站项目','创建一个网站,选择主题后即可开始添加页面、产品和新闻。',actionButton('+ 创建第一个网站','new-site')));
|
||||
}
|
||||
function sites(){return htmlHeading('我的网站','统一管理所有网站项目,每个网站的数据、主题和构建记录彼此独立。',actionButton('+ 创建网站','new-site'))+
|
||||
(state.sites.length?`<div class="site-list">${state.sites.map(siteCard).join('')}</div>`:emptyState('暂时没有网站','选择行业主题,开始制作您的第一个网站。',actionButton('创建网站','new-site')));
|
||||
}
|
||||
function currentSite(){return !!state.site}
|
||||
function noSite(){return htmlHeading('请先选择一个网站','选择现有网站或创建新网站,才能继续进行内容编辑、迁移和构建。')+emptyState('尚未选择网站','点击右上角选择网站,或直接创建一个新的企业网站。',actionButton('+ 创建网站','new-site'))}
|
||||
function latestDone(){return state.builds.find(b=>['done','complete','success','completed'].includes(b.status))||null}
|
||||
function overview(){const s=state.site;if(!s)return noSite();const count=state.entries.length;const build=latestDone();const hasContent=count>1;const hasDomain=!!s.domain;const steps=[
|
||||
{title:'建立网站',desc:'选择行业主题并创建项目',done:true,view:'settings'},
|
||||
{title:'准备内容',desc:'添加页面、产品或导入旧数据',done:hasContent,view:'content'},
|
||||
{title:'完善设计',desc:'选用主题,调整图文和全站风格',done:false,view:'design'},
|
||||
{title:'构建并预览',desc:'生成静态网站,检查效果与链接',done:!!build,view:'build'}];
|
||||
const next=steps.find(x=>!x.done)||steps[3];
|
||||
return htmlHeading(s.name,'这是您的网站工作台。按照下面的顺序操作,完成后就能预览网站。',actionButton('↗ 构建网站','build-now'))+
|
||||
`<div class="task-note"><div><strong>下一步建议:${next.title}</strong><p>${next.desc}。${!hasDomain?'网站域名也可以稍后再设置。':''}</p></div><button class="btn btn-primary" ${nav(next.view)}>现在去做 →</button></div>`+
|
||||
`<div class="stats">${stat('网站内容',count,'▧')}${stat('已构建版本',state.builds.length,'↗')}${stat('当前主题',themeName[s.theme]||'企业品牌','◈')}${stat('域名状态',hasDomain?'已填写':'待设置','◎')}</div>`+
|
||||
`<div class="section-heading"><div><h2>四步完成网站制作</h2><p>从左到右进行;每一步都可以随时返回修改。</p></div></div>`+
|
||||
`<div class="wizard-grid">${steps.map((step,i)=>`<div class="wizard-step ${step.done?'done':next===step?'next':''}"><span class="wizard-index">${step.done?'✓':i+1}</span><h3>${step.title}</h3><p>${step.desc}</p><button ${nav(step.view)}>打开 →</button></div>`).join('')}</div>`+
|
||||
`<div class="section-heading"><div><h2>常用操作</h2><p>日常更新网站通常只需要使用以下三个入口。</p></div></div>`+
|
||||
`<div class="quick-grid">${overviewShortcut('content','▧','添加和编辑内容','管理网站页面、产品、新闻、案例。')}${overviewShortcut('design','◈','修改网站样式','更换主题、编辑页面和移动端样式。')}${overviewShortcut('build','↗','查看和下载网站','提交构建,预览并下载静态网站。')}</div>`;
|
||||
}
|
||||
function content(){if(!currentSite())return noSite();let arr=state.entries.filter(e=>(!state.typ||e.type===state.typ)&&(!state.search||`${e.title} ${e.route}`.toLowerCase().includes(state.search.toLowerCase())));
|
||||
return htmlHeading('内容与页面','所有网页、产品、车型和新闻都在这里维护,保存后重新构建即可更新网站。',actionButton('+ 添加内容','new-entry'))+
|
||||
`<div class="panel"><div class="panel-head"><div><h2>内容列表</h2><p>共 ${state.entries.length} 条内容。点击“设计页面”可进行可视化排版。</p></div></div><div class="toolbar"><input id="contentSearch" aria-label="搜索内容" placeholder="搜索标题或页面路径…" value="${esc(state.search)}"><select id="contentType" aria-label="按类型筛选"><option value="">全部类型</option>${Object.entries(typeName).map(([key,value])=>`<option value="${key}" ${state.typ===key?'selected':''}>${value}</option>`).join('')}</select></div>`+
|
||||
(arr.length?`<div class="table-wrap"><table class="data-table"><thead><tr><th>名称</th><th>内容类型</th><th>页面地址</th><th>状态</th><th>操作</th></tr></thead><tbody>${arr.slice(0,200).map(e=>`<tr><td><strong>${esc(e.title)}</strong></td><td>${esc(typeName[e.type]||e.type)}</td><td class="muted">${esc(e.route)}</td><td><span class="pill ${e.status==='draft'?'warn':'success'}">${e.status==='draft'?'草稿':'已保存'}</span></td><td class="action-cell"><button class="btn btn-tiny" data-action="edit-entry" data-id="${esc(e.id)}">修改内容</button> <a class="btn btn-tiny" href="/editor/${encodeURIComponent(state.site.id)}/${encodeURIComponent(e.id)}">设计页面 ↗</a></td></tr>`).join('')}</tbody></table></div>`:emptyState('没有找到内容','可以清除筛选条件,或者添加新内容。',actionButton('+ 添加内容','new-entry')))+
|
||||
`${arr.length>200?'<p class="muted">当前显示前 200 条,请使用搜索缩小范围。</p>':''}</div>`;
|
||||
}
|
||||
function design(){if(!currentSite())return noSite();const s=state.site;const cards=Object.entries(themeName).map(([id,name])=>`<div class="theme-card"><div class="theme-preview ${id}">${name}</div><div class="theme-desc"><h3>${name} ${s.theme===id?'<span class="pill success">当前使用</span>':''}</h3><p>${({automotive:'适合汽车出口、车型及品牌展示',industrial:'适合制造业、产品与技术参数',corporate:'适合集团公司、服务和案例',minimal:'适合作品展示和品牌形象'})[id]}</p><button class="btn btn-tiny ${s.theme===id?'btn-muted':'btn-primary'}" ${s.theme===id?'disabled':''} data-action="set-theme" data-theme="${id}">${s.theme===id?'正在使用':'使用此主题'}</button></div></div>`).join('');
|
||||
return htmlHeading('外观与设计','先选一套网站风格,再打开可视化编辑器修改图文、导航和手机端布局。')+
|
||||
`<div class="section-heading"><div><h2>第一步 · 选择行业主题</h2><p>更换主题不会删除您已经录入的业务内容和 URL。</p></div><a href="/theme-gallery" class="btn btn-tiny">查看完整主题预览 ↗</a></div><div class="choice-cards">${cards}</div>`+
|
||||
`<div class="section-heading"><div><h2>第二步 · 设计网页</h2><p>挑选要修改的页面;可在设计器中编辑导航、页眉、页脚及各设备布局。</p></div></div><div class="panel"><div class="panel-head"><div><h2>选择要设计的页面</h2><p>修改先保存为草稿,正式发布设计后才会参与下一次静态构建。</p></div></div><div class="grid-2 grid-equal">${state.entries.filter(e=>e.type==='page').slice(0,8).map(e=>`<a class="site-card" href="/editor/${encodeURIComponent(s.id)}/${encodeURIComponent(e.id)}"><span class="site-icon">▤</span><span><h3>${esc(e.title)}</h3><p>${esc(e.route)}</p></span><span class="arrow">›</span></a>`).join('')||'<div class="muted">尚无页面,请先添加内容。</div>'}</div></div><div class="help-hint" style="margin-top:19px"><strong>提示</strong>:网站设置用于公司信息和域名;可视化编辑器用于调整页面外观。两者不会混在同一表单里。</div>`;
|
||||
}
|
||||
function imports(){if(!currentSite())return noSite();return htmlHeading('导入与迁移','根据您已有的资料选择操作方式:Excel 产品、WordPress 网站,或者外部 API / 数据库。')+
|
||||
`<div class="quick-grid">${overviewShortcut('import','▤','导入 Excel / CSV','批量增加产品、车型和新闻数据。')}<a class="quick-card" href="/production-v8"><span class="quick-ico">⇄</span><span><h3>迁移 WordPress 旧站</h3><p>迁移会话、文章采集、图片与 SEO 检查。</p><span class="quick-more">打开迁移工作台 ↗</span></span></a><a class="quick-card" href="/production-v8"><span class="quick-ico">⌘</span><span><h3>API / XML / MySQL</h3><p>连接外部业务系统,进行字段映射和分批导入。</p><span class="quick-more">进入高级导入 ↗</span></span></a></div>`+
|
||||
`<div class="section-heading"><div><h2>快速导入文件</h2><p>适合简单的 Excel、CSV、JSON、WordPress XML 文件。</p></div></div><div class="panel"><h2>上传数据文件</h2><p class="panel-desc">已有网站会保留;重复页面路径默认跳过。复杂迁移请使用上方的专业迁移工作台先预检。</p><form id="importForm"><div class="import-box"><label for="importFile"><strong>选择要导入的数据文件</strong></label><input type="file" id="importFile" name="file" accept=".csv,.xlsx,.xls,.json,.xml" required><p class="muted">支持 CSV、Excel、JSON、WordPress XML;单文件最多 15 MB。</p></div><div class="form-actions"><button class="btn btn-primary" type="submit">确认导入数据</button><a href="/production-v8" class="btn">需要预演与批次回滚?</a></div></form><div id="importSummary" class="message" style="margin-top:15px" hidden></div></div>`+
|
||||
`<div class="help-hint" style="margin-top:17px"><strong>迁移旧网站请注意:</strong>如果需要保留旧 URL、原有 SEO、复杂插件字段和全部图片,请优先使用 WordPress 迁移工作台,而不是仅上传一个内容文件。</div>`;
|
||||
}
|
||||
function builds(){if(!currentSite())return noSite();const latest=latestDone();return htmlHeading('构建与交付','将当前网站数据生成为独立的 HTML 文件。先预览和检查,再决定是否发布。',actionButton('↗ 开始构建','build-now'))+
|
||||
`<div class="task-note"><div><strong>${latest?'上次构建已完成,可以继续预览':'您还没有完成网站构建'}</strong><p>内容或设计有更新时,需要重新构建才能生成新版本。</p></div><a class="btn" href="/operations">构建任务与发布审批 ↗</a></div>`+
|
||||
`<div class="panel"><div class="panel-head"><div><h2>构建版本</h2><p>下载的 ZIP 是独立静态网站,可交给运维部署。</p></div></div><div id="jobNotice" class="message" ${state.job?'':'hidden'}>${esc(state.job||'')}</div>`+
|
||||
(state.builds.length?state.builds.slice(0,20).map(b=>`<div class="build-row"><span class="quick-ico">${['done','completed','success','complete'].includes(b.status)?'✓':'◷'}</span><div class="build-meta"><h3>${esc(fmtDate(b.created_at))} · ${esc(b.total_pages||'—')} 个页面</h3><p>版本 ${esc(b.id.slice(0,12))} · 状态 ${esc(b.status||'未知')}</p></div><div class="inline-actions"><a class="btn btn-tiny" href="/preview/${encodeURIComponent(state.site.id)}/${encodeURIComponent(b.id)}/" target="_blank" rel="noopener">预览 ↗</a><button class="btn btn-tiny" data-action="report" data-id="${esc(b.id)}">质量报告</button><a class="btn btn-tiny btn-primary" href="/api/sites/${encodeURIComponent(state.site.id)}/builds/${encodeURIComponent(b.id)}/download">下载 ZIP</a></div></div>`).join(''):emptyState('尚无构建版本','网站内容和设计准备好后,点击“开始构建”生成独立网站文件。',actionButton('开始构建','build-now')))+`<div id="reportContent"></div></div>`;
|
||||
}
|
||||
function settings(){if(!currentSite())return noSite();const s=state.site;return htmlHeading('网站设置','这里只管理公司资料、域名与站点属性。网页样式请到“外观与设计”修改。')+
|
||||
`<div class="panel"><h2>基本资料</h2><p class="panel-desc">没有域名也可以制作与预览网站,正式上线前再填写即可。</p><form id="settingsForm"><div class="field-grid"><label class="full">网站名称<input name="name" required value="${esc(s.name)}"></label><label class="full">正式域名<input name="domain" placeholder="https://www.example.com" value="${esc(s.domain||'')}"></label><label class="full">企业介绍 / 网站标语<textarea name="tagline" rows="3">${esc(s.tagline||'')}</textarea></label><label class="full">Logo 图片地址<input name="logo" value="${esc(s.logo||'')}"></label><label>网站类型<select name="mode"><option value="new" ${s.mode==='new'?'selected':''}>新建网站</option><option value="migration" ${s.mode==='migration'?'selected':''}>旧站迁移</option><option value="hybrid" ${s.mode==='hybrid'?'selected':''}>混合建站</option></select></label><label>主题色<input type="color" name="accent" value="${esc(s.accent||'#245ac6')}" style="padding:4px;height:43px"></label></div><div class="form-actions"><button class="btn btn-primary" type="submit">保存网站设置</button></div></form></div>`;
|
||||
}
|
||||
function rerender(){const area=$('#mainContent');const selected=state.view;$('#breadcrumbTitle').textContent=headings[selected]||'工作台';$('#sideSiteName').textContent=state.site?.name||'请先选择网站';
|
||||
document.querySelectorAll('[data-view].nav-item').forEach(el=>{const yes=el.dataset.view===selected;el.classList.toggle('active',yes);yes?el.setAttribute('aria-current','page'):el.removeAttribute('aria-current')});
|
||||
area.innerHTML=({home,sites,overview,content,design,import:imports,build:builds,settings}[selected]||home)();
|
||||
}
|
||||
async function loadSites(){state.sites=await api('/api/sites');const picker=$('#sitePicker'),prev=state.site?.id||'';picker.innerHTML='<option value="">选择网站…</option>'+state.sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');picker.value=state.sites.some(s=>s.id===prev)?prev:''}
|
||||
async function selectSite(id,go='overview'){if(!id){state.site=null;state.entries=[];state.builds=[];state.view='sites';$('#sitePicker').value='';rerender();return}try{const [site,entries,builds]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`)]);state.site=site;state.entries=entries;state.builds=builds;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();window.scrollTo({top:0,behavior:'instant'})}catch(e){notifyError(e)}}
|
||||
function navigate(to){if(!state.site&&!['home','sites'].includes(to)){state.view='sites';rerender();toast('请先选择网站项目');return}state.view=to;rerender();closeMobile();window.scrollTo({top:0,behavior:'instant'})}
|
||||
function openModal(name){const node=$('#'+name);node.showModal();node.querySelector('input:not([type=hidden]),select')?.focus()}
|
||||
function closeModal(name){$('#'+name)?.close()}
|
||||
function openEntry(id){const form=$('#entryForm');form.reset();form.elements.entry_id.value='';$('#entryModalTitle').textContent=id?'修改内容':'添加内容';if(id){const e=state.entries.find(x=>x.id===id);if(!e)return toast('这条内容不存在',true);for(const field of ['title','route','summary','body','seo_title','seo_description','type','status']){if(form.elements[field])form.elements[field].value=e[field]||''}form.elements.entry_id.value=id}openModal('entryModal')}
|
||||
async function saveEntry(ev){ev.preventDefault();if(!state.site)return;const el=ev.currentTarget;const data=Object.fromEntries(new FormData(el));const id=data.entry_id;delete data.entry_id;
|
||||
if(!data.route?.trim())delete data.route;
|
||||
try{await api(`/api/sites/${state.site.id}/entries${id?'/'+encodeURIComponent(id):''}`,id?'PATCH':'POST',data);closeModal('entryModal');toast(id?'内容已更新':'内容已添加');await selectSite(state.site.id,'content')}catch(e){notifyError(e)}}
|
||||
async function saveSite(ev){ev.preventDefault();try{const f=ev.currentTarget;const data=Object.fromEntries(new FormData(f));const s=await api('/api/sites','POST',data);closeModal('siteModal');toast('网站创建成功,现在可以添加内容');await loadSites();await selectSite(s.id,'overview')}catch(e){notifyError(e)}}
|
||||
async function setTheme(theme){if(!state.site||theme===state.site.theme)return;if(!confirm(`切换到「${themeName[theme]}」?网站内容及已有 URL 将保持不变,外观会在下次构建后更新。`))return;try{await api(`/api/sites/${state.site.id}`,'PATCH',{theme});toast('主题已切换,重新构建后生效');await selectSite(state.site.id,'design')}catch(e){notifyError(e)}}
|
||||
async function saveSettings(ev){ev.preventDefault();try{await api(`/api/sites/${state.site.id}`,'PATCH',Object.fromEntries(new FormData(ev.currentTarget)));toast('网站设置已保存');await loadSites();await selectSite(state.site.id,'settings')}catch(e){notifyError(e)}}
|
||||
async function importFile(ev){ev.preventDefault();try{if(!state.site)return;const form=new FormData(ev.currentTarget);if(!form.get('file')?.name)throw Error('请选择文件');if(!confirm('将文件内容直接写入当前网站,重复路径默认跳过。需要预演和批次回滚请先使用高级导入。确认继续?'))return;const result=await api(`/api/sites/${state.site.id}/import`,'POST',form);const box=$('#importSummary');box.hidden=false;box.textContent=`导入完成:新增 ${result.imported||0} 条,更新 ${result.updated||0} 条,跳过 ${result.skipped||0} 条。错误 ${result.errors?.length||0} 条。`;toast('导入已完成');await refreshSiteKeepView()}catch(e){notifyError(e)}}
|
||||
async function refreshSiteKeepView(){if(!state.site)return;const [site,entries,builds]=await Promise.all([api('/api/sites/'+state.site.id),api('/api/sites/'+state.site.id+'/entries'),api('/api/sites/'+state.site.id+'/builds')]);state.site=site;state.entries=entries;state.builds=builds}
|
||||
async function buildNow(){if(!state.site)return toast('请先选择网站',true);const id=state.site.id;try{navigate('build');state.job='正在提交网站构建任务…';rerender();const job=await api(`/api/sites/${id}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()});state.job=`网站已进入构建队列(任务 ${job.id}),关闭页面也可以继续运行。`;rerender();for(let i=0;i<120;i++){await new Promise(resolve=>setTimeout(resolve,1600));if(state.site?.id!==id||state.view!=='build')break;const status=await api(`/api/enterprise/sites/${id}/jobs/${job.id}`);if(['done','failed','cancelled'].includes(status.state)){state.job=status.state==='done'?'网站构建已完成,可以预览和下载。':`构建${status.state==='cancelled'?'已取消':'失败'}:${status.error||'请在任务中心查看详情'}`;await refreshSiteKeepView();rerender();toast(state.job,status.state!=='done');break}if(i%5===0){state.job=`Worker 正在构建(${status.state||'等待中'})…`;$('#jobNotice')?.replaceChildren(document.createTextNode(state.job))}}}catch(e){notifyError(e)}}
|
||||
async function showReport(buildId){try{const r=await api(`/api/sites/${state.site.id}/builds/${buildId}/report`);const target=$('#reportContent');if(!target)return;target.innerHTML=`<div class="panel" style="margin-top:16px"><h3>质量检查结果</h3><p class="muted">本报告检查静态内容与资源,不保证搜索引擎排名。</p><div class="stats">${stat('HTML 页面',r.pages||0,'▤')}${stat('重定向',r.redirects||0,'⇄')}${stat('已本地化图片',r.mirrored_images||0,'▧')}${stat('提示数量',(r.warnings||[]).length,'!')}</div>${r.warnings?.length?`<div class="message">${r.warnings.slice(0,20).map(x=>esc(x)).join('\n')}</div>`:'<span class="pill success">当前检查没有警告</span>'}</div>`;target.scrollIntoView({behavior:'smooth',block:'nearest'})}catch(e){notifyError(e)}}
|
||||
function closeMobile(){$('#sideMenu').classList.remove('open');$('#mobileBackdrop').classList.remove('open');$('#menuToggle').setAttribute('aria-expanded','false')}
|
||||
async function init(){try{const me=await api('/api/auth/me');state.csrf=me.csrf;await loadSites();const health=await api('/api/health');$('#appVersion').textContent='版本 '+health.version;
|
||||
// Opening the home screen intentionally does not auto-select a site.
|
||||
navigate('home')}catch(e){notifyError(e);$('#mainContent').innerHTML=emptyState('后台加载失败',e.message,'<button class="btn btn-primary" onclick="location.reload()">重新加载</button>')}}
|
||||
// Delegated UI interactions keep navigation and form actions centrally auditable.
|
||||
document.addEventListener('click',async ev=>{const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='new-site')openModal('siteModal');if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='report')await showReport(control.dataset.id)}catch(e){notifyError(e)}});
|
||||
document.addEventListener('click',ev=>{const btn=ev.target.closest('[data-close]');if(btn)closeModal(btn.dataset.close)});
|
||||
$('#siteForm').addEventListener('submit',saveSite);$('#entryForm').addEventListener('submit',saveEntry);
|
||||
document.addEventListener('submit',ev=>{if(ev.target.id==='settingsForm')saveSettings(ev);if(ev.target.id==='importForm')importFile(ev)});
|
||||
document.addEventListener('input',ev=>{if(ev.target.id==='contentSearch'){state.search=ev.target.value;const cursor=ev.target.selectionStart;const type=state.typ;rerender();const el=$('#contentSearch');el.focus();el.setSelectionRange(cursor,cursor);state.typ=type}});
|
||||
document.addEventListener('change',ev=>{if(ev.target.id==='contentType'){state.typ=ev.target.value;rerender()}});
|
||||
$('#sitePicker').addEventListener('change',ev=>selectSite(ev.target.value));
|
||||
$('#newSiteTop').addEventListener('click',()=>openModal('siteModal'));
|
||||
$('#logoutBtn').addEventListener('click',async()=>{try{await api('/api/auth/logout','POST');location.href='/login'}catch(e){notifyError(e)}});
|
||||
$('#menuToggle').addEventListener('click',()=>{const open=$('#sideMenu').classList.toggle('open');$('#mobileBackdrop').classList.toggle('open',open);$('#menuToggle').setAttribute('aria-expanded',String(open))});
|
||||
$('#mobileBackdrop').addEventListener('click',closeMobile);
|
||||
document.addEventListener('keydown',ev=>{if(ev.key==='Escape')closeMobile()});
|
||||
document.addEventListener('DOMContentLoaded',init);
|
||||
Reference in New Issue
Block a user