upgrade: adopt V8.12.0-dev.1 (V8.11 unified professional UI + V8.12 workflow usability)

- Overlay 26 changed files from SiteForge_Enterprise_V8_12 (UI layer only:
  professional-ui.js/css, workspace/factory-v2/operations/editor templates,
  +2 test files, compose/ci/scripts/docs)
- Re-apply v8_workflows type-mapping fix (类型/分类) lost in upgrade
- Pin compose project name to siteforge-v8-10 so existing data volumes
  (hesupipe site 483295c29ea0, 193 entries) keep attached
- Drop stray .s10_* state files; ignore .s10_*/.sf_*/.s87_* patterns
- Plan/spec revisions per plan-review round 1 (icons are new design,
  real test assertions, recorded deviations)

Tests: 226 passed / 1 env-only failure (test needs node binary in PATH,
absent in python:3.12-slim container; host node v24 --check all 9 JS OK)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 17:02:45 +08:00
parent 19e865b889
commit 5c7643fcf2
31 changed files with 598 additions and 81 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ function setTab(tab){state.tab=tab;$$('#navtabs button').forEach(b=>b.classList.
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;
$('#sitePicker').onchange=()=>{state.site=state.sites.find(s=>s.id===$('#sitePicker').value);window.SiteForgeShell?.selectSite(state.site?.id);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;
+1 -1
View File
@@ -11,7 +11,7 @@ async function request(url,{method='GET',body=null,upload=false}={}){
}
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 chooseSite(){siteId=$('#site').value;window.SiteForgeShell?.selectSite(siteId);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([
+87
View File
@@ -0,0 +1,87 @@
/* SiteForge V8.11 unified professional experience; isolated sf-* selectors */
:root{--sf-blue:#245fc7;--sf-ink:#203451;--sf-line:#dce5f1;--sf-soft:#f5f8fd;--sf-muted:#60748e}
.sf-globalbar,.sf-globalbar *,.sf-onboarding,.sf-onboarding *{box-sizing:border-box}
.sf-globalbar{position:relative;z-index:100;background:#fff;color:var(--sf-ink);border-bottom:1px solid var(--sf-line);font:13px/1.45 Inter,'Microsoft YaHei',system-ui,sans-serif;box-shadow:0 2px 12px #20345109}
.sf-bar-main{min-height:61px;display:flex;align-items:center;gap:19px;padding:10px clamp(15px,2.3vw,32px);flex-wrap:wrap}
.sf-bar-brand{display:flex;align-items:center;gap:10px;white-space:nowrap;color:#213b66!important;text-decoration:none!important;font-weight:800;font-size:17px}
.sf-bar-logo{display:grid;place-items:center;width:33px;height:33px;background:#265fbd;color:white;border-radius:9px;letter-spacing:-1px}
.sf-bar-loc{font-size:12px;color:#6d7d94;border-left:1px solid #dae3ef;padding-left:16px;min-width:0}
.sf-bar-loc strong{color:#253d63;font-size:13px}
.sf-bar-nav{display:flex;align-items:center;gap:4px;margin-left:auto;min-width:0}
.sf-bar-nav a,.sf-bar-help{font:600 12px/1.5 system-ui,'Microsoft YaHei',sans-serif;text-decoration:none!important;color:#61758f!important;padding:8px 11px;border:1px solid transparent;border-radius:8px;white-space:nowrap;background:transparent;cursor:pointer}
.sf-bar-nav a:hover,.sf-bar-nav a[aria-current='page'],.sf-bar-help:hover{background:#eaf2fe;color:var(--sf-blue)!important}
.sf-bar-site{display:flex;align-items:center;gap:7px;min-width:0;color:#60748e;font-size:12px}
.sf-bar-site select{min-width:120px;max-width:184px;height:36px;padding:7px 28px 7px 10px;border:1px solid #cdd9e9;border-radius:8px;background:#fff;color:#253d63;font-size:12px}
.sf-onboarding{background:#f4f8ff;border-bottom:1px solid #dfe9f7;color:#4b6180;font:12px/1.6 system-ui,'Microsoft YaHei',sans-serif;padding:10px clamp(15px,2.3vw,32px);display:flex;align-items:center;gap:11px;flex-wrap:wrap}
.sf-onboarding strong{color:#1f4d95;white-space:nowrap}.sf-onboarding .sf-onboarding-more{display:none;flex-basis:100%;padding:10px 12px;border:1px solid #d8e5f9;background:white;border-radius:9px}
.sf-onboarding[data-open='true'] .sf-onboarding-more{display:block}.sf-onboarding [data-sf-help]{margin-left:auto;font-size:12px;border:1px solid #bdcfee;background:#fff;color:#2757aa;border-radius:7px;padding:5px 10px;cursor:pointer}
.sf-professional button:not(:disabled),.sf-professional a{touch-action:manipulation}
.sf-professional :is(input:not([type=checkbox]):not([type=radio]),select,textarea):focus-visible,.sf-professional button:focus-visible,.sf-professional a:focus-visible{outline:2px solid #4485eb!important;outline-offset:2px}
.sf-professional :is(input:not([type=checkbox]):not([type=radio]),select,textarea){accent-color:var(--sf-blue)}
.sf-professional button:disabled{opacity:.5;cursor:not-allowed}
.sf-professional .sf-hint{font-size:12px;color:var(--sf-muted);line-height:1.6}
.sf-professional .sf-step{font-size:11px;color:#2859ad;background:#eaf2fe;border-radius:6px;padding:4px 8px;font-weight:700}
/* Avoid duplicate menus: one universal navigation, page-local tabs remain for tasks. */
.sf-professional.sf-page-theme>header{display:none}
.sf-professional.sf-page-production>header{display:none}
.sf-professional.sf-page-operations .layout{display:block;min-height:auto}
.sf-professional.sf-page-operations .layout>aside{display:none}
.sf-professional.sf-page-operations .layout>main{max-width:1460px;padding:27px clamp(15px,3vw,35px)}
.sf-professional.sf-page-factory .shell{display:block}
.sf-professional.sf-page-factory .sidebar{height:auto;position:static;background:#fff;color:#263e5d;padding:10px 25px;border-bottom:1px solid var(--sf-line);display:block}
.sf-professional.sf-page-factory .sidebar .identity,.sf-professional.sf-page-factory .sidebar .side-title,.sf-professional.sf-page-factory .sidebar .side-bottom{display:none}
.sf-professional.sf-page-factory #navtabs{display:flex;gap:5px;flex-wrap:wrap}
.sf-professional.sf-page-factory #navtabs button{color:#5b708a;padding:10px 14px;border-radius:8px}
.sf-professional.sf-page-factory #navtabs button.active,.sf-professional.sf-page-factory #navtabs button:hover{background:#e9f1ff;color:#245dc4}
.sf-professional.sf-page-factory .top{min-height:59px}
.sf-professional.sf-page-factory .top-crumb{display:none}
.sf-professional.sf-page-factory .top-controls>label{display:none}
.sf-professional.sf-page-operations header .right>label{display:none}
.sf-professional.sf-page-production #site{display:none}
.sf-professional.sf-page-production .panel:has(> .row > div > h2:first-child){scroll-margin-top:24px}
.sf-professional.sf-page-editor .studio{height:calc(100vh - var(--sf-extras-height,116px));min-height:560px}
.sf-professional.sf-page-editor .studio-top{height:auto;min-height:70px;padding:10px 18px;gap:8px;flex-wrap:wrap}
.sf-professional.sf-page-editor .studio-top .brand,.sf-professional.sf-page-editor .studio-top .divide{display:none}
.sf-professional.sf-page-editor .studio-top .top-center{margin-left:auto;margin-right:8px}
.sf-professional.sf-page-editor .studio-top .file-name{max-width:320px}
.sf-professional.sf-page-editor .sf-editor-actiongroup{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.sf-professional.sf-page-editor .sf-editor-actiongroup[data-group='publish']{padding-left:9px;border-left:1px solid #dae3ef}
.sf-professional.sf-page-editor .sf-editor-actiongroup .ui-btn{padding:9px 11px;font-size:11px}
.sf-professional.sf-page-editor .preview-workspace .stage{padding:13px}
.sf-editor-mobile-tabs{display:none}
.sf-professional.sf-page-editor .sf-editor-help{color:#6b7d95;font-size:11px;margin:0;padding:0 6px}
@media(max-width:1280px){.sf-bar-main{gap:8px}.sf-bar-nav a{padding:7px}.sf-professional.sf-page-editor .studio-top .file-name{display:none}}
@media(max-width:920px){.sf-bar-nav{order:5;width:100%;overflow:auto;margin-left:0;border-top:1px solid #edf1f8;padding-top:6px}.sf-bar-nav a{flex-shrink:0}.sf-bar-site{margin-left:auto}.sf-professional.sf-page-editor .studio{height:calc(100vh - var(--sf-extras-height,156px))}.sf-professional.sf-page-editor .studio-top{position:relative}.sf-professional.sf-page-editor .right-pane{top:0;position:relative;width:auto;box-shadow:none}}
@media(max-width:650px){.sf-bar-main{padding:10px 12px}.sf-bar-loc{display:none}.sf-bar-site{max-width:180px}.sf-bar-site select{max-width:125px}.sf-onboarding{padding:8px 12px;align-items:flex-start}.sf-onboarding>span{flex:1 1 75%;min-width:0}.sf-onboarding [data-sf-help]{margin-left:0}.sf-professional.sf-page-editor .studio{height:auto;min-height:calc(100vh - 160px)}.sf-professional.sf-page-editor .studio-top{padding:9px;gap:5px}.sf-professional.sf-page-editor .studio-top .ui-btn.ghost{display:inline-flex}.sf-professional.sf-page-editor .studio-top .sf-editor-actiongroup{display:flex}.sf-professional.sf-page-editor .studio-top .top-center{margin:0 auto}.sf-professional.sf-page-editor .studio-body{display:block;min-height:610px}.sf-professional.sf-page-editor .studio-body>aside,.sf-professional.sf-page-editor .studio-body>.preview-workspace{display:none}.sf-professional.sf-page-editor[data-mobile-pane='structure'] .studio-body>.left-pane{display:flex;min-height:600px}.sf-professional.sf-page-editor[data-mobile-pane='preview'] .studio-body>.preview-workspace{display:flex;min-height:600px}.sf-professional.sf-page-editor[data-mobile-pane='properties'] .studio-body>.right-pane{display:flex;min-height:600px}.sf-professional.sf-page-editor .left-pane,.sf-professional.sf-page-editor .right-pane{overflow:visible;border:0}.sf-editor-mobile-tabs{display:flex;gap:5px;padding:8px;background:#fff;border-bottom:1px solid var(--sf-line)}.sf-editor-mobile-tabs button{flex:1;border:1px solid #dce5f1;border-radius:7px;background:#f7f9fc;padding:9px 6px;color:#4c617d}.sf-editor-mobile-tabs button[aria-selected='true']{background:#e8f0ff;border-color:#94b8f3;color:#1c56ba;font-weight:750}.sf-professional.sf-page-editor .device-frame{min-height:570px}.sf-professional.sf-page-editor iframe{min-height:600px}.sf-professional.sf-page-factory .sidebar{padding:8px}.sf-professional.sf-page-factory #navtabs{flex-wrap:nowrap;overflow-x:auto}.sf-professional.sf-page-factory #navtabs button{white-space:nowrap}.sf-professional.sf-page-operations .layout>main{padding:14px}.sf-professional.sf-page-production main{padding:0 13px}}
@media(prefers-reduced-motion:reduce){.sf-globalbar *, .sf-onboarding *{transition:none!important}}
/* Shared actions and form language */
.sf-professional :is(.primary,.btn-primary,.ui-btn.primary,button.primary){background:#245fc7!important;border-color:#245fc7!important;color:#fff!important}
.sf-professional :is(.primary,.btn-primary,.ui-btn.primary,button.primary):hover:not(:disabled){background:#1c4fa6!important;border-color:#1c4fa6!important}
.sf-professional :is(.outline,.btn-muted,.secondary,.ui-btn.ghost){border-color:#cedceb;color:#3f5674}
.sf-professional .danger,.sf-professional button.danger{color:#ba4249!important}
.sf-professional :is(input,textarea,select):disabled{background:#f2f5fa;color:#8895a7}
.sf-professional :is(input,textarea,select)[aria-invalid='true']{border-color:#c8464e!important;background:#fff8f8!important}
.sf-professional :is(.panel,.section) h2{letter-spacing:-.25px}
.sf-professional .sf-visually-hidden{position:absolute;width:1px;height:1px;clip:rect(0 0 0 0);overflow:hidden;white-space:nowrap}
/* Keep editing controls accessible on tablets, not only desktop. */
@media(max-width:950px){
.sf-professional.sf-page-editor .studio-body{display:block;min-height:600px}
.sf-professional.sf-page-editor .studio-body>aside,.sf-professional.sf-page-editor .studio-body>.preview-workspace{display:none}
.sf-professional.sf-page-editor[data-mobile-pane='structure'] .studio-body>.left-pane{display:flex;min-height:600px}
.sf-professional.sf-page-editor[data-mobile-pane='preview'] .studio-body>.preview-workspace{display:flex;min-height:600px}
.sf-professional.sf-page-editor[data-mobile-pane='properties'] .studio-body>.right-pane{display:flex;min-height:600px;position:static;width:100%;box-shadow:none}
.sf-professional.sf-page-editor .sf-editor-mobile-tabs{display:flex;gap:5px;padding:8px;background:white}
.sf-professional.sf-page-editor .sf-editor-mobile-tabs button{flex:1;padding:9px 5px;border:1px solid #d5e0f0;border-radius:7px}
.sf-professional.sf-page-editor .sf-editor-mobile-tabs button[aria-selected='true']{background:#e8f0ff;color:#245fc7;border-color:#99b7e9}
}
.sf-ops-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:18px 0 20px}
.sf-ops-tabs button{display:grid;gap:4px;text-align:left;border:1px solid #d9e3f1;border-radius:10px;padding:13px 17px;background:#fff;color:#586e8a;cursor:pointer;font:inherit}
.sf-ops-tabs button strong{font-size:13px;color:#334c6d}
.sf-ops-tabs button small{font-size:11px;color:#8a98aa}
.sf-ops-tabs button[aria-selected='true']{border-color:#91b4ec;background:#eaf2ff;box-shadow:inset 3px 0 #326bd6}
.sf-professional.sf-page-operations .layout>main>.two[hidden]{display:none!important}
.sf-professional.sf-page-production .tool-switch{position:sticky;top:0;z-index:6;box-shadow:0 4px 16px #1b355509}
.sf-professional.sf-page-theme .frame{height:calc(100vh - 285px)}
@media(max-width:650px){.sf-ops-tabs{grid-template-columns:1fr;gap:7px}.sf-ops-tabs button{padding:9px 12px}.sf-professional.sf-page-production .tool-switch{position:static}}
+67
View File
@@ -0,0 +1,67 @@
/* Shared non-invasive shell for SiteForge specialized tools. No business API changes. */
(()=>{'use strict';
const PAGES={editor:{title:'可视化编辑器',group:'外观与设计',help:'编辑区块和全站布局,先保存草稿,确认后再发布设计。',more:'左侧调整页面结构,中间查看真实主题预览,右侧编辑属性。修改草稿不会改变已构建网站;发布设计后需要重新构建。',next:'完成设计后到「构建与交付」查看最终 HTML。'},theme:{title:'主题预览',group:'外观与设计',help:'比较四套主题及不同设备的页面效果。',more:'这里使用演示资料展示主题结构。选择真正的网站主题请返回工作台 → 外观与设计,切换后重新构建。',next:'返回外观与设计,选择主题并编辑页面。'},factory:{title:'高级建站',group:'专业工具',help:'主题版本、字段映射、SEO 审计及高级构建集中在这里。',more:'先选择网站,再按“总览 → 主题 → 内容 → 迁移检查 → 构建”逐步操作。普通内容管理请使用简洁工作台。',next:'数据准备完成后执行构建与质量检查。'},production:{title:'数据与迁移工具',group:'导入与迁移',help:'一次只处理一类数据:选工具 → 预览 → 审核 → 提交。',more:'WordPress、Excel/CSV、XML、API 与 MySQL 都进入可复核的暂存流程。高级参数可保留默认值,完成后回到工作台构建。',next:'在构建与交付中验证导入后的实际页面。'},operations:{title:'任务与交付',group:'构建与交付',help:'检查迁移问题、Worker 任务、构建质量和发布审批。',more:'按“迁移复核 → 构建任务 → 质量报告 → 审批 → 部署”顺序处理。部署操作会影响线上环境,请先核查版本和目标。',next:'通过质量与审批后,再选择目标环境发布。'}};
const ctx=document.body?.dataset.sfPage; if(!PAGES[ctx])return; const p=PAGES[ctx],siteKey='siteforge:selected-site';
const safeRead=()=>{try{return localStorage.getItem(siteKey)||''}catch{return ''}};
const safeWrite=id=>{try{if(id)localStorage.setItem(siteKey,id);else localStorage.removeItem(siteKey)}catch{}};
const route=(view)=>`/?view=${encodeURIComponent(view)}${selected()?`&site=${encodeURIComponent(selected())}`:''}`;
const selected=()=>document.getElementById('sfGlobalSite')?.value || safeRead();
const navbar=[['网站工作台','/','home'],['内容管理','content','content'],['外观设计','design','design'],['导入迁移','import','import'],['构建交付','build','build']];
const html=document.createElement('div');html.className='sf-globalbar';html.setAttribute('role','navigation');html.setAttribute('aria-label','网站工作区导航');
const main=document.createElement('div');main.className='sf-bar-main';
const brand=document.createElement('a');brand.href='/';brand.className='sf-bar-brand';brand.innerHTML='<span class="sf-bar-logo">SF</span><span>SiteForge</span>';
const loc=document.createElement('span');loc.className='sf-bar-loc';loc.textContent=`${p.group} / ${p.title}`;
const nav=document.createElement('nav');nav.className='sf-bar-nav';nav.setAttribute('aria-label','主要功能');
navbar.forEach(([label,view])=>{const a=document.createElement('a');a.textContent=label;a.dataset.sfView=view;a.href=view==='/'?'/':route(view);if((ctx==='editor'||ctx==='theme')&&view==='design'||ctx==='production'&&view==='import'||ctx==='operations'&&view==='build'){a.setAttribute('aria-current','page')}nav.append(a)});
const sLabel=document.createElement('label');sLabel.className='sf-bar-site';sLabel.textContent='当前网站';const sel=document.createElement('select');sel.id='sfGlobalSite';sel.setAttribute('aria-label','当前管理的网站');sel.append(new Option('正在加载…',''));sLabel.append(sel);
main.append(brand,loc,nav,sLabel);html.append(main);
const help=document.createElement('div');help.className='sf-onboarding';help.dataset.open='false';
const strong=document.createElement('strong');strong.textContent='当前任务';const short=document.createElement('span');short.textContent=p.help;const toggle=document.createElement('button');toggle.type='button';toggle.dataset.sfHelp='';toggle.textContent='查看操作说明 +';toggle.setAttribute('aria-expanded','false');
const more=document.createElement('div');more.className='sf-onboarding-more';more.textContent=`操作方法:${p.more} 下一步:${p.next}`;help.append(strong,short,toggle,more);
document.body.classList.add('sf-professional','sf-page-'+ctx);document.body.insertBefore(help,document.body.firstChild);document.body.insertBefore(html,help);
const syncHeight=()=>{document.documentElement.style.setProperty('--sf-extras-height',(html.getBoundingClientRect().height+help.getBoundingClientRect().height)+'px')};
const localSelect=()=>({factory:'#sitePicker',operations:'#site',production:'#site'})[ctx]||null;
const refreshNav=()=>nav.querySelectorAll('a[data-sf-view]').forEach(a=>{a.href=a.dataset.sfView==='/'?'/':route(a.dataset.sfView)});
const local=()=>document.querySelector(localSelect()||'#sf__no_local');
let applying=false;
function mirrorToLocal(id){const node=local();if(!node||!Array.from(node.options).some(o=>o.value===id))return false;if(node.value===id)return true;applying=true;node.value=id;node.dispatchEvent(new Event('change',{bubbles:true}));applying=false;return true}
function selectSite(id){safeWrite(id);refreshNav();}
window.SiteForgeShell={selectSite,selectedSite:selected};
sel.addEventListener('change',()=>{const id=sel.value;if(!id)return;selectSite(id);if(ctx==='editor'){location.assign(route('design'));return}if(!mirrorToLocal(id)&&ctx!=='theme'){location.assign(route('overview'))}});
nav.addEventListener('click',()=>selectSite(sel.value));
toggle.addEventListener('click',()=>{const on=help.dataset.open!=='true';help.dataset.open=String(on);toggle.textContent=on?'收起操作说明 −':'查看操作说明 +';toggle.setAttribute('aria-expanded',String(on));syncHeight()});
async function load(){try{const res=await fetch('/api/sites',{credentials:'same-origin'});if(!res.ok)throw Error('网站列表加载失败');const items=await res.json();sel.replaceChildren(new Option('选择网站',''));
for(const item of items){sel.append(new Option(item.name,item.id))}
const parts=location.pathname.split('/');const editorId=ctx==='editor'?parts[2]:'';const params=new URLSearchParams(location.search);const wanted=editorId||params.get('site')||safeRead()||items[0]?.id||'';
if(items.some(s=>s.id===wanted)){sel.value=wanted;safeWrite(wanted)}else if(items.length){sel.value=items[0].id;safeWrite(sel.value)}
const native=local();if(native){const apply=()=>{const desired=sel.value;if(Array.from(native.options).some(o=>o.value===desired))mirrorToLocal(desired)};if(native.options.length)apply();const observer=new MutationObserver(()=>{if(!applying)apply()});observer.observe(native,{childList:true});native.addEventListener('change',()=>{if(applying)return;sel.value=native.value;selectSite(native.value)})}
refreshNav();}catch(err){sel.replaceChildren(new Option('在工作台选择网站',''));sel.disabled=true}syncHeight()}
if(ctx==='operations'){
// Business steps are optional views over existing DOM; hidden panels retain their API listeners.
const content=document.querySelector('.layout > main');
const groups=Array.from(content?.querySelectorAll(':scope > .two')||[]);
const metrics=content?.querySelector('.metrics');
if(groups.length===3&&metrics){
const tabbar=document.createElement('div');tabbar.className='sf-ops-tabs';tabbar.setAttribute('role','tablist');tabbar.setAttribute('aria-label','运行与发布阶段');
const choices=[['migration','① 导入与复核','确认旧站数据'],['build','② 生成静态网站','查看 Worker 任务'],['release','③ 审批与发布','核对部署目标']];
const names=['migration','build','release'];groups.forEach((g,i)=>{g.dataset.sfOps=names[i];g.setAttribute('role','tabpanel')});
function choose(key){groups.forEach(g=>g.hidden=g.dataset.sfOps!==key);tabbar.querySelectorAll('button').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.view===key)));}
choices.forEach(([key,title,desc])=>{const b=document.createElement('button');b.type='button';b.dataset.view=key;b.setAttribute('role','tab');const label=document.createElement('strong');label.textContent=title;const small=document.createElement('small');small.textContent=desc;b.append(label,small);b.addEventListener('click',()=>choose(key));tabbar.append(b)});
metrics.insertAdjacentElement('afterend',tabbar);choose('migration');
}
}
// Mark required and invalid fields visibly, leaving native validation intact.
document.addEventListener('invalid',ev=>{const field=ev.target;if(field.matches?.('input,select,textarea'))field.setAttribute('aria-invalid','true')},true);
document.addEventListener('input',ev=>{if(ev.target.matches?.('input,select,textarea')&&ev.target.checkValidity())ev.target.removeAttribute('aria-invalid')});
if(ctx==='editor'){
const studio=document.querySelector('.studio');const top=studio?.querySelector('.studio-top');
if(top){const reorder=(label,group,names)=>{const div=document.createElement('div');div.className='sf-editor-actiongroup';div.dataset.group=group;div.setAttribute('aria-label',label);for(const name of names){const node=Array.from(top.children).find(x=>x.tagName==='BUTTON' && (x.textContent||'').includes(name));if(node)div.append(node)}top.append(div)};
reorder('页面与全站设计','design',['设计规范','全站页眉']);reorder('历史与草稿','draft',['撤销','重做','放弃草稿','保存草稿']);reorder('预览与构建','preview',['刷新预览','构建静态站']);reorder('发布设计','publish',['发布设计']);
}
const tabs=document.createElement('div');tabs.className='sf-editor-mobile-tabs';tabs.setAttribute('role','tablist');tabs.setAttribute('aria-label','手机端编辑面板');[['structure','页面结构'],['preview','页面预览'],['properties','属性设置']].forEach(([view,title])=>{const b=document.createElement('button');b.type='button';b.setAttribute('role','tab');b.dataset.sfPane=view;b.textContent=title;b.addEventListener('click',()=>setPane(view));tabs.append(b)});
const body=studio?.querySelector('.studio-body');if(body)studio.insertBefore(tabs,body);
function setPane(pane){document.body.dataset.mobilePane=pane;tabs.querySelectorAll('button').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.sfPane===pane)))}setPane('structure');
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',load);else load();
window.addEventListener('resize',syncHeight);
})();
File diff suppressed because one or more lines are too long
+69 -24
View File
@@ -5,7 +5,9 @@ const esc=x=>String(x??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'
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 sfSiteKey='siteforge:selected-site';
const sfRemember=id=>{try{if(id)localStorage.setItem(sfSiteKey,id)}catch{}};
const state={sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null,selectionToken:0,building:false};
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;
@@ -13,7 +15,7 @@ async function api(url,method='GET',data=null,extraHeaders={}){
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;
if(!res.ok){const ex=Error(typeof value.detail==='string'?value.detail:JSON.stringify(value.detail||value));ex.status=res.status;throw ex}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)}
@@ -22,6 +24,13 @@ function actionButton(text,action=''){return `<button type="button" class="btn b
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}"`}
// Business navigation is a shareable, browser-history-aware URL; selection is still authorized by /api/sites.
const siteUrl=(view,site)=>{const q=new URLSearchParams();if(site)q.set('site',site);if(view&&(view!=='home'||site))q.set('view',view);return '/'+(q.size?'?'+q.toString():'')};
function rememberView(replace=false){const url=siteUrl(state.view,state.site?.id);history[replace?'replaceState':'pushState']({site:state.site?.id||null,view:state.view},'',url)}
function activeJobs(){return state.jobs.filter(j=>['queued','running','retrying'].includes(j.state))}
function buildStatusLabel(stateCode){return ({queued:'等待 Worker',running:'正在生成',retrying:'重试中',done:'已完成',failed:'失败',cancelled:'已取消'})[stateCode]||stateCode||'未知'}
function buildReadyStep(){return state.entries.some(e=>e.status!=='draft'&&e.type!=='page')||state.entries.some(e=>e.status!=='draft'&&e.type==='page')}
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);
@@ -40,19 +49,19 @@ function sites(){return htmlHeading('我的网站','统一管理所有网站项
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=[
function overview(){const s=state.site;if(!s)return noSite();const count=state.entries.length;const build=latestDone();const hasContent=buildReadyStep();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>`+
{title:'调整设计',desc:'可反复完善主题、导航和移动端样式',optional:true,view:'design'},
{title:'构建并检查',desc:'生成静态网站,查看质量报告',done:!!build,view:'build'}];
const next=!hasContent?steps[1]:!build?steps[3]:null;
return htmlHeading(s.name,'这里显示真实的内容和构建状态。设计可随时调整,正式上线需要单独审核。',actionButton('↗ 构建网站','build-now'))+
`<div class="task-note"><div><strong>${next?'建议下一步:'+next.title:'网站已经生成过构建版本'}</strong><p>${next?next.desc:'内容或设计有修改时,重新构建并检查新版即可。'}。${!hasDomain?'正式域名还未填写;测试预览不受影响。':''}</p></div><button class="btn btn-primary" ${nav(next?.view||'build')}>${next?'现在去做':'查看构建版本'} →</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>`;
`<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?'✓':step.optional?'◈':i+1}</span><h3>${step.title}${step.optional?'(持续优化)':''}</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'))+
@@ -66,14 +75,18 @@ function design(){if(!currentSite())return noSite();const s=state.site;const car
`<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="quick-grid"><a class="quick-card" href="/production-v8"><span class="quick-ico">▤</span><span><h3>导入 Excel / CSV</h3><p>推荐先预览字段、检查冲突,再确认提交。</p><span class="quick-more">安全导入与批次回滚 →</span></span></a><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="panel"><h2>推荐:先预演再导入</h2><p>在数据导入工作台检查字段匹配、页面路径冲突及品牌分类关系,再分批提交。</p><a class="btn btn-primary" href="/production-v8">打开安全导入工作台 →</a><details class="form-expand" style="margin-top:20px"><summary>高级:直接写入网站(不可批次回滚)</summary><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></details></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 builds(){if(!currentSite())return noSite();const latest=latestDone();const pending=activeJobs();const first=pending[0];
return htmlHeading('构建与交付','网站构建由独立 Worker 执行。离开页面或刷新后仍可查看任务状态。',`<button type="button" class="btn btn-primary" data-action="build-now" ${pending.length||state.building?'disabled':''}>${pending.length?'已有构建在运行':'↗ 开始构建'}</button>`)+
`<div class="task-note"><div><strong>${first?'当前:'+buildStatusLabel(first.state):latest?'已有构建版本,可以预览':'还没有已完成的网站构建'}</strong><p>${first?'当前任务 '+esc(first.id.slice(0,12))+';Worker 在后台执行,您可以关闭此页面。':'内容或设计有更新时,重新生成网站版本。'}</p></div><div class="inline-actions"><button class="btn" data-action="check-readiness">交付前检查</button><a class="btn" href="/operations">任务与发布审批 ↗</a></div></div>`+
`<div class="panel"><div class="panel-head"><div><h2>后台任务</h2><p>可以查看任务状态。仅取消当前仍在队列或运行中的构建。</p></div><button class="btn btn-tiny" data-action="refresh-builds">刷新状态</button></div><div id="jobNotice" class="message" ${state.job?'':'hidden'}>${esc(state.job||'')}</div>`+
(state.jobs.length?state.jobs.slice(0,8).map(j=>`<div class="build-row"><span class="quick-ico">${j.state==='done'?'✓':j.state==='failed'?'!':'◷'}</span><div class="build-meta"><h3>${esc(buildStatusLabel(j.state))}</h3><p>${esc(fmtDate(j.created_at))} · 任务 ${esc(j.id.slice(0,12))}${j.state==='failed'&&j.error?' · '+esc(j.error):''}</p></div>${['queued','running','retrying'].includes(j.state)?`<button class="btn btn-tiny btn-danger" data-action="cancel-job" data-id="${esc(j.id)}">取消任务</button>`:''}</div>`).join(''):'<p class="muted">当前网站还没有后台构建任务。</p>')+`</div>`+
`<div class="panel" style="margin-top:18px"><div class="panel-head"><div><h2>构建版本</h2><p>建议先预览和核对质量报告,再下载 ZIP。预览不代表正式发布。</p></div></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">${['done','completed','success','complete'].includes(b.status)?`<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>`:'<span class="muted">尚未完成,暂不能下载</span>'}</div></div>`).join(''):emptyState('尚无完成版本','准备好网站数据后,在上方提交构建任务。'))+
`<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>`;
@@ -83,8 +96,18 @@ function rerender(){const area=$('#mainContent');const selected=state.view;$('#b
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'})}
async function readJobs(siteId){try{return await api(`/api/enterprise/sites/${encodeURIComponent(siteId)}/jobs`)}catch(e){if(![403,404].includes(Number(e.status)))console.warn('加载任务状态失败',e);return []}}
async function selectSite(id,go='overview',historyMode='push'){
const token=++state.selectionToken;
if(!id){state.site=null;state.entries=[];state.builds=[];state.jobs=[];state.job=null;state.view=go==='home'?'home':'sites';$('#sitePicker').value='';rerender();if(historyMode!=='none')rememberView(historyMode==='replace');return}
if(!state.sites.some(x=>x.id===id)){toast('该网站不在您的可见列表中',true);return}
try{const [site,entries,builds,jobs]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`),readJobs(id)]);
if(token!==state.selectionToken)return;
state.site=site;sfRemember(id);state.entries=entries;state.builds=builds;state.jobs=jobs;state.job=null;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();if(historyMode!=='none')rememberView(historyMode==='replace');closeMobile();window.scrollTo({top:0,behavior:'instant'});
}catch(e){if(token===state.selectionToken)notifyError(e)}
}
function navigate(to,historyMode='push'){if(!state.site&&!['home','sites'].includes(to)){state.view='sites';rerender();toast('请先选择网站项目');return}state.view=to;rerender();closeMobile();if(historyMode!=='none')rememberView(historyMode==='replace');if(to==='build')refreshBuilds();window.scrollTo({top:0,behavior:'instant'})}
async function refreshBuilds(){const id=state.site?.id;if(!id)return;try{const previouslyRunning=activeJobs().length>0;const [jobs,builds]=await Promise.all([readJobs(id),api(`/api/sites/${encodeURIComponent(id)}/builds`)]);if(state.site?.id!==id)return;state.jobs=jobs;state.builds=builds;if(previouslyRunning&&!activeJobs().length){const last=jobs[0];state.job=last?.state==='done'?'后台构建已完成,请预览并查看质量报告。':last?.state==='failed'?'本次构建失败,请在任务中心检查原因。':last?.state==='cancelled'?'本次构建已取消。':null}if(state.view==='build')rerender()}catch(e){notifyError(e)}}
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')}
@@ -96,20 +119,42 @@ async function setTheme(theme){if(!state.site||theme===state.site.theme)return;i
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 cancelJob(jobId){if(!state.site)return;if(!confirm('确定取消这个构建任务?已经完成的静态版本不会被删除。'))return;try{const id=state.site.id;await api(`/api/enterprise/sites/${encodeURIComponent(id)}/jobs/${encodeURIComponent(jobId)}/cancel`,'POST');toast('已发送取消请求');await refreshBuilds()}catch(e){notifyError(e)}}
async function buildNow(){if(!state.site)return toast('请先选择网站',true);if(state.building||activeJobs().length)return toast('已有构建在执行,请先查看任务状态');
const id=state.site.id;state.building=true;
try{navigate('build');state.job='正在提交构建任务…';rerender();const job=await api(`/api/sites/${encodeURIComponent(id)}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()});
if(state.site?.id!==id)return;state.job=`任务 ${job.id?.slice(0,12)||'已提交'} 已进入队列,可以离开页面;重新打开“构建与交付”查看结果。`;toast('已提交构建任务');await refreshBuilds();
}catch(e){notifyError(e)}finally{state.building=false;if(state.site?.id===id&&state.view==='build')rerender()}}
async function checkReadiness(){if(!state.site)return;const siteId=state.site.id;
try{const result=await api(`/api/factory/sites/${encodeURIComponent(siteId)}/migration/readiness`);
if(state.site?.id!==siteId)return;
const box=$('#reportContent');if(!box)return;
const gates=result.gates||[];
box.innerHTML=`<div class="panel" style="margin-top:18px"><h3>正式交付前检查</h3><p class="muted">检查旧站 URL、SEO 和静态文件风险;通过检查不等于已经上线。</p><p class="${result.can_publish?'pill success':'pill warn'}">${result.can_publish?'没有发现发布阻断项':'存在需要先处理的问题'}</p>${gates.length?'<ul class="readiness-list">'+gates.map(g=>`<li><strong>${g.level==='block'?'必须修复':'需要复核'}</strong> ${esc(g.message)}</li>`).join('')+'</ul>':'<p>当前没有需要处理的检查项。仍需人工确认内容、域名与询盘表单。</p>'}<div class="form-actions"><a class="btn" href="/operations">进入迁移复核与发布审批 →</a></div></div>`;
box.scrollIntoView({behavior:'smooth',block:'nearest'});
}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>')}}
// Restore URL first, then a previously selected site *only* after verifying /api/sites.
const params=new URLSearchParams(location.search),requested=params.get('site');
const target=params.get('view');const allowed=new Set(['home','sites','overview','content','design','import','build','settings']);
let stored='';try{stored=localStorage.getItem(sfSiteKey)||''}catch{}
const chosen=[requested,stored].find(id=>id&&state.sites.some(x=>x.id===id));
if(chosen){await selectSite(chosen,allowed.has(target)?target:'overview','replace')}
else {state.view=target==='sites'?'sites':'home';rerender();rememberView(true)}
}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',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==='refresh-builds')await refreshBuilds();if(action==='check-readiness')await checkReadiness();if(action==='cancel-job')await cancelJob(control.dataset.id);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));
window.addEventListener('popstate',async()=>{const p=new URLSearchParams(location.search);const site=p.get('site'),view=p.get('view')||'home';const allowed=['home','sites','overview','content','design','import','build','settings'];if(site&&state.sites.some(s=>s.id===site))await selectSite(site,allowed.includes(view)?view:'overview','none');else{state.selectionToken++;state.site=null;state.entries=[];state.jobs=[];state.builds=[];state.view=view==='sites'?'sites':'home';rerender()}});
setInterval(()=>{if(!document.hidden&&state.view==='build'&&activeJobs().length)refreshBuilds()},5000);
$('#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))});