upgrade: adopt V8.31.1 (theme 1.3/1.4 redesigns, editor deep-canvas, guided launch, component library)
Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme, registry tests, and design docs. Re-applied full patch set on identical anchors (discovery/env_for/blocks.css/type-mapping/locale-override/ theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins. Verified: theme suites 35/35; dual-baseline full run 415 passed vs pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails). Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact, trade-pro build 203 pages 0 errors. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
'use strict';(()=>{document.querySelectorAll('.auto31-search').forEach(form=>{form.addEventListener('submit',()=>{const input=form.querySelector('input[name=q]');if(input)input.value=input.value.trim().slice(0,120);});});})();
|
||||
@@ -35,5 +35,119 @@
|
||||
function shouldAcceptPreview(requestedPage,requestedSeq,currentPage,currentSeq){
|
||||
return requestedPage===currentPage&&requestedSeq===currentSeq;
|
||||
}
|
||||
return {reorder,duplicate,filterBlocks,historyState,clampNumber,shouldAcceptPreview};
|
||||
function findTopIndex(rows,id){return Array.isArray(rows)&&typeof id==='string'?rows.findIndex(b=>b.id===id):-1;}
|
||||
function reorderById(rows,sourceId,targetId){
|
||||
const from=findTopIndex(rows,sourceId),to=findTopIndex(rows,targetId);
|
||||
return from<0||to<0||from===to?null:reorder(rows,from,to);
|
||||
}
|
||||
function adjacentWidths(block,widths){
|
||||
if(!block||block.type!=='layout'||!Array.isArray(block.columns)||!Array.isArray(widths)||widths.length!==block.columns.length)return null;
|
||||
const validated=widths.map(v=>Number(v));
|
||||
if(validated.some(v=>!Number.isFinite(v)||v<.5||v>4))return null;
|
||||
// Keep the same type and field shape expected by blueprints.validate_blocks.
|
||||
return validated.map(v=>Math.round(v*100)/100);
|
||||
}
|
||||
function safeCanvasCommand(command,rows,types){
|
||||
if(!command||typeof command!=='object'||typeof command.blockId!=='string'||command.blockId.length>150)return false;
|
||||
const i=findTopIndex(rows,command.blockId);
|
||||
if(i<0)return false;
|
||||
const actions=['select','edit','up','down','add-after','duplicate','delete','reorder','resize-columns','inline-text'];
|
||||
if(!actions.includes(command.action))return false;
|
||||
if(command.action==='reorder')return findTopIndex(rows,command.targetId)>=0;
|
||||
if(command.action==='resize-columns')return !!adjacentWidths(rows[i],command.widths);
|
||||
if(command.action==='inline-text')return (command.field==='title'||command.field==='description')&&
|
||||
typeof command.value==='string'&&command.value.length<=500&&types?.[rows[i].type]?.fields?.includes(command.field);
|
||||
return true;
|
||||
}
|
||||
// V8.24: resolve the actual parent of a nested child from the validated tree.
|
||||
// A client message supplies stable IDs, never trusted indexes or an editable path.
|
||||
// Renderer accepts at most two layout levels; a bounded walk avoids malformed cycles.
|
||||
function findNestedChild(rows,parentId,childId){
|
||||
if(typeof parentId!=='string'||typeof childId!=='string'||!Array.isArray(rows)||
|
||||
!parentId||!childId||parentId===childId)return null;
|
||||
for(let rootIndex=0;rootIndex<rows.length;rootIndex++){
|
||||
const root=rows[rootIndex];
|
||||
function visit(layout,depth,ancestors){
|
||||
if(!layout||layout.type!=='layout'||!Array.isArray(layout.columns)||depth>2)return null;
|
||||
for(let ci=0;ci<layout.columns.length;ci++){
|
||||
const children=layout.columns[ci]?.blocks;
|
||||
if(!Array.isArray(children))continue;
|
||||
for(let ri=0;ri<children.length;ri++){
|
||||
const child=children[ri];
|
||||
if(!child||typeof child!=='object')continue;
|
||||
if(layout.id===parentId&&child.id===childId)
|
||||
return {parentIndex:rootIndex,root,parent:layout,columnIndex:ci,
|
||||
childIndex:ri,child,depth,ancestors:[...ancestors,layout.id]};
|
||||
if(child.type==='layout'&&depth<2){
|
||||
const found=visit(child,depth+1,[...ancestors,layout.id]);
|
||||
if(found)return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const found=visit(root,0,[]);
|
||||
if(found)return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findChildInRoot(rows,rootId,childId){
|
||||
const rootIndex=findTopIndex(rows,rootId);
|
||||
if(rootIndex<0||typeof childId!=='string')return null;
|
||||
const root=rows[rootIndex];
|
||||
if(root.type!=='layout')return null;
|
||||
function visit(layout,depth){
|
||||
if(depth>2)return null;
|
||||
for(const col of layout.columns||[]){
|
||||
for(const child of col.blocks||[]){
|
||||
if(child?.id===childId)return findNestedChild(rows,layout.id,childId);
|
||||
if(child?.type==='layout'){
|
||||
const match=visit(child,depth+1);
|
||||
if(match)return match;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return visit(root,0);
|
||||
}
|
||||
function findLayout(rows,layoutId){
|
||||
if(typeof layoutId!=='string'||!Array.isArray(rows))return null;
|
||||
const topIndex=findTopIndex(rows,layoutId);
|
||||
if(topIndex>=0)return rows[topIndex].type==='layout'?{layout:rows[topIndex],rootIndex:topIndex,depth:0}:null;
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const root=rows[i];
|
||||
if(root.type!=='layout')continue;
|
||||
for(const col of root.columns||[])for(const child of col.blocks||[]){
|
||||
if(child?.id===layoutId && child.type==='layout')return {layout:child,rootIndex:i,depth:1};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function safeNestedCanvasCommand(command,rows,types){
|
||||
if(!command||typeof command!=='object')return false;
|
||||
const found=findNestedChild(rows,command.blockId,command.childId);
|
||||
if(!found)return false;
|
||||
const actions=['child-select','child-edit','child-up','child-down','child-duplicate','child-delete','child-inline-text'];
|
||||
if(!actions.includes(command.action))return false;
|
||||
if(command.action==='child-inline-text')return ['title','subtitle','description'].includes(command.field)
|
||||
&& types?.[found.child.type]?.fields?.includes(command.field)
|
||||
&& typeof command.value==='string'&&command.value.length<=500;
|
||||
return true;
|
||||
}
|
||||
function layoutPreset(block,preset){
|
||||
if(!block||block.type!=='layout'||!Array.isArray(block.columns))return null;
|
||||
const count=block.columns.length;if(count<1||count>4)return null;
|
||||
if(preset==='equal')return Array(count).fill(1);
|
||||
if(count!==2)return null;
|
||||
if(preset==='left-wide')return [2,1];
|
||||
if(preset==='right-wide')return [1,2];
|
||||
return null;
|
||||
}
|
||||
function moveNested(block,columnIndex,childIndex,direction){
|
||||
if(!block||block.type!=='layout'||![-1,1].includes(direction))return null;
|
||||
const list=block.columns?.[columnIndex]?.blocks;
|
||||
return Array.isArray(list)?reorder(list,childIndex,childIndex+direction):null;
|
||||
}
|
||||
return {reorder,duplicate,filterBlocks,historyState,clampNumber,shouldAcceptPreview,findTopIndex,reorderById,adjacentWidths,safeCanvasCommand,findNestedChild,findChildInRoot,findLayout,safeNestedCanvasCommand,layoutPreset,moveNested};
|
||||
});
|
||||
|
||||
@@ -88,3 +88,71 @@ iframe{min-height:590px;height:calc(100vh - 224px)}
|
||||
|
||||
/* Accessible repeated-item forms for galleries, carousels, FAQs and features */
|
||||
.sf-array-editor{display:grid;gap:10px}.sf-array-hint{font-size:11px;line-height:1.6;color:#8391a3;margin:0 0 4px}.sf-array-card{border:1px solid #e5eaf0;background:#fafbfd;padding:11px;border-radius:10px}.sf-array-card-head{display:flex;align-items:center;justify-content:space-between;gap:7px;margin-bottom:12px}.sf-array-card-head b{font-size:12px;color:#586a7f}.sf-array-card-head div{display:flex;gap:3px}.sf-array-card-head button{border:1px solid #e2e7ed;border-radius:5px;color:#6c7a8e;background:white;padding:4px 7px;font-size:11px}.sf-array-card-head button:last-child{color:#b95d62}.sf-array-card-head button:disabled{opacity:.3;cursor:not-allowed}.sf-array-field{display:block!important;margin:10px 0!important;font-size:11px!important;font-weight:650!important}.sf-array-field input,.sf-array-field textarea{display:block;width:100%;margin-top:5px;background:white}.sf-array-field textarea{min-height:65px}.sf-array-add{border:1px dashed #e5a77c;border-radius:9px;background:#fff8f2;color:#be672a;padding:13px 9px;font-size:12px;font-weight:800}
|
||||
|
||||
/* V8.22 canvas-level authoring with no change to published templates */
|
||||
.sf-insert-hint{margin:8px 12px;padding:10px 12px;background:#fff3e8;color:#aa5525;font-size:12px;border:1px solid #f0bd98;border-radius:9px;line-height:1.6}
|
||||
.sf-insert-hint[hidden]{display:none!important}
|
||||
.preview-head .preview-note{max-width:350px;font-size:11px;color:#67778b}
|
||||
#sitePreview:focus-visible{outline:2px solid #e77b37;outline-offset:2px}
|
||||
|
||||
/* V8.23 child-first layout inspector */
|
||||
.sf-nested-edit{margin:5px 0 20px;display:grid;gap:9px}.sf-nested-card{border:1px solid #e3e9f0;border-radius:10px;padding:10px;background:#fbfcfd}.sf-nested-card.active{border-color:#42a8b9;background:#f2fbfd}.sf-nested-heading{display:flex;align-items:center;justify-content:space-between;gap:6px}.sf-nested-heading>button{border:0;background:none;font-size:12px;color:#36556e;font-weight:750;text-align:left;cursor:pointer}.sf-nested-heading span{display:flex;gap:4px}.sf-nested-heading span button,.sf-nested-actions button{border:1px solid #d8e4eb;background:white;border-radius:6px;padding:5px 9px;color:#36556e;cursor:pointer}.sf-nested-heading button:disabled{opacity:.3;cursor:not-allowed}.sf-nested-field{display:block;margin:10px 0;color:#536477;font-size:12px}.sf-nested-field input{display:block;margin-top:6px;width:100%;padding:10px;border:1px solid #d7e3e9;border-radius:7px;font-size:12px}.sf-nested-actions{display:flex;gap:6px;margin:10px 0 2px}.sf-nested-actions button:last-child{color:#b44549}.sf-layout-presets{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:9px 0 18px}.sf-layout-presets button{white-space:normal;padding:10px 5px;border:1px solid #e5b995;border-radius:8px;background:#fff8f3;color:#945325;font-size:11px;cursor:pointer}.sf-layout-presets button:disabled{opacity:.4;cursor:not-allowed}
|
||||
|
||||
/* V8.25: friendly shared-component management, scoped to editor only. */
|
||||
.sf-shared-palette-row{border:1px solid #e4e8ef;background:white;border-radius:9px;padding:10px;display:grid;gap:8px;font-size:12px}
|
||||
.sf-shared-palette-row strong{display:flex;justify-content:space-between;gap:7px}
|
||||
.sf-shared-palette-row small{color:#8594a8}
|
||||
.sf-shared-palette-row>div{display:flex;gap:7px}
|
||||
.sf-shared-palette-row button,.sf-shared-actions button{border:1px solid #e0e4e9;border-radius:7px;background:#fff;padding:7px 9px;cursor:pointer;color:#364759}
|
||||
.sf-shared-palette-row button:last-child{background:#fff3e8;color:#b95a23;border-color:#eac7ae}
|
||||
.sf-shared-dialog{width:min(780px,96vw);max-height:88vh;overflow:auto;border:1px solid #e3e7ec;border-radius:16px;padding:24px;box-shadow:0 20px 60px #263a5430;color:#334155}
|
||||
.sf-shared-dialog::backdrop{background:#0e203b70}
|
||||
.sf-shared-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.sf-shared-dialog h2{margin:5px 0 12px;font-size:22px}.sf-shared-dialog h3{margin:18px 0 10px}
|
||||
.sf-shared-card{display:flex;justify-content:space-between;gap:14px;border:1px solid #e3e8ee;border-radius:10px;padding:13px;margin:10px 0;align-items:center}
|
||||
.sf-shared-card>div:first-child{display:grid;gap:4px}.sf-shared-card small,.sf-shared-usage small{display:block;color:#64748b;font-size:11px;margin-top:5px}
|
||||
.sf-shared-actions{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.sf-shared-usage{display:flex;align-items:center;gap:12px;padding:12px;border:1px solid #e2e7ed;border-radius:9px;margin:7px 0;cursor:pointer}
|
||||
.sf-shared-usage:has(input:disabled){opacity:.6;cursor:not-allowed}
|
||||
#sharedComponentDetail{margin-top:22px;border-top:1px solid #e2e7ed;padding-top:12px}
|
||||
#sharedComponentDetail>.ui-btn{margin-top:14px}
|
||||
@media(max-width:700px){.sf-shared-card{display:grid}.sf-shared-dialog{padding:15px}}
|
||||
|
||||
.sf-share-info{border:1px solid #f0cfb5;background:#fff8f1;padding:13px;border-radius:10px;margin:12px 0;color:#955023;font-size:12px}.sf-share-info p{line-height:1.5}
|
||||
|
||||
.sf-shared-history{display:flex;justify-content:space-between;align-items:center;gap:10px;border-bottom:1px solid #edf0f4;padding:10px 0;font-size:12px;flex-wrap:wrap}
|
||||
|
||||
/* V8.26 global design navigation, review and restoration */
|
||||
.sf-visual-nav{padding:15px;border:1px solid #e2e8f0;border-radius:12px;background:#fafcff}
|
||||
.sf-visual-nav-header{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;margin-bottom:12px}
|
||||
.sf-visual-nav-header span,.sf-visual-nav-help{font-size:12px;color:#64748b}
|
||||
.sf-nav-item{padding:8px;margin-bottom:6px;border:1px solid #dde5ed;background:#fff;border-radius:10px;display:flex;align-items:center;flex-wrap:wrap;gap:6px}
|
||||
.sf-nav-item input{min-width:100px;flex:1 1 135px;padding:8px;border:1px solid #cbd5e1;border-radius:7px}
|
||||
.sf-nav-action{font-size:12px!important;padding:6px 9px!important}
|
||||
.sf-global-review{width:min(720px,95vw);max-height:85vh;overflow:auto;border:1px solid #dce5ed;border-radius:16px;padding:24px;box-shadow:0 18px 54px #132c4540}
|
||||
.sf-review-change{padding:12px;border:1px solid #dfe6ee;border-radius:10px;margin:9px 0;background:#f9fbfe;overflow-wrap:anywhere}
|
||||
.sf-review-change p{font-size:12px;color:#64748b;margin:7px 0;white-space:pre-wrap}
|
||||
.sf-review-buttons{display:flex;justify-content:flex-end;gap:10px;margin-top:16px}
|
||||
@media(max-width:600px){.sf-nav-item{margin-left:0!important}.sf-global-review{padding:16px}}
|
||||
|
||||
.sf-global-entry{display:flex;flex-direction:column;gap:6px;padding:12px;border:1px solid #f1d7bf;border-radius:10px;background:#fffaf5;margin-bottom:12px}.sf-global-entry button{font-weight:650;text-align:left}.sf-global-entry small{color:#876c56;font-size:11px}
|
||||
|
||||
/* V8.29: discoverable content palette and safe theme comparison */
|
||||
.sf-palette-filter{display:flex;gap:6px;margin:12px 13px 6px}.sf-palette-filter input{min-width:0;flex:1;border:1px solid #d8e2ed;border-radius:9px;padding:10px;font-size:12px}.sf-palette-filter button{border:1px solid #dee6ee;border-radius:8px;background:#f7f9fc;color:#53657a;font-size:11px;padding:0 9px}.sf-palette-summary{font-size:11px;color:#657487;padding:2px 14px 9px}.sf-palette-categories{display:block;padding:0 12px 12px}.sf-palette-group{margin-bottom:12px}.sf-palette-group h4{font-size:12px;color:#4a5870;margin:6px 3px 9px;font-weight:700}.sf-palette-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.sf-palette-grid button{min-height:54px;border:1px solid #e3e9ef;background:white;padding:10px;border-radius:10px;display:flex;gap:8px;align-items:center;text-align:left;color:#35445b;font-size:12px;font-weight:600;line-height:1.4;cursor:pointer}.sf-palette-grid button:hover{background:#fff6ee;border-color:#e8a576}.sf-palette-grid button span{font-size:18px;flex-shrink:0;color:#b9652f}.sf-palette-empty{font-size:12px;color:#697b90;padding:10px}.sf-history-quick{border:1px solid #e0e6ed;background:white;color:#49596a;border-radius:8px;min-width:32px;height:34px;font-size:20px;cursor:pointer}.sf-history-quick:disabled{opacity:.35;cursor:not-allowed}.sf-theme-preview-entry{white-space:nowrap;color:#43576d}.sf-theme-showcase{width:min(1150px,96vw);height:min(94vh,900px);max-height:94vh;border:1px solid #dce3eb;border-radius:17px;overflow:auto;background:white;padding:0;box-shadow:0 25px 80px #10284148}.sf-theme-showcase::backdrop{background:#1424389c}.sf-showcase-head{display:flex;justify-content:space-between;align-items:start;gap:16px;padding:20px 24px 13px;border-bottom:1px solid #e5eaf0}.sf-showcase-head small{color:#b65c2d;font-weight:700}.sf-showcase-head h2{font-size:21px;margin:7px 0}.sf-showcase-head p{font-size:12px;color:#697b90;margin:0}.sf-showcase-tools{display:flex;gap:12px;align-items:end;flex-wrap:wrap;padding:14px 24px}.sf-showcase-tools label{display:grid;gap:5px;font-size:12px;font-weight:600;color:#43566c}.sf-showcase-tools select{border:1px solid #dce3ed;border-radius:8px;padding:9px 14px;min-width:145px;background:white}.sf-showcase-device{display:flex;gap:5px;margin-left:auto;background:#eef2f6;border-radius:8px;padding:4px}.sf-showcase-device button{padding:8px 12px;border:0;background:transparent;color:#596b7e;border-radius:6px;cursor:pointer}.sf-showcase-device button[aria-pressed=true]{background:#fff;color:#ad5c28;box-shadow:0 1px 6px #122c4419;font-weight:700}.sf-showcase-status{margin:0 24px 10px;color:#64748b;font-size:12px}.sf-showcase-stage{height:min(55vh,520px);overflow:auto;background:#eef1f5;padding:15px;display:flex;align-items:stretch;justify-content:center}.sf-showcase-frame{min-width:0;max-width:100%;height:100%;transition:width .18s ease;background:white;box-shadow:0 9px 30px #21334c20}.sf-showcase-frame iframe{display:block;width:100%;height:100%;min-height:0;border:0;background:white}.sf-showcase-footer{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 24px;border-top:1px solid #e5eaf0}.sf-showcase-footer span{color:#718195;font-size:12px}.sf-theme-showcase button:focus-visible,.sf-theme-showcase select:focus-visible,.sf-palette-filter input:focus-visible,.sf-history-quick:focus-visible{outline:3px solid #eeab77;outline-offset:2px}@media(max-width:1100px){.sf-theme-preview-entry{font-size:11px;padding:8px!important}}@media(max-width:700px){.sf-theme-preview-entry{font-size:0;min-width:32px;padding:6px!important}.sf-theme-preview-entry::before{content:'◫';font-size:17px}.sf-showcase-head,.sf-showcase-tools,.sf-showcase-footer{padding:12px}.sf-showcase-stage{height:55vh;padding:8px}.sf-showcase-footer{flex-direction:column;align-items:start}.sf-showcase-device{margin-left:0}}@media(prefers-reduced-motion:reduce){.sf-showcase-frame{transition:none}}
|
||||
|
||||
.sf-style-presets{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:9px 0 17px}.sf-style-presets button{border:1px solid #dae2eb;border-radius:9px;background:#fff;padding:10px 5px;min-height:48px;font-size:11px;color:#364459;cursor:pointer}.sf-style-presets button:nth-child(2){background:#f0f3f8}.sf-style-presets button:nth-child(3){background:#26374d;color:#fff}.sf-style-presets button:focus-visible{outline:3px solid #efa971;outline-offset:2px}
|
||||
/* Prevent translated, closed inspector drawers from widening the mobile viewport. */
|
||||
@media(max-width:1060px){html,body{max-width:100%;overflow-x:clip}}
|
||||
/* V8.29 mobile authoring: two deliberate toolbar rows, never horizontal-scrolling actions. */
|
||||
@media(max-width:720px){
|
||||
.studio-top.sf-easy-top{height:auto;min-height:100px;flex-wrap:wrap;justify-content:space-between;align-content:center;padding:7px 9px;gap:5px}
|
||||
.studio-top.sf-easy-top .brand,.studio-top.sf-easy-top .file-name,.studio-top.sf-easy-top .sf-history-quick,.studio-top.sf-easy-top .sf-theme-preview-entry{display:none!important}
|
||||
.studio-top.sf-easy-top .sf-pane-controls{order:1;flex:0 0 auto}
|
||||
.studio-top.sf-easy-top #saveDraftButton{order:2;flex:0 0 auto}
|
||||
.studio-top.sf-easy-top #publishDesignButton{order:3;flex:0 0 auto}
|
||||
.studio-top.sf-easy-top .studio-actions{order:4;flex:0 0 auto}
|
||||
.studio-top.sf-easy-top .sf-easy-device{order:5;flex:1 1 100%;display:flex;justify-content:center;margin:0}
|
||||
.studio-top.sf-easy-top .sf-easy-device .icon-btn{flex:1;max-width:115px}
|
||||
.studio-body .left-pane,.studio-body .right-pane{top:140px!important}
|
||||
.studio-actions-menu{right:0;max-width:calc(100vw - 18px)}
|
||||
}
|
||||
|
||||
+408
-44
@@ -10,19 +10,296 @@ const toast=(s,err=false)=>{const t=$('#toast');t.textContent=s;t.style.backgrou
|
||||
const activePage=()=>pages.find(e=>e.id===pageId);
|
||||
const editorTools=window.SiteForgeEditorHelpers;
|
||||
let libraryOpen=false;
|
||||
let inspectorTab='content',blockSearch='',previewRequest=0,previewController=null,savePromise=null,lastContinuousEdit=0;
|
||||
let sharedPreview=null;
|
||||
let siteNavDraft=[],siteDesignReviewToken=null;
|
||||
let selectedChildId=null;
|
||||
let inspectorTab='content',paletteSearch='',blockSearch='',previewRequest=0,previewController=null,savePromise=null,lastContinuousEdit=0,insertAfterId=null;
|
||||
|
||||
let snapshots=[],snapshotCursor=-1,draggedChild=null,draftRevision=0,siteDesign=null,siteDesignRevision=0,siteDesignPreview=null;
|
||||
function recordSnapshot(){const current=JSON.stringify({blocks,kind,layoutMode});if(snapshotCursor>=0 && snapshots[snapshotCursor]===current)return;snapshots=snapshots.slice(0,snapshotCursor+1);snapshots.push(current);if(snapshots.length>75)snapshots.shift();snapshotCursor=snapshots.length-1}
|
||||
function restoreSnapshot(index){if(index<0||index>=snapshots.length)return;snapshotCursor=index;const state=JSON.parse(snapshots[index]);blocks=state.blocks;kind=state.kind;layoutMode=state.layoutMode;selected=Math.min(selected,blocks.length-1);$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');Studio.updateHistoryButtons();Studio.draw();Studio.refreshPreview()}
|
||||
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);selectedChildId=null;$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');Studio.updateHistoryButtons();Studio.draw();Studio.refreshPreview()}
|
||||
const Studio={
|
||||
highlightCanvas(){const block=blocks[selected];const frame=$('#sitePreview');if(block&&frame?.contentWindow)frame.contentWindow.postMessage({source:'siteforge-studio-host',action:'highlight',blockId:block.id,childId:selectedChildId},'*')},
|
||||
showInsertHint(){const box=$('#sfInsertHint');if(!box)return;const item=blocks.find(x=>x.id===insertAfterId);box.hidden=!item;box.textContent=item?'正在「'+(item.title||schema?.blocks[item.type]?.name||'当前区块')+'」下方插入:选择一种内容。':''},
|
||||
beginAddAfter(blockId){if(editorTools.findTopIndex(blocks,blockId)<0)return;insertAfterId=blockId;Studio.showInsertHint();Studio.toggleLibrary(true);document.body.classList.add('show-structure');document.body.classList.remove('show-inspector');$('#sfAddBlock').scrollIntoView({behavior:'smooth',block:'nearest'})},
|
||||
canvasCommand(command){
|
||||
if(String(command?.action||'').startsWith('child-')){Studio.childCanvasCommand(command);return;}
|
||||
if(command?.action==='resize-columns'){
|
||||
const nestedLayout=editorTools.findLayout(blocks,command.blockId);
|
||||
if(!nestedLayout)return;
|
||||
const widths=editorTools.adjacentWidths(nestedLayout.layout,command.widths);
|
||||
if(!widths)return;
|
||||
if(nestedLayout.layout.columns.every((c,k)=>Math.abs(c.width-widths[k])<.001))return;
|
||||
nestedLayout.layout.columns.forEach((c,k)=>c.width=widths[k]);
|
||||
selected=nestedLayout.rootIndex;
|
||||
selectedChildId=nestedLayout.depth?command.blockId:null;
|
||||
Studio.modified();Studio.draw();return;
|
||||
}
|
||||
if(!editorTools.safeCanvasCommand(command,blocks,schema.blocks))return;
|
||||
const i=editorTools.findTopIndex(blocks,command.blockId),block=blocks[i];
|
||||
if(command.action==='select'||command.action==='edit'){Studio.select(i);if(command.action==='edit'){inspectorTab='content';Studio.applyInspectorTab();}return;}
|
||||
if(command.action==='up'||command.action==='down'){Studio.moveBlock(i,command.action==='up'?-1:1);return;}
|
||||
if(command.action==='add-after'){Studio.beginAddAfter(command.blockId);return;}
|
||||
if(command.action==='duplicate'){Studio.duplicate(i);return;}
|
||||
if(command.action==='delete'){Studio.remove(i);return;}
|
||||
if(command.action==='reorder'){
|
||||
const to=editorTools.findTopIndex(blocks,command.targetId);
|
||||
const next=editorTools.reorderById(blocks,command.blockId,command.targetId);
|
||||
if(!next)return;blocks=next;selected=to;Studio.modified();Studio.draw();return;
|
||||
}
|
||||
if(command.action==='resize-columns'){
|
||||
const widths=editorTools.adjacentWidths(block,command.widths);
|
||||
if(!widths)return;
|
||||
if(block.columns.every((c,k)=>Math.abs(c.width-widths[k])<.001))return;
|
||||
block.columns.forEach((col,j)=>col.width=widths[j]);selected=i;
|
||||
Studio.modified();Studio.inspector();return;
|
||||
}
|
||||
if(command.action==='inline-text'){
|
||||
if(block[command.field]===command.value)return;
|
||||
block[command.field]=command.value;selected=i;Studio.modified();Studio.draw();
|
||||
toast('文字已修改,记得保存草稿');
|
||||
}
|
||||
},
|
||||
childCanvasCommand(command){
|
||||
if(!editorTools.safeNestedCanvasCommand(command,blocks,schema.blocks))return;
|
||||
const found=editorTools.findNestedChild(blocks,command.blockId,command.childId);
|
||||
if(!found)return;
|
||||
const {parentIndex,parent,columnIndex,childIndex,child}=found;
|
||||
if(command.action==='child-select'||command.action==='child-edit'){
|
||||
selected=parentIndex;selectedChildId=child.id;inspectorTab='content';Studio.draw();Studio.highlightCanvas();
|
||||
const card=document.querySelector('#sf-nested-active');card?.scrollIntoView({block:'nearest'});
|
||||
if(window.matchMedia('(max-width:1060px)').matches)document.body.classList.add('show-inspector');
|
||||
return;
|
||||
}
|
||||
if(command.action==='child-inline-text'){
|
||||
if(child[command.field]===command.value)return;
|
||||
child[command.field]=command.value;selected=parentIndex;selectedChildId=child.id;
|
||||
Studio.modified();Studio.draw();return;
|
||||
}
|
||||
if(command.action==='child-delete'){
|
||||
if(!confirm('确定删除这一列中的组件?'))return;
|
||||
parent.columns[columnIndex].blocks.splice(childIndex,1);selectedChildId=null;
|
||||
}else if(command.action==='child-duplicate'){
|
||||
const copy=editorTools.duplicate(child,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));
|
||||
parent.columns[columnIndex].blocks.splice(childIndex+1,0,copy);selectedChildId=copy.id;
|
||||
}else{
|
||||
const next=editorTools.moveNested(parent,columnIndex,childIndex,command.action==='child-up'?-1:1);
|
||||
if(!next)return;parent.columns[columnIndex].blocks=next;selectedChildId=child.id;
|
||||
}
|
||||
selected=parentIndex;Studio.modified();Studio.draw();
|
||||
},
|
||||
setLayoutGap(value){const block=blocks[selected];if(!block||block.type!=='layout')return;block.gap=Math.max(0,Math.min(64,Math.round(Number(value)||0)));Studio.modified();Studio.inspector()},
|
||||
setLayoutPreset(preset){
|
||||
const block=blocks[selected];const widths=editorTools.layoutPreset(block,preset);
|
||||
if(!widths)return toast('当前列数不支持这种预设',true);
|
||||
block.columns.forEach((col,i)=>col.width=widths[i]);Studio.modified();Studio.inspector();
|
||||
},
|
||||
// One inspector for both levels, with a readable path back to the parent.
|
||||
childQuickEditor(layout){
|
||||
if(layout.type!=='layout')return '';
|
||||
const cards=[];
|
||||
const walk=(container,trail=[],depth=0)=>{
|
||||
if(depth>2)return;
|
||||
container.columns.forEach((column,ci)=>(column.blocks||[]).forEach((child,ri)=>{
|
||||
const active=selectedChildId===child.id;
|
||||
const label=schema.blocks[child.type]?.name||child.type;
|
||||
const breadcrumb=[...trail,`第${ci+1}列`].join(' › ');
|
||||
cards.push(`<div class="sf-nested-card ${active?'active':''}" style="margin-left:${Math.min(depth,1)*12}px" ${active?'id="sf-nested-active"':''}><div class="sf-nested-heading"><button type="button" onclick="Studio.selectChild('${html(child.id)}')">${active?'✓ ':''}${html(breadcrumb)} · ${html(label)}</button><span><button type="button" title="上移" ${ri===0?'disabled':''} onclick="Studio.childCanvasCommand({action:'child-up',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">↑</button><button type="button" title="下移" ${ri===column.blocks.length-1?'disabled':''} onclick="Studio.childCanvasCommand({action:'child-down',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">↓</button></span></div>`);
|
||||
if(active){
|
||||
if(child.type==='layout'){
|
||||
cards.push(`<div class="sf-nested-field">内层布局 · ${child.columns.length} 列</div><div class="sf-nested-actions"><button type="button" onclick="Studio.setNestedLayoutPreset('${html(child.id)}','equal')">均分列宽</button>${child.columns.length===2?`<button type="button" onclick="Studio.setNestedLayoutPreset('${html(child.id)}','left-wide')">左宽右窄</button><button type="button" onclick="Studio.setNestedLayoutPreset('${html(child.id)}','right-wide')">左窄右宽</button>`:''}</div><label class="sf-nested-field">列间距(像素)<input type="number" min="0" max="64" value="${Number(child.gap)||0}" onchange="Studio.setNestedLayoutGap('${html(child.id)}',this.value)"></label><div class="sf-nested-actions"><label>手机列数 <select onchange="Studio.setNestedDeviceColumns('${html(child.id)}','mobile',this.value)"><option value="1" ${child.mobile_columns===1?'selected':''}>1 列</option><option value="2" ${child.mobile_columns===2?'selected':''}>2 列</option></select></label><label>平板列数 <select onchange="Studio.setNestedDeviceColumns('${html(child.id)}','tablet',this.value)">${[1,2,3].map(n=>`<option value="${n}" ${child.tablet_columns===n?'selected':''}>${n} 列</option>`).join('')}</select></label></div>`);
|
||||
}else{
|
||||
const fields=(schema.blocks[child.type]?.fields||[]).filter(k=>['title','subtitle','description','image','button','href'].includes(k));
|
||||
fields.forEach(key=>cards.push(`<label class="sf-nested-field">${html(({title:'标题',subtitle:'副标题',description:'描述',image:'图片地址',button:'按钮文字',href:'跳转链接'})[key])}<input type="text" value="${html(child[key]??'')}" oninput="Studio.childFieldById('${html(child.id)}','${key}',this.value)"></label>`));
|
||||
}
|
||||
if(child.type==='layout'){
|
||||
child.columns.forEach((col,index)=>cards.push(`<div class="sf-nested-actions" role="group" aria-label="内层第${index+1}列添加内容"><span>第 ${index+1} 列</span>${['richtext','image_text','features','cta'].map(t=>`<button type="button" onclick="Studio.addNestedChild('${html(child.id)}',${index},'${t}')">+ ${html(schema.blocks[t]?.name||t)}</button>`).join('')}</div>`));
|
||||
}
|
||||
cards.push(`<button type="button" class="ui-btn ghost" onclick="Studio.saveReusable()">☆ 保存当前组件到组件库</button>`);
|
||||
if(schema.blocks[child.type]?.fields?.includes('image')){
|
||||
cards.push(`<button type="button" class="ui-btn ghost" onclick="Studio.chooseMedia()">🖼 从素材库更换选中图片</button>`);
|
||||
}
|
||||
cards.push(`<div class="sf-nested-actions"><button type="button" onclick="Studio.childCanvasCommand({action:'child-duplicate',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">复制组件</button><button type="button" onclick="Studio.childCanvasCommand({action:'child-delete',blockId:'${html(container.id)}',childId:'${html(child.id)}'})">删除组件</button></div>`);
|
||||
}
|
||||
cards.push('</div>');
|
||||
if(child.type==='layout')walk(child,[...trail,`第${ci+1}列 · 内层布局`],depth+1);
|
||||
}));
|
||||
};
|
||||
walk(layout);
|
||||
return `<div class="sf-nested-edit"><div class="subheading">画布组件 · 选择任意一层内容直接编辑</div>${cards.join('')||'<p class="prop-tip">当前没有子组件,请添加内容。</p>'}</div>`;
|
||||
},
|
||||
setNestedLayoutPreset(layoutId,preset){
|
||||
const found=editorTools.findLayout(blocks,layoutId);
|
||||
if(!found||found.rootIndex!==selected||found.depth!==1)return;
|
||||
const widths=editorTools.layoutPreset(found.layout,preset);
|
||||
if(!widths)return toast('当前内层列数不支持此预设',true);
|
||||
found.layout.columns.forEach((c,k)=>c.width=widths[k]);selectedChildId=layoutId;
|
||||
Studio.modified();Studio.draw();
|
||||
},
|
||||
setNestedLayoutGap(layoutId,value){
|
||||
const found=editorTools.findLayout(blocks,layoutId);
|
||||
if(!found||found.rootIndex!==selected||found.depth!==1)return;
|
||||
found.layout.gap=Math.round(Math.max(0,Math.min(64,Number(value)||0)));
|
||||
selectedChildId=layoutId;Studio.modified();Studio.draw();
|
||||
},
|
||||
addNestedChild(layoutId,columnIndex,type){
|
||||
const found=editorTools.findLayout(blocks,layoutId);
|
||||
if(!found||found.rootIndex!==selected||found.depth!==1)return;
|
||||
// A second-level layout cannot contain another layout. Use only the safe
|
||||
// declarative blocks offered in the UI and validate limits before mutation.
|
||||
if(!['richtext','image_text','features','cta'].includes(type)||
|
||||
!Number.isInteger(columnIndex)||columnIndex<0||columnIndex>=found.layout.columns.length)return;
|
||||
const column=found.layout.columns[columnIndex];
|
||||
if(column.blocks.length>=60)return toast('这一列的组件数量已达到限制',true);
|
||||
const item=structuredClone(schema.blocks[type].defaults);
|
||||
item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);
|
||||
item.type=type;item.visible=true;
|
||||
column.blocks.push(item);selectedChildId=item.id;
|
||||
Studio.modified();Studio.draw();
|
||||
toast('已添加内层组件,请在右侧修改内容');
|
||||
},
|
||||
setNestedDeviceColumns(layoutId,device,value){
|
||||
if(!['mobile','tablet'].includes(device))return;
|
||||
const found=editorTools.findLayout(blocks,layoutId);
|
||||
if(!found||found.rootIndex!==selected||found.depth!==1)return;
|
||||
const max=device==='mobile'?2:3;
|
||||
const count=Math.max(1,Math.min(max,Math.round(Number(value)||1)));
|
||||
found.layout[device+'_columns']=count;
|
||||
found.layout.columns.forEach(c=>c[device+'_span']=Math.min(count,c[device+'_span']||1));
|
||||
selectedChildId=layoutId;Studio.modified();Studio.draw();
|
||||
},
|
||||
selectChild(childId){const root=blocks[selected];const found=editorTools.findChildInRoot(blocks,root?.id,childId);if(!found)return;Studio.childCanvasCommand({action:'child-select',blockId:found.parent.id,childId})},
|
||||
childFieldById(childId,key,value){
|
||||
const parent=blocks[selected];const found=editorTools.findChildInRoot(blocks,parent?.id,childId);
|
||||
if(!found||!schema.blocks[found.child.type]?.fields?.includes(key)||!['title','subtitle','description','image','button','href'].includes(key))return;
|
||||
found.child[key]=String(value).slice(0,1500);selectedChildId=childId;Studio.modified(true);
|
||||
},
|
||||
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(savePromise){toast('保存仍在进行,请稍后切换页面',true);$('#pageSelect').value=pageId;return}if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/studio-draft`);draftRevision=data.revision;blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;snapshots=[];snapshotCursor=-1;lastContinuousEdit=0;blockSearch='';$('#blockSearch').value='';recordSnapshot();Studio.updateHistoryButtons();$('#saveStatus').textContent=draftRevision?'草稿 v'+draftRevision:'未修改';$('#saveStatus').classList.remove('dirty');Studio.draw();Studio.refreshPreview()},
|
||||
async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<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=editorTools.duplicate(saved.block,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();},
|
||||
renderPalette(){let p=$('#palette');p.innerHTML=Object.entries(schema.blocks).map(([key,b])=>`<button onclick="Studio.add('${key}')"><span>${html(b.icon)}</span>${html(b.name)}</button>`).join('')},
|
||||
toggleLibrary(force){libraryOpen=typeof force==='boolean'?force:!libraryOpen;const el=$('#sfLibraryPanel'),btn=$('#sfAddBlock');if(el)el.hidden=!libraryOpen;if(btn){btn.setAttribute('aria-expanded',String(libraryOpen));btn.textContent=libraryOpen?'× 收起区块库':'+ 添加内容区块'};Studio.updateGuidance()},
|
||||
async choosePage(id){if(savePromise){toast('保存仍在进行,请稍后切换页面',true);$('#pageSelect').value=pageId;return}if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;selectedChildId=null;insertAfterId=null;Studio.showInsertHint();$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/studio-draft`);draftRevision=data.revision;blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;snapshots=[];snapshotCursor=-1;lastContinuousEdit=0;blockSearch='';$('#blockSearch').value='';recordSnapshot();Studio.updateHistoryButtons();$('#saveStatus').textContent=draftRevision?'草稿 v'+draftRevision:'未修改';$('#saveStatus').classList.remove('dirty');Studio.draw();Studio.refreshPreview()},
|
||||
async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<div class="sf-shared-palette-row"><strong>${html(x.name)} <small>v${x.version}</small></strong><div><button type="button" onclick="Studio.insertReusable('${html(x.id)}')" title="复制后独立编辑">复制</button><button type="button" onclick="Studio.insertReusable('${html(x.id)}',true)" title="可以在检查后选择性同步的公共组件">关联插入</button></div></div>`).join('')||'<span style="font-size:12px;opacity:.55;padding:10px">暂无保存的组件</span>'}catch(e){console.warn(e)}},
|
||||
async saveReusable(){
|
||||
const root=blocks[selected];
|
||||
const found=selectedChildId?editorTools.findChildInRoot(blocks,root?.id,selectedChildId):null;
|
||||
if(selectedChildId&&!found)return toast('当前子组件已失效,请重新选中后保存',true);
|
||||
const block=found?.child||root;
|
||||
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,linked=false){
|
||||
const saved=reusables.find(x=>x.id===id);if(!saved)return;
|
||||
const copy=editorTools.duplicate(saved.block,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));
|
||||
if(linked)copy.shared_ref={id:saved.id,version:saved.version,hash:saved.snapshot_hash};
|
||||
const root=blocks[selected];
|
||||
const found=selectedChildId?editorTools.findChildInRoot(blocks,root?.id,selectedChildId):null;
|
||||
if(selectedChildId&&!found)return toast('当前子组件已失效,请重新选中后插入',true);
|
||||
// A nested reusable must never introduce a third layout container level.
|
||||
// Layout templates remain insertable as top-level blocks.
|
||||
if(found && copy.type!=='layout'){
|
||||
const parent=found.child.type==='layout'?found.child:found.parent;
|
||||
const column=found.child.type==='layout'?parent.columns[0]:parent.columns[found.columnIndex];
|
||||
if(!column||column.blocks.length>=60)return toast('此列已达到组件数量限制',true);
|
||||
const after=found.child.type==='layout'?column.blocks.length:found.childIndex+1;
|
||||
column.blocks.splice(after,0,copy);
|
||||
selected=found.parentIndex;selectedChildId=copy.id;
|
||||
}else{
|
||||
const foundIndex=insertAfterId?editorTools.findTopIndex(blocks,insertAfterId):-1;
|
||||
const at=foundIndex>=0?foundIndex+1:blocks.length;
|
||||
if(blocks.length>=60)return toast('页面顶层最多60个组件',true);
|
||||
blocks.splice(at,0,copy);selected=at;selectedChildId=null;
|
||||
}
|
||||
insertAfterId=null;Studio.showInsertHint();Studio.modified();Studio.draw();Studio.toggleLibrary(false);
|
||||
toast(linked?'已关联公共组件:修改源组件后仍需要审核并选择同步':'已插入独立组件副本;不会自动跟随更新');
|
||||
},
|
||||
detachShared(){
|
||||
const item=selectedChildId?editorTools.findChildInRoot(blocks,blocks[selected]?.id,selectedChildId)?.child:blocks[selected];
|
||||
if(!item?.shared_ref)return toast('当前区块未关联公共组件',true);
|
||||
if(!confirm('解除关联后,本区块仍保留当前内容,但以后不会参与公共组件同步。确定吗?'))return;
|
||||
delete item.shared_ref;Studio.modified();Studio.draw();toast('已解除关联。请保存草稿');
|
||||
},
|
||||
async showSharedComponents(){
|
||||
if(!reusables.length)await Studio.loadReusables();
|
||||
const dlg=$('#sharedComponentModal');if(!dlg.open)dlg.showModal();sharedPreview=null;
|
||||
$('#sharedComponentBody').innerHTML=reusables.map(x=>`<article class="sf-shared-card"><div><strong>${html(x.name)}</strong><small>当前版本 v${x.version} · 不会自动更新页面</small></div><div class="sf-shared-actions"><button type="button" onclick="Studio.sharedUsage('${html(x.id)}')">查看引用与同步</button><button type="button" onclick="Studio.sharedVersions('${html(x.id)}')">版本记录</button><button type="button" onclick="Studio.sharedReplace('${html(x.id)}')">用当前选中区块创建新版</button></div></article>`).join('')||'<p>暂无公共组件。先在页面里选择一个区块,再通过左侧「保存当前组件」创建。</p>';
|
||||
},
|
||||
async sharedVersions(id){
|
||||
try{const r=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/versions`);
|
||||
$('#sharedComponentDetail').innerHTML='<h3>组件历史版本</h3><p>恢复历史不会覆盖已有版本,而是创建新的源组件版本。</p>'+r.items.map(x=>`<div class="sf-shared-history"><span>v${x.version} · ${html(x.created_at)} · SHA ${html(x.snapshot_hash.slice(0,12))}</span><button type="button" class="btn ui-btn ghost" onclick="Studio.sharedRestore('${html(id)}',${x.version})">恢复为新版本</button></div>`).join('');
|
||||
}catch(e){toast(e.message,true)}
|
||||
},
|
||||
async sharedRestore(id,version){
|
||||
const current=reusables.find(x=>x.id===id);
|
||||
if(!current)return toast('组件信息已过期,请重新加载',true);
|
||||
if(!confirm(`将历史 v${version} 的内容恢复为全新的组件版本?所有网站页面仍保持不变。`))return;
|
||||
try{const snapshot=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/versions/${version}`);
|
||||
await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}`,req('PUT',{
|
||||
name:current.name,expected_version:current.version,block:snapshot.block}));
|
||||
await Studio.loadReusables();await Studio.showSharedComponents();await Studio.sharedVersions(id);
|
||||
toast('历史组件已恢复为新的源版本,请检查页面后再同步');
|
||||
}catch(e){toast('恢复失败:'+e.message,true)}
|
||||
},
|
||||
async sharedReplace(id){
|
||||
const chosen=selectedChildId?editorTools.findChildInRoot(blocks,blocks[selected]?.id,selectedChildId)?.child:blocks[selected];
|
||||
const current=reusables.find(x=>x.id===id);if(!chosen||!current)return toast('请先选择页面中的区块',true);
|
||||
if(!confirm(`将当前选中的区块保存为「${current.name}」的新版本?已发布页面不会变化。`))return;
|
||||
try{await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}`,req('PUT',{
|
||||
name:current.name,expected_version:current.version,block:chosen}));
|
||||
await Studio.loadReusables();await Studio.showSharedComponents();toast('新版本已保存。请检查引用页面后选择性同步');
|
||||
}catch(e){toast(e.message,true)}
|
||||
},
|
||||
async sharedUsage(id){
|
||||
try{sharedPreview=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/usage`);
|
||||
const preview=sharedPreview;
|
||||
$('#sharedComponentDetail').innerHTML=`<h3>${html(preview.component.name)} · 同步前检查</h3><p>只同步勾选的页面,并新建草稿;已发布网站不会立即变动。</p>`+
|
||||
(preview.references.length?preview.references.map(x=>`<label class="sf-shared-usage"><input type="checkbox" class="sf-shared-select" value="${html(x.entry_id)}" ${x.eligible?'':'disabled'}><span><strong>${html(x.title)}</strong><small>${html(x.route)} · v${html(x.current_version)} → v${html(preview.component.version)} ${x.reason?' · '+html(x.reason):''}</small></span></label>`).join(''):'<p>尚无关联页面。插入时请选择「关联插入」。</p>')+
|
||||
'<button type="button" class="ui-btn primary" onclick="Studio.sharedApply()">确认并同步勾选的页面草稿</button>';
|
||||
}catch(e){toast('读取引用失败:'+e.message,true)}
|
||||
},
|
||||
async sharedApply(){
|
||||
if(!sharedPreview)return;
|
||||
if(dirty)return toast('请先保存当前页面修改,再进行组件同步',true);
|
||||
const ids=[...new Set([...document.querySelectorAll('.sf-shared-select:checked')].map(x=>x.value))];
|
||||
if(!ids.length)return toast('请先选择可以同步的页面',true);
|
||||
if(!confirm(`确认更新 ${ids.length} 个页面的设计草稿?已发布页面不变,稍后需要逐页审核发布。`))return;
|
||||
try{const id=sharedPreview.component.id;
|
||||
const resp=await api(`/api/sites/${siteId}/components/${encodeURIComponent(id)}/sync`,req('POST',{plan_token:sharedPreview.plan_token,entry_ids:ids}));
|
||||
$('#sharedComponentModal').close();sharedPreview=null;
|
||||
if(ids.includes(pageId))await Studio.choosePage(pageId);
|
||||
toast(`已为 ${resp.updated.length} 个页面保存新草稿;请分别检查并发布`);
|
||||
}catch(e){toast('同步取消:'+e.message,true);await Studio.sharedUsage(sharedPreview.component.id)}
|
||||
},
|
||||
renderPalette(){
|
||||
const panel=$('#palette'),summary=$('#paletteSummary');if(!panel||!schema?.blocks)return;
|
||||
const grouped=window.SiteForgeStudioExperience.groupBlocks(schema.blocks,paletteSearch);
|
||||
const order=[['basic','常用内容'],['visual','图片与展示'],['business','产品与业务'],['advanced','复杂布局']];
|
||||
panel.innerHTML=order.map(([type,title])=>{
|
||||
const items=grouped[type]||[];if(!items.length)return '';
|
||||
return `<section class="sf-palette-group" aria-label="${title}"><h4>${title}</h4><div class="sf-palette-grid">${items.map(({key,block})=>`<button type="button" onclick="Studio.add('${html(key)}')"><span aria-hidden="true">${html(block.icon)}</span>${html(block.name)}</button>`).join('')}</div></section>`;
|
||||
}).join('')||'<p class="sf-palette-empty">没有找到对应组件,试试“文字”或“图片”。</p>';
|
||||
summary.textContent='找到 '+Object.values(grouped).reduce((n,x)=>n+x.length,0)+' 个可添加组件';
|
||||
},
|
||||
filterPalette(value){paletteSearch=String(value||'').slice(0,70);Studio.renderPalette()},
|
||||
clearPaletteSearch(){paletteSearch='';const inp=$('#paletteSearch');if(inp){inp.value='';inp.focus()}Studio.renderPalette()},
|
||||
showThemePreview(){const dialog=$('#themePreviewModal');if(!dialog)return;const current=['automotive','industrial','corporate','minimal'].includes(site?.theme)?site.theme:'corporate';$('#themePreviewTheme').value=current;if(!dialog.open)dialog.showModal();Studio.themeDevice('desktop');Studio.updateThemePreview()},
|
||||
closeThemePreview(){const dialog=$('#themePreviewModal');if(dialog?.open)dialog.close();const frame=$('#themePreviewFrame');if(frame)frame.removeAttribute('src')},
|
||||
themeDevice(device){
|
||||
const width={desktop:'100%',tablet:'780px',phone:'390px'}[device];if(!width)return;
|
||||
$('#themePreviewFrameShell').style.width=width;
|
||||
document.querySelectorAll('[data-theme-device]').forEach(btn=>btn.setAttribute('aria-pressed',String(btn.dataset.themeDevice===device)));
|
||||
},
|
||||
updateThemePreview(){
|
||||
const theme=$('#themePreviewTheme')?.value,page=$('#themePreviewPage')?.value;
|
||||
const url=window.SiteForgeStudioExperience.previewUrl(theme,page);
|
||||
if(!url)return;
|
||||
const frame=$('#themePreviewFrame'),status=$('#themePreviewStatus');if(!frame)return;
|
||||
status.textContent='正在加载主题示例…(不影响已保存的页面)';
|
||||
frame.onload=()=>{if($('#themePreviewModal')?.open)status.textContent='已加载示例。可切换主题、页面类型和设备继续比较。'};
|
||||
frame.onerror=()=>{status.textContent='示例加载失败,请稍后重试。'};
|
||||
frame.src=url;
|
||||
}, toggleLibrary(force){libraryOpen=typeof force==='boolean'?force:!libraryOpen;const el=$('#sfLibraryPanel'),btn=$('#sfAddBlock');if(el)el.hidden=!libraryOpen;if(btn){btn.setAttribute('aria-expanded',String(libraryOpen));btn.textContent=libraryOpen?'× 收起区块库':'+ 添加内容区块'};Studio.updateGuidance()},
|
||||
showHelp(){const dlg=$('#sfGuideDialog');if(dlg&&!dlg.open)dlg.showModal()},
|
||||
updateGuidance(){const one=$('#sfStep1'),two=$('#sfStep2'),three=$('#sfStep3');if(!one||!two||!three)return;one.classList.toggle('active',selected<0||libraryOpen);two.classList.toggle('active',selected>=0&&!dirty&&!libraryOpen);three.classList.toggle('active',dirty);},
|
||||
draw(){
|
||||
@@ -39,10 +316,10 @@ const Studio={
|
||||
changeInspectorTab(tab){if(!['content','style','responsive'].includes(tab))return;inspectorTab=tab;Studio.applyInspectorTab()},
|
||||
applyInspectorTab(){for(const key of ['content','style','responsive']){const active=key===inspectorTab;const btn=$('#tab'+key.charAt(0).toUpperCase()+key.slice(1));btn?.setAttribute('aria-selected',String(active));const pane=$('[data-inspector-section="'+key+'"]');if(pane)pane.hidden=!active}},
|
||||
togglePane(which){const type=which==='left'?'show-structure':'show-inspector';document.body.classList.toggle(type);if(type==='show-structure')document.body.classList.remove('show-inspector');else document.body.classList.remove('show-structure');},
|
||||
updateHistoryButtons(){const state=editorTools.historyState(snapshots,snapshotCursor);$('#studioUndo').disabled=!state.canUndo;$('#studioRedo').disabled=!state.canRedo},
|
||||
updateHistoryButtons(){const state=editorTools.historyState(snapshots,snapshotCursor);for(const id of ['#studioUndo','#quickUndo']){const el=$(id);if(el)el.disabled=!state.canUndo}for(const id of ['#studioRedo','#quickRedo']){const el=$(id);if(el)el.disabled=!state.canRedo}},
|
||||
|
||||
select(i){selected=i;Studio.draw();if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
|
||||
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();Studio.toggleLibrary(false);if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
|
||||
select(i){selected=i;selectedChildId=null;Studio.draw();if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
|
||||
add(type){let data=structuredClone(schema.blocks[type].defaults);data.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);data.type=type;data.visible=true;const found=insertAfterId?editorTools.findTopIndex(blocks,insertAfterId):-1;const at=found>=0?found+1:blocks.length;blocks.splice(at,0,data);selected=at;insertAfterId=null;Studio.showInsertHint();Studio.modified();Studio.draw();Studio.toggleLibrary(false);if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
|
||||
duplicate(i){const b=editorTools.duplicate(blocks[i],()=>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')},
|
||||
@@ -64,9 +341,17 @@ const Studio={
|
||||
removeItem(field,index){const rows=blocks[selected]?.[field];if(!Array.isArray(rows)||!rows[index])return;rows.splice(index,1);Studio.modified();Studio.inspector()},
|
||||
moveItem(field,index,by){const rows=blocks[selected]?.[field],target=index+by;if(!Array.isArray(rows)||target<0||target>=rows.length)return;[rows[index],rows[target]]=[rows[target],rows[index]];Studio.modified();Studio.inspector()},
|
||||
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()},
|
||||
styleField(key,value){const b=blocks[selected];if(!b)return;b.style={...(b.style||{}),[key]:value};Studio.modified()},
|
||||
applyStylePreset(preset){
|
||||
const block=blocks[selected];if(!block)return;
|
||||
const style=window.SiteForgeStudioExperience.stylePreset(site?.theme,preset);
|
||||
if(!style)return;
|
||||
block.style={...(block.style||{}),...style};
|
||||
Studio.modified();Studio.inspector();
|
||||
toast('已应用区块外观,保存草稿前可以撤销');
|
||||
},
|
||||
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection"><b>还没有选中内容</b><p>先到左侧选择一个区块,或点击“添加内容区块”。</p><button type="button" class="ui-btn primary" onclick="Studio.toggleLibrary(true)">+ 添加内容</button></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><div data-inspector-section="content">`;
|
||||
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;
|
||||
for(const field of spec.fields.filter(f=>b.type!=='layout'||!['columns','gap','tablet_columns','mobile_columns'].includes(f))){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+=Studio.arrayEditor(field,value,b.type);
|
||||
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>`;
|
||||
@@ -77,9 +362,10 @@ const Studio={
|
||||
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+=Studio.childQuickEditor(b);
|
||||
out+='</div><div data-inspector-section="responsive">';
|
||||
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>`;
|
||||
out+=`<div class="subheading">快捷布局</div><p class="prop-tip">常用排版一键设置,也可直接拖动画布中的列宽分隔线。</p><div class="sf-layout-presets"><button type="button" onclick="Studio.setLayoutPreset('equal')">均分列宽</button><button type="button" ${b.columns.length===2?'':'disabled'} onclick="Studio.setLayoutPreset('left-wide')">左宽右窄</button><button type="button" ${b.columns.length===2?'':'disabled'} onclick="Studio.setLayoutPreset('right-wide')">左窄右宽</button></div><div class="prop"><label>列间距(0–64px)</label><input type="number" min="0" max="64" value="${html(b.gap??24)}" onchange="Studio.setLayoutGap(this.value)"></div><div class="subheading">手机与平板</div><p class="prop-tip">选 1 列更适合手机阅读。以下设置只影响对应设备,不更改桌面列宽。</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>`;
|
||||
@@ -88,12 +374,14 @@ const Studio={
|
||||
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+=`<div class="layout-child-toolbar">${['richtext','image_text','collection','cta','features','layout'].map(t=>`<button onclick="Studio.addChild(${ci},'${t}')">+${t==='layout'?'内层布局':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>平板上下间距(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>`;
|
||||
out+=`</div><div data-inspector-section="style"><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>从图库选择图片</label><button type="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></div>`;
|
||||
out+=`</div><div data-inspector-section="style"><div class="subheading">快速设置外观</div><p class="prop-tip">不必先调色值:选择样式后会立即更新预览,也可以撤销。</p><div class="sf-style-presets"><button type="button" onclick="Studio.applyStylePreset('clean')">清爽白底</button><button type="button" onclick="Studio.applyStylePreset('soft')">柔和浅底</button><button type="button" onclick="Studio.applyStylePreset('feature')">重点深底</button></div><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>从图库选择图片</label><button type="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></div>`;
|
||||
const sharing=selectedChildId?editorTools.findChildInRoot(blocks,b.id,selectedChildId)?.child?.shared_ref:b.shared_ref;
|
||||
if(sharing)out+=`<div class="sf-share-info"><strong>已关联公共组件 · v${html(sharing.version)}</strong><p>此区块将来可以按需同步新版。直接修改后会被视为本地定制。</p><button type="button" class="ui-btn ghost" onclick="Studio.detachShared()">解除关联,保留当前内容</button></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;Studio.applyInspectorTab();
|
||||
},
|
||||
@@ -115,7 +403,7 @@ const Studio={
|
||||
try{
|
||||
const doc=await api(`/api/sites/${siteId}/pages/${page}/editor-preview`,{...req('POST',{blocks,page_template:kind,layout_mode:layoutMode,site_design:siteDesignPreview}),signal:previewController.signal});
|
||||
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest))return;
|
||||
frame.srcdoc=doc;$('#previewState').textContent='实时预览 · 已更新';$('#previewDot').classList.remove('loading','error');
|
||||
frame.onload=()=>Studio.highlightCanvas();frame.srcdoc=doc;$('#previewState').textContent='实时预览 · 已更新';$('#previewDot').classList.remove('loading','error');
|
||||
}catch(e){
|
||||
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest)||e.name==='AbortError')return;
|
||||
$('#previewState').textContent='预览更新失败';$('#previewDot').classList.remove('loading');$('#previewDot').classList.add('error');toast('预览失败:'+e.message,true);
|
||||
@@ -161,6 +449,23 @@ const Studio={
|
||||
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()},
|
||||
drawSiteNav(){
|
||||
const root=$('#siteNavRows');root.replaceChildren();
|
||||
const draw=(items,depth=0)=>items.forEach((node,index)=>{
|
||||
const line=document.createElement('div');line.className='sf-nav-item';line.style.marginLeft=`${depth*16}px`;
|
||||
const title=document.createElement('input');title.placeholder='菜单名称';title.maxLength=48;title.value=node.title||'';title.setAttribute('aria-label','菜单名称');title.oninput=()=>node.title=title.value;
|
||||
const route=document.createElement('input');route.placeholder='/products/';route.value=node.route||'/';route.setAttribute('aria-label','站内路径');route.oninput=()=>node.route=route.value;
|
||||
line.append(title,route);
|
||||
const op=(label,fn)=>{const button=document.createElement('button');button.type='button';button.className='ui-btn ghost sf-nav-action';button.textContent=label;button.onclick=fn;line.append(button)};
|
||||
if(depth<2)op('+ 子菜单',()=>{node.children=node.children||[];node.children.push({title:'新菜单',route:'/',children:[]});Studio.drawSiteNav()});
|
||||
if(index)op('↑',()=>{[items[index-1],items[index]]=[items[index],items[index-1]];Studio.drawSiteNav()});
|
||||
if(index<items.length-1)op('↓',()=>{[items[index+1],items[index]]=[items[index],items[index+1]];Studio.drawSiteNav()});
|
||||
op('删除',()=>{items.splice(index,1);Studio.drawSiteNav()});
|
||||
root.append(line);if(node.children?.length)draw(node.children,depth+1);
|
||||
});
|
||||
draw(siteNavDraft);
|
||||
},
|
||||
addSiteNav(){siteNavDraft.push({title:'新菜单',route:'/',children:[]});Studio.drawSiteNav()},
|
||||
async showSiteDesign(){try{
|
||||
const result=await api(`/api/sites/${siteId}/site-design`);
|
||||
siteDesign=result.config;siteDesignRevision=result.revision;siteDesignPreview=structuredClone(siteDesign);
|
||||
@@ -168,7 +473,7 @@ const Studio={
|
||||
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);
|
||||
siteNavDraft=structuredClone(siteDesign.nav||[]);Studio.drawSiteNav();
|
||||
form.elements.namedItem('mobile_tabs_enabled').checked=Boolean(siteDesign.mobile_tabs?.enabled);
|
||||
Studio.drawMobileTabs(siteDesign.mobile_tabs?.items||[]);
|
||||
Studio.toggleMobileTabsForm();
|
||||
@@ -194,8 +499,7 @@ const Studio={
|
||||
collectSiteDesign(){const form=$('#siteDesignForm');if(!siteDesign)throw Error('请先打开全站设计');
|
||||
const v=(n)=>form.elements.namedItem(n).value;
|
||||
const yes=(n)=>form.elements.namedItem(n).checked;
|
||||
let nav;try{nav=JSON.parse(v('nav'))}catch(e){throw Error('导航必须是合法 JSON:'+e.message)}
|
||||
if(!Array.isArray(nav))throw Error('导航必须是数组');
|
||||
const nav=structuredClone(siteNavDraft);
|
||||
const tabs=[...$('#mobileTabsRows').children].map(row=>({title:row.querySelector('.mt-title').value.trim(),icon:row.querySelector('.mt-icon').value,route:row.querySelector('.mt-route').value.trim(),action:row.querySelector('.mt-action').value}));
|
||||
if(yes('mobile_tabs_enabled')&&!tabs.length)throw Error('开启底部 Tabs 后,请至少添加一个按钮');
|
||||
return {tokens:structuredClone(siteDesign.tokens),mobile_tabs:{enabled:yes('mobile_tabs_enabled'),items:tabs},header:{announcement:v('announcement'),show_announcement:yes('show_announcement'),show_search:yes('show_search'),
|
||||
@@ -211,12 +515,73 @@ const Studio={
|
||||
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;
|
||||
async publishSiteDesign(){
|
||||
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)}},
|
||||
try{
|
||||
const review=await api(`/api/sites/${siteId}/site-design/review`);
|
||||
siteDesignReviewToken=review.plan_token;
|
||||
const box=$('#siteDesignReviewBody');box.replaceChildren();
|
||||
const lead=document.createElement('p');lead.textContent=`草稿 v${review.revision} · ${review.change_count} 处字段变更 · 影响本站下一次构建的全部页面`;box.append(lead);
|
||||
if(!review.change_count){const empty=document.createElement('p');empty.textContent='没有任何实际配置变化。可以返回编辑。';box.append(empty);}
|
||||
review.changed_fields.forEach(change=>{
|
||||
const article=document.createElement('article');article.className='sf-review-change';
|
||||
const label=document.createElement('strong');label.textContent=Studio.designFieldLabel(change.field);article.append(label);
|
||||
const old=document.createElement('p');old.textContent='原配置:'+Studio.formatDesignDifference(change.before,change.field);
|
||||
const newer=document.createElement('p');newer.textContent='新配置:'+Studio.formatDesignDifference(change.after,change.field);
|
||||
article.append(old,newer);box.append(article);
|
||||
});
|
||||
$('#siteDesignReviewModal').showModal();
|
||||
}catch(e){toast('审核预览失败:'+e.message,true)}
|
||||
},
|
||||
designFieldLabel(key){const names={header:'页眉',footer:'页脚',nav:'网站导航',mobile_tabs:'手机底部导航',tokens:'全站风格',responsive:'响应式布局',announcement:'提示文字',show_announcement:'显示提示栏',show_search:'显示搜索',show_cta:'显示联系按钮',cta_text:'按钮文字',cta_href:'按钮地址',description:'介绍文字',email:'邮箱',title:'标题',items:'导航项目',enabled:'是否启用'};return key.split('.').map(v=>names[v]||v).join(' · ')},
|
||||
formatDesignDifference(value,field=''){
|
||||
if(typeof value==='boolean')return value?'已开启':'已关闭';
|
||||
if(value==null)return '未设置';
|
||||
if(field==='nav'&&Array.isArray(value)){
|
||||
const parts=[];const walk=(items,depth)=>items.forEach(n=>{parts.push(' '.repeat(depth)+(n.title||'未命名')+' → '+(n.route||'/'));walk(n.children||[],depth+1)});walk(value,0);
|
||||
return parts.length?parts.join('\n'):'(未配置导航)';
|
||||
}
|
||||
if(field==='mobile_tabs'&&value&&typeof value==='object'){
|
||||
return value.enabled?'已启用:'+(value.items||[]).map(v=>v.title+' → '+(v.action==='menu'?'打开菜单':v.route)).join(' · '):'未启用';
|
||||
}
|
||||
if(Array.isArray(value))return value.map(v=>typeof v==='object'?(v.title||JSON.stringify(v)):v).join('、')||'空列表';
|
||||
const text=typeof value==='string'?value:JSON.stringify(value);
|
||||
return text.length>440?text.slice(0,440)+'…':text;
|
||||
},
|
||||
async confirmReviewedSiteDesign(){
|
||||
if(!siteDesignReviewToken)return toast('请先查看发布差异',true);
|
||||
try{
|
||||
await api(`/api/sites/${siteId}/site-design/publish-reviewed`,req('POST',{
|
||||
expected_revision:siteDesignRevision,plan_token:siteDesignReviewToken}));
|
||||
siteDesignReviewToken=null;siteDesignRevision=0;siteDesignPreview=null;
|
||||
$('#siteDesignReviewModal').close();$('#siteDesignModal').close();
|
||||
$('#siteDesignStatus').textContent='全站设计已发布到内容库,下一次构建后生效';
|
||||
await Studio.refreshPreview();toast('全站设计已审核并发布,请构建及部署网站。');
|
||||
}catch(e){toast('发布失败:'+e.message,true)}
|
||||
},
|
||||
async showSiteDesignHistory(){
|
||||
try{
|
||||
const items=await api(`/api/sites/${siteId}/site-design/publications`),box=$('#siteDesignHistoryBody');box.replaceChildren();
|
||||
if(!items.length){box.textContent='尚无发布记录';}
|
||||
for(const item of items){
|
||||
const row=document.createElement('div');row.className='sf-review-change';
|
||||
const title=document.createElement('strong');title.textContent=`${item.published_at} · 版本 ${item.revision}`;row.append(title);
|
||||
const show=document.createElement('button');show.type='button';show.className='ui-btn ghost';show.textContent='查看变更';
|
||||
show.onclick=async()=>{try{const detail=await api(`/api/sites/${siteId}/site-design/publications/${item.id}`);const p=document.createElement('p');p.textContent=detail.changes.map(x=>x.field).join('、')||'无字段变化';row.append(p);show.disabled=true;}catch(e){toast(e.message,true)}};
|
||||
const restore=document.createElement('button');restore.type='button';restore.className='ui-btn ghost';restore.textContent='恢复为新草稿';
|
||||
restore.onclick=async()=>{
|
||||
if(!confirm('确认以这个历史版本生成新的全站设计草稿?不会直接更改线上页面。'))return;
|
||||
try{const live=await api(`/api/sites/${siteId}/site-design/live-hash`);
|
||||
await api(`/api/sites/${siteId}/site-design/publications/${item.id}/restore`,req('POST',{
|
||||
snapshot:'after',expected_revision:siteDesignRevision,expected_live_hash:live.live_hash}));
|
||||
$('#siteDesignHistoryModal').close();await Studio.showSiteDesign();toast('历史版本已恢复为新草稿,请检查后发布');
|
||||
}catch(e){toast(e.message,true)}
|
||||
};
|
||||
row.append(show,restore);box.append(row);
|
||||
}
|
||||
$('#siteDesignHistoryModal').showModal();
|
||||
}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('全站草稿已放弃')
|
||||
@@ -227,14 +592,25 @@ const Studio={
|
||||
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()},
|
||||
setColumnWidth(i,value){const b=blocks[selected];if(!b||b.type!=='layout'||!b.columns[i])return;b.columns[i].width=Math.round(Math.max(.5,Math.min(4,Number(value)||1))*100)/100;Studio.modified();Studio.inspector()},
|
||||
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)}},
|
||||
applyMedia(url){
|
||||
const root=blocks[selected];if(!root)return;
|
||||
const found=selectedChildId?editorTools.findChildInRoot(blocks,root.id,selectedChildId):null;
|
||||
if(selectedChildId&&!found)return toast('当前子组件已失效,请重新选中后添加图片',true);
|
||||
const block=found?.child||root;
|
||||
const fields=schema?.blocks?.[block.type]?.fields||[];
|
||||
if(block.type==='gallery')block.images=[...(block.images||[]),{url,alt:''}];
|
||||
else if(block.type==='carousel')block.items=[...(block.items||[]),{image:url,alt:'',caption:''}];
|
||||
else if(fields.includes('image'))block.image=url;
|
||||
else return toast('选中的组件不支持图片,请先选择一个图片组件',true);
|
||||
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.applyMedia(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++){
|
||||
@@ -250,22 +626,10 @@ window.addEventListener('DOMContentLoaded',()=>Studio.init());
|
||||
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
|
||||
|
||||
window.addEventListener('message',event=>{
|
||||
const frame=document.querySelector('#sitePreview');
|
||||
if(!frame||event.source!==frame.contentWindow)return;
|
||||
const data=event.data||{};
|
||||
if(data.source==='siteforge-select-block'){
|
||||
if(typeof data.blockId!=='string'||data.blockId.length>150)return;
|
||||
const findTop=rows=>{for(let i=0;i<rows.length;i++){const row=rows[i];if(row.id===data.blockId)return i;if(row.type==='layout'&&row.columns?.some(col=>col.blocks?.some(child=>child.id===data.blockId)))return i}return -1};
|
||||
const index=findTop(blocks);if(index>=0){selected=index;Studio.draw();if(window.matchMedia('(max-width:1060px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}}
|
||||
return;
|
||||
}
|
||||
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('已修改文字,点击保存页面写入数据库');
|
||||
const frame=document.querySelector('#sitePreview');
|
||||
if(!frame||event.source!==frame.contentWindow)return;
|
||||
const data=event.data;
|
||||
if(data?.source==='siteforge-studio-canvas')Studio.canvasCommand(data);
|
||||
});
|
||||
|
||||
window.addEventListener('keydown',event=>{
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
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');
|
||||
// Support shareable searches from automotive homepage without changing page URLs.
|
||||
const params=new URLSearchParams(window.location.search);
|
||||
if(q && params.has('q')) q.value=(params.get('q')||'').slice(0,120);
|
||||
if(b && params.has('brand') && [...b.options].some(o=>o.value===params.get('brand'))) b.value=params.get('brand');
|
||||
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,147 @@
|
||||
/* V8.22 trusted, sandboxed studio canvas bridge. No arbitrary user code. */
|
||||
(function(){
|
||||
'use strict';
|
||||
const source='siteforge-studio-canvas';
|
||||
const list=()=>Array.from(document.querySelectorAll('.sf-section[data-block]')).filter(n=>!n.parentElement.closest('.sf-section[data-block]'));
|
||||
const send=(action,id,extra={})=>parent.postMessage({source,action,blockId:id,...extra},'*');
|
||||
let active='', dragging='',resize=null;
|
||||
const all=()=>Array.from(document.querySelectorAll('.sf-section[data-block]'));
|
||||
const style=document.createElement('style');
|
||||
style.textContent=`
|
||||
.sf-section[data-block]{position:relative}
|
||||
.sf-section[data-block].sf-canvas-selected{outline:2px solid #ec7c35!important;outline-offset:-2px}
|
||||
.sf-section[data-block].sf-canvas-drop{box-shadow:inset 0 4px 0 #ef8741!important}
|
||||
.sf-section[data-block]:hover{outline:1px dashed #eb995a;outline-offset:-1px}
|
||||
.sf-canvas-toolbar{position:absolute;right:7px;top:5px;z-index:2147480000;display:none;align-items:center;gap:3px;padding:5px;background:#263447;color:#fff;border-radius:9px;box-shadow:0 5px 20px #20304450;font:12px/1.4 system-ui,sans-serif;white-space:nowrap;pointer-events:auto}
|
||||
.sf-canvas-selected>.sf-canvas-toolbar,.sf-section[data-block]:hover>.sf-canvas-toolbar,.sf-canvas-toolbar:focus-within{display:flex}
|
||||
.sf-canvas-toolbar button{background:transparent;color:inherit;border:0;cursor:pointer;padding:5px 7px;border-radius:5px;font:inherit}
|
||||
.sf-canvas-toolbar button:hover,.sf-canvas-toolbar button:focus-visible{background:#52687e;outline:1px solid #fff6}
|
||||
.sf-canvas-toolbar .sf-canvas-drag{cursor:grab;background:#496174}
|
||||
.sf-canvas-toolbar .sf-canvas-danger:hover{background:#ab4d46}
|
||||
.sf-section.sf-canvas-child-selected{outline:2px solid #0e97ac!important;outline-offset:-2px}
|
||||
.sf-nested-toolbar{position:absolute;top:6px;left:6px;z-index:2147480001;display:none;gap:3px;background:#126c80;color:#fff;padding:5px;border-radius:8px;font:12px system-ui;pointer-events:auto}
|
||||
.sf-canvas-child-selected>.sf-nested-toolbar,.sf-section[data-block]:hover>.sf-nested-toolbar,.sf-nested-toolbar:focus-within{display:flex}
|
||||
.sf-nested-toolbar button{color:#fff;background:transparent;border:0;border-radius:5px;cursor:pointer;padding:5px 7px}
|
||||
.sf-nested-toolbar button:hover,.sf-nested-toolbar button:focus-visible{background:#248ea4;outline:1px solid #fff6}
|
||||
.sf-canvas-hint{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);background:#1c293ee8;color:white;padding:9px 13px;border-radius:9px;z-index:2147480000;font:12px system-ui;pointer-events:none}
|
||||
.sf-layout-grid{position:relative}
|
||||
.sf-canvas-resize{position:absolute;z-index:2147480000;top:4px;bottom:4px;width:12px;transform:translateX(-50%);cursor:col-resize;touch-action:none;opacity:0;display:flex;align-items:center;justify-content:center}
|
||||
.sf-canvas-resize:after{content:'⋮';background:#e77632;color:white;box-shadow:0 2px 8px #2225;border-radius:8px;font:20px/26px system-ui;text-align:center;width:19px;min-width:19px}
|
||||
.sf-section:hover .sf-canvas-resize,.sf-canvas-resize:focus-visible,.sf-canvas-resize.sf-resizing{opacity:1}
|
||||
[contenteditable].sf-canvas-editing{outline:2px dashed #df7435!important;border-radius:3px;cursor:text!important}
|
||||
@media(max-width:820px){.sf-canvas-resize{display:none!important}}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
const types={title:'h1,h2',description:'.sf-hero-copy p,.sf-image-text p,.sf-cta-wrap p'};
|
||||
function activate(id,notify=true){
|
||||
active=id||'';for(const el of all())el.classList.toggle('sf-canvas-selected',el.dataset.block===id);
|
||||
if(notify&&id)send('select',id);
|
||||
}
|
||||
const topSections=list();
|
||||
for(const block of topSections){
|
||||
const id=block.dataset.block;
|
||||
const bar=document.createElement('div');bar.className='sf-canvas-toolbar';bar.setAttribute('role','toolbar');bar.setAttribute('aria-label','区块快捷操作');
|
||||
const controls=[['↕','拖动区块','drag'],['✎','编辑区块','edit'],['↑','上移区块','up'],['↓','下移区块','down'],['+','在下方添加区块','add-after'],['⧉','复制区块','duplicate'],['×','删除区块','delete']];
|
||||
for(const [txt,label,action] of controls){
|
||||
const b=document.createElement('button');b.type='button';b.textContent=txt;b.title=label;b.setAttribute('aria-label',label);b.dataset.action=action;
|
||||
if(action==='drag'){b.draggable=true;b.className='sf-canvas-drag';b.addEventListener('dragstart',e=>{dragging=id;e.dataTransfer.setData('text/plain',id);e.dataTransfer.effectAllowed='move';e.stopPropagation()});b.addEventListener('dragend',()=>{dragging='';list().forEach(el=>el.classList.remove('sf-canvas-drop'))})}
|
||||
else {if(action==='delete')b.className='sf-canvas-danger';b.addEventListener('click',e=>{e.preventDefault();e.stopPropagation();activate(id,false);send(action,id)})}
|
||||
bar.appendChild(b);
|
||||
}
|
||||
block.prepend(bar);
|
||||
block.addEventListener('click',event=>{
|
||||
if(event.target.closest('.sf-canvas-toolbar,.sf-canvas-resize'))return;
|
||||
if(event.target.closest('a'))event.preventDefault();
|
||||
activate(id);
|
||||
});
|
||||
block.addEventListener('dragover',event=>{if(!dragging)return;event.preventDefault();event.stopPropagation();block.classList.add('sf-canvas-drop')});
|
||||
block.addEventListener('dragleave',event=>{if(!block.contains(event.relatedTarget))block.classList.remove('sf-canvas-drop')});
|
||||
block.addEventListener('drop',event=>{if(!dragging)return;event.preventDefault();event.stopPropagation();block.classList.remove('sf-canvas-drop');if(dragging!==id)send('reorder',dragging,{targetId:id});dragging=''});
|
||||
}
|
||||
// Rich text is not edited as plain text: modifying sanitized HTML would lose formatting.
|
||||
for(const section of topSections){
|
||||
const id=section.dataset.block;
|
||||
for(const [field,selector] of Object.entries(types)){
|
||||
const target=section.querySelector(selector);if(!target||target.closest('.sf-section[data-block]')!==section)continue;
|
||||
target.title='双击直接修改'+(field==='title'?'标题':'描述');
|
||||
target.addEventListener('dblclick',event=>{
|
||||
event.stopPropagation();activate(id);const before=target.textContent;
|
||||
target.contentEditable='true';target.classList.add('sf-canvas-editing');target.focus();
|
||||
let closed=false;
|
||||
const finish=commit=>{
|
||||
if(closed)return;closed=true;target.removeAttribute('contenteditable');target.classList.remove('sf-canvas-editing');
|
||||
const value=target.textContent.replace(/\r/g,'').trim();
|
||||
if(!commit)target.textContent=before;
|
||||
else if(value!==before&&value.length<=500)send('inline-text',id,{field,value});
|
||||
else if(value.length>500)target.textContent=before;
|
||||
};
|
||||
target.addEventListener('blur',()=>finish(true),{once:true});
|
||||
target.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();finish(false);target.blur()}if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();target.blur()} });
|
||||
});
|
||||
}
|
||||
}
|
||||
// Children of every nested layout (including second-level content) are selectable. The parent
|
||||
// checks IDs against the current schema and rejects forged/obsolete events.
|
||||
const nested=Array.from(document.querySelectorAll('.sf-layout-column > .sf-section[data-block]'));
|
||||
for(const child of nested){
|
||||
const layout=child.closest('.sf-layout-grid[data-layout]');
|
||||
if(!layout)continue;
|
||||
const parentId=layout.dataset.layout,childId=child.dataset.block;
|
||||
const push=(action,extra={})=>send(action,parentId,{childId,...extra});
|
||||
const toolbar=document.createElement('div');toolbar.className='sf-nested-toolbar';toolbar.setAttribute('role','toolbar');toolbar.setAttribute('aria-label','列内组件操作');
|
||||
for(const [symbol,label,action] of [['✎','编辑子组件','child-edit'],['↑','上移子组件','child-up'],['↓','下移子组件','child-down'],['⧉','复制子组件','child-duplicate'],['×','删除子组件','child-delete']]){
|
||||
const button=document.createElement('button');button.type='button';button.textContent=symbol;button.setAttribute('aria-label',label);
|
||||
button.addEventListener('click',event=>{event.preventDefault();event.stopPropagation();push(action)});
|
||||
toolbar.appendChild(button);
|
||||
}
|
||||
child.prepend(toolbar);
|
||||
child.addEventListener('click',event=>{if(event.target.closest('.sf-nested-toolbar'))return;if(event.target.closest('a'))event.preventDefault();event.stopPropagation();push('child-select')});
|
||||
// Only plain text fields are inline editable; rich HTML remains in the form.
|
||||
for(const [field,selector] of Object.entries(types)){
|
||||
const node=child.querySelector(selector);
|
||||
if(!node||node.closest('.sf-section[data-block]')!==child)continue;
|
||||
node.title='双击编辑列内'+(field==='title'?'标题':'描述');
|
||||
node.addEventListener('dblclick',event=>{
|
||||
event.stopPropagation();push('child-select');const original=node.textContent;
|
||||
node.contentEditable='true';node.classList.add('sf-canvas-editing');node.focus();let complete=false;
|
||||
const finish=save=>{if(complete)return;complete=true;node.removeAttribute('contenteditable');node.classList.remove('sf-canvas-editing');const text=node.textContent.replace(/\r/g,'').trim();if(!save||text.length>500)node.textContent=original;else if(text!==original)push('child-inline-text',{field,value:text})};
|
||||
node.addEventListener('blur',()=>finish(true),{once:true});
|
||||
node.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();finish(false);node.blur()}if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();node.blur()}});
|
||||
});
|
||||
}
|
||||
}
|
||||
// Desktop-only handles keep the total adjacent width stable and never free-position blocks.
|
||||
for(const grid of document.querySelectorAll('.sf-layout-grid[data-layout]')){
|
||||
const id=grid.dataset.layout;
|
||||
const cols=Array.from(grid.children).filter(el=>el.classList.contains('sf-layout-column'));
|
||||
if(cols.length<2)continue;
|
||||
for(let i=0;i<cols.length-1;i++){
|
||||
const handle=document.createElement('div');handle.className='sf-canvas-resize';handle.tabIndex=0;handle.setAttribute('role','separator');handle.setAttribute('aria-label',`拖动调整第 ${i+1} 列宽度`);
|
||||
const place=()=>{const a=cols[i].getBoundingClientRect(),g=grid.getBoundingClientRect();handle.style.left=(a.right-g.left+parseFloat(getComputedStyle(grid).columnGap||'0')/2)+'px'};
|
||||
grid.appendChild(handle);place();
|
||||
handle.addEventListener('pointerdown',e=>{
|
||||
if(e.button!==0||matchMedia('(max-width:820px)').matches)return;
|
||||
e.stopPropagation();e.preventDefault();handle.setPointerCapture(e.pointerId);handle.classList.add('sf-resizing');
|
||||
const raw=grid.style.getPropertyValue('--sf-layout-cols').trim().split(/\s+/).map(x=>parseFloat(x)||1);
|
||||
const start=e.clientX,span=Math.max(50,cols[i].getBoundingClientRect().width+cols[i+1].getBoundingClientRect().width);
|
||||
resize={id,i,raw,start,span};activate(id);
|
||||
});
|
||||
handle.addEventListener('pointermove',e=>{
|
||||
if(!resize||resize.i!==i)return;
|
||||
const v=resize.raw.slice(),total=v[i]+v[i+1];const delta=(e.clientX-resize.start)/resize.span*total;
|
||||
v[i]=Math.round(Math.min(4,Math.max(.5,resize.raw[i]+delta))*100)/100;
|
||||
v[i+1]=Math.round(Math.min(4,Math.max(.5,total-v[i]))*100)/100;
|
||||
if(Math.abs(v[i]+v[i+1]-total)>0.011)return;
|
||||
grid.style.setProperty('--sf-layout-cols',v.map(x=>x+'fr').join(' '));place();
|
||||
});
|
||||
const done=e=>{if(!resize||resize.i!==i)return;const raw=grid.style.getPropertyValue('--sf-layout-cols').trim().split(/\s+/).map(x=>parseFloat(x)||1);resize=null;handle.classList.remove('sf-resizing');send('resize-columns',id,{widths:raw});};
|
||||
handle.addEventListener('pointerup',done);handle.addEventListener('pointercancel',()=>{resize=null;handle.classList.remove('sf-resizing')});
|
||||
handle.addEventListener('keydown',e=>{if(e.key!=='ArrowLeft'&&e.key!=='ArrowRight')return;e.preventDefault();const w=grid.style.getPropertyValue('--sf-layout-cols').trim().split(/\s+/).map(x=>parseFloat(x)||1);const delta=e.key==='ArrowRight'?.1:-.1;const total=w[i]+w[i+1];const first=Math.round(Math.min(4,Math.max(.5,w[i]+delta))*100)/100,second=Math.round((total-first)*100)/100;if(second<.5||second>4)return;w[i]=first;w[i+1]=second;grid.style.setProperty('--sf-layout-cols',w.map(x=>x+'fr').join(' '));place();send('resize-columns',id,{widths:w})});
|
||||
new ResizeObserver(place).observe(grid);
|
||||
}
|
||||
}
|
||||
window.addEventListener('message',event=>{
|
||||
if(event.source!==parent||event.data?.source!=='siteforge-studio-host')return;
|
||||
if(event.data.action==='highlight'&&typeof event.data.blockId==='string'){activate(event.data.blockId,false);for(const n of nested)n.classList.toggle('sf-canvas-child-selected',n.dataset.block===event.data.childId);}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,46 @@
|
||||
/* SiteForge V8.29: presentation-only studio helpers. No persisted-schema changes. */
|
||||
'use strict';
|
||||
(function(root){
|
||||
const themes=new Set(['automotive','industrial','corporate','minimal']);
|
||||
const pages=new Set(['home','listing','product','article','about','contact']);
|
||||
const categories={
|
||||
visual:new Set(['hero','banner','carousel','gallery','image','image_text','image-grid','video','slider']),
|
||||
business:new Set(['collection','products','product_list','brand_wall','brands','features','stats','testimonials','faq','contact','form','cta','map']),
|
||||
advanced:new Set(['layout','columns','tabs','accordion','html-embed','embed','spacer','divider'])
|
||||
};
|
||||
function categoryOf(key){for(const [name,ids] of Object.entries(categories))if(ids.has(key))return name;return 'basic'}
|
||||
function groupBlocks(blocks,query=''){
|
||||
const result={basic:[],visual:[],business:[],advanced:[]};
|
||||
const q=String(query).trim().toLocaleLowerCase().slice(0,70);
|
||||
for(const [key,block] of Object.entries(blocks||{})){
|
||||
if(!block||typeof block!=='object')continue;
|
||||
const text=[key,block.name,block.description].join(' ').toLocaleLowerCase();
|
||||
if(q&&!text.includes(q))continue;
|
||||
result[categoryOf(key)].push({key,block});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function previewUrl(theme,page){
|
||||
if(!themes.has(theme)||!pages.has(page))return null;
|
||||
return `/api/factory/themes/${theme}/preview?page_type=${page}`;
|
||||
}
|
||||
/* Only CSS-token values accepted by the existing server-side block validator. */
|
||||
const designPalettes={
|
||||
automotive:['#ffffff','#14283d','#f3f6f9','#102e47'],
|
||||
industrial:['#ffffff','#193f59','#eef5f8','#104a6d'],
|
||||
corporate:['#ffffff','#262d58','#f4f3fb','#34397b'],
|
||||
minimal:['#faf9f5','#292924','#f0efe9','#30302c']
|
||||
};
|
||||
function stylePreset(theme,preset){
|
||||
const [base,ink,soft,dark]=designPalettes[themes.has(theme)?theme:'corporate'];
|
||||
switch(preset){
|
||||
case 'clean':return {background:base,text_color:ink,padding:72};
|
||||
case 'soft':return {background:soft,text_color:ink,padding:58};
|
||||
case 'feature':return {background:dark,text_color:'#ffffff',padding:90};
|
||||
default:return null;
|
||||
}
|
||||
}
|
||||
const api=Object.freeze({categoryOf,groupBlocks,previewUrl,stylePreset});
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=api;
|
||||
if(root)root.SiteForgeStudioExperience=api;
|
||||
})(typeof window!=='undefined'?window:null);
|
||||
@@ -0,0 +1,35 @@
|
||||
/* V8.27: pure journey model. Display only server-observed milestones as complete. */
|
||||
(function(root,factory){
|
||||
const api=factory();
|
||||
if(typeof module==='object'&&module.exports)module.exports=api;
|
||||
else root.SiteForgeJourney=api;
|
||||
})(typeof window!=='undefined'?window:this,function(){
|
||||
'use strict';
|
||||
const successful=new Set(['complete','completed','done','success']);
|
||||
const active=new Set(['queued','running','retrying']);
|
||||
function parse(t){const d=Date.parse(t||'');return Number.isFinite(d)?d:null}
|
||||
function plan(input){
|
||||
const site=input?.site||null, entries=input?.entries||[], builds=input?.builds||[], jobs=input?.jobs||[], history=input?.delivery?.history||[];
|
||||
if(!site)return {ready:false,steps:[],next:null,completed:0,hasBuild:false,live:false};
|
||||
const userEntries=entries.filter(e=>e.source!=='starter' && e.status!=='draft');
|
||||
const manuallyEditedHome=entries.some(e=>e.route==='/'&&e.status!=='draft'&&e.source==='starter'&&parse(e.updated_at)!==null&&parse(e.created_at)!==null&&parse(e.updated_at)>parse(e.created_at)+1000);
|
||||
const hasContent=userEntries.length>0||manuallyEditedHome;
|
||||
const lastBuild=builds.filter(x=>successful.has(x.status)).sort((a,b)=>(parse(b.created_at)||0)-(parse(a.created_at)||0))[0]||null;
|
||||
const anyRunning=jobs.some(x=>active.has(x.state));
|
||||
const changeTimes=[site.updated_at,...entries.filter(e=>e.status!=='draft').map(e=>e.updated_at)].map(parse).filter(x=>x!==null);
|
||||
const changedAfterBuild=!!lastBuild&&changeTimes.some(x=>{const built=parse(lastBuild.created_at);return built!==null&&x>built+1000});
|
||||
const hasBuild=!!lastBuild;
|
||||
const live=history.some(x=>x.target==='production' && !!x.release_id && !x.is_rollback);
|
||||
const domain=Boolean((site.domain||'').trim());
|
||||
const steps=[
|
||||
{id:'site',label:'创建网站',hint:'网站已经建立,基础资料可以随时修改。',view:'settings',done:true,optional:false},
|
||||
{id:'content',label:'准备网站内容',hint:hasContent?'已有人工添加的正式内容。':'添加页面、产品或导入旧站内容。新建时自带的示例首页不代表内容已准备好。',view:site.mode==='migration'?'import':'content',done:hasContent,optional:false},
|
||||
{id:'design',label:'调整网站样式',hint:'可视化修改页面、页眉、导航和手机布局;设计需人工确认。',view:'design',done:false,optional:true},
|
||||
{id:'build',label:'生成并预览网站',hint:anyRunning?'后台正在生成,请在构建页面查看结果。':!hasBuild?'生成静态页面后,再检查预览与质量报告。':changedAfterBuild?'已有构建版本,但内容可能已更新,请检查是否需要重新构建。':'已有构建版本;仍需人工检查预览、图片和 SEO。',view:'build',done:hasBuild&&!changedAfterBuild,optional:false},
|
||||
{id:'review',label:'审核并正式上线',hint:live?'存在正式发布记录,请核对目标网址是否正确显示。':'正式上线前需要发布条件检查、独立审批与服务器部署。',view:'launch',done:live,optional:false}
|
||||
];
|
||||
const next=steps.find(s=>!s.optional&&!s.done)||steps[4];
|
||||
return {ready:true,steps,next,completed:steps.filter(s=>!s.optional&&s.done).length,hasBuild,changedAfterBuild,live,domain,anyRunning};
|
||||
}
|
||||
return {plan};
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/* V8.28: untrusted display state only; final authorization always on server. */
|
||||
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.SiteForgeLaunch=api;})(typeof window!=='undefined'?window:this,function(){
|
||||
'use strict';
|
||||
const finished=new Set(['complete','completed','done','success']);
|
||||
function plan({site=null,build=null,requests=[],requestsKnown=false,delivery={},target='staging',previewChecked=false,preflight=null}={}){
|
||||
const version=build&&finished.has(build.status)?build:null;
|
||||
const bids=version?.id;
|
||||
const rows=Array.isArray(requests)?requests.filter(x=>x.build_id===bids):[];
|
||||
const approval=rows.some(x=>x.state==='approved')?'approved':rows.some(x=>x.state==='pending')?'pending':rows.some(x=>x.state==='rejected')?'rejected':requestsKnown&&Array.isArray(requests)?'none':'unknown';
|
||||
const safeTarget=['staging','production'].includes(target)?target:'staging';
|
||||
const preflightReady=Boolean(version&&preflight?.siteId===site?.id&&preflight?.buildId===version.id&&preflight?.target===safeTarget&&preflight?.ready===true);
|
||||
const preflightMessage=preflightReady?'服务器已通过检查,部署时还会重新验证。':preflight?.siteId===site?.id&&preflight?.buildId===bids?preflight.message:'尚未完成此版本的发布前检查';
|
||||
const configured=delivery?.configured?.[safeTarget]===true;
|
||||
const canDeploy=Boolean(version&&previewChecked&&approval==='approved'&&preflightReady&&configured);
|
||||
const history=Array.isArray(delivery?.history)?delivery.history:[];
|
||||
const matches=history.filter(x=>x.target===safeTarget&&x.release_id&&x.build_id===bids);
|
||||
return {approval,canDeploy,preflightReady,preflightMessage,hasRemoteRecord:matches.length>0,
|
||||
remoteDescription:matches.length?'存在当前版本的部署记录,但尚需实际访问确认。':'尚无当前版本在该环境的部署记录。',configured,
|
||||
target:safeTarget,hasBuild:!!version};
|
||||
}
|
||||
return {plan};
|
||||
});
|
||||
@@ -3,4 +3,45 @@
|
||||
/* V8.12: task progress and dangerous actions, consistent focus and mobile sizing */
|
||||
.btn-danger{border:1px solid #f1c3c7;color:#a92840;background:#fff5f6}.btn-danger:hover{background:#feecee}.btn:disabled{opacity:.55;cursor:not-allowed}.build-row{min-width:0}.build-meta{overflow-wrap:anywhere}.site-card:focus-visible,.quick-card:focus-visible{outline:3px solid #80aaf6;outline-offset:2px}@media(max-width:570px){.build-row{align-items:flex-start;flex-wrap:wrap}.build-row>.inline-actions{width:100%;padding-left:0}.site-picker-wrap select{max-width:130px}}
|
||||
|
||||
/* V8.27 guided creation and actionable journey. Keep the original admin styling. */
|
||||
[hidden]{display:none!important}
|
||||
.sf-create-dialog .modal-inner{max-width:650px}.sf-create-dialog .modal-top h2{margin-bottom:4px}
|
||||
.sf-create-progress{list-style:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:2px 0 22px;padding:0}
|
||||
.sf-create-progress li{border-bottom:3px solid #dde6f1;padding:9px 2px 11px;color:#8391a3;text-align:center;font-size:12px}
|
||||
.sf-create-progress li[aria-current=step]{border-color:var(--blue);color:var(--blue);font-weight:750}
|
||||
.sf-create-step h3{font-size:19px;margin:10px 0}.sf-guide-desc{font-size:13px;color:var(--muted);line-height:1.7;margin:6px 0 24px}
|
||||
.sf-required{font-size:11px;color:#aa4c3f}.sf-create-summary{border:1px solid #dbe5f0;border-radius:12px;background:#f8fafc;padding:13px 18px;margin:15px 0 22px}
|
||||
.sf-create-summary p{margin:10px 0;overflow-wrap:anywhere}.sf-guide-safety{border-radius:10px;background:#eff5ff;color:#4a6282;padding:13px 15px;line-height:1.7;margin:18px 0}
|
||||
.sf-create-error{color:#ab2537;background:#fff2f2;border:1px solid #f4cbd0;padding:11px;border-radius:8px}
|
||||
.sf-journey-prompt,.sf-journey-intro{display:flex;align-items:center;justify-content:space-between;gap:22px;background:white;border:1px solid var(--line);border-radius:14px;padding:20px 24px;margin:16px 0}
|
||||
.sf-journey-prompt p,.sf-journey-intro p{line-height:1.65;color:var(--muted);margin:7px 0 0}.sf-journey-intro h2{font-size:24px;margin:5px 0}.sf-journey-eyebrow{color:var(--muted);font-size:12px}
|
||||
.sf-journey-progress{display:grid;text-align:center;border-radius:12px;background:#eff5ff;padding:13px 20px;min-width:130px}
|
||||
.sf-journey-progress strong{font-size:28px;color:var(--blue)}.sf-journey-progress span{font-size:11px;color:var(--muted)}
|
||||
.sf-journey-list{margin:16px 0 22px;padding:0;list-style:none;display:grid;gap:10px}
|
||||
.sf-journey-step{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:18px;align-items:center;background:white;border:1px solid var(--line);border-radius:13px;padding:17px 19px}
|
||||
.sf-journey-step.is-next{border-color:#aac1f2;box-shadow:0 0 0 2px #eff5ff}
|
||||
.sf-journey-number{width:34px;height:34px;border:1px solid #dde5ef;border-radius:50%;background:#f4f7fa;display:grid;place-items:center;color:#6b788b;font-weight:700}
|
||||
.sf-journey-step.is-done .sf-journey-number{background:#e7f7f0;color:#16835a;border-color:#cbebde}
|
||||
.sf-journey-title{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.sf-journey-title strong{font-size:15px}.sf-journey-title span{color:#6b7b8d;font-size:11px}
|
||||
.sf-journey-step p{color:var(--muted);font-size:12px;line-height:1.65;margin:6px 0 0}
|
||||
.sf-journey-after,.sf-journey-notice{background:white;border:1px solid var(--line);border-radius:13px;padding:20px 22px;margin:16px 0}
|
||||
.sf-journey-after h2{font-size:17px;margin:0 0 14px}.sf-glossary-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
.sf-glossary-grid p{margin:0;padding:12px;background:#f7f9fc;border-radius:9px}.sf-glossary-grid strong{display:block;margin-bottom:6px}.sf-glossary-grid span{display:block;color:var(--muted);font-size:12px;line-height:1.7}
|
||||
.sf-journey-notice{background:#fffdf6;color:#705d35;line-height:1.8}
|
||||
@media(max-width:680px){.sf-journey-step{grid-template-columns:34px minmax(0,1fr);gap:11px}.sf-journey-step>.btn{grid-column:2;width:max-content}.sf-journey-intro,.sf-journey-prompt{flex-direction:column;align-items:stretch}.sf-glossary-grid{grid-template-columns:1fr}.sf-journey-progress{max-width:190px}}
|
||||
/* V8.28: calm, readable launch center for nontechnical users */
|
||||
.sf-launch-hero{background:#fff;border:1px solid var(--line);border-radius:14px;padding:23px;display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:19px}
|
||||
.sf-launch-hero h2{margin:5px 0;font-size:22px}.sf-launch-hero p{margin:5px 0;color:var(--muted);line-height:1.65;max-width:700px}
|
||||
.sf-launch-hero>strong{padding:9px 14px;background:#edf3ff;color:#245ac6;border-radius:30px;white-space:nowrap;font-size:12px}
|
||||
.sf-launch-step{display:grid;grid-template-columns:40px minmax(0,1fr);gap:17px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin-bottom:12px}
|
||||
.sf-launch-step.is-ready{border-color:#b4e2cf}.sf-launch-index{width:34px;height:34px;border-radius:50%;background:#eef3fb;color:#38577f;display:grid;place-items:center;font-weight:750}
|
||||
.sf-launch-step.is-ready .sf-launch-index{background:#e0f5eb;color:#197a54}.sf-launch-detail{min-width:0}.sf-launch-detail h2{margin:1px 0 6px;font-size:16px}
|
||||
.sf-launch-detail>p{color:var(--muted);font-size:13px;margin:0 0 14px;line-height:1.7}
|
||||
.sf-launch-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.sf-launch-field{display:grid;gap:6px;font-size:12px;font-weight:650;color:#495d73;min-width:200px;max-width:350px}
|
||||
.sf-launch-field select{width:100%}.sf-launch-check{display:flex;align-items:center;gap:8px;font-size:12px;color:#425572;padding:10px;border-radius:8px;background:#f6f9fd}
|
||||
.sf-launch-check input{width:18px;height:18px;flex-shrink:0}.sf-launch-status{font-size:12px;color:#657589;max-width:600px}
|
||||
.sf-launch-notice{padding:16px 18px;border-radius:10px;background:#edf3ff;color:#244f82;margin:15px 0;line-height:1.75}
|
||||
@media(max-width:620px){.sf-launch-hero{flex-direction:column;align-items:start}.sf-launch-step{padding:16px;gap:11px;grid-template-columns:32px minmax(0,1fr)}.sf-launch-controls{align-items:stretch;flex-direction:column}.sf-launch-field,.sf-launch-controls>.btn{width:100%;max-width:none}.sf-launch-check{align-items:start}}
|
||||
|
||||
.theme-preview.trade-pro{background:linear-gradient(125deg,#0b3d91,#3d6dc1);color:#e8f1fb}.site-icon.trade-pro{background:#e8f1fb;color:#0b3d91}
|
||||
|
||||
+150
-30
@@ -7,8 +7,8 @@ const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',br
|
||||
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 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,delivery:{jobs:[],history:[],configured:{}},language:{group:null,sites:[],units:[]},languageGroups:[],languageEntries:[]};
|
||||
const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',languages:'多语言管理',settings:'网站设置'};
|
||||
const state={launchDeploying:false,releaseRequestSeq:0,releaseRequests:[],releaseRequestsKnown:false,launchBuildId:'',launchTarget:'staging',launchReview:false,launchPreflight:null,sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null,selectionToken:0,building:false,delivery:{jobs:[],history:[],configured:{}},language:{group:null,sites:[],units:[]},languageGroups:[],languageEntries:[]};
|
||||
const headings={home:'工作台',sites:'我的网站',guide:'建站操作指引',launch:'上线操作中心',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'生成与预览',languages:'多语言管理',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)}
|
||||
@@ -49,20 +49,74 @@ 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=buildReadyStep();const hasDomain=!!s.domain;const steps=[
|
||||
{title:'建立网站',desc:'选择行业主题并创建项目',done:true,view:'settings'},
|
||||
{title:'准备内容',desc:'添加页面、产品或导入旧数据',done:hasContent,view:'content'},
|
||||
{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?'✓':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 overview(){
|
||||
const s=state.site;if(!s)return noSite();
|
||||
const journey=window.SiteForgeJourney?.plan({site:s,entries:state.entries,builds:state.builds,jobs:state.jobs,delivery:state.delivery});
|
||||
const next=journey?.next||{label:'查看操作指引',hint:'检查各步骤是否已经完成。',view:'guide'};
|
||||
const contentCount=state.entries.filter(e=>e.source!=='starter').length;
|
||||
return htmlHeading(s.name,'所有网站操作都从这里开始;保存内容、生成网站和正式上线是不同的步骤。',`<button type="button" class="btn btn-primary" data-action="navigate" data-view="guide">查看完整操作指引 →</button>`)+
|
||||
`<section class="sf-journey-intro"><div><span class="sf-journey-eyebrow">建议下一步</span><h2>${esc(next.label)}</h2><p>${esc(next.hint)}</p></div><button type="button" class="btn btn-primary" data-action="navigate" data-view="${esc(next.view)}">现在去做 →</button></section>`+
|
||||
`<div class="stats">${stat('人工添加的内容',contentCount,'▧')}${stat('静态构建版本',state.builds.length,'↗')}${stat('当前主题',themeName[s.theme]||'企业品牌','◈')}${stat('正式域名',s.domain?'已填写':'未设置','◎')}</div>`+
|
||||
`<div class="section-heading"><div><h2>最常用的三项操作</h2><p>不需要了解技术细节,选择你想完成的事情即可。</p></div></div>`+
|
||||
`<div class="quick-grid">${overviewShortcut('content','▧','修改文字和产品','填写网站内容,修改已经创建的页面。')}${overviewShortcut('design','◈','调整网页外观','编辑图片、版式、导航和手机端。')}${overviewShortcut('launch','↗','检查并上线网站','一步一步完成预览、审批和正式部署。')}</div>`+
|
||||
`<div class="sf-journey-notice" role="note"><strong>关于上线</strong>:页面编辑和保存不会自动修改线上网站。上线仍需生成静态版本、检查、独立审批和部署。</div>`;
|
||||
}
|
||||
function guide(){
|
||||
if(!currentSite())return noSite();
|
||||
const journey=window.SiteForgeJourney?.plan({site:state.site,entries:state.entries,builds:state.builds,jobs:state.jobs,delivery:state.delivery});
|
||||
if(!journey)return htmlHeading('建站操作指引','操作引导暂时无法加载,请刷新网页。');
|
||||
const checked=journey.completed;
|
||||
const rows=journey.steps.map((step,i)=>{
|
||||
const label=step.optional?'可选:随时完善':step.done?'已记录完成':step.id===journey.next.id?'建议现在进行':'等待前面的步骤';
|
||||
return `<li class="sf-journey-step ${step.done?'is-done':''} ${step.id===journey.next.id?'is-next':''}">
|
||||
<span class="sf-journey-number" aria-hidden="true">${step.done?'✓':i+1}</span>
|
||||
<div><div class="sf-journey-title"><strong>${esc(step.label)}</strong><span>${esc(label)}</span></div>
|
||||
<p>${esc(step.hint)}</p></div>
|
||||
<button type="button" class="btn ${step.id===journey.next.id?'btn-primary':'btn-muted'}" data-action="navigate" data-view="${esc(step.view)}">${step.id===journey.next.id?'开始操作':'查看 / 修改'} →</button>
|
||||
</li>`;
|
||||
}).join('');
|
||||
return htmlHeading('建站操作指引','按照实际操作顺序完成网站制作,系统只根据已保存的数据与任务记录显示进度。')+
|
||||
`<section class="sf-journey-intro"><div><span class="sf-journey-eyebrow">当前项目</span><h2>${esc(state.site.name)}</h2><p>${esc(journey.next.hint)}</p></div>
|
||||
<div class="sf-journey-progress"><strong>${checked}/4</strong><span>已记录的必要步骤</span></div></section>
|
||||
<ol class="sf-journey-list">${rows}</ol>
|
||||
<section class="sf-journey-after"><h2>保存、构建和上线有什么不同?</h2><div class="sf-glossary-grid">
|
||||
<p><strong>保存内容</strong><span>把修改写入管理系统,外部访客还看不到新版。</span></p>
|
||||
<p><strong>生成网站</strong><span>制作一份可预览、可下载的静态网站版本。</span></p>
|
||||
<p><strong>检查与审批</strong><span>核对页面、图片、网址和 SEO,并取得相应审批。</span></p>
|
||||
<p><strong>正式上线</strong><span>把批准的版本部署到正式服务器,再核对公网访问。</span></p></div></section>
|
||||
<section class="sf-journey-notice" role="note"><strong>重要说明</strong>:${journey.live?'系统发现正式发布记录,但无法仅凭该记录确认当前公网网站可访问,请人工检查实际域名。':'当前没有可确认的正式发布记录;生成 ZIP 或点击“发布设计”都不代表网站已上线。'}${journey.changedAfterBuild?' 网站内容在最近一次构建后可能有变化,建议重新构建并预览。':''}${!journey.domain?' 正式域名尚未填写,不影响预览。':''}</section>`;
|
||||
}
|
||||
// V8.28 guided delivery: one deliberate action per stage, existing secure APIs only.
|
||||
function launch(){
|
||||
if(!currentSite())return noSite();
|
||||
const buildList=state.builds.filter(b=>b.status==='complete');
|
||||
const selection=buildList.find(b=>b.id===state.launchBuildId)||buildList[0]||null;
|
||||
const selected=selection?.id||'';
|
||||
if(selected&&!state.launchBuildId){state.launchBuildId=selected;state.launchReview=false;state.launchPreflight=null;}
|
||||
const requests=state.releaseRequests.filter(r=>r.build_id===selected);
|
||||
const result=window.SiteForgeLaunch?.plan({site:state.site,build:selection,requests,requestsKnown:state.releaseRequestsKnown,
|
||||
delivery:state.delivery,target:state.launchTarget,previewChecked:state.launchReview,preflight:state.launchPreflight});
|
||||
const row=(index,name,body,controls,kind='')=>`<section class="sf-launch-step ${kind}"><div class="sf-launch-index">${index}</div><div class="sf-launch-detail"><h2>${esc(name)}</h2><p>${esc(body)}</p><div class="sf-launch-controls">${controls}</div></div></section>`;
|
||||
const buildOptions=buildList.map(b=>`<option value="${esc(b.id)}" ${selected===b.id?'selected':''}>${esc(fmtDate(b.created_at))} · ${esc(b.id.slice(0,12))} · ${Number(b.total_pages)||0} 页</option>`).join('');
|
||||
const select=`<label class="sf-launch-field">静态构建版本<select id="launchBuildSelect" ${buildList.length?'':'disabled'}><option value="">${buildList.length?'请选择版本':'暂无成功构建'}</option>${buildOptions}</select></label>`;
|
||||
const checked=Boolean(state.launchReview&&selected);
|
||||
const canRequestApproval=Boolean(selected&&checked&&state.releaseRequestsKnown&&['none','rejected'].includes(result?.approval));
|
||||
const preview=selected?`<a class="btn" target="_blank" rel="noopener" href="/preview/${encodeURIComponent(state.site.id)}/${encodeURIComponent(selected)}/">打开网站预览 ↗</a><button type="button" class="btn" data-action="launch-report" data-id="${esc(selected)}">检查质量报告</button>`:`<button class="btn btn-primary" data-action="launch-build">去生成网站</button>`;
|
||||
const approvalText=result?.approval==='approved'?'已由其他审核人批准。':result?.approval==='pending'?'已提交审批,请由其他有审核权限的人员处理。':result?.approval==='rejected'?'最近申请被驳回,请解决问题后重新申请。':!state.releaseRequestsKnown?'审批记录暂时无法确认,请刷新或到审批中心查询。':'尚无已批准的申请。';
|
||||
const config=Boolean(state.delivery.configured?.[state.launchTarget]);
|
||||
const domainUrl=/^https:\/\/[^\s<>]+$/i.test(state.site.domain||'')?state.site.domain:'';
|
||||
const deploymentText=state.launchTarget==='production'?'正式环境':'测试环境';
|
||||
return htmlHeading('上线操作中心','按顺序完成检查和审核,每一步都解释操作用途。网站不会因为保存、预览或提交审批而自动上线。',`<button type="button" class="btn" data-action="launch-refresh">刷新状态</button>`)+
|
||||
`<div class="sf-launch-hero"><div><span class="sf-journey-eyebrow">当前网站</span><h2>${esc(state.site.name)}</h2><p>上线属于独立审批和服务器部署流程;如有不确定之处,可以随时离开,已经保存的记录不会消失。</p></div><strong>${result?.approval==='approved'?'已获审批':result?.approval==='pending'?'等待审批':selected?'准备上线':'请先构建'}</strong></div>`+
|
||||
row('1','选择网站版本','选择已生成成功、准备交付的静态版本。内容有变更时需要重新构建。',select+`<button class="btn" data-action="launch-build">${selected?'重新构建版本':'开始构建网站'}</button>`,selected?'is-ready':'')+
|
||||
row('2','预览网站并检查质量','在新窗口检查页面、图片、移动端、链接和 SEO。质量报告是自动检查,仍需要人工确认。',preview+`<label class="sf-launch-check"><input id="launchReviewCheck" type="checkbox" ${checked?'checked':''} ${selected?'':'disabled'}> 我已经检查此版本的预览与质量报告</label>`,checked?'is-ready':'')+
|
||||
row('3','提交给其他人审批',approvalText,`<button class="btn" data-action="launch-request-approval" ${canRequestApproval?'':'disabled'}>${result?.approval==='pending'?'已提交,等待审核':result?.approval==='approved'?'此版本已批准':'提交审批申请'}</button><a class="btn" href="/operations">查看审批中心 ↗</a>`,result?.approval==='approved'?'is-ready':'')+
|
||||
row('4','检查正式发布条件','审批通过后,再检查构建质量、旧站迁移复核和发布归档是否满足条件。',`<button class="btn" data-action="launch-preflight" ${result?.approval==='approved'&&checked?'':'disabled'}>执行发布前检查</button><span class="sf-launch-status">${esc(result?.preflightMessage||'尚未完成此版本的发布前检查')}</span>`,result?.preflightReady?'is-ready':'')+
|
||||
row('5','提交服务器部署','只有目标环境已配置、审批通过且发布前检查成功,才能从这里提交部署任务。',`<label class="sf-launch-field">选择发布环境<select id="launchTarget"><option value="staging" ${state.launchTarget==='staging'?'selected':''}>测试环境</option><option value="production" ${state.launchTarget==='production'?'selected':''}>正式环境</option></select></label><button class="btn btn-primary" data-action="launch-deploy" ${result?.canDeploy&&!state.launchDeploying?'':'disabled'}>${state.launchDeploying?'正在提交…':'提交部署到'}${deploymentText}</button><span class="sf-launch-status">${state.delivery.unavailable?'无法读取发布目标状态,请刷新或联系管理员。':config?'目标服务器已配置':'当前环境没有配置可用的发布目标,请联系管理员。'}</span>`,result?.hasRemoteRecord?'is-ready':'')+
|
||||
row('6','核对网站是否真正上线','提交部署只是创建任务。请确认任务成功,再在真实域名检查页面和联系表单。',`<button class="btn" data-action="launch-refresh">刷新任务</button>${domainUrl?`<a class="btn" href="${esc(domainUrl)}" target="_blank" rel="noopener noreferrer">打开正式域名 ↗</a>`:'<button class="btn" data-action="navigate" data-view="settings">设置正式域名</button>'}<p class="sf-launch-status">${esc(result?.remoteDescription||'未确认有当前环境的部署记录。')} 系统不会仅凭发布记录声称公网验收通过。</p>`)+
|
||||
`<div class="sf-journey-notice" role="note"><strong>注意</strong>:这里的确认只影响当前操作界面,正式发布仍由服务器重新验证权限、审批、质量、文件和目标配置。页面预览正常也不代表公网已上线。</div><div id="launchNotice" class="sf-launch-notice" role="status" aria-live="polite" ${state.launchMessage?'':'hidden'}>${esc(state.launchMessage||'')}</div><div id="launchReport"></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>`+
|
||||
@@ -80,20 +134,20 @@ function imports(){if(!currentSite())return noSite();return htmlHeading('导入
|
||||
`<div class="help-hint" style="margin-top:17px"><strong>迁移旧网站请注意:</strong>如果需要保留旧 URL、原有 SEO、复杂插件字段和全部图片,请优先使用 WordPress 迁移工作台,而不是仅上传一个内容文件。</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>`)+
|
||||
return htmlHeading('生成与预览','先生成网站版本,确认效果后再使用上线操作中心提交审批。任务会在后台执行。',`<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>`+
|
||||
`<div class="panel" style="margin-top:18px"><div class="panel-head"><div><h2>正式发布与回滚</h2><p>先完成质量检查与独立审批,Worker 负责上传、校验和切换版本。没有配置目标服务器时不能发布。</p></div><a href="/operations" class="btn btn-tiny">审批与复核工具 ↗</a></div>
|
||||
`<div class="sf-launch-followup"><strong>下一步:预览检查后提交审批</strong><p>完成构建后,请在上线操作中心按照步骤检查质量、提交审批并部署。</p><button class="btn btn-primary" data-action="navigate" data-view="launch">前往上线操作中心 →</button></div><details class="form-expand sf-advanced-publish"><summary>高级:直接进行发布和历史版本回滚</summary><div class="panel" style="margin-top:18px"><div class="panel-head"><div><h2>正式发布与回滚</h2><p>先完成质量检查与独立审批,Worker 负责上传、校验和切换版本。没有配置目标服务器时不能发布。</p></div><a href="/operations" class="btn btn-tiny">审批与复核工具 ↗</a></div>
|
||||
<div class="field-grid"><label>选择已完成构建<select id="deliveryBuild">${state.builds.filter(b=>b.status==='complete').map(b=>`<option value="${esc(b.id)}">${esc(b.id.slice(0,12))} · ${esc(fmtDate(b.created_at))}</option>`).join('')}</select></label>
|
||||
<label>目标环境<select id="deliveryTarget"><option value="staging">测试环境(Staging)</option><option value="production">正式环境(Production)</option></select></label></div>
|
||||
<div class="form-actions"><button class="btn" data-action="release-preflight">检查发布条件</button><button class="btn" data-action="release-approval">提交独立审批</button><button class="btn btn-primary" data-action="release-deploy">提交发布任务</button></div>
|
||||
<div id="deliveryNotice" class="message" aria-live="polite" hidden></div>
|
||||
<h3>最近发布任务</h3>${(state.delivery.jobs||[]).length?state.delivery.jobs.slice(0,8).map(j=>`<div class="build-row"><div class="build-meta"><strong>${j.kind==='remote_rollback'?'回滚':'部署'} · ${esc(buildStatusLabel(j.state))}</strong><p>${esc(j.id.slice(0,12))} · ${esc(j.created_at)}${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>'}
|
||||
<h3>已发布版本</h3>${(state.delivery.history||[]).length?state.delivery.history.slice(0,8).map(r=>`<div class="build-row"><div class="build-meta"><strong>${esc(r.target)} · ${esc(r.release_id)}</strong><p>构建 ${esc(r.build_id.slice(0,12))} · ${esc(r.deployed_at)}</p></div><button class="btn btn-tiny" data-action="release-rollback" data-target="${esc(r.target)}" data-id="${esc(r.release_id)}">回滚到此版本</button></div>`).join(''):'<p class="muted">当前没有远程部署记录。</p>'}</div>`;
|
||||
<h3>已发布版本</h3>${(state.delivery.history||[]).length?state.delivery.history.slice(0,8).map(r=>`<div class="build-row"><div class="build-meta"><strong>${esc(r.target)} · ${esc(r.release_id)}</strong><p>构建 ${esc(r.build_id.slice(0,12))} · ${esc(r.deployed_at)}</p></div><button class="btn btn-tiny" data-action="release-rollback" data-target="${esc(r.target)}" data-id="${esc(r.release_id)}">回滚到此版本</button></div>`).join(''):'<p class="muted">当前没有远程部署记录。</p>'}</div></details>`;
|
||||
}
|
||||
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>`;
|
||||
@@ -125,29 +179,93 @@ async function submitLanguageUnit(ev){ev.preventDefault();const d=Object.fromEnt
|
||||
async function submitLanguageLink(ev){ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));try{await api('/api/i18n/groups/'+state.language.group.group_id+'/units/'+encodeURIComponent(d.unit_id)+'/members','POST',{site_id:d.site_id,entry_id:d.entry_id,state:d.state});toast('内容翻译关系已保存');await loadLanguage()}catch(e){notifyError(e)}}
|
||||
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,languages,settings}[selected]||home)();
|
||||
area.innerHTML=({home,sites,overview,guide,launch,content,design,import:imports,build:builds,languages,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 readDelivery(siteId){try{return await api(`/api/production/sites/${encodeURIComponent(siteId)}/delivery-status`)}catch(e){return {jobs:[],history:[],configured:{}}}}
|
||||
async function readDelivery(siteId){try{return await api(`/api/production/sites/${encodeURIComponent(siteId)}/delivery-status`)}catch(e){return {jobs:[],history:[],configured:{},unavailable:true}}}
|
||||
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.delivery={jobs:[],history:[],configured:{}};state.job=null;state.view=go==='home'?'home':'sites';$('#sitePicker').value='';rerender();if(historyMode!=='none')rememberView(historyMode==='replace');return}
|
||||
if(!id){state.site=null;resetLaunch();state.entries=[];state.builds=[];state.jobs=[];state.delivery={jobs:[],history:[],configured:{}};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,delivery]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`),readJobs(id),readDelivery(id)]);
|
||||
if(token!==state.selectionToken)return;
|
||||
state.site=site;sfRemember(id);state.entries=entries;state.builds=builds;state.jobs=jobs;state.delivery=delivery;state.job=null;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();if(historyMode!=='none')rememberView(historyMode==='replace');if(go==='languages')loadLanguage();closeMobile();window.scrollTo({top:0,behavior:'instant'});
|
||||
state.site=site;resetLaunch();sfRemember(id);state.entries=entries;state.builds=builds;state.jobs=jobs;state.delivery=delivery;state.job=null;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();if(historyMode!=='none')rememberView(historyMode==='replace');if(go==='languages')loadLanguage();if(go==='launch')loadReleaseRequests();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();if(to==='languages')loadLanguage();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,delivery]=await Promise.all([readJobs(id),api(`/api/sites/${encodeURIComponent(id)}/builds`),readDelivery(id)]);if(state.site?.id!==id)return;state.jobs=jobs;state.builds=builds;state.delivery=delivery;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 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'||to==='launch')refreshBuilds();if(to==='launch')loadReleaseRequests();if(to==='languages')loadLanguage();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,delivery]=await Promise.all([readJobs(id),api(`/api/sites/${encodeURIComponent(id)}/builds`),readDelivery(id)]);if(state.site?.id!==id)return;state.jobs=jobs;state.builds=builds;state.delivery=delivery;if(state.view==='launch'){const selected=state.launchBuildId;const stillValid=builds.some(b=>b.id===selected&&b.status==='complete');if(selected&&!stillValid){state.launchBuildId='';state.launchReview=false;state.launchPreflight=null;state.launchMessage='原先选择的构建版本已不可用,请重新选择并核对。'}}if(previouslyRunning&&!activeJobs().length){const last=jobs[0];state.job=last?.state==='done'?'后台构建已完成,请预览并查看质量报告。':last?.state==='failed'?'本次构建失败,请在任务中心检查原因。':last?.state==='cancelled'?'本次构建已取消。':null}if(['build','launch'].includes(state.view))rerender()}catch(e){notifyError(e)}}
|
||||
function resetLaunch(){state.releaseRequestSeq++;state.launchDeploying=false;state.releaseRequests=[];state.releaseRequestsKnown=false;state.launchBuildId='';state.launchTarget='staging';state.launchReview=false;state.launchPreflight=null;state.launchMessage=''}
|
||||
async function loadReleaseRequests(){const siteId=state.site?.id;if(!siteId)return;const seq=++state.releaseRequestSeq;try{
|
||||
const rows=await api(`/api/factory/sites/${encodeURIComponent(siteId)}/release-requests`);
|
||||
if(state.site?.id!==siteId||seq!==state.releaseRequestSeq)return;
|
||||
state.releaseRequests=Array.isArray(rows)?rows:[];state.releaseRequestsKnown=true;
|
||||
}catch(e){if(state.site?.id!==siteId||seq!==state.releaseRequestSeq)return;state.releaseRequestsKnown=false;state.releaseRequests=[];if(![403,404].includes(Number(e.status)))console.warn('读取审批记录失败',e)}
|
||||
if(state.view==='launch')rerender();
|
||||
}
|
||||
async function launchRefresh(){if(!state.site)return;const siteId=state.site.id;await Promise.all([refreshBuilds(),loadReleaseRequests()]);if(state.site?.id!==siteId)return;state.launchMessage='状态已刷新。审批记录、部署任务及公网验收是不同状态。';rerender()}
|
||||
function launchPickBuild(id){const known=state.builds.some(b=>b.id===id&&b.status==='complete');state.launchBuildId=known?id:'';state.launchReview=false;state.launchPreflight=null;state.launchMessage='切换构建版本后,请重新检查预览并获取审批。';rerender()}
|
||||
function launchPickTarget(target){if(!['staging','production'].includes(target))return;state.launchTarget=target;state.launchPreflight=null;state.launchMessage='已切换目标环境。发布前检查将重新确认。';rerender()}
|
||||
function launchChosenBuild(){const id=state.launchBuildId||state.builds.find(b=>b.status==='complete')?.id;if(!id)throw Error('请先生成一个完整的静态构建版本');return id}
|
||||
async function launchRequestApproval(){const sid=state.site?.id,bid=launchChosenBuild();if(!state.launchReview)throw Error('请先检查网站预览并勾选人工核对');if(!state.releaseRequestsKnown)throw Error('审批状态读取失败,请刷新后再操作');if(state.releaseRequests.some(r=>r.build_id===bid&&['pending','approved'].includes(r.state)))throw Error('当前版本已经存在审批申请,无需重复提交');if(!confirm('确定为此构建版本提交独立审批吗?提交后必须由另一位审核人员批准。'))return;
|
||||
const r=await api(`/api/factory/sites/${encodeURIComponent(sid)}/release-requests`,'POST',{build_id:bid});if(state.site?.id!==sid)return;
|
||||
state.launchMessage=`已创建审批申请 ${r.id},当前仍在等待他人审核。`;
|
||||
await loadReleaseRequests();rerender();
|
||||
}
|
||||
async function launchPreflight(){const sid=state.site?.id,bid=launchChosenBuild(),target=state.launchTarget;if(!state.launchReview)throw Error('请先完成人工预览检查');
|
||||
const r=await api(`/api/production/sites/${encodeURIComponent(sid)}/preflight/${encodeURIComponent(bid)}`);if(state.site?.id!==sid||state.launchTarget!==target||launchChosenBuild()!==bid)return;
|
||||
state.launchPreflight={siteId:sid,buildId:bid,ready:r.ready===true,message:r.message||'',target};
|
||||
state.launchMessage=r.ready?'发布前检查通过;提交任务时服务器仍会再次校验。':'尚不能发布:'+r.message;
|
||||
rerender();
|
||||
}
|
||||
async function launchDeploy(){const sid=state.site?.id,bid=launchChosenBuild(),target=state.launchTarget;const approvals=state.releaseRequests.filter(r=>r.build_id===bid);
|
||||
const model=window.SiteForgeLaunch?.plan({site:state.site,build:state.builds.find(b=>b.id===bid),requests:approvals,requestsKnown:state.releaseRequestsKnown,delivery:state.delivery,target,previewChecked:state.launchReview,preflight:state.launchPreflight});
|
||||
if(!model?.canDeploy)throw Error('请先完成预览检查、独立审批、发布前检查,并确认目标服务器已配置');
|
||||
if(state.launchDeploying)return;
|
||||
if(!confirm(`确定将版本 ${bid.slice(0,12)} 提交到${target==='production'?'正式':'测试'}环境吗?这会触发服务器部署。`))return;
|
||||
state.launchDeploying=true;rerender();
|
||||
try{const r=await api(`/api/production/sites/${encodeURIComponent(sid)}/deploy`,'POST',{build_id:bid,target},{'Idempotency-Key':crypto.randomUUID()});if(state.site?.id!==sid)return;
|
||||
await launchRefresh();state.launchMessage=`部署任务 ${r.id} 已进入后台队列。请刷新任务后,再核查真实网站。`;
|
||||
}finally{if(state.site?.id===sid){state.launchDeploying=false;rerender();}}
|
||||
}
|
||||
async function launchReport(id){const sid=state.site?.id;if(!sid||!state.builds.some(b=>b.id===id))return;const r=await api(`/api/sites/${encodeURIComponent(sid)}/builds/${encodeURIComponent(id)}/report`);if(state.site?.id!==sid||state.view!=='launch')return;
|
||||
const t=$('#launchReport');if(!t)return;t.innerHTML=`<section class="sf-journey-after"><h2>版本质量报告</h2><p>HTML 页面:${esc(r.pages||0)} · 警告 ${esc((r.warnings||[]).length)} 项</p>${r.warnings?.length?`<ul>${r.warnings.slice(0,20).map(w=>`<li>${esc(w)}</li>`).join('')}</ul>`:'<p>自动检查未返回警告,仍需核对业务内容和手机页面。</p>'}</section>`;
|
||||
}
|
||||
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)}}
|
||||
let createStep=0;
|
||||
function createValidate(){
|
||||
const f=$('#siteForm'),name=f.elements.name.value.trim(),domain=f.elements.domain.value.trim();
|
||||
if(!name){return '请填写网站名称。'}
|
||||
if(name.length>150)return '网站名称过长,请缩短后再继续。';
|
||||
if(domain&&!/^https:\/\/[^\s/]+(?:[\/?#][^\s]*)?$/i.test(domain))return '正式域名请填写完整的 https:// 地址,或先留空。';
|
||||
return '';
|
||||
}
|
||||
function renderCreateStep(){
|
||||
const form=$('#siteForm');if(!form)return;
|
||||
form.querySelectorAll('[data-create-step]').forEach(el=>{el.hidden=Number(el.dataset.createStep)!==createStep});
|
||||
form.querySelectorAll('[data-create-indicator]').forEach(el=>{const current=Number(el.dataset.createIndicator)===createStep;if(current)el.setAttribute('aria-current','step');else el.removeAttribute('aria-current')});
|
||||
$('#createProgressText').textContent=`第 ${createStep+1} 步,共 3 步 · ${['填写信息','选择风格','确认创建'][createStep]}`;
|
||||
$('#createBack').hidden=createStep===0;
|
||||
$('#createNext').hidden=createStep===2;
|
||||
$('#createNext').textContent=createStep===0?'下一步:选择风格 →':'下一步:确认信息 →';
|
||||
$('#createSubmit').hidden=createStep!==2;
|
||||
$('#createError').hidden=true;
|
||||
if(createStep===2){const data=Object.fromEntries(new FormData(form));$('#createSummary').innerHTML=`<p><strong>网站名称:</strong>${esc(data.name)}</p><p><strong>网站类型:</strong>${esc(({new:'全新制作网站',migration:'迁移旧站',hybrid:'旧站内容重新设计'})[data.mode]||data.mode)}</p><p><strong>网站风格:</strong>${esc(themeName[data.theme]||'企业品牌')}</p>`}
|
||||
}
|
||||
function openCreateWizard(){const form=$('#siteForm');form.reset();createStep=0;renderCreateStep();openModal('siteModal')}
|
||||
async function saveSite(ev){ev.preventDefault();const error=createValidate();if(error){$('#createError').textContent=error;$('#createError').hidden=false;return}
|
||||
if(createStep!==2){createStep=2;renderCreateStep();return}
|
||||
const button=$('#createSubmit');if(button.disabled)return;button.disabled=true;button.textContent='正在创建…';
|
||||
try{const data=Object.fromEntries(new FormData(ev.currentTarget));const s=await api('/api/sites','POST',data);closeModal('siteModal');toast('网站已建立,下一步请完善真实内容');await loadSites();await selectSite(s.id,'guide')}
|
||||
catch(e){notifyError(e);$('#createError').textContent=e.message||'创建失败,请重试';$('#createError').hidden=false}
|
||||
finally{button.disabled=false;button.textContent='确认创建网站'}
|
||||
}
|
||||
|
||||
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)}}
|
||||
@@ -178,23 +296,25 @@ function closeMobile(){$('#sideMenu').classList.remove('open');$('#mobileBackdro
|
||||
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;
|
||||
// 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','languages','settings']);
|
||||
const target=params.get('view');const allowed=new Set(['home','sites','overview','guide','launch','content','design','import','build','languages','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==='language-refresh')await loadLanguage();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);if(action==='release-preflight')await releasePreflight();if(action==='release-approval')await releaseApproval();if(action==='release-deploy')await releaseDeploy();if(action==='release-rollback')await releaseRollback(control.dataset.target,control.dataset.id)}catch(e){notifyError(e)}});
|
||||
document.addEventListener('click',async ev=>{const sidebar=ev.target.closest('.nav-item[data-view]');if(sidebar&&!sidebar.hasAttribute('data-action')){navigate(sidebar.dataset.view);return;}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==='language-refresh')await loadLanguage();if(action==='new-site')openCreateWizard();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);if(action==='release-preflight')await releasePreflight();if(action==='release-approval')await releaseApproval();if(action==='release-deploy')await releaseDeploy();if(action==='release-rollback')await releaseRollback(control.dataset.target,control.dataset.id);if(action==='launch-refresh')await launchRefresh();if(action==='launch-build')await buildNow();if(action==='launch-report')await launchReport(control.dataset.id);if(action==='launch-request-approval')await launchRequestApproval();if(action==='launch-preflight')await launchPreflight();if(action==='launch-deploy')await launchDeploy()}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);if(ev.target.id==='langGroupForm')submitLanguageGroup(ev);if(ev.target.id==='langJoinForm')submitLanguageJoin(ev);if(ev.target.id==='langUnitForm')submitLanguageUnit(ev);if(ev.target.id==='langLinkForm')submitLanguageLink(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()}if(ev.target.id==='langMemberSite')loadLanguageEntries(ev.target.value)});
|
||||
document.addEventListener('change',ev=>{if(ev.target.id==='launchBuildSelect')launchPickBuild(ev.target.value);if(ev.target.id==='launchTarget')launchPickTarget(ev.target.value);if(ev.target.id==='launchReviewCheck'){state.launchReview=ev.target.checked;state.launchPreflight=null;rerender()}if(ev.target.id==='contentType'){state.typ=ev.target.value;rerender()}if(ev.target.id==='langMemberSite')loadLanguageEntries(ev.target.value)});
|
||||
$('#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','languages','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()}});
|
||||
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','guide','launch','content','design','import','build','languages','settings'];if(site&&state.sites.some(s=>s.id===site))await selectSite(site,allowed.includes(view)?view:'overview','none');else{state.selectionToken++;resetLaunch();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'));
|
||||
$('#newSiteTop').addEventListener('click',openCreateWizard);
|
||||
$('#createNext').addEventListener('click',()=>{const error=createValidate();if(error){$('#createError').textContent=error;$('#createError').hidden=false;return}createStep=Math.min(2,createStep+1);renderCreateStep()});
|
||||
$('#createBack').addEventListener('click',()=>{createStep=Math.max(0,createStep-1);renderCreateStep()});
|
||||
$('#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);
|
||||
|
||||
Reference in New Issue
Block a user