2026-10-22 16:49:25 +08:00
<!doctype html> < html lang = "zh-CN" >< head >< meta charset = "utf-8" >< meta name = "viewport" content = "width=device-width,initial-scale=1" >< title > SiteForge · 数据与迁移工具</ title >
< style >
: root { font-family : Inter , "Microsoft YaHei" , system-ui , sans-serif ; color : #202a39 ; background : #f4f7fb } * { box-sizing : border-box } body { margin : 0 } header { display : flex ; align-items : center ; justify-content : space-between ; background : #101d31 ; color : white ; padding : 18 px 28 px ; gap : 16 px } header h1 { font-size : 18 px ; margin : 0 } header small { color : #a4b9d4 } main { max-width : 1320 px ; margin : 24 px auto ; padding : 0 20 px }. pill { border-radius : 20 px ; background : #eaf2ff ; color : #164a8d ; font-size : 12 px ; padding : 5 px 10 px }. muted { color : #6d7685 ; font-size : 13 px }. row { display : flex ; align-items : center ; gap : 12 px ; flex-wrap : wrap }. nav { display : flex ; gap : 10 px }. nav a { color : #d8e8f9 ; font-size : 13 px ; text-decoration : none }. kpi { display : grid ; grid-template-columns : repeat ( 3 , 1 fr ); gap : 14 px ; margin : 18 px 0 }. kpi article , . panel { background : white ; border : 1 px solid #e1e8f1 ; border-radius : 12 px ; padding : 20 px ; box-shadow : 0 2 px 12 px #102038 06 }. kpi strong { display : block ; font-size : 25 px ; margin-top : 8 px }. layout { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 18 px }. panel h2 { font-size : 17 px ; margin : 0 0 6 px }. panel h3 { font-size : 14 px ; margin : 16 px 0 8 px } label { display : block ; font-size : 12 px ; color : #526075 ; font-weight : 600 ; margin-top : 13 px ; margin-bottom : 5 px } input , select , textarea { width : 100 % ; padding : 10 px 11 px ; font : inherit ; font-size : 13 px ; border : 1 px solid #cbd6e6 ; border-radius : 7 px ; background : white } textarea { min-height : 115 px ; resize : vertical } button { cursor : pointer ; border : 1 px solid #c5d2e4 ; border-radius : 8 px ; background : white ; color : #213249 ; font-weight : 600 ; font-size : 13 px ; padding : 10 px 14 px } button : hover { border-color : #2274bd } button . primary { background : #1658a6 ; color : #fff ; border-color : #1658a6 } button . danger { color : #b22d2d } button : disabled { opacity : .45 ; cursor : not-allowed } hr { border : 0 ; border-top : 1 px solid #e8edf5 ; margin : 20 px 0 }. message { white-space : pre-wrap ; font-size : 12 px ; line-height : 1.55 ; overflow : auto ; max-height : 270 px ; background : #f5f8fc ; padding : 12 px ; border-radius : 8 px }. results { max-height : 270 px ; overflow : auto }. results > div { padding : 10 px ; border-bottom : 1 px solid #eef1f5 ; font-size : 13 px ; display : flex ; gap : 10 px ; justify-content : space-between }. foot { margin : 20 px 0 ; color : #6f7b8b ; font-size : 12 px }. ok { color : #176d45 }. err { color : #c0392b }@ media ( max-width : 850px ) {. layout , . kpi { grid-template-columns : 1 fr } header { align-items : flex-start ; flex-direction : column } main { margin : 12 px auto }}
[ hidden ] { display : none !important }. tool-switch { display : flex ; flex-wrap : wrap ; gap : 9 px ; background : #fff ; padding : 12 px ; margin : 18 px 0 0 ; border-radius : 12 px ; border : 1 px solid #e1e8f1 }. tool-btn { background : #f6f8fc ; color : #50627c ; border-color : transparent ; padding : 11 px 14 px }. tool-btn . active { background : #e8f0ff ; color : #174ea7 ; border : 1 px solid #a5c5f5 }. tool-help { font-size : 12 px ; color : #64748b ; margin : 12 px 5 px 16 px }. advanced-layout { grid-template-columns : minmax ( 0 , 1 fr )}. advanced-layout > . panel { width : 100 % }. nav a : first-child { border : 1 px solid #7793bd ; padding : 9 px 11 px ; border-radius : 7 px ; background : #fff1 }@ media ( max-width : 720px ) {. tool-switch { gap : 5 px }. tool-btn { padding : 9 px 11 px }}
2026-10-22 17:02:45 +08:00
</ style >< link rel = "stylesheet" href = "/ui/professional-ui.css" >< script defer src = "/ui/professional-ui.js" ></ script ></ head >< body data-sf-page = "production" >
< header >< div >< h1 > SiteForge · 网站生产控制台 < span style = "font-size:11px;color:#acd5ff" > V8.11 · 高级工具</ span ></ h1 >< small > 整站模板 · 数据批次导入 · 可审计回滚 · 静态构建</ small ></ div >< div class = "nav" >< a href = "/" > 返回网站工作台</ a >< a href = "/factory-studio" > 页面编辑器</ a >< a href = "/theme-gallery" > 主题中心</ a >< a href = "/operations" > 任务中心</ a ></ div ></ header >
2026-10-22 16:49:25 +08:00
< main >
< div class = "panel" >< div class = "row" style = "justify-content:space-between" >< div >< h2 > 选择要操作的网站</ h2 >< div class = "muted" > 所有批次、模板和构建记录均按网站隔离;新网站可先在网站工作台创建。</ div ></ div >< select id = "site" style = "max-width:410px" aria-label = "选择网站" ></ select ></ div ></ div >
< section class = "kpi" >< article >< span class = "muted" > 当前网站</ span >< strong id = "siteName" > —</ strong ></ article >< article >< span class = "muted" > 内容记录</ span >< strong id = "entryCount" > —</ strong ></ article >< article >< span class = "muted" > 导入批次</ span >< strong id = "batchCount" > —</ strong ></ article ></ section >
< div class = "tool-switch" role = "tablist" aria-label = "高级工具分类" >
< button type = "button" class = "tool-btn active" data-tool = "batch" role = "tab" aria-selected = "true" > ① 批次导入</ button >
< button type = "button" class = "tool-btn" data-tool = "templates" role = "tab" aria-selected = "false" > ② 整站模板</ button >
< button type = "button" class = "tool-btn" data-tool = "wp" role = "tab" aria-selected = "false" > ③ WordPress 迁移</ button >
< button type = "button" class = "tool-btn" data-tool = "api" role = "tab" aria-selected = "false" > ④ XML / API</ button >
< button type = "button" class = "tool-btn" data-tool = "mysql" role = "tab" aria-selected = "false" > ⑤ MySQL 数据源</ button >
</ div >< p class = "tool-help" id = "toolHelp" role = "status" > 先核对字段与 URL 冲突,再将数据正式写入网站。</ p >
< section class = "layout advanced-layout" >
< div class = "panel" id = "tool-batch" >< h2 > 批次数据导入</ h2 >< div class = "muted" > 先预览字段与 URL 冲突,确认后再提交;支持按批次回滚且保护导入后的人工作业。</ div >
< label for = "source" > 数据格式</ label >< select id = "source" >< option value = "json" > JSON 行数组</ option >< option value = "wxr" > WordPress WXR XML 文件</ option >< option value = "table" > Excel / CSV / JSON 文件</ option ></ select >
< label for = "kind" > 内容类型</ label >< select id = "kind" >< option value = "product" > 产品</ option >< option value = "vehicle" > 车型</ option >< option value = "page" > 普通页面</ option >< option value = "post" > 新闻文章</ option >< option value = "case" > 案例</ option >< option value = "brand" > 品牌</ option >< option value = "service" > 服务</ option ></ select >
< div id = "jsonInputs" >< label for = "records" > JSON 数据</ label >< textarea id = "records" spellcheck = "false" > [{"车型名称":"Example Vehicle A","品牌":"Example Brand","动力形式":"EV"}]</ textarea >
< div class = "muted" > 第一行字段自动建议映射。中文字段可对应标题、品牌、分类、参数等。</ div ></ div >
< div id = "tableInputs" style = "display:none" >< label for = "tableFile" > 上传 XLSX / CSV / JSON</ label >< input type = "file" id = "tableFile" accept = ".xlsx,.csv,.json" ></ div >< div id = "wxrInputs" style = "display:none" >< label for = "wxrFile" > 上传 WordPress 全量 WXR XML</ label >< input type = "file" id = "wxrFile" accept = ".xml" ></ div >
< div class = "row" style = "margin-top:16px" >< button class = "primary" id = "stage" > 1. 校验并暂存</ button >< button id = "refreshBatch" > 刷新批次</ button ></ div >< pre class = "message" id = "importResult" > 请选择网站后导入。</ pre >
< h3 > 已暂存/已导入批次</ h3 >< div class = "results" id = "batches" ></ div >
</ div >
< div class = "panel" id = "tool-templates" hidden >< h2 > 整站模板生产线</ h2 >< div class = "muted" > 保存当前网站的页面区块、导航与设计令牌。模板不复制产品、文章、域名、Logo、SEO 或用户密钥。</ div >
< label for = "snapshotName" > 新模板名称</ label >< input id = "snapshotName" value = "企业官网整站模板" >< button id = "saveTemplate" style = "margin-top:10px" class = "primary" > 保存当前网站的模板快照</ button >
< hr >< label for = "sourceSite" > 模板来源网站</ label >< select id = "sourceSite" ></ select >< label for = "templateId" > 模板版本</ label >< select id = "templateId" ></ select >
< div class = "row" style = "margin-top:14px" >< button id = "preflight" > 2. 兼容性预检</ button >< button id = "apply" class = "primary" > 3. 暂存整站模板</ button >< button id = "publishTemplate" class = "primary" > 4. Owner 发布模板</ button ></ div >
< pre class = "message" id = "templateResult" > 先保存模板,再在另一网站上预检并应用。若存在同路径页面,需显式授权替换布局。</ pre >
< hr >< h3 > 静态网站生产</ h3 >< div class = "muted" > 使用现有独立 Worker 构建队列,提交后到任务中心检查超时、重试和取消。</ div >
< div class = "row" style = "margin-top:12px" >< button class = "primary" id = "build" > 提交静态构建任务</ button >< a href = "/operations" > 查看任务进度 →</ a ></ div >
< pre class = "message" id = "buildResult" > 尚未提交</ pre >
</ div >
</ section >
< section class = "panel" id = "tool-wp" style = "margin-top:18px" aria-labelledby = "wpTitle" hidden >
< h2 id = "wpTitle" > WordPress 全站迁移工作台</ h2 >
< div class = "muted" > 适用于你有权迁移的公开 WordPress 网站。每次安全读取一页(最多 50 条),保留进度,所有内容先暂存,确认后才生成原站 URL/SEO 基线。旧站复杂插件与页面效果仍须单独复核。</ div >
< div class = "row" style = "align-items:flex-end;margin-top:12px" >
< div style = "flex:2;min-width:250px" >< label for = "wpOrigin" > 原 WordPress 网站地址</ label >< input id = "wpOrigin" placeholder = "https://old.example.com/" ></ div >
< div style = "flex:1;min-width:140px" >< label for = "wpLimit" > 最多采集记录</ label >< input id = "wpLimit" type = "number" min = "1" max = "20000" value = "2000" ></ div >
< button id = "wpCreate" class = "primary" > 新建迁移会话</ button >
</ div >
< div class = "row" style = "margin-top:10px" >
< select aria-label = "WordPress 迁移会话" id = "wpSession" style = "min-width:240px;max-width:430px" ></ select >
< button id = "wpNext" > 继续采集一页</ button >< button id = "wpWorker" class = "primary" > 提交 Worker 后台采集</ button >< button id = "wpSitemap" > 发现 Sitemap</ button >< button id = "wpMedia" > 本地化图片(20 张)</ button >< select id = "wpCatalogType" aria-label = "同步 WordPress 目录" >< option value = "categories" > 分类</ option >< option value = "tags" > 标签</ option >< option value = "media" > 媒体库</ option ></ select >< button id = "wpCatalogSync" > 同步目录下一页</ button >< button id = "wpCatalogStatus" > 目录进度</ button >< button id = "wpAuto" > 连续采集</ button >< button id = "wpStop" > 停止连续采集</ button >
< button id = "wpFinalize" class = "primary" > 确认并导入全部暂存</ button >
< button id = "wpCancel" class = "danger" > 取消迁移</ button >
< button id = "wpReload" > 刷新状态</ button >< button id = "wpAudit" > 对比最新构建的 URL/SEO</ button >
</ div >
< pre id = "wpResult" class = "message" > 选择站点并新建迁移会话。每页请求独立保存检查点;采集完成后可明确审核 URL、SEO、缺失内容。</ pre >
</ section >
< section class = "panel" id = "tool-api" style = "margin-top:18px" aria-labelledby = "p3Title" hidden >
< h2 id = "p3Title" > P3 · XML / HTTP API 数据连接器</ h2 >
< div class = "muted" > XML 仅支持扁平或两层记录,拒绝外部实体;HTTP API 支持公开或密钥引用认证的 HTTPS JSON。全部数据先进入暂存批次,需要单独审核并提交;API 采集在 Worker 后台执行。</ div >
< div class = "layout" style = "margin-top:12px" >
< div >
< h3 > XML 导入</ h3 >
< label for = "xmlFile" > XML 文件(最大 8 MB) </ label >< input id = "xmlFile" type = "file" accept = ".xml,text/xml,application/xml" >
< label for = "xmlRecord" > 记录节点标签(如 product / item) </ label >< input id = "xmlRecord" value = "item" >
< label for = "xmlMapping" > 字段映射 JSON(可为空自动识别)</ label >< textarea id = "xmlMapping" spellcheck = "false" rows = "3" > {}</ textarea >
< button class = "primary" id = "xmlStage" > 校验并暂存 XML</ button >
</ div >
< div >
< h3 > JSON API(公开或认证)</ h3 >
< label for = "apiUrl" > HTTPS 数据接口</ label >< input id = "apiUrl" placeholder = "https://api.example.com/v1/products" >
< label for = "apiAuth" > 认证方式</ label >< select id = "apiAuth" >< option value = "none" > 公开 API</ option >< option value = "bearer" > Bearer Token</ option >< option value = "api_key" > X-API-Key</ option >< option value = "custom" > 自定义 X- 请求头</ option ></ select >
< label for = "apiSecretRef" > 密钥引用(仅 Worker 读取,不填写密钥本身)</ label >< input id = "apiSecretRef" placeholder = "file:partner_api_token 或 env:SITEFORGE_API_TOKEN" >
< label for = "apiAuthHeader" > 自定义请求头(仅 custom) </ label >< input id = "apiAuthHeader" value = "X-Partner-Key" >
< label for = "apiItems" > 列表 JSON 路径</ label >< input id = "apiItems" value = "data.items" >
< div class = "row" >< div style = "flex:1" >< label for = "apiPageSize" > 每页数量</ label >< input id = "apiPageSize" type = "number" value = "50" min = "1" max = "100" ></ div >< div style = "flex:1" >< label for = "apiMaxPages" > 最多页数</ label >< input id = "apiMaxPages" type = "number" value = "10" min = "1" max = "50" ></ div ></ div >
< label for = "apiMapping" > 字段映射 JSON(可为空自动识别)</ label >< textarea id = "apiMapping" spellcheck = "false" rows = "3" > {}</ textarea >
< div class = "row" >< button class = "primary" id = "apiCreate" > 创建 API 数据源</ button >< button id = "apiReload" > 刷新采集记录</ button ></ div >
< label for = "apiSource" > 已保存的数据源</ label >< select id = "apiSource" aria-label = "API 数据源" ></ select >
< div class = "row" >< button class = "primary" id = "apiRun" > 提交 Worker 采集</ button >< button id = "apiStatus" > 查看采集与批次</ button >< button class = "danger" id = "apiCancel" > 取消采集</ button ></ div >
</ div >
</ div >
< pre class = "message" id = "p3Result" > 创建数据源、提交 Worker 后到“任务中心”查看执行;采集完后到上方“已暂存批次”确认导入。</ pre >
</ section >
< section class = "panel" id = "tool-mysql" style = "margin-top:18px" aria-labelledby = "mysqlTitle" hidden >
< h2 id = "mysqlTitle" > P3 · 只读 MySQL 连接器</ h2 >
< div class = "muted" > 需要管理员预先在服务器环境配置允许的主机和数据库表,并建立数据库侧仅 SELECT 的账号。强制 TLS 证书校验,拒绝任意 SQL;数据分批暂存,不自动发布。</ div >
< div class = "layout" >
< div >< label for = "myHost" > 管理员批准的 MySQL 主机</ label >< input id = "myHost" placeholder = "db.internal.example" >
< label for = "myDatabase" > 数据库名</ label >< input id = "myDatabase" placeholder = "catalog" >
< label for = "myTable" > 表或视图</ label >< input id = "myTable" placeholder = "products_public" >
< label for = "myKey" > 有唯一索引的游标列</ label >< input id = "myKey" value = "id" >
< label for = "myColumns" > 允许读取的列(英文逗号分隔)</ label >< input id = "myColumns" placeholder = "id,name,brand,category" >
</ div >
< div >< label for = "myUserRef" > MySQL 只读账号密钥引用</ label >< input id = "myUserRef" placeholder = "file:mysql_reader_user" >
< label for = "myPassRef" > MySQL 密码密钥引用</ label >< input id = "myPassRef" placeholder = "file:mysql_reader_password" >
< label for = "myMapping" > 字段映射 JSON</ label >< textarea id = "myMapping" rows = "2" > {"title":"name","brand":"brand"}</ textarea >
< div class = "row" >< div style = "flex:1" >< label for = "myPageSize" > 单页</ label >< input id = "myPageSize" type = "number" value = "100" min = "1" max = "250" ></ div >< div style = "flex:1" >< label for = "myMaxPages" > 最多页数</ label >< input id = "myMaxPages" type = "number" value = "10" min = "1" max = "50" ></ div ></ div >
< div class = "row" >< button class = "primary" id = "myCreate" > 创建 MySQL 数据源</ button >< button id = "myReload" > 刷新</ button ></ div >
< label for = "mySource" > 已保存的数据源</ label >< select id = "mySource" aria-label = "MySQL 数据源" ></ select >
< div class = "row" >< button class = "primary" id = "myRun" > 提交后台采集</ button >< button id = "myStatus" > 检查批次</ button >< button class = "danger" id = "myCancel" > 取消</ button ></ div >
</ div >
</ div >
< pre class = "message" id = "myResult" > 配置源数据后交由 Worker 执行;使用最小权限账号、TLS CA 证书和服务器侧白名单。</ pre >
</ section >
< p class = "foot" > 提示:V8 当前是分阶段开发版本。远程正式发布、Art Design Pro 官方工程编译、复杂 WordPress 组件无损迁移仍需专项验收。</ p >
</ main >< script >
'use strict' ; const $ = id => document . getElementById ( id ); let sites = [], csrf = '' , templates = [];
const toolNames = [ 'batch' , 'templates' , 'wp' , 'api' , 'mysql' ];
const toolHelp = { batch : '先核对字段与 URL 冲突,再将数据正式写入网站。' , templates : '保存整站布局 → 预检 → 暂存 → Owner 确认应用。' , wp : '逐页采集旧站、同步图片与分类;确认导入后进行 SEO 验收。' , api : '先建立 XML 或 HTTPS API 数据源,执行后在“批次导入”中审核。' , mysql : '此处面向管理员:只读账号、TLS 和数据源白名单必须事先配置。' };
function changeTool ( key ){ if ( ! toolNames . includes ( key )) return ; for ( const k of toolNames ){ const el = $ ( 'tool-' + k ); el . hidden = k !== key ;} for ( const btn of document . querySelectorAll ( '[data-tool]' )){ let yes = btn . dataset . tool === key ; btn . classList . toggle ( 'active' , yes ); btn . setAttribute ( 'aria-selected' , yes ? 'true' : 'false' ); btn . tabIndex = yes ? 0 :- 1 ;} $ ( 'toolHelp' ). textContent = toolHelp [ key ] || '' ; window . scrollTo ({ top : 0 , behavior : 'instant' })}
document . querySelectorAll ( '[data-tool]' ). forEach ( btn => btn . addEventListener ( 'click' ,()=> changeTool ( btn . dataset . tool )));
async function api ( path , method = 'GET' , body ){ const opts = { method , credentials : 'same-origin' , headers : { 'Accept' : 'application/json' }}; if ( method !== 'GET' ) opts . headers [ 'X-SiteForge-CSRF' ] = csrf ; if ( body instanceof FormData ) opts . body = body ; else if ( body !== undefined ){ opts . headers [ 'Content-Type' ] = 'application/json' ; opts . body = JSON . stringify ( body )} const r = await fetch ( path , opts ); let data ; try { data = await r . json ()} catch { data = { detail : '服务器返回非 JSON' }} if ( ! r . ok ) throw Error ( typeof data . detail === 'string' ? data . detail : JSON . stringify ( data . detail || data )); return data }
function show ( id , obj ){ $ ( id ). textContent = typeof obj === 'string' ? obj : JSON . stringify ( obj , null , 2 )}
function current (){ return $ ( 'site' ). value } function opt ( value , text ){ const el = document . createElement ( 'option' ); el . value = value ; el . textContent = text ; return el }
async function load (){ try { const me = await api ( '/api/auth/me' ); csrf = me . csrf ; sites = await api ( '/api/sites' ); $ ( 'site' ). replaceChildren (... sites . map ( s => opt ( s . id , s . name ))); $ ( 'sourceSite' ). replaceChildren (... sites . map ( s => opt ( s . id , s . name ))); await refresh ();} catch ( e ){ alert ( '需要先登录或加载失败:' + e . message ); location . href = '/login' }}
async function refresh (){ const s = sites . find ( x => x . id === current ()); if ( ! s ) return ; $ ( 'siteName' ). textContent = s . name ; $ ( 'entryCount' ). textContent = s . entries_count ; await loadBatches (); await loadTemplates (); await loadWpSessions (); await loadApiSources (); await loadMySources ()}
async function loadBatches (){ try { const data = await api ( `/api/factory/sites/ ${ current () } /staging` ); $ ( 'batchCount' ). textContent = data . items . length ; const box = $ ( 'batches' ); box . replaceChildren (); for ( const b of data . items ){ const div = document . createElement ( 'div' ); const title = document . createElement ( 'span' ); title . textContent = ` ${ b . origin } · ${ b . state } · ${ b . input_count } 条` ; div . append ( title ); const inspect = document . createElement ( 'button' ); inspect . textContent = '关系检查' ; inspect . onclick = async ()=>{ try { show ( 'importResult' , await api ( `/api/connectors/sites/ ${ current () } /import-batches/ ${ b . id } /relationships` ))} catch ( e ){ show ( 'importResult' , '关系检查失败:' + e . message )}}; div . append ( inspect ); if ( b . state === 'staged' ){ const c = document . createElement ( 'button' ); c . textContent = '提交' ; c . onclick = ()=> processBatch ( b . id , 'commit' ); div . append ( c )} if ([ 'committed' , 'partial' ]. includes ( b . state )){ const c = document . createElement ( 'button' ); c . textContent = '回滚' ; c . className = 'danger' ; c . onclick = ()=> processBatch ( b . id , 'rollback' ); div . append ( c )} box . append ( div )}} catch ( e ){ show ( 'importResult' , e . message )}}
async function processBatch ( id , action ){ let payload = {}; if ( action === 'commit' ){ const c = confirm ( '确认提交本批数据?遇到已存在的路径,默认跳过,不覆盖。' ); if ( ! c ) return ; payload = { on_conflict : 'skip' }} else if ( ! confirm ( '确认回滚本批导入?导入后被人工修改的内容将被保护,不能强制覆盖。' )) return ; try { show ( 'importResult' , await api ( `/api/factory/sites/ ${ current () } /staging/ ${ id } / ${ action } ` , 'POST' , payload )); await loadBatches ()} catch ( e ){ show ( 'importResult' , '错误:' + e . message )}}
$ ( 'source' ). onchange = ()=>{ $ ( 'jsonInputs' ). style . display = $ ( 'source' ). value === 'json' ? 'block' : 'none' ; $ ( 'wxrInputs' ). style . display = $ ( 'source' ). value === 'wxr' ? 'block' : 'none' ; $ ( 'tableInputs' ). style . display = $ ( 'source' ). value === 'table' ? 'block' : 'none' };
$ ( 'site' ). onchange = refresh ; $ ( 'sourceSite' ). onchange = loadTemplates ;
$ ( 'stage' ). onclick = async ()=>{ try { let data ; if ( $ ( 'source' ). value === 'table' ){ const form = new FormData (); if ( ! $ ( 'tableFile' ). files [ 0 ]) throw Error ( '请选择 Excel/CSV/JSON 文件' ); form . append ( 'file' , $ ( 'tableFile' ). files [ 0 ]); form . append ( 'content_type' , $ ( 'kind' ). value ); data = await api ( `/api/factory/sites/ ${ current () } /staging/file` , 'POST' , form )} else if ( $ ( 'source' ). value === 'wxr' ){ const form = new FormData (); if ( ! $ ( 'wxrFile' ). files [ 0 ]) throw Error ( '请选择 WXR XML' ); form . append ( 'file' , $ ( 'wxrFile' ). files [ 0 ]); data = await api ( `/api/factory/sites/ ${ current () } /staging/wxr` , 'POST' , form )} else { const rows = JSON . parse ( $ ( 'records' ). value ); data = await api ( `/api/factory/sites/ ${ current () } /staging` , 'POST' ,{ rows , content_type : $ ( 'kind' ). value })} show ( 'importResult' , data ); await loadBatches ()} catch ( e ){ show ( 'importResult' , '错误:' + e . message )}};
$ ( 'refreshBatch' ). onclick = loadBatches ;
$ ( 'saveTemplate' ). onclick = async ()=>{ try { show ( 'templateResult' , await api ( `/api/factory/sites/ ${ current () } /whole-site-templates` , 'POST' ,{ name : $ ( 'snapshotName' ). value })); await loadTemplates ()} catch ( e ){ show ( 'templateResult' , '错误:' + e . message )}};
async function loadTemplates (){ const source = $ ( 'sourceSite' ). value ; if ( ! source ) return ; try { templates = await api ( `/api/factory/sites/ ${ source } /whole-site-templates` ); $ ( 'templateId' ). replaceChildren (... templates . map ( t => opt ( t . id , ` ${ t . name } · ${ t . version } ` )))} catch ( e ){ show ( 'templateResult' , e . message )}}
async function applyTemplate ( confirmApply ){ try { const payload = { source_site_id : $ ( 'sourceSite' ). value , template_id : $ ( 'templateId' ). value , confirm : confirmApply , overwrite_layouts : confirmApply }; if ( ! payload . template_id ) throw Error ( '请先保存或选择整站模板' ); const document = await api ( `/api/factory/sites/ ${ payload . source_site_id } /whole-site-templates/ ${ payload . template_id } ` ); const report = await api ( `/api/factory/sites/ ${ current () } /whole-site-templates/preflight` , 'POST' ,{ template : document }); if ( ! report . compatible ||! confirmApply ){ show ( 'templateResult' , report ); return } if ( ! confirm ( '兼容性通过。应用模板将替换同路径页面布局,但保留内容及 SEO。确认?' )) return ; const draft = await api ( `/api/factory/sites/ ${ current () } /whole-site-templates/staged/current` ); payload . expected_revision = draft . revision || 0 ; show ( 'templateResult' , await api ( `/api/factory/sites/ ${ current () } /whole-site-templates/apply` , 'POST' , payload ))} catch ( e ){ show ( 'templateResult' , '错误:' + e . message )}}
$ ( 'preflight' ). onclick = ()=> applyTemplate ( false ); $ ( 'apply' ). onclick = ()=> applyTemplate ( true ); $ ( 'publishTemplate' ). onclick = async ()=>{ try { const s = await api ( `/api/factory/sites/ ${ current () } /whole-site-templates/staged/current` ); if ( s . state !== 'draft' ) throw Error ( '没有待发布的整站模板' ); if ( s . conflict ) throw Error ( '网站已经变化,请重新预检并暂存' ); if ( ! confirm ( '将由 Owner 正式应用整站布局,并保留原网站业务内容与 SEO。继续?' )) return ; show ( 'templateResult' , await api ( `/api/factory/sites/ ${ current () } /whole-site-templates/staged/publish` , 'POST' ,{ expected_revision : s . revision }))} catch ( e ){ show ( 'templateResult' , '错误:' + e . message )}};
$ ( 'build' ). onclick = async ()=>{ try { show ( 'buildResult' , await api ( `/api/sites/ ${ current () } /builds` , 'POST' ,{ mirror_images : false }))} catch ( e ){ show ( 'buildResult' , '错误:' + e . message )}};
async function loadWpSessions (){ try { if ( ! current ()) return ; const result = await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions` ); const select = $ ( 'wpSession' ); const chosen = select . value ; select . replaceChildren (... result . items . map ( s => opt ( s . id , ` ${ s . origin } · ${ s . state } · ${ s . fetched } 条` ))); if ( result . items . some ( s => s . id === chosen )) select . value = chosen ; if ( select . value ){ const item = await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions/ ${ select . value } ` ); show ( 'wpResult' , item )}} catch ( e ){ show ( 'wpResult' , '查询错误:' + e . message )}}
async function wpAction ( action ){ const sid = $ ( 'wpSession' ). value ; if ( ! sid ) throw Error ( '请先创建迁移会话' ); return api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions/ ${ sid } / ${ action } ` , 'POST' ,{})}
$ ( 'wpCreate' ). onclick = async ()=>{ try { const data = await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions` , 'POST' ,{ origin : $ ( 'wpOrigin' ). value , max_items : Number ( $ ( 'wpLimit' ). value )}); await loadWpSessions (); $ ( 'wpSession' ). value = data . id ; show ( 'wpResult' , data )} catch ( e ){ show ( 'wpResult' , '错误:' + e . message )}};
let wpAutoRunning = false ;
$ ( 'wpAuto' ). onclick = async ()=>{ if ( wpAutoRunning ) return ; wpAutoRunning = true ; let count = 0 ; try { while ( wpAutoRunning && count < 200 ){ const r = await wpAction ( 'advance' ); show ( 'wpResult' , r ); count ++ ; if ([ 'ready' , 'completed' , 'cancelled' , 'limit_reached' ]. includes ( r . state )) break ; await new Promise ( done => setTimeout ( done , 350 ))} await loadWpSessions ()} catch ( e ){ show ( 'wpResult' , '暂停:' + e . message )} finally { wpAutoRunning = false }};
$ ( 'wpStop' ). onclick = ()=>{ wpAutoRunning = false };
$ ( 'wpWorker' ). onclick = async ()=>{ try { const sid = $ ( 'wpSession' ). value ; show ( 'wpResult' , await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions/ ${ sid } /scan` , 'POST' ,{})); await loadWpSessions ()} catch ( e ){ show ( 'wpResult' , '提交失败:' + e . message )}};
$ ( 'wpSitemap' ). onclick = async ()=>{ try { const sid = $ ( 'wpSession' ). value ; show ( 'wpResult' , await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions/ ${ sid } /sitemap` , 'POST' ,{}));} catch ( e ){ show ( 'wpResult' , 'Sitemap 失败:' + e . message )}};
$ ( 'wpMedia' ). onclick = async ()=>{ try { const sid = $ ( 'wpSession' ). value ; show ( 'wpResult' , await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions/ ${ sid } /media/localize` , 'POST' ,{ limit : 20 }));} catch ( e ){ show ( 'wpResult' , '媒体迁移失败:' + e . message )}};
$ ( 'wpCatalogSync' ). onclick = async ()=>{ try { const sid = $ ( 'wpSession' ). value ; const resource = $ ( 'wpCatalogType' ). value ; show ( 'wpResult' , await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions/ ${ sid } /catalog/ ${ resource } /next` , 'POST' ,{}));} catch ( e ){ show ( 'wpResult' , '目录同步失败:' + e . message )}};
$ ( 'wpCatalogStatus' ). onclick = async ()=>{ try { show ( 'wpResult' , await api ( `/api/migrations/sites/ ${ current () } /wordpress/sessions/ ${ $ ( 'wpSession' ). value } /catalog` ));} catch ( e ){ show ( 'wpResult' , '读取目录失败:' + e . message )}};
$ ( 'wpNext' ). onclick = async ()=>{ try { show ( 'wpResult' , await wpAction ( 'advance' )); await loadWpSessions ()} catch ( e ){ show ( 'wpResult' , '错误:' + e . message )}};
$ ( 'wpFinalize' ). onclick = async ()=>{ if ( ! confirm ( '采集完成后,确认将全部暂存内容正式导入并登记原 URL/SEO 基线?' )) return ; try { show ( 'wpResult' , await wpAction ( 'finalize' )); await loadWpSessions (); await loadBatches ()} catch ( e ){ show ( 'wpResult' , '错误:' + e . message )}};
$ ( 'wpCancel' ). onclick = async ()=>{ if ( ! confirm ( '取消此迁移会话?已经暂存的批次不会自动删除。' )) return ; try { show ( 'wpResult' , await wpAction ( 'cancel' )); await loadWpSessions ()} catch ( e ){ show ( 'wpResult' , '错误:' + e . message )}};
$ ( 'wpReload' ). onclick = loadWpSessions ;
$ ( 'wpAudit' ). onclick = async ()=>{ try { const builds = await api ( `/api/sites/ ${ current () } /builds` ); const latest = builds . find ( b => b . status === 'complete' || b . status === 'done' || b . status === 'success' || b . status === 'completed' ); if ( ! latest ) throw Error ( '尚未找到已完成的静态构建版本' ); const result = await api ( `/api/audit/sites/ ${ current () } /builds/ ${ latest . id } /report` ); show ( 'wpResult' ,{ build_id : latest . id ,... result })} catch ( e ){ show ( 'wpResult' , 'SEO 验收错误:' + e . message )}};
$ ( 'wpSession' ). onchange = loadWpSessions ;
async function loadApiSources (){ if ( ! current ()) return ; try { const res = await api ( `/api/connectors/sites/ ${ current () } /api-sources` ); const el = $ ( 'apiSource' ), was = el . value ; el . replaceChildren (... res . items . map ( s => opt ( s . id , ` ${ s . url } · ${ s . state } · ${ s . staged_count } 条` ))); if ( res . items . some ( x => x . id === was )) el . value = was ;} catch ( e ){ show ( 'p3Result' , '查询 API 数据源失败:' + e . message )}}
$ ( 'xmlStage' ). onclick = async ()=>{ try { if ( ! $ ( 'xmlFile' ). files [ 0 ]) throw Error ( '请选择 XML 文件' ); const form = new FormData (); form . append ( 'file' , $ ( 'xmlFile' ). files [ 0 ]); form . append ( 'record_tag' , $ ( 'xmlRecord' ). value ); form . append ( 'content_type' , $ ( 'kind' ). value ); form . append ( 'mapping_json' , $ ( 'xmlMapping' ). value ); show ( 'p3Result' , await api ( `/api/connectors/sites/ ${ current () } /xml/staging` , 'POST' , form )); await loadBatches ()} catch ( e ){ show ( 'p3Result' , 'XML 导入失败:' + e . message )}};
$ ( 'apiCreate' ). onclick = async ()=>{ try { const result = await api ( `/api/connectors/sites/ ${ current () } /api-sources` , 'POST' ,{ url : $ ( 'apiUrl' ). value , items_path : $ ( 'apiItems' ). value , content_type : $ ( 'kind' ). value , page_size : Number ( $ ( 'apiPageSize' ). value ), max_pages : Number ( $ ( 'apiMaxPages' ). value ), mapping : JSON . parse ( $ ( 'apiMapping' ). value || '{}' ), auth_type : $ ( 'apiAuth' ). value , secret_ref : $ ( 'apiAuth' ). value === 'none' ? '' : $ ( 'apiSecretRef' ). value , auth_header : $ ( 'apiAuthHeader' ). value }); await loadApiSources (); $ ( 'apiSource' ). value = result . id ; show ( 'p3Result' , result )} catch ( e ){ show ( 'p3Result' , '创建失败:' + e . message )}};
$ ( 'apiReload' ). onclick = loadApiSources ;
$ ( 'apiRun' ). onclick = async ()=>{ try { if ( ! $ ( 'apiSource' ). value ) throw Error ( '请先选择数据源' ); show ( 'p3Result' , await api ( `/api/connectors/sites/ ${ current () } /api-sources/ ${ $ ( 'apiSource' ). value } /scan` , 'POST' ,{})); await loadApiSources ()} catch ( e ){ show ( 'p3Result' , '提交失败:' + e . message )}};
$ ( 'apiStatus' ). onclick = async ()=>{ try { show ( 'p3Result' , await api ( `/api/connectors/sites/ ${ current () } /api-sources/ ${ $ ( 'apiSource' ). value } ` )); await loadBatches ()} catch ( e ){ show ( 'p3Result' , '查询失败:' + e . message )}};
$ ( 'apiCancel' ). onclick = async ()=>{ if ( ! confirm ( '取消当前 API 数据源的后台采集?已暂存的批次不自动删除。' )) return ; try { show ( 'p3Result' , await api ( `/api/connectors/sites/ ${ current () } /api-sources/ ${ $ ( 'apiSource' ). value } /cancel` , 'POST' ,{})); await loadApiSources ()} catch ( e ){ show ( 'p3Result' , '取消失败:' + e . message )}};
async function loadMySources (){ if ( ! current ()) return ; try { const data = await api ( `/api/connectors/sites/ ${ current () } /mysql-sources` ); const el = $ ( 'mySource' ), was = el . value ; el . replaceChildren (... data . items . map ( s => opt ( s . id , ` ${ s . db_name } . ${ s . table_name } · ${ s . state } · ${ s . staged_count } 条` ))); if ( data . items . some ( s => s . id === was )) el . value = was } catch ( e ){ show ( 'myResult' , '读取 MySQL 数据源失败:' + e . message )}}
$ ( 'myCreate' ). onclick = async ()=>{ try { const result = await api ( `/api/connectors/sites/ ${ current () } /mysql-sources` , 'POST' ,{ host : $ ( 'myHost' ). value , database : $ ( 'myDatabase' ). value , table : $ ( 'myTable' ). value , key_column : $ ( 'myKey' ). value , columns : $ ( 'myColumns' ). value . split ( ',' ). map ( s => s . trim ()). filter ( Boolean ), user_ref : $ ( 'myUserRef' ). value , password_ref : $ ( 'myPassRef' ). value , page_size : Number ( $ ( 'myPageSize' ). value ), max_pages : Number ( $ ( 'myMaxPages' ). value ), content_type : $ ( 'kind' ). value , mapping : JSON . parse ( $ ( 'myMapping' ). value || '{}' )}); await loadMySources (); $ ( 'mySource' ). value = result . id ; show ( 'myResult' , result )} catch ( e ){ show ( 'myResult' , '创建失败:' + e . message )}};
$ ( 'myReload' ). onclick = loadMySources ;
$ ( 'myRun' ). onclick = async ()=>{ try { show ( 'myResult' , await api ( `/api/connectors/sites/ ${ current () } /mysql-sources/ ${ $ ( 'mySource' ). value } /scan` , 'POST' ,{}))} catch ( e ){ show ( 'myResult' , '提交失败:' + e . message )}};
$ ( 'myStatus' ). onclick = async ()=>{ try { show ( 'myResult' , await api ( `/api/connectors/sites/ ${ current () } /mysql-sources/ ${ $ ( 'mySource' ). value } ` )); await loadBatches ()} catch ( e ){ show ( 'myResult' , '查询失败:' + e . message )}};
$ ( 'myCancel' ). onclick = async ()=>{ if ( ! confirm ( '取消 MySQL 后台采集?已暂存的批次保留。' )) return ; try { show ( 'myResult' , await api ( `/api/connectors/sites/ ${ current () } /mysql-sources/ ${ $ ( 'mySource' ). value } /cancel` , 'POST' ,{})); await loadMySources ()} catch ( e ){ show ( 'myResult' , '取消失败:' + e . message )}};
load ();
</ script ></ body ></ html >