chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests) - Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR imports keep post/product types instead of collapsing to page - Docs: trade-pro theme port spec (R3) and implementation plan Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="sf-enterprise">
|
||||
<div class="intro"><div><small>SITEFORGE / ENTERPRISE CONTROL</small><h1>网站交付与质量控制</h1><p>角色权限 · 原网址与 SEO 基线 · 异步构建 · 版本质量</p></div><el-button type="primary" @click="reload">刷新数据</el-button></div>
|
||||
<el-card shadow="never" class="panel"><el-form inline><el-form-item label="选择站点"><el-select v-model="siteId" filterable placeholder="选择要管理的网站" style="width:330px" @change="reloadSite"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" /></el-select></el-form-item></el-form></el-card>
|
||||
<el-tabs v-if="siteId" v-model="tab" type="border-card" class="main-tabs">
|
||||
<el-tab-pane label="迁移验收" name="qa"><el-alert type="info" show-icon :closable="false" title="首先导入原始 URL 基线,然后才能确认改版后没有漏页。"/><el-row :gutter="18" class="summary"><el-col v-for="[title,value] in summaries" :key="title" :span="6"><el-statistic :title="title" :value="value" /></el-col></el-row><el-space wrap><el-button @click="snapshot">从当前内容建立基线</el-button><el-button @click="loadQA">重新检查</el-button><el-button @click="downloadBaseline">导出 CSV</el-button></el-space><el-table :data="qa?.issues || []" stripe style="margin-top:20px"><el-table-column prop="route" label="原路径" min-width="250"/><el-table-column prop="field" label="对比字段" width="150"/><el-table-column prop="severity" label="严重程度" width="120"/><el-table-column prop="to" label="当前内容" min-width="260" show-overflow-tooltip/></el-table></el-tab-pane>
|
||||
<el-tab-pane label="任务队列" name="jobs"><el-button type="primary" @click="enqueue">提交后台构建</el-button><el-button @click="loadJobs">刷新任务</el-button><el-table :data="jobs" stripe style="margin-top:20px"><el-table-column prop="id" label="任务 ID" min-width="180"/><el-table-column prop="kind" label="类型"/><el-table-column prop="state" label="状态"/><el-table-column prop="attempts" label="尝试次数"/><el-table-column prop="created_at" label="提交时间" min-width="200"/></el-table></el-tab-pane>
|
||||
<el-tab-pane label="站点成员" name="members"><el-alert type="info" :closable="false" title="Owner 管理成员和发布,Editor 编辑网站,Reviewer/Viewer 只读。"/><el-table :data="members" style="margin-top:20px"><el-table-column prop="username" label="用户"/><el-table-column prop="display_name" label="显示名称"/><el-table-column prop="role" label="权限角色"/></el-table></el-tab-pane>
|
||||
<el-tab-pane label="审计记录" name="audit"><el-table :data="logs" stripe><el-table-column prop="actor" label="操作者"/><el-table-column prop="operation" label="操作"/><el-table-column prop="path" label="API 路径" min-width="350" show-overflow-tooltip/><el-table-column prop="status" label="响应码"/><el-table-column prop="created_at" label="操作时间" min-width="195"/></el-table></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
interface Site {id:string;name:string}
|
||||
interface QA {baseline_count:number;current_count:number;errors:number;warnings:number;issues:Array<{route:string;field:string;severity:string;to:string}>}
|
||||
const sites=ref<Site[]>([]),siteId=ref(''),csrf=ref(''),tab=ref('qa'),qa=ref<QA|null>(null),jobs=ref<any[]>([]),members=ref<any[]>([]),logs=ref<any[]>([])
|
||||
const summaries=computed<Array<[string,number]>>(()=>[['旧站 URL',qa.value?.baseline_count||0],['新站 URL',qa.value?.current_count||0],['URL 缺失',qa.value?.errors||0],['SEO 差异',qa.value?.warnings||0]])
|
||||
async function request<T>(url:string,method='GET',body?:unknown):Promise<T>{const r=await fetch(url,{method,credentials:'same-origin',headers:{'Content-Type':'application/json',...(method!=='GET'?{'X-SiteForge-CSRF':csrf.value}:{})},body:body===undefined?undefined:JSON.stringify(body)});const json=await r.json();if(!r.ok)throw new Error(json.detail||'请求失败');return json as T}
|
||||
async function safe(action:()=>Promise<void>){try{await action()}catch(error:any){ElMessage.error(error.message||'操作失败')}}
|
||||
async function reload(){await safe(async()=>{sites.value=await request<Site[]>('/api/sites');if(!siteId.value&&sites.value.length)siteId.value=sites.value[0].id;await reloadSite()})}
|
||||
async function loadQA(){qa.value=await request<QA>(`/api/enterprise/sites/${siteId.value}/baseline/report`)}
|
||||
async function loadJobs(){jobs.value=await request<any[]>(`/api/enterprise/sites/${siteId.value}/jobs`)}
|
||||
async function reloadSite(){if(!siteId.value)return;await safe(async()=>{await Promise.all([loadQA(),loadJobs(),(async()=>{try{members.value=await request<any[]>(`/api/enterprise/sites/${siteId.value}/members`)}catch{members.value=[]}})(),(async()=>{try{logs.value=await request<any[]>(`/api/enterprise/audit?site_id=${siteId.value}`)}catch{logs.value=[]}})()])})}
|
||||
async function snapshot(){await safe(async()=>{await ElMessageBox.confirm('当前站点内容会覆盖之前的旧 URL 基线。推荐在迁移导入后、内容改版前操作。','确认基线');await request(`/api/enterprise/sites/${siteId.value}/baseline/snapshot`,'POST',{});ElMessage.success('URL 基线已保存');await loadQA()})}
|
||||
async function enqueue(){await safe(async()=>{await request(`/api/enterprise/sites/${siteId.value}/jobs`,'POST',{kind:'build'});ElMessage.success('任务已进入后台队列');await loadJobs()})}
|
||||
function downloadBaseline(){window.open(`/api/enterprise/sites/${siteId.value}/baseline/export.csv`,'_blank','noopener')}
|
||||
onMounted(()=>safe(async()=>{csrf.value=(await request<{csrf:string}>('/api/auth/me')).csrf;await reload()}))
|
||||
</script>
|
||||
<style scoped>
|
||||
.sf-enterprise{padding:8px 5px 30px}.intro{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.intro small{color:#6386b0;letter-spacing:2px;font-weight:800}.intro h1{font-size:28px;margin:7px 0}.intro p{color:#8994a4;margin:0}.panel{margin-bottom:18px}.main-tabs{border-radius:12px}.summary{margin:20px 0 24px}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="migration-center">
|
||||
<div class="heading"><h2>CMS 迁移与数据核对</h2><p>导入前预览,保留原 URL;复杂关系和 Elementor 布局不会被静默丢弃。</p></div>
|
||||
<el-card shadow="never"><el-form label-position="top">
|
||||
<el-row :gutter="20"><el-col :span="8"><el-form-item label="目标网站"><el-select v-model="siteId" style="width:100%"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="原站系统"><el-select v-model="platform" style="width:100%"><el-option label="WordPress REST JSON" value="wordpress-rest"/><el-option label="WooCommerce JSON" value="woocommerce"/><el-option label="Drupal JSON:API" value="drupal-jsonapi"/><el-option label="Shopify Products JSON" value="shopify"/></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="导入文件"><input type="file" accept="application/json,.json" @change="setFile" /></el-form-item></el-col></el-row>
|
||||
<el-space><el-button type="primary" :loading="loading" @click="preview">检查字段及差异</el-button><el-button :disabled="!report || report.errors.length>0" type="success" :loading="loading" @click="commit">确认导入</el-button></el-space>
|
||||
</el-form></el-card>
|
||||
<el-row v-if="report" :gutter="14" style="margin-top:15px"><el-col :span="8"><el-card>原记录:{{report.input}}</el-card></el-col><el-col :span="8"><el-card>可映射:{{report.mapped}}</el-card></el-col><el-col :span="8"><el-card>需人工检查:{{report.warnings.length}}</el-card></el-col></el-row>
|
||||
<el-card v-if="report" shadow="never" style="margin-top:16px"><template #header>数据预览与检查结果</template>
|
||||
<el-alert v-if="report.errors.length" type="error" :title="`存在 ${report.errors.length} 个错误,修复后才能导入`" :closable="false" />
|
||||
<el-table :data="report.preview" max-height="300"><el-table-column prop="title" label="标题"/><el-table-column prop="type" label="类型" width="110"/><el-table-column prop="route" label="原路径"/><el-table-column prop="seo_title" label="SEO 标题"/></el-table>
|
||||
<h4>字段与组件人工复核提示</h4><el-table :data="report.warnings" max-height="260"><el-table-column prop="row" label="记录" width="90"/><el-table-column prop="issue" label="问题" /></el-table>
|
||||
</el-card>
|
||||
<el-card shadow="never" style="margin-top:16px"><template #header><el-space>迁移人工复核 <el-tag type="warning">{{reviews.length}} 待处理</el-tag><el-button text @click="loadReviews">刷新</el-button></el-space></template>
|
||||
<el-table :data="reviews" max-height="320"><el-table-column prop="platform" label="来源" width="150"/><el-table-column prop="row_number" label="行" width="75"/><el-table-column prop="issue" label="待处理问题"/><el-table-column label="复核" width="130"><template #default="{row}"><el-button link type="primary" @click="resolve(row.id)">处理并确认</el-button></template></el-table-column></el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
interface Site{id:string;name:string}
|
||||
interface Report{input:number;mapped:number;errors:Array<{row:number;error:string}>;warnings:Array<{row:number;issue:string}>;preview:Array<{title:string;type:string;route:string;seo_title:string}>}
|
||||
const sites=ref<Site[]>([]),siteId=ref(''),platform=ref('wordpress-rest'),file=ref<File|null>(null),report=ref<Report|null>(null),loading=ref(false),csrf=ref(''),reviews=ref<Array<{id:string;issue:string;platform:string;row_number:number}>>([])
|
||||
function setFile(event:Event){file.value=(event.target as HTMLInputElement).files?.[0]||null;report.value=null}
|
||||
async function request(url:string,init:RequestInit={}){const r=await fetch(url,{...init,credentials:'same-origin'});const data=await r.json();if(!r.ok)throw Error(data.detail||'请求错误');return data}
|
||||
async function submit(mode:'preview'|'import'){if(!siteId.value||!file.value)return ElMessage.warning('请选网站和 JSON 文件');const body=new FormData();body.append('file',file.value);body.append('platform',platform.value);if(mode==='import')body.append('conflict','skip');loading.value=true;try{const data=await request(`/api/migrations/sites/${siteId.value}/${mode}`,{method:'POST',headers:{'X-SiteForge-CSRF':csrf.value},body});if(mode==='preview')report.value=data as Report;else{ElMessage.success(`导入完成:${data.result.imported} 条`);report.value=null;await loadReviews()}}catch(e:any){ElMessage.error(e.message)}finally{loading.value=false}}
|
||||
async function preview(){await submit('preview')}
|
||||
async function commit(){if(!report.value||report.value.errors.length)return;try{await ElMessageBox.confirm('导入后会写入网站数据库。复杂字段保留人工核对标记,是否继续?','确认导入');await submit('import')}catch{}}
|
||||
async function loadReviews(){if(!siteId.value)return;try{reviews.value=(await request(`/api/migrations/sites/${siteId.value}/reviews`)).items}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function resolve(id:string){try{const result=await ElMessageBox.prompt('请输入实际核对结果或修正措施,至少12字','迁移复核',{inputValidator:(v:string)=>v.trim().length>=12||'请说明具体处理情况'});await request(`/api/migrations/sites/${siteId.value}/reviews/${id}/resolve`,{method:'POST',headers:{'Content-Type':'application/json','X-SiteForge-CSRF':csrf.value},body:JSON.stringify({resolution:result.value})});ElMessage.success('已保存复核记录');await loadReviews()}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
onMounted(async()=>{try{csrf.value=(await request('/api/auth/me')).csrf;sites.value=await request('/api/sites');siteId.value=sites.value[0]?.id||'';await loadReviews()}catch(e:any){ElMessage.error(e.message)}})
|
||||
</script>
|
||||
<style scoped>.heading{margin-bottom:20px}.heading h2{margin:0;font-size:24px}.heading p{color:var(--el-text-color-secondary)}</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template><section class="release-center"><h2>构建与发布中心</h2><p>构建先通过迁移与质量验收,再提交独立审批;通过后才能部署到预先配置的 HTTPS 环境。</p>
|
||||
<el-card shadow="never"><el-space><el-select v-model="siteId" @change="reload" style="width:240px"><el-option v-for="s in sites" :label="s.name" :value="s.id" :key="s.id" /></el-select><el-button @click="reload" :loading="loading">刷新任务</el-button></el-space></el-card>
|
||||
<el-card shadow="never" style="margin-top:16px"><template #header>构建版本</template><el-table :data="builds" max-height="360"><el-table-column prop="id" label="版本ID" min-width="170"/><el-table-column prop="status" label="状态" width="110"/><el-table-column prop="total_pages" label="页面数" width="90"/><el-table-column prop="created_at" label="生成日期" width="180"/><el-table-column label="操作" min-width="260"><template #default="{row}"><el-button link @click="review(row.id)">申请审批</el-button><el-button link type="primary" @click="deploy(row.id,'staging')">发布测试环境</el-button><el-button link type="danger" @click="deploy(row.id,'production')">正式发布</el-button></template></el-table-column></el-table></el-card>
|
||||
<el-card shadow="never" style="margin-top:16px"><template #header>审批请求</template><el-table :data="requests"><el-table-column prop="build_id" label="构建ID"/><el-table-column prop="state" label="审批状态" width="150"/><el-table-column prop="requested_by" label="申请人"/><el-table-column label="操作"><template #default="{row}"><el-button v-if="row.state==='pending'" link @click="approve(row.id)">审核通过</el-button></template></el-table-column></el-table></el-card>
|
||||
</section></template>
|
||||
<script setup lang="ts">
|
||||
import {onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
interface Site{id:string;name:string}
|
||||
const sites=ref<Site[]>([]),siteId=ref(''),builds=ref<any[]>([]),requests=ref<any[]>([]),loading=ref(false),csrf=ref('')
|
||||
async function api(path:string,method='GET',body?:unknown){const res=await fetch(path,{method,headers:{...(method==='GET'?{}:{'Content-Type':'application/json','X-SiteForge-CSRF':csrf.value})},credentials:'same-origin',body:body===undefined?undefined:JSON.stringify(body)});const data=await res.json();if(!res.ok)throw Error(data.detail||'请求失败');return data}
|
||||
async function reload(){if(!siteId.value)return;loading.value=true;try{[builds.value,requests.value]=await Promise.all([api(`/api/sites/${siteId.value}/builds`),api(`/api/factory/sites/${siteId.value}/release-requests`)])}catch(e:any){ElMessage.error(e.message)}finally{loading.value=false}}
|
||||
async function review(id:string){try{const r=await api(`/api/factory/sites/${siteId.value}/release-requests`,'POST',{build_id:id});ElMessage.success('审批请求已建立:'+r.id);await reload()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function approve(id:string){try{await ElMessageBox.confirm('审批人不能是申请人,是否批准?','审核发布');await api(`/api/factory/sites/${siteId.value}/release-requests/${id}/review`,'POST',{state:'approved'});ElMessage.success('审批完成');await reload()}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
async function deploy(build_id:string,target:'staging'|'production'){try{await ElMessageBox.confirm(`将构建版本 ${build_id} 部署到 ${target},确认目标环境已配置 HTTPS?`,'发布确认');await api(`/api/production/sites/${siteId.value}/deploy`,'POST',{build_id,target});ElMessage.success('远程部署接收端已确认发布')}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
onMounted(async()=>{try{csrf.value=(await api('/api/auth/me')).csrf;sites.value=await api('/api/sites');siteId.value=sites.value[0]?.id||'';await reload()}catch(e:any){ElMessage.error(e.message)}})
|
||||
</script>
|
||||
<style scoped>.release-center h2{font-size:24px;margin:0 0 8px}.release-center p{color:var(--el-text-color-secondary);margin:0 0 25px;font-size:13px}</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<el-row justify="space-between" align="middle" style="margin-bottom:22px">
|
||||
<div><h1>网站主题中心</h1><p>版本固定、主题安装与内容重新装配</p></div>
|
||||
<el-upload :auto-upload="false" :limit="1" :show-file-list="false" accept=".zip" :on-change="install"><el-button type="primary">安装主题 ZIP</el-button></el-upload>
|
||||
</el-row>
|
||||
<el-card shadow="never" style="margin-bottom:20px"><el-form inline><el-form-item label="当前网站"><el-select v-model="siteId" filterable style="width:310px" @change="loadPinned"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id"/></el-select></el-form-item></el-form></el-card>
|
||||
<el-row :gutter="18"><el-col v-for="theme in themes" :key="theme.id||theme.theme_id" :xs="24" :sm="12" :lg="8" style="margin-bottom:20px"><el-card shadow="never"><div class="theme-cover" :class="theme.id||theme.theme_id"><span>WEBSITE<br>DESIGN<br>SYSTEM</span></div><h3>{{theme.name}}</h3><p class="secondary">{{theme.description}}</p><el-space><el-tag>v{{theme.version}}</el-tag><el-tag v-if="pinned.theme===(theme.id||theme.theme_id)" type="success">当前使用</el-tag></el-space><div class="buttons"><el-button type="primary" @click="apply(theme)">应用版本</el-button><el-button @click="versions(theme)">版本列表</el-button></div></el-card></el-col></el-row>
|
||||
<el-card shadow="never"><h3>主题自动装配</h3><p class="secondary">自动为旧内容应用当前主题预设,保留历史 URL、SEO、正文内容。</p><el-button @click="assemble('missing')">仅补齐缺失页面</el-button><el-button type="warning" plain @click="assemble('overwrite')">覆盖全部页面布局</el-button></el-card>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import type {UploadFile} from 'element-plus'
|
||||
interface Site{id:string;name:string}
|
||||
interface Theme{id?:string;theme_id?:string;name:string;description:string;version:string}
|
||||
const sites=ref<Site[]>([]),themes=ref<Theme[]>([]),siteId=ref(''),pinned=ref({theme:'',version:''}),csrf=ref('')
|
||||
async function request(url:string,method='GET',payload?:unknown,isFile=false){
|
||||
const headers=new Headers();if(method!=='GET')headers.set('X-SiteForge-CSRF',csrf.value);if(payload&&!isFile)headers.set('Content-Type','application/json')
|
||||
const r=await fetch(url,{method,headers,credentials:'same-origin',body:payload?(isFile?payload as FormData:JSON.stringify(payload)):undefined})
|
||||
const v=await r.json();if(!r.ok)throw new Error(v.detail||'请求失败');return v
|
||||
}
|
||||
async function load(){try{sites.value=await request('/api/sites');themes.value=await request('/api/factory/themes');if(!siteId.value)siteId.value=sites.value[0]?.id||'';await loadPinned()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function loadPinned(){if(siteId.value)pinned.value=await request(`/api/factory/sites/${siteId.value}/theme`)}
|
||||
async function apply(theme:Theme){if(!siteId.value)return;try{const id=theme.id||theme.theme_id;await request(`/api/factory/sites/${siteId.value}/theme`,'PUT',{theme:id,version:theme.version});ElMessage.success('已绑定主题版本');await loadPinned()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function versions(theme:Theme){try{let list=await request(`/api/factory/themes/${theme.id||theme.theme_id}/versions`);await ElMessageBox.alert(list.map((x:any)=>`${x.version} (${x.installed_at||'内置'})`).join('\n'),'主题版本',{confirmButtonText:'关闭'})}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function install(file:UploadFile){try{const form=new FormData();if(!file.raw)throw Error('未选择 ZIP');form.append('file',file.raw);let r=await request('/api/factory/themes/install','POST',form,true);ElMessage.success(`已安装 ${r.name} v${r.version}`);await load()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function assemble(mode:string){if(!siteId.value)return;try{if(mode==='overwrite')await ElMessageBox.confirm('将覆盖所有已有页面蓝图,确定继续?','风险确认');let r=await request(`/api/sites/${siteId.value}/apply-theme`,'POST',{mode});ElMessage.success(`已装配 ${r.updated} 个页面`)}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
onMounted(async()=>{try{csrf.value=(await request('/api/auth/me')).csrf;await load()}catch(e:any){ElMessage.error(e.message)}})
|
||||
</script>
|
||||
<style scoped>
|
||||
h1{margin:0 0 6px;font-size:26px}p.secondary,.page-content>p{font-size:13px;color:var(--el-text-color-secondary);line-height:1.7}.theme-cover{height:174px;border-radius:7px;background:#e6eafa;padding:25px;margin-bottom:16px;color:#3c4591}.theme-cover span{font-size:25px;line-height:1;letter-spacing:-1px;font-weight:900}.theme-cover.automotive{background:#172940;color:#e4c69a}.theme-cover.industrial{background:#e3edf6;color:#1c6292}.theme-cover.minimal{background:#eeeae3;color:#3b3b36}.buttons{margin-top:20px}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="factory-v8">
|
||||
<el-card shadow="never" class="title-card">
|
||||
<div class="head"><div><h2>SiteForge V8 · 网站生产控制台</h2><p>批量内容导入、WordPress WXR、整站模板复用、构建任务</p></div>
|
||||
<el-select v-model="siteId" placeholder="选择网站" style="width:300px" @change="reload">
|
||||
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id"/>
|
||||
</el-select></div>
|
||||
</el-card>
|
||||
<el-row :gutter="18">
|
||||
<el-col :xs="24" :lg="12"><el-card shadow="never">
|
||||
<template #header><b>数据导入批次</b></template>
|
||||
<el-alert type="info" title="数据先暂存,检查 URL 冲突后再提交;回滚不会覆盖后续人工修改。" :closable="false"/>
|
||||
<el-form label-position="top" style="margin-top:18px">
|
||||
<el-form-item label="文件来源"><el-select v-model="source" style="width:100%"><el-option label="Excel / CSV / JSON" value="file"/><el-option label="WordPress WXR" value="wxr"/></el-select></el-form-item>
|
||||
<el-form-item label="内容类型"><el-select v-model="kind" style="width:100%" :disabled="source==='wxr'"><el-option label="产品" value="product"/><el-option label="车型" value="vehicle"/><el-option label="文章" value="post"/><el-option label="页面" value="page"/></el-select></el-form-item>
|
||||
<el-form-item label="上传文件"><input type="file" :accept="source==='wxr'?'.xml':'.csv,.xlsx,.json'" @change="selectFile"/></el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" :disabled="!siteId||!file" :loading="loading" @click="stageFile">预览并暂存</el-button>
|
||||
<pre class="log">{{ result }}</pre>
|
||||
<el-table :data="batches" size="small" style="margin-top:18px"><el-table-column prop="origin" label="来源" min-width="110"/><el-table-column prop="input_count" label="条数" width="65"/><el-table-column prop="state" label="状态" width="95"/>
|
||||
<el-table-column label="操作" width="145"><template #default="{row}"><el-button v-if="row.state==='staged'" link type="primary" @click="commit(row.id)">提交</el-button><el-button v-if="['committed','partial'].includes(row.state)" link type="danger" @click="rollback(row.id)">回滚</el-button></template></el-table-column></el-table>
|
||||
</el-card></el-col>
|
||||
<el-col :xs="24" :lg="12"><el-card shadow="never"><template #header><b>整站模板</b></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="保存当前站点为模板"><el-input v-model="name" placeholder="例如汽车企业模板"/></el-form-item>
|
||||
<el-button type="primary" :disabled="!siteId" @click="snapshot">保存模板</el-button>
|
||||
<el-divider />
|
||||
<el-form-item label="来源网站"><el-select v-model="sourceSite" style="width:100%" @change="loadTemplates"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id"/></el-select></el-form-item>
|
||||
<el-form-item label="模板版本"><el-select v-model="templateId" style="width:100%"><el-option v-for="t in templates" :key="t.id" :label="t.name+' · '+t.version" :value="t.id"/></el-select></el-form-item>
|
||||
<el-button :disabled="!templateId" @click="apply(false)">兼容性预检</el-button>
|
||||
<el-button type="primary" :disabled="!templateId" @click="apply(true)">确认套用版式</el-button>
|
||||
</el-form>
|
||||
<pre class="log">{{ templateLog }}</pre>
|
||||
<el-divider/><el-button type="success" :disabled="!siteId" @click="build">提交 Worker 静态构建</el-button>
|
||||
</el-card></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
interface Site{id:string;name:string}
|
||||
interface Batch{id:string;state:string;origin:string;input_count:number}
|
||||
interface Template{id:string;name:string;version:string}
|
||||
const sites=ref<Site[]>([]),siteId=ref(''),sourceSite=ref(''),templateId=ref(''),templates=ref<Template[]>([]),batches=ref<Batch[]>([])
|
||||
const source=ref<'file'|'wxr'>('file'),kind=ref('product'),file=ref<File|null>(null),loading=ref(false),name=ref('新整站模板')
|
||||
const result=ref(''),templateLog=ref(''),csrf=ref('')
|
||||
async function api<T>(url:string,init:RequestInit={}):Promise<T>{
|
||||
const headers=new Headers(init.headers);if((init.method||'GET').toUpperCase()!=='GET')headers.set('X-SiteForge-CSRF',csrf.value)
|
||||
const res=await fetch(url,{...init,headers,credentials:'same-origin'});const out=await res.json();if(!res.ok)throw Error(typeof out.detail==='string'?out.detail:JSON.stringify(out.detail||out));return out as T
|
||||
}
|
||||
const post=(data:unknown):RequestInit=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})
|
||||
function selectFile(e:Event){file.value=(e.target as HTMLInputElement).files?.[0]||null}
|
||||
async function reload(){if(!siteId.value)return;const out=await api<{items:Batch[]}>(`/api/factory/sites/${siteId.value}/staging`);batches.value=out.items}
|
||||
async function loadTemplates(){if(!sourceSite.value)return;templates.value=await api<Template[]>(`/api/factory/sites/${sourceSite.value}/whole-site-templates`);templateId.value=templates.value[0]?.id||''}
|
||||
async function stageFile(){if(!file.value||!siteId.value)return;loading.value=true;try{const form=new FormData();form.append('file',file.value);if(source.value==='file')form.append('content_type',kind.value)
|
||||
const path=`/api/factory/sites/${siteId.value}/staging/${source.value}`
|
||||
result.value=JSON.stringify(await api(path,{method:'POST',body:form}),null,2);await reload()
|
||||
}catch(e:any){ElMessage.error(e.message)}finally{loading.value=false}}
|
||||
async function commit(id:string){try{await ElMessageBox.confirm('提交后将写入本站内容。已有 URL 默认跳过。','确认导入');result.value=JSON.stringify(await api(`/api/factory/sites/${siteId.value}/staging/${id}/commit`,post({on_conflict:'skip'})),null,2);await reload()}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
async function rollback(id:string){try{await ElMessageBox.confirm('回滚不会覆盖导入后人工修改的内容。','确认回滚');result.value=JSON.stringify(await api(`/api/factory/sites/${siteId.value}/staging/${id}/rollback`,post({})),null,2);await reload()}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
async function snapshot(){try{templateLog.value=JSON.stringify(await api(`/api/factory/sites/${siteId.value}/whole-site-templates`,post({name:name.value})),null,2);await loadTemplates()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function apply(confirm:boolean){try{if(confirm)await ElMessageBox.confirm('现有同路径页面会更新布局,正文和 SEO 不变。','确认套用');templateLog.value=JSON.stringify(await api(`/api/factory/sites/${siteId.value}/whole-site-templates/apply`,post({source_site_id:sourceSite.value,template_id:templateId.value,confirm,overwrite_layouts:confirm})),null,2)}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
async function build(){try{const task=await api<{id:string}>(`/api/sites/${siteId.value}/builds`,post({mirror_images:false}));ElMessage.success('任务已排队:'+task.id)}catch(e:any){ElMessage.error(e.message)}}
|
||||
onMounted(async()=>{try{csrf.value=(await api<{csrf:string}>('/api/auth/me')).csrf;sites.value=await api<Site[]>('/api/sites');siteId.value=sites.value[0]?.id||'';sourceSite.value=siteId.value;await reload();await loadTemplates()}catch(e:any){ElMessage.error(e.message)}})
|
||||
</script>
|
||||
<style scoped>.factory-v8{padding:12px}.title-card{margin-bottom:20px}.head{display:flex;align-items:center;justify-content:space-between;gap:15px}.head h2{margin:0 0 7px;font-size:23px}.head p{margin:0;color:#81909e;font-size:13px}.log{white-space:pre-wrap;max-height:250px;overflow:auto;background:var(--el-fill-color-light);padding:12px;border-radius:7px;font-size:12px;line-height:1.5}@media(max-width:760px){.head{flex-direction:column;align-items:stretch}}</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<section class="siteforge-editor">
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<div><h2>可视化网站编辑器</h2><p>在统一管理后台内编辑,支持多列布局、图片替换、撤销/重做、设备预览和全局设计规范。</p></div>
|
||||
<div class="actions"><el-select v-model="siteId" placeholder="选择网站" filterable @change="refreshPages" style="width:210px"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
||||
<el-select v-model="entryId" placeholder="选择页面" filterable style="width:220px"><el-option v-for="p in pages" :key="p.id" :label="p.title+' · '+p.route" :value="p.id" /></el-select>
|
||||
<el-button type="primary" :disabled="!entryId" @click="loadEditor">打开页面</el-button></div>
|
||||
</div>
|
||||
</el-card>
|
||||
<div class="editor-viewport" v-if="url"><iframe :key="url" :src="url" title="SiteForge Visual Studio" /></div>
|
||||
<el-empty v-else description="选择网站和页面,打开可视化编辑器" />
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {ElMessage} from 'element-plus'
|
||||
interface Site{id:string;name:string}
|
||||
interface Page{id:string;title:string;route:string}
|
||||
const sites=ref<Site[]>([]),pages=ref<Page[]>([]),siteId=ref(''),entryId=ref(''),url=ref('')
|
||||
async function get<T>(path:string):Promise<T>{const r=await fetch(path,{credentials:'same-origin'});if(!r.ok)throw Error((await r.json()).detail||'请求失败');return r.json()}
|
||||
async function refreshPages(){url.value='';pages.value=[];entryId.value='';if(!siteId.value)return;try{pages.value=await get(`/api/sites/${siteId.value}/entries`);entryId.value=pages.value.find(p=>p.route==='/')?.id||pages.value[0]?.id||''}catch(e:any){ElMessage.error(e.message)}}
|
||||
function loadEditor(){if(entryId.value)url.value=`/editor/${siteId.value}/${entryId.value}`}
|
||||
onMounted(async()=>{try{sites.value=await get('/api/sites');if(sites.value.length){siteId.value=sites.value[0].id;await refreshPages()}}catch(e:any){ElMessage.error(e.message)}})
|
||||
</script>
|
||||
<style scoped>
|
||||
.siteforge-editor{display:flex;flex-direction:column;gap:12px;height:calc(100vh - 140px);min-height:680px}
|
||||
.toolbar{display:flex;justify-content:space-between;gap:14px;align-items:center}
|
||||
h2{margin:0;font-size:22px}p{margin:7px 0 0;color:var(--el-text-color-secondary);font-size:13px}.actions{display:flex;gap:10px;align-items:center}
|
||||
.editor-viewport{flex:1;min-height:540px;border:1px solid var(--el-border-color);border-radius:12px;overflow:hidden;background:#fff}
|
||||
.editor-viewport iframe{width:100%;height:100%;border:0}
|
||||
@media(max-width:1020px){.toolbar,.actions{flex-wrap:wrap}.siteforge-editor{height:calc(100vh - 80px)}}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="sf-art-page">
|
||||
<div class="sf-art-intro">
|
||||
<div><div class="sf-overline">SITEFORGE / WEBSITE FACTORY</div><h1>网站生产工厂</h1><p>多站点管理 · 旧站迁移 · 主题装配 · 可视化页面设计 · 静态发布</p></div>
|
||||
<el-button type="primary" size="large" @click="createVisible=true"><el-icon><Plus /></el-icon> 创建新网站</el-button>
|
||||
</div>
|
||||
<el-row :gutter="18" class="metric-line">
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">网站数量</div><strong>{{ sites.length }}</strong></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">站点内容</div><strong>{{ sites.reduce((sum, s)=>sum+s.entries_count,0) }}</strong></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">构建版本</div><strong>{{ sites.reduce((sum, s)=>sum+s.build_count,0) }}</strong></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">行业主题</div><strong>{{ themes.length }}</strong></el-card></el-col>
|
||||
</el-row>
|
||||
<el-card shadow="never" class="listing-card">
|
||||
<template #header><div class="table-head"><div><h3>我的网站</h3><p>可选择站点继续编辑、批量套版或进行静态构建</p></div><el-input v-model="filter" clearable placeholder="搜索网站名称" style="width:240px" /></div></template>
|
||||
<el-table :data="visibleSites" v-loading="loading" size="large" stripe>
|
||||
<el-table-column label="网站项目" min-width="220"><template #default="{ row }"><div class="site-name"><span class="site-mark">{{ row.name[0] }}</span><div><b>{{ row.name }}</b><small>{{ row.domain || '尚未绑定域名' }}</small></div></div></template></el-table-column>
|
||||
<el-table-column label="主题" width="135"><template #default="{ row }"><el-tag effect="plain">{{ themeTitle(row.theme) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="entries_count" label="内容" width="85" />
|
||||
<el-table-column prop="build_count" label="版本" width="85" />
|
||||
<el-table-column prop="updated_at" label="更新日期" min-width="180" />
|
||||
<el-table-column label="操作" width="350" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="editSite(row)">管理站点</el-button><el-button link @click="openStudio(row)">设计首页</el-button><el-button link @click="applyPreset(row)">内容套版</el-button><el-button link type="success" @click="build(row)">构建</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-dialog v-model="createVisible" title="创建网站项目" width="540px" destroy-on-close>
|
||||
<el-form :model="draft" label-position="top">
|
||||
<el-form-item label="网站名称"><el-input v-model="draft.name" placeholder="例如:HESU Global Auto" /></el-form-item>
|
||||
<el-form-item label="网站地址"><el-input v-model="draft.domain" placeholder="https://example.com" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="draft.tagline" placeholder="品牌服务介绍" /></el-form-item>
|
||||
<el-form-item label="行业主题"><el-select v-model="draft.theme" style="width:100%"><el-option v-for="t in themes" :key="t.id" :label="t.name" :value="t.id" /></el-select></el-form-item>
|
||||
<el-form-item label="建站模式"><el-radio-group v-model="draft.mode"><el-radio value="new">新建网站</el-radio><el-radio value="migration">旧站迁移</el-radio><el-radio value="hybrid">混合建站</el-radio></el-radio-group></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="createVisible=false">取消</el-button><el-button type="primary" :loading="loading" @click="create">创建网站</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="siteVisible" :title="active?.name || '网站管理'" width="680px">
|
||||
<el-tabs v-model="siteTab"><el-tab-pane label="基本信息" name="info"><p>主题:{{ themeTitle(active?.theme) }} · {{ active?.domain }}</p><p>{{ active?.tagline }}</p></el-tab-pane><el-tab-pane label="内容管理" name="content"><el-button @click="nativeConsole(active)">打开内容编辑工作台</el-button><p class="hint">支持页面、产品、文章、SEO及 WordPress/CSV/XLSX 数据导入。</p></el-tab-pane><el-tab-pane label="主题与编辑" name="design"><el-button type="primary" @click="openStudio(active)">进入 SiteForge Visual Studio</el-button></el-tab-pane><el-tab-pane label="静态发布" name="build"><el-button type="success" @click="build(active)">生成静态 HTML</el-button><p class="hint">构建后可以在 SiteForge Web 控制台预览、下载 ZIP 或发布到持久化目录。</p></el-tab-pane></el-tabs>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {Plus} from '@element-plus/icons-vue'
|
||||
interface Site {id:string;name:string;domain:string;tagline:string;theme:string;entries_count:number;build_count:number;updated_at:string}
|
||||
interface Theme {id:string;name:string}
|
||||
const sites=ref<Site[]>([]),themes=ref<Theme[]>([]),filter=ref(''),loading=ref(false),createVisible=ref(false),siteVisible=ref(false)
|
||||
const active=ref<Site|null>(null),siteTab=ref('info'),csrf=ref('')
|
||||
const draft=ref({name:'',domain:'',tagline:'',theme:'corporate',mode:'new'})
|
||||
const visibleSites=computed(()=>sites.value.filter(s=>s.name.toLowerCase().includes(filter.value.trim().toLowerCase())))
|
||||
const themeTitle=(key?:string)=>themes.value.find(t=>t.id===key)?.name||key||'—'
|
||||
async function api<T>(path:string,init:RequestInit={}){
|
||||
const method=(init.method||'GET').toUpperCase(),headers=new Headers(init.headers)
|
||||
if(!['GET','HEAD'].includes(method))headers.set('X-SiteForge-CSRF',csrf.value)
|
||||
const res=await fetch(path,{...init,headers,credentials:'same-origin'})
|
||||
if(res.status===401)throw Error('请先通过 SiteForge 登录')
|
||||
const json=await res.json()
|
||||
if(!res.ok)throw Error(json.detail||'请求失败')
|
||||
return json as T
|
||||
}
|
||||
const post=(data:unknown):RequestInit=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})
|
||||
async function reload(){loading.value=true;try{const [list,ts]=await Promise.all([api<Site[]>('/api/sites'),api<Theme[]>('/api/themes')]);sites.value=list;themes.value=ts}catch(e:any){ElMessage.error(e.message)}finally{loading.value=false}}
|
||||
onMounted(async()=>{try{csrf.value=(await api<{csrf:string}>('/api/auth/me')).csrf;await reload()}catch(e:any){ElMessage.error(e.message)}})
|
||||
async function create(){if(!draft.value.name.trim())return ElMessage.warning('请输入网站名称');loading.value=true;try{await api('/api/sites',post(draft.value));createVisible.value=false;await reload();ElMessage.success('网站创建成功')}catch(e:any){ElMessage.error(e.message)}finally{loading.value=false}}
|
||||
function editSite(s:Site){active.value=s;siteTab.value='info';siteVisible.value=true}
|
||||
function nativeConsole(s:Site|null){if(s)window.open('/','_blank','noopener')}
|
||||
async function openStudio(s:Site|null){if(!s)return;try{const entries=await api<Array<{id:string;route:string}>>(`/api/sites/${s.id}/entries`);const home=entries.find(x=>x.route==='/')||entries[0];if(!home)return ElMessage.warning('网站暂无可编辑的页面');window.open(`/editor/${s.id}/${home.id}`,'_blank','noopener')}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function applyPreset(s:Site){try{await ElMessageBox.confirm('自动给没有布局的页面装配新主题,原 URL、正文和 SEO 不变。继续吗?','批量内容套版');const r=await api<{updated:number}>(`/api/sites/${s.id}/apply-theme`,post({mode:'missing'}));ElMessage.success(`已完成 ${r.updated} 页布局装配`)}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
async function build(s:Site|null){
|
||||
if(!s)return
|
||||
try{
|
||||
const task=await api<{id:string;state:string}>(`/api/sites/${s.id}/builds`,post({mirror_images:false}))
|
||||
ElMessage.info('构建任务已加入 Worker 队列,正在执行')
|
||||
for(let attempts=0;attempts<240;attempts++){
|
||||
await new Promise(resolve=>setTimeout(resolve,1500))
|
||||
const job=await api<{state:string;error:string;result_json:string}>(`/api/enterprise/sites/${s.id}/jobs/${task.id}`)
|
||||
if(job.state==='done'){
|
||||
const result=JSON.parse(job.result_json||'{}')
|
||||
ElMessage.success(`构建成功:${result.pages||0} 个静态页面`)
|
||||
if(result.version)window.open(`/preview/${s.id}/${result.version}/`,'_blank','noopener')
|
||||
await reload();return
|
||||
}
|
||||
if(['failed','cancelled'].includes(job.state))throw new Error(job.error||job.state)
|
||||
}
|
||||
ElMessage.warning('任务仍在运行,可以在构建中心查询进度')
|
||||
}catch(e:any){ElMessage.error(e.message)}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.sf-art-page{padding:4px 4px 30px}.sf-art-intro{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:27px}.sf-art-intro h1{font-size:30px;color:var(--el-text-color-primary);margin:5px 0 7px}.sf-art-intro p,.table-head p{color:var(--el-text-color-secondary);font-size:13px;margin:0}.sf-overline{font-size:10px;letter-spacing:2px;font-weight:800;color:#5884d4}.metric-line{margin-bottom:22px}.metric-label{font-size:12px;color:var(--el-text-color-secondary);margin-bottom:10px}.metric-line strong{font-size:32px;line-height:1.2;display:block}.table-head{display:flex;justify-content:space-between;align-items:center}.table-head h3{margin:0 0 5px}.site-name{display:flex;align-items:center;gap:11px}.site-mark{background:#eaf1ff;color:#4b73ca;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;font-size:16px;font-weight:800}.site-name b{display:block;font-size:13px}.site-name small{display:block;font-size:11px;color:#98a4b6;margin-top:3px}.hint{font-size:12px;color:#8997a8;line-height:1.8}
|
||||
</style>
|
||||
Reference in New Issue
Block a user