'use strict'; /* SiteForge simplified workspace: native APIs, no parallel business backend. */ const $=(s,root=document)=>root.querySelector(s); const esc=x=>String(x??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); const themeName={automotive:'汽车与出行',industrial:'工业与制造',corporate:'企业品牌',minimal:'极简展示','trade-pro':'Trade Pro'}; const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',brand:'品牌',case:'案例',service:'服务'}; const fmtDate=t=>{if(!t)return '—';const d=new Date(t);return Number.isNaN(d.getTime())?String(t).slice(0,16):d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})}; const sfSiteKey='siteforge:selected-site'; const sfRemember=id=>{try{if(id)localStorage.setItem(sfSiteKey,id)}catch{}}; 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)} const res=await fetch(url,{method,headers,body,credentials:'same-origin'}); if(res.status===401){location.href='/login';throw Error('登录已过期,请重新登录')} let value;try{value=await res.json()}catch{throw Error('服务器响应异常(HTTP '+res.status+')')} if(!res.ok){const ex=Error(typeof value.detail==='string'?value.detail:JSON.stringify(value.detail||value));ex.status=res.status;throw ex}return value; } function toast(msg,bad=false){const el=$('#toast');el.textContent=msg;el.classList.toggle('bad',bad);el.classList.add('show');clearTimeout(el.timer);el.timer=setTimeout(()=>el.classList.remove('show'),4000)} function notifyError(error){console.error(error);toast(error?.message||String(error),true)} function htmlHeading(title,desc,actions=''){return `

SITEFORGE / ${esc(headings[state.view]||'WORKSPACE')}

${esc(title)}

${esc(desc)}

${actions}
`} function actionButton(text,action=''){return ``} function stat(title,num,icon){return `
${icon}${title}${esc(num)}
`} function emptyState(title,description,button=''){return `
◇

${esc(title)}

${esc(description)}

${button}
`} function nav(button){return `data-action="navigate" data-view="${button}"`} // Business navigation is a shareable, browser-history-aware URL; selection is still authorized by /api/sites. const siteUrl=(view,site)=>{const q=new URLSearchParams();if(site)q.set('site',site);if(view&&(view!=='home'||site))q.set('view',view);return '/'+(q.size?'?'+q.toString():'')}; function rememberView(replace=false){const url=siteUrl(state.view,state.site?.id);history[replace?'replaceState':'pushState']({site:state.site?.id||null,view:state.view},'',url)} function activeJobs(){return state.jobs.filter(j=>['queued','running','retrying'].includes(j.state))} function buildStatusLabel(stateCode){return ({queued:'等待 Worker',running:'正在生成',retrying:'重试中',done:'已完成',failed:'失败',cancelled:'已取消'})[stateCode]||stateCode||'未知'} function buildReadyStep(){return state.entries.some(e=>e.status!=='draft'&&e.type!=='page')||state.entries.some(e=>e.status!=='draft'&&e.type==='page')} function siteCard(s){return ``} function overviewShortcut(view,icon,title,desc){return ``} function home(){const sites=state.sites;const total=sites.reduce((n,s)=>n+(+s.entries_count||0),0);const builds=sites.reduce((n,s)=>n+(+s.build_count||0),0); return htmlHeading('欢迎回来','管理和制作企业网站,从这里开始。',actionButton('+ 创建新网站','new-site'))+ `
${stat('网站项目',sites.length,'▤')}${stat('内容总数',total,'▧')}${stat('构建版本',builds,'↗')}${stat('可用主题',4,'◈')}
`+ `

您想先做什么?

按使用目的进入对应页面,不需要了解技术术语。

`+ overviewShortcut('sites','+','创建或管理网站','从零搭建网站,或者接着制作已有网站。')+ overviewShortcut('import','⇩','迁移旧站 / 导入数据','导入 WordPress 或 Excel 产品资料。')+ overviewShortcut('design','◈','更换网站设计','选择行业主题,编辑网页与全站外观。')+ `

最近的网站

点击网站继续制作。

`+ (sites.length?`
${sites.slice(0,4).map(siteCard).join('')}
`:emptyState('还没有网站项目','创建一个网站,选择主题后即可开始添加页面、产品和新闻。',actionButton('+ 创建第一个网站','new-site'))); } function sites(){return htmlHeading('我的网站','统一管理所有网站项目,每个网站的数据、主题和构建记录彼此独立。',actionButton('+ 创建网站','new-site'))+ (state.sites.length?`
${state.sites.map(siteCard).join('')}
`:emptyState('暂时没有网站','选择行业主题,开始制作您的第一个网站。',actionButton('创建网站','new-site'))); } 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 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,'所有网站操作都从这里开始;保存内容、生成网站和正式上线是不同的步骤。',``)+ `
建议下一步

