2026-10-22 16:49:25 +08:00
'use strict' ;
const $ = ( q , el = document )=> el . querySelector ( q );
const E = s => String ( s ?? '' ). replace ( /[&<>"']/g , c =>({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
const ATTR = s => E ( s );
2026-10-22 18:51:27 +08:00
const cfg = { automotive : { label : '汽车与出行' , desc : '高级质感 · 深色车型展厅' , prev : 'auto' , title : 'Move beyond.' }, industrial : { label : '工业制造' , desc : '专业理性 · 工业产品展示' , prev : 'ind' , title : 'Built to last.' }, corporate : { label : '企业品牌' , desc : '现代明亮 · 集团企业官网' , prev : 'corp' , title : 'Ideas become impact.' }, minimal : { label : '极简设计' , desc : '编辑式排版 · 极简作品展示' , prev : 'min' , title : 'Less, but better.' }, 'trade-pro' : { label : 'Trade Pro' , desc : '英文外贸 · B2B 目录与询盘' , prev : 'corp' , title : 'Go global.' }};
2026-10-22 16:49:25 +08:00
let csrfToken = '' ;
const state = { sites : [], siteId : null , site : null , entries : [], builds : [], tab : 'overview' , presets : [], query : '' , typ : '' };
const api = async ( path , opt = {})=>{
if ( ! [ 'GET' , 'HEAD' , 'OPTIONS' ]. includes (( opt . method || 'GET' ). toUpperCase ())) { opt . headers = {...( opt . headers || {}), 'X-SiteForge-CSRF' : csrfToken }}
const res = await fetch ( path , opt ); if ( res . status === 401 ){ location . assign ( '/login' ); throw new Error ( '登录已过期' )} let data ; try { data = await res . json ()} catch { data = { detail : '请求失败,响应不是 JSON' }}
if ( ! res . ok ) throw new Error ( typeof data . detail === 'string' ? data . detail : JSON . stringify ( data . detail || data )); return data ;
};
const json = ( method , data )=>({ method , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ( data )});
const notice = ( s , bad = false )=>{ const t = $ ( '#toast' ); t . innerText = s ; t . style . background = bad ? '#a03e45' : '#243952' ; t . classList . add ( 'show' ); clearTimeout ( t . _timer ); t . _timer = setTimeout (()=> t . classList . remove ( 'show' ), 4300 )};
const onError = e =>{ console . error ( e ); notice ( e . message || String ( e ), true )};
const simpleDate = s => s ? new Date ( s ). toLocaleString ( 'zh-CN' ,{ year : 'numeric' , month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' }) : '—' ;
const b = ( text , fn , extra = '' )=> `<button class="btn light small" ${ extra } onclick=" ${ fn } "> ${ text } </button>` ;
const tabs = [[ 'overview' , '工作概览' ],[ 'entries' , '内容管理' ],[ 'templates' , '模板与设计' ],[ 'design' , '可视化页面设计' ],[ 'models' , '内容字段模型' ],[ 'import' , '旧站 / 数据导入' ],[ 'seo' , 'SEO / URL' ],[ 'builds' , '构建与导出' ]];
function topTitle ( name ){ $ ( '#topTitle' ). textContent = name }
function hero ( title , desc , buttons = '' , eyebrow = 'SITEFORGE / WEBSITE FACTORY' ){ return `<div class="page-head"><div><span class="eyebrow"> ${ E ( eyebrow ) } </span><h1> ${ E ( title ) } </h1><p> ${ E ( desc ) } </p></div><div class="top-actions"> ${ buttons } </div></div>` }
function themeCard ( key , action ){ let t = cfg [ key ]; return `<div class="template-card"><div class="template-art preview- ${ t . prev }${ key === 'minimal' ? ' min' : '' } "><span>THEME / ${ E ( key . toUpperCase ()) } </span><strong> ${ E ( t . title ) } </strong></div><div class="detail"><div><h3> ${ E ( t . label ) } </h3><p> ${ E ( t . desc ) } </p></div> ${ action } </div></div>` }
const App = {
async init (){ try { const me = await api ( '/api/auth/me' ); csrfToken = me . csrf ; state . sites = await api ( '/api/sites' ); state . presets = await api ( '/api/presets' ); App . renderSiteNav (); App . openDashboard ()} catch ( e ){ onError ( e )}},
async logout (){ try { await api ( '/api/auth/logout' ,{ method : 'POST' }); location . assign ( '/login' )} catch ( e ){ onError ( e )}},
async reload (){ state . sites = await api ( '/api/sites' ); App . renderSiteNav (); if ( state . siteId ){ state . site = await api ( '/api/sites/' + state . siteId ); state . entries = await api ( '/api/sites/' + state . siteId + '/entries' ); state . builds = await api ( '/api/sites/' + state . siteId + '/builds' )}},
renderSiteNav (){ $ ( '#siteNav' ). innerHTML = state . sites . map ( s => `<button class=" ${ s . id === state . siteId ? 'active' : '' } " onclick="App.openSite(' ${ s . id } ')"><span class="site-dot"> ${ E (( s . name || 'S' )[ 0 ]) } </span><span title=" ${ ATTR ( s . name ) } "> ${ E ( s . name ) } </span></button>` ). join ( '' ) || '<div style="padding:10px 12px;font-size:11px;color:#7185a2">暂无网站项目</div>' },
openDashboard (){ state . siteId = null ; topTitle ( '网站总览' ); App . renderSiteNav (); document . querySelectorAll ( '.side-link' ). forEach ( x => x . classList . remove ( 'selected' )); $ ( '#nav-dashboard' ). classList . add ( 'selected' );
let n = state . sites . length , counts = state . sites . reduce (( x , s )=> x + s . entries_count , 0 ), builds = state . sites . reduce (( x , s )=> x + s . build_count , 0 );
$ ( '#view' ). innerHTML = hero ( '网站生产工作台' , '从零创建、迁移旧站、导入内容、更换模板,统一生产独立静态网站。' , `<button class="btn primary" onclick="App.openNewSite()">+ 创建网站</button>` , 'WELCOME TO YOUR FACTORY' ) +
`<div class="metrics-grid"><div class="metric"><small>网站总数</small><strong> ${ n } </strong><span>独立站点项目</span></div><div class="metric"><small>内容条目</small><strong> ${ counts } </strong><span>页面、产品、文章等</span></div><div class="metric"><small>构建版本</small><strong> ${ builds } </strong><span>可预览与下载</span></div><div class="metric"><small>可用主题</small><strong>4</strong><span>支持每站独立风格</span></div></div>` +
`<div class="page-head" style="margin-top:35px"><div><h1 style="font-size:21px">我的网站</h1><p>选择任意站点,继续编辑、导入数据或构建版本</p></div></div><div class="cards">` +
state . sites . map ( s => `<article class="site-card" onclick="App.openSite(' ${ s . id } ')"><div class="card-preview preview- ${ cfg [ s . theme ] ? . prev || 'corp' } "><span class="card-tag"> ${ E ( cfg [ s . theme ] ? . label || '企业站' ) } </span><div class="brandmark"> ${ E ( s . name ) } </div></div><div class="site-card-body"><h3> ${ E ( s . name ) } </h3><p> ${ E ( s . domain || '待绑定域名' ) } · ${ E ( s . tagline || '尚未填写站点简介' ) } </p><div class="site-card-meta"><span> ${ s . entries_count } 条内容</span><span> ${ s . build_count } 次构建 →</span></div></div></article>` ). join ( '' ) + `<article class="add-card" onclick="App.openNewSite()"><div class="add-circle">+ </div><h3>创建新网站</h3><p>旧站迁移 / 新建站点 / 导入内容</p></article></div>` +
( ! state . sites . length ? `<div class="panel" style="margin-top:26px"><div class="panel-title"><div><h3>第一次体验 SiteForge? </h3><p>生成演示网站,并立即构建一个可独立下载的纯静态网站。</p></div><button class="btn light" onclick="App.seedDemo()">快速创建演示网站 →</button></div></div>` : '' );
},
async openSite ( id , tab = 'overview' ){ try { state . siteId = id ; state . tab = tab ; await App . reload (); topTitle ( state . site . name ); App . renderSiteNav (); App . renderSite ()} catch ( e ){ onError ( e )}},
renderSite (){ const s = state . site ; let head = hero ( s . name , s . domain || '独立网站 · 尚未设置域名' , `<button class="btn light" onclick="App.openEntry()">+ 添加内容</button><button class="btn primary" onclick="App.build()">↗ 构建网站</button>` , 'WEBSITE PROJECT / ' + s . theme . toUpperCase ());
let navigation = `<div class="tabs"> ${ tabs . map (([ id , name ])=> `<button class="tab ${ state . tab === id ? 'current' : '' } " onclick="App.changeTab(' ${ id } ')"> ${ name } </button>` ). join ( '' ) } </div>` ;
$ ( '#view' ). innerHTML = head + `<div class="metrics-grid"><div class="metric"><small>页面 / 内容</small><strong> ${ state . entries . length } </strong><span>统一内容数据中心</span></div><div class="metric"><small>主题</small><strong style="font-size:17px;padding:9px 0"> ${ E ( cfg [ s . theme ] ? . label ) } </strong><span>可随时切换</span></div><div class="metric"><small>构建次数</small><strong> ${ state . builds . length } </strong><span>独立构建版本</span></div><div class="metric"><small>SEO 模式</small><strong style="font-size:17px;padding:9px 0"> ${ s . mode === 'migration' ? '原址保留' : '灵活新建' } </strong><span>路由独立管理</span></div></div>` + navigation + `<div id="tab-content"></div>` ; App . renderTab ();
},
changeTab ( tab ){ state . tab = tab ; App . renderSite ()},
renderTab (){ let html = '' ; switch ( state . tab ){ case 'overview' : html = App . overview (); break ; case 'entries' : html = App . entriesView (); break ; case 'templates' : html = App . templateView (); break ; case 'design' : html = App . designView (); break ; case 'models' : html = App . modelsView (); break ; case 'import' : html = App . importView (); break ; case 'seo' : html = App . seoView (); break ; case 'builds' : html = App . buildView (); break ;} $ ( '#tab-content' ). innerHTML = html ; if ( state . tab === 'import' ) App . loadMedia (); if ( state . tab === 'seo' ) App . loadRedirects (); if ( state . tab === 'models' ) App . loadModels ()},
overview (){ let s = state . site , latest = state . builds [ 0 ]; return `<div class="panel"><div class="panel-title"><div><h3>网站生产流程</h3><p>每次修改内容、主题或域名后都可以重新构建</p></div></div><div class="steps"><div class="step"><span>STEP 01</span><strong>创建网站</strong><p>选择主题,完成独立站点初始化</p></div><div class="step"><span>STEP 02</span><strong>添加或导入内容</strong><p>手动录入、旧站采集、文件批量导入</p></div><div class="step"><span>STEP 03</span><strong>主题与 SEO</strong><p>自定义视觉样式、路由及页面元信息</p></div><div class="step"><span>STEP 04</span><strong>构建并导出</strong><p>生成静态 HTML 文件,预览并下载 ZIP</p></div></div></div><div class="split"><div class="panel"><div class="panel-title"><div><h3>网站基本资料</h3><p>设置域名、品牌和原网站来源</p></div></div> ${ App . settingsForm () } </div><div class="panel"><div class="panel-title"><div><h3>最近构建版本</h3><p>构建文件与管理后台彼此独立</p></div></div> ${ latest ? `<div class="pill green">静态构建完成</div><h2 style="margin:16px 0"> ${ latest . total_pages } 个 HTML 页面</h2><p style="color:#8292a8"> ${ simpleDate ( latest . created_at ) } </p><div class="panel-footer" style="justify-content:start"><a class="btn light" target="_blank" href="/preview/ ${ s . id } / ${ latest . id } /">查看预览 ↗</a><a class="btn primary" href="/api/sites/ ${ s . id } /builds/ ${ latest . id } /download">下载 ZIP</a></div>` : `<div class="empty"><div>⌁</div><h3>还没有构建版本</h3><p>保存内容并点击「构建网站」,即可生成首个网站。</p><button class="btn primary" onclick="App.build()">开始第一次构建</button></div>` } </div></div>` },
settingsForm (){ let s = state . site ; return `<form id="settingsForm" onsubmit="App.saveSettings(event)"><div class="setting-grid"><label class="full">网站名称<input name="name" value=" ${ ATTR ( s . name ) } " required></label><label class="full">正式网站地址<input name="domain" value=" ${ ATTR ( s . domain ) } " placeholder="https://domain.com"></label><label class="full">网站简介 / 标语<textarea name="tagline" rows="2"> ${ E ( s . tagline ) } </textarea></label><label class="full">源站地址(旧站导入)<input name="origin" value=" ${ ATTR ( s . settings . origin || '' ) } " placeholder="https://oldsite.com"></label><label>品牌色<input name="accent" type="color" value=" ${ ATTR ( s . accent ) } " style="height:42px;padding:3px"></label><label>站点模式<select name="mode"><option value="new" ${ s . mode === 'new' ? 'selected' : '' } >新建</option><option value="migration" ${ s . mode === 'migration' ? 'selected' : '' } >迁移</option><option value="hybrid" ${ s . mode === 'hybrid' ? 'selected' : '' } >混合</option></select></label><label class="full">Logo 图片 URL<input name="logo" value=" ${ ATTR ( s . logo || '' ) } "></label></div><div class="panel-footer"><button class="btn primary" type="submit">保存网站设置</button></div></form>` },
async saveSettings ( ev ){ ev . preventDefault (); try { let data = Object . fromEntries ( new FormData ( ev . target )); state . site = await api ( '/api/sites/' + state . siteId , json ( 'PATCH' , data )); await App . reload (); notice ( '已保存站点设置' ); App . renderSite ()} catch ( e ){ onError ( e )}},
entriesView (){ let arr = state . entries . filter ( e =>( ! state . typ || e . type === state . typ ) && ( ! state . query || e . title . toLowerCase (). includes ( state . query . toLowerCase ()) || e . route . toLowerCase (). includes ( state . query . toLowerCase ()))); return `<div class="panel"><div class="panel-title"><div><h3>内容库</h3><p>页面 / 产品 / 文章 / 自定义内容,支持独立 URL 和 SEO</p></div><button class="btn primary small" onclick="App.openEntry()">+ 新建内容</button></div><div class="toolbar"><input id="searchEntry" placeholder="搜索标题或 URL" value=" ${ ATTR ( state . query ) } " oninput="App.filterContent()"><select id="typeFilter" onchange="App.filterContent()"><option value="">全部类型</option> ${ [ 'page' , 'product' , 'post' , 'service' , 'case' , 'vehicle' , 'brand' , 'other' ]. map ( t => `<option value=" ${ t } " ${ state . typ === t ? 'selected' : '' } > ${ { page : '页面' , product : '产品' , post : '文章' , service : '服务' , case : '案例' , vehicle : '车型' , brand : '品牌' , other : '自定义' } [t]}</option>` ). join ( '' ) } </select><span class="muted"> ${ arr . length } 条结果</span></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>标题</th><th>类型</th><th>路由</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead><tbody> ${ arr . map ( e => `<tr><td><strong> ${ E ( e . title ) } </strong><div style="font-size:11px;color:#9da5b2;max-width:240px;overflow:hidden;text-overflow:ellipsis"> ${ E ( e . summary || '' ) } </div></td><td> ${ E ( e . type ) } </td><td class="mono"> ${ E ( e . route ) } </td><td><span class="pill ${ e . status === 'published' ? 'green' : 'yellow' } "> ${ e . status === 'published' ? '已发布' : '草稿' } </span></td><td> ${ simpleDate ( e . updated_at ) } </td><td><div class="row-actions"><a class="btn light tiny" href="/editor/ ${ state . siteId } / ${ e . id } " target="_blank">页面设计</a><button class="btn light tiny" onclick="App.openEntry(' ${ e . id } ')">编辑内容</button><button class="btn light tiny" onclick="App.deleteEntry(' ${ e . id } ')">删除</button></div></td></tr>` ). join ( '' ) } </tbody></table> ${ arr . length ? '' : '<div class="empty"><h3>尚无匹配内容</h3><p>新建内容或使用导入中心批量导入</p></div>' } </div></div>` },
filterContent (){ const active = document . activeElement ? . id ; const caret = active === 'searchEntry' ? $ ( '#searchEntry' ). selectionStart : null ; state . query = $ ( '#searchEntry' ) ? . value || '' ; state . typ = $ ( '#typeFilter' ) ? . value || '' ; $ ( '#tab-content' ). innerHTML = App . entriesView (); if ( active === 'searchEntry' ){ const input = $ ( '#searchEntry' ); input . focus (); if ( caret !== null ) input . setSelectionRange ( caret , caret )}},
async updateNavigation ( ev ){ ev . preventDefault (); try { const raw = $ ( '#navRoutes' ). value . split ( '\n' ). map ( x => x . trim ()). filter ( Boolean ); const entries = raw . map ( line =>{ const sep = line . indexOf ( '|' ); if ( sep < 0 ) throw new Error ( '每行必须为:导航文字 | /路径/' ); return { title : line . slice ( 0 , sep ). trim (), route : line . slice ( sep + 1 ). trim ()}}); let path = '/api/sites/' + state . siteId + '/site-design' ; let current = await api ( path ); current . config . nav = entries ; await api ( path , json ( 'PUT' ,{ config : current . config , expected_revision : current . revision })); notice ( '导航已保存为全站草稿:' + entries . length + '项,请在设计器中发布全站设计' )} catch ( e ){ onError ( e )}},
async scaffold (){ if ( ! confirm ( '创建关于我们、联系我们和服务页面?已有同路径页面不会覆盖。' )) return ; try { let r = await api ( '/api/sites/' + state . siteId + '/scaffold' ,{ method : 'POST' }); await App . reload (); App . renderSite (); notice ( '创建 ' + r . created . length + ' 个页面,跳过 ' + r . skipped . length + ' 个' )} catch ( e ){ onError ( e )}},
modelsView (){ return `<div class="panel"><div class="panel-title"><div><h3>内容字段模型</h3><p>自定义产品、车型、品牌、文章等数据字段。字段保存在每个站点自己的配置内,不影响其他站点。</p></div></div><div class="toolbar"><label>内容类型 <select id="modelKind" onchange="App.renderModelFields()"> ${ [ 'product' , 'vehicle' , 'post' , 'brand' , 'case' , 'service' , 'other' ]. map ( x => `<option value=" ${ x } "> ${ x } </option>` ). join ( '' ) } </select></label><button class="btn primary" onclick="App.addModelField()">+ 添加字段</button></div><div id="modelFields"><p>正在读取字段定义……</p></div><div class="panel-footer"><button class="btn primary" onclick="App.saveModelFields()">保存字段模型</button></div></div>` },
async loadModels (){ try { state . models = await api ( '/api/sites/' + state . siteId + '/schemas' ); App . renderModelFields ()} catch ( e ){ onError ( e )}},
renderModelFields (){ let el = $ ( '#modelFields' ); if ( ! el ||! state . models ) return ; let kind = $ ( '#modelKind' ). value ; let defs = state . models [ kind ] || []; el . innerHTML = `<table class="data-table"><thead><tr><th>字段键名</th><th>显示名称</th><th>字段类型</th><th></th></tr></thead><tbody id="fieldRows"> ${ defs . map ( d => App . modelRow ( d )). join ( '' ) } </tbody></table>` },
modelRow ( d = {}){ return `<tr class="field-row"><td><input class="field-key" value=" ${ ATTR ( d . name || '' ) } " placeholder="例如:wheelbase" style="width:100%"></td><td><input class="field-label" value=" ${ ATTR ( d . label || '' ) } " placeholder="字段名称" style="width:100%"></td><td><select class="field-type"> ${ [ 'text' , 'textarea' , 'number' , 'date' , 'url' , 'boolean' ]. map ( t => `<option value=" ${ t } " ${ d . type === t ? 'selected' : '' } > ${ t } </option>` ). join ( '' ) } </select></td><td><button class="btn light tiny" onclick="this.closest('tr').remove()">删除</button></td></tr>` },
addModelField (){ if ( ! $ ( '#fieldRows' )) return ; $ ( '#fieldRows' ). insertAdjacentHTML ( 'beforeend' , App . modelRow ())},
async saveModelFields (){ try { const fields = [... document . querySelectorAll ( '.field-row' )]. map ( tr =>({ name : tr . querySelector ( '.field-key' ). value . trim (), label : tr . querySelector ( '.field-label' ). value . trim (), type : tr . querySelector ( '.field-type' ). value })); let kind = $ ( '#modelKind' ). value ; let result = await api ( '/api/sites/' + state . siteId + '/schemas/' + kind , json ( 'PUT' , fields )); state . models [ kind ] = result . fields ; notice ( '已保存 ' + kind + ' 字段模型' )} catch ( e ){ onError ( e )}},
designView (){ const s = state . site ; return `<div class="panel"><div class="panel-title"><div><h3>Visual Studio · 页面可视化工作台</h3><p>拖动区块顺序、修改属性、自动绑定内容,并在同一个界面预览 PC 和移动端。</p></div></div><div class="cards"> ${ state . entries . filter ( e => e . type === 'page' || e . type === 'product' || e . type === 'post' ). slice ( 0 , 36 ). map ( e => `<div class="site-card" style="cursor:default;padding:20px"><div class="pill"> ${ E ( e . type ) } </div><h3> ${ E ( e . title ) } </h3><p style="color:#8999a9"> ${ E ( e . route ) } </p><a class="btn primary small" href="/editor/ ${ s . id } / ${ e . id } " target="_blank">打开可视化编辑器 ↗</a></div>` ). join ( '' ) } </div><p style="margin-top:22px;color:#7f90a2">页面编辑后点击构建,系统生成对应的独立静态页面,不会丢失原有 URL 和 SEO。 <button class="btn light small" style="margin-left:15px" onclick="App.scaffold()">一键补齐企业常用页面</button></p></div><div class="panel"><div class="panel-title"><div><h3>顶部导航配置</h3><p>每行:标题 | /原始路径/,不填写则按网站已有栏目自动生成导航。</p></div></div><form onsubmit="App.updateNavigation(event)"><textarea id="navRoutes" style="width:100%;min-height:115px;border:1px solid #dce3ed;padding:15px;border-radius:10px"> ${ E (( state . site . settings . nav || []). map ( n => n . title + ' | ' + n . route ). join ( '\n' )) } </textarea><div class="panel-footer"><button class="btn primary" type="submit">保存导航结构</button></div></form></div>` },
async applyTemplates ( mode ){ if ( ! confirm ( mode === 'overwrite' ? '将用当前主题的默认布局覆盖所有页面区块,内容和SEO不变,确定继续?' : '将给尚未设置布局的页面自动套用当前主题,确定继续?' )) return ; try { let r = await api ( '/api/sites/' + state . siteId + '/apply-theme' , json ( 'POST' ,{ mode })); await App . reload (); notice ( '已装配 ' + r . updated + ' 个页面,跳过 ' + r . skipped + ' 个' ); App . renderSite ()} catch ( e ){ onError ( e )}},
templateView (){ let s = state . site ; return `<div class="panel"><div class="panel-title"><div><h3>主题样式库</h3><p>切换后内容、原有 URL 和 SEO 数据不变,重新构建立即产生全新视觉版本</p></div></div><div class="template-grid"> ${ Object . keys ( cfg ). map ( t => themeCard ( t , `<button class="btn ${ s . theme === t ? 'light' : 'primary' } small" ${ s . theme === t ? 'disabled' : '' } onclick="App.setTheme(' ${ t } ')"> ${ s . theme === t ? '✓ 正在使用' : '使用主题' } </button>` )). join ( '' ) } </div></div><div class="panel"><div class="panel-title"><div><h3>模板工厂</h3><p>保存当前主题与首页区块配置,以后创建其他网站时可以直接套用。</p></div></div><div class="toolbar"><input id="presetName" placeholder="例如:HESU 汽车企业基础模板" style="max-width:400px"><button class="btn primary" onclick="App.savePreset()">保存为新模板</button><button class="btn light" onclick="App.cloneSite()">克隆整个站点</button></div><div class="toolbar" style="margin:16px 0"><button class="btn primary" onclick="App.applyTemplates('missing')">一键把已有内容装入新主题(保留已设计的页面)</button><button class="btn light" onclick="App.applyTemplates('overwrite')">全部应用当前主题预设布局</button></div><div class="tip">当前版本支持页面区块拖拽排序、PC / 手机预览、动态内容绑定。完全自由定位的像素级编辑仍不支持。</div></div>` },
importView (){ return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>批量导入文件</h3><p>支持 WordPress XML、CSV、Excel XLSX、JSON</p></div></div><form id="importForm" onsubmit="App.importFile(event)"><label class="field">选择内容数据文件<input type="file" name="file" required accept=".xml,.csv,.json,.xlsx"></label><label class="field" style="margin-top:18px">遇到相同 URL 时<select name="on_conflict"><option value="skip">跳过原有页面(安全默认)</option><option value="update">覆盖当前内容(请先备份)</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">开始批量导入</button></div></form><div class="tip" style="margin-top:18px">建议 CSV 字段:type, title, route, summary, body, cover, seo_title, seo_description。</div></div><div class="panel"><div class="panel-title"><div><h3>旧站公开页面采集</h3><p>支持 WordPress、普通 HTML 与其他 CMS 的公开内容</p></div></div><form id="crawlForm" onsubmit="App.crawl(event)"><label class="field">旧网站首页 URL<input name="url" type="url" required value=" ${ ATTR ( state . site . settings . origin || '' ) } " placeholder="https://oldsite.com/"></label><label class="field" style="margin-top:18px">最大抓取页面数<select name="max_pages"><option value="10">10 页(快速验证)</option><option value="20" selected>20 页</option><option value="50">50 页</option></select></label><label class="field" style="margin-top:18px">冲突策略<select name="on_conflict"><option value="skip">保留本地内容</option><option value="update">用采集内容更新</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">扫描并导入公开页面</button></div></form><div class="tip warning" style="margin-top:18px">公开爬取无法保证包含私有字段、插件数据或复杂动态页面。推荐尽量使用原站完整数据库/导出文件。</div></div></div><div class="panel"><div class="panel-title"><div><h3>素材中心</h3><p>上传图片,生成本地媒体路径,内容页可直接引用。</p></div><label class="btn light small">上传图片<input type="file" accept="image/*" hidden onchange="App.uploadMedia(this.files[0])"></label></div><div class="tip">上传文件 URL 将返回类似 media/abc123.jpg,静态站构建时自动携带。</div><div class="toolbar" style="margin-top:18px"><label class="btn light small">批量导入 WordPress 媒体 ZIP<input type="file" accept=".zip" hidden onchange="App.uploadMediaArchive(this.files[0])"></label></div><div id="mediaGrid" style="padding-top:12px"></div></div>` },
seoView (){ return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>SEO 保留策略</h3><p>不因改版随意改变页面 URL</p></div></div><div class="feature-option"><div class="feature-icon">↗</div><b>页面 URL Registry</b><p>所有现有 URL 由内容库的 route 精确定义;冲突直接禁止保存。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">◇</div><b>SEO Metadata</b><p>编辑内容可以设置 Title、Description、Canonical 和 Robots。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">▤</div><b>Sitemap + 服务器规则</b><p>构建时生成 sitemap.xml、robots.txt、Nginx snippet 和 .htaccess。</p></div></div><div class="panel"><div class="panel-title"><div><h3>301 / 302 重定向管理</h3><p>原 URL 不能保留时,人工审批后配置对应跳转。</p></div></div><form id="redirectForm" onsubmit="App.createRedirect(event)"><label class="field">原 URL Path<input name="old_path" required placeholder="/old-product/"/></label><label class="field" style="margin-top:14px">目标 URL Path<input name="new_path" required placeholder="/new-product/"/></label><label class="field" style="margin-top:14px">HTTP 状态码<select name="status_code"><option value="301">301 永久</option><option value="302">302 临时</option><option value="307">307 临时保留方法</option><option value="308">308 永久保留方法</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">添加重定向</button></div></form><div id="redirectList"></div></div></div>` },
buildView (){ return `<div class="panel"><div class="panel-title"><div><h3>静态构建中心</h3><p>将当前内容、模板和 SEO 路由组合成完全独立的 HTML 文件。</p></div></div><div class="split"><div><div class="feature-option"><div class="feature-icon">▦</div><b>静态 HTML / CSS / JS</b><p>无需 PHP、WordPress 或中心 CMS,就能独立部署访问。</p></div></div><div><div class="feature-option"><div class="feature-icon">▰</div><b>可回溯构建版本</b><p>每次构建产生独立 ZIP 和版本报告,不会覆盖旧版本。</p></div></div></div><div class="toolbar" style="margin-top:22px"><label style="display:flex;align-items:center;gap:9px"><input id="mirrorChoice" type="checkbox" style="width:18px;height:18px">打包外部图片(较慢,外部资源可能下载失败)</label></div><div class="panel-footer"><button class="btn primary" onclick="App.build()">↗ 生成新的静态网站版本</button></div></div><div class="panel"><div class="panel-title"><div><h3>历史构建</h3><p>选择版本查看预览、下载 ZIP、检查 SEO/媒体报告</p></div></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>版本 ID</th><th>构建时间</th><th>页面数量</th><th>报告</th><th>操作</th></tr></thead><tbody> ${ state . builds . map ( x =>{ let r = JSON . parse ( x . report_json || '{}' ); return `<tr><td class="mono"> ${ x . id } </td><td> ${ simpleDate ( x . created_at ) } </td><td> ${ x . total_pages } </td><td><span class="pill ${ r . quality_status === 'warning' ? 'yellow' : 'green' } "> ${ r . quality_status === 'warning' ? '有告警' : '已通过基础检查' } </span></td><td><div class="row-actions"><a class="btn light tiny" target="_blank" href="/preview/ ${ state . siteId } / ${ x . id } /">预览 ↗</a><a class="btn light tiny" href="/api/sites/ ${ state . siteId } /builds/ ${ x . id } /download">ZIP ↓</a><button class="btn light tiny" onclick="App.showReport(' ${ x . id } ')">报告</button><button class="btn light tiny" onclick="App.publishLocal(' ${ x . id } ')" title="发布到 Docker 持久化发布目录">发布</button></div></td></tr>` } ).join('')}</tbody></table> ${ state . builds . length ? '' : '<div class="empty"><div>⌘</div><h3>尚无构建记录</h3><p>完成内容编辑后,点击上方按钮生成版本。</p></div>' } </div><div id="reportBox"></div></div>` },
async openTemplates (){ state . siteId = null ; topTitle ( '模板资源库' ); App . renderSiteNav (); document . querySelectorAll ( '.side-link' ). forEach ( x => x . classList . remove ( 'selected' )); $ ( '#nav-template' ). classList . add ( 'selected' ); $ ( '#view' ). innerHTML = hero ( '模板与主题资源库' , '从四套行业视觉风格开始,逐步沉淀企业自己的模板库。' , `<button class="btn primary" onclick="App.openNewSite()">使用模板建站 →</button>` ) + `<div class="template-grid"> ${ Object . keys ( cfg ). map ( t => themeCard ( t , `<button class="btn primary small" onclick="App.openNewSite(' ${ t } ')">基于模板创建</button>` )). join ( '' ) } </div><div class="panel" style="margin-top:20px"><div class="panel-title"><div><h3>已保存的模板预设</h3><p>由已有网站保存,复用主题及首页区块结构。</p></div></div> ${ state . presets . length ? state . presets . map ( p => `<div class="feature-option" style="margin-bottom:9px"><b> ${ E ( p . name ) } </b><p> ${ E ( cfg [ p . theme ] ? . label || p . theme ) } · ${ simpleDate ( p . created_at ) } </p><button class="btn light tiny" style="margin-top:11px" onclick="App.openNewSite(' ${ p . theme } ',' ${ p . id } ')">使用此模板建站</button></div>` ). join ( '' ) : '<div class="empty"><h3>暂未保存企业自定义模板</h3><p>从任意网站的「模板与设计」中保存为模板。</p></div>' } </div>` },
openNewSite ( theme = '' , preset = '' ){ const f = $ ( '#siteForm' ); f . reset (); if ( theme ) f . elements . theme . value = theme ; $ ( '#presetSelector' ). innerHTML = '<option value="">不使用自定义模板</option>' + state . presets . map ( p => `<option value=" ${ p . id } "> ${ E ( p . name ) } · ${ E ( p . theme ) } </option>` ). join ( '' ); if ( preset ) f . elements . preset_id . value = preset ; $ ( '#siteDialog' ). showModal ()},
closeDialog ( id ){ $ ( '#' + id ). close ()},
async createSite ( ev ){ ev . preventDefault (); try { let values = Object . fromEntries ( new FormData ( ev . target )); let site = await api ( '/api/sites' , json ( 'POST' , values )); App . closeDialog ( 'siteDialog' ); notice ( '网站已创建,可以开始添加内容' ); await App . openSite ( site . id )} catch ( e ){ onError ( e )}},
openEntry ( id = '' ){ let f = $ ( '#entryForm' ); f . reset (); let e = state . entries . find ( x => x . id === id ); f . elements . entry_id . value = id ; $ ( '#entryHeading' ). textContent = id ? '编辑内容' : '新建内容' ; if ( e ){ for ( let key of [ 'title' , 'route' , 'summary' , 'body' , 'cover' , 'seo_title' , 'seo_description' , 'canonical' , 'type' , 'status' , 'robots' ]) if ( f . elements [ key ]) f . elements [ key ]. value = e [ key ] ?? '' ; f . elements . meta . value = JSON . stringify ( e . meta || {}, null , 2 )} else { f . elements . meta . value = '{}' ; f . elements . type . value = 'page' ; f . elements . status . value = 'published' } $ ( '#entryDialog' ). showModal (); App . loadEntryFields ()},
async loadEntryFields (){ try { state . models = await api ( '/api/sites/' + state . siteId + '/schemas' ); App . renderEntryFields ()} catch ( e ){ onError ( e )}},
renderEntryFields (){ let el = $ ( '#dynamicContentFields' ); if ( ! el ) return ; let kind = $ ( '#entryType' ). value ; let list = ( state . models || {})[ kind ] || []; let parsed = {}; try { parsed = JSON . parse ( $ ( '#entryMeta' ). value || '{}' )} catch {}; const values = parsed . fields || parsed ; el . innerHTML = list . length ? `<div class="panel-title"><div><h3>业务字段 · ${ E ( kind ) } </h3><p>来自当前网站的字段模型,保存时同步写入内容数据。</p></div></div><div class="setting-grid"> ${ list . map ( f => `<label> ${ E ( f . label || f . name ) } <input class="sf-data-field" data-key=" ${ ATTR ( f . name ) } " type=" ${ [ 'number' , 'date' , 'url' ]. includes ( f . type ) ? f . type : 'text' } " value=" ${ ATTR ( values [ f . name ] || '' ) } "></label>` ). join ( '' ) } </div>` : '' },
addBlock ( type ){ const el = $ ( '#entryMeta' ); let parsed = {}; try { parsed = JSON . parse ( el . value || '{}' )} catch { return notice ( '当前 JSON 格式不合法,先修复后再添加区块' , true )} if ( ! Array . isArray ( parsed . blocks )) parsed . blocks = []; let obj = ({ hero : { type : 'hero' , title : '核心主标题' , subtitle : 'OUR VISION' , description : '可编辑的首页主视觉描述' }, features : { type : 'features' , title : '核心优势' , items : [{ title : '专业实力' , text : '填写优势简介' },{ title : '可靠交付' , text : '填写优势简介' },{ title : '服务网络' , text : '填写优势简介' }]}, stats : { type : 'stats' , items : [{ value : '30+' , label : '合作品牌' },{ value : '70+' , label : '服务市场' }]}, cta : { type : 'cta' , title : '期待与您合作' , subtitle : '联系我们,了解更多服务' , button : '联系我们' , href : '/contact/' }})[ type ]; parsed . blocks . push ( obj ); el . value = JSON . stringify ( parsed , null , 2 ); notice ( '已添加区块,请编辑 JSON 中的标题与内容' )},
async saveEntry ( ev ){ ev . preventDefault (); try { let v = Object . fromEntries ( new FormData ( ev . target )), id = v . entry_id ; delete v . entry_id ; v . meta = JSON . parse ( v . meta || '{}' ); const fieldInputs = [... document . querySelectorAll ( '#dynamicContentFields .sf-data-field' )]; if ( fieldInputs . length ){ let fields = {...( v . meta . fields || {})}; for ( const input of fieldInputs ) fields [ input . dataset . key ] = input . value ; v . meta . fields = fields } if ( ! id &&! v . route ) delete v . route ; let endpoint = '/api/sites/' + state . siteId + '/entries' + ( id ? '/' + id : '' ); await api ( endpoint , json ( id ? 'PATCH' : 'POST' , v )); App . closeDialog ( 'entryDialog' ); await App . openSite ( state . siteId , 'entries' ); notice ( id ? '已更新内容' : '已创建内容' )} catch ( e ){ onError ( e )}},
async deleteEntry ( id ){ if ( ! confirm ( '确定删除此内容吗?删除之后重新构建才会影响线上静态网站。' )) return ; try { await api ( '/api/sites/' + state . siteId + '/entries/' + id ,{ method : 'DELETE' }); await App . openSite ( state . siteId , 'entries' ); notice ( '内容已删除' )} catch ( e ){ onError ( e )}},
async setTheme ( theme ){ if ( ! confirm ( '切换到「' + cfg [ theme ]. label + '」?内容和 URL 保持不变,需重新构建才能更新站点。' )) return ; try { await api ( '/api/sites/' + state . siteId , json ( 'PATCH' ,{ theme })); await App . openSite ( state . siteId , 'templates' ); notice ( '主题已更换,请重新构建预览' )} catch ( e ){ onError ( e )}},
async savePreset (){ let name = $ ( '#presetName' ) ? . value ? . trim (); if ( ! name ) return notice ( '请输入新模板名称' , true ); try { let result = await api ( '/api/sites/' + state . siteId + '/save-preset' , json ( 'POST' ,{ name })); state . presets = await api ( '/api/presets' ); notice ( '已保存模板:' + result . name )} catch ( e ){ onError ( e )}},
async cloneSite (){ let name = prompt ( '新站名称:' , state . site . name + ' 副本' ); if ( ! name ) return ; try { let newsite = await api ( '/api/sites/' + state . siteId + '/clone' , json ( 'POST' ,{ name })); notice ( '已克隆全部内容到新网站' ); await App . openSite ( newsite . id )} catch ( e ){ onError ( e )}},
async importFile ( ev ){ ev . preventDefault (); let btn = $ ( 'button[type=submit]' , ev . target ); btn . disabled = true ; btn . innerText = '导入中...' ; try { let form = new FormData ( ev . target ); let result = await api ( '/api/sites/' + state . siteId + '/import' ,{ method : 'POST' , body : form }); await App . openSite ( state . siteId , 'import' ); notice ( `导入完成:新增 ${ result . imported } ,更新 ${ result . updated } ,跳过 ${ result . skipped } ,错误 ${ result . errors ? . length || 0 } ` ); if ( result . errors ? . length ) alert ( JSON . stringify ( result . errors . slice ( 0 , 15 ), null , 2 ))} catch ( e ){ onError ( e )} finally { btn . disabled = false ; btn . innerText = '开始批量导入' }},
async crawl ( ev ){ ev . preventDefault (); if ( ! confirm ( '确认对该公开网站进行限定范围采集?请仅采集你拥有或获授权迁移的站点。' )) return ; let btn = $ ( 'button[type=submit]' , ev . target ); btn . disabled = true ; btn . innerText = '正在采集...' ; try { const input = Object . fromEntries ( new FormData ( ev . target )); input . max_pages =+ input . max_pages ; const r = await api ( '/api/sites/' + state . siteId + '/crawl' , json ( 'POST' , input )); await App . openSite ( state . siteId , 'import' ); notice ( `采集完成:发现 ${ r . discovered } 页,新增 ${ r . imported } 页,页面获取失败 ${ r . fetch_errors ? . length || 0 } ` )} catch ( e ){ onError ( e )} finally { btn . disabled = false ; btn . innerText = '扫描并导入公开页面' }},
async uploadMediaArchive ( file ){ if ( ! file ) return ; try { let form = new FormData (); form . append ( 'file' , file ); notice ( '正在解压媒体文件...' ); let r = await api ( '/api/sites/' + state . siteId + '/media-archive' ,{ method : 'POST' , body : form }); notice ( `媒体导入: ${ r . imported } 文件,跳过 ${ r . skipped } 文件` )} catch ( e ){ onError ( e )}},
async uploadMedia ( file ){ if ( ! file ) return ; try { let fd = new FormData (); fd . append ( 'file' , file ); let r = await api ( '/api/sites/' + state . siteId + '/media' ,{ method : 'POST' , body : fd }); notice ( '图片已上传:' + r . url ); App . loadMedia ()} catch ( e ){ onError ( e )}},
async uploadCover ( file ){ if ( ! file ) return ; try { let fd = new FormData (); fd . append ( 'file' , file ); let r = await api ( '/api/sites/' + state . siteId + '/media' ,{ method : 'POST' , body : fd }); $ ( '#coverInput' ). value = r . url ; notice ( '封面上传成功' )} catch ( e ){ onError ( e )}},
async loadMedia (){ if ( state . tab !== 'import' ) return ; try { let data = await api ( '/api/sites/' + state . siteId + '/media' ); $ ( '#mediaGrid' ). innerHTML = data . map ( x => `<div style="display:inline-flex;flex-direction:column;gap:4px;border:1px solid #ddd;padding:9px;margin:5px"><img style="width:95px;height:70px;object-fit:contain" src="/local-media/ ${ state . siteId } / ${ E ( x . name ) } "><button class="btn light tiny" onclick="navigator.clipboard.writeText(' ${ x . url } ');notice('已复制图片地址')">复制路径</button></div>` ). join ( '' ) || '<p class="muted">暂无本地素材</p>' } catch ( e ){ onError ( e )}},
async createRedirect ( ev ){ ev . preventDefault (); let value = Object . fromEntries ( new FormData ( ev . target )); value . status_code =+ value . status_code ; try { await api ( '/api/sites/' + state . siteId + '/redirects' , json ( 'POST' , value )); ev . target . reset (); notice ( '已创建重定向规则' ); App . loadRedirects ()} catch ( e ){ onError ( e )}},
async loadRedirects (){ if ( state . tab !== 'seo' ) return ; try { let data = await api ( '/api/sites/' + state . siteId + '/redirects' ); $ ( '#redirectList' ). innerHTML = data . length ? '<h4 style="margin-top:24px">已有重定向</h4>' + data . map ( x => `<p class="mono" style="display:flex;justify-content:space-between;gap:12px"> ${ E ( x . old_path ) } ${ x . status_code } → ${ E ( x . new_path ) } <button class="btn light tiny" onclick="App.deleteRedirect(' ${ x . id } ')">删除</button></p>` ). join ( '' ) : '<p style="margin-top:18px;color:#9aa5b2;font-size:12px">当前没有重定向规则。</p>' } catch ( e ){ onError ( e )}},
async deleteRedirect ( id ){ if ( ! confirm ( '确认删除这条规则?' )) return ; try { await api ( '/api/sites/' + state . siteId + '/redirects/' + id ,{ method : 'DELETE' }); App . loadRedirects ()} catch ( e ){ onError ( e )}},
async publishLocal ( id ){ if ( ! confirm ( '将此版本发布到 Docker 持久化目录?请确认配置好独立站点服务器。' )) return ; try { const x = await api ( '/api/sites/' + state . siteId + '/builds/' + id + '/publish-local' ,{ method : 'POST' }); notice ( '已发布:' + x . release + '(发布文件位于 Docker 数据卷)' )} catch ( e ){ onError ( e )}},
async build (){ let s = state . siteId ; if ( ! s ) return ; let mirror = $ ( '#mirrorChoice' ) ? . checked || false ;
try { let job = await api ( '/api/sites/' + s + '/builds' ,{... json ( 'POST' ,{ mirror_images : mirror }), headers : { 'Content-Type' : 'application/json' , 'Idempotency-Key' : crypto . randomUUID ()}});
notice ( '已进入后台构建队列:' + job . id + ',关闭浏览器也可继续执行' );
for ( let i = 0 ; i < 240 ; i ++ ){ await new Promise ( resolve => setTimeout ( resolve , 1500 ));
let status = await api ( '/api/enterprise/sites/' + s + '/jobs/' + job . id );
if ( status . state === 'done' ){ let result = JSON . parse ( status . result_json || '{}' ); await App . openSite ( s , 'builds' ); notice ( '构建完成:' + ( result . pages || 0 ) + ' 个页面' ); if ( result . version ) App . showReport ( result . version ); return }
if ( status . state === 'failed' || status . state === 'cancelled' ){ notice ( '任务' + status . state + ': ' + ( status . error || '' ), true ); return }
} notice ( '构建仍在后台运行,请在任务中心查看状态' );
} catch ( e ){ onError ( e )}},
async showReport ( id ){ try { const r = await api ( '/api/sites/' + state . siteId + '/builds/' + id + '/report' ); let box = $ ( '#reportBox' ); if ( ! box ){ alert ( JSON . stringify ( r , null , 2 )); return } box . innerHTML = `<div class="panel" style="margin-top:20px;background:#fafdff"><div class="panel-title"><div><h3>质量报告: ${ E ( id ) } </h3><p>本报告只覆盖当前实现的基础结构与资源检查,不代表 SEO 排名保证。</p></div></div><div class="metrics-grid"><div class="metric"><small>静态 HTML 数</small><strong> ${ r . pages } </strong></div><div class="metric"><small>重定向数</small><strong> ${ r . redirects } </strong></div><div class="metric"><small>已镜像图片</small><strong> ${ r . mirrored_images } </strong></div><div class="metric"><small>质量告警</small><strong> ${ r . warnings . length } </strong></div></div> ${ r . warnings . length ? `<div class="tip warning"> ${ r . warnings . map ( x => `• ${ E ( x ) } ` ). join ( '<br>' ) } </div>` : '<div class="tip">✓ 当前规则下未发现告警</div>' } <h4>详细报告</h4><pre class="pre"> ${ E ( JSON . stringify ( r , null , 2 )) } </pre></div>` ; box . scrollIntoView ({ behavior : 'smooth' , block : 'nearest' })} catch ( e ){ onError ( e )}},
async seedDemo (){ try { const s = await api ( '/api/sites' , json ( 'POST' ,{ name : 'HESU Global Motors · 演示站' , tagline : '全球汽车采购、出口与交付服务' , theme : 'automotive' , domain : 'https://demo.example.com' , mode : 'new' })); let base = '/api/sites/' + s . id + '/entries' ; for ( let v of [
{ type : 'page' , title : '关于我们' , route : '/about/' , summary : '专注全球汽车出口与本地交付' , body : '<p>这里是示例公司介绍。请在内容管理中替换为你的真实资料。</p>' , seo_title : 'About us' },
{ type : 'product' , title : 'ROX 01' , route : '/product/rox-01/' , summary : '豪华全地形 SUV · 示例数据,可在后台编辑' , body : '<h2>产品概览</h2><p>此条目为演示内容,不代表实际库存或车型参数。</p>' },
{ type : 'product' , title : 'BYD ATTO 3' , route : '/product/byd-atto-3/' , summary : '纯电 SUV · 示例车型信息' , body : '<p>可以从 WordPress、Excel 或 JSON 重新导入真正的车型数据。</p>' },
{ type : 'post' , title : '网站动态示例' , route : '/news/first-news/' , summary : '一个独立的新网站如何快速发布' , body : '<p>编辑内容、切换主题、点击构建,即可导出静态网站。</p>' }
]) await api ( base , json ( 'POST' , v )); await App . openSite ( s . id ); await App . build (); notice ( '演示网站已创建并成功构建,可直接预览或下载 ZIP' )} catch ( e ){ onError ( e )}},
};
window . App = App ;
$ ( '#siteForm' ). addEventListener ( 'submit' , App . createSite );
$ ( '#entryForm' ). addEventListener ( 'submit' , App . saveEntry );
window . addEventListener ( 'DOMContentLoaded' ,()=> App . init ());