upgrade: adopt V8.12.0-dev.1 (V8.11 unified professional UI + V8.12 workflow usability)

- Overlay 26 changed files from SiteForge_Enterprise_V8_12 (UI layer only:
  professional-ui.js/css, workspace/factory-v2/operations/editor templates,
  +2 test files, compose/ci/scripts/docs)
- Re-apply v8_workflows type-mapping fix (类型/分类) lost in upgrade
- Pin compose project name to siteforge-v8-10 so existing data volumes
  (hesupipe site 483295c29ea0, 193 entries) keep attached
- Drop stray .s10_* state files; ignore .s10_*/.sf_*/.s87_* patterns
- Plan/spec revisions per plan-review round 1 (icons are new design,
  real test assertions, recorded deviations)

Tests: 226 passed / 1 env-only failure (test needs node binary in PATH,
absent in python:3.12-slim container; host node v24 --check all 9 JS OK)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 17:02:45 +08:00
parent 19e865b889
commit 5c7643fcf2
31 changed files with 598 additions and 81 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
sleep 2
done
test "$web" = healthy && test "$worker" = healthy
docker compose exec -T siteforge python -c "import json,urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/health')); assert data['version']=='8.10.0-dev.1' and data.get('commit')"
docker compose exec -T siteforge python -c "import json,urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/health')); assert data['version']=='8.12.0-dev.1' and data.get('commit')"
- name: Stop Compose smoke
if: always()
env:
+3
View File
@@ -14,3 +14,6 @@ secrets/
demo_storage/
hesupipe_clean2.xml
.s10_*
.sf_*
.s87_*
-1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
5552c7a9a9c1
+20 -5
View File
@@ -1,9 +1,9 @@
# SiteForge Enterprise V8.10.0-dev.1 — 简洁管理后台体验升级
# SiteForge Enterprise V8.12.0-dev.1 — 工作台流程与任务状态增强
> **开发集成版,尚未通过 P0–P4 全部生产验收门禁。** 完整工程基于 V8.8 升级;P5/多语言和 Art Design Pro 官方工程等未在本轮解决。
> **开发集成版,尚未通过 P0–P4 全部生产验收门禁。** 完整工程在 V8.11 基线上升级;P5/多语言和 Art Design Pro 官方工程等未在本轮解决。
## V8.10:管理后台重新设计(本轮重点)
## V8.11:管理后台重新设计(本轮重点)
默认 `http://localhost:8787/` 现使用 **简洁工作台**,保留原有业务 API 和数据结构,不迁移数据库。
@@ -12,13 +12,13 @@
- **网站概览**:四步流程(建立网站、准备内容、完善设计、构建预览),动态提示下一步。
- **内容与页面**:页面、产品、新闻统一管理,可快捷修改文字或进入可视化设计器。
- **外观与设计**:行业主题选择与实际页面编辑;草稿/发布隔离仍由原编辑器负责。
- **导入与迁移**:简单文件快速导入;WordPress/认证 API/MySQL 等复杂任务引导进入专业工作台。快速导入是直接写入,复杂迁移必须使用带预演与复核的入口。
- **导入与迁移**:默认进入带预演、复核与批次回滚的导入工作台;小文件直写保留为高级操作且必须确认。
- **构建与交付**:使用既有异步 Worker,预览、静态 ZIP、质量报告集中呈现;正式审批和远程发布保留在企业交付中心。
- **网站设置**:站点名称、域名、公司标语、Logo、类型等从设计功能中独立出来。
- **高级工具**:折叠保留原有生产控制台、任务与审批、主题预览以及历史后台 `/classic`,确保不丢功能。
新增页面在 `app/templates/workspace.html`,样式和功能逻辑在 `app/static/workspace.css`、`app/static/workspace.js`。
其他高级页面属于旧界面,后续可逐一沿用本次信息架构迁移;这次未将 Art Design Pro 官方完整工程视为已整合。
专业页面已经使用 V8.11 的统一导航与分组交互;Art Design Pro 官方完整工程仍未正式编译整合。
**运行前后检查**:登录、创建站点、内容编辑、主题切换、文件导入、异步构建、预览/ZIP、手机宽度侧边栏。
@@ -281,3 +281,18 @@ python scripts/verify_release.py
接下来的 P2 收尾任务:自动尝试多种 Sitemap 路径、WordPress 媒体库/分类标签迁移、REST 采集的统计/重试监控、媒体 CDN 授权白名单和批量迁移压测。之后再推进 P3 XML/API/只读数据库连接器、P4 整站编辑器、P5 安全发布、多语言与 PostgreSQL/Redis 生产化。
更多细节:[V8.3 验收与剩余功能](docs/V8_3_RELEASE_STATUS.md)。
## V8.11:统一专业管理体验
本版在 V8.10 基础上统一五个专业工具页面的导航、当前网站选择、业务提示、表单焦点和操作按钮。编辑器将“草稿/预览/正式发布”分组,平板和手机提供结构/预览/属性面板切换;任务中心按迁移、构建、审批发布分为三个阶段。所有原业务 API 与 Docker 服务保持不变。
- 默认工作台:`http://localhost:8787/`
- 主题预览:`/theme-gallery`
- 页面编辑器:从工作台的“外观与设计”进入
- 高级建站:`/factory-studio`
- 数据迁移:`/production-v8`
- 任务与交付:`/operations`
详细功能对照与人工验收清单见 [`docs/V8_11_UNIFIED_ADMIN.md`](docs/V8_11_UNIFIED_ADMIN.md)。
运行测试:`python -m pytest -q --disable-warnings`。本版在当前环境通过 218 项测试。Docker 实机与浏览器跨设备回归仍需部署后完成。
+1 -1
View File
@@ -12,7 +12,7 @@ function setTab(tab){state.tab=tab;$$('#navtabs button').forEach(b=>b.classList.
function $$(s){return [...document.querySelectorAll(s)]}
function setHeading(h,desc){$('#heading').textContent=h;$('#description').textContent=desc;$('#currentTab').textContent=h;}
async function init(){try{state.csrf=(await api('/api/auth/me')).csrf;await refresh();document.querySelectorAll('#navtabs button').forEach(btn=>btn.addEventListener('click',()=>setTab(btn.dataset.tab)));
$('#sitePicker').onchange=()=>{state.site=state.sites.find(s=>s.id===$('#sitePicker').value);render().catch(e=>toast(e.message,true))};$('#reload').onclick=refresh;$('#newSite').onclick=openCreate;$('#closeCreate').onclick=()=>$('#createDialog').close();$('#createForm').onsubmit=create;
$('#sitePicker').onchange=()=>{state.site=state.sites.find(s=>s.id===$('#sitePicker').value);window.SiteForgeShell?.selectSite(state.site?.id);render().catch(e=>toast(e.message,true))};$('#reload').onclick=refresh;$('#newSite').onclick=openCreate;$('#closeCreate').onclick=()=>$('#createDialog').close();$('#createForm').onsubmit=create;
$('#buildQuick').onclick=()=>buildNow();await render()}catch(e){toast('初始化失败:'+e.message,true)}}
async function refresh(){state.sites=await api('/api/sites');state.themes=await api('/api/factory/themes');let old=state.site?.id;state.site=state.sites.find(x=>x.id===old)||state.sites[0]||null;
$('#sitePicker').innerHTML=state.sites.map(s=>`<option value="${H(s.id)}">${H(s.name)}</option>`).join('');if(state.site)$('#sitePicker').value=state.site.id;
+1 -1
View File
@@ -11,7 +11,7 @@ async function request(url,{method='GET',body=null,upload=false}={}){
}
const post=(url,body)=>request(url,{method:'POST',body});
function formatJobs(list){return list.length?list.map(j=>`<div class="row"><div><b>${esc(j.kind||'build')} <span class="flag">${esc(j.state)}</span></b><small>${esc(j.id)} · 尝试 ${esc(j.attempts||0)} 次</small></div>${['queued','running','retrying'].includes(j.state)?`<button class="secondary" onclick="cancelJob('${esc(j.id)}')">取消</button>`:''}</div>`).join(''):'当前没有任务';}
async function chooseSite(){siteId=$('#site').value;lastPreview=null;$('#commitImport').disabled=true;await reload()}
async function chooseSite(){siteId=$('#site').value;window.SiteForgeShell?.selectSite(siteId);lastPreview=null;$('#commitImport').disabled=true;await reload()}
async function init(){try{csrf=(await request('/api/auth/me')).csrf;sites=await request('/api/sites');$('#site').innerHTML=sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');siteId=sites[0]?.id||'';$('#site').onchange=chooseSite;$('#reload').onclick=reload;$('#previewImport').onclick=()=>importJSON(false);$('#commitImport').onclick=()=>importJSON(true);$('#build').onclick=buildSite;$('#deploy').onclick=deploySite;$('#refreshRelease').onclick=reload;await reload()}catch(e){notify(e.message,true)}}
async function reload(){if(!siteId)return;try{
const [list,jobsData,buildData,approvals,history,reviewData]=await Promise.all([
+87
View File
@@ -0,0 +1,87 @@
/* SiteForge V8.11 unified professional experience; isolated sf-* selectors */
:root{--sf-blue:#245fc7;--sf-ink:#203451;--sf-line:#dce5f1;--sf-soft:#f5f8fd;--sf-muted:#60748e}
.sf-globalbar,.sf-globalbar *,.sf-onboarding,.sf-onboarding *{box-sizing:border-box}
.sf-globalbar{position:relative;z-index:100;background:#fff;color:var(--sf-ink);border-bottom:1px solid var(--sf-line);font:13px/1.45 Inter,'Microsoft YaHei',system-ui,sans-serif;box-shadow:0 2px 12px #20345109}
.sf-bar-main{min-height:61px;display:flex;align-items:center;gap:19px;padding:10px clamp(15px,2.3vw,32px);flex-wrap:wrap}
.sf-bar-brand{display:flex;align-items:center;gap:10px;white-space:nowrap;color:#213b66!important;text-decoration:none!important;font-weight:800;font-size:17px}
.sf-bar-logo{display:grid;place-items:center;width:33px;height:33px;background:#265fbd;color:white;border-radius:9px;letter-spacing:-1px}
.sf-bar-loc{font-size:12px;color:#6d7d94;border-left:1px solid #dae3ef;padding-left:16px;min-width:0}
.sf-bar-loc strong{color:#253d63;font-size:13px}
.sf-bar-nav{display:flex;align-items:center;gap:4px;margin-left:auto;min-width:0}
.sf-bar-nav a,.sf-bar-help{font:600 12px/1.5 system-ui,'Microsoft YaHei',sans-serif;text-decoration:none!important;color:#61758f!important;padding:8px 11px;border:1px solid transparent;border-radius:8px;white-space:nowrap;background:transparent;cursor:pointer}
.sf-bar-nav a:hover,.sf-bar-nav a[aria-current='page'],.sf-bar-help:hover{background:#eaf2fe;color:var(--sf-blue)!important}
.sf-bar-site{display:flex;align-items:center;gap:7px;min-width:0;color:#60748e;font-size:12px}
.sf-bar-site select{min-width:120px;max-width:184px;height:36px;padding:7px 28px 7px 10px;border:1px solid #cdd9e9;border-radius:8px;background:#fff;color:#253d63;font-size:12px}
.sf-onboarding{background:#f4f8ff;border-bottom:1px solid #dfe9f7;color:#4b6180;font:12px/1.6 system-ui,'Microsoft YaHei',sans-serif;padding:10px clamp(15px,2.3vw,32px);display:flex;align-items:center;gap:11px;flex-wrap:wrap}
.sf-onboarding strong{color:#1f4d95;white-space:nowrap}.sf-onboarding .sf-onboarding-more{display:none;flex-basis:100%;padding:10px 12px;border:1px solid #d8e5f9;background:white;border-radius:9px}
.sf-onboarding[data-open='true'] .sf-onboarding-more{display:block}.sf-onboarding [data-sf-help]{margin-left:auto;font-size:12px;border:1px solid #bdcfee;background:#fff;color:#2757aa;border-radius:7px;padding:5px 10px;cursor:pointer}
.sf-professional button:not(:disabled),.sf-professional a{touch-action:manipulation}
.sf-professional :is(input:not([type=checkbox]):not([type=radio]),select,textarea):focus-visible,.sf-professional button:focus-visible,.sf-professional a:focus-visible{outline:2px solid #4485eb!important;outline-offset:2px}
.sf-professional :is(input:not([type=checkbox]):not([type=radio]),select,textarea){accent-color:var(--sf-blue)}
.sf-professional button:disabled{opacity:.5;cursor:not-allowed}
.sf-professional .sf-hint{font-size:12px;color:var(--sf-muted);line-height:1.6}
.sf-professional .sf-step{font-size:11px;color:#2859ad;background:#eaf2fe;border-radius:6px;padding:4px 8px;font-weight:700}
/* Avoid duplicate menus: one universal navigation, page-local tabs remain for tasks. */
.sf-professional.sf-page-theme>header{display:none}
.sf-professional.sf-page-production>header{display:none}
.sf-professional.sf-page-operations .layout{display:block;min-height:auto}
.sf-professional.sf-page-operations .layout>aside{display:none}
.sf-professional.sf-page-operations .layout>main{max-width:1460px;padding:27px clamp(15px,3vw,35px)}
.sf-professional.sf-page-factory .shell{display:block}
.sf-professional.sf-page-factory .sidebar{height:auto;position:static;background:#fff;color:#263e5d;padding:10px 25px;border-bottom:1px solid var(--sf-line);display:block}
.sf-professional.sf-page-factory .sidebar .identity,.sf-professional.sf-page-factory .sidebar .side-title,.sf-professional.sf-page-factory .sidebar .side-bottom{display:none}
.sf-professional.sf-page-factory #navtabs{display:flex;gap:5px;flex-wrap:wrap}
.sf-professional.sf-page-factory #navtabs button{color:#5b708a;padding:10px 14px;border-radius:8px}
.sf-professional.sf-page-factory #navtabs button.active,.sf-professional.sf-page-factory #navtabs button:hover{background:#e9f1ff;color:#245dc4}
.sf-professional.sf-page-factory .top{min-height:59px}
.sf-professional.sf-page-factory .top-crumb{display:none}
.sf-professional.sf-page-factory .top-controls>label{display:none}
.sf-professional.sf-page-operations header .right>label{display:none}
.sf-professional.sf-page-production #site{display:none}
.sf-professional.sf-page-production .panel:has(> .row > div > h2:first-child){scroll-margin-top:24px}
.sf-professional.sf-page-editor .studio{height:calc(100vh - var(--sf-extras-height,116px));min-height:560px}
.sf-professional.sf-page-editor .studio-top{height:auto;min-height:70px;padding:10px 18px;gap:8px;flex-wrap:wrap}
.sf-professional.sf-page-editor .studio-top .brand,.sf-professional.sf-page-editor .studio-top .divide{display:none}
.sf-professional.sf-page-editor .studio-top .top-center{margin-left:auto;margin-right:8px}
.sf-professional.sf-page-editor .studio-top .file-name{max-width:320px}
.sf-professional.sf-page-editor .sf-editor-actiongroup{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.sf-professional.sf-page-editor .sf-editor-actiongroup[data-group='publish']{padding-left:9px;border-left:1px solid #dae3ef}
.sf-professional.sf-page-editor .sf-editor-actiongroup .ui-btn{padding:9px 11px;font-size:11px}
.sf-professional.sf-page-editor .preview-workspace .stage{padding:13px}
.sf-editor-mobile-tabs{display:none}
.sf-professional.sf-page-editor .sf-editor-help{color:#6b7d95;font-size:11px;margin:0;padding:0 6px}
@media(max-width:1280px){.sf-bar-main{gap:8px}.sf-bar-nav a{padding:7px}.sf-professional.sf-page-editor .studio-top .file-name{display:none}}
@media(max-width:920px){.sf-bar-nav{order:5;width:100%;overflow:auto;margin-left:0;border-top:1px solid #edf1f8;padding-top:6px}.sf-bar-nav a{flex-shrink:0}.sf-bar-site{margin-left:auto}.sf-professional.sf-page-editor .studio{height:calc(100vh - var(--sf-extras-height,156px))}.sf-professional.sf-page-editor .studio-top{position:relative}.sf-professional.sf-page-editor .right-pane{top:0;position:relative;width:auto;box-shadow:none}}
@media(max-width:650px){.sf-bar-main{padding:10px 12px}.sf-bar-loc{display:none}.sf-bar-site{max-width:180px}.sf-bar-site select{max-width:125px}.sf-onboarding{padding:8px 12px;align-items:flex-start}.sf-onboarding>span{flex:1 1 75%;min-width:0}.sf-onboarding [data-sf-help]{margin-left:0}.sf-professional.sf-page-editor .studio{height:auto;min-height:calc(100vh - 160px)}.sf-professional.sf-page-editor .studio-top{padding:9px;gap:5px}.sf-professional.sf-page-editor .studio-top .ui-btn.ghost{display:inline-flex}.sf-professional.sf-page-editor .studio-top .sf-editor-actiongroup{display:flex}.sf-professional.sf-page-editor .studio-top .top-center{margin:0 auto}.sf-professional.sf-page-editor .studio-body{display:block;min-height:610px}.sf-professional.sf-page-editor .studio-body>aside,.sf-professional.sf-page-editor .studio-body>.preview-workspace{display:none}.sf-professional.sf-page-editor[data-mobile-pane='structure'] .studio-body>.left-pane{display:flex;min-height:600px}.sf-professional.sf-page-editor[data-mobile-pane='preview'] .studio-body>.preview-workspace{display:flex;min-height:600px}.sf-professional.sf-page-editor[data-mobile-pane='properties'] .studio-body>.right-pane{display:flex;min-height:600px}.sf-professional.sf-page-editor .left-pane,.sf-professional.sf-page-editor .right-pane{overflow:visible;border:0}.sf-editor-mobile-tabs{display:flex;gap:5px;padding:8px;background:#fff;border-bottom:1px solid var(--sf-line)}.sf-editor-mobile-tabs button{flex:1;border:1px solid #dce5f1;border-radius:7px;background:#f7f9fc;padding:9px 6px;color:#4c617d}.sf-editor-mobile-tabs button[aria-selected='true']{background:#e8f0ff;border-color:#94b8f3;color:#1c56ba;font-weight:750}.sf-professional.sf-page-editor .device-frame{min-height:570px}.sf-professional.sf-page-editor iframe{min-height:600px}.sf-professional.sf-page-factory .sidebar{padding:8px}.sf-professional.sf-page-factory #navtabs{flex-wrap:nowrap;overflow-x:auto}.sf-professional.sf-page-factory #navtabs button{white-space:nowrap}.sf-professional.sf-page-operations .layout>main{padding:14px}.sf-professional.sf-page-production main{padding:0 13px}}
@media(prefers-reduced-motion:reduce){.sf-globalbar *, .sf-onboarding *{transition:none!important}}
/* Shared actions and form language */
.sf-professional :is(.primary,.btn-primary,.ui-btn.primary,button.primary){background:#245fc7!important;border-color:#245fc7!important;color:#fff!important}
.sf-professional :is(.primary,.btn-primary,.ui-btn.primary,button.primary):hover:not(:disabled){background:#1c4fa6!important;border-color:#1c4fa6!important}
.sf-professional :is(.outline,.btn-muted,.secondary,.ui-btn.ghost){border-color:#cedceb;color:#3f5674}
.sf-professional .danger,.sf-professional button.danger{color:#ba4249!important}
.sf-professional :is(input,textarea,select):disabled{background:#f2f5fa;color:#8895a7}
.sf-professional :is(input,textarea,select)[aria-invalid='true']{border-color:#c8464e!important;background:#fff8f8!important}
.sf-professional :is(.panel,.section) h2{letter-spacing:-.25px}
.sf-professional .sf-visually-hidden{position:absolute;width:1px;height:1px;clip:rect(0 0 0 0);overflow:hidden;white-space:nowrap}
/* Keep editing controls accessible on tablets, not only desktop. */
@media(max-width:950px){
.sf-professional.sf-page-editor .studio-body{display:block;min-height:600px}
.sf-professional.sf-page-editor .studio-body>aside,.sf-professional.sf-page-editor .studio-body>.preview-workspace{display:none}
.sf-professional.sf-page-editor[data-mobile-pane='structure'] .studio-body>.left-pane{display:flex;min-height:600px}
.sf-professional.sf-page-editor[data-mobile-pane='preview'] .studio-body>.preview-workspace{display:flex;min-height:600px}
.sf-professional.sf-page-editor[data-mobile-pane='properties'] .studio-body>.right-pane{display:flex;min-height:600px;position:static;width:100%;box-shadow:none}
.sf-professional.sf-page-editor .sf-editor-mobile-tabs{display:flex;gap:5px;padding:8px;background:white}
.sf-professional.sf-page-editor .sf-editor-mobile-tabs button{flex:1;padding:9px 5px;border:1px solid #d5e0f0;border-radius:7px}
.sf-professional.sf-page-editor .sf-editor-mobile-tabs button[aria-selected='true']{background:#e8f0ff;color:#245fc7;border-color:#99b7e9}
}
.sf-ops-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:18px 0 20px}
.sf-ops-tabs button{display:grid;gap:4px;text-align:left;border:1px solid #d9e3f1;border-radius:10px;padding:13px 17px;background:#fff;color:#586e8a;cursor:pointer;font:inherit}
.sf-ops-tabs button strong{font-size:13px;color:#334c6d}
.sf-ops-tabs button small{font-size:11px;color:#8a98aa}
.sf-ops-tabs button[aria-selected='true']{border-color:#91b4ec;background:#eaf2ff;box-shadow:inset 3px 0 #326bd6}
.sf-professional.sf-page-operations .layout>main>.two[hidden]{display:none!important}
.sf-professional.sf-page-production .tool-switch{position:sticky;top:0;z-index:6;box-shadow:0 4px 16px #1b355509}
.sf-professional.sf-page-theme .frame{height:calc(100vh - 285px)}
@media(max-width:650px){.sf-ops-tabs{grid-template-columns:1fr;gap:7px}.sf-ops-tabs button{padding:9px 12px}.sf-professional.sf-page-production .tool-switch{position:static}}
+67
View File
@@ -0,0 +1,67 @@
/* Shared non-invasive shell for SiteForge specialized tools. No business API changes. */
(()=>{'use strict';
const PAGES={editor:{title:'可视化编辑器',group:'外观与设计',help:'编辑区块和全站布局,先保存草稿,确认后再发布设计。',more:'左侧调整页面结构,中间查看真实主题预览,右侧编辑属性。修改草稿不会改变已构建网站;发布设计后需要重新构建。',next:'完成设计后到「构建与交付」查看最终 HTML。'},theme:{title:'主题预览',group:'外观与设计',help:'比较四套主题及不同设备的页面效果。',more:'这里使用演示资料展示主题结构。选择真正的网站主题请返回工作台 → 外观与设计,切换后重新构建。',next:'返回外观与设计,选择主题并编辑页面。'},factory:{title:'高级建站',group:'专业工具',help:'主题版本、字段映射、SEO 审计及高级构建集中在这里。',more:'先选择网站,再按“总览 → 主题 → 内容 → 迁移检查 → 构建”逐步操作。普通内容管理请使用简洁工作台。',next:'数据准备完成后执行构建与质量检查。'},production:{title:'数据与迁移工具',group:'导入与迁移',help:'一次只处理一类数据:选工具 → 预览 → 审核 → 提交。',more:'WordPress、Excel/CSV、XML、API 与 MySQL 都进入可复核的暂存流程。高级参数可保留默认值,完成后回到工作台构建。',next:'在构建与交付中验证导入后的实际页面。'},operations:{title:'任务与交付',group:'构建与交付',help:'检查迁移问题、Worker 任务、构建质量和发布审批。',more:'按“迁移复核 → 构建任务 → 质量报告 → 审批 → 部署”顺序处理。部署操作会影响线上环境,请先核查版本和目标。',next:'通过质量与审批后,再选择目标环境发布。'}};
const ctx=document.body?.dataset.sfPage; if(!PAGES[ctx])return; const p=PAGES[ctx],siteKey='siteforge:selected-site';
const safeRead=()=>{try{return localStorage.getItem(siteKey)||''}catch{return ''}};
const safeWrite=id=>{try{if(id)localStorage.setItem(siteKey,id);else localStorage.removeItem(siteKey)}catch{}};
const route=(view)=>`/?view=${encodeURIComponent(view)}${selected()?`&site=${encodeURIComponent(selected())}`:''}`;
const selected=()=>document.getElementById('sfGlobalSite')?.value || safeRead();
const navbar=[['网站工作台','/','home'],['内容管理','content','content'],['外观设计','design','design'],['导入迁移','import','import'],['构建交付','build','build']];
const html=document.createElement('div');html.className='sf-globalbar';html.setAttribute('role','navigation');html.setAttribute('aria-label','网站工作区导航');
const main=document.createElement('div');main.className='sf-bar-main';
const brand=document.createElement('a');brand.href='/';brand.className='sf-bar-brand';brand.innerHTML='<span class="sf-bar-logo">SF</span><span>SiteForge</span>';
const loc=document.createElement('span');loc.className='sf-bar-loc';loc.textContent=`${p.group} / ${p.title}`;
const nav=document.createElement('nav');nav.className='sf-bar-nav';nav.setAttribute('aria-label','主要功能');
navbar.forEach(([label,view])=>{const a=document.createElement('a');a.textContent=label;a.dataset.sfView=view;a.href=view==='/'?'/':route(view);if((ctx==='editor'||ctx==='theme')&&view==='design'||ctx==='production'&&view==='import'||ctx==='operations'&&view==='build'){a.setAttribute('aria-current','page')}nav.append(a)});
const sLabel=document.createElement('label');sLabel.className='sf-bar-site';sLabel.textContent='当前网站';const sel=document.createElement('select');sel.id='sfGlobalSite';sel.setAttribute('aria-label','当前管理的网站');sel.append(new Option('正在加载…',''));sLabel.append(sel);
main.append(brand,loc,nav,sLabel);html.append(main);
const help=document.createElement('div');help.className='sf-onboarding';help.dataset.open='false';
const strong=document.createElement('strong');strong.textContent='当前任务';const short=document.createElement('span');short.textContent=p.help;const toggle=document.createElement('button');toggle.type='button';toggle.dataset.sfHelp='';toggle.textContent='查看操作说明 +';toggle.setAttribute('aria-expanded','false');
const more=document.createElement('div');more.className='sf-onboarding-more';more.textContent=`操作方法:${p.more} 下一步:${p.next}`;help.append(strong,short,toggle,more);
document.body.classList.add('sf-professional','sf-page-'+ctx);document.body.insertBefore(help,document.body.firstChild);document.body.insertBefore(html,help);
const syncHeight=()=>{document.documentElement.style.setProperty('--sf-extras-height',(html.getBoundingClientRect().height+help.getBoundingClientRect().height)+'px')};
const localSelect=()=>({factory:'#sitePicker',operations:'#site',production:'#site'})[ctx]||null;
const refreshNav=()=>nav.querySelectorAll('a[data-sf-view]').forEach(a=>{a.href=a.dataset.sfView==='/'?'/':route(a.dataset.sfView)});
const local=()=>document.querySelector(localSelect()||'#sf__no_local');
let applying=false;
function mirrorToLocal(id){const node=local();if(!node||!Array.from(node.options).some(o=>o.value===id))return false;if(node.value===id)return true;applying=true;node.value=id;node.dispatchEvent(new Event('change',{bubbles:true}));applying=false;return true}
function selectSite(id){safeWrite(id);refreshNav();}
window.SiteForgeShell={selectSite,selectedSite:selected};
sel.addEventListener('change',()=>{const id=sel.value;if(!id)return;selectSite(id);if(ctx==='editor'){location.assign(route('design'));return}if(!mirrorToLocal(id)&&ctx!=='theme'){location.assign(route('overview'))}});
nav.addEventListener('click',()=>selectSite(sel.value));
toggle.addEventListener('click',()=>{const on=help.dataset.open!=='true';help.dataset.open=String(on);toggle.textContent=on?'收起操作说明 −':'查看操作说明 +';toggle.setAttribute('aria-expanded',String(on));syncHeight()});
async function load(){try{const res=await fetch('/api/sites',{credentials:'same-origin'});if(!res.ok)throw Error('网站列表加载失败');const items=await res.json();sel.replaceChildren(new Option('选择网站',''));
for(const item of items){sel.append(new Option(item.name,item.id))}
const parts=location.pathname.split('/');const editorId=ctx==='editor'?parts[2]:'';const params=new URLSearchParams(location.search);const wanted=editorId||params.get('site')||safeRead()||items[0]?.id||'';
if(items.some(s=>s.id===wanted)){sel.value=wanted;safeWrite(wanted)}else if(items.length){sel.value=items[0].id;safeWrite(sel.value)}
const native=local();if(native){const apply=()=>{const desired=sel.value;if(Array.from(native.options).some(o=>o.value===desired))mirrorToLocal(desired)};if(native.options.length)apply();const observer=new MutationObserver(()=>{if(!applying)apply()});observer.observe(native,{childList:true});native.addEventListener('change',()=>{if(applying)return;sel.value=native.value;selectSite(native.value)})}
refreshNav();}catch(err){sel.replaceChildren(new Option('在工作台选择网站',''));sel.disabled=true}syncHeight()}
if(ctx==='operations'){
// Business steps are optional views over existing DOM; hidden panels retain their API listeners.
const content=document.querySelector('.layout > main');
const groups=Array.from(content?.querySelectorAll(':scope > .two')||[]);
const metrics=content?.querySelector('.metrics');
if(groups.length===3&&metrics){
const tabbar=document.createElement('div');tabbar.className='sf-ops-tabs';tabbar.setAttribute('role','tablist');tabbar.setAttribute('aria-label','运行与发布阶段');
const choices=[['migration','① 导入与复核','确认旧站数据'],['build','② 生成静态网站','查看 Worker 任务'],['release','③ 审批与发布','核对部署目标']];
const names=['migration','build','release'];groups.forEach((g,i)=>{g.dataset.sfOps=names[i];g.setAttribute('role','tabpanel')});
function choose(key){groups.forEach(g=>g.hidden=g.dataset.sfOps!==key);tabbar.querySelectorAll('button').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.view===key)));}
choices.forEach(([key,title,desc])=>{const b=document.createElement('button');b.type='button';b.dataset.view=key;b.setAttribute('role','tab');const label=document.createElement('strong');label.textContent=title;const small=document.createElement('small');small.textContent=desc;b.append(label,small);b.addEventListener('click',()=>choose(key));tabbar.append(b)});
metrics.insertAdjacentElement('afterend',tabbar);choose('migration');
}
}
// Mark required and invalid fields visibly, leaving native validation intact.
document.addEventListener('invalid',ev=>{const field=ev.target;if(field.matches?.('input,select,textarea'))field.setAttribute('aria-invalid','true')},true);
document.addEventListener('input',ev=>{if(ev.target.matches?.('input,select,textarea')&&ev.target.checkValidity())ev.target.removeAttribute('aria-invalid')});
if(ctx==='editor'){
const studio=document.querySelector('.studio');const top=studio?.querySelector('.studio-top');
if(top){const reorder=(label,group,names)=>{const div=document.createElement('div');div.className='sf-editor-actiongroup';div.dataset.group=group;div.setAttribute('aria-label',label);for(const name of names){const node=Array.from(top.children).find(x=>x.tagName==='BUTTON' && (x.textContent||'').includes(name));if(node)div.append(node)}top.append(div)};
reorder('页面与全站设计','design',['设计规范','全站页眉']);reorder('历史与草稿','draft',['撤销','重做','放弃草稿','保存草稿']);reorder('预览与构建','preview',['刷新预览','构建静态站']);reorder('发布设计','publish',['发布设计']);
}
const tabs=document.createElement('div');tabs.className='sf-editor-mobile-tabs';tabs.setAttribute('role','tablist');tabs.setAttribute('aria-label','手机端编辑面板');[['structure','页面结构'],['preview','页面预览'],['properties','属性设置']].forEach(([view,title])=>{const b=document.createElement('button');b.type='button';b.setAttribute('role','tab');b.dataset.sfPane=view;b.textContent=title;b.addEventListener('click',()=>setPane(view));tabs.append(b)});
const body=studio?.querySelector('.studio-body');if(body)studio.insertBefore(tabs,body);
function setPane(pane){document.body.dataset.mobilePane=pane;tabs.querySelectorAll('button').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.sfPane===pane)))}setPane('structure');
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',load);else load();
window.addEventListener('resize',syncHeight);
})();
File diff suppressed because one or more lines are too long
+69 -24
View File
@@ -5,7 +5,9 @@ const esc=x=>String(x??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'
const themeName={automotive:'汽车与出行',industrial:'工业与制造',corporate:'企业品牌',minimal:'极简展示'};
const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',brand:'品牌',case:'案例',service:'服务'};
const fmtDate=t=>{if(!t)return '—';const d=new Date(t);return Number.isNaN(d.getTime())?String(t).slice(0,16):d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})};
const state={sites:[],site:null,entries:[],builds:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null};
const sfSiteKey='siteforge:selected-site';
const sfRemember=id=>{try{if(id)localStorage.setItem(sfSiteKey,id)}catch{}};
const state={sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null,selectionToken:0,building:false};
const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',settings:'网站设置'};
async function api(url,method='GET',data=null,extraHeaders={}){
const headers={...extraHeaders};if(method!=='GET'&&method!=='HEAD')headers['X-SiteForge-CSRF']=state.csrf;
@@ -13,7 +15,7 @@ async function api(url,method='GET',data=null,extraHeaders={}){
const res=await fetch(url,{method,headers,body,credentials:'same-origin'});
if(res.status===401){location.href='/login';throw Error('登录已过期,请重新登录')}
let value;try{value=await res.json()}catch{throw Error('服务器响应异常(HTTP '+res.status+')')}
if(!res.ok)throw Error(typeof value.detail==='string'?value.detail:JSON.stringify(value.detail||value));return value;
if(!res.ok){const ex=Error(typeof value.detail==='string'?value.detail:JSON.stringify(value.detail||value));ex.status=res.status;throw ex}return value;
}
function toast(msg,bad=false){const el=$('#toast');el.textContent=msg;el.classList.toggle('bad',bad);el.classList.add('show');clearTimeout(el.timer);el.timer=setTimeout(()=>el.classList.remove('show'),4000)}
function notifyError(error){console.error(error);toast(error?.message||String(error),true)}
@@ -22,6 +24,13 @@ function actionButton(text,action=''){return `<button type="button" class="btn b
function stat(title,num,icon){return `<div class="stat"><span class="glyph">${icon}</span><small>${title}</small><strong class="value">${esc(num)}</strong></div>`}
function emptyState(title,description,button=''){return `<div class="empty"><div class="empty-symbol">◇</div><h3>${esc(title)}</h3><p>${esc(description)}</p>${button}</div>`}
function nav(button){return `data-action="navigate" data-view="${button}"`}
// Business navigation is a shareable, browser-history-aware URL; selection is still authorized by /api/sites.
const siteUrl=(view,site)=>{const q=new URLSearchParams();if(site)q.set('site',site);if(view&&(view!=='home'||site))q.set('view',view);return '/'+(q.size?'?'+q.toString():'')};
function rememberView(replace=false){const url=siteUrl(state.view,state.site?.id);history[replace?'replaceState':'pushState']({site:state.site?.id||null,view:state.view},'',url)}
function activeJobs(){return state.jobs.filter(j=>['queued','running','retrying'].includes(j.state))}
function buildStatusLabel(stateCode){return ({queued:'等待 Worker',running:'正在生成',retrying:'重试中',done:'已完成',failed:'失败',cancelled:'已取消'})[stateCode]||stateCode||'未知'}
function buildReadyStep(){return state.entries.some(e=>e.status!=='draft'&&e.type!=='page')||state.entries.some(e=>e.status!=='draft'&&e.type==='page')}
function siteCard(s){return `<button class="site-card" data-action="select-site" data-id="${esc(s.id)}"><span class="site-icon ${esc(s.theme)}">${esc((s.name||'S').slice(0,1))}</span><span><h3>${esc(s.name)}</h3><p>${esc(s.domain||'尚未设置正式域名')}</p><span class="site-badges"><span>${esc(themeName[s.theme]||'企业网站')}</span><span>·</span><span>${Number(s.entries_count)||0} 条内容</span><span>·</span><span>${Number(s.build_count)||0} 次构建</span></span></span><span class="arrow">›</span></button>`}
function overviewShortcut(view,icon,title,desc){return `<button class="quick-card" ${nav(view)}><span class="quick-ico">${icon}</span><span><h3>${title}</h3><p>${desc}</p><span class="quick-more">前往操作 →</span></span></button>`}
function home(){const sites=state.sites;const total=sites.reduce((n,s)=>n+(+s.entries_count||0),0);const builds=sites.reduce((n,s)=>n+(+s.build_count||0),0);
@@ -40,19 +49,19 @@ function sites(){return htmlHeading('我的网站','统一管理所有网站项
function currentSite(){return !!state.site}
function noSite(){return htmlHeading('请先选择一个网站','选择现有网站或创建新网站,才能继续进行内容编辑、迁移和构建。')+emptyState('尚未选择网站','点击右上角选择网站,或直接创建一个新的企业网站。',actionButton('+ 创建网站','new-site'))}
function latestDone(){return state.builds.find(b=>['done','complete','success','completed'].includes(b.status))||null}
function overview(){const s=state.site;if(!s)return noSite();const count=state.entries.length;const build=latestDone();const hasContent=count>1;const hasDomain=!!s.domain;const steps=[
function overview(){const s=state.site;if(!s)return noSite();const count=state.entries.length;const build=latestDone();const hasContent=buildReadyStep();const hasDomain=!!s.domain;const steps=[
{title:'建立网站',desc:'选择行业主题并创建项目',done:true,view:'settings'},
{title:'准备内容',desc:'添加页面、产品或导入旧数据',done:hasContent,view:'content'},
{title:'完善设计',desc:'选用主题,调整图文和全站风格',done:false,view:'design'},
{title:'构建并预览',desc:'生成静态网站,检查效果与链接',done:!!build,view:'build'}];
const next=steps.find(x=>!x.done)||steps[3];
return htmlHeading(s.name,'这是您的网站工作台。按照下面的顺序操作,完成后就能预览网站。',actionButton('↗ 构建网站','build-now'))+
`<div class="task-note"><div><strong>下一步建议:${next.title}</strong><p>${next.desc}。${!hasDomain?'网站域名也可以稍后再设置。':''}</p></div><button class="btn btn-primary" ${nav(next.view)}>现在去做 →</button></div>`+
{title:'调整设计',desc:'可反复完善主题、导航和移动端样式',optional:true,view:'design'},
{title:'构建并检查',desc:'生成静态网站,查看质量报告',done:!!build,view:'build'}];
const next=!hasContent?steps[1]:!build?steps[3]:null;
return htmlHeading(s.name,'这里显示真实的内容和构建状态。设计可随时调整,正式上线需要单独审核。',actionButton('↗ 构建网站','build-now'))+
`<div class="task-note"><div><strong>${next?'建议下一步:'+next.title:'网站已经生成过构建版本'}</strong><p>${next?next.desc:'内容或设计有修改时,重新构建并检查新版即可。'}。${!hasDomain?'正式域名还未填写;测试预览不受影响。':''}</p></div><button class="btn btn-primary" ${nav(next?.view||'build')}>${next?'现在去做':'查看构建版本'} →</button></div>`+
`<div class="stats">${stat('网站内容',count,'▧')}${stat('已构建版本',state.builds.length,'↗')}${stat('当前主题',themeName[s.theme]||'企业品牌','◈')}${stat('域名状态',hasDomain?'已填写':'待设置','◎')}</div>`+
`<div class="section-heading"><div><h2>四步完成网站制作</h2><p>从左到右进行;每一步都可以随时返回修改。</p></div></div>`+
`<div class="wizard-grid">${steps.map((step,i)=>`<div class="wizard-step ${step.done?'done':next===step?'next':''}"><span class="wizard-index">${step.done?'✓':i+1}</span><h3>${step.title}</h3><p>${step.desc}</p><button ${nav(step.view)}>打开 →</button></div>`).join('')}</div>`+
`<div class="section-heading"><div><h2>常用操作</h2><p>日常更新网站通常只需要使用以下三个入口。</p></div></div>`+
`<div class="quick-grid">${overviewShortcut('content','▧','添加和编辑内容','管理网站页面、产品、新闻、案例。')}${overviewShortcut('design','◈','修改网站样式','更换主题、编辑页面和移动端样式。')}${overviewShortcut('build','↗','查看和下载网站','提交构建,预览并下载静态网站。')}</div>`;
`<div class="section-heading"><div><h2>四步完成网站制作</h2><p>步骤反映已有数据,设计可以随时修改,不会虚报“设计完成”。</p></div></div>`+
`<div class="wizard-grid">${steps.map((step,i)=>`<div class="wizard-step ${step.done?'done':next===step?'next':''}"><span class="wizard-index">${step.done?'✓':step.optional?'◈':i+1}</span><h3>${step.title}${step.optional?'(持续优化)':''}</h3><p>${step.desc}</p><button ${nav(step.view)}>打开 →</button></div>`).join('')}</div>`+
`<div class="section-heading"><div><h2>常用操作</h2><p>通常只需要下面三个入口。</p></div></div>`+
`<div class="quick-grid">${overviewShortcut('content','▧','添加和编辑内容','管理页面、产品、新闻和案例。')}${overviewShortcut('design','◈','修改网站样式','更换主题、编辑页面和移动端样式。')}${overviewShortcut('build','↗','查看和下载网站','查看任务状态、预览及下载静态网站。')}</div>`;
}
function content(){if(!currentSite())return noSite();let arr=state.entries.filter(e=>(!state.typ||e.type===state.typ)&&(!state.search||`${e.title} ${e.route}`.toLowerCase().includes(state.search.toLowerCase())));
return htmlHeading('内容与页面','所有网页、产品、车型和新闻都在这里维护,保存后重新构建即可更新网站。',actionButton('+ 添加内容','new-entry'))+
@@ -66,14 +75,18 @@ function design(){if(!currentSite())return noSite();const s=state.site;const car
`<div class="section-heading"><div><h2>第二步 · 设计网页</h2><p>挑选要修改的页面;可在设计器中编辑导航、页眉、页脚及各设备布局。</p></div></div><div class="panel"><div class="panel-head"><div><h2>选择要设计的页面</h2><p>修改先保存为草稿,正式发布设计后才会参与下一次静态构建。</p></div></div><div class="grid-2 grid-equal">${state.entries.filter(e=>e.type==='page').slice(0,8).map(e=>`<a class="site-card" href="/editor/${encodeURIComponent(s.id)}/${encodeURIComponent(e.id)}"><span class="site-icon">▤</span><span><h3>${esc(e.title)}</h3><p>${esc(e.route)}</p></span><span class="arrow">›</span></a>`).join('')||'<div class="muted">尚无页面,请先添加内容。</div>'}</div></div><div class="help-hint" style="margin-top:19px"><strong>提示</strong>:网站设置用于公司信息和域名;可视化编辑器用于调整页面外观。两者不会混在同一表单里。</div>`;
}
function imports(){if(!currentSite())return noSite();return htmlHeading('导入与迁移','根据您已有的资料选择操作方式:Excel 产品、WordPress 网站,或者外部 API / 数据库。')+
`<div class="quick-grid">${overviewShortcut('import','▤','导入 Excel / CSV','批量增加产品、车型和新闻数据。')}<a class="quick-card" href="/production-v8"><span class="quick-ico">⇄</span><span><h3>迁移 WordPress 旧站</h3><p>迁移会话、文章采集、图片与 SEO 检查。</p><span class="quick-more">打开迁移工作台 ↗</span></span></a><a class="quick-card" href="/production-v8"><span class="quick-ico">⌘</span><span><h3>API / XML / MySQL</h3><p>连接外部业务系统,进行字段映射和分批导入。</p><span class="quick-more">进入高级导入 ↗</span></span></a></div>`+
`<div class="section-heading"><div><h2>快速导入文件</h2><p>适合简单的 Excel、CSV、JSON、WordPress XML 文件。</p></div></div><div class="panel"><h2>上传数据文件</h2><p class="panel-desc">已有网站会保留;重复页面路径默认跳过。复杂迁移请使用上方的专业迁移工作台先预检。</p><form id="importForm"><div class="import-box"><label for="importFile"><strong>选择要导入的数据文件</strong></label><input type="file" id="importFile" name="file" accept=".csv,.xlsx,.xls,.json,.xml" required><p class="muted">支持 CSV、Excel、JSON、WordPress XML;单文件最多 15 MB。</p></div><div class="form-actions"><button class="btn btn-primary" type="submit">确认导入数据</button><a href="/production-v8" class="btn">需要预演与批次回滚?</a></div></form><div id="importSummary" class="message" style="margin-top:15px" hidden></div></div>`+
`<div class="quick-grid"><a class="quick-card" href="/production-v8"><span class="quick-ico">▤</span><span><h3>导入 Excel / CSV</h3><p>推荐先预览字段、检查冲突,再确认提交。</p><span class="quick-more">安全导入与批次回滚 →</span></span></a><a class="quick-card" href="/production-v8"><span class="quick-ico">⇄</span><span><h3>迁移 WordPress 旧站</h3><p>迁移会话、文章采集、图片与 SEO 检查。</p><span class="quick-more">打开迁移工作台 ↗</span></span></a><a class="quick-card" href="/production-v8"><span class="quick-ico">⌘</span><span><h3>API / XML / MySQL</h3><p>连接外部业务系统,进行字段映射和分批导入。</p><span class="quick-more">进入高级导入 ↗</span></span></a></div>`+
`<div class="panel"><h2>推荐:先预演再导入</h2><p>在数据导入工作台检查字段匹配、页面路径冲突及品牌分类关系,再分批提交。</p><a class="btn btn-primary" href="/production-v8">打开安全导入工作台 →</a><details class="form-expand" style="margin-top:20px"><summary>高级:直接写入网站(不可批次回滚)</summary><p class="panel-desc">仅用于确认内容无误的小文件,建议先备份;复杂迁移请使用上方工作台。</p><form id="importForm"><div class="import-box"><label for="importFile"><strong>选择要导入的数据文件</strong></label><input type="file" id="importFile" name="file" accept=".csv,.xlsx,.xls,.json,.xml" required><p class="muted">支持 CSV、Excel、JSON、WordPress XML;单文件最多 15 MB。</p></div><div class="form-actions"><button class="btn btn-primary" type="submit">确认导入数据</button><a href="/production-v8" class="btn">需要预演与批次回滚?</a></div></form><div id="importSummary" class="message" style="margin-top:15px" hidden></div></details></div>`+
`<div class="help-hint" style="margin-top:17px"><strong>迁移旧网站请注意:</strong>如果需要保留旧 URL、原有 SEO、复杂插件字段和全部图片,请优先使用 WordPress 迁移工作台,而不是仅上传一个内容文件。</div>`;
}
function builds(){if(!currentSite())return noSite();const latest=latestDone();return htmlHeading('构建与交付','将当前网站数据生成为独立的 HTML 文件。先预览和检查,再决定是否发布。',actionButton('↗ 开始构建','build-now'))+
`<div class="task-note"><div><strong>${latest?'上次构建已完成,可以继续预览':'您还没有完成网站构建'}</strong><p>内容或设计有更新时,需要重新构建才能生成新版本。</p></div><a class="btn" href="/operations">构建任务与发布审批 ↗</a></div>`+
`<div class="panel"><div class="panel-head"><div><h2>构建版本</h2><p>下载的 ZIP 是独立静态网站,可交给运维部署。</p></div></div><div id="jobNotice" class="message" ${state.job?'':'hidden'}>${esc(state.job||'')}</div>`+
(state.builds.length?state.builds.slice(0,20).map(b=>`<div class="build-row"><span class="quick-ico">${['done','completed','success','complete'].includes(b.status)?'✓':'◷'}</span><div class="build-meta"><h3>${esc(fmtDate(b.created_at))} · ${esc(b.total_pages||'—')} 个页面</h3><p>版本 ${esc(b.id.slice(0,12))} · 状态 ${esc(b.status||'未知')}</p></div><div class="inline-actions"><a class="btn btn-tiny" href="/preview/${encodeURIComponent(state.site.id)}/${encodeURIComponent(b.id)}/" target="_blank" rel="noopener">预览 ↗</a><button class="btn btn-tiny" data-action="report" data-id="${esc(b.id)}">质量报告</button><a class="btn btn-tiny btn-primary" href="/api/sites/${encodeURIComponent(state.site.id)}/builds/${encodeURIComponent(b.id)}/download">下载 ZIP</a></div></div>`).join(''):emptyState('尚无构建版本','网站内容和设计准备好后,点击“开始构建”生成独立网站文件。',actionButton('开始构建','build-now')))+`<div id="reportContent"></div></div>`;
function builds(){if(!currentSite())return noSite();const latest=latestDone();const pending=activeJobs();const first=pending[0];
return htmlHeading('构建与交付','网站构建由独立 Worker 执行。离开页面或刷新后仍可查看任务状态。',`<button type="button" class="btn btn-primary" data-action="build-now" ${pending.length||state.building?'disabled':''}>${pending.length?'已有构建在运行':'↗ 开始构建'}</button>`)+
`<div class="task-note"><div><strong>${first?'当前:'+buildStatusLabel(first.state):latest?'已有构建版本,可以预览':'还没有已完成的网站构建'}</strong><p>${first?'当前任务 '+esc(first.id.slice(0,12))+';Worker 在后台执行,您可以关闭此页面。':'内容或设计有更新时,重新生成网站版本。'}</p></div><div class="inline-actions"><button class="btn" data-action="check-readiness">交付前检查</button><a class="btn" href="/operations">任务与发布审批 ↗</a></div></div>`+
`<div class="panel"><div class="panel-head"><div><h2>后台任务</h2><p>可以查看任务状态。仅取消当前仍在队列或运行中的构建。</p></div><button class="btn btn-tiny" data-action="refresh-builds">刷新状态</button></div><div id="jobNotice" class="message" ${state.job?'':'hidden'}>${esc(state.job||'')}</div>`+
(state.jobs.length?state.jobs.slice(0,8).map(j=>`<div class="build-row"><span class="quick-ico">${j.state==='done'?'✓':j.state==='failed'?'!':'◷'}</span><div class="build-meta"><h3>${esc(buildStatusLabel(j.state))}</h3><p>${esc(fmtDate(j.created_at))} · 任务 ${esc(j.id.slice(0,12))}${j.state==='failed'&&j.error?' · '+esc(j.error):''}</p></div>${['queued','running','retrying'].includes(j.state)?`<button class="btn btn-tiny btn-danger" data-action="cancel-job" data-id="${esc(j.id)}">取消任务</button>`:''}</div>`).join(''):'<p class="muted">当前网站还没有后台构建任务。</p>')+`</div>`+
`<div class="panel" style="margin-top:18px"><div class="panel-head"><div><h2>构建版本</h2><p>建议先预览和核对质量报告,再下载 ZIP。预览不代表正式发布。</p></div></div>`+
(state.builds.length?state.builds.slice(0,20).map(b=>`<div class="build-row"><span class="quick-ico">${['done','completed','success','complete'].includes(b.status)?'✓':'◷'}</span><div class="build-meta"><h3>${esc(fmtDate(b.created_at))} · ${esc(b.total_pages||'—')} 个页面</h3><p>版本 ${esc(b.id.slice(0,12))} · ${esc(b.status||'未知')}</p></div><div class="inline-actions">${['done','completed','success','complete'].includes(b.status)?`<a class="btn btn-tiny" href="/preview/${encodeURIComponent(state.site.id)}/${encodeURIComponent(b.id)}/" target="_blank" rel="noopener">预览 ↗</a><button class="btn btn-tiny" data-action="report" data-id="${esc(b.id)}">质量报告</button><a class="btn btn-tiny btn-primary" href="/api/sites/${encodeURIComponent(state.site.id)}/builds/${encodeURIComponent(b.id)}/download">下载 ZIP</a>`:'<span class="muted">尚未完成,暂不能下载</span>'}</div></div>`).join(''):emptyState('尚无完成版本','准备好网站数据后,在上方提交构建任务。'))+
`<div id="reportContent"></div></div>`;
}
function settings(){if(!currentSite())return noSite();const s=state.site;return htmlHeading('网站设置','这里只管理公司资料、域名与站点属性。网页样式请到“外观与设计”修改。')+
`<div class="panel"><h2>基本资料</h2><p class="panel-desc">没有域名也可以制作与预览网站,正式上线前再填写即可。</p><form id="settingsForm"><div class="field-grid"><label class="full">网站名称<input name="name" required value="${esc(s.name)}"></label><label class="full">正式域名<input name="domain" placeholder="https://www.example.com" value="${esc(s.domain||'')}"></label><label class="full">企业介绍 / 网站标语<textarea name="tagline" rows="3">${esc(s.tagline||'')}</textarea></label><label class="full">Logo 图片地址<input name="logo" value="${esc(s.logo||'')}"></label><label>网站类型<select name="mode"><option value="new" ${s.mode==='new'?'selected':''}>新建网站</option><option value="migration" ${s.mode==='migration'?'selected':''}>旧站迁移</option><option value="hybrid" ${s.mode==='hybrid'?'selected':''}>混合建站</option></select></label><label>主题色<input type="color" name="accent" value="${esc(s.accent||'#245ac6')}" style="padding:4px;height:43px"></label></div><div class="form-actions"><button class="btn btn-primary" type="submit">保存网站设置</button></div></form></div>`;
@@ -83,8 +96,18 @@ function rerender(){const area=$('#mainContent');const selected=state.view;$('#b
area.innerHTML=({home,sites,overview,content,design,import:imports,build:builds,settings}[selected]||home)();
}
async function loadSites(){state.sites=await api('/api/sites');const picker=$('#sitePicker'),prev=state.site?.id||'';picker.innerHTML='<option value="">选择网站…</option>'+state.sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');picker.value=state.sites.some(s=>s.id===prev)?prev:''}
async function selectSite(id,go='overview'){if(!id){state.site=null;state.entries=[];state.builds=[];state.view='sites';$('#sitePicker').value='';rerender();return}try{const [site,entries,builds]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`)]);state.site=site;state.entries=entries;state.builds=builds;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();window.scrollTo({top:0,behavior:'instant'})}catch(e){notifyError(e)}}
function navigate(to){if(!state.site&&!['home','sites'].includes(to)){state.view='sites';rerender();toast('请先选择网站项目');return}state.view=to;rerender();closeMobile();window.scrollTo({top:0,behavior:'instant'})}
async function readJobs(siteId){try{return await api(`/api/enterprise/sites/${encodeURIComponent(siteId)}/jobs`)}catch(e){if(![403,404].includes(Number(e.status)))console.warn('加载任务状态失败',e);return []}}
async function selectSite(id,go='overview',historyMode='push'){
const token=++state.selectionToken;
if(!id){state.site=null;state.entries=[];state.builds=[];state.jobs=[];state.job=null;state.view=go==='home'?'home':'sites';$('#sitePicker').value='';rerender();if(historyMode!=='none')rememberView(historyMode==='replace');return}
if(!state.sites.some(x=>x.id===id)){toast('该网站不在您的可见列表中',true);return}
try{const [site,entries,builds,jobs]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`),readJobs(id)]);
if(token!==state.selectionToken)return;
state.site=site;sfRemember(id);state.entries=entries;state.builds=builds;state.jobs=jobs;state.job=null;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();if(historyMode!=='none')rememberView(historyMode==='replace');closeMobile();window.scrollTo({top:0,behavior:'instant'});
}catch(e){if(token===state.selectionToken)notifyError(e)}
}
function navigate(to,historyMode='push'){if(!state.site&&!['home','sites'].includes(to)){state.view='sites';rerender();toast('请先选择网站项目');return}state.view=to;rerender();closeMobile();if(historyMode!=='none')rememberView(historyMode==='replace');if(to==='build')refreshBuilds();window.scrollTo({top:0,behavior:'instant'})}
async function refreshBuilds(){const id=state.site?.id;if(!id)return;try{const previouslyRunning=activeJobs().length>0;const [jobs,builds]=await Promise.all([readJobs(id),api(`/api/sites/${encodeURIComponent(id)}/builds`)]);if(state.site?.id!==id)return;state.jobs=jobs;state.builds=builds;if(previouslyRunning&&!activeJobs().length){const last=jobs[0];state.job=last?.state==='done'?'后台构建已完成,请预览并查看质量报告。':last?.state==='failed'?'本次构建失败,请在任务中心检查原因。':last?.state==='cancelled'?'本次构建已取消。':null}if(state.view==='build')rerender()}catch(e){notifyError(e)}}
function openModal(name){const node=$('#'+name);node.showModal();node.querySelector('input:not([type=hidden]),select')?.focus()}
function closeModal(name){$('#'+name)?.close()}
function openEntry(id){const form=$('#entryForm');form.reset();form.elements.entry_id.value='';$('#entryModalTitle').textContent=id?'修改内容':'添加内容';if(id){const e=state.entries.find(x=>x.id===id);if(!e)return toast('这条内容不存在',true);for(const field of ['title','route','summary','body','seo_title','seo_description','type','status']){if(form.elements[field])form.elements[field].value=e[field]||''}form.elements.entry_id.value=id}openModal('entryModal')}
@@ -96,20 +119,42 @@ async function setTheme(theme){if(!state.site||theme===state.site.theme)return;i
async function saveSettings(ev){ev.preventDefault();try{await api(`/api/sites/${state.site.id}`,'PATCH',Object.fromEntries(new FormData(ev.currentTarget)));toast('网站设置已保存');await loadSites();await selectSite(state.site.id,'settings')}catch(e){notifyError(e)}}
async function importFile(ev){ev.preventDefault();try{if(!state.site)return;const form=new FormData(ev.currentTarget);if(!form.get('file')?.name)throw Error('请选择文件');if(!confirm('将文件内容直接写入当前网站,重复路径默认跳过。需要预演和批次回滚请先使用高级导入。确认继续?'))return;const result=await api(`/api/sites/${state.site.id}/import`,'POST',form);const box=$('#importSummary');box.hidden=false;box.textContent=`导入完成:新增 ${result.imported||0} 条,更新 ${result.updated||0} 条,跳过 ${result.skipped||0} 条。错误 ${result.errors?.length||0} 条。`;toast('导入已完成');await refreshSiteKeepView()}catch(e){notifyError(e)}}
async function refreshSiteKeepView(){if(!state.site)return;const [site,entries,builds]=await Promise.all([api('/api/sites/'+state.site.id),api('/api/sites/'+state.site.id+'/entries'),api('/api/sites/'+state.site.id+'/builds')]);state.site=site;state.entries=entries;state.builds=builds}
async function buildNow(){if(!state.site)return toast('请先选择网站',true);const id=state.site.id;try{navigate('build');state.job='正在提交网站构建任务…';rerender();const job=await api(`/api/sites/${id}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()});state.job=`网站已进入构建队列(任务 ${job.id}),关闭页面也可以继续运行。`;rerender();for(let i=0;i<120;i++){await new Promise(resolve=>setTimeout(resolve,1600));if(state.site?.id!==id||state.view!=='build')break;const status=await api(`/api/enterprise/sites/${id}/jobs/${job.id}`);if(['done','failed','cancelled'].includes(status.state)){state.job=status.state==='done'?'网站构建已完成,可以预览和下载。':`构建${status.state==='cancelled'?'已取消':'失败'}:${status.error||'请在任务中心查看详情'}`;await refreshSiteKeepView();rerender();toast(state.job,status.state!=='done');break}if(i%5===0){state.job=`Worker 正在构建(${status.state||'等待中'})…`;$('#jobNotice')?.replaceChildren(document.createTextNode(state.job))}}}catch(e){notifyError(e)}}
async function cancelJob(jobId){if(!state.site)return;if(!confirm('确定取消这个构建任务?已经完成的静态版本不会被删除。'))return;try{const id=state.site.id;await api(`/api/enterprise/sites/${encodeURIComponent(id)}/jobs/${encodeURIComponent(jobId)}/cancel`,'POST');toast('已发送取消请求');await refreshBuilds()}catch(e){notifyError(e)}}
async function buildNow(){if(!state.site)return toast('请先选择网站',true);if(state.building||activeJobs().length)return toast('已有构建在执行,请先查看任务状态');
const id=state.site.id;state.building=true;
try{navigate('build');state.job='正在提交构建任务…';rerender();const job=await api(`/api/sites/${encodeURIComponent(id)}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()});
if(state.site?.id!==id)return;state.job=`任务 ${job.id?.slice(0,12)||'已提交'} 已进入队列,可以离开页面;重新打开“构建与交付”查看结果。`;toast('已提交构建任务');await refreshBuilds();
}catch(e){notifyError(e)}finally{state.building=false;if(state.site?.id===id&&state.view==='build')rerender()}}
async function checkReadiness(){if(!state.site)return;const siteId=state.site.id;
try{const result=await api(`/api/factory/sites/${encodeURIComponent(siteId)}/migration/readiness`);
if(state.site?.id!==siteId)return;
const box=$('#reportContent');if(!box)return;
const gates=result.gates||[];
box.innerHTML=`<div class="panel" style="margin-top:18px"><h3>正式交付前检查</h3><p class="muted">检查旧站 URL、SEO 和静态文件风险;通过检查不等于已经上线。</p><p class="${result.can_publish?'pill success':'pill warn'}">${result.can_publish?'没有发现发布阻断项':'存在需要先处理的问题'}</p>${gates.length?'<ul class="readiness-list">'+gates.map(g=>`<li><strong>${g.level==='block'?'必须修复':'需要复核'}</strong> ${esc(g.message)}</li>`).join('')+'</ul>':'<p>当前没有需要处理的检查项。仍需人工确认内容、域名与询盘表单。</p>'}<div class="form-actions"><a class="btn" href="/operations">进入迁移复核与发布审批 →</a></div></div>`;
box.scrollIntoView({behavior:'smooth',block:'nearest'});
}catch(e){notifyError(e)}
}
async function showReport(buildId){try{const r=await api(`/api/sites/${state.site.id}/builds/${buildId}/report`);const target=$('#reportContent');if(!target)return;target.innerHTML=`<div class="panel" style="margin-top:16px"><h3>质量检查结果</h3><p class="muted">本报告检查静态内容与资源,不保证搜索引擎排名。</p><div class="stats">${stat('HTML 页面',r.pages||0,'▤')}${stat('重定向',r.redirects||0,'⇄')}${stat('已本地化图片',r.mirrored_images||0,'▧')}${stat('提示数量',(r.warnings||[]).length,'!')}</div>${r.warnings?.length?`<div class="message">${r.warnings.slice(0,20).map(x=>esc(x)).join('\n')}</div>`:'<span class="pill success">当前检查没有警告</span>'}</div>`;target.scrollIntoView({behavior:'smooth',block:'nearest'})}catch(e){notifyError(e)}}
function closeMobile(){$('#sideMenu').classList.remove('open');$('#mobileBackdrop').classList.remove('open');$('#menuToggle').setAttribute('aria-expanded','false')}
async function init(){try{const me=await api('/api/auth/me');state.csrf=me.csrf;await loadSites();const health=await api('/api/health');$('#appVersion').textContent='版本 '+health.version;
// Opening the home screen intentionally does not auto-select a site.
navigate('home')}catch(e){notifyError(e);$('#mainContent').innerHTML=emptyState('后台加载失败',e.message,'<button class="btn btn-primary" onclick="location.reload()">重新加载</button>')}}
// Restore URL first, then a previously selected site *only* after verifying /api/sites.
const params=new URLSearchParams(location.search),requested=params.get('site');
const target=params.get('view');const allowed=new Set(['home','sites','overview','content','design','import','build','settings']);
let stored='';try{stored=localStorage.getItem(sfSiteKey)||''}catch{}
const chosen=[requested,stored].find(id=>id&&state.sites.some(x=>x.id===id));
if(chosen){await selectSite(chosen,allowed.has(target)?target:'overview','replace')}
else {state.view=target==='sites'?'sites':'home';rerender();rememberView(true)}
}catch(e){notifyError(e);$('#mainContent').innerHTML=emptyState('后台加载失败',e.message,'<button class="btn btn-primary" onclick="location.reload()">重新加载</button>')}}
// Delegated UI interactions keep navigation and form actions centrally auditable.
document.addEventListener('click',async ev=>{const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='new-site')openModal('siteModal');if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='report')await showReport(control.dataset.id)}catch(e){notifyError(e)}});
document.addEventListener('click',async ev=>{const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='new-site')openModal('siteModal');if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='refresh-builds')await refreshBuilds();if(action==='check-readiness')await checkReadiness();if(action==='cancel-job')await cancelJob(control.dataset.id);if(action==='report')await showReport(control.dataset.id)}catch(e){notifyError(e)}});
document.addEventListener('click',ev=>{const btn=ev.target.closest('[data-close]');if(btn)closeModal(btn.dataset.close)});
$('#siteForm').addEventListener('submit',saveSite);$('#entryForm').addEventListener('submit',saveEntry);
document.addEventListener('submit',ev=>{if(ev.target.id==='settingsForm')saveSettings(ev);if(ev.target.id==='importForm')importFile(ev)});
document.addEventListener('input',ev=>{if(ev.target.id==='contentSearch'){state.search=ev.target.value;const cursor=ev.target.selectionStart;const type=state.typ;rerender();const el=$('#contentSearch');el.focus();el.setSelectionRange(cursor,cursor);state.typ=type}});
document.addEventListener('change',ev=>{if(ev.target.id==='contentType'){state.typ=ev.target.value;rerender()}});
$('#sitePicker').addEventListener('change',ev=>selectSite(ev.target.value));
window.addEventListener('popstate',async()=>{const p=new URLSearchParams(location.search);const site=p.get('site'),view=p.get('view')||'home';const allowed=['home','sites','overview','content','design','import','build','settings'];if(site&&state.sites.some(s=>s.id===site))await selectSite(site,allowed.includes(view)?view:'overview','none');else{state.selectionToken++;state.site=null;state.entries=[];state.jobs=[];state.builds=[];state.view=view==='sites'?'sites':'home';rerender()}});
setInterval(()=>{if(!document.hidden&&state.view==='build'&&activeJobs().length)refreshBuilds()},5000);
$('#newSiteTop').addEventListener('click',()=>openModal('siteModal'));
$('#logoutBtn').addEventListener('click',async()=>{try{await api('/api/auth/logout','POST');location.href='/login'}catch(e){notifyError(e)}});
$('#menuToggle').addEventListener('click',()=>{const open=$('#sideMenu').classList.toggle('open');$('#mobileBackdrop').classList.toggle('open',open);$('#menuToggle').setAttribute('aria-expanded',String(open))});
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge Studio · 可视化建站</title><link rel="stylesheet" href="/ui/editor.css"><script defer src="/ui/editor.js"></script></head>
<body>
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge Studio · 可视化建站</title><link rel="stylesheet" href="/ui/editor.css"><script defer src="/ui/editor.js"></script><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head>
<body data-sf-page="editor">
<div class="studio">
<header class="studio-top">
<div class="brand"><a href="/" title="返回网站管理">◈</a><div><b>SiteForge</b><small>VISUAL STUDIO</small></div></div>
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 高级建站中心</title><link rel="stylesheet" href="/ui/factory-v2.css"><script defer src="/ui/factory-v2.js"></script></head>
<body><div class="shell">
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 高级建站中心</title><link rel="stylesheet" href="/ui/factory-v2.css"><script defer src="/ui/factory-v2.js"></script><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head>
<body data-sf-page="factory"><div class="shell">
<aside class="sidebar"><a class="identity" href="/factory-studio"><span class="mark">SF</span><span><strong>SiteForge</strong><small>高级建站工具</small></span></a>
<div class="side-title">生产中心 / WORKSPACE</div>
<nav id="navtabs"><button data-tab="overview" class="active">◫ &nbsp; 网站总览</button><button data-tab="themes">▧ &nbsp; 主题与版本</button><button data-tab="content">▤ &nbsp; 内容与数据装配</button><button data-tab="migration">⇄ &nbsp; 迁移与 SEO 验收</button><button data-tab="build">◈ &nbsp; 构建与发布</button></nav>
+1 -1
View File
@@ -1,4 +1,4 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 企业运行中心</title><link rel="stylesheet" href="/ui/operations.css"><script defer src="/ui/operations.js"></script></head><body>
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 企业运行中心</title><link rel="stylesheet" href="/ui/operations.css"><script defer src="/ui/operations.js"></script><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head><body data-sf-page="operations">
<div class="layout"><aside><a class="wordmark" href="/"><b>◆</b><span><strong>SiteForge</strong><small>高级运行工具</small></span></a><p class="eyebrow">网站生产工厂</p><a href="/">▦ 返回网站工作台</a><a href="/theme-gallery">▤ 主题预览</a><a class="active" href="/operations">⇄ 迁移与交付中心</a><div class="sidefoot">Web + Worker<br>PostgreSQL / Redis 可选部署</div></aside>
<main><header><div><span class="crumb">SiteForge / 企业运行中心</span><h1>迁移、构建与发布</h1><p>同一个界面核对迁移数据,执行静态构建、审批、发布与回滚。</p></div><div class="right"><label>网站项目<select id="site"></select></label><button id="reload" class="secondary">刷新</button></div></header>
<div id="notice" role="status"></div><div class="metrics"><div><span>站点构建</span><strong id="statBuild">—</strong></div><div><span>待处理任务</span><strong id="statJobs">—</strong></div><div><span>审批请求</span><strong id="statApprovals">—</strong></div><div><span>迁移模式</span><strong id="statMode">—</strong></div></div>
+2 -2
View File
@@ -3,8 +3,8 @@
:root{font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;color:#202a39;background:#f4f7fb}*{box-sizing:border-box}body{margin:0}header{display:flex;align-items:center;justify-content:space-between;background:#101d31;color:white;padding:18px 28px;gap:16px}header h1{font-size:18px;margin:0}header small{color:#a4b9d4}main{max-width:1320px;margin:24px auto;padding:0 20px}.pill{border-radius:20px;background:#eaf2ff;color:#164a8d;font-size:12px;padding:5px 10px}.muted{color:#6d7685;font-size:13px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.nav{display:flex;gap:10px}.nav a{color:#d8e8f9;font-size:13px;text-decoration:none}.kpi{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0}.kpi article,.panel{background:white;border:1px solid #e1e8f1;border-radius:12px;padding:20px;box-shadow:0 2px 12px #10203806}.kpi strong{display:block;font-size:25px;margin-top:8px}.layout{display:grid;grid-template-columns:1fr 1fr;gap:18px}.panel h2{font-size:17px;margin:0 0 6px}.panel h3{font-size:14px;margin:16px 0 8px}label{display:block;font-size:12px;color:#526075;font-weight:600;margin-top:13px;margin-bottom:5px}input,select,textarea{width:100%;padding:10px 11px;font:inherit;font-size:13px;border:1px solid #cbd6e6;border-radius:7px;background:white}textarea{min-height:115px;resize:vertical}button{cursor:pointer;border:1px solid #c5d2e4;border-radius:8px;background:white;color:#213249;font-weight:600;font-size:13px;padding:10px 14px}button:hover{border-color:#2274bd}button.primary{background:#1658a6;color:#fff;border-color:#1658a6}button.danger{color:#b22d2d}button:disabled{opacity:.45;cursor:not-allowed}hr{border:0;border-top:1px solid #e8edf5;margin:20px 0}.message{white-space:pre-wrap;font-size:12px;line-height:1.55;overflow:auto;max-height:270px;background:#f5f8fc;padding:12px;border-radius:8px}.results{max-height:270px;overflow:auto}.results>div{padding:10px;border-bottom:1px solid #eef1f5;font-size:13px;display:flex;gap:10px;justify-content:space-between}.foot{margin:20px 0;color:#6f7b8b;font-size:12px}.ok{color:#176d45}.err{color:#c0392b}@media(max-width:850px){.layout,.kpi{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column}main{margin:12px auto}}
[hidden]{display:none!important}.tool-switch{display:flex;flex-wrap:wrap;gap:9px;background:#fff;padding:12px;margin:18px 0 0;border-radius:12px;border:1px solid #e1e8f1}.tool-btn{background:#f6f8fc;color:#50627c;border-color:transparent;padding:11px 14px}.tool-btn.active{background:#e8f0ff;color:#174ea7;border:1px solid #a5c5f5}.tool-help{font-size:12px;color:#64748b;margin:12px 5px 16px}.advanced-layout{grid-template-columns:minmax(0,1fr)}.advanced-layout>.panel{width:100%}.nav a:first-child{border:1px solid #7793bd;padding:9px 11px;border-radius:7px;background:#fff1}@media(max-width:720px){.tool-switch{gap:5px}.tool-btn{padding:9px 11px}}
</style></head><body>
<header><div><h1>SiteForge · 网站生产控制台 <span style="font-size:11px;color:#acd5ff">V8.10 · 高级工具</span></h1><small>整站模板 · 数据批次导入 · 可审计回滚 · 静态构建</small></div><div class="nav"><a href="/">返回网站工作台</a><a href="/factory-studio">页面编辑器</a><a href="/theme-gallery">主题中心</a><a href="/operations">任务中心</a></div></header>
</style><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head><body data-sf-page="production">
<header><div><h1>SiteForge · 网站生产控制台 <span style="font-size:11px;color:#acd5ff">V8.11 · 高级工具</span></h1><small>整站模板 · 数据批次导入 · 可审计回滚 · 静态构建</small></div><div class="nav"><a href="/">返回网站工作台</a><a href="/factory-studio">页面编辑器</a><a href="/theme-gallery">主题中心</a><a href="/operations">任务中心</a></div></header>
<main>
<div class="panel"><div class="row" style="justify-content:space-between"><div><h2>选择要操作的网站</h2><div class="muted">所有批次、模板和构建记录均按网站隔离;新网站可先在网站工作台创建。</div></div><select id="site" style="max-width:410px" aria-label="选择网站"></select></div></div>
<section class="kpi"><article><span class="muted">当前网站</span><strong id="siteName">—</strong></article><article><span class="muted">内容记录</span><strong id="entryCount">—</strong></article><article><span class="muted">导入批次</span><strong id="batchCount">—</strong></article></section>
+2 -2
View File
@@ -1,6 +1,6 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 完整主题预览中心</title>
<style>*{box-sizing:border-box}body{margin:0;background:#eef1f5;font:14px/1.6 system-ui,'Microsoft YaHei',sans-serif;color:#172c42}header{display:flex;align-items:center;justify-content:space-between;padding:14px 26px;background:#fff;border-bottom:1px solid #dce3eb;gap:24px;flex-wrap:wrap}header strong{font-size:17px}header strong span{background:#15253e;color:#fff;border-radius:6px;padding:6px 8px;margin-right:8px}header a{color:#255b99;text-decoration:none}.controls{display:flex;align-items:end;flex-wrap:wrap;gap:12px;padding:18px 26px;border-bottom:1px solid #dce3eb;background:white}.controls label{font-weight:700;color:#5c7083;font-size:11px;display:grid;gap:6px}.controls select{border:1px solid #ccd5df;background:#fff;color:#192d41;padding:10px 14px;border-radius:7px;min-width:132px;font-size:13px}.devices{display:flex;gap:4px;margin-left:auto;background:#f0f3f8;border-radius:8px;padding:4px}.devices button{border:0;background:none;padding:9px 14px;border-radius:6px;cursor:pointer;color:#52697e}.devices button.active{background:#1e446c;color:white}.stage{padding:22px 18px 80px;display:flex;justify-content:center;overflow:auto}.frame{width:100%;height:calc(100vh - 210px);min-height:520px;background:white;box-shadow:0 16px 50px #12304720;border-radius:10px;overflow:hidden;transition:width .25s}.frame iframe{border:0;width:100%;height:100%;background:white}.help{text-align:center;color:#6d8296;font-size:12px;margin:-55px auto 28px}.error{color:#c14f45;display:none}@media(max-width:700px){header,.controls{padding:12px}header strong{font-size:14px}.devices{margin-left:0}.frame{min-height:450px}}</style></head>
<body><header><strong><span>SF</span> SiteForge · 主题预览中心</strong><a href="/factory-studio">返回网站工厂 →</a></header>
<style>*{box-sizing:border-box}body{margin:0;background:#eef1f5;font:14px/1.6 system-ui,'Microsoft YaHei',sans-serif;color:#172c42}header{display:flex;align-items:center;justify-content:space-between;padding:14px 26px;background:#fff;border-bottom:1px solid #dce3eb;gap:24px;flex-wrap:wrap}header strong{font-size:17px}header strong span{background:#15253e;color:#fff;border-radius:6px;padding:6px 8px;margin-right:8px}header a{color:#255b99;text-decoration:none}.controls{display:flex;align-items:end;flex-wrap:wrap;gap:12px;padding:18px 26px;border-bottom:1px solid #dce3eb;background:white}.controls label{font-weight:700;color:#5c7083;font-size:11px;display:grid;gap:6px}.controls select{border:1px solid #ccd5df;background:#fff;color:#192d41;padding:10px 14px;border-radius:7px;min-width:132px;font-size:13px}.devices{display:flex;gap:4px;margin-left:auto;background:#f0f3f8;border-radius:8px;padding:4px}.devices button{border:0;background:none;padding:9px 14px;border-radius:6px;cursor:pointer;color:#52697e}.devices button.active{background:#1e446c;color:white}.stage{padding:22px 18px 80px;display:flex;justify-content:center;overflow:auto}.frame{width:100%;height:calc(100vh - 210px);min-height:520px;background:white;box-shadow:0 16px 50px #12304720;border-radius:10px;overflow:hidden;transition:width .25s}.frame iframe{border:0;width:100%;height:100%;background:white}.help{text-align:center;color:#6d8296;font-size:12px;margin:-55px auto 28px}.error{color:#c14f45;display:none}@media(max-width:700px){header,.controls{padding:12px}header strong{font-size:14px}.devices{margin-left:0}.frame{min-height:450px}}</style><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head>
<body data-sf-page="theme"><header><strong><span>SF</span> SiteForge · 主题预览中心</strong><a href="/factory-studio">返回网站工厂 →</a></header>
<section class="controls"><label>主题<select id="theme"><option value="automotive">Automotive · 汽车与出行</option><option value="industrial">Industrial · 工业制造</option><option value="corporate">Corporate · 企业品牌</option><option value="minimal">Minimal · 极简设计</option></select></label>
<label>页面类型<select id="page"><option value="home">首页</option><option value="about">关于我们</option><option value="listing">产品 / 分类列表</option><option value="category">分类详情</option><option value="product">产品详情</option><option value="brand">品牌详情</option><option value="article">新闻详情</option><option value="case">案例详情</option><option value="service">服务详情</option><option value="contact">联系我们</option><option value="faq">常见问题</option><option value="standard">标准页面</option><option value="notfound">404</option></select></label>
<label>主题版本<select id="version"><option value="">最新</option></select></label><div class="devices"><button data-size="100%" class="active">PC</button><button data-size="820px">平板</button><button data-size="390px">手机</button></div><span class="error" id="error">预览失败,请确认已登录。</span></section>
+1 -1
View File
@@ -1,5 +1,5 @@
"""Single source of truth for the SiteForge platform release identity."""
import os
VERSION = "8.10.0-dev.1"
VERSION = "8.12.0-dev.1"
PLATFORM_NAME = "SiteForge Factory"
BUILD_SHA = os.environ.get("SITEFORGE_GIT_SHA", "unknown").strip()[:40] or "unknown"
+3 -3
View File
@@ -1,4 +1,4 @@
name: siteforge-v8-10-enterprise
name: siteforge-v8-12-enterprise
services:
siteforge:
build:
@@ -6,7 +6,7 @@ services:
dockerfile: Dockerfile.enterprise
args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.10.0-dev.1
image: siteforge/web:8.12.0-dev.1
init: true
restart: unless-stopped
ports:
@@ -48,7 +48,7 @@ services:
retries: 3
start_period: 30s
worker:
image: siteforge/web:8.10.0-dev.1
image: siteforge/web:8.12.0-dev.1
command: ['python','-m','app.worker']
init: true
restart: unless-stopped
+1 -1
View File
@@ -6,7 +6,7 @@ services:
dockerfile: Dockerfile.enterprise
args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.10.0-dev.1
image: siteforge/web:8.12.0-dev.1
command: ['python','-m','uvicorn','app.deploy_agent:app','--host','0.0.0.0','--port','8790']
restart: unless-stopped
environment:
+3 -3
View File
@@ -1,4 +1,4 @@
name: siteforge-v8-10
name: siteforge-v8-10 # pinned: existing volumes with production data follow this project name
services:
siteforge:
build:
@@ -6,7 +6,7 @@ services:
dockerfile: Dockerfile
args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.10.0-dev.1
image: siteforge/web:8.12.0-dev.1
init: true
restart: unless-stopped
ports:
@@ -41,7 +41,7 @@ services:
retries: 3
start_period: 30s
worker:
image: siteforge/web:8.10.0-dev.1
image: siteforge/web:8.12.0-dev.1
depends_on:
siteforge: {condition: service_started}
init: true
+57
View File
@@ -0,0 +1,57 @@
# SiteForge Enterprise V8.11 — 专业后台统一化验收
版本:`8.12.0-dev.1`。以 V8.10 完整项目为基线,**保留所有已有业务 API、数据库与构建 Worker**,仅重构统一界面层及选站导航。
## 1. 核心问题与对应处理
| 旧体验 | 新实现 | 代码位置 |
|---|---|---|
| 5 个专业页面各自导航,反复跳转找不到入口 | 共享顶部导航、面包屑、当前任务说明、返回业务工作台入口 | `app/static/professional-ui.js`, `professional-ui.css` |
| 当前网站在不同工具间失效或被重置 | 使用同域 `siteforge:selected-site` 偏好记录,**再次经过 API 可见网站列表校验**;旧控件联动 | `professional-ui.js`, `workspace.js`, `factory-v2.js`, `operations.js` |
| 编辑器按钮密集,草稿、构建、发布混在一起 | 操作区域分组,草稿和正式发布文案清楚区分 | `professional-ui.js`, `professional-ui.css` |
| 手机后台打不开属性/结构面板 | 结构、预览、属性三种移动/平板面板切换 | `professional-ui.js`, `professional-ui.css` |
| 任务中心 CMS、构建、发布全部长列表堆在一页 | 三个任务阶段切换,不删除已有控件或修改业务逻辑 | `professional-ui.js`, `professional-ui.css` |
| 高级建站中心重复侧边栏 | 压缩为水平业务选项卡,共享站点导航 | `professional-ui.css` |
| 表单视觉、按钮焦点不统一 | 统一输入焦点、主次操作、危险色与必填校验可见状态 | `professional-ui.css`, `professional-ui.js` |
| 用户不知道复杂工具做什么 | 每页一行任务提示,可展开查看完整操作顺序和下一步 | `professional-ui.js` |
## 2. 页面覆盖
- `/` 默认简洁工作台(原入口保留,支持 `?view=...&site=...` 深链接)。
- `/editor/{site}/{entry}` 可视化页面编辑器。
- `/theme-gallery` 主题版本与设备预览。
- `/factory-studio` 高级网站设计与数据映射。
- `/production-v8` WordPress / Excel / XML / API / MySQL / 整站模板。
- `/operations` 迁移复核、Worker 任务、审批发布。
- `/classic` 旧式后台保留,不作为默认入口。
## 3. UX 规则
1. 顶层统一入口按照业务目标:工作台、内容管理、外观设计、导入迁移、构建交付。
2. 每个高级页面只显示当前任务的主要内容,原有专业功能通过局部 Tab 展示。
3. 按钮样式统一:蓝色主操作、白色次操作、危险操作文字红色。**危险操作仍使用既有权限校验与业务确认,不依赖 CSS。**
4. 页面选择网站必须校验 `GET /api/sites` 返回的可见 ID;编辑器跨网站切换时跳到目标站点的设计入口,而不拼接旧页面 ID。
5. 新增表单有效性提示使用浏览器原生校验,继续使用既有 `X-SiteForge-CSRF`。
6. 不把设计草稿误显示为网站正式已发布版本。
## 4. 自动化验证
- `python -m pytest -q --disable-warnings` 验证全套回归:**218 项通过**。
- `tests/test_professional_ui_v811.py` 为 8 项专项测试,覆盖受保护页面、资源、选站联动、表单、编辑器、任务分步和旧业务 API。
- `node --check app/static/professional-ui.js` 及既有 JS 文件语法检查。
- `python scripts/verify_release.py` 校验版本标识,CI/Compose 使用 `8.12.0-dev.1`。
## 5. 本轮边界
- 本轮统一的是 **SiteForge 原生管理后台**,不是已经编译完成的 Art Design Pro 官方工程。
- 流程界面优化不代表 CMS 深度迁移、SFTP/S3、公网发布、PostgreSQL/Redis 高可用已经完成。
- 当前执行环境没有完成真实 Docker Engine 和浏览器跨设备 E2E,因此不声称生产视觉验收通过。
- `SiteForge_V8_11_统一专业后台_交互预览.html` 为离线演示,包含模拟数据,**不连接真实 API**。以部署后的后台为实际行为依据。
## 6. 浏览器人工验收要点
- 登录 → 工作台选择站 A → 进入编辑器/主题预览/数据工具/任务中心,当前网站保持为 A;切换 B 后各工具使用 B 的记录。
- 在编辑器检查草稿、预览与发布按钮的分组;移动 390px / 360px 宽度下可切换结构、预览、属性并继续编辑。
- 任务中心切换“迁移 → 构建 → 审批发布”,原有按钮按权限可操作,错误及危险操作有明确反馈。
- 检查页面键盘焦点、必填校验、主次按钮色、系统 401 登录过期回退。
- Docker Compose 部署、升级/回滚后健康接口版本与后台版本一致。
+44
View File
@@ -0,0 +1,44 @@
# SiteForge Enterprise V8.12 — 工作台流程与任务可恢复性
## 本轮目标
在 V8.11 专业页面统一导航之后,继续修复普通用户实际操作中的断点。**本版不声称完成 Art Design Pro 官方前端或 P5 企业级远程发布**,数据库 schema、主题和已有构建引擎未作破坏性变更。
## 真实代码改动
| 旧问题 | 新功能 | 实现位置 |
|---|---|---|
| 刷新后回到首页,丢失当前站点 | 优先解析 URL 中 site/view,之后参考上次网站偏好;**都要先校验 /api/sites 可访问列表** | `app/static/workspace.js` |
| 无法前进/后退或分享指定页面 | 用 `history.pushState` 和 `popstate` 管理站点+菜单状态,避免浏览器刷新;异步选站采用序号避免慢响应覆盖当前站 | `workspace.js` |
| 工作台“完善设计”永远显示未完成 | 设计标为持续优化项,按已有内容与构建确定建议的下一步,不杜撰人工验收结果 | `workspace.js` |
| 关闭/刷新构建页后看不到运行中的任务 | 查询 `/api/enterprise/sites/{id}/jobs` 和已有构建版本;进入构建页自动刷新;有活跃任务时定期刷新并禁止重复点击 | `workspace.js` |
| 任务取消入口不直观 | 在构建页直观显示 Worker 状态与取消按钮,仍调用受权限保护的原 API | `workspace.js` |
| 构建未完成也显示预览/下载入口 | 仅完成状态提供预览、质量报告和 ZIP 下载 | `workspace.js` |
| “快速导入”默认直接写入且不可批次回滚 | 默认进入既有可暂存、预演、确认、回滚的专业导入工作台;快速直写保留在显式展开的高级区并要求确认 | `workspace.js` |
| 难以分清构建与正式发布 | 增加“交付前检查”按钮,调用既有 `/api/factory/sites/{id}/migration/readiness`,展示阻断及待审事项,不将构建完成误称为上线 | `workspace.js` |
## 验证与已知限制
- 后端使用现有 CSRF 与 RBAC,不引入第二套任务队列。
- 离线预览文件使用模拟 API 和示例数据,不连接真实后台,不能用于证明真实浏览器 E2E。
- 正式站点需要实测多网站切换、长耗时 Worker、真实数据导入和 SEO 审核;生产发布仍需 P5 完整实施。
- V8.12 没有声称已经完成 Elementor/WooCommerce 任意站点无损迁移、PostgreSQL/Redis 多实例生产验收或官方 Art Design Pro 编译。
## 推荐人工验收步骤
1. 登录,选择网站 A,进入内容管理;复制浏览器 URL 并刷新,仍在 A 内容页面。
2. 切换 B,使用浏览器返回/前进,正确恢复 A/B 且没有旧站 API 响应覆盖当前站。
3. 提交构建,跳到其他菜单再回来;Worker 运行时界面可恢复;取消按钮有确认。
4. 构建未完成时不提供下载;成功后出现预览、检查及 ZIP。
5. 导入页面的主要操作进入“安全导入”,直写只在高级折叠区。
6. 运行交付前检查,能识别阻断和待审核事项;检查通过也不会显示已上线。
7. 360px 与 390px 下侧边栏、任务列表、操作按钮可触达。
## 运行
```powershell
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
```
**部署建议:** 在新测试卷启动,不直接覆盖原 V8.11 数据卷。检查 `/api/health` 版本为 `8.12.0-dev.1` 后再进行测试。
@@ -20,13 +20,9 @@
## Phase 1+2:平台适配与骨架跑通(Task 1-8)
### Task 1: Git 基线
### Task 1: Git 基线 —— ✅ 已完成(2026-10-22,幂等跳过)
**Files:** 无新文件(初始化仓库)
- [ ] **Step 1:** `cd $ROOT && git init && git add -A && git status --short | head -20`
- [ ] **Step 2:** 确认 `.env`、`storage/`、`hesupipe_clean2.xml` 未被跟踪(.gitignore 已含 `.env`、`storage/`;`git check-ignore .env` 应有输出;`hesupipe_clean2.xml` 加入 .gitignore 一行)
- [ ] **Step 3:** `git commit -m "chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec"`(含 Co-Authored-By 行,下同)
已执行并有扩展:仓库初始化、基线提交、远程 `http://219.157.255.213:25311/xinyouhe/cms-SiteForge.git` 配置并推送(main + feat/trade-pro-theme),远程原有 V5 基线历史经 `-s ours` 无损合并,`docs/COMPLETION_PLAN.md` 带入当前树;`.env`/密码确认未入库。随后追加 **V8_12 原地升级**(26 文件覆盖 + bug 修复重放 + compose 项目名钉回 `siteforge-v8-10` 保护数据卷)。执行者无需重跑本任务。
### Task 2: 主题发现单一源(F1)
@@ -40,15 +36,18 @@
```python
"""Theme discovery must be a single source of truth."""
import json
from pathlib import Path
from app import core, theme_pro, theme_store
ROOT = Path(core.__file__).resolve().parent.parent
def test_discover_returns_builtin_themes_with_manifests():
ids = core.discover_themes()
for tid in ('automotive', 'industrial', 'corporate', 'minimal'):
assert tid in ids
import json
for tid in ids:
manifest = json.loads((core.ROOT / 'themes' / tid / 'manifest.json').read_text(encoding='utf-8'))
manifest = json.loads((ROOT / 'themes' / tid / 'manifest.json').read_text(encoding='utf-8'))
assert manifest.get('id') == tid
def test_three_whitelists_agree():
@@ -56,8 +55,6 @@ def test_three_whitelists_agree():
assert isinstance(core.THEMES, tuple)
```
注:`core.ROOT` 若不存在,测试里用 `pathlib.Path(app.__file__).parent.parent`。
- [ ] **Step 2:** `python -m pytest tests/test_theme_registry.py -q` → 预期 FAIL(`discover_themes` 不存在)
- [ ] **Step 3: 实现**——`app/core.py:12` 替换为:
@@ -208,13 +205,19 @@ def test_trade_pro_registered_and_compatible():
assert result['valid'], result['errors']
assert not result['errors']
def test_trade_pro_builds_real_site():
# 集成冒烟:真实构建函数对演示站点渲染 home 不抛异常(复用现有测试夹具方式,
# 参考 tests/test_theme_pro_v3.py 中的最小站点夹具)
...
def test_trade_pro_builds_real_site(tmp_path):
# 复用 tests/test_theme_pro_v3.py 中"建站→切主题→构建→断言预览页"的既有写法
# (夹具工厂在 tests/test_factory_v2.py:15-30),theme 用 'trade-pro'。
# 真实断言(不允许空壳):
# 1. 构建成功且生成 index.html
# 2. 输出包含 lang="en"
# 3. 不包含平台中文默认导航文案"首页"
html = (tmp_path / 'index.html').read_text(encoding='utf-8') # 按夹具实际构建目录
assert 'lang="en"' in html
assert '>首页<' not in html
```
(第二个测试复用 test_theme_pro_v3.py 已有的最小站点构建夹具写法,theme 传 'trade-pro'。)
(执行时先读 test_theme_pro_v3.py 的相邻测试,逐行复制其夹具调用方式,仅替换 theme 参数与断言;禁止保留 `...` 空壳。)
- [ ] **Step 2:** 跑 → FAIL
- [ ] **Step 3: 创建文件**
@@ -230,7 +233,7 @@ def test_trade_pro_builds_real_site():
"style": "international-b2b",
"entry": "pro.html",
"styles": ["theme.css", "blocks.css"],
"page_templates": ["home","about","contact","listing","product","article","case","standard","category"],
"page_templates": ["home","about","contact","listing","category","product","article","brand","case","service","faq","standard","notfound"],
"blocks": ["hero","richtext","features","stats","collection","image_text","gallery","faq","cta","attributes","spacer","layout","carousel","video","tabs","pricing","map","form_link"],
"responsive": true,
"auto_binding": true,
@@ -240,7 +243,9 @@ def test_trade_pro_builds_real_site():
}
```
`site.html`:以 `themes/industrial/site.html` 为底,改 `lang="zh-CN"` → `lang="en"`,其余变量占位不动(它只包 {{content}})。
`site.html`:以 `themes/industrial/site.html` 为底(它是 4.4KB 的完整独立模板,自带 header/footer 结构,**不引用共享宏库**),改 `lang="zh-CN"` → `lang="en"`。注意:pro 主题的正式渲染走 pro.html,site.html 主要服务非 pro 路径/兜底。
**已知新行为(评审发现,记录不阻断)**:Task 2 后 trade-pro 进入 BUILTIN,上传主题 ZIP 可声明 `layout_family:'trade-pro'`(theme_store.install 会拷贝 trade-pro 的 site/pro.html),而 ZIP 包渲染走共享宏回落——与依赖自有宏库的 trade-pro 存在潜在错配。本轮接受(上传包本就以声明式为主),后续如出问题在 install() 校验 layout_family 白名单。
`_professional_components.html`:先做**透传骨架**(从共享库复制 card/cover 等宏,Task 10 起逐个换成 cms 设计 + SVG 图标)。
@@ -289,9 +294,11 @@ def test_frontend_entries_include_trade_pro():
]:
assert needle in (base / rel).read_text(encoding='utf-8'), rel
def test_theme_color_map_covers_trade_pro():
# theme_pro.make_context 的 theme_color 字典含 trade-pro(渲染 smoke 断言 meta theme-color)
...
def test_theme_color_map_covers_trade_pro(tmp_path):
# 真实断言:trade-pro 构建出的首页含 meta theme-color #0b3d91
# (复用 Task 5 的构建夹具写法)
html = (tmp_path / 'index.html').read_text(encoding='utf-8')
assert '<meta name="theme-color" content="#0b3d91"' in html
```
- [ ] **Step 2:** 跑 → FAIL
@@ -328,11 +335,13 @@ def test_theme_color_map_covers_trade_pro():
- [ ] 验收断言:`themes/trade-pro/theme.css` 覆盖 tokens 全集(颜色/字体/圆角/间距);构建产物 `assets/theme.css` 含 `--sf-tp-` 变量;平台 custom.css 的 `.btn{...}` 与主题按钮类无交集(grep 断言)
- [ ] pytest(现有快照不受影响)+ commit
### Task 9: 图标宏库(替代箭头)
### Task 9: 图标宏库(新增设计——不是 cms 移植)
- 源:cms 组件与 dist HTML 中的内联 SVG(`grep -o '<svg[^>]*>' dist/hesupipe/en/index.html` 起逐个提取;参考 `src/components/*.astro` 里的图标用法)
- [ ] `themes/trade-pro/_professional_components.html` 定义 `icon(name)` 宏:内联 SVG 集合(≥8 个:arrow-right/check/phone/mail/location/whatsapp/download/quote 等,从 cms 实际用到的集合提取)
- [ ] 验收:home/product 页渲染输出含 `<svg` 且无裸文本箭头 `→` 作为唯一图标(对照 dist)
> 评审修正(2026-10-22):cms 源码与 dist 实测 **0 个 SVG**,其图标实为文本箭头 `→`(index 15 处、product 17 处)。用户需求"图标需要优化、现在很多都是箭头"意味着图标是**超出 cms 源的新增设计**,不与 dist 对照。
- [ ] `themes/trade-pro/_professional_components.html` 定义 `icon(name)` 宏:自设计内联 SVG 集合,线条风格与 `--sf-tp-*` 令牌一致,≥8 个:arrow-right / check / phone / mail / location / download / search / quote
- [ ] 尺寸/描边随 CSS 变量(`currentColor`、`1.5px` stroke),跟随深浅文字色
- [ ] 验收:home/product 渲染中**交互元素的图标均为 SVG**,不再以裸 `→` 作为唯一图标(对照对象是本主题自身,非 dist);`aria-hidden="true"` 保证可访问性
- [ ] commit
### Task 10: 九种独立布局逐个移植(每布局一个 commit)
@@ -361,6 +370,14 @@ def test_theme_color_map_covers_trade_pro():
- [ ] 实战验收:站点 483295c29ea0 最终构建 ≥203 页零错误;视觉对照两层标准(spec §6)逐页记录
- [ ] 更新 spec 附录验收记录;commit
## 与 spec 的已记录偏差(评审裁定可接受)
1. **发现函数放 core 而非 spec 4.2.1 写的 theme_store**:等价实现。core 已被全部引用方(builder/main/theme_pro)导入,放 core 消除反向耦合;theme_store.BUILTIN 经 import 别名保持一致。
2. **builder.ENV 保持单目录**(spec 4.2.4 提到两处构造点):已验证 site.html 是不引用共享宏的独立模板,且带主题前缀的路径加载在单目录下安全;仅 theme_pro.LOADER 改为 per-theme env_for。
3. **spec §6 "渲染快照"以 E2E 页面断言实现**(构建后逐页 200 + 结构选择器 + 文案断言),不另建 Jinja 快照测试文件。
4. **/api/themes(main.py:267)经 core.THEMES 动态化自动生效**,不设专项测试(catalog 由既有测试覆盖)。
5. **前端入口 option 文案**:三个 HTML 下拉用"Trade Pro · 外贸 B2B(英文)"中文风格与各文件现状一致(theme-gallery 用英文)。
## 风险与回退
- 每个 Task 独立 commit,出问题 `git revert` 单点回退
@@ -5,7 +5,7 @@
## 1. 背景与目标
把 cms 项目(D:\project\cms)Astro 主题的视觉设计移植为 SiteForge 第 5 套内置主题 `trade-pro`,首要服务 hesupipe.com 重建(英文外贸 B2B),长期服务后续外贸类客户站。附带解决:默认语言英文(现四套主题模板硬编码 `lang="zh-CN"` + 中文文案)、图标体系(现主题大量箭头符号,cms 主题有内联 SVG 图标集)。
把 cms 项目(D:\project\cms)Astro 主题的视觉设计移植为 SiteForge 第 5 套内置主题 `trade-pro`,首要服务 hesupipe.com 重建(英文外贸 B2B),长期服务后续外贸类客户站。附带解决:默认语言英文(现四套主题模板硬编码 `lang="zh-CN"` + 中文文案)。**图标更正(R3.1)**:cms 源码实测 0 个 SVG、图标亦为文本箭头,故"替换箭头为 SVG 图标集"属**新增设计项**(用户明确要求优化图标),不是 cms 移植内容,不与 dist 对照。
### 范围外
@@ -88,7 +88,7 @@ themes/trade-pro/
- `lang="en"`,主题内全部 UI 文案/面包屑/分页/日期英文;英文 chrome 兜底见 4.2.5
- tokens.css 变量 `--sf-tp-*` 桥接 SiteForge 设计令牌(V8.7 全站设计草稿可调),优先级规则见 4.2.6
- 图标:cms 内联 SVG 集合进主题宏库(替代箭头)
- 图标:自设计内联 SVG 集合进主题宏库(替代箭头;**新增设计**,cms 源无 SVG,见 §1 更正)
- 素材规范:主题包仅含中性演示素材;hesupipe 真实图片留在站点数据侧
### 4.6 数据映射与导入通道
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@ password=secrets.token_urlsafe(24)
secret=secrets.token_urlsafe(48)
pg_password=secrets.token_urlsafe(30)
deploy_secret=secrets.token_urlsafe(48)
config.write_text(f'''# SiteForge Factory 8.10.0-dev.1 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\nSITEFORGE_PG_USER=siteforge\nSITEFORGE_PG_PASSWORD={pg_password}\nSITEFORGE_PG_DB=siteforge\nSITEFORGE_DEPLOY_SECRET={deploy_secret}\n# Remote deployment target: configure HTTPS URLs and corresponding secrets manually.\nSITEFORGE_DEPLOY_STAGING_URL=\nSITEFORGE_DEPLOY_STAGING_SECRET=\nSITEFORGE_DEPLOY_PRODUCTION_URL=\nSITEFORGE_DEPLOY_PRODUCTION_SECRET=\n''', encoding='utf-8')
config.write_text(f'''# SiteForge Factory 8.12.0-dev.1 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\nSITEFORGE_PG_USER=siteforge\nSITEFORGE_PG_PASSWORD={pg_password}\nSITEFORGE_PG_DB=siteforge\nSITEFORGE_DEPLOY_SECRET={deploy_secret}\n# Remote deployment target: configure HTTPS URLs and corresponding secrets manually.\nSITEFORGE_DEPLOY_STAGING_URL=\nSITEFORGE_DEPLOY_STAGING_SECRET=\nSITEFORGE_DEPLOY_PRODUCTION_URL=\nSITEFORGE_DEPLOY_PRODUCTION_SECRET=\n''', encoding='utf-8')
try:os.chmod(config,0o600)
except OSError:pass
print('SiteForge 已生成 .env 配置。')
+1 -1
View File
@@ -6,7 +6,7 @@ import sys
ROOT=Path(__file__).resolve().parent.parent
sys.path.insert(0,str(ROOT))
from app.version import VERSION
assert VERSION=='8.10.0-dev.1'
assert VERSION=='8.12.0-dev.1'
api=(ROOT/'app'/'main.py').read_text(encoding='utf-8')
assert 'return builder.build(' not in api, 'A synchronous request-thread build has reappeared'
assert "@app.post('/api/sites/{site_id}/builds', status_code=202)" in api
+85
View File
@@ -0,0 +1,85 @@
"""V8.11: specialized tools share a single task-oriented UI without changing APIs."""
from test_factory import client
PROFESSIONAL = {
'/editor/{site}/{entry}': 'editor',
'/theme-gallery': 'theme',
'/factory-studio': 'factory',
'/operations': 'operations',
'/production-v8': 'production',
}
def create_site(client):
response = client.post('/api/sites', json={'name': 'V8.11 UI', 'theme': 'corporate'})
assert response.status_code == 201, response.text
sid = response.json()['id']
entry = client.get(f'/api/sites/{sid}/entries').json()[0]
return sid, entry['id']
def test_all_professional_pages_load_unified_shell(client):
site, entry = create_site(client)
for pattern, page_type in PROFESSIONAL.items():
uri = pattern.format(site=site, entry=entry)
response = client.get(uri)
assert response.status_code == 200, uri
assert f'data-sf-page="{page_type}"' in response.text, uri
assert '/ui/professional-ui.js' in response.text
assert '/ui/professional-ui.css' in response.text
def test_shared_navigation_assets_are_real_routes(client):
css = client.get('/ui/professional-ui.css')
js = client.get('/ui/professional-ui.js')
assert css.status_code == 200 and js.status_code == 200
assert 'sf-bar-nav' in css.text and '.sf-editor-mobile-tabs' in css.text
for code in ('siteforge:selected-site', 'sfGlobalSite', 'aria-current', 'MutationObserver'):
assert code in js.text
def test_site_switcher_avoids_cross_site_editor_paths(client):
js = client.get('/ui/professional-ui.js').text
assert "ctx==='editor'" in js
assert "location.assign(route('design'))" in js
assert 'items.some(s=>s.id===wanted)' in js # selected ID validated against visible sites
assert 'siteforge:selected-site' in client.get('/ui/workspace.js').text
def test_professional_forms_keep_validation_and_csrf(client):
js = client.get('/ui/professional-ui.js').text
assert "addEventListener('invalid'" in js
assert "aria-invalid" in js
assert 'X-SiteForge-CSRF' in client.get('/ui/operations.js').text
assert 'X-SiteForge-CSRF' in client.get('/ui/workspace.js').text
def test_operations_are_split_into_three_steps(client):
shell = client.get('/ui/professional-ui.js').text
assert '② 生成静态网站' in shell
assert '③ 审批与发布' in shell
assert "role','tabpanel'" in shell
page = client.get('/operations').text
assert all(word in page for word in ('CMS 数据迁移', '生产构建任务', '远程发布'))
def test_editor_preserves_draft_publish_and_mobile_panels(client):
site, entry = create_site(client)
html = client.get(f'/editor/{site}/{entry}').text
assert 'Studio.save()' in html and 'Studio.publishDesign()' in html
js = client.get('/ui/professional-ui.js').text
assert all(word in js for word in ('结构', '页面预览', '属性设置', "dataset.mobilePane"))
assert 'sf-editor-actiongroup' in js
def test_health_and_main_workspace_match_unified_release(client):
assert client.get('/api/health').json()['version'] == '8.12.0-dev.1'
assert 'const headings=' in client.get('/ui/workspace.js').text
assert 'new URLSearchParams(location.search)' in client.get('/ui/workspace.js').text
def test_existing_theme_and_content_apis_unchanged(client):
sid, _ = create_site(client)
assert client.get('/api/themes').status_code == 200
assert client.get(f'/api/sites/{sid}/entries').status_code == 200
assert client.get(f'/api/sites/{sid}/builds').status_code == 200
+1 -1
View File
@@ -145,7 +145,7 @@ def test_v8_api_authenticated_and_browser_page(env):
assert client.get(f'/api/factory/sites/{sid}/staging').json()['items'][0]['state']=='committed'
snap=client.post(f'/api/factory/sites/{sid}/whole-site-templates',json={'name':'Test'})
assert snap.status_code==200,snap.text
assert client.get('/api/health').json()['version']=='8.10.0-dev.1'
assert client.get('/api/health').json()['version']=='8.12.0-dev.1'
def test_wxr_preserves_post_type(env):
+73
View File
@@ -0,0 +1,73 @@
"""V8.12 workbench usability, security boundaries and backend compatibility."""
from test_factory import client
import subprocess
from pathlib import Path
def test_workbench_has_real_entry_and_assets(client):
html=client.get('/').text
assert '/ui/workspace.js' in html and 'sitePicker' in html
assert client.get('/ui/workspace.js').status_code==200
assert client.get('/ui/workspace.css').status_code==200
def test_authorized_site_restoration_and_browser_history(client):
js=client.get('/ui/workspace.js').text
assert 'siteforge:selected-site' in js
assert "state.sites.some(x=>x.id===id)" in js
assert "state.sites.some(x=>x.id===id)" in js
assert "window.addEventListener('popstate'" in js
assert "history[replace?'replaceState':'pushState']" in js
assert "const token=++state.selectionToken" in js
def test_build_screen_recovers_durable_worker_jobs(client):
js=client.get('/ui/workspace.js').text
assert '/api/enterprise/sites/' in js
assert 'function activeJobs()' in js
assert 'refreshBuilds()' in js
assert 'cancel-job' in js and 'async function cancelJob' in js
assert '交付前检查' in js and '/migration/readiness' in js
assert 'setInterval' in js and '!document.hidden' in js
def test_build_rejects_download_while_incomplete(client):
js=client.get('/ui/workspace.js').text
assert '尚未完成,暂不能下载' in js
assert 'status' in js and "['done','completed','success','complete']" in js
def test_safe_import_is_default_and_direct_import_is_explicit(client):
js=client.get('/ui/workspace.js').text
assert '推荐:先预演再导入' in js
assert '直接写入网站(不可批次回滚)' in js
assert '安全导入与批次回滚' in js
assert "if(!confirm('将文件内容直接写入" in js
def test_wizard_does_not_claim_design_automatically_finished(client):
js=client.get('/ui/workspace.js').text
assert 'optional:true,view:\'design\'' in js
assert '不自动标记为完成' not in js or '设计可以随时修改' in js
def test_live_job_endpoint_can_list_user_site_jobs(client):
site=client.post('/api/sites',json={'name':'UI V8.12 smoke','theme':'corporate'})
assert site.status_code==201,site.text
sid=site.json()['id']
result=client.get(f'/api/enterprise/sites/{sid}/jobs')
assert result.status_code==200,result.text
assert isinstance(result.json(),list)
audit=client.get(f'/api/factory/sites/{sid}/migration/readiness')
assert audit.status_code==200,audit.text
assert 'gates' in audit.json()
def test_version_is_updated(client):
assert client.get('/api/health').json()['version']=='8.12.0-dev.1'
def test_js_standalone_syntax():
root=Path(__file__).resolve().parents[1]
proc=subprocess.run(['node','--check',str(root/'app/static/workspace.js')],capture_output=True,text=True)
assert proc.returncode==0,proc.stderr