2026-10-22 16:49:25 +08:00
'use strict' ;
const $ = s => document . querySelector ( s ), H = s => String ( s ?? '' ). replace ( /[&<>"']/g , c =>({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
const state = { sites : [], themes : [], site : null , tab : 'overview' , csrf : '' , importRows : [], fields : [], mapping : {}, kind : 'product' };
const toast = ( s , error = false )=>{ let el = $ ( '#alert' ); el . textContent = s ; el . className = 'on' ; el . style . background = error ? '#ffe6e6' : '#e3f7ed' ; el . style . color = error ? '#b13445' : '#176e50' ; setTimeout (()=> el . className = '' , 5500 )};
async function api ( url , method = 'GET' , data = null ){ const opt = { method , credentials : 'same-origin' , headers : {}}; if ( ! [ 'GET' , 'HEAD' ]. includes ( method )) opt . headers [ 'X-SiteForge-CSRF' ] = state . csrf ;
if ( data instanceof FormData ) opt . body = data ; else if ( data !== null ){ opt . headers [ 'Content-Type' ] = 'application/json' ; opt . body = JSON . stringify ( data )}
const r = await fetch ( url , opt ); if ( r . status === 401 ){ location . href = '/login' ; throw Error ( '请登录' )}; let body = ( r . headers . get ( 'content-type' ) || '' ). includes ( 'json' ) ? await r . json () : await r . text (); if ( ! r . ok ) throw Error ( body . detail || body ); return body ;}
const siteUrl = path => `/api/sites/ ${ state . site . id }${ path } ` ;
const factoryUrl = path => `/api/factory/sites/ ${ state . site . id }${ path } ` ;
function selected (){ if ( ! state . site ){ $ ( '#panel' ). innerHTML = '<div class="section"><h2>创建第一个网站</h2><p>选择主题后即可开始添加新内容,或导入旧网站的数据。</p><button class="primary" onclick="openCreate()">创建网站</button></div>' ; return false } return true ;}
function setTab ( tab ){ state . tab = tab ; $$ ( '#navtabs button' ). forEach ( b => b . classList . toggle ( 'active' , b . dataset . tab === tab )); render (). catch ( e => toast ( e . message , true ))}
function $$ ( s ){ return [... document . querySelectorAll ( s )]}
function setHeading ( h , desc ){ $ ( '#heading' ). textContent = h ; $ ( '#description' ). textContent = desc ; $ ( '#currentTab' ). textContent = h ;}
async function init (){ try { state . csrf = ( await api ( '/api/auth/me' )). csrf ; await refresh (); document . querySelectorAll ( '#navtabs button' ). forEach ( btn => btn . addEventListener ( 'click' ,()=> setTab ( btn . dataset . tab )));
2026-10-22 17:02:45 +08:00
$ ( '#sitePicker' ). onchange = ()=>{ state . site = state . sites . find ( s => s . id === $ ( '#sitePicker' ). value ); window . SiteForgeShell ? . selectSite ( state . site ? . id ); render (). catch ( e => toast ( e . message , true ))}; $ ( '#reload' ). onclick = refresh ; $ ( '#newSite' ). onclick = openCreate ; $ ( '#closeCreate' ). onclick = ()=> $ ( '#createDialog' ). close (); $ ( '#createForm' ). onsubmit = create ;
2026-10-22 16:49:25 +08:00
$ ( '#buildQuick' ). onclick = ()=> buildNow (); await render ()} catch ( e ){ toast ( '初始化失败:' + e . message , true )}}
async function refresh (){ state . sites = await api ( '/api/sites' ); state . themes = await api ( '/api/factory/themes' ); let old = state . site ? . id ; state . site = state . sites . find ( x => x . id === old ) || state . sites [ 0 ] || null ;
$ ( '#sitePicker' ). innerHTML = state . sites . map ( s => `<option value=" ${ H ( s . id ) } "> ${ H ( s . name ) } </option>` ). join ( '' ); if ( state . site ) $ ( '#sitePicker' ). value = state . site . id ;
$ ( '#newTheme' ). innerHTML = state . themes . map ( t => `<option value=" ${ H ( t . id || t . theme_id ) } "> ${ H ( t . name ) } </option>` ). join ( '' );}
function openCreate (){ $ ( '#createDialog' ). showModal ()}
async function create ( e ){ e . preventDefault (); try { let d = Object . fromEntries ( new FormData ( e . target )); let result = await api ( '/api/sites' , 'POST' , d ); state . site = result ; await api ( `/api/factory/sites/ ${ result . id } /bootstrap` , 'POST' ,{}); $ ( '#createDialog' ). close (); toast ( '新网站已创建并生成基础页面结构' ); await refresh (); await render ()} catch ( err ){ toast ( err . message , true )}}
async function render (){ $ ( '#openEditor' ). href = state . site ? `/editor/ ${ state . site . id } ` : '#' ; if ( ! selected ()) return ;
const maps = { overview : [ '网站总览' , '查看生产进度、页面数量和建站方式' ], themes : [ '主题与版本' , '安装主题、切换主题、固定版本以及批量套版' ], content : [ '内容与自动装配' , '导入 CSV/JSON、自动识别字段、预览映射并建立新内容' ], migration : [ '迁移与 SEO 验收' , '核对旧站 URL、媒体资源和 SEO 差异' ], build : [ '构建与发布' , '生成独立 HTML 站点、检测问题、下载与版本管理' ]}; setHeading (... maps [ state . tab ]);
const fn = { overview : overview , themes : themes , content : content , migration : migration , build : buildPanel }[ state . tab ]; $ ( '#panel' ). innerHTML = '<div class="loading">加载中……</div>' ; await fn ();}
async function overview (){ let [ entries , builds , source ] = await Promise . all ([ api ( siteUrl ( '/entries' )), api ( siteUrl ( '/builds' )), api ( factoryUrl ( '/theme' ))]); let s = state . site ;
$ ( '#panel' ). innerHTML = `<div class="cards"><div class="card"><span class="overline">当前站点</span><strong> ${ H ( s . name ) } </strong><small> ${ H ( s . mode === 'migration' ? '旧站迁移' : '新站搭建' ) } · ${ H ( s . theme ) } </small></div><div class="card"><span class="overline">内容总数</span><strong> ${ entries . length } </strong><small>页面/产品/品牌/文章</small></div><div class="card"><span class="overline">构建版本</span><strong> ${ builds . length } </strong><small>静态网站历史记录</small></div><div class="card"><span class="overline">主题版本</span><strong> ${ H ( source . version ) } </strong><small>已固定版本可追踪</small></div></div>
<div class="section"><h2>网站工厂生产流程</h2><div class="steps"><div class="step">01<b>网站结构</b><small>首页、栏目页和导航</small></div><div class="step">02<b>内容自动装配</b><small>导入新数据或旧内容</small></div><div class="step">03<b>设计编辑</b><small>切换主题、可视化编辑</small></div><div class="step">04<b>静态站交付</b><small>构建、验收、发布</small></div></div></div>
<div class="columns"><div class="section"><div class="toolbar"><h2>网站页面</h2><button class="outline" onclick="bootstrap()">补齐标准页面</button></div><div class="scroll"><table><thead><tr><th>页面/内容</th><th>类型</th><th>URL</th><th>操作</th></tr></thead><tbody> ${ entries . slice ( 0 , 25 ). map ( x => `<tr><td> ${ H ( x . title ) } </td><td> ${ H ( x . type ) } </td><td> ${ H ( x . route ) } </td><td><a href="/editor/ ${ H ( s . id ) } / ${ H ( x . id ) } ">设计页面 ↗</a></td></tr>` ). join ( '' ) } </tbody></table></div></div><div class="section"><h2>联系信息与表单服务</h2><p>邮箱会出现在主题的联系页与页脚。外部表单地址为空时,不显示假提交按钮。</p><label class="field">联系邮箱<input id="configEmail" type="email" value=" ${ H ( s . settings ? . email || '' ) } "></label><label class="field">HTTPS 表单接收地址(可选)<input id="configForm" value=" ${ H ( s . settings ? . form_endpoint || '' ) } "></label><label class="field">页脚说明<input id="configFooter" value=" ${ H ( s . settings ? . footer_text || '' ) } "></label><button class="outline" onclick="saveContactSettings()">保存网站联系配置</button></div><div class="section"><h2>常用功能</h2><div class="stack"><button class="outline" onclick="setTab('themes')">切换完整主题 →</button><button class="outline" onclick="setTab('content')">导入内容 / 字段映射 →</button><button class="outline" onclick="setTab('migration')">检查 SEO 和旧站路径 →</button><button class="primary" onclick="buildNow()">生成网站 HTML →</button></div><p>当没有真实产品时,主题不会虚构业务数据;可先完善基础页面,再逐步导入。</p></div></div>` ;}
async function bootstrap (){ try { let r = await api ( factoryUrl ( '/bootstrap' ), 'POST' ,{}); toast ( `新增 ${ r . created . length } 个基础页面;不会覆盖原 URL` ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function themes (){ let cur = await api ( factoryUrl ( '/theme' )); const schemes = { automotive : [ '#172840' , '#cfab79' , 'MOVE BEYOND' ], industrial : [ '#dcebf4' , '#14517d' , 'BUILD FURTHER' ], corporate : [ '#e7e9fb' , '#535bb4' , 'CREATE IMPACT' ], minimal : [ '#e9e5dc' , '#3d3b35' , 'LESS IS MORE' ]};
$ ( '#panel' ). innerHTML = `<div class="section"><div class="toolbar"><h2>可安装、可版本化的主题包</h2><span class="badge">当前主题: ${ H ( cur . theme ) } · v ${ H ( cur . version ) } </span></div><div class="gridthemes"> ${ state . themes . map ( t =>{ let id = t . id || t . theme_id , p = schemes [ id ] || [ '#e9eff8' , '#246c9e' , 'YOUR BRAND' ]; return `<article class="theme-card"><div class="theme-visual" style="background: ${ p [ 0 ] } ;color: ${ p [ 1 ] } "><div class="mocknav"></div><div class="mocktitle"> ${ H ( p [ 2 ]) } </div><div class="mockbtn"></div></div><div class="theme-info"><h3> ${ H ( t . name ) } <small class="muted">v ${ H ( t . version ) } </small></h3><p> ${ H ( t . description ) } </p><div class="actions"><button class="outline" onclick="chooseTheme(' ${ H ( id ) } ',' ${ H ( t . version ) } ')"> ${ id === cur . theme ? '重新应用' : '应用主题' } </button><button class="outline" onclick="showVersions(' ${ H ( id ) } ')">版本管理</button><button class="outline" onclick="checkTheme(' ${ H ( id ) } ',' ${ H ( t . version ) } ')">兼容检查</button><a class="outline" target="_blank" href="/theme-gallery">全部页面预览</a><a class="outline" target="_blank" href="/api/factory/themes/ ${ H ( id ) } /preview?version= ${ H ( t . version ) } ">首页预览</a></div></div></article>` } ).join('')}</div></div>
<div class="columns"><div class="section"><h2>安装新主题 ZIP</h2><p>主题包由 manifest.json、theme.css、page-presets.json 组成。只能安装受控的页面组件和 CSS,不执行上传的 JavaScript 或模板逻辑。</p><label class="field">上传 ZIP 文件<input type="file" id="themePackage" accept=".zip"></label><button class="primary" onclick="uploadTheme()">安装并登记版本</button></div><div class="section"><h2>批量重装配现有内容</h2><p>会按页面类型给已有内容应用当前主题;保留现有 URL、SEO 和正文。选择「只补缺失」可避免覆盖已手工设计的区块。</p><button class="outline" onclick="applyTheme('missing')">仅装配缺失页面</button> <button class="outline" onclick="applyTheme('overwrite')">覆盖全部布局(谨慎)</button><div id="themeVersions" class="muted" style="margin-top:18px"></div></div></div>` ;}
async function chooseTheme ( id , version ){ if ( ! confirm ( `为当前网站选择主题 ${ id } v ${ version } ?将保留网站内容。` )) return ; try { await api ( factoryUrl ( '/theme' ), 'PUT' ,{ theme : id , version }); await refresh (); toast ( '主题切换完成。需要重新构建才能更新正式网站' ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function showVersions ( id ){ let list = await api ( `/api/factory/themes/ ${ id } /versions` ); $ ( '#themeVersions' ). textContent = '已安装版本:' + list . map ( x => x . version ). join ( ' / ' )}
async function checkTheme ( id , version ){ try { const r = await api ( `/api/factory/themes/ ${ id } /compatibility?version= ${ encodeURIComponent ( version ) } ` ); $ ( '#themeVersions' ). textContent = ` ${ r . valid ? '✓ 主题结构可用' : '✗ 存在阻断' } · ${ r . checked_page_types || 0 } 类页面 · ${ [...( r . errors || []),...( r . warnings || [])]. join ( '; ' ) || '无结构警告' } 。正式交付前仍需实际浏览器验收。` ;} catch ( e ){ toast ( e . message , true )}}
async function uploadTheme (){ let f = $ ( '#themePackage' ). files ? .[ 0 ]; if ( ! f ) return toast ( '请选择主题 ZIP' , true ); let data = new FormData (); data . append ( 'file' , f ); try { let r = await api ( '/api/factory/themes/install' , 'POST' , data ); toast ( `已安装: ${ r . name } v ${ r . version } ` ); await refresh (); await render ()} catch ( e ){ toast ( e . message , true )}}
async function applyTheme ( mode ){ if ( mode === 'overwrite' &&! confirm ( '覆盖已有页面设计(内容和 SEO 不变),继续吗?' )) return ; try { let r = await api ( siteUrl ( '/apply-theme' ), 'POST' ,{ mode }); toast ( `套版完成:更新 ${ r . updated } 个页面` )} catch ( e ){ toast ( e . message , true )}}
async function content (){ let entries = await api ( siteUrl ( '/entries' )); $ ( '#panel' ). innerHTML = `<div class="columns"><div class="section"><h2>新内容 / 旧内容导入装配</h2><p>上传 Excel、JSON 数组或 CSV;系统自动建议字段对应关系,例如「车型名称 → 标题」「品牌 → 产品品牌」。映射会经过预览与冲突检查。</p><label class="field">文件<input id="dataFile" type="file" accept=".csv,.json,.xlsx"></label><label class="field">导入内容类型<select id="mapKind"><option value="product">产品</option><option value="vehicle">车型</option><option value="brand">品牌</option><option value="post">新闻</option><option value="case">案例</option><option value="service">服务</option><option value="page">页面</option></select></label><button class="primary" onclick="readData()">读取文件并识别字段</button><div id="mappingUI"></div><div class="toolbar"><button class="outline" onclick="previewRelations()">预览产品-品牌关系</button><button class="outline" onclick="assembleRelations()">生成缺失的品牌页面</button></div><div id="relationReport"></div></div><div class="section"><h2>本站内容统计</h2> ${ [ 'page' , 'product' , 'vehicle' , 'brand' , 'post' , 'case' , 'service' ]. map ( t => `<div class="row"><strong> ${ H (({ page : '页面' , product : '产品' , vehicle : '车型' , brand : '品牌' , post : '新闻' , case : '案例' , service : '服务' } )[t])}</strong><span class="badge"> ${ entries . filter ( e => e . type === t ). length } </span></div>` ). join ( '' ) } <p>其他导入方式(WordPress XML/REST、Excel、媒体 ZIP)可以在原版站点管理工作台使用,使用相同的数据存储。</p><a class="outline" href="/">打开完整内容管理 →</a></div></div>` ;}
function parseCSV ( text ){ let rows = [], row = [], cell = '' , quoted = false ; for ( let i = 0 ; i < text . length ; i ++ ){ let ch = text [ i ]; if ( ch === '"' ){ if ( quoted && text [ i + 1 ] === '"' ){ cell += '"' ; i ++ } else quoted =! quoted } else if ( ch === ',' &&! quoted ){ row . push ( cell ); cell = '' } else if (( ch === '\n' || ch === '\r' ) &&! quoted ){ if ( ch === '\r' && text [ i + 1 ] === '\n' ) i ++ ; row . push ( cell ); if ( row . some ( x => x . trim ())) rows . push ( row ); row = []; cell = '' } else cell += ch ;} row . push ( cell ); if ( row . some ( x => x . trim ())) rows . push ( row ); let cols = rows . shift () ? . map ( s => s . trim ()) || []; return rows . map ( r => Object . fromEntries ( cols . map (( c , i )=>[ c , r [ i ] || '' ])))}
async function readData (){ let f = $ ( '#dataFile' ). files ? .[ 0 ]; if ( ! f ) return toast ( '请选择 Excel、CSV 或 JSON 文件' , true );
try { let data = new FormData (); data . append ( 'file' , f ); const parsed = await api ( factoryUrl ( '/mapping/parse-file' ), 'POST' , data ); state . importRows = parsed . rows ; state . fields = parsed . columns ; state . mapping = parsed . suggested ; drawMapping ();}
catch ( e ){ toast ( e . message , true )}}
async function previewRelations (){ try { const r = await api ( factoryUrl ( '/relationships' )); $ ( '#relationReport' ). innerHTML = `<div class="card"><b>产品-品牌关联检查</b><p>已有品牌 ${ r . brands . length } 个、分类 ${ r . categories . length } 个;缺少独立品牌页 ${ r . brand_pages_missing . length } 个</p><p> ${ H ( r . brand_pages_missing . map ( x => x . brand ). join ( ' / ' ) || '无' ) } </p> ${ r . issues . slice ( 0 , 10 ). map ( x => `<p> ${ H ( x . route ) } · ${ H ( x . message ) } </p>` ). join ( '' ) } </div>` ;} catch ( e ){ toast ( e . message , true )}}
async function assembleRelations (){ if ( ! confirm ( '依据真实产品品牌字段创建缺少的品牌页面?不会自动生成虚构文案。' )) return ; try { const r = await api ( factoryUrl ( '/relationships/assemble' ), 'POST' ,{}); toast ( `已创建 ${ r . count } 个品牌页面` ); await previewRelations ()} catch ( e ){ toast ( e . message , true )}}
function drawMapping (){ let fields = [ 'title' , 'route' , 'summary' , 'body' , 'cover' , 'brand' , 'category' , 'model' , 'energy' , 'range' , 'gallery' , 'specs' , 'sku' , 'price' , 'seo_title' , 'seo_description' , 'canonical' ]; let labels = { title : '标题(必填)' , route : '旧站 URL' , summary : '摘要' , body : '详情正文' , cover : '封面' , brand : '品牌' , category : '分类' , seo_title : 'SEO标题' , seo_description : 'SEO描述' , canonical : 'Canonical' , model : '型号' , energy : '动力形式' , range : '续航' , gallery : '图库(JSON数组或分号分隔)' , specs : '技术参数' , sku : 'SKU' , price : '价格' };
$ ( '#mappingUI' ). innerHTML = `<div style="margin-top:27px"><h2>字段映射(已读取 ${ state . importRows . length } 行)</h2> ${ fields . map ( t => `<div class="rule"><strong> ${ H ( labels [ t ]) } </strong><select data-target=" ${ t } " class="mapfield"><option value="">不映射</option> ${ state . fields . map ( x => `<option value=" ${ H ( x ) } " ${ state . mapping [ t ] === x ? 'selected' : '' } > ${ H ( x ) } </option>` ). join ( '' ) } </select></div>` ). join ( '' ) } <button class="outline" onclick="mappingPreview()">预览数据和错误</button><button class="primary" onclick="mappingCommit()">确认导入</button><div id="mappingReport" style="margin-top:16px"></div></div>` ;}
function gather (){ let rules = {}; $$ ( '.mapfield' ). forEach ( x =>{ if ( x . value ) rules [ x . dataset . target ] = x . value }); return { rows : state . importRows , rules , content_type : $ ( '#mapKind' ). value }}
async function mappingPreview (){ try { let r = await api ( factoryUrl ( '/mapping/run' ), 'POST' ,{... gather (), dry_run : true }); $ ( '#mappingReport' ). innerHTML = `<div class="card"><b>预览: ${ r . mapped } 条可映射</b><p>错误 ${ r . errors . length } 条,URL 冲突 ${ r . conflicts . length } 条</p><div class="scroll"><table><thead><tr><th>标题</th><th>新 URL</th><th>内容类型</th></tr></thead><tbody> ${ r . sample . map ( x => `<tr><td> ${ H ( x . title ) } </td><td> ${ H ( x . route ) } </td><td> ${ H ( x . type ) } </td></tr>` ). join ( '' ) } </tbody></table></div> ${ r . errors . length ? '<p class="error">' + H ( JSON . stringify ( r . errors . slice ( 0 , 6 ))) + '</p>' : '' } </div>` } catch ( e ){ toast ( e . message , true )}}
async function mappingCommit (){ if ( ! confirm ( '确认将已映射的数据写入当前网站?' )) return ; try { let r = await api ( factoryUrl ( '/mapping/run' ), 'POST' ,{... gather (), dry_run : false , on_conflict : 'skip' }); toast ( `新增 ${ r . created } 条,跳过 ${ r . skipped } 条,错误 ${ r . errors . length } 条` ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function migration (){ let [ data , assets , builds ] = await Promise . all ([ api ( factoryUrl ( '/migration/readiness' )), api ( factoryUrl ( '/assets/inventory' )), api ( siteUrl ( '/builds' ))]);
let audit = builds . length ? await api ( `/api/audit/sites/ ${ state . site . id } /builds/ ${ builds [ 0 ]. id } /report` ). catch (()=> null ) : null ; const gate = data . gates || []; $ ( '#panel' ). innerHTML = `<div class="cards"><div class="card"><span class="overline">旧站 URL 基线</span><strong> ${ data . baseline . baseline_count } </strong><small>应保持有效的历史路径</small></div><div class="card"><span class="overline">缺失 URL</span><strong> ${ data . baseline . errors } </strong><small>发布阻断项</small></div><div class="card"><span class="overline">SEO 差异</span><strong> ${ data . baseline . warnings } </strong><small>需要人工核查</small></div><div class="card"><span class="overline">资源引用</span><strong> ${ assets . references } </strong><small>实际已识别的内容图片</small></div></div><div class="columns"><div class="section"><h2>迁移验收门禁</h2> ${ gate . length ? gate . map ( g => `<div class="row"><div><span class="badge ${ g . level === 'block' ? 'danger' : 'warn' } "> ${ g . level === 'block' ? '阻止发布' : '需人工复核' } </span><p> ${ H ( g . message ) } </p></div></div>` ). join ( '' ) : '<p class="success">当前已检测范围内未发现阻断项。</p>' } <div class="toolbar"><label>上传原站清单(WXR / Sitemap XML / CSV / JSON) <input type="file" id="baselineFile" accept=".xml,.csv,.json"></label><button class="primary" onclick="uploadBaseline()">导入原站 URL 基线</button></div><div class="toolbar"><button class="outline" onclick="snapshot()">用当前内容建立 URL 基线(仅限新站)</button><a class="outline" href=" ${ factoryUrl ( '/assets/inventory' ) } " target="_blank">资源清单 JSON</a><button class="outline" onclick="openLatestAudit()">构建后查看完整差异报告</button></div><div id="baselineImportResult"></div><p>提示:完整旧站 URL 基线应来自迁移前的原网站清单,不要用迁移后不完整的网站替代。</p></div><div class="section"><h2>实际生成 HTML 与旧站对比</h2> ${ audit ? `<p>旧站 ${ audit . baseline_count } 条 / 已匹配 ${ audit . matched_pages || 0 } 条;阻断 ${ audit . errors } 项;待复核 ${ audit . unreviewed_warnings || 0 } 项</p><a class="outline" href="/api/audit/sites/ ${ state . site . id } /builds/ ${ builds [ 0 ]. id } /report.csv">下载差异 CSV</a><div class="scroll"><table><thead><tr><th>问题</th><th>路径</th><th>操作</th></tr></thead><tbody> ${ audit . issues . slice ( 0 , 40 ). map ( i => `<tr><td> ${ H ( i . field ) } · ${ H ( i . message ) } </td><td> ${ H ( i . route ) } </td><td> ${ i . severity === 'warning' &&! i . reviewed ? `<button class="outline" onclick="approveAuditWarning(' ${ H ( builds [ 0 ]. id ) } ',' ${ H ( i . issue_id ) } ')">复核接受</button>` : i . reviewed ? '已复核' : '必须修复' } </td></tr>` ). join ( '' ) } </tbody></table></div>` : '<p>请先构建新站,再与旧站基线对比。</p>' } </div><div class="section"><h2>前 12 项差异</h2> ${ data . baseline . issues . slice ( 0 , 12 ). map ( x => `<div class="row"><div><strong> ${ H ( x . route ) } </strong><small> ${ H ( x . field ) } · ${ H ( x . severity ) } </small></div></div>` ). join ( '' ) || '<p>没有基线差异。</p>' } </div></div>` ;}
async function saveContactSettings (){ try { await api ( siteUrl ( '/contact-settings' ), 'PUT' ,{ email : $ ( '#configEmail' ). value , form_endpoint : $ ( '#configForm' ). value , footer_text : $ ( '#configFooter' ). value }); toast ( '网站联系配置已保存' ); await refresh ()} catch ( e ){ toast ( e . message , true )}}
async function approveAuditWarning ( bid , issue ){ const reason = prompt ( '填写核查原因及处理依据(至少10字,审核后对本构建生效):' , '' ); if ( ! reason ) return ; try { await api ( `/api/audit/sites/ ${ state . site . id } /builds/ ${ bid } /review-warning` , 'POST' ,{ issue_id : issue , reason }); toast ( '已记录审查决定' ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function uploadBaseline (){ const file = $ ( '#baselineFile' ) ? . files ? .[ 0 ]; if ( ! file ) return toast ( '请选择原站清单文件' , true ); const data = new FormData (); data . append ( 'file' , file );
try { let r = await api ( `/api/audit/sites/ ${ state . site . id } /import-baseline` , 'POST' , data ); if ( r . errors ? . length ){ $ ( '#baselineImportResult' ). textContent = JSON . stringify ( r . errors . slice ( 0 , 15 )); toast ( '源站清单存在错误,未写入基线' , true ); return } toast ( `已导入 ${ r . imported } 个旧站 URL` ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function openLatestAudit (){ try { let builds = await api ( siteUrl ( '/builds' )); if ( ! builds . length ) return toast ( '请先构建网站' , true ); window . open ( `/api/audit/sites/ ${ state . site . id } /builds/ ${ builds [ 0 ]. id } /report` , '_blank' , 'noopener' )} catch ( e ){ toast ( e . message , true )}}
async function snapshot (){ if ( ! confirm ( '仅适用于尚未迁移的测试网站。真实迁移应导入旧站原始 URL 清单!继续吗?' )) return ; try { let r = await api ( `/api/enterprise/sites/ ${ state . site . id } /baseline/snapshot` , 'POST' ,{}); toast ( '已建立 ' + r . captured + ' 条当前内容基线' ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function buildPanel (){ let [ builds , ready , releaseRequests ] = await Promise . all ([ api ( siteUrl ( '/builds' )), api ( factoryUrl ( '/migration/readiness' )), api ( factoryUrl ( '/release-requests' ))]); $ ( '#panel' ). innerHTML = `<div class="columns"><div class="section"><h2>静态构建版本</h2><div class="toolbar"><button class="primary" onclick="buildNow()">+ 构建新版本</button><span class="badge ${ ready . can_publish ? '' : 'danger' } "> ${ ready . can_publish ? '可进入发布审查' : '发布前有阻断项' } </span></div><div class="scroll"><table><thead><tr><th>版本</th><th>页面数</th><th>生成时间</th><th>交付</th></tr></thead><tbody> ${ builds . slice ( 0 , 15 ). map ( b => `<tr><td> ${ H ( b . id ) } </td><td> ${ b . total_pages } </td><td> ${ H ( b . created_at ) } </td><td><a target="_blank" href="/preview/ ${ state . site . id } / ${ b . id } /">预览</a> · <a href="/api/sites/ ${ state . site . id } /builds/ ${ b . id } /download">下载</a> · <button class="outline" onclick="requestRelease(' ${ b . id } ')">提交审批</button> · <button class="outline" onclick="publishRelease(' ${ b . id } ')">发布</button></td></tr>` ). join ( '' ) } </tbody></table></div></div><div class="section"><h2>交付说明</h2><p>静态网站包含 HTML、CSS、JavaScript、独立搜索索引、站点地图和 Nginx 重定向示例配置。</p><p>通过实际发布前,需要校验 SEO 基线、静态资源和功能;当前本地发布仅适用于事先配置的发布目录,公网 DNS/CDN 连接器仍需另行开发。</p><div class="stack"> ${ ready . gates . map ( g => `<div class="row"><span class="badge ${ g . level === 'block' ? 'danger' : 'warn' } "> ${ H ( g . level ) } </span><p> ${ H ( g . message ) } </p></div>` ). join ( '' ) } </div><h2 style="margin-top:24px">发布审批记录</h2> ${ releaseRequests . slice ( 0 , 8 ). map ( r => `<div class="row"><div><strong> ${ H ( r . build_id ) } </strong><small> ${ H ( r . requested_by ) } · ${ H ( r . state ) } </small></div><div> ${ r . state === 'pending' ? `<button class="outline" onclick="reviewRelease(' ${ r . id } ','approved')">通过</button> <button class="outline" onclick="reviewRelease(' ${ r . id } ','rejected')">驳回</button>` : '' } </div></div>` ). join ( '' ) || '<p>暂无发布审批记录。</p>' } </div></div>` ;}
async function requestRelease ( build ){ try { let r = await api ( factoryUrl ( '/release-requests' ), 'POST' ,{ build_id : build }); toast ( '审批申请已提交:' + r . id ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function reviewRelease ( id , status ){ try { let note = prompt ( '请输入审批说明(可留空)' , '' ) || '' ; await api ( factoryUrl ( '/release-requests/' + id + '/review' ), 'POST' ,{ state : status , note }); toast ( '审批状态已更新' ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function publishRelease ( build ){ if ( ! confirm ( '确认将此版本发布到预先配置的本地持久化站点目录?' )) return ; try { let r = await api ( siteUrl ( '/builds/' + build + '/publish-local' ), 'POST' ,{}); toast ( '已发布:' + r . release ); await render ()} catch ( e ){ toast ( e . message , true )}}
async function buildNow (){ if ( ! state . site ) return ; try {
toast ( '构建已提交后台队列……' ); const sid = state . site . id ;
const job = await api ( siteUrl ( '/builds' ), 'POST' ,{ mirror_images : false });
toast ( '任务 ' + job . id + ' 排队中;关闭窗口不会中断' );
for ( let i = 0 ; i < 240 ; i ++ ){
await new Promise ( resolve => setTimeout ( resolve , 1500 ));
const status = await api ( `/api/enterprise/sites/ ${ sid } /jobs/ ${ job . id } ` );
if ( status . state === 'done' ){ const r = JSON . parse ( status . result_json || '{}' ); toast ( `构建成功, ${ r . pages } 个页面` ); await render (); if ( r . version ) window . open ( `/preview/ ${ sid } / ${ r . version } /` , '_blank' , 'noopener' ); return }
if ([ 'failed' , 'cancelled' ]. includes ( status . state )){ toast ( '构建' + status . state + ': ' + ( status . error || '' ), true ); return }
} toast ( '任务仍在后台运行,请到任务中心查看' );
} catch ( e ){ toast ( e . message , true )}}
window . addEventListener ( 'DOMContentLoaded' , init );
window . requestRelease = requestRelease ; window . reviewRelease = reviewRelease ; window . publishRelease = publishRelease ; window . bootstrap = bootstrap ; window . chooseTheme = chooseTheme ; window . checkTheme = checkTheme ; window . showVersions = showVersions ; window . uploadTheme = uploadTheme ; window . applyTheme = applyTheme ; window . readData = readData ; window . uploadBaseline = uploadBaseline ; window . approveAuditWarning = approveAuditWarning ; window . saveContactSettings = saveContactSettings ; window . openLatestAudit = openLatestAudit ; window . previewRelations = previewRelations ; window . assembleRelations = assembleRelations ; window . mappingPreview = mappingPreview ; window . mappingCommit = mappingCommit ; window . snapshot = snapshot ; window . buildNow = buildNow ; window . setTab = setTab ; window . openCreate = openCreate ;