upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.
New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.
compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).
Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="sf-home page-content">
|
||||
<header class="intro"><div><div class="eyebrow">SITEFORGE / CONTROL CENTER</div><h1>网站管理工作台</h1><p>统一管理网站、内容、设计、迁移与交付。功能权限以 SiteForge 服务端为准。</p></div><el-button @click="refresh" :loading="loading">刷新状态</el-button></header>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="notice" />
|
||||
<el-row :gutter="14" v-if="data">
|
||||
<el-col :xs="24" :sm="8"><el-card shadow="never"><small>可管理网站</small><b>{{ data.sites.length }}</b></el-card></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-card shadow="never"><small>当前角色</small><b>{{ data.capabilities.role }}</b></el-card></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-card shadow="never"><small>平台版本</small><b class="version">{{ data.version }}</b></el-card></el-col>
|
||||
</el-row>
|
||||
<el-card class="primary" shadow="never" v-if="data">
|
||||
<template #header><div class="head"><strong>当前网站</strong><el-tag type="info">{{ data.user }}</el-tag></div></template>
|
||||
<el-select v-model="active" filterable placeholder="选择网站" style="width:min(100%,440px)" @change="siteChanged"><el-option v-for="s in data.sites" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
||||
<p class="hint">切换网站后其他 SiteForge 页面将记住这次选择。权限在后端再次检查。</p>
|
||||
<el-empty v-if="!data.sites.length" description="暂无有权访问的网站,请联系管理员分配站点权限" />
|
||||
</el-card>
|
||||
<h2>开始工作</h2>
|
||||
<div class="actions"><el-card v-for="item in data?.navigation||[]" :key="item.key" shadow="hover" class="action" @click="open(item.path)"><strong>{{ item.title }}</strong><p>{{ descriptions[item.key]||'查看当前模块' }}</p><span>进入模块 →</span></el-card></div>
|
||||
<el-alert class="notice" type="info" show-icon :closable="false" title="请先在预生产环境验证构建质量、权限与发布审批;管理后台不会绕过生产发布门禁。" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {onMounted,ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {bootstrap,rememberSite,type SiteForgeBootstrap} from './_shared'
|
||||
const router=useRouter(),loading=ref(false),error=ref(''),data=ref<SiteForgeBootstrap|null>(null),active=ref('')
|
||||
const descriptions:Record<string,string>={dashboard:'查看工作状态与最近网站',sites:'创建、选择与管理网站',content:'管理产品、文章、页面与 SEO',design:'编辑页面布局与响应式设计',migration:'导入旧站并复核数据',release:'构建、审批、发布与回滚',system:'团队权限与系统管理'}
|
||||
async function refresh(){loading.value=true;error.value='';try{data.value=await bootstrap();active.value=data.value.selected_site_id||''}catch(e:any){error.value=e?.message||'初始化失败'}finally{loading.value=false}}
|
||||
function siteChanged(id:string){rememberSite(id);refresh()}
|
||||
function open(path:string){router.push(path)}
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
<style scoped>
|
||||
.sf-home{padding:8px 5px 28px}.intro,.head{display:flex;justify-content:space-between;align-items:center;gap:14px}.intro h1{font-size:28px;margin:6px 0}.intro p,.hint,.actions p{color:var(--el-text-color-secondary);font-size:13px}.eyebrow{letter-spacing:1.5px;color:var(--el-color-primary);font-size:11px}.el-card b{display:block;font-size:27px;margin-top:10px}.el-card b.version{font-size:20px}.primary{margin:18px 0}.notice{margin-top:20px}.actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.action{cursor:pointer}.action strong{font-size:16px}.action span{color:var(--el-color-primary);font-size:12px}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="sf-content page-content">
|
||||
<header class="heading"><div><h1>内容管理</h1><p>统一维护页面、产品、车型、新闻与 SEO;保留已有 URL。</p></div><el-button type="primary" :disabled="!canEdit" @click="openCreate">新增内容</el-button></header>
|
||||
<el-card shadow="never">
|
||||
<div class="filters"><el-select v-model="siteId" style="width:245px" filterable placeholder="选择网站" @change="siteChanged"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" /></el-select><el-select v-model="kind" style="width:155px" @change="load" placeholder="全部类型" clearable><el-option v-for="t in types" :key="t.value" :label="t.label" :value="t.value" /></el-select><el-input v-model="query" placeholder="搜索标题或 URL" clearable style="max-width:280px" @keyup.enter="load"/><el-button :loading="loading" @click="load">查询</el-button></div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" class="alert" />
|
||||
<el-table :data="records" v-loading="loading" row-key="id" style="margin-top:15px" max-height="560"><el-table-column prop="title" label="内容标题" min-width="190" show-overflow-tooltip/><el-table-column prop="type" label="类型" width="105"/><el-table-column prop="route" label="原始 URL 路径" min-width="200" show-overflow-tooltip/><el-table-column prop="status" label="状态" width="110"/><el-table-column label="操作" width="210" fixed="right"><template #default="{row}"><el-button link type="primary" :disabled="!canEdit" @click="edit(row)">编辑</el-button><el-button link @click="openDesigner(row)">设计页面</el-button><el-button link type="danger" :disabled="!canEdit||row.route==='/'" @click="remove(row)">删除</el-button></template></el-table-column></el-table>
|
||||
<el-empty v-if="!records.length&&!loading" description="暂无内容,可以新增或从旧站导入" />
|
||||
</el-card>
|
||||
<el-dialog v-model="showDialog" :title="draft.id?'编辑内容':'新增内容'" width="min(720px,95vw)" destroy-on-close>
|
||||
<el-form label-position="top"><el-row :gutter="15"><el-col :span="12"><el-form-item label="内容类型"><el-select v-model="draft.type"><el-option v-for="t in types" :key="t.value" :label="t.label" :value="t.value"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="发布状态"><el-select v-model="draft.status"><el-option label="草稿" value="draft"/><el-option label="可参与构建" value="published"/></el-select></el-form-item></el-col></el-row>
|
||||
<el-form-item label="标题(必填)"><el-input v-model="draft.title" maxlength="250" show-word-limit /></el-form-item>
|
||||
<el-form-item label="页面地址(迁移网站请保留原路径)"><el-input v-model="draft.route" placeholder="/products/example/" /></el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="draft.summary" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="正文 HTML"><el-input v-model="draft.body" type="textarea" :rows="5" /></el-form-item>
|
||||
<el-collapse><el-collapse-item title="SEO 进阶设置" name="seo"><el-form-item label="SEO Title"><el-input v-model="draft.seo_title"/></el-form-item><el-form-item label="Meta Description"><el-input v-model="draft.seo_description" type="textarea" :rows="2"/></el-form-item></el-collapse-item></el-collapse></el-form>
|
||||
<template #footer><el-button @click="showDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存内容</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {sfApi,bootstrap,preferredSite,rememberSite,postJson,patchJson,type SiteRef,type SiteForgeBootstrap} from './_shared'
|
||||
interface RecordItem {id:string; title:string;type:string;route:string;status:string;summary:string;body:string;seo_title:string;seo_description:string}
|
||||
const types=[{value:'page',label:'页面'},{value:'product',label:'产品'},{value:'vehicle',label:'车型'},{value:'post',label:'新闻'},{value:'brand',label:'品牌'},{value:'case',label:'案例'},{value:'service',label:'服务'}]
|
||||
const sites=ref<SiteRef[]>([]),siteId=ref(''),ctx=ref<SiteForgeBootstrap|null>(null),records=ref<RecordItem[]>([]),kind=ref(''),query=ref(''),loading=ref(false),saving=ref(false),error=ref(''),showDialog=ref(false)
|
||||
const empty=()=>({id:'',type:'page',title:'',route:'',status:'draft',summary:'',body:'',seo_title:'',seo_description:''})
|
||||
const draft=ref(empty())
|
||||
const canEdit=computed(()=>Boolean(ctx.value?.capabilities.edit))
|
||||
async function siteChanged(id:string){rememberSite(id);ctx.value=await bootstrap();await load()}
|
||||
async function load(){if(!siteId.value)return;loading.value=true;error.value='';try{const p=new URLSearchParams();if(kind.value)p.set('typ',kind.value);if(query.value.trim())p.set('q',query.value.trim());records.value=await sfApi<RecordItem[]>(`/api/sites/${encodeURIComponent(siteId.value)}/entries?${p}`)}catch(e:any){error.value=e.message}finally{loading.value=false}}
|
||||
function openCreate(){if(!canEdit.value)return;draft.value=empty();showDialog.value=true}
|
||||
function edit(row:RecordItem){if(!canEdit.value)return;draft.value={...row};showDialog.value=true}
|
||||
async function save(){if(!draft.value.title.trim())return ElMessage.warning('请输入内容标题');if(!canEdit.value)return; saving.value=true;try{const {id,...body}=draft.value;const endpoint=`/api/sites/${encodeURIComponent(siteId.value)}/entries${id?'/'+encodeURIComponent(id):''}`;await sfApi(endpoint,id?patchJson(body):postJson(body));showDialog.value=false;ElMessage.success('保存成功');await load()}catch(e:any){ElMessage.error(e.message)}finally{saving.value=false}}
|
||||
async function remove(row:RecordItem){try{await ElMessageBox.confirm(`确认删除“${row.title}”?此操作不能通过编辑器撤销。`,'删除内容',{type:'warning'});await sfApi(`/api/sites/${encodeURIComponent(siteId.value)}/entries/${encodeURIComponent(row.id)}`,{method:'DELETE'});await load()}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
function openDesigner(row:RecordItem){window.open(`/editor/${encodeURIComponent(siteId.value)}/${encodeURIComponent(row.id)}`,'_blank','noopener')}
|
||||
onMounted(async()=>{try{ctx.value=await bootstrap();sites.value=ctx.value.sites;siteId.value=preferredSite(sites.value);await siteChanged(siteId.value)}catch(e:any){error.value=e.message}})
|
||||
</script>
|
||||
<style scoped>.sf-content{padding:8px 5px 28px}.heading{display:flex;justify-content:space-between;align-items:center;gap:15px;margin-bottom:20px}.heading h1{font-size:26px;margin:0}.heading p{font-size:13px;color:var(--el-text-color-secondary)}.filters{display:flex;gap:10px;flex-wrap:wrap;align-items:center}.alert{margin-top:12px}@media(max-width:730px){.heading{align-items:flex-start}.filters>*{max-width:100%!important;width:100%!important}}</style>
|
||||
@@ -11,21 +11,23 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue'
|
||||
import {watch,computed,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {preferredSite,rememberSite,sfApi} from './_shared'
|
||||
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 request<T>(url:string,method='GET',body?:unknown):Promise<T>{return sfApi<T>(url,{method,headers:body===undefined?{}:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)})}
|
||||
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 reload(){await safe(async()=>{sites.value=await request<Site[]>('/api/sites');if(!siteId.value&&sites.value.length)siteId.value=preferredSite(sites.value);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')}
|
||||
watch(siteId,(id)=>rememberSite(id))
|
||||
onMounted(()=>safe(async()=>{csrf.value=(await request<{csrf:string}>('/api/auth/me')).csrf;await reload()}))
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
@@ -19,18 +19,20 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {watch,ref,onMounted} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {preferredSite,rememberSite,sfApi} from './_shared'
|
||||
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 request(url:string,init:RequestInit={}){return sfApi<any>(url,init)}
|
||||
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)}})
|
||||
watch(siteId,(id)=>rememberSite(id))
|
||||
onMounted(async()=>{try{csrf.value=(await request('/api/auth/me')).csrf;sites.value=await request('/api/sites');siteId.value=preferredSite(sites.value);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>
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
<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 {watch,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {preferredSite,rememberSite,sfApi} from './_shared'
|
||||
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 api(path:string,method='GET',body?:unknown){return sfApi<any>(path,{method,headers:body===undefined?{}:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)})}
|
||||
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)}})
|
||||
watch(siteId,(id)=>rememberSite(id))
|
||||
onMounted(async()=>{try{csrf.value=(await api('/api/auth/me')).csrf;sites.value=await api('/api/sites');siteId.value=preferredSite(sites.value);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>
|
||||
|
||||
@@ -10,23 +10,21 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {watch,ref,onMounted} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {preferredSite,rememberSite,sfApi} from './_shared'
|
||||
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 request(url:string,method='GET',payload?:unknown,isFile=false){return sfApi<any>(url,{method,headers:payload!==undefined&&!isFile?{'Content-Type':'application/json'}:{},body:payload===undefined?undefined:isFile?payload as FormData:JSON.stringify(payload)})}
|
||||
async function load(){try{sites.value=await request('/api/sites');themes.value=await request('/api/factory/themes');if(!siteId.value)siteId.value=preferredSite(sites.value);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)}}
|
||||
watch(siteId,(id)=>rememberSite(id))
|
||||
onMounted(async()=>{try{csrf.value=(await request('/api/auth/me')).csrf;await load()}catch(e:any){ElMessage.error(e.message)}})
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
@@ -37,18 +37,16 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {watch,ref,onMounted} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {preferredSite,rememberSite,sfApi} from './_shared'
|
||||
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
|
||||
}
|
||||
async function api<T>(url:string,init:RequestInit={}):Promise<T>{return sfApi<T>(url,init)}
|
||||
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}
|
||||
@@ -62,6 +60,7 @@ async function rollback(id:string){try{await ElMessageBox.confirm('回滚不会
|
||||
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)}})
|
||||
watch(siteId,(id)=>rememberSite(id))
|
||||
onMounted(async()=>{try{csrf.value=(await api<{csrf:string}>('/api/auth/me')).csrf;sites.value=await api<Site[]>('/api/sites');siteId.value=preferredSite(sites.value);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>
|
||||
|
||||
@@ -13,15 +13,17 @@
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {watch,ref,onMounted} from 'vue'
|
||||
import {ElMessage} from 'element-plus'
|
||||
import {preferredSite,rememberSite,sfApi} from './_shared'
|
||||
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 get<T>(path:string):Promise<T>{return sfApi<T>(path)}
|
||||
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)}})
|
||||
watch(siteId,(id)=>rememberSite(id))
|
||||
onMounted(async()=>{try{sites.value=await get('/api/sites');if(sites.value.length){siteId.value=preferredSite(sites.value);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}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {preferredSite,rememberSite,sfApi} from './_shared'
|
||||
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}
|
||||
@@ -47,15 +48,7 @@ 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
|
||||
}
|
||||
async function api<T>(path:string,init:RequestInit={}){return sfApi<T>(path,init)}
|
||||
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)}})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** SiteForge session/API boundary. Do not use Art demo login tokens as SiteForge credentials. */
|
||||
export interface SiteRef { id:string; name:string; domain?:string }
|
||||
export interface SiteForgeCapabilities { role:string; view:boolean; edit:boolean; review:boolean; deploy:boolean; manage_members:boolean; create_site:boolean; manage_users:boolean }
|
||||
export interface SiteForgeBootstrap {
|
||||
version:string; user:string; superadmin:boolean; sites:SiteRef[]; selected_site_id:string|null;
|
||||
capabilities:SiteForgeCapabilities;
|
||||
navigation:Array<{key:string;title:string;path:string;requires_site:boolean}>
|
||||
}
|
||||
let csrfCache:string|undefined
|
||||
const key='siteforge:art-selected-site'
|
||||
|
||||
export class SiteForgeError extends Error {
|
||||
constructor(message:string,readonly status:number){super(message);this.name='SiteForgeError'}
|
||||
}
|
||||
export async function sfApi<T>(path:string, init:RequestInit={}):Promise<T>{
|
||||
if(!path.startsWith('/api/') || path.startsWith('//'))throw new Error('仅允许调用本站 SiteForge API')
|
||||
const method=(init.method||'GET').toUpperCase()
|
||||
const headers=new Headers(init.headers)
|
||||
if(!['GET','HEAD'].includes(method)){
|
||||
if(!csrfCache){const me=await sfApi<{csrf:string}>('/api/auth/me');csrfCache=me.csrf}
|
||||
headers.set('X-SiteForge-CSRF',csrfCache)
|
||||
}
|
||||
const res=await fetch(path,{...init,method,headers,credentials:'same-origin',redirect:'follow'})
|
||||
if(res.status===401){csrfCache=undefined;throw new SiteForgeError('SiteForge 登录已过期,请从统一登录页重新登录',401)}
|
||||
const contentType=res.headers.get('content-type')||''
|
||||
if(!contentType.includes('application/json'))throw new SiteForgeError('接口返回了非 JSON 响应,请检查同域 API 代理配置',502)
|
||||
const data=await res.json()
|
||||
if(!res.ok){
|
||||
const detail=typeof data?.detail==='string'?data.detail:JSON.stringify(data?.detail||'请求失败')
|
||||
throw new SiteForgeError(detail,res.status)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
export async function session(){const me=await sfApi<{csrf:string;user:string}>('/api/auth/me');csrfCache=me.csrf;return me}
|
||||
export function preferredSite<T extends SiteRef>(sites:T[]):string{
|
||||
const params=new URLSearchParams(window.location.search)
|
||||
const proposed=params.get('site') || sessionStorage.getItem(key) || ''
|
||||
const chosen=sites.find(s=>s.id===proposed) || sites[0]
|
||||
if(chosen)sessionStorage.setItem(key,chosen.id)
|
||||
return chosen?.id||''
|
||||
}
|
||||
export function rememberSite(siteId:string){if(siteId)sessionStorage.setItem(key,siteId)}
|
||||
export async function bootstrap():Promise<SiteForgeBootstrap>{
|
||||
const preferred=sessionStorage.getItem(key)||''
|
||||
try {
|
||||
return await sfApi<SiteForgeBootstrap>('/api/console/bootstrap'+(preferred?'?site_id='+encodeURIComponent(preferred):''))
|
||||
} catch(e){
|
||||
if(e instanceof SiteForgeError && e.status===403){sessionStorage.removeItem(key);return sfApi<SiteForgeBootstrap>('/api/console/bootstrap')}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
export function postJson(body:unknown):RequestInit{return {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}}
|
||||
export function patchJson(body:unknown):RequestInit{return {method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}}
|
||||
Reference in New Issue
Block a user