${esc(next.label)}

${esc(next.hint)}

`+ `
${stat('人工添加的内容',contentCount,'▧')}${stat('静态构建版本',state.builds.length,'↗')}${stat('当前主题',themeName[s.theme]||'企业品牌','◈')}${stat('正式域名',s.domain?'已填写':'未设置','◎')}
`+ `

最常用的三项操作

不需要了解技术细节,选择你想完成的事情即可。

`+ `
${overviewShortcut('content','▧','修改文字和产品','填写网站内容,修改已经创建的页面。')}${overviewShortcut('design','◈','调整网页外观','编辑图片、版式、导航和手机端。')}${overviewShortcut('launch','↗','检查并上线网站','一步一步完成预览、审批和正式部署。')}
`+ `
关于上线:页面编辑和保存不会自动修改线上网站。上线仍需生成静态版本、检查、独立审批和部署。
`; } 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 `
  • ${esc(step.label)}${esc(label)}

    ${esc(step.hint)}

  • `; }).join(''); return htmlHeading('建站操作指引','按照实际操作顺序完成网站制作,系统只根据已保存的数据与任务记录显示进度。')+ `
    当前项目

    ${esc(state.site.name)}

    ${esc(journey.next.hint)}

    ${checked}/4已记录的必要步骤
      ${rows}

    保存、构建和上线有什么不同?

    保存内容把修改写入管理系统,外部访客还看不到新版。

    生成网站制作一份可预览、可下载的静态网站版本。

    检查与审批核对页面、图片、网址和 SEO,并取得相应审批。

    正式上线把批准的版本部署到正式服务器,再核对公网访问。

    重要说明:${journey.live?'系统发现正式发布记录,但无法仅凭该记录确认当前公网网站可访问,请人工检查实际域名。':'当前没有可确认的正式发布记录;生成 ZIP 或点击“发布设计”都不代表网站已上线。'}${journey.changedAfterBuild?' 网站内容在最近一次构建后可能有变化,建议重新构建并预览。':''}${!journey.domain?' 正式域名尚未填写,不影响预览。':''}
    `; } // 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='')=>`
    ${index}

    ${esc(name)}

    ${esc(body)}

    ${controls}
    `; const buildOptions=buildList.map(b=>``).join(''); const select=``; const checked=Boolean(state.launchReview&&selected); const canRequestApproval=Boolean(selected&&checked&&state.releaseRequestsKnown&&['none','rejected'].includes(result?.approval)); const preview=selected?`打开网站预览 ↗`:``; 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('上线操作中心','按顺序完成检查和审核,每一步都解释操作用途。网站不会因为保存、预览或提交审批而自动上线。',``)+ `
    当前网站

    ${esc(state.site.name)}

    上线属于独立审批和服务器部署流程;如有不确定之处,可以随时离开,已经保存的记录不会消失。

    ${result?.approval==='approved'?'已获审批':result?.approval==='pending'?'等待审批':selected?'准备上线':'请先构建'}
    `+ row('1','选择网站版本','选择已生成成功、准备交付的静态版本。内容有变更时需要重新构建。',select+``,selected?'is-ready':'')+ row('2','预览网站并检查质量','在新窗口检查页面、图片、移动端、链接和 SEO。质量报告是自动检查,仍需要人工确认。',preview+``,checked?'is-ready':'')+ row('3','提交给其他人审批',approvalText,`查看审批中心 ↗`,result?.approval==='approved'?'is-ready':'')+ row('4','检查正式发布条件','审批通过后,再检查构建质量、旧站迁移复核和发布归档是否满足条件。',`${esc(result?.preflightMessage||'尚未完成此版本的发布前检查')}`,result?.preflightReady?'is-ready':'')+ row('5','提交服务器部署','只有目标环境已配置、审批通过且发布前检查成功,才能从这里提交部署任务。',`${state.delivery.unavailable?'无法读取发布目标状态,请刷新或联系管理员。':config?'目标服务器已配置':'当前环境没有配置可用的发布目标,请联系管理员。'}`,result?.hasRemoteRecord?'is-ready':'')+ row('6','核对网站是否真正上线','提交部署只是创建任务。请确认任务成功,再在真实域名检查页面和联系表单。',`${domainUrl?`打开正式域名 ↗`:''}

    ${esc(result?.remoteDescription||'未确认有当前环境的部署记录。')} 系统不会仅凭发布记录声称公网验收通过。

    `)+ `
    注意:这里的确认只影响当前操作界面,正式发布仍由服务器重新验证权限、审批、质量、文件和目标配置。页面预览正常也不代表公网已上线。
    ${esc(state.launchMessage||'')}
    `; } 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'))+ `

    内容列表

    共 ${state.entries.length} 条内容。点击“设计页面”可进行可视化排版。

    `+ (arr.length?`
    ${arr.slice(0,200).map(e=>``).join('')}
    名称内容类型页面地址状态操作
    ${esc(e.title)}${esc(typeName[e.type]||e.type)}${esc(e.route)}${e.status==='draft'?'草稿':'已保存'} 设计页面 ↗
    `:emptyState('没有找到内容','可以清除筛选条件,或者添加新内容。',actionButton('+ 添加内容','new-entry')))+ `${arr.length>200?'

    当前显示前 200 条,请使用搜索缩小范围。

    ':''}
    `; } function design(){if(!currentSite())return noSite();const s=state.site;const cards=Object.entries(themeName).map(([id,name])=>`
    ${name}

    ${name} ${s.theme===id?'当前使用':''}

    ${({automotive:'适合汽车出口、车型及品牌展示',industrial:'适合制造业、产品与技术参数',corporate:'适合集团公司、服务和案例',minimal:'适合作品展示和品牌形象','trade-pro':'适合外贸 B2B 英文站点与询盘'})[id]}

    预览页面效果
    `).join(''); return htmlHeading('外观与设计','先选一套网站风格,再打开可视化编辑器修改图文、导航和手机端布局。')+ `

    第一步 · 选择行业主题

    更换主题不会删除您已经录入的业务内容和 URL。

    查看完整主题预览 ↗
    ${cards}
    `+ `

    第二步 · 设计网页

    挑选要修改的页面;可在设计器中编辑导航、页眉、页脚及各设备布局。

    选择要设计的页面

    修改先保存为草稿,正式发布设计后才会参与下一次静态构建。

    ${state.entries.filter(e=>e.type==='page').slice(0,8).map(e=>`▤

    ${esc(e.title)}

    ${esc(e.route)}

    ›
    `).join('')||'
    尚无页面,请先添加内容。
    '}
    提示:网站设置用于公司信息和域名;可视化编辑器用于调整页面外观。两者不会混在同一表单里。
    `; } function imports(){if(!currentSite())return noSite();return htmlHeading('导入与迁移','根据您已有的资料选择操作方式:Excel 产品、WordPress 网站,或者外部 API / 数据库。')+ `
    ▤

    导入 Excel / CSV

    推荐先预览字段、检查冲突,再确认提交。

    安全导入与批次回滚 →
    ⇄

    迁移 WordPress 旧站

    迁移会话、文章采集、图片与 SEO 检查。

    打开迁移工作台 ↗
    ⌘

    API / XML / MySQL

    连接外部业务系统,进行字段映射和分批导入。

    进入高级导入 ↗
    `+ `

    推荐:先预演再导入

    在数据导入工作台检查字段匹配、页面路径冲突及品牌分类关系,再分批提交。

    打开安全导入工作台 →
    高级:直接写入网站(不可批次回滚)

    仅用于确认内容无误的小文件,建议先备份;复杂迁移请使用上方工作台。

    支持 CSV、Excel、JSON、WordPress XML;单文件最多 15 MB。

    `+ `
    迁移旧网站请注意:如果需要保留旧 URL、原有 SEO、复杂插件字段和全部图片,请优先使用 WordPress 迁移工作台,而不是仅上传一个内容文件。
    `; } function builds(){if(!currentSite())return noSite();const latest=latestDone();const pending=activeJobs();const first=pending[0]; return htmlHeading('生成与预览','先生成网站版本,确认效果后再使用上线操作中心提交审批。任务会在后台执行。',``)+ `
    ${first?'当前:'+buildStatusLabel(first.state):latest?'已有构建版本,可以预览':'还没有已完成的网站构建'}

    ${first?'当前任务 '+esc(first.id.slice(0,12))+';Worker 在后台执行,您可以关闭此页面。':'内容或设计有更新时,重新生成网站版本。'}

    任务与发布审批 ↗
    `+ `

    后台任务

    可以查看任务状态。仅取消当前仍在队列或运行中的构建。

    ${esc(state.job||'')}
    `+ (state.jobs.length?state.jobs.slice(0,8).map(j=>`
    ${j.state==='done'?'✓':j.state==='failed'?'!':'◷'}

    ${esc(buildStatusLabel(j.state))}

    ${esc(fmtDate(j.created_at))} · 任务 ${esc(j.id.slice(0,12))}${j.state==='failed'&&j.error?' · '+esc(j.error):''}

    ${['queued','running','retrying'].includes(j.state)?``:''}
    `).join(''):'

    当前网站还没有后台构建任务。

    ')+`
    `+ `

    构建版本

    建议先预览和核对质量报告,再下载 ZIP。预览不代表正式发布。

    `+ (state.builds.length?state.builds.slice(0,20).map(b=>`
    ${['done','completed','success','complete'].includes(b.status)?'✓':'◷'}

    ${esc(fmtDate(b.created_at))} · ${esc(b.total_pages||'—')} 个页面

    版本 ${esc(b.id.slice(0,12))} · ${esc(b.status||'未知')}

    ${['done','completed','success','complete'].includes(b.status)?`预览 ↗下载 ZIP`:'尚未完成,暂不能下载'}
    `).join(''):emptyState('尚无完成版本','准备好网站数据后,在上方提交构建任务。'))+ `
    `+ `
    下一步:预览检查后提交审批

    完成构建后,请在上线操作中心按照步骤检查质量、提交审批并部署。

    高级:直接进行发布和历史版本回滚

    正式发布与回滚

    先完成质量检查与独立审批,Worker 负责上传、校验和切换版本。没有配置目标服务器时不能发布。

    审批与复核工具 ↗

    最近发布任务

    ${(state.delivery.jobs||[]).length?state.delivery.jobs.slice(0,8).map(j=>`
    ${j.kind==='remote_rollback'?'回滚':'部署'} · ${esc(buildStatusLabel(j.state))}

    ${esc(j.id.slice(0,12))} · ${esc(j.created_at)}${j.error?' · '+esc(j.error):''}

    ${['queued','running','retrying'].includes(j.state)?``:''}
    `).join(''):'

    暂无发布任务。

    '}

    已发布版本

    ${(state.delivery.history||[]).length?state.delivery.history.slice(0,8).map(r=>`
    ${esc(r.target)} · ${esc(r.release_id)}

    构建 ${esc(r.build_id.slice(0,12))} · ${esc(r.deployed_at)}

    `).join(''):'

    当前没有远程部署记录。

    '}
    `; } function settings(){if(!currentSite())return noSite();const s=state.site;return htmlHeading('网站设置','这里只管理公司资料、域名与站点属性。网页样式请到“外观与设计”修改。')+ `

    基本资料

    没有域名也可以制作与预览网站,正式上线前再填写即可。

    `; } function languages(){ if(!currentSite())return noSite(); const d=state.language||{group:null,sites:[],units:[]};const g=d.group; const groups=state.languageGroups||[]; const header=htmlHeading('多语言管理','多个语言版本分别拥有独立的网站、域名和内容。系统只关联已审核的翻译,不会自动翻译企业事实。',''); if(!g)return header+`

    第一步 · 建立语言组

    例如“集团全球网站”包含中文版、英文版、法文版。创建语言组后将网站加入对应语言。

    `+ (groups.length?`

    加入已有语言组

    仅可关联您有管理权限、且已配置独立 HTTPS 域名的网站。

    `:'')+ `
    上线前提示:网站设置中的正式域名应为 https://example.com 形式;不同语言网站不能共用一个域名。当前域名:${esc(state.site.domain||'未设置')}
    `; const groupSites=d.sites||[]; const uniqueUnits=[...new Map((d.units||[]).map(u=>[u.id,u])).values()]; const canJoin=state.sites.filter(x=>!groupSites.some(m=>m.site_id===x.id)); return header+`

    ${esc(g.name)}

    当前站语言 ${esc(g.locale)} · 默认语言 ${esc(g.default_locale)} · ${groupSites.length} 个语言站点

    ${groupSites.map(x=>``).join('')}
    语言网站正式域名
    ${esc(x.locale)}${esc(x.name)}${esc(x.domain)}
    `+ `

    第二步 · 添加其他语言网站

    先通过“我的网站”创建另一语言站并设置 HTTPS 域名,再加入语言组。

    `+ `

    第三步 · 建立翻译内容关系

    相同产品、文章或页面可以使用不同 URL。用一个内容标识关联它们,只有正式发布且审核通过的内容才会生成 hreflang。

    `+ `

    第四步 · 关联各语言的实际内容

    `+ `

    已有语言关系

    没有已审核的双向内容关系,就不会输出假语言链接。

    ${d.units.length?`
    ${d.units.map(u=>``).join('')}
    内容组语言内容 / URL状态
    ${esc(u.reference_key)}${esc(u.locale)}${esc(u.title)}
    ${esc(u.route)}
    ${u.state==='approved'?'已审核':'待翻译'}
    `:'

    还没有关联内容。

    '}
    `; } async function loadLanguage(){const sid=state.site?.id;if(!sid)return;try{ const [data,groups]=await Promise.all([api('/api/i18n/sites/'+encodeURIComponent(sid)),api('/api/i18n/groups')]); if(state.site?.id!==sid)return;state.language=data;state.languageGroups=groups;await loadLanguageEntries(sid,false);if(state.view==='languages')rerender(); }catch(e){notifyError(e)}} async function loadLanguageEntries(siteId,redraw=true){if(!siteId)return;try{const rows=await api('/api/sites/'+encodeURIComponent(siteId)+'/entries');state.languageEntries=rows;if(redraw){const target=$('#langMemberEntry');if(target)target.innerHTML=rows.map(x=>``).join('')}}catch(e){notifyError(e);state.languageEntries=[]}} async function submitLanguageGroup(ev){ev.preventDefault();if(!state.site)return;try{const d=Object.fromEntries(new FormData(ev.target));const locale=d.current_locale;delete d.current_locale;const group=await api('/api/i18n/groups','POST',d);await api('/api/i18n/groups/'+group.id+'/sites','POST',{site_id:state.site.id,locale});toast('语言组已创建');await loadLanguage()}catch(e){notifyError(e)}} async function submitLanguageJoin(ev){ev.preventDefault();try{const d=Object.fromEntries(new FormData(ev.target));await api('/api/i18n/groups/'+d.group_id+'/sites','POST',{site_id:d.site_id||state.site.id,locale:d.locale});toast('语言站已关联');await loadLanguage()}catch(e){notifyError(e)}} async function submitLanguageUnit(ev){ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));try{await api('/api/i18n/groups/'+state.language.group.group_id+'/units','POST',d);toast('翻译组已创建');await loadLanguage()}catch(e){notifyError(e)}} 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,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=''+state.sites.map(s=>``).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:{},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;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;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'||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=`

    版本质量报告

    HTML 页面:${esc(r.pages||0)} · 警告 ${esc((r.warnings||[]).length)} 项

    ${r.warnings?.length?``:'

    自动检查未返回警告,仍需核对业务内容和手机页面。

    '}
    `; } 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)}} 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=`

    网站名称:${esc(data.name)}

    网站类型:${esc(({new:'全新制作网站',migration:'迁移旧站',hybrid:'旧站内容重新设计'})[data.mode]||data.mode)}

    网站风格:${esc(themeName[data.theme]||'企业品牌')}

    `} } 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)}} async function refreshSiteKeepView(){if(!state.site)return;const [site,entries,builds]=await Promise.all([api('/api/sites/'+state.site.id),api('/api/sites/'+state.site.id+'/entries'),api('/api/sites/'+state.site.id+'/builds')]);state.site=site;state.entries=entries;state.builds=builds} async function cancelJob(jobId){if(!state.site)return;if(!confirm('确定取消这个构建任务?已经完成的静态版本不会被删除。'))return;try{const id=state.site.id;await api(`/api/enterprise/sites/${encodeURIComponent(id)}/jobs/${encodeURIComponent(jobId)}/cancel`,'POST');toast('已发送取消请求');await refreshBuilds()}catch(e){notifyError(e)}} async function buildNow(){if(!state.site)return toast('请先选择网站',true);if(state.building||activeJobs().length)return toast('已有构建在执行,请先查看任务状态'); const id=state.site.id;state.building=true; try{navigate('build');state.job='正在提交构建任务…';rerender();const job=await api(`/api/sites/${encodeURIComponent(id)}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()}); if(state.site?.id!==id)return;state.job=`任务 ${job.id?.slice(0,12)||'已提交'} 已进入队列,可以离开页面;重新打开“构建与交付”查看结果。`;toast('已提交构建任务');await refreshBuilds(); }catch(e){notifyError(e)}finally{state.building=false;if(state.site?.id===id&&state.view==='build')rerender()}} function selectedReleaseBuild(){const id=$('#deliveryBuild')?.value;if(!id)throw Error('没有可发布的构建版本,请先完成网站构建');return id} function showDeliveryMessage(message){const el=$('#deliveryNotice');if(el){el.hidden=false;el.textContent=message}toast(message)} async function releasePreflight(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id;const r=await api(`/api/production/sites/${encodeURIComponent(s)}/preflight/${encodeURIComponent(id)}`);if(state.site?.id!==s)return;showDeliveryMessage(r.ready?'已通过发布前置检查:'+r.message:'尚不能发布:'+r.message)}catch(e){notifyError(e)}} async function releaseApproval(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id;const r=await api(`/api/factory/sites/${encodeURIComponent(s)}/release-requests`,'POST',{build_id:id});showDeliveryMessage('审批申请已创建,请由另一名有审核权限的人员批准。申请编号:'+r.id)}catch(e){notifyError(e)}} async function releaseDeploy(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id,target=$('#deliveryTarget')?.value;if(!state.delivery.configured?.[target])throw Error('尚未配置此目标服务器,请管理员配置目标发布方式(Agent/SFTP/S3)、服务器凭据与 HTTPS 验证地址');if(!confirm(`确定将构建 ${id.slice(0,12)} 提交到${target==='production'?'正式':'测试'}环境吗?必须已有独立审批。`))return;const r=await api(`/api/production/sites/${encodeURIComponent(s)}/deploy`,'POST',{build_id:id,target},{'Idempotency-Key':crypto.randomUUID()});showDeliveryMessage(`发布任务 ${r.id} 已排队。Worker 会先暂存和校验,再激活新版本。`);await refreshBuilds()}catch(e){notifyError(e)}} async function releaseRollback(target,release){if(!state.site)return;if(!confirm(`确定在 ${target} 环境恢复历史版本 ${release} 吗?请确认其内容仍符合当前业务要求。`))return;try{const r=await api(`/api/production/sites/${encodeURIComponent(state.site.id)}/rollback`,'POST',{target,release_id:release},{'Idempotency-Key':crypto.randomUUID()});showDeliveryMessage(`回滚任务 ${r.id} 已排队,请在任务列表查看结果。`);await refreshBuilds()}catch(e){notifyError(e)}} async function checkReadiness(){if(!state.site)return;const siteId=state.site.id; try{const result=await api(`/api/factory/sites/${encodeURIComponent(siteId)}/migration/readiness`); if(state.site?.id!==siteId)return; const box=$('#reportContent');if(!box)return; const gates=result.gates||[]; box.innerHTML=`

    正式交付前检查

    检查旧站 URL、SEO 和静态文件风险;通过检查不等于已经上线。

    ${result.can_publish?'没有发现发布阻断项':'存在需要先处理的问题'}

    ${gates.length?'':'

    当前没有需要处理的检查项。仍需人工确认内容、域名与询盘表单。

    '}
    进入迁移复核与发布审批 →
    `; box.scrollIntoView({behavior:'smooth',block:'nearest'}); }catch(e){notifyError(e)} } async function showReport(buildId){try{const r=await api(`/api/sites/${state.site.id}/builds/${buildId}/report`);const target=$('#reportContent');if(!target)return;target.innerHTML=`

    质量检查结果

    本报告检查静态内容与资源,不保证搜索引擎排名。

    ${stat('HTML 页面',r.pages||0,'▤')}${stat('重定向',r.redirects||0,'⇄')}${stat('已本地化图片',r.mirrored_images||0,'▧')}${stat('提示数量',(r.warnings||[]).length,'!')}
    ${r.warnings?.length?`
    ${r.warnings.slice(0,20).map(x=>esc(x)).join('\n')}
    `:'当前检查没有警告'}
    `;target.scrollIntoView({behavior:'smooth',block:'nearest'})}catch(e){notifyError(e)}} function closeMobile(){$('#sideMenu').classList.remove('open');$('#mobileBackdrop').classList.remove('open');$('#menuToggle').setAttribute('aria-expanded','false')} async function init(){try{const me=await api('/api/auth/me');state.csrf=me.csrf;await loadSites();const health=await api('/api/health');$('#appVersion').textContent='版本 '+health.version; // 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','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,'')}} // Delegated UI interactions keep navigation and form actions centrally auditable. 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==='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','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',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); document.addEventListener('keydown',ev=>{if(ev.key==='Escape')closeMobile()}); document.addEventListener('DOMContentLoaded',init);