chore: import SiteForge Factory V5 source (baseline)

导入 SiteForge Factory V5 基线源码:
- FastAPI + SQLite + Docker Compose(Web + Worker)架构
- 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照)
- 四种建站模式骨架 + 迁移审计 + 发布门禁
- 52 项 pytest 测试
- 补全计划主文档 docs/superpowers/plans/

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 10:42:19 +08:00
commit 3c57c167f2
264 changed files with 11682 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Art Design Pro 官方工程对接(可执行安装器)
SiteForge V2 随包提供三个 Vue 3 + Element Plus 业务页面:`WebsiteFactory.vue`、`ThemeCenter.vue`、`EnterpriseCenter.vue`。
> **重要:** 由于本次代码执行环境无法访问 GitHub 与 npm,无法直接打包或编译 Art Design Pro 官方完整仓库。以下对接脚本已实现源码复制和路由注册逻辑,但尚未针对官方实际项目执行 `pnpm build`。默认可运行的 `/factory-studio` Web 管理台不依赖这些文件。
## 在联网设备对接
1. 依官方文档克隆 `https://github.com/Daymychen/art-design-pro`,使用符合要求的 Node 版本和 pnpm。
2. 在 SiteForge 根目录执行:
```bash
python scripts/install_into_art_design_pro.py /path/to/art-design-pro
python scripts/install_into_art_design_pro.py /path/to/art-design-pro --apply
```
3. 使用同域反向代理:
- Art 前端 `/` 为网站工厂管理 UI
- `/api` → SiteForge FastAPI
- `/editor`、`/preview`、`/ui`、`/local-media`、`/login`、`/factory-studio` → SiteForge FastAPI
4. 在 Art 工程目录执行 `pnpm install && pnpm build`,访问 `/siteforge/factory`、`/siteforge/themes`、`/siteforge/enterprise`。
5. SiteForge API 仍使用自己的服务端 Session、CSRF、站点级 RBAC;前端的隐藏菜单不构成授权。`/api/auth/me` 必须在同一 Cookie 域名可访问。正式发布需统一登录体验,并补齐 Art 当前版本的路由/权限约定。
安装脚本不会覆盖已有配置而不备份:原 `asyncRoutes.ts` 会先保存 `.ts.siteforge-backup`,同名 Vue 文件另存 `.vue.siteforge-backup`。重复执行不会重复插入路由。
官方路由结构和本项目的适配规则以 [Art Design Pro 官方路由文档](https://www.artd.pro/docs/zh/guide/essentials/route.html) 为准。
@@ -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,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,71 @@
<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 r=await api<{version:string;pages:number}>(`/api/sites/${s.id}/builds`,post({mirror_images:false}));ElMessage.success(`构建成功:${r.pages} 个静态页面`);window.open(`/preview/${s.id}/${r.version}/`,'_blank','noopener');await reload()}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>