Files
2026-10-22 18:51:27 +08:00

105 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const $=(q,el=document)=>el.querySelector(q);
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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.'},'trade-pro':{label:'Trade Pro',desc:'英文外贸 · B2B 目录与询盘',prev:'corp',title:'Go global.'}};
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());