upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)

Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+7 -3
View File
@@ -13,9 +13,9 @@ SITEFORGE_PG_PASSWORD=GENERATE_STRONG_URL_SAFE_PASSWORD
SITEFORGE_PG_DB=siteforge SITEFORGE_PG_DB=siteforge
# Must be HTTPS and trusted deployment endpoint; never set to an arbitrary user URL # Must be HTTPS and trusted deployment endpoint; never set to an arbitrary user URL
SITEFORGE_DEPLOY_STAGING_URL= SITEFORGE_DEPLOY_STAGING_URL=
SITEFORGE_DEPLOY_STAGING_SECRET= SITEFORGE_DEPLOY_STAGING_SECRET_REF=
SITEFORGE_DEPLOY_PRODUCTION_URL= SITEFORGE_DEPLOY_PRODUCTION_URL=
SITEFORGE_DEPLOY_PRODUCTION_SECRET= SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF=
# Release agent is deployed as an isolated service on the receiving host # Release agent is deployed as an isolated service on the receiving host
SITEFORGE_DEPLOY_SECRET= SITEFORGE_DEPLOY_SECRET=
@@ -34,5 +34,9 @@ SITEFORGE_MYSQL_CA_FILE=
# SITEFORGE_MYSQL_READ_USER=<use Docker secret instead> # SITEFORGE_MYSQL_READ_USER=<use Docker secret instead>
# SITEFORGE_MYSQL_READ_PASSWORD=<use Docker secret instead> # SITEFORGE_MYSQL_READ_PASSWORD=<use Docker secret instead>
# Preferred remote signing secret reference (instead of plaintext _SECRET) # Preferred remote signing secret reference (instead of plaintext _SECRET)
# SITEFORGE_DEPLOY_STAGING_SECRET_REF=env:SITEFORGE_DEPLOY_STAGING_SECRET # SITEFORGE_DEPLOY_STAGING_SECRET_REF=env:SITEFORGE_STAGING_TOKEN
# SITEFORGE_STAGING_TOKEN=<independent random 32+ char token, delivered only to Worker>
# SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF=file:siteforge_deploy_production # SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF=file:siteforge_deploy_production
# Mount /run/secrets/siteforge_deploy_production in Worker using a private Compose override.
# Set SITEFORGE_DEPLOY_SECRET_REF=file:siteforge_deploy_production in the remote Agent.
# Never reuse the same token between staging and production.
+19
View File
@@ -0,0 +1,19 @@
........................................................................ [ 19%]
....................................................F................... [ 39%]
.F.............................sFF............F......................... [ 58%]
...........................................F..........F...F..FFF........ [ 78%]
........................................................................ [ 97%]
........ [100%]
=========================== short test summary info ===========================
FAILED tests/test_p0_backup.py::test_sqlite_backup_and_restore_with_media - P...
FAILED tests/test_p0_p4_completion_v89.py::test_restore_replaces_directory_atomically_and_preserves_previous
FAILED tests/test_release_native_v814.py::test_sftp_immutable_release_atomic_switch_rollback_and_verification
FAILED tests/test_release_native_v814.py::test_sftp_probe_failure_restores_previous_release
FAILED tests/test_release_v813.py::test_agent_rejects_invalid_signature_and_paths
FAILED tests/test_theme_registry.py::test_trade_pro_builds_real_site - assert...
FAILED tests/test_v818_pg_recovery.py::test_pg_env_never_passes_secret_in_commands
FAILED tests/test_v818_pg_recovery.py::test_traversal_and_symlink_refused - O...
FAILED tests/test_v818_sqlite_pg_transfer.py::test_dependent_tables_transferred_with_hashes
FAILED tests/test_v818_sqlite_pg_transfer.py::test_target_nonempty_refuses_all_changes
FAILED tests/test_v818_sqlite_pg_transfer.py::test_migration_guard_and_schema_mismatch
11 failed, 356 passed, 1 skipped in 285.02s (0:04:45)
+59 -4
View File
@@ -26,8 +26,46 @@ jobs:
env: env:
SITEFORGE_ADMIN_PASSWORD: administrator-strong-password-for-ci SITEFORGE_ADMIN_PASSWORD: administrator-strong-password-for-ci
SITEFORGE_SESSION_SECRET: ci-only-session-key-not-for-production-with-entropy SITEFORGE_SESSION_SECRET: ci-only-session-key-not-for-production-with-entropy
SITEFORGE_PG_PASSWORD: ci-only-test-postgresql-password
run: | run: |
bash scripts/check.sh SITEFORGE_REGRESSION_REPEAT=2 bash scripts/check.sh
python scripts/http_end_to_end.py
- name: Upload V8.21 regression evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: v821-regression-${{ matrix.python }}
path: reports/v821-regression*
if-no-files-found: ignore
retention-days: 14
browser-smoke:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install test and Chromium dependencies
run: |
python -m pip install -r requirements-dev.lock
python -m pip install -r requirements-browser.txt
python -m playwright install --with-deps chromium
- name: Run real desktop and mobile browser smoke
env:
SITEFORGE_ADMIN_PASSWORD: browser-ci-only-password
SITEFORGE_SESSION_SECRET: browser-ci-only-session-secret-not-for-production
run: |
mkdir -p reports/browser
python scripts/browser_smoke.py reports/browser
- name: Upload browser screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: v821-browser-smoke
path: reports/browser/
if-no-files-found: ignore
retention-days: 14
container: container:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
@@ -55,7 +93,7 @@ jobs:
sleep 2 sleep 2
done done
test "$web" = healthy && test "$worker" = healthy 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.12.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.21.0-dev.1' and data.get('commit')"
- name: Stop Compose smoke - name: Stop Compose smoke
if: always() if: always()
env: env:
@@ -77,6 +115,13 @@ jobs:
options: >- options: >-
--health-cmd "pg_isready -U siteforge -d siteforge_ci" --health-cmd "pg_isready -U siteforge -d siteforge_ci"
--health-interval 10s --health-timeout 5s --health-retries 5 --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:7.4.2-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s --health-timeout 5s --health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
@@ -84,7 +129,17 @@ jobs:
python-version: '3.12' python-version: '3.12'
- name: Install pinned dependencies and PG driver - name: Install pinned dependencies and PG driver
run: pip install -r requirements.lock -r requirements-enterprise.txt run: pip install -r requirements.lock -r requirements-enterprise.txt
- name: Bootstrap PG and verify transactional queue - name: Install PostgreSQL 16 maintenance clients
run: |
sudo apt-get update -qq
sudo apt-get install -y postgresql-client-16
pg_dump --version
- name: Bootstrap all PG schemas and verify worker queue
env: env:
SITEFORGE_DATABASE_URL: postgresql://siteforge:postgres-ci-only@127.0.0.1:5432/siteforge_ci SITEFORGE_DATABASE_URL: postgresql://siteforge:postgres-ci-only@127.0.0.1:5432/siteforge_ci
run: python scripts/verify_pg_bootstrap.py SITEFORGE_REDIS_URL: redis://127.0.0.1:6379/0
run: |
python scripts/verify_pg_bootstrap.py
SITEFORGE_ALLOW_PG_DATA_SMOKE=1 python scripts/verify_sqlite_pg_transfer.py
python scripts/verify_redis_signal.py
SITEFORGE_ALLOW_PG_DR_SMOKE=1 python scripts/verify_pg_backup_restore.py
@@ -0,0 +1,69 @@
# Manual-only workflow. Runs against ephemeral test services, never production data.
name: SiteForge V8.17 acceptance (isolated)
on:
workflow_dispatch:
permissions:
contents: read
jobs:
lint-and-audit:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install isolated audit dependencies
run: python -m pip install -r requirements-dev.lock
- name: Run V8.17 tests
run: python -m pytest -q tests/test_siteforge_v817_audit.py tests/test_wordpress_baseline.py tests/test_regression_runner_v817.py
- name: Static repository audit
run: python scripts/siteforge_v817_audit.py --repo . --output reports/ci-repo
- name: Upload non-secret audit report
if: always()
uses: actions/upload-artifact@v4
with:
name: siteforge-v817-repo-audit
path: reports/ci-repo.*
if-no-files-found: warn
isolated-services:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: siteforge_ci
POSTGRES_PASSWORD: ephemeral-ci-only-password
POSTGRES_DB: siteforge_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U siteforge_ci -d siteforge_ci"
--health-interval 5s --health-timeout 3s --health-retries 10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s --health-timeout 3s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install read-only probe clients
run: python -m pip install 'psycopg[binary]>=3,<4' 'redis>=5,<7'
- name: Probe disposable PG/Redis services
env:
SITEFORGE_DATABASE_URL: postgresql://siteforge_ci:ephemeral-ci-only-password@127.0.0.1:5432/siteforge_ci
SITEFORGE_REDIS_URL: redis://127.0.0.1:6379/0
run: python scripts/siteforge_v817_audit.py --postgres --redis --output reports/ci-datastores
- name: Upload non-secret audit report
if: always()
uses: actions/upload-artifact@v4
with:
name: siteforge-v817-datastore-audit
path: reports/ci-datastores.*
if-no-files-found: warn
+274 -259
View File
@@ -1,298 +1,313 @@
# SiteForge Enterprise V8.12.0-dev.1 — 工作台流程与任务状态增强 # SiteForge Enterprise V8.21.3 — 四套内置主题手机端统一优化
> **开发集成版,尚未通过 P0–P4 全部生产验收门禁。** 完整工程在 V8.11 基线上升级;P5/多语言和 Art Design Pro 官方工程等未在本轮解决。 > **状态:开发集成版**。从 V8.21.2 完整源码升级;原站点与数据未覆盖。四套内置主题统一支持抽屉菜单,可选的 1–5 项手机底栏 Tabs 默认关闭。正式生产的域名部署、Apple/Android 真机与 Art Design Pro 官方构建仍需独立验收。
## 本轮重点
## V8.11:管理后台重新设计(本轮重点) - `automotive` / `industrial` / `corporate` / `minimal` 手机端导航统一为右侧抽屉,菜单可包含二三级链接。
- 全站设计新增 `mobile_tabs`:默认 `enabled=false`;开启后新增、删除、排序、图标、站内/HTTPS/电话/邮箱链接均通过可视化表单配置。
- 支持底部 `菜单` Tab 打开抽屉;适配安全区域与小屏宽度。
- 保留现有网站内容、URL/SEO、设计草稿与构建/发布流程;旧配置无需迁移,默认不显示底栏。
- 手机端 4 套主题 × 首页/产品列表/产品详情/文章详情 × 375/390/430px 离线 Chromium 回归通过。
默认 `http://localhost:8787/` 现使用 **简洁工作台**,保留原有业务 API 和数据结构,不迁移数据库。 ## 快速使用和测试
- **工作台**:网站数量、操作引导、最近网站;不要求初学者理解 Worker/SEO/主题 Schema。 - **直观预览**:`examples/mobile-showcase/index.html`(离线主题样例,可切换四主题和四类页面)。
- **我的网站**:一个地方选择所有网站;创建新站的表单只保留必要字段。 - **后台配置**:网站 → 可视化编辑器 → 全站页眉与页脚 → 手机底部快捷导航 Tabs。
- **网站概览**:四步流程(建立网站、准备内容、完善设计、构建预览),动态提示下一步。 - **专项测试**:`python -m pytest tests/test_mobile_tabs_v8213.py -q`。
- **内容与页面**:页面、产品、新闻统一管理,可快捷修改文字或进入可视化设计器。 - **Chromium 测试**:`python scripts/browser_mobile_v8213.py`(需要 Playwright 和 Chromium);结果写入 `reports/mobile-v8213-browser.json`。
- **外观与设计**:行业主题选择与实际页面编辑;草稿/发布隔离仍由原编辑器负责。 - **HTTP 端到端**:`python scripts/http_end_to_end.py`。
- **导入与迁移**:默认进入带预演、复核与批次回滚的导入工作台;小文件直写保留为高级操作且必须确认。 - **完整技术说明**:`docs/V8_21_3_MOBILE_THEMES.md`。
- **构建与交付**:使用既有异步 Worker,预览、静态 ZIP、质量报告集中呈现;正式审批和远程发布保留在企业交付中心。
- **网站设置**:站点名称、域名、公司标语、Logo、类型等从设计功能中独立出来。
- **高级工具**:折叠保留原有生产控制台、任务与审批、主题预览以及历史后台 `/classic`,确保不丢功能。
新增页面在 `app/templates/workspace.html`,样式和功能逻辑在 `app/static/workspace.css`、`app/static/workspace.js`。
专业页面已经使用 V8.11 的统一导航与分组交互;Art Design Pro 官方完整工程仍未正式编译整合。
**运行前后检查**:登录、创建站点、内容编辑、主题切换、文件导入、异步构建、预览/ZIP、手机宽度侧边栏。
**注意**:新 UI 不改变业务 API 或权限模型;`/classic` 留作过渡。真实浏览器/生产 Docker 的全面实机回归仍应在目标服务器执行。
--- ---
## V8.9 已有能力(原样保留) # SiteForge Enterprise V8.21.2 — 简洁可视化编辑器(开发集成版)
本轮优化聚焦**降低学习成本**,而不是增加技术菜单。新增三步操作指引、显眼的草稿保存和设计发布、左侧一键添加区块、右侧内容/样式/设备编辑,复杂模板与动态数据设置默认收起;中间预览可以直接点击区块选中。FAQ、轮播、画廊、优势、数字统计等条目使用逐项普通表单,**不再要求普通用户填写 JSON**。
- 编辑器:原 `/editor/{site_id}/{entry_id}` 路径,无数据库结构变更。
1. **P0 离线恢复安全**:恢复前校验 ZIP 路径、内容范围和数据库完整性;同文件系统暂存目录重命名切换,保留原数据目录以便回退,失败不先覆盖旧数据库。使用 `python scripts/restore_backup.py backup.zip --data /path --confirmed-offline`(务必先停 Web/Worker),完整备份生成用 `python -m app.backup`。 - 离线可交互预览:[`examples/Studio_V8_21_2_新手友好版交互预览.html`](examples/Studio_V8_21_2_新手友好版交互预览.html)。演示中的保存/发布不连接生产 API。
2. **P2 WordPress 响应式图片**:解析 `srcset`、`data-src`、`data-srcset`、`picture/source` 和视频 poster 的外部资源引用;受限本地化后保留宽度/倍数提示及懒加载 URL,无法处理的外链进入迁移复核,绝不静默删除 CDN 引用。外部 CSS 背景图、任意复杂插件布局仍未全面迁移。 - 变更与验收:[`docs/V8_21_2_EASY_STUDIO.md`](docs/V8_21_2_EASY_STUDIO.md)。
3. **P3 5000 条真实构建**:独立 SQLite 夹具 10×500 记录实际导入 5,000 条并生成超 5,000 个静态 HTML;构建器预计算品牌/分类索引,交付检查和质量审计改为流式处理,避免随内容数平方增长;正式 SEO 警告仍需清理。 - 仍保持原有 CSRF、权限、草稿乐观锁、审核及静态构建流程;**发布设计不等于自动部署上线**。
4. **P4 维持现有设计器与模板**:保留跨设备列设置、页面草稿、全站设计草稿、Owner 整站套版与事务回滚;针对优化过的构建器/质量检查加入回归测试。 - 官方 Art Design Pro 工程、真实 Chromium E2E 和生产环境验收仍需独立完成;不要据此宣布正式投产。
**验收报告与限制:** [P0–P4 V8.9 评估](docs/P0_P4_V8_9_VERIFICATION.md) | [原验收矩阵](docs/P0_P4_ACCEPTANCE_MATRIX.md)
## 启动与测试(Windows PowerShell / Docker Desktop)
```powershell
# 在解压后的项目根目录执行,首次生成 .env
# 不能直接覆写旧项目数据;务必使用新测试卷
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
docker compose ps
```
- 简洁管理后台(默认推荐):`http://localhost:8787/`
- 旧版专业操作页:`http://localhost:8787/classic`
- 高级数据与迁移工具:`http://localhost:8787/production-v8`
- 主题中心:`http://localhost:8787/theme-gallery`
- `/api/health` 应返回平台版本 `8.10.0-dev.1` 和构建提交标识。
- 本地测试:`bash scripts/check.sh` 或 Windows PowerShell `scripts/check.ps1`。
- 独立 5000 条验收:`python scripts/pilot_5000.py --report pilot.json`(默认只验证 5,000 条导入)。有足够资源的测试机上加 `--build` 同时检查整站构建;仍需分开核查 SEO 警告。。
## 迁移及回滚注意
离线恢复在目标目录旁保留 `*.pre-restore-*` 原数据目录;恢复完成后先验证旧构建、媒体、权限和管理员登录,再确认是否可以清理原目录。请先把备份 ZIP 拷贝到 Docker 卷之外。此恢复脚本适用于 SQLite 单机数据;**不是 PostgreSQL 恢复工具**。恢复前 Web 与 Worker 必须全部停止。
## 开发验收边界
此版本未在当前环境执行 Docker Engine、GitHub Actions 云端 CI、真实授权 WordPress 全站迁移、MySQL TLS 实机、生产密钥轮换以及桌面/移动浏览器完整视觉验收。P0–P4 尚不能全部标为生产交付完成。跨域 CDN、CSS 背景资源、付费/私有 WordPress 数据及复杂 WooCommerce/Elementor 页仍需定制适配与人工复核。
--- ---
## 历史功能说明(原 V8.8/之前版本,仅供追溯) # SiteForge Enterprise V8.21.1 — Studio 可视化编辑器体验增强
# SiteForge Enterprise V8.8.0-dev.1 — P0–P4 集成增强版 > 基于 V8.21.0-dev.1 完整源码迭代;当前版本 `8.21.1-dev.1`,属于兼容性开发集成版,未覆盖生产网站。
> **交付状态:开发集成版,尚未通过 P0–P4 全部门禁,不应作为正式商业验收报告。**
本版延续 V8.7 的完整源码,聚焦 P4(响应式独立列布局、图片素材库、整站模板两步审核与多页原子操作),同时修复深层页面 CTA/媒体根路径问题、版本与 Compose 遗留问题。旧站迁移、数据装配、安全 HTTP、Worker、四套主题继续保留。
- **P4 设备布局**:页面中的多列区块可设置平板/手机独立列数、排序、跨度和显示隐藏;所有自定义 CSS 经过严格参数校验,并按页面作用域生成。
- **P4 模板套版**:生产控制台先预检、再暂存,最后 Owner 发布;冲突拒绝、失败回滚,同步保证数据与 SEO 不被模板业务内容覆盖。旧版直接应用 API 需显式设置开发兼容开关。
- **P4 媒体素材**:编辑器新增本站图片库选择,支持图片组件、画廊和轮播;深层页面正确使用根路径。
- **验证状态与全部遗留**:[P0–P4 验收矩阵](docs/P0_P4_ACCEPTANCE_MATRIX.md)。
- **运行**:与 V8.7 一样,首先在独立测试目录执行 `python scripts/init_config.py`(或 Docker 运行该脚本),再 `docker compose up -d --build`。不得覆盖运行中的旧版数据卷。
---
## V8.7 历史项目说明(保留升级依据)
# SiteForge Enterprise V8.7.0-dev.1 — 全站设计草稿与响应式管理
> **交付状态:开发试点版。** 本轮基于 V8.6 延续原 Web + Docker 工程,不是单独演示网站。
## V8.7 本轮实际实现
- **全站设计草稿**:`GET/PUT/DELETE /api/sites/{site}/site-design` 管理独立草稿;`POST /site-design/publish` 才更新用于正式构建的全站配置。对 `revision` 和底层设置指纹做并发冲突检查,发布记录保存在 `site_design_publications`。
- **统一页眉/页脚/导航**:支持安全的三级导航、搜索显示、顶部公告、联系 CTA、页脚标题/介绍/邮箱、页脚菜单显示选项;四套专业主题的最终 HTML 均消费同一份配置。导航只允许站内路径,严禁任意脚本、外部链接注入。
- **响应式控制**:平板与手机的区块间距、手机内容左右内边距、移动端公告和页脚导航显隐;已写入最终静态 `assets/custom.css` 和可视化编辑器预览 CSS。它是可交付的响应式参数编辑,**并非**完整独立设备画布。
- **设计令牌统一入草稿**:字体、标题缩放、版心宽度、圆角等改为全站草稿保存/发布;未经发布不会更改新构建。编辑器内可在“设计规范”和“全站页眉与页脚”面板操作。
- **关闭旧版直写绕过**:`/blueprint` 直写、旧导航直写、旧设计令牌直写及 `PATCH entry.meta` 的布局直写,在生产环境默认返回 HTTP 409。旧脚本若必须临时过渡,可显式设 `SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE=1`;**正式生产不应启用此选项**。原后台导航表单与企业设计令牌页已改写全站草稿。
- **测试**:在现有 168 项回归基础上新增 13 项专项测试,验证四主题、草稿构建隔离、发布、设计令牌、安全输入、跨站隔离、并发拒绝、旧接口安全门禁。
### 使用方法
1. 解压后先在**独立测试环境**启动 Docker(以下为 PowerShell):
```powershell
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
```
2. 登录 `http://localhost:8787/`,进入任意站点页面的 `/editor/{site_id}/{entry_id}` 设计器。
3. 点击 **全站页眉与页脚**,调整导航/页眉/页脚/手机与平板设置,点击 **预览草稿**,随后 **保存全站草稿**。
4. 由网站 Owner 点击 **发布全站设计**,然后触发 Worker 网站构建。现有已发布静态版本不会因保存或发布设计而被原位篡改。
5. CI 中可执行 `pytest`、Python 编译检查、`node --check`;Docker、真实浏览器和服务器发布需要独立实机验收。
### API 及兼容性
| 方法与路径 | 行为 |
|---|---|
| `GET /api/sites/{site}/site-design` | 返回已发布配置或草稿、版本、冲突状态 |
| `PUT /api/sites/{site}/site-design` | 携带 `config` 与 `expected_revision` 保存草稿 |
| `POST /api/sites/{site}/site-design/publish` | Owner 审核发布,要求版本号 |
| `DELETE /api/sites/{site}/site-design` | 删除未发布草稿 |
| `GET /api/sites/{site}/site-design/publications` | 查询最近的发布审计记录 |
| `POST /api/sites/{site}/pages/{entry}/editor-preview` | 请求体的 `site_design` 可预览未发布的整站草稿 |
### 明确未完成
- 目前并非 Figma 式自由定位编辑;完整 PC/平板/手机**独立布局**、全站模板可视化拖拽、实时跨设备视觉验收仍待开发。
- 旧版批量套版及部分内容创建接口仍可能更新布局,需在后续将所有设计变更统一纳入发布工作流;本次仅限制上述直接绕过入口。
- Art Design Pro 官方工程、复杂 CMS 无损迁移、PostgreSQL/Redis 多实例、SFTP/S3 正式发布仍未完成完整实机验收。
- 当前环境无 Docker Engine,**不能宣称** `docker compose up` 已实机通过;亦未完成 GitHub 云端 CI 和真机 Chrome 视觉回归。
---
## 上一版 V8.6 的开发记录(保留供升级追踪)
# SiteForge Enterprise V8.6.0-dev.1
> **状态:开发试点版,P4 设计器与整站模板增强。** 同一主线延续 V8.5 的认证 API、只读 MySQL、V8.4 XML/API、WordPress 迁移、四套主题、构建 Worker 和发布审批。尚未完成正式商业/生产验收。
## 这一轮新增(P4)
- **六种声明式组件**:`carousel` 图片滚动展示、`video` YouTube/Vimeo 白名单视频、`tabs` 可访问详情分组、`pricing` 服务方案、`map` 可信地图外链、`form_link` 询盘入口。都使用可信渲染器输出,不允许用户提交 JavaScript。
- **独立设计草稿**:V8.6 可视化编辑器的“保存草稿”只写入 `studio_drafts`,不会修改正式 entries 或被静态构建读取;“发布设计”才会原子写入正式页面,随后需重新构建静态网站。
- **并发保护**:每次草稿更新携带 `expected_revision`;正式页面被其他人改动后,拒绝发布,防止静默覆盖。放弃草稿会恢复正式版设计。操作支持站点权限隔离与审计。
- **整站模板兼容性**:模板带主题版本声明;新增 `/whole-site-templates/preflight` 无副作用检查,验证主题是否存在、指定版本是否可用、已安装组件、已有路径,后台预检后再应用。
- **前端操作入口**:原 `/editor/{site_id}/{entry_id}` 页面新增保存草稿、发布设计、放弃草稿;整站模板控制台在应用前执行独立兼容性检查。原 `/blueprint` 直写接口保留兼容旧系统,尚未完全禁用。
### Docker 运行
Windows PowerShell(在解压后的项目根目录):
```powershell
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
docker compose ps
```
页面:管理台 `http://localhost:8787/`;生产控制台 `/production-v8`;主题 `/theme-gallery`;设计器从网站页面进入 `/editor/{site_id}/{entry_id}`;API 版本 `/api/health`。
首次升级必须使用**独立测试环境和数据副本**,备份 `.env`、数据库、媒体及归档。草稿表通过幂等方式初始化,未在原有生产数据库上完成实际迁移演练。
### P4 接口
| HTTP 接口 | 用途 |
|---|---|
| `GET /api/editor/blocks` | 读取组件注册表与属性契约 |
| `GET /api/sites/{site}/pages/{entry}/studio-draft` | 获取当前草稿或正式布局 |
| `GET /api/sites/{site}/pages/{entry}/studio-draft/preset?page_template=home` | 获取主题预设,不修改正式页 |
| `PUT /api/sites/{site}/pages/{entry}/studio-draft` | 保存草稿,要求 `expected_revision` |
| `POST /api/sites/{site}/pages/{entry}/studio-draft/publish` | 发布设计,需要 owner 角色和 `expected_revision` |
| `DELETE /api/sites/{site}/pages/{entry}/studio-draft` | 放弃草稿 |
| `POST /api/factory/sites/{site}/whole-site-templates/preflight` | 整站模板无副作用兼容检查 |
开发环境已经完成 **168 项本地 pytest**、全部 JavaScript 语法与版本检查,另有“草稿构建不包含未发布内容 → 发布后重新构建包含内容”的实测回归。
**未验收**:Docker Engine 实机、GitHub 云端 CI、浏览器 PC/手机端手工交互、真实客户内容视觉检查、Art Design Pro 官方工程编译、复杂 WordPress 无损迁移、PostgreSQL/Redis 多实例能力、SFTP/S3 正式发布。
详细信息见 [V8.6 验收与剩余需求](docs/V8_6_RELEASE_STATUS.md)。
---
## 历史版本功能参考(以下内容为 V8.5 及更早阶段的开发记录,不是 V8.6 最新状态)
---
## V8.4 历史能力(继续保留)
> 当前阶段:统一网站生产工厂,**P3 数据连接器第一轮(安全 XML、公开 JSON API + Worker)**。本版本是开发试点版,并非所有网站工厂需求均已实现。
## V8.4 本轮新增
- **安全 XML 导入**:`/production-v8` 可以上传 XML,选择记录节点、内容类型和字段映射,支持扁平或两级结构;使用 `defusedxml` 禁用 DTD 与外部实体,并限制最大 8 MB、1000 条记录及节点/深度上限。
- **公开 HTTPS JSON API**:设置 `url`、JSON 列表路径(例如 `data.items`)、字段映射和分页;创建后提交 Worker 后台采集,使用既有租约/取消/重试/超时机制。每页形成独立暂存批次,可以审批提交或回滚。所有采集均不自动上线。
- **进度和误差边界**:最多 50 页 × 每页 100 条,合计 5000 条;发生中断从已保存页码继续,复用已有同页批次避免重试重复写入;达到最大页数而上页满额会显示 `limit_reached`,不能视为采集完整。
- **统一后台**:新增 XML 文件上传、API 创建、采集任务、取消和进度查询操作区域,并保留 WordPress 迁移、主题和编辑器。
- **版本与 CI**:统一应用版本标记、Compose 名称、脚本和 CI 版本断言为 `8.5.0-dev.1`;**144 项本地自动化测试通过**。
[详细功能、接口、安全限制与剩余需求](docs/V8_4_RELEASE_STATUS.md)
**边界**:目前 API **只支持无需认证的公开 JSON**,不支持带密钥 API;MySQL 只读连接器、完整 Art Design Pro 编译和生产级外部发布也尚未完成。没有运行 Docker 实机或云端 CI,因此不得将该版本称为企业正式上线完成版。
---
## 既有 V8.3 迁移与建站功能(本轮保留)
> SiteForge Enterprise 统一开发主线:**WordPress 可恢复迁移 + Sitemap 完整性审计 + 有界媒体本地化 + Worker 后台采集**。
> >
> 版本状态:开发试点候选版,不可直接对外宣称完整 WordPress 无损迁移或企业生产部署已验收。V8.1 网站、主题、编辑器、导入批次和静态构建功能均在此版本继续维护。 > **本次直接改进**:区块搜索和快捷排序、属性分类、PC/平板/手机预览、保存与预览异步竞态保护、移动端侧栏、Ctrl+S、测试工具和离线交互示例。
>
> 交付说明:[可视化编辑器优化实施与验证](docs/V8_21_1_STUDIO_UX.md) | [无需服务器的离线交互演示](examples/Studio_V8_21_1_交互预览.html) | [原始测试数据](reports/studio-ux/editor-ux-validation.json)
>
> **准确验收边界**:39 个测试模块拆分运行累计 328 项通过,HTTP+Worker 构建通过;一键回归在第七批出现停滞,真实 Chromium 因环境策略被拒绝。尚未完成正式 Art Design Pro 全工程编译或真实企业部署验收。
## 快速启动(Docker Desktop / Linux Docker) ---
项目根目录第一次初始化: # SiteForge Enterprise V8.21.0-dev.1 — 高级编辑历史与安全数据装配
**交付性质:完整源码的开发集成版。** 基于 V8.20 继续开发,保留原有 WordPress 迁移、主题建站、P5 发布、多语言和企业运维代码。
本轮新增:
- **持久化设计版本历史**:每次页面草稿保存与发布自动创建完整快照,按网站和页面隔离,SHA-256 完整性校验,编辑权限控制;可从历史记录恢复成**新草稿**(不直接覆写线上页面)。前端编辑器新增“版本历史”操作。
- **更安全的数据装配**:字段映射支持受限的 trim、默认值、英文大小写转换、前后缀;预览生成确定性 `plan_hash`,确认导入时可携带该指纹防止过期预览;新增严格冲突拒绝模式 `abort`,现有 `skip`、`replace` 仍兼容。内容导入 UI 新增转换选择、冲突策略和预检确认。
- **质量与版本管理**:更新镜像标签、版本常量和检查入口;专项测试覆盖历史恢复、站点隔离、快照篡改阻断、过期预检及字段处理。
验收:本地按 **10 个独立分组**执行原项目全部 **38 个测试模块、325 项测试通过**;Python 编译、Node.js 语法、静态发布检查和 HTTP+Worker E2E 通过。长流程一键检查在本环境**曾偶发停滞**,因此不能把它记为完全稳定。真实 Docker、PostgreSQL/Redis、正式 Art Design Pro 工程编译及客户迁移仍未通过验收。
文档:[V8.21 实现与验收](docs/V8_21_EDITOR_AND_MAPPING.md) · [HTML 验收报告](reports/V8_21_集成验收.html) · [10 批原始日志](reports/v821_manual_verified/)
验证命令:
```bash ```bash
# PowerShell 将 `$(pwd)` 改为 `${PWD}` python -m pytest -q tests/test_v821_editor_history_and_mapping.py
docker run --rm -v "$(pwd):/project" -w /project python:3.12-slim python scripts/init_config.py python scripts/http_end_to_end.py
bash scripts/check.sh
```
---
# SiteForge Enterprise V8.20.0-dev.1 — 复杂 WordPress 迁移预检与兼容性增强
基于 V8.19 完整源码。本轮新增 WXR 离线兼容预检 API + 后台入口、ACF 简单 Repeater 元数据提取、CSS 背景图本地化、危险 URL 在直接导入及暂存前阻断,以及带构建路径对账的 JSON/HTML CLI 报告。
- 阅读:[`docs/V8_20_COMPLEX_WORDPRESS.md`](docs/V8_20_COMPLEX_WORDPRESS.md)
- 演示:`python scripts/wp_v820_preflight.py fixtures/v820/wordPress-complex-sample.xml --output reports/v820-demo.json`(演示数据包含故意设置的阻断项,返回码为 1)
- 回归:`bash scripts/check.sh` 或 `python scripts/regression_v820.py --batch-size 4 --report reports/v820-regression`
- HTTP 集成:`python scripts/http_end_to_end.py`
- 安全:所有离线预检不写数据库、不联网、不执行 PHP/JS 或反序列化未知 PHP 对象。
**交付状态:开发集成版。** 尚未实现 WordPress/Elementor/ACF/WooCommerce 完整 1:1 视觉或动态业务迁移;Art Design Pro 官方工程真实编译与生产 PostgreSQL/Redis/远程发布仍需现场验收。
---
# SiteForge Enterprise V8.19.0-dev.1 — 统一后台集成版
V8.19 基于 V8.18 的完整项目继续开发。新增 **后端授权菜单 `/api/console/bootstrap`**、Art Design Pro **单一导航树安装器**、Vue 工作台与内容管理、共享会话/CSRF/站点选择、官方工程安装校验,以及 HTTPS 同源反向代理示例。正式功能与限制详见 [`docs/V8_19_ART_UNIFIED_BACKEND.md`](docs/V8_19_ART_UNIFIED_BACKEND.md)。
**交付性质:开发集成版。** 本仓库含完整 SiteForge 后端和 Art 对接业务模块,**不含官方 Art Design Pro 整套前端工程**;真实官方 `pnpm build`、PC/移动端浏览器及 HTTPS 正式发布尚未验收。不得把安装器 fixture 测试等同于官方完整前端编译。历史的 PostgreSQL/Redis/多 Worker 生产门禁仍未关闭。
建议执行:
```bash
bash scripts/check.sh
python scripts/http_end_to_end.py
python scripts/install_into_art_design_pro.py /path/to/official/art-design-pro
python scripts/install_into_art_design_pro.py /path/to/official/art-design-pro --apply
python scripts/verify_art_design_pro_v819.py /path/to/official/art-design-pro
```
---
# SiteForge Enterprise V8.18.0-dev.1 — 企业底座与数据迁移安全
V8.18 基于 V8.17 完整源码继续开发。新增 PostgreSQL 专用灾备、SQLite→PG 数据迁移校验、外部发布不确定状态的安全处置、CI 集成验证。详见 [`README_V8_18.md`](README_V8_18.md) 和 [`docs/V8_18_ENTERPRISE_HARDENING.md`](docs/V8_18_ENTERPRISE_HARDENING.md)。**本版本尚未完成真实生产环境验收。**
---
# SiteForge Enterprise V8.17.0-dev.1 — 集成回归与生产验收整合版
> **状态:开发集成版;不代表生产环境验收通过。** 基于 V8.16 完整源码升级,合入 V8.17 只读审计和 WordPress URL 迁移基线工具,并改造官方回归执行器。原 ZIP 不受影响。
## V8.17 本轮已整合
- **统一回归执行器**:`scripts/regression_v817.py` 逐批运行测试(每批最多 10 个文件),每批超时默认 90 秒;超时将结束整个子进程组、留存日志,随后逐个模块隔离定位。任何超时、失败或重试后才通过,都不能算本次验收通过。
- **Windows + Linux 一致性**:`scripts/check.sh` 和 `scripts/check.ps1` 均调用该执行器。JSON、Markdown、HTML 及每批 verbose 日志保存在 `reports/`。
- **可重复 CI**:Linux CI 两轮重复测试并上传报告;新增隔离浏览器 smoke 作业,原有 PostgreSQL、Docker 质量门禁保留。CI 定义存在并不表示已经在 GitHub Actions 实际执行。
- **Chromium 测试修复**:移动端新页面复用桌面端登录上下文;默认使用 Playwright 安装的 Chromium,`SITEFORGE_CHROMIUM` 可显式指定浏览器。
- **生产验收只读工具**:`scripts/siteforge_v817_audit.py` 支持仓库、存活/就绪探针、Compose、PG SELECT 1、Redis PING、静态构建/SEO、人工发布证据校验;不会执行部署或数据库变更。
- **WordPress 路径核对**:`scripts/export_wordpress_url_baseline.py` 从授权站点导出的 WXR/Sitemap 等本地文件建立旧路径基线,再用构建目录对比。缺失路径阻断验收而不是静默改 URL。
- 本轮产品版本更新为 `8.17.0-dev.1`;Compose 镜像及已有版本断言相应升级。不存在自动升级现有部署卷的操作。
### 一键回归(隔离本地目录)
```bash
bash scripts/check.sh
python scripts/http_end_to_end.py
python scripts/siteforge_v817_audit.py --repo . --output reports/v817-audit
```
Windows PowerShell:`./scripts/check.ps1`。
若需重复运行和定位卡点:
```bash
python scripts/regression_v817.py --repeat 3 --timeout 90 --module-timeout 50 --report reports/repeated
```
### 上线门禁
正式上线之前必须在独立测试环境完成 GitHub CI 的 Docker / PG / Redis / Chromium、真实远程发布与回滚、备份恢复、业务页面与 301/canonical/hreflang 审核。**没有实测证据时这些条目均视为未验收。** `Art Design Pro` 官方完整工程并未随此源码提供,本版本没有声称已编译或安装该后台。
参阅:`docs/V8_17_ACCEPTANCE_RUNBOOK.md` 和 `docs/V8_17_INTEGRATION_RELEASE_NOTES.md`。
---
# SiteForge Enterprise V8.16.0-dev.1 — 企业部署基础与可重复验收
> **交付状态:开发集成版。** 该版本继续沿用 V8.15 全部建站、编辑、WordPress 迁移、P5 发布和 P6 多语言功能。本轮重点是修复 PostgreSQL 兼容隐患、健康探针、Worker 实例隔离和实测建站链路。**Art Design Pro 官方完整工程没有随项目提供,不能声称已集成或完成编译;真实 PostgreSQL/Redis、Docker 和公网上线仍须实机验收。**
## V8.16 本轮变更
1. **PostgreSQL 查询行适配**:`PGRow` 同时提供字典字段访问和数字索引(如 `fetchone()[0]`),修复了原队列容量检查与其他遗留语句在 PG 下类型不匹配的问题。占位符转换现在保护 SQL 注释和转义引号;DDL 分句避免破坏字符串里的分号。
2. **数据库初始化迁移**:原先仅 SQLite 执行的 `schema_registry` 迁移账本现在也在 PostgreSQL 创建和校验;重复运行可验证迁移 checksum。团队账号唯一性冲突也支持 PG 错误码 `23505`。
3. **探针分层**:`GET /api/health` 为轻量存活;新增公开 `GET /api/ready` 检查数据库查询和 `/data` 可写性,失败只返回 `503 {"status":"unavailable"}`,不会泄露数据库/文件路径。Compose 的 Web 探针改用 `/api/ready`,Worker 探针附加数据库和存储检查。
4. **Worker 心跳隔离**:支持 `SITEFORGE_WORKER_INSTANCE`,企业 PostgreSQL 模式下默认以容器主机名隔离心跳,防止两个 Worker 相互掩盖健康问题;构建队列依然以数据库锁/租约为准。
5. **集成验证**:新增 `scripts/http_end_to_end.py`,可在隔离 SQLite 中实际启动 HTTP 服务,创建网站和页面、提交任务、启动 Worker 子进程、检查 ZIP 和预览。`scripts/browser_smoke.py` 为真实 Chromium 脚本(需要浏览器允许本地 HTTP);扩展 `scripts/verify_pg_bootstrap.py` 对所有模块初始化和队列 claim/complete/cancel 的真实 PostgreSQL 验证。
6. **CI**:回归按模块分进程运行,减少历史测试共享状态造成的超时;Web 真实 HTTP 集成测试纳入测试 Job,PG 连接测试继续由 CI `postgres-smoke` Job 执行。
## 快速检查
```bash
bash scripts/check.sh
python scripts/http_end_to_end.py
# 仅在隔离的 PostgreSQL 数据库中:
SITEFORGE_DATABASE_URL='postgresql://siteforge:***@postgres:5432/siteforge_test' python scripts/verify_pg_bootstrap.py
# 可选:安装 Playwright + Chromium 后,且允许访问 localhost:
python scripts/browser_smoke.py
```
完整验收说明和未完成门禁见 [`docs/V8_16_PRODUCTION_ACCEPTANCE.md`](docs/V8_16_PRODUCTION_ACCEPTANCE.md)。**不要在生产数据库上运行 PG smoke**,不要直接把 SQLite 数据文件重定向为 PostgreSQL,也不要在没有备份/恢复演练时升级正式卷。
---
# SiteForge Enterprise V8.15.0-dev.1 — P6 多语言网站与国际 SEO
> 历史版本说明:V8.14 在 V8.13 基础上新增原生 SFTP、S3 发布适配器及 S3 发布指针读取服务,保留 P5 发布任务、不可变版本校验与历史回滚;保留四套主题、内容管理、WordPress 迁移、数据连接器、编辑器和 Worker。**SFTP/S3 已有代码及本地仿真测试,但未完成真实 Docker、远程服务器和生产域名验收,不可直接宣布生产级完成。**
## V8.15 新增:P6 多语言网站管理(开发集成版)
**请先读此段**:V8.15 在 V8.14 完整源码上新增语言组/翻译组、审核状态、前台语言切换、HTML `lang`、双向 `hreflang` 与 Sitemap `xhtml:link`。所有业务内容由用户自己翻译、审核;不会自动翻译产品规格、公司地址或 SEO 事实。
- 入口:后台首页 → 选择网站 → **多语言管理**。
- 一个 SiteForge 站点代表一个语言版本,每个语言站拥有独立的企业内容和 URL。已实现**独立域名或子域名**方案,组成员必须配置自己的 HTTPS 域名;尚不支持多个语言共用域名下的 `/en/` 子路径方案。
- 管理员创建语言组,选择默认语言;拥有两个语言站 Owner 权限的操作者可以把站点加入同一语言组。
- 按语义创建翻译内容组(如 `product:rox01`),把中文 `/products/rox/` 与英文 `/vehicle/rox/` 的**实际内容 ID** 关联。两端需要 `published` 和 `approved` 且允许索引才会输出相互指向的语言链接。
- `pending` 只允许关联内容草稿。如果后来绕过编辑器直接将 pending 内容改为 published,**构建会拒绝执行**。
- 默认语言确实有已审核对应页面时才输出 `x-default`;`noindex`、缺失、草稿或不一致 canonical 的页面不参与。
- 生成的 `sitemap.xml` 使用正确的 XHTML 命名空间和双方对称的 alternate URL;每个独立网站的 canonical 仍指向自己的正式 URL。
- UI 有可点击的语言切换条,未关联的页面不显示不存在的翻译页面入口。
- 旧站无需设置语言组,可继续按原构建方式工作;数据库迁移只新增表格。
- 本轮**没有**实现机器翻译、批量翻译工作流、子路径多语言站点、主题各栏目默认文案的完全国际化。四套现有商业主题的预设导航和空栏目提示部分仍是中文,正式英文站上线前需配置企业自己的导航和文案。
操作示例:
1. 在“我的网站”分别新建中文站和英文站,设置 `https://zh.example.com`、`https://en.example.com`。
2. 选择中文站 → 多语言管理 → 建立语言组,默认语言可选 `en`,并将当前站点登记为 `zh-CN`(若建组时,分别填写“默认语言”和“当前网站实际语言”,两者可以不同)。
3. 将英文站加入该语言组,设置 `en`;在两个站各自建立真实对应页面。
4. 创建翻译组 `about-company`,分别选中两语言的页面,状态设为“已确认”。
5. 在两个站分别执行 Worker 静态构建,检查 HTML `<html lang=...>`、`rel=alternate` 和 `sitemap.xml`;正式发布仍需要独立审批。
本轮核心代码:`app/multilingual.py`、`app/builder.py`、`app/templates/workspace.html`、`app/static/workspace.js`,专项测试 `tests/test_multilingual_v815.py`。
## V8.15 新增:P6 多语言网站管理(开发集成版)
**请先读此段**:V8.15 在 V8.14 完整源码上新增语言组/翻译组、审核状态、前台语言切换、HTML `lang`、双向 `hreflang` 与 Sitemap `xhtml:link`。所有业务内容由用户自己翻译、审核;不会自动翻译产品规格、公司地址或 SEO 事实。
- 入口:后台首页 → 选择网站 → **多语言管理**。
- 一个 SiteForge 站点代表一个语言版本,每个语言站拥有独立的企业内容和 URL。已实现**独立域名或子域名**方案,组成员必须配置自己的 HTTPS 域名;尚不支持多个语言共用域名下的 `/en/` 子路径方案。
- 管理员创建语言组,选择默认语言;拥有两个语言站 Owner 权限的操作者可以把站点加入同一语言组。
- 按语义创建翻译内容组(如 `product:rox01`),把中文 `/products/rox/` 与英文 `/vehicle/rox/` 的**实际内容 ID** 关联。两端需要 `published` 和 `approved` 且允许索引才会输出相互指向的语言链接。
- `pending` 只允许关联内容草稿。如果后来绕过编辑器直接将 pending 内容改为 published,**构建会拒绝执行**。
- 默认语言确实有已审核对应页面时才输出 `x-default`;`noindex`、缺失、草稿或不一致 canonical 的页面不参与。
- 生成的 `sitemap.xml` 使用正确的 XHTML 命名空间和双方对称的 alternate URL;每个独立网站的 canonical 仍指向自己的正式 URL。
- UI 有可点击的语言切换条,未关联的页面不显示不存在的翻译页面入口。
- 旧站无需设置语言组,可继续按原构建方式工作;数据库迁移只新增表格。
- 本轮**没有**实现机器翻译、批量翻译工作流、子路径多语言站点、主题各栏目默认文案的完全国际化。四套现有商业主题的预设导航和空栏目提示部分仍是中文,正式英文站上线前需配置企业自己的导航和文案。
操作示例:
1. 在“我的网站”分别新建中文站和英文站,设置 `https://zh.example.com`、`https://en.example.com`。
2. 选择中文站 → 多语言管理 → 建立语言组,默认语言可选 `en`,并将当前站点登记为 `zh-CN`(若建组时默认语言不是当前站实际语言,请创建后在已建组界面使用对应语言站点;管理后台的建组快捷操作默认登记所选默认语言,操作前请校正)。
3. 将英文站加入该语言组,设置 `en`;在两个站各自建立真实对应页面。
4. 创建翻译组 `about-company`,分别选中两语言的页面,状态设为“已确认”。
5. 在两个站分别执行 Worker 静态构建,检查 HTML `<html lang=...>`、`rel=alternate` 和 `sitemap.xml`;正式发布仍需要独立审批。
本轮核心代码:`app/multilingual.py`、`app/builder.py`、`app/templates/workspace.html`、`app/static/workspace.js`,专项测试 `tests/test_multilingual_v815.py`。
## 一、系统入口
- 简洁管理后台:`http://localhost:8787/`
- 「构建与交付」:构建、预览、质量检查、提交审批、远程发布及回滚
- 「高级工具」→「任务与发布审批」:审核人对不同账号提交的申请进行审批,查看任务详情
- `http://localhost:8787/api/health`:核实运行版本与构建提交
### V8.14 新增实际功能(同时保留 V8.13 发布流程)
| 流程 | 位置 | 实际行为 |
|---|---|---|
| 发布前核对 | `GET /api/production/sites/{site}/preflight/{build}` | 必须构建 complete、有 ZIP、静态质量通过、迁移复核完成、另一账号审批通过 |
| 发起远程发布 | `POST /api/production/sites/{site}/deploy` | Owner 提交到共享 Worker 队列,返回 202 + 任务 ID;不在 Web 请求中传 ZIP |
| Worker 实际上传 | `app.job_runner` + `app.release_api.run_delivery` | 暂存 ZIP、比对 SHA256、校验远程文件清单、激活线上指针、再核对公开首页 |
| 历史版本回滚 | `POST /api/production/sites/{site}/rollback` | Owner 只能回滚本站指定目标的已记录版本,后台异步执行 |
| 发布进度/历史 | `GET /api/production/sites/{site}/delivery-status` | 返回任务、发布历史及目标是否配置;不暴露 URL 凭据 |
| 远程 Agent | `app.deploy_agent:app` | 新增 `staging` / `verify` / `activate` 事务式三阶段,不覆盖已发布目录 |
共享 Worker 的取消、重试、限额、幂等键及租约机制沿用 V8.12;**远程发布依赖在目标服务器部署新版本 Agent,旧版 Agent 不支持三阶段 API**。
## V8.14 原生 SFTP / S3 发布
- `SITEFORGE_DEPLOY_STAGING_TYPE` 和 `SITEFORGE_DEPLOY_PRODUCTION_TYPE` 支持 `agent`(兼容默认)、`sftp`、`s3`。
- 原生 SFTP:Worker 使用管理员挂载的私钥与严格 known_hosts;远程服务器需要支持原子 `posix_rename`;发布指针为 `current` 符号链接。
- 原生 S3:按版本前缀不可变保存文件,使用 S3 条件更新 `current.json`;必须搭配只读指针感知网关 `app.s3_gateway:app`,不能将 S3 裸网站端点视为原子上线。
- 目标 URL、SSH 主机、桶和密钥只能由服务器配置,不能由建站人员在浏览器随意填写;Worker 执行真实上传,Web 仅提交任务。
- 详见 [`deploy/native-release/README.md`](deploy/native-release/README.md)、`compose.publish-targets.example.yaml`、`compose.s3-gateway.example.yaml`。
- **约束**:本次对 SFTP 和 S3 的测试使用模拟文件系统与模拟 S3 兼容语义,尚未在实际远程服务器或 Docker 环境验收。
## 二、Docker 安装(Windows PowerShell)
```powershell
# 解压 V8.14 后进入项目根目录。请勿直接覆盖 V8.12 的数据卷。
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build docker compose up -d --build
docker compose ps docker compose ps
``` ```
- 登录:`http://localhost:8787/` 推荐先用独立测试域名 / Docker volume。Web 默认只监听 `127.0.0.1:8787`。如果反向代理到外网,需要设置 HTTPS 和 `SITEFORGE_COOKIE_SECURE=true`,自行配置防火墙、TLS 与可信反向代理。
- **V8.4 生产控制台**:`http://localhost:8787/production-v8`
- 主题管理:`http://localhost:8787/theme-gallery`
- 页面编辑:`http://localhost:8787/factory-studio`
- 任务中心:`http://localhost:8787/operations`
- 构建版本:`http://localhost:8787/api/health`;当前应显示 `8.5.0-dev.1`。
**升级注意:** 不要直接覆盖 V8.1 正在运行的存储卷。先备份 `.env`、数据库、媒体和生成文件,在独立 Compose 项目中完成验收,然后安排升级与回滚。新版本表结构采用幂等的 `CREATE TABLE IF NOT EXISTS`,但并未进行真实旧数据库原地升级演练。 **PostgreSQL/Redis 测试配置**:`docker compose -f compose.enterprise.yaml up -d --build`。这只是配置与兼容层,不能视为分布式高可用验收通过。
## V8.3 新增:WordPress 关系数据与归档 URL 保真 ## 三、远程发布如何配置?
- 为每次 WordPress 迁移会话采集公开 REST 分类、标签和媒体库**元数据**,分别保存页码和已采集数量。关闭页面后可以继续下一页。 1. 在**接收网站的独立服务器**上部署 `compose.release-agent.yaml`,把 Agent 的 `127.0.0.1:8790` 通过受控 HTTPS 反向代理转发为固定发布域名(如 `https://deploy.example.com`)。Agent 所在服务器必须保留持久化 `releases` 卷。
- 原始 WordPress ID、分类/标签/特色图片的关联 ID 写入批次记录和关系索引;导入确认前,在已同步名称的情况下补充分类/标签字段;绝不凭空创建不存在的分类内容。 2. 在接收服务器设置 `SITEFORGE_DEPLOY_SECRET_REF=file:production_release`,把强随机密钥文件仅挂载到 Agent `/run/secrets/production_release`。可通过 `compose.agent-secrets.example.yaml` 作为私有 Compose 覆盖参考。
- 分类/标签来源归档 URL 纳入旧站 SEO 基线。无对应的新页面或批准重定向时,需要通过差异检查处理。 3. 在 SiteForge 管理服务器 `.env` 中设置 `SITEFORGE_DEPLOY_PRODUCTION_URL=https://deploy.example.com` 和 `SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF=file:production_release`,将**同一生产密钥**只挂载到 Worker `/run/secrets/production_release`;通过 `compose.release-secrets.example.yaml` 添加私有只读挂载。不要把密钥写进网站数据库或前端 JavaScript。
- 对缺失的标签/分类、未知特色图片、ACF 和页面构建器内容保留人工审核事项。公开 ACF 只保留有界快照,不承诺原样转换。 4. Staging 使用**另一套服务器及独立密钥**,不要复用生产凭据。
- 控制台按钮:选择“分类 / 标签 / 媒体库”→ “同步目录下一页”→ “目录进度”,完成后再核对媒体和 Sitemap,最后确认导入。 5. 使用 Owner 账号选择已完成的构建,执行「检查发布条件」→「提交审批」,由**另一个具有审核权限的账号**批准后选择目标提交异步发布,回到任务列表等待确认。
6. 正式域名的 DNS/CDN、静态页面反向代理、询盘服务和 HTTPS 证书需单独配置并验证。**Agent 的 `/public/{site}/` 是自身提供的内容探针,不等于你公司的域名已经自动生效。**
详细的操作与验收边界见 [V8.3 开发验收报告](docs/V8_3_RELEASE_STATUS.md)。 生产系统不应在管理后台填入任意远程 URL:仅运维人员通过可信环境配置发布目标,且 `safe_http` 禁止访问回环、内网、云元数据与 DNS 重绑定目标。
## V8.3 迁移流程与增强 ### Agent 路由
1. 创建迁移会话:指定授权 WordPress 来源和最多采集条数。 - `POST /staging/{site}`:签名上传并生成不可变版本文件,不修改线上 current。
2. **提交 Worker 后台采集**:一次提交到统一数据库任务队列,Worker 子进程逐页采集 WordPress REST `pages/posts`,保持每页检查点。浏览器关闭后可以继续执行。 - `GET /sites/{site}/verify/{release}`:签名核对文件哈希、完整性、是否在线。
3. 在任务中心监控扫描的排队、执行、取消、重试、超时等状态。扫描任务不会自动提交暂存内容、不会直接上线。 - `POST /sites/{site}/activate/{release}`:验证后原子切换 current 指针。
4. **发现 Sitemap**:单独读取 WordPress `wp-sitemap.xml`;支持 `sitemapindex` 嵌套与有界 gzip XML,记录未从 REST 采集到的 URL。若使用 Yoast 或 RankMath 自定义 Sitemap,可以通过 API 指定同源 URL。 - `POST /sites/{site}/rollback/{release}`:校验、原子切换回旧版本。
5. **媒体本地化**:在最终导入前,手动分批读取(每次最多 40 个)暂存文章中的图片,限制大小、格式、原站域名,自动重写正文及特色图片路径。其他 CDN 域名默认拒绝自动下载,并形成待复核项。 - `GET /public/{site}/`:验证激活后网站首页实际可访问,Worker 会比对首页 SHA256。
6. 检查扫描结果、Sitemap 缺页和图片缺失,明确点击“确认并导入”。将旧 URL / SEO 清单加入审计;存在未处理差异的迁移站继续受发布审批门禁限制。
7. 选择主题后使用原有 Worker 构建网站,查看 QA 和 ZIP。
**重要边界**:Sitemap 只用于发现遗漏,并不自动解析任意 Sitemap 页面内容;完整的 `srcset`、CSS 背景图片、跨域 CDN、附件、视频、登录后数据及复杂 Elementor/ACF/WooCommerce 迁移仍需要额外适配和人工复核。未运行 Sitemap 完整性审计时,系统不能保证完整发现旧站 URL。WP 媒体本地化是用户主动触发的、同步分批执行的步骤,不会在后台无界下载资源。 ## 四、运行回归
## V8.3 主要接口
所有接口需要原有 Session + CSRF + 网站权限;`POST` 不允许匿名调用。
| 接口 | 说明 |
|---|---|
| `POST /api/migrations/sites/{site}/wordpress/sessions` | 创建迁移会话 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/scan` | 将 REST 扫描加入 Worker 队列;可传 `idempotency_key` |
| `GET /api/migrations/sites/{site}/wordpress/sessions/{session}` | 查询检查点、页数和警告 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/sitemap` | 安全发现同源 Sitemap,JSON 可传 `url` |
| `GET /api/migrations/sites/{site}/wordpress/sessions/{session}/sitemap` | 已发现 URL 数、缺失计数、前 100 项示例 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/media/localize` | 分批媒体本地化,JSON `limit` 1–40 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/finalize` | 明确确认提交,建立 URL/SEO 基线与复核项 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/cancel` | 取消会话;正在运行的任务另在任务中心申请取消 |
后台扫描任务使用 `build_jobs.kind='wordpress_scan'`,与原网站构建共享 Worker/容量限制/租约/退避。可使用 `/operations` 监控或使用既有企业任务 API。**扫描完成不代表迁移交付完成**。
## 测试与已知限制
```bash ```bash
python -m pytest -q
python -m compileall -q app
bash scripts/check.sh bash scripts/check.sh
python scripts/verify_release.py python scripts/verify_release.py
``` ```
截至本次代码交付,130 项自动化测试在当前开发环境全部通过,包含 V8.2 的 120 项和本轮的 10 项专项测试。测试覆盖嵌套 Sitemap、压缩炸弹、XXE、缺页审计、媒体格式与同源限制、子进程任务派发、幂等、容量门禁、API 鉴权等。 **现有环境验证**:本地 Python、JavaScript 与 HTTP 模拟发布 Agent 的测试。远端真实公网 HTTPS、断网后的最终一致性、Docker Engine、浏览器跨设备交互以及正式域名端到端发布都还需要运维实机验收。严禁把模拟 Agent 测试记录成外网部署通过。
本环境没有 Docker Engine;没有实际执行 Docker 实机启动、GitHub 云端 CI、官方 Art Design Pro 编译、真实 WordPress 全量迁移、浏览器全流程或远端正式发布。是否能交付企业生产仍需上述验收。 ## 五、限制及尚未实现
接下来的 P2 收尾任务:自动尝试多种 Sitemap 路径、WordPress 媒体库/分类标签迁移、REST 采集的统计/重试监控、媒体 CDN 授权白名单和批量迁移压测。之后再推进 P3 XML/API/只读数据库连接器、P4 整站编辑器、P5 安全发布、多语言与 PostgreSQL/Redis 生产化。 - 本轮新增 **原生 SFTP/S3 发布代码及只读 S3 指针网关**;真实 SSH/S3、CDN、DNS/HTTPS、原子切换在正式服务器上的验收仍未完成。
- Worker 取消发生在指针切换瞬间时可能形成“远程已经切换、任务记录尚未写入”的待复核状态;必须检查 Agent 的带签名版本清单并人工处理,不保证跨机器严格事务。
- 存在内容/SEO 警告时必须人工复核;系统不会自动保证真实站点 SEO 或询盘业务有效。
- Art Design Pro 官方完整工程尚未集成编译;企业级 PostgreSQL/Redis 高可用、SSO、容灾和完整生产安全审计待验收。
- P0–P4 尚存在复杂插件迁移、真实浏览器、生产 MySQL/TLS、独立设备设计深度等未关闭项。
更多细节:[V8.3 验收与剩余功能](docs/V8_3_RELEASE_STATUS.md)。 参阅 [V8.14 原生发布配置](deploy/native-release/README.md)、[V8.14 验收报告](docs/V8_14_P5_ACCEPTANCE.md) 和 [V8.13 旧版发布验收](docs/V8_13_P5_ACCEPTANCE.md) 与 [V8.12 历史说明](docs/HISTORY_V8_12_README.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 实机与浏览器跨设备回归仍需部署后完成。
+14
View File
@@ -0,0 +1,14 @@
# SiteForge Enterprise V8.18.0-dev.1
在 V8.17 全源码基础上新增企业数据安全与灾备工具。全部历史业务模块保留。
**先阅读** [`docs/V8_18_ENTERPRISE_HARDENING.md`](docs/V8_18_ENTERPRISE_HARDENING.md) 了解备份、迁移、恢复和正式环境限制。
```bash
bash scripts/check.sh
python scripts/http_end_to_end.py
python scripts/pg_disaster_recovery.py --help
python scripts/migrate_sqlite_to_postgres.py --help
```
本版仍为开发集成版,不得把 SQLite 模拟测试当作真实 PG/Docker 验收通过。
+527
View File
@@ -0,0 +1,527 @@
8160c295667adfcabf7f9c1583b9d85e748c8f0da9dbce69b6cfb6fbcb1ea526 .dockerignore
df1884dd9fb98e9d34b0fc684f43477cf9fb83d5e2528735afb0131390e6d1e7 .env.example
268951a0195a3cbae90202d9ad981bd26ad47d3c9b60285faad0f52e4bad3a1d .github/workflows/ci.yml
bdb2dc0a51c3c076f6c546faac14d4d5f2adc115102d2e9e4319bfc3c955ce89 .github/workflows/siteforge-v817-acceptance.yml
2231dfe8f0e707d633756aeecc6c0a4d0528e1c6036ceab6b1d467ecf4cd2a1f .gitignore
4397605f43fd98bee2223852331757714decd30d9887635dd7a9038d794a84f5 Dockerfile
66564850ecf93dce486c2135016b93401a97eaded7f1a2bcc09fc935c7df3912 Dockerfile.enterprise
4844d1cc288ebfdd2972985778b8514edccb6601c78ab6ce26665009db503578 README.md
34520443b3e9cf65e22feaf4064a9baccb02aee2182bfd21d36c812af5129686 README_V8_18.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
db344b49c6e2f26203f0726b8a58e4b0b42683276e6ba46e8e4c93562e9fdd27 app/adapters.py
51f7258c207f8ac39c6087b42d5a7c479c2ef5f0fe4f9c60eef8f311c52fb185 app/assembly.py
ecd0e4bc46440bc6ee877d9361325deae92ded21e0de6e452a041fe7a81f2db5 app/backup.py
10f7e802d61a74da656ea6c62ee47ec366506fe6fdfff1598a61aa53b6c0383c app/blueprints.py
658647e48ae1c263934ef057c2532222e09a76b98c0834d3a73e31e0d736b27f app/builder.py
d6904c6d19646457eeec64afbd018fbef008ad8efde22d099cbdfca6552ff0c4 app/cms_pipeline.py
24643585b2cdfcf95e11df7b7a42359b2c9bae4ed288a2da330ac966ebf72dcf app/commercial_readiness.py
b02659cdb9c477bf066e9da8c950b6e5e915b5cd23ccb7c8ab2be21eb9c68146 app/connectors_v84.py
76647bd3087718346982694b9669138f3fd576ed82026a31ec96a79cb194b9a1 app/connectors_v85.py
cf080514f78e5d9789885360f32a554cca7fe0e4d1c51f0543dffff744edc0af app/console_v819.py
e9275988896ce3b3b730772e77699026aa556e1968148e46372e448c9e3ba803 app/core.py
ff3510d4e1db341637b0bf36e424ad3bcc389f658767675eb2332d5aac98b51b app/db.py
4ca6961e2dcd9d4f466bd29a7805fbd41af6d1d18f48a47a90a24192118bec73 app/deploy_agent.py
9eaf38b155172c557ee432519fbb287f59b2a657bd721f57eec1d93ceb73847f app/enterprise.py
a121f181115480f136a1c632d8fb3e954c374a6ce9e8e67ffc1a2bbd4b078888 app/factory_api.py
96d7205c657dd13a056223c75e578725a75c2e32c20594ac56f655b6825e2901 app/healthchecks.py
5ab6032d3e6dc96042a41583003f830b471009dc824d5f6cba5f5341477d1d02 app/importers.py
3e8d060ed93479c1f3853473c583e52550e96192356adc9e309843fac9068acc app/job_runner.py
100922fd669f98211e268d3b0b648d4750df7e1f33774447e6c4bc2cd3f6115f app/jobs.py
9e0150e86c6d8df055b8e1baa6933938cf27cc49952c081bf4f0e6e9c62bb090 app/main.py
9f8e80c8db2ff93bcd7371eea1b7addb49dd107df8fbaff90e84feb0dd837042 app/migration_audit.py
0a5fad652f4b5cdd9400de6f6d513bc6108170c63a4773a0bc79f8f7ce3ee348 app/multilingual.py
8632f8bcc9fb6bf13c332183473a374c57798c0059898d24636cd08b739db8c7 app/pg_compat.py
b5ef8ecb5dbf3736b815af9b78f3479719b1b709583a3dfba9555c6f98b9be0b app/pg_disaster_recovery.py
58971247927d5691dfa87f515d8fbf5d9492a1255a6d22a07ee7602a0ee762ae app/preview_rewrite.py
5ff67d4a664a0fc33eb1273da6b6252d90a6fcc8b7279462b531ef4849c50fdd app/quality.py
ca604b8bf684dd26c0697b6b19d1fa0d0c872a226e631266742f10039180823a app/redis_signal.py
0bc2351993b49da4b88a94bda13e24c0a718711f16667ec71ab1b635c442b7bc app/relations.py
4da0e2c48fad9f6a833ca40f170555c0c98250c17d1ea2c8fc2e744eaad6bd46 app/release_api.py
84dc5c1a78229efdc92c0092df64c8686a005af458a68a86642d8e4c4a8a3324 app/release_targets.py
b52a4bbf946449e48300f981f69ebd06dcf3f867af5700e3135207dd9fc7941e app/request_trace.py
9a86055d047bd3966b4d7934faca49960d6fb987235e26925de1e085039a9212 app/responsive.py
d8cc021c56f50de072368e23b12c318c19e58946a50989c66455e9a948af4f8f app/reusables.py
7cde83d093289210be2759864bb686a9537ccbbc94c5cea09f7a55eed707dc50 app/s3_gateway.py
5f5612da8d182fdaa4704fcbcb2da57428e70f9b0c88fff481b1b3c316f9a6f2 app/safe_http.py
99a193de6dcf33045112535ba23d31215e77da9751ef2b1af44a7c9b9a38a855 app/schema_registry.py
9d6c1e1209470e519d38cfd480497ca110ec977f2be8186ec9eaf6a137b30dd3 app/secret_refs.py
1fbdd2973f7124c0c5858c29fa4f89e56a7a0bf7fb4369d716060f80899ef165 app/security.py
10f3a61518d579a13e8f1d7e7e75b81a8a5231d984f230c3ee48031a8f61833b app/site_design_v87.py
9a7e7e8e90071590a6cb32b5e96beb4c44fb922cea80102aa73b8a794d092d16 app/sqlite_pg_transfer.py
18d358b56f21719a9f08b78ac9b7e26f1a74f98436f8c77bd75655de4160b269 app/static/admin.css
3d8cca57b06613796b11d2f634ee694fe6c69e6b0ce875ecd0d507dbe4e4ffeb app/static/admin.js
2901d62f48ba63f4a674b566df5ff724533f858b8dc73d13f831abec728d1d4f app/static/blocks.css
25bb8cc3c567cf5298536c7b870e4b70044cc83fde1ef8ecaedb8c66ff3ae163 app/static/commercial.css
607d65720012495068eb5a1c77b9b5ff1405dff9c0ec72f672fd4b9215eee60a app/static/editor-helpers.js
87856aa2d78488ebf8af5a0f7579a108aea183bdc9d4ba5ea950e405902c9d3e app/static/editor.css
ff96eb606ffcfffeaba9e9de59e8b4b18204ecd1c14f5649c3dc9ffdcce6aaf8 app/static/editor.js
6946a8ae5c590c15bebd862d008d13a802c6572e83e627737b62d29a765e4c96 app/static/enterprise-ui.js
e00adc97487f62f8aa7b9c6866b3a0178b5b820da039ae8b299afd0ab6a3877d app/static/factory-v2.css
89a91a6305f297b5197c8da287839b83a01b69cfa088ff3b694315bf2a4a70ec app/static/factory-v2.js
90ee3adaf693525b87e1d95fc49652573decf534a2abf742113ffb620a85897a app/static/factory.css
46acb1830cf8065a172036984bcd6fa496fb30a69785ae934507fe8d98b62fa5 app/static/favicon.ico
ea0192e7a6c11f21053ad57bab7c62f7415c6d19ec42b107b555a651d524d08b app/static/operations.css
b90a776a6215f5843522ae0cb2c49ed2c46a9c6f05d619942c14e7665c2fb377 app/static/operations.js
a5f356afed6bf4a4a72a95b5152a8a68ce48f418a7c12aa2b052f511e84751e1 app/static/pro-base.css
f681ff6686f296728c0ec7addd056994f9960ee87e751560c8dc53da339db531 app/static/pro.js
a3161924f80590a4f252690d9949faf36dc0591f083f2d38bbddcfa9592842d7 app/static/professional-ui.css
afc4b234f8503147e2ea4840dc6d6386c65dd216d3b66664c07dde76f2331bfb app/static/professional-ui.js
a76917b83e1c305b4d07c1e8fd28cbfdf36e1955a83d36a465f83e572cb6ed7b app/static/site-runtime.js
3bf16dcaf0e85e3cf5d4ab204fa5f60480e1462be865ccb8656a5b655b32933c app/static/workspace.css
3993aae17a23540c7579d90595175ad84b6d838a52701212a912c7090d705143 app/static/workspace.js
d1b0719a7cd9e202867cc28c3a6b2310c446e21b6f58d91d92684aa13ba3b419 app/static_site.py
5c2fafa38683d2dff8ac275a6b12b8daab66f592ff07c19da959955ef6b20773 app/studio_v86.py
2f909fbe08b8c2f1bac0af5c58fbbb74b61bbdfbc02ac1694a86832794609a5c app/table_loader.py
00a2966ac9fabaa06488c2644b00868293fc92d9a44bbc0a92d7f5462947dd54 app/templates/admin.html
d0980087a43c4480a07c0043d26bb8ca83d0641c5dcc90fff8d13d08efa03577 app/templates/editor.html
62e89984c93a510f3b2d9982fdb0ca7983a750c54d2114d084f49df6ad6bb6c9 app/templates/factory-v2.html
904db5703893e3429a1006e3b40f2c85317b2846ecbd2759b001dc8fb0dfb39b app/templates/login.html
01037e8d9e41248ae05472cec13e4ba8a8fb7a845e1a839196faca609c78e445 app/templates/operations.html
04a3826e0bca24a5050e6a673507476376fae3d24f33f8e0202931893defb175 app/templates/production-v8.html
8818fe0aea672ac3acf93e674712f0c965792183f292d85623b7eedbef5ed8a2 app/templates/theme-gallery.html
18cc2dd7be37a61879d29ab9f0ced42ee252e503c6ab97e5fb5d79095459beb7 app/templates/workspace.html
8e46c4e5ae3555cdabf542aaa4adfe6fd76ad2ba09b5264fa799ce54fe465dfe app/theme_pro.py
8f79878f39f1b97aa9cc3d7d30a79d58b30514518e271add02d81fc7ff8827c5 app/theme_store.py
b81a089c1a1aa709c924fa97f5b097797e7a28550f1c0d51854e5d392f4f94ef app/v8_templates.py
201c84875d4abb20d38fb79482ab0de075c911a2f1535d3ee44b1135e57c43f3 app/v8_workflows.py
06c36e6a4563535797c9ca56f6b660dc672832e3de4c5fa8e2e005a875988f20 app/version.py
9221fed981059ca2636d2201464a73af5ad200146da42beee23e936a1739a47d app/wordpress_compat.py
01f1ffe2f905bbfc28a8cd4f976d613b6866aac70491950f0acc1df4cffdf4a0 app/worker.py
a81c904d3b52af6b0be8311c1a22f9d06dc457b2f0502ed5e0eccd47e916f4f5 app/worker_health.py
49b6f9c88cdaa0f438e278347cb44302fee85f448812571dbf25984526c29683 app/wp_complex_v820.py
fbd08f545111f0c3f8c93e2a086bfdfd74aeecafb31c7dadf832b23645d09f17 app/wp_migration_v81.py
91fc8c973afcd3607f68de5229e305b359d22f269239af2932f173a89c1b2d59 app/wp_migration_v82.py
b360582b75e2f1ec88362777d10cd2cdd2af8c3342b10c2958120978970b8a95 app/wp_migration_v83.py
88057ac3b66fe3aa8c721c399aa215a8bcdee78955d7a4247415bebb8d2642e8 compose.agent-secrets.example.yaml
8e40d12416362d6a05d33482a22802b8142388bce1d05762a22d7abcfbf592f4 compose.enterprise.yaml
d040afae76ea42f5b50d39526f8e8e35afe6bf121b1d861859abe103fe5e9172 compose.mysql-secrets.yaml
e1d68ca913864ea297b3423e5e388d9d6682114b6f5519879facd18a61f54358 compose.public.yaml
207375844eeb51d1d860e58e1f61d633638da6c272009411e04d450a8bcb1ffc compose.publish-targets.example.yaml
da6ae00325e4c3f2a99c71af8d63017c9ed267aeac9c2898f6c452e4e4e14241 compose.release-agent.yaml
9ecab06570c12919d93c697374d49dd37a4dc2a4ff49895471ac037741572fa5 compose.release-secrets.example.yaml
1de018c7b19334035772a10b42dc14d6204ec3a5710061a5d98ff8912b6503fa compose.s3-gateway.example.yaml
506e1c126087393127448202d37f5d72684b9a189e27aa07a9919b18f89f1eb9 compose.yaml
0118e47006525bea623dd260ccc614b65d327016ada007e8dbf2c6ba24346697 deploy/Caddyfile
77f7036a1c0c72b3e04aa5197d820b968fc2c646b86679b2b0527613e8cd94ca deploy/art-design-pro/siteforge-nginx.conf.example
4f8fef885de20660176a05e717c062fdf570dc8d19b8a20343e85c966ccc3bd5 deploy/native-release/README.md
397ea1a45decb52c1e04b1f54bd725e54eaf84dea0f4c4e8c6991d26caf18de3 docs/CHANGELOG_V6.md
e0bd3ccaa54c4dcc51188f74420aa715285b650714eb2003b0b49d1b6186518e docs/CHANGELOG_v0.3.md
7dcfe5731c35539fd888854e1628ab8cd58a6434b2d7513f6533e246d8755fd0 docs/COMMERCIAL_THEME_V5.md
23dcfc4bb981ee647e8d4d8fc27367d22dccdafe526642d18bb3cf89aac1504e docs/COMPLETION_PLAN_ALIGNMENT_V7_1.md
d2ddc1ab9f40ab47c4adb8c4ffe599d0d4f003b8ef9323fdc1761d9187c1a8e9 docs/DEPLOYMENT_V7.md
7d63e0277e25f1b4284993be70495cee9ec81e3802ab9bd14ab9e62506ae770e docs/HISTORY_V8_12_README.md
9019aa7ae8548b9a9e6d29fc7860840a700bebd995d4f0c5bd131d3376754280 docs/IMPLEMENTATION_STATUS_V6.md
ae44d4843744f6dbdbe05dd6b348057158fb5c265bc57a4ab3d32717ef1877ac docs/IMPLEMENTATION_V4.md
258d09112cdbb4b6e11fc46d5670f1bfc1bc9b1324ece1b55ce6e1c3311831c8 docs/OPERATIONS_V6.md
3ca12f1387b066ec52c70fe54bf0eaceee77ca253e6676d5cd374f7958a36fd6 docs/P0_P4_ACCEPTANCE_MATRIX.md
30d9fb7bd0440434a3ea7647f026d0500dd5672afe99f3f69e01072235e231b0 docs/P0_P4_V8_9_VERIFICATION.md
9f22777f9b5b9223a8faad5fd00ece0a83e993ea7dec7a7a463bdd5c22400b26 docs/P6_QUICKSTART.md
cc5bd537c9f4c509c0999ff954b7a9758de5bc39754be62c3db5e0bfa91bc16d docs/PRODUCTION_ROADMAP.md
b1534e64fbecf1a8b1f642aa25f639fc5f89991c4fbc9ab511b9398246c054d8 docs/RELEASE_STATUS_V2.md
edb33b5f5b482591a01169b88d7ce688894fad69e8e2d3f994edbb653e382d90 docs/RELEASE_STATUS_V7.md
510349c284b7e44dcb9e8a8f92091f72c3e9cdb060d6364c5e2a430f9eecd304 docs/REQUIREMENTS_V7.md
df960ed41279c51cfcc0c7814bb62d3ffca01c42eb693c5689ce9e871774f529 docs/SiteForge_V2_Architecture.md
f3a8d60d2deb0f0b016af3a7ce19801fddc2ae8ecf6b0884aeb050a478e48ddf docs/V7_2_P1_IMPLEMENTATION.md
1c3f11c9faf2bf875f3d4121fdc726c1b8dab6e6f33d96a83f7e416c9f342e26 docs/V8_10_RELEASE_STATUS.md
46700ee2a818abed56ce76104490ea4d1f9ce093acc190ce98c1bf021c7172de docs/V8_10_UI_GUIDE.md
e5a86662ee9660dc85bb7a7b5cb3e535a5b32e6eb613f87fe3247cc77ee6cb10 docs/V8_11_UNIFIED_ADMIN.md
32a200865516375159c8948d61ded9543ed9cd91b99ad21f38e52c506225739a docs/V8_12_WORKFLOW_USABILITY.md
39bcf6c6fd02d895fd8151e499ce840fd7107ee3fecee624c43dd1d6a0f897d8 docs/V8_13_P5_ACCEPTANCE.md
ef6a3fa525d5e31d71f06d4ebbaf035e9e627bdf38cf23786be9aa8798b050ae docs/V8_14_P5_ACCEPTANCE.md
db7a5d1833e0fe15c9d5f49a3c456b49138a54675f50aded9aa317eeb742b140 docs/V8_15_P6_ACCEPTANCE.md
5e6c5b06fdededc3092302c260f4ff1b7a785cabfea41885b5af2b4fc5641ba1 docs/V8_16_PRODUCTION_ACCEPTANCE.md
877683f0dc74e564bf3f66c41972421f168773dfaef7c446336b313567b83959 docs/V8_17_ACCEPTANCE_RUNBOOK.md
8ddd24fc7559f9a85e0ef8d4c02fa7ddc034c063f08e6835cfbd6bc1c5eb9a0d docs/V8_17_INTEGRATION_RELEASE_NOTES.md
f20f02ccd342cd20d93655fa425217dcd103228b69634d1805bc89c3b301e84b docs/V8_17_INTEGRATION_TEST_RESULTS.md
46ae264dd42eba223ead7ed1cb3c4f6bd4f984d80bac2a72edaf36554a46b0b8 docs/V8_18_ENTERPRISE_HARDENING.md
bbf9acbb3ac38f62c22672136a763fc3c7bbbf97de6e5be3f843b0a3ad8b72ff docs/V8_19_ART_UNIFIED_BACKEND.md
3435a4d9c74e7b79022dbcf02e87561c9831323f05000b6613da3af0238b4416 docs/V8_1_RELEASE_STATUS.md
0772375d9a8f9c5d946e6e5c12ed3e6b87f1027ae17e0540cbdddaa0105e6cb8 docs/V8_20_COMPLEX_WORDPRESS.md
e7f6cb5ac850b9718ccc028c357b0bc728447da27eb946fc192537387a83ffaf docs/V8_21_1_STUDIO_UX.md
34f62aef43df157e9be0d0d805f66a1e8b56994b4e2a31725d5db2e9be5aed0c docs/V8_21_2_EASY_STUDIO.md
d00e37927e077dc96c0b5fc6620d39059b10a6e6d8e4e2c7a6639cf884c0c856 docs/V8_21_3_MOBILE_THEMES.md
fab1525c484f29564b9d5e1f81cd4f301429eda5f6fb1e5f5d277e68d3231d97 docs/V8_21_EDITOR_AND_MAPPING.md
4e0d51f96458a07500f609a891e77d05c6e27ef8e744c49c84073cb20f38084d docs/V8_2_RELEASE_STATUS.md
36c9baed7a596e2a50c487d985a4e035f935f9ba322ea4d89320e623457dc96f docs/V8_3_RELEASE_STATUS.md
bf1784e8616e5064df8c7d846f9f6fe1d6a677e1935e04824a2d663209ef62bb docs/V8_4_RELEASE_STATUS.md
9d09d3a943ecb41a1a92e994cb65d569212b8888e0585be24100afedf8552b2d docs/V8_5_RELEASE_STATUS.md
8dc46eee0205ffa5c5d6ed56c9e1b030c7b94d5e55df944b5e15247070fa24c8 docs/V8_6_RELEASE_STATUS.md
235951d21e66fb6b0eb784f00da4d4f51737e3a91f8585a133d4d626eead17b6 docs/V8_7_RELEASE_STATUS.md
0f1bd9c80859c099becf6b15c54bd896b1ca31707b448856000fbcd0592b7046 docs/V8_RELEASE_STATUS.md
5e94b8dc8efa5e65a072c0369796d90036446c55aa92c2bc951e8e0b0724cc31 examples/Studio_V8_21_1_交互预览.html
51b6a38ef26133246201621818b86a041866744787b620c43381a545900bbd2b examples/Studio_V8_21_2_新手友好版交互预览.html
503b72f32f10af98dd8e42636d5b5546d49f39d375738927e4f31af43af338eb examples/content.json
7fc29633073afe33fc6eae6a46ff380c1fd974fda205e998ed48d58a7042d9a5 examples/declarative-theme-1.0.0.zip
440d212d5101d8b6d4b53368c5929f686ca0e998601e2200c320e99396b373a9 examples/evidence.template.json
d7734fbd698070aa0e62cc5d1c3ae19e3be9e625fec97a84b9cdb4325c0c7c69 examples/mobile-showcase/automotive/article.html
2901d62f48ba63f4a674b566df5ff724533f858b8dc73d13f831abec728d1d4f examples/mobile-showcase/automotive/assets/blocks.css
25bb8cc3c567cf5298536c7b870e4b70044cc83fde1ef8ecaedb8c66ff3ae163 examples/mobile-showcase/automotive/assets/commercial.css
ce99b22d93867c3a1ab2d6f8344244f5f476f4b9e3274aaa92612a4d48a6c0cc examples/mobile-showcase/automotive/assets/custom.css
90ee3adaf693525b87e1d95fc49652573decf534a2abf742113ffb620a85897a examples/mobile-showcase/automotive/assets/factory.css
a5f356afed6bf4a4a72a95b5152a8a68ce48f418a7c12aa2b052f511e84751e1 examples/mobile-showcase/automotive/assets/pro-base.css
c4fdce0a226a3e952424fc72225d9a48f285783658889aa913c8e065ac0fcdd3 examples/mobile-showcase/automotive/assets/pro.css
f681ff6686f296728c0ec7addd056994f9960ee87e751560c8dc53da339db531 examples/mobile-showcase/automotive/assets/pro.js
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 examples/mobile-showcase/automotive/assets/search-index.json
a76917b83e1c305b4d07c1e8fd28cbfdf36e1955a83d36a465f83e572cb6ed7b examples/mobile-showcase/automotive/assets/site-runtime.js
7740f6529507f41901eb08e8d923e99e05e918e0ac3eeb35a65cf807da68cdfc examples/mobile-showcase/automotive/assets/theme.css
732a3d37ab415f4e877d9c7104b81c3822c931757801ca1f7b1bd57a23579d90 examples/mobile-showcase/automotive/assets/visual/hero.svg
b04f9fc7cada56dd9da42c6f19e9aa99c80e4c35723329649424f98681591e5a examples/mobile-showcase/automotive/index.html
354aa639424a8f72b230db973fa742b903945f4de87d4c99894cdf7c5abe45ae examples/mobile-showcase/automotive/listing.html
88c35a63bd8bbeb0d295eac40133634b2216d0af370ee18646c415be66077656 examples/mobile-showcase/automotive/product.html
67eb36ddd801d473d384dfb87b917d59c8705d28b67b1fabe24e0be9e54c7a98 examples/mobile-showcase/corporate/article.html
2901d62f48ba63f4a674b566df5ff724533f858b8dc73d13f831abec728d1d4f examples/mobile-showcase/corporate/assets/blocks.css
25bb8cc3c567cf5298536c7b870e4b70044cc83fde1ef8ecaedb8c66ff3ae163 examples/mobile-showcase/corporate/assets/commercial.css
ce99b22d93867c3a1ab2d6f8344244f5f476f4b9e3274aaa92612a4d48a6c0cc examples/mobile-showcase/corporate/assets/custom.css
90ee3adaf693525b87e1d95fc49652573decf534a2abf742113ffb620a85897a examples/mobile-showcase/corporate/assets/factory.css
a5f356afed6bf4a4a72a95b5152a8a68ce48f418a7c12aa2b052f511e84751e1 examples/mobile-showcase/corporate/assets/pro-base.css
5cf231b4fb89bb47fd18961214a197823ab24daa3df1c2e445ad9576532f0627 examples/mobile-showcase/corporate/assets/pro.css
f681ff6686f296728c0ec7addd056994f9960ee87e751560c8dc53da339db531 examples/mobile-showcase/corporate/assets/pro.js
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 examples/mobile-showcase/corporate/assets/search-index.json
a76917b83e1c305b4d07c1e8fd28cbfdf36e1955a83d36a465f83e572cb6ed7b examples/mobile-showcase/corporate/assets/site-runtime.js
7421696b8bf8f210a13663e20e6078aa1dcfb35135ddd489eaf4b9a14954aa8a examples/mobile-showcase/corporate/assets/theme.css
8eb9ae4b3bdbd778a9931079cb393fbf2789c2ce9f3226e47f903f9dd35cbc3f examples/mobile-showcase/corporate/assets/visual/hero.svg
3e8bbe485cdec6d1a82f7509ba85eb81daec7118fa971b53b15341f9b721c3f1 examples/mobile-showcase/corporate/index.html
0472f5dee2073fcbcf107d9d9c62e8b17e625b80a09058c401bc37a918df1965 examples/mobile-showcase/corporate/listing.html
328307b5cae28b6df2215c13f5866824b334c9f2b082995a6fb635bc7414ff45 examples/mobile-showcase/corporate/product.html
2a41272ff1f4213028d802d0026210859f7951c4ddf9a9696c616b988f3c2acc examples/mobile-showcase/index.html
1dc0375e4a703408a266c5cc9b827cd6c6dc73f7a8ed6af92ccd473cfdbfecf5 examples/mobile-showcase/industrial/article.html
2901d62f48ba63f4a674b566df5ff724533f858b8dc73d13f831abec728d1d4f examples/mobile-showcase/industrial/assets/blocks.css
25bb8cc3c567cf5298536c7b870e4b70044cc83fde1ef8ecaedb8c66ff3ae163 examples/mobile-showcase/industrial/assets/commercial.css
ce99b22d93867c3a1ab2d6f8344244f5f476f4b9e3274aaa92612a4d48a6c0cc examples/mobile-showcase/industrial/assets/custom.css
90ee3adaf693525b87e1d95fc49652573decf534a2abf742113ffb620a85897a examples/mobile-showcase/industrial/assets/factory.css
a5f356afed6bf4a4a72a95b5152a8a68ce48f418a7c12aa2b052f511e84751e1 examples/mobile-showcase/industrial/assets/pro-base.css
61e5ee68f42e743ae15da56c5f18cafc941e276cdc6c863e45bc8daae1023edf examples/mobile-showcase/industrial/assets/pro.css
f681ff6686f296728c0ec7addd056994f9960ee87e751560c8dc53da339db531 examples/mobile-showcase/industrial/assets/pro.js
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 examples/mobile-showcase/industrial/assets/search-index.json
a76917b83e1c305b4d07c1e8fd28cbfdf36e1955a83d36a465f83e572cb6ed7b examples/mobile-showcase/industrial/assets/site-runtime.js
a2e30bcaa8d4e15658558531329c177c1aecfa980cf36fe001ed5effa38ccd93 examples/mobile-showcase/industrial/assets/theme.css
75d4bebbb077e5af36d4f9383f893c91a8b45db1ffd91dc6e2d5107daa849006 examples/mobile-showcase/industrial/assets/visual/hero.svg
0d1f318ff83c436b3ab7fb3405df67109c188ef5bee59816685360d8d88edb75 examples/mobile-showcase/industrial/index.html
a3473c04afcaa2db36fba5604e59944ac6434ac6fad1cd4daea4831e5fa4b804 examples/mobile-showcase/industrial/listing.html
74f7d31274ea7944df622e6502fb35f5c832808c068d4c394ebf7e44d6791fcd examples/mobile-showcase/industrial/product.html
d3ea10d5c1cbd84df83456421668044fdaafd0f0c4de730f8d17c5cfca870d9e examples/mobile-showcase/minimal/article.html
2901d62f48ba63f4a674b566df5ff724533f858b8dc73d13f831abec728d1d4f examples/mobile-showcase/minimal/assets/blocks.css
25bb8cc3c567cf5298536c7b870e4b70044cc83fde1ef8ecaedb8c66ff3ae163 examples/mobile-showcase/minimal/assets/commercial.css
ce99b22d93867c3a1ab2d6f8344244f5f476f4b9e3274aaa92612a4d48a6c0cc examples/mobile-showcase/minimal/assets/custom.css
90ee3adaf693525b87e1d95fc49652573decf534a2abf742113ffb620a85897a examples/mobile-showcase/minimal/assets/factory.css
a5f356afed6bf4a4a72a95b5152a8a68ce48f418a7c12aa2b052f511e84751e1 examples/mobile-showcase/minimal/assets/pro-base.css
0d435209536c0c5880c009d7e092dc3e554001b42aa52f83a28d0853d89a54c5 examples/mobile-showcase/minimal/assets/pro.css
f681ff6686f296728c0ec7addd056994f9960ee87e751560c8dc53da339db531 examples/mobile-showcase/minimal/assets/pro.js
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 examples/mobile-showcase/minimal/assets/search-index.json
a76917b83e1c305b4d07c1e8fd28cbfdf36e1955a83d36a465f83e572cb6ed7b examples/mobile-showcase/minimal/assets/site-runtime.js
de2d571429238c261a8007c4ab0b9e21248bf86d5a050993795f42d508c4ea6b examples/mobile-showcase/minimal/assets/theme.css
a6354fbf3249d11acf8d3aa77bc9f541a1db37d2f2a5dc37141509635cf428c1 examples/mobile-showcase/minimal/assets/visual/hero.svg
7f0b6cfac7eb54dfa25ebeb9c1bf39019270f65f5909414da9e9b4fd120801cf examples/mobile-showcase/minimal/index.html
bb6aa568cfe3dd88f1a578b97e9c44bb51b40681c29d9b0a5bcceb374e12265f examples/mobile-showcase/minimal/listing.html
c39f5992acb242803fbbb90b4ba471307e3d01b99732aa9044ed271ba2945a5a examples/mobile-showcase/minimal/product.html
e66cddcf207d131a56b27dcb000a61edee12093a4dbf9aacf80a6cf993479fee examples/old_urls.example.txt
1dab822b685885be30fc3d863178aec3d39c63c63f7ded989e203d5f2c2ba3e2 examples/products.csv
085c9abf8a24acabf4926930ff66d59af54db2f67464a33c9ed7259506fa68ce examples/professional-theme-1.0.0.zip
4536502b97660b9e7d4f428fb30799d626df76a3b6282b0fdf895d6e1958e695 examples/ui-preview-v811.html
44124c6090bf9a5cc1cb83b20cd8114aa28f0bf2c621d02e9eb46d647c5fe887 fixtures/v820/wordPress-complex-sample.xml
4ee8162d3036fb75250fe31474ab623ea04d848fd1fefe5e89a88a0ff481ce24 integrations/art-design-pro/README.md
073da020b5d75954912452e4631ce8262cace5c1604fbc456c5e7a3fad98881a integrations/art-design-pro/src/views/siteforge/ConsoleHome.vue
87ab2867880c11e1cf8622c5866bbbc90f64d36d0fd0ea02f1b9c5cf71516eba integrations/art-design-pro/src/views/siteforge/ContentCenter.vue
9033a7d39950ddd2aab946b563b1219aba24a083f4b9db6ef4f14bfb425c5afd integrations/art-design-pro/src/views/siteforge/EnterpriseCenter.vue
d122d4e7e8812592bd068ed6de9ea7e47e4254e96edba3927f4cd03526aab640 integrations/art-design-pro/src/views/siteforge/MigrationCenter.vue
fc40cdd757e74c2a6191f292f6f5942d584b09fa108f6074a5b7f9d384a83766 integrations/art-design-pro/src/views/siteforge/ReleaseCenter.vue
73e487c0332b1cf8c76f4633847132e3c2f7510ed38e667367885608e2140fa9 integrations/art-design-pro/src/views/siteforge/ThemeCenter.vue
883300e6de94bc1273de0b0406b71d6c5ee26e6b67366f5c6d84dd83cfcfe221 integrations/art-design-pro/src/views/siteforge/V8Production.vue
df08341b3de99a068b807d4f9f70b98381da3953703fcfacf8a2182887a00b83 integrations/art-design-pro/src/views/siteforge/VisualEditor.vue
3569d331c2ccc7b938641d20906791ea71d829dfc6bbcd839478c8e575587e97 integrations/art-design-pro/src/views/siteforge/WebsiteFactory.vue
1c330830a66f780d882c4af8d75c4dcd298da0c200c9eb577b8feff4e21e7400 integrations/art-design-pro/src/views/siteforge/_shared.ts
14fb28fdf4ca5b427455c60b5740884eef6403b53448330af8c9e66c497f69de pytest.ini
aed398c04846446a0068e49864b433dc172256a47045fc8cd387f23bc1257b7f reports/V8_21_3_移动端验收总览.html
e34f8dad614495ce4f6d2ac5d2e7a648687b6bb5f5bc4c64a264e129e39ff4ee reports/V8_21_3_移动端验收总览.md
fe85e9bf26f194ca93fff722b762754353e45e3f4793f236c974b5f5126b0312 reports/mobile-v8213-automotive.png
f73fe5b8a039e8ce271d30019c35b66a779ad8b87f0f77e0c5b7bbcacb788279 reports/mobile-v8213-browser-console.log
b7eb359bcb20c7dc02ee5d8f01f25c3505edc8ac2046854f22bb4591a84b0b61 reports/mobile-v8213-browser.json
80eb1be649f258ba492e642c87fdeb111cb0d53e0e2c0d724cb23b1b6613eddb reports/v8213-final-regression.html
2731550949eebf7f1186b739eab480efa9d2a122b1ca4691744349cc1d8a32a0 reports/v8213-final-regression.json
155026cefbcb8f4462bca111b8905630dd553386f6c6eeec2bb24f0256bde763 reports/v8213-final-regression.md
bccd0fe89f389f9c326564b6972f1314d6d070e28cf17843f3aee382f2064e4f reports/v8213-final-regression_logs/round1-batch1.log
2996dea9e66555c38d4542851628fe61e1a80c4eb2ac0329d0e82fce495014f2 reports/v8213-final-regression_logs/round1-batch10.log
81294ee87af2b756b3f9df3b7c9afe615001ad7304ca1447b467c64ad4ea2665 reports/v8213-final-regression_logs/round1-batch11.log
d8c9922ea0fbab88bd1eed0d001ad52ca98b17975d38e8deea4d0d9fe596b35c reports/v8213-final-regression_logs/round1-batch2.log
ccee3d2db6d9ff75baee8b9dbbcb4fdcd91b625e58c5cd836aa2a10efac7911e reports/v8213-final-regression_logs/round1-batch3.log
66c175f4a4b7b3576568651d441da4525db12c47767db3eae02a4b7235444133 reports/v8213-final-regression_logs/round1-batch4.log
34874cfda7987f0684900823766877380611f01ffa0d2c873f1d3f62f3824a05 reports/v8213-final-regression_logs/round1-batch5.log
1349efe3a1e55822e9046da7fcaa69f267ba11d1c1ab2d686ad4c1f16bec1082 reports/v8213-final-regression_logs/round1-batch6.log
11405b9c8284a798c06ddf50a2449044ec75b1c05dab52cb2fa89c01cb97187a reports/v8213-final-regression_logs/round1-batch7.log
8aa1edc5aa0ce2b01868824ab4cf96ed276ce04d9df0dade875489600eb09d1d reports/v8213-final-regression_logs/round1-batch8.log
cb005c22b63c340f23b9e7ecb111adb1569d664c7adc86c17c5f633c2912421f reports/v8213-final-regression_logs/round1-batch9.log
db585d1d9ed9dc7f295f9d3b7e8de6feb404f7a1767a80af55fff013c0f66c1c reports/v8213-regression.html
1c4b559441cd0e23dfcc3cc06cfbdbf498f3c2b8c77d6644b522df24f07f1fa8 reports/v8213-regression.json
fc43c8ec6d31b09ee7c3ed7c66eb38c64adc496db6145549e6356e2fe144bf36 reports/v8213-regression.md
45f589873f5c916eb42add4ab200541cf5420952826ea9c24867df465c27d924 reports/v8213-regression_logs/round1-batch1.log
03480b3f6b08fabf693ac994947849400355a281fd6106cbda1856959fad2c1f reports/v8213-regression_logs/round1-batch10.log
d74edd2dd15c4ab9d486b04cc9d6884af7898430fbaec52b6735dfab8021dda1 reports/v8213-regression_logs/round1-batch11.log
8a47a9eab0220194e1480323a77e540efae2f82c0300876591df5bc5321e3185 reports/v8213-regression_logs/round1-batch2.log
e1470a1a5fcd10be3f13a7504c36476b352c1db04b33922e2ac644eba58ee44e reports/v8213-regression_logs/round1-batch3.log
c0af4e5795fd4d0f3c7c2a0e3bb590f07d7d0bad32ba9112560116c0c0018609 reports/v8213-regression_logs/round1-batch4.log
788b4648d0418ed36d8adfc915087e7ffa04a893948e610f2f6f4b7fd4f61edc reports/v8213-regression_logs/round1-batch5.log
37dc6d8499e020fe985c94a4a0a60c4e0d43ab3ab6398d002bd2865138b9a48a reports/v8213-regression_logs/round1-batch6.log
8e784c828267469a6bbdae17447fd6b67c3a74356e40135e64b09cb293e26eff reports/v8213-regression_logs/round1-batch7.log
72f1188a1c8a390ecb9717abad33af7e54f39e2da75f31a5c3880765a2d8f8e0 reports/v8213-regression_logs/round1-batch8.log
4106fa4b2a15ae04b87be8a5718aa203446b26f04cb0ae66fab5272beb4247da reports/v8213-regression_logs/round1-batch9.log
c3a84856b2f24bc42d1906b9cc13c7183fd60893b5bd3e2584316ba0fbb77aba requirements-browser.txt
b407b691bd09d8971eb740b15c00021e7cf2768e380018314ed37b6dd54b7788 requirements-dev.lock
415388e890176d100337197eecd02090c4c2ed119961b7b33f1dfcc53f3522ca requirements-enterprise.txt
0495f1025cd7943144431002ae4b2ae2e97d1157ab2d3a82809d670afc22bd29 requirements.lock
2ceef8bf20d5bb17820cafef0de12378c734258138625a84b831d8e4f4ecb2b5 requirements.txt
3472ece8dd8c050ca617006abdc59578cfdfd4637a965471285d0af3cb17edec scripts/browser_mobile_v8213.py
b7a6785b246fd03c2f52cc81fc65c1fd6b4854227589affd49f8f65b908d6851 scripts/browser_smoke.py
6f8669b936445cd70e4ef355d0c9aec58c12690889dacd594533cd3b0f011886 scripts/browser_studio_v8211.py
721166cee5860e1c73520fec92340235ed57e8fd2005d8d6b2e9fcd8c012da40 scripts/check.ps1
064248f80aaa5a6b712a45a7d0ac5713b7a7bbd40c95f571f80f17e8ee281f38 scripts/check.sh
4562fa6d09b05019431844a2580c9c76e7f22ee09485369bf8f37e5daae7e161 scripts/demo_multilingual_v815.py
cb9e388ae3229befbe684a417b225994c73070ccbd7668091a88edcc23c2194a scripts/export_wordpress_url_baseline.py
1418c04906f368de1da191973ec30ab1607b6cf8a19f5e4b919c47581089cfa5 scripts/http_end_to_end.py
f60422bf26c23e55dc579d1faaea29a0f413df3ba7f4b0ba81f2f42660219c93 scripts/init_config.py
8c13463a2b4f90c47f587b27bbc855d08a0bcd0df095c63e67370ea1c1f2a7c0 scripts/install_into_art_design_pro.py
288456a27bfb073ef0f318e699867f09d2e63b63f500f4a2dc454fd8e518553c scripts/make_demo_v87.py
9fe93353fd29e9024bf3ec5b8f5db0aa2299bae95c998644ca1700539d42bad3 scripts/make_offline_preview.py
cb95b024c432d2f0e038e24a8d39921a0ea3c671bd4407372c261d568250812b scripts/make_theme_demo.py
b455260a67f8e7a0d24c3bcf11a9d97e3457c9e5c232cbdc4b1102ad11c0f0ab scripts/migrate_sqlite_to_postgres.py
85d99354794ae3b8d109ab80fd0f14a9f8761938ba0e3d0146421b41e53c0a9c scripts/pg_disaster_recovery.py
ef879522a7767f444479691edf727f85b35164b9a981b86375cab9e548f49851 scripts/pilot_5000.py
0d8cd086eea69171269c529ce47a844b32ccedce0186386cab30de363fa468d3 scripts/regression_v817.py
deb8e1829d3472b7563c3af65545115ed172ccd7cc94fa34302c4c4f86cd08b1 scripts/regression_v820.py
d8521180e05e701045d3baac89119eb23e48559a5a0536f74949efac5763f6e0 scripts/regression_v821.py
5bcd06b744b4e50c9a6e8783df723723588ba42ccab85bbcb5808a5ffb3bebc7 scripts/restore_backup.py
7b08a9c8393cd85bf5b43f90d31824e69152a9ee0aeb85355dc246315f513347 scripts/siteforge_v817_audit.py
94c64376f70178f209966ed64ddd1ef254b22cc17c1d1925dfe48290f8ef0a00 scripts/split_theme_pages.py
d3a11a643472bfa09113dc22a37ba05a44d457050b9831fbf46e2d6f42eb8bef scripts/verify_art_design_pro_v819.py
d5309478ede3ba625d585dc2837f75ca1bcba558de167d1515bf144e222e06a9 scripts/verify_pg_backup_restore.py
fc706a2157f928c2f0c20162833e0ed81a806928a4fd40de75891fb6ef1d93d7 scripts/verify_pg_bootstrap.py
6bbbef44fae6d78bd2bf0cf47da82231ae378ea10109368496ff7ae181a0c8a7 scripts/verify_redis_signal.py
d3b624bf4599f77f2dda3b1270c0ccd67e3b3fd84f7b97ab83a7ef0910915e44 scripts/verify_release.py
12e3ff67f6da300ae4f960815aca55e081fadee785dae293ff18cf3f1b86bf8f scripts/verify_sqlite_pg_transfer.py
d2b8733a3a7b30bf891e26272bbfe117c588f20030695bde7880258b371b606f scripts/wp_v820_preflight.py
b672af10eab58394677df926007198711bb875e220af68240c53a41e8dbdefee scripts/write_v818_acceptance.py
0af611f0ef2abb27424b6e1991d63fabb9b58c715e6c14fde959d4c02fa9b93a scripts/write_v819_acceptance.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 tests/__init__.py
5dfe8c77e631ffe7932d4ac635ae4d1fa497ae79f943e3748100e1516e7df649 tests/legacy.py
e796c595978c128f29fe76659d3abae81f3d925ff1eca4821fdb31438fab6a91 tests/test_commercial_v5.py
24638cd075fc3d43eb9f5c9043b53f51ffda9e0be399b36866b0aeec5da9948b tests/test_connectors_v84.py
192f4733e41451337ff434456fc4fb36a92bb712314b186963eae2c8125da060 tests/test_connectors_v85.py
26aef85e219e62f1052f862789e5c4397f50c2dbebf8b8d7a4f9633f2fe2030f tests/test_console_v819.py
231750efdeab991c8e5250c00f7552f15e770c4d9fa302b9b7079c5c6621d48b tests/test_core_v4.py
a8825db764cd07e622de87cec32ec7b15bcb58157b9bb1ad4bc095b876653200 tests/test_editor_easy_v8212.py
dfa352ac631f2e941dcde007e2276b83ecfab118cf4a5c79e5484b3f01b6ba14 tests/test_editor_ux_v8211.py
52258cce5592a449aa8a6ceb13aee8f013faa4bd7828edd740a652b86012e3ca tests/test_enterprise.py
4ea173ce905b62d83a02347001036acd1dd36c0436fddeebb7af2ee0801e5c07 tests/test_enterprise_v7.py
f3243a938041fbfc8e8657dc3cac3b878069c8a41d05353ac4bdbfd4d0853e13 tests/test_factory.py
8f374a6a1b55e450e571d21906decd27604e48435797fede7f2f946948f66937 tests/test_factory_v2.py
3380435fe0ad3dc7010836720b50b2e06361cfe16ca093278fc4cb431faa4e63 tests/test_mobile_tabs_v8213.py
84fa19aacc1d25308b498413b6f6179f709d4887171334929becd7832200cfe6 tests/test_multilingual_v815.py
c5cf789733593e695153c7a70577b8f83980c37632ca0534ac08fc2a3cb772a5 tests/test_p0_71.py
12cd73d922d1a4bc4b3e244499f7e25595e06b4f141832c4586a80d292923593 tests/test_p0_backup.py
5d64577cd53252404b9ece944a8dc3b7d3aaa1abc2c098c50486bc079b52f631 tests/test_p0_p4_completion_v88.py
53962cf2982b63ea6209235f811b20210811dea87885e9dd73640de5aeb7e4dc tests/test_p0_p4_completion_v89.py
9e010c9808cec332314ecabd982e1dfc616e62021dd9b77e2bf440f59725527e tests/test_p1_platform.py
7df0c88f6676b1a2183011316fe6a6f85c96c012ee7b3aefb268138e138f8199 tests/test_production_v816.py
e03e94aafdbd6c388145591b5ac5261179fad91c92746cab80703bad1139900d tests/test_professional_ui_v811.py
ad87729c5fd185efcdd145a95cc64ab12f17b8744c854747c4216925f3b7021f tests/test_regression_runner_v817.py
c3900769ae4bdc5d37d2d4a7ced3f86c2e6755015d9d238d8cc100c980d2f46f tests/test_release_native_v814.py
17839885f66be132b7ca66e9565317a9efcdff0f8766076909962e9c59759b16 tests/test_release_reviews_v7.py
b48a7f6c5a0dbaf829bec4cf66f47761cc2a304d606b0b2380f1ced05cf0d7a3 tests/test_release_v813.py
dbd245f3149880671821074775e2338137536519ea5d79d17c6b2d7dfc01a189 tests/test_site_design_v87.py
a089b321d9851228e8971d3e171213337d7b8480cda5391831685955893a2e6d tests/test_siteforge_v817_audit.py
77f0d65d506a85c7e6ab3082d7ea319035f1ab63e5ce377d8ed0ec9d1d2c0982 tests/test_studio_v86.py
04599e8b758c632e4e7ad74445e95e875b979d79b37f4c4cbaf33b6665d84841 tests/test_theme_pro_v3.py
65866ccc626f266e2ce84495338080fd5ce37849e009ac128915b8cd4d4ecb09 tests/test_v818_pg_recovery.py
9d4a0a0ddc080bc9af9e4a7e6bae2252d692035388e7f574cb9097fbac4177a6 tests/test_v818_sqlite_pg_transfer.py
fd07a2536975b7267bc97fd6d5b6f2b8f496fc4d24f294bf4f942a4640153ad7 tests/test_v821_editor_history_and_mapping.py
6d1f2d2c45921a76a6cb5b5a3f6bf42c2df10f2c58a0117c40494fa6674c83fa tests/test_v8_art_installer.py
eabbd02268b6aba9a78a810f8dd22d519acb76b9f74068c701fd6bcdeb74d1b4 tests/test_v8_workflows.py
3c2058f4a0e029e4761cf25b532f0999da1b0d1fd2a06b3debb587d7e1707d06 tests/test_wordpress_baseline.py
efa8b6a4f765b73b362e31c4d24e480002a9481168a17f074f4abdeb3d2b56eb tests/test_worker_queue_v6.py
99e68531e65ee03243021850da89ff8b716c21374e81d83e8b068a508985a38a tests/test_workspace_v810.py
f1a0bed80b3ea20fc5743f5a825c9fbde053603172db03ab2b658293c96bf11b tests/test_workspace_v812.py
554299864a6a946601c67d874e0abfbac3ceb99beec81dc7314d2bb0b4df3f2e tests/test_wp_complex_v820.py
f9d4f1734077b8a8cbfa46fd79ecebf9f770f1ce4ff1ab2e32c49a6a2d613de3 tests/test_wp_migration_v81.py
e8c356f904326771e9a9470367e2e42e9cbcbd4dc655fb1b172928fea1753d15 tests/test_wp_migration_v82.py
bd3f32c385264fd93aeae8833f9cb85e9b0fa552d23493eef11074836a0ca174 tests/test_wp_migration_v83.py
82c5b1c9202cfb87a9e5c9ae1254fbb1581d80efb75c3ec066910cc37e34ee6b tests_js/editor_ux.test.cjs
8a16d1e70f9ab6c849ed8c0196eae0125d2f1dac32cf19130e7e078549ab2e9f themes/_professional_components.html
f6e40134b8f5294c7f02b4974808c095a46a99628bb101cdadbe9ed06c5c3e3c themes/automotive/manifest.json
c0e7c5fd1617d48300f0bac5606b1f6b1b5a86f2f51e086a8439de19874a3425 themes/automotive/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/automotive/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/automotive/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/automotive/pages/case.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/automotive/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/automotive/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/pages/faq.html
b7920948d65fb0a2c646993261a4c984a7d76395523cd27f53192164f89024cc themes/automotive/pages/home.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/automotive/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/automotive/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/automotive/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/automotive/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/pages/standard.html
c4fdce0a226a3e952424fc72225d9a48f285783658889aa913c8e065ac0fcdd3 themes/automotive/pro.css
2be448929ddcc4ecde89c0647f593c045d0ae0f962297868b3771e606ebd13da themes/automotive/pro.html
07386d7d71b50f0ea19f85cb2df4b2d717170f13a42176ed446062b6f3e40743 themes/automotive/site.html
7740f6529507f41901eb08e8d923e99e05e918e0ac3eeb35a65cf807da68cdfc themes/automotive/theme.css
5b295f03ca539ece288548541ca00b30458d9433f192d823aa7635c3e56dacdd themes/automotive/versions/0.3.0/manifest.json
f87685b7369d5297dfce6d72bddab9fe4b3d42b66dad84efd3f3a80139a38d4f themes/automotive/versions/0.3.0/page-presets.json
07386d7d71b50f0ea19f85cb2df4b2d717170f13a42176ed446062b6f3e40743 themes/automotive/versions/0.3.0/site.html
7740f6529507f41901eb08e8d923e99e05e918e0ac3eeb35a65cf807da68cdfc themes/automotive/versions/0.3.0/theme.css
91d3cad90009c93c749cfc007f2c162d727ba86f69a9939ea40931cc908aaa53 themes/automotive/versions/1.0.0/manifest.json
c0e7c5fd1617d48300f0bac5606b1f6b1b5a86f2f51e086a8439de19874a3425 themes/automotive/versions/1.0.0/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/versions/1.0.0/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/automotive/versions/1.0.0/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/automotive/versions/1.0.0/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/automotive/versions/1.0.0/pages/case.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/automotive/versions/1.0.0/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/automotive/versions/1.0.0/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/versions/1.0.0/pages/faq.html
b7920948d65fb0a2c646993261a4c984a7d76395523cd27f53192164f89024cc themes/automotive/versions/1.0.0/pages/home.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/automotive/versions/1.0.0/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/automotive/versions/1.0.0/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/automotive/versions/1.0.0/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/automotive/versions/1.0.0/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/versions/1.0.0/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/automotive/versions/1.0.0/pages/standard.html
74181f49b666377638eed3776737d2f54e24f4fb4d8d9172068cc4d21f3654d9 themes/automotive/versions/1.0.0/pro.css
b1f825b6b215257a643919410e39be60f42980cb4e2a10814b90824ae9aea5a5 themes/automotive/versions/1.0.0/pro.html
07386d7d71b50f0ea19f85cb2df4b2d717170f13a42176ed446062b6f3e40743 themes/automotive/versions/1.0.0/site.html
7740f6529507f41901eb08e8d923e99e05e918e0ac3eeb35a65cf807da68cdfc themes/automotive/versions/1.0.0/theme.css
732a3d37ab415f4e877d9c7104b81c3822c931757801ca1f7b1bd57a23579d90 themes/automotive/visual/hero.svg
764a96f305d46b733a450b8139e4dd0f0c752ac3aa97a9255b20025e5ac163d3 themes/corporate/manifest.json
c792f57e830744306047ea7e0db806499d417b08ca30fdd8210c388055f357d5 themes/corporate/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/corporate/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/corporate/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/corporate/pages/case.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/corporate/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/corporate/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/pages/faq.html
e1204a00120a6e769fda3adf510106be3f2f14b51a6ef001f79a977ccb0a9be7 themes/corporate/pages/home.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/corporate/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/corporate/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/corporate/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/corporate/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/pages/standard.html
5cf231b4fb89bb47fd18961214a197823ab24daa3df1c2e445ad9576532f0627 themes/corporate/pro.css
388828da6f27ff2b6b9525b963318b8f5410e8f27a8b322b002b8a64d723ce84 themes/corporate/pro.html
c819c8026fe231497a9287a116e744e368753820a33a7af322226a44ed407e4f themes/corporate/site.html
7421696b8bf8f210a13663e20e6078aa1dcfb35135ddd489eaf4b9a14954aa8a themes/corporate/theme.css
e86b64bd48209d63679a10427cc3e904a8d7f51b196d142c53f636bdf5a09b1b themes/corporate/versions/0.3.0/manifest.json
87ec89878c501eb32008bc20eb9118427aea5b2c180919c77db8d20d8517350f themes/corporate/versions/0.3.0/page-presets.json
c819c8026fe231497a9287a116e744e368753820a33a7af322226a44ed407e4f themes/corporate/versions/0.3.0/site.html
7421696b8bf8f210a13663e20e6078aa1dcfb35135ddd489eaf4b9a14954aa8a themes/corporate/versions/0.3.0/theme.css
e94dc6550b72125f1638e2424a3964f82463f3da88ef8074b23f3aba436efb55 themes/corporate/versions/1.0.0/manifest.json
c792f57e830744306047ea7e0db806499d417b08ca30fdd8210c388055f357d5 themes/corporate/versions/1.0.0/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/versions/1.0.0/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/corporate/versions/1.0.0/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/corporate/versions/1.0.0/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/corporate/versions/1.0.0/pages/case.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/corporate/versions/1.0.0/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/corporate/versions/1.0.0/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/versions/1.0.0/pages/faq.html
e1204a00120a6e769fda3adf510106be3f2f14b51a6ef001f79a977ccb0a9be7 themes/corporate/versions/1.0.0/pages/home.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/corporate/versions/1.0.0/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/corporate/versions/1.0.0/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/corporate/versions/1.0.0/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/corporate/versions/1.0.0/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/versions/1.0.0/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/corporate/versions/1.0.0/pages/standard.html
9a5554aa291de86a7bd4061336a6a3936d2c8f11e625f156742ebf5ed752a945 themes/corporate/versions/1.0.0/pro.css
8b06a52682226604b59fb4217784f10f233f34d1077ac1aafcfaf51d6bb8b19d themes/corporate/versions/1.0.0/pro.html
c819c8026fe231497a9287a116e744e368753820a33a7af322226a44ed407e4f themes/corporate/versions/1.0.0/site.html
7421696b8bf8f210a13663e20e6078aa1dcfb35135ddd489eaf4b9a14954aa8a themes/corporate/versions/1.0.0/theme.css
8eb9ae4b3bdbd778a9931079cb393fbf2789c2ce9f3226e47f903f9dd35cbc3f themes/corporate/visual/hero.svg
ebd36953d821750b8defe25578901f3f00adf8391ae447c16a7ff703b2bf5a01 themes/industrial/manifest.json
191c6ba0f76ae37b990176af254333f10fefb06e72d0ef353ba8b62c0112c534 themes/industrial/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/industrial/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/industrial/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/industrial/pages/case.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/industrial/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/industrial/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/pages/faq.html
70ea2da210acef293c6279168c375b8532cc2347371daff0e2836111b6cd4ca0 themes/industrial/pages/home.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/industrial/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/industrial/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/industrial/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/industrial/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/pages/standard.html
61e5ee68f42e743ae15da56c5f18cafc941e276cdc6c863e45bc8daae1023edf themes/industrial/pro.css
fc753d9d35c96df869e7d5ef18435773e8bab252a57c337deafb4cfb9f7384a9 themes/industrial/pro.html
3ab957e66d9c954c45de8c8b5624942df2d2ca5f4b0591f26a5e1d89ca27a462 themes/industrial/site.html
a2e30bcaa8d4e15658558531329c177c1aecfa980cf36fe001ed5effa38ccd93 themes/industrial/theme.css
e3bd50c633bc55f44c530e31734da0adf28b09a57f11095709f26ca93d47e375 themes/industrial/versions/0.3.0/manifest.json
91107383d370ea4c2646eebb7d54e69f011c9ef97ea8e14f596ec5cdd90f7c4c themes/industrial/versions/0.3.0/page-presets.json
3ab957e66d9c954c45de8c8b5624942df2d2ca5f4b0591f26a5e1d89ca27a462 themes/industrial/versions/0.3.0/site.html
a2e30bcaa8d4e15658558531329c177c1aecfa980cf36fe001ed5effa38ccd93 themes/industrial/versions/0.3.0/theme.css
e0f2b817a043c1e067a1eb06fa7b82214e88152d249c07e75152a03914b1c114 themes/industrial/versions/1.0.0/manifest.json
191c6ba0f76ae37b990176af254333f10fefb06e72d0ef353ba8b62c0112c534 themes/industrial/versions/1.0.0/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/versions/1.0.0/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/industrial/versions/1.0.0/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/industrial/versions/1.0.0/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/industrial/versions/1.0.0/pages/case.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/industrial/versions/1.0.0/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/industrial/versions/1.0.0/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/versions/1.0.0/pages/faq.html
70ea2da210acef293c6279168c375b8532cc2347371daff0e2836111b6cd4ca0 themes/industrial/versions/1.0.0/pages/home.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/industrial/versions/1.0.0/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/industrial/versions/1.0.0/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/industrial/versions/1.0.0/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/industrial/versions/1.0.0/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/versions/1.0.0/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/industrial/versions/1.0.0/pages/standard.html
26bfb1465837ee63ac8c9a9baada75fda533222245d69e36e6e7b0df75429c64 themes/industrial/versions/1.0.0/pro.css
f471223d4480a225f308d2a1d5251101495391554d5263b7ae28313247092d4d themes/industrial/versions/1.0.0/pro.html
3ab957e66d9c954c45de8c8b5624942df2d2ca5f4b0591f26a5e1d89ca27a462 themes/industrial/versions/1.0.0/site.html
a2e30bcaa8d4e15658558531329c177c1aecfa980cf36fe001ed5effa38ccd93 themes/industrial/versions/1.0.0/theme.css
75d4bebbb077e5af36d4f9383f893c91a8b45db1ffd91dc6e2d5107daa849006 themes/industrial/visual/hero.svg
52b7256f3e1eb4801610685b575593f50eb1d8d9146524e10adcd7a195152683 themes/minimal/manifest.json
cfeeadce4397f215aa95d6f210ac913c235da707e1b6215dc274e6ee921cecb4 themes/minimal/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/minimal/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/minimal/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/minimal/pages/case.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/minimal/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/minimal/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/pages/faq.html
f254f6e1cd2c99f9815bbb3771ab327e7a8994cf3963763f829ca53142d4f60f themes/minimal/pages/home.html
4ee5ce55a3c7715c86c63249c3b0b71c99b88d27f6e26fb34206fbc0cae7da3b themes/minimal/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/minimal/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/minimal/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/minimal/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/pages/standard.html
0d435209536c0c5880c009d7e092dc3e554001b42aa52f83a28d0853d89a54c5 themes/minimal/pro.css
d7d923af2e0fca93843eeaeddb4306eeb855b85f8cd33bc1eb500cbee5d0c8aa themes/minimal/pro.html
59dd9ac5e98b580f414585820813e8a8e47053e5e4e75d3a55d26e4f8ed50853 themes/minimal/site.html
de2d571429238c261a8007c4ab0b9e21248bf86d5a050993795f42d508c4ea6b themes/minimal/theme.css
e8ec393149754bfddb2e18cb4cd131a6c22c548f5edff080b56adae779ad64d6 themes/minimal/versions/0.3.0/manifest.json
6afc7fe17dab2d93135e11fdb75347238ab9bfd790882f6d3b0716f2cd4fa66f themes/minimal/versions/0.3.0/page-presets.json
59dd9ac5e98b580f414585820813e8a8e47053e5e4e75d3a55d26e4f8ed50853 themes/minimal/versions/0.3.0/site.html
de2d571429238c261a8007c4ab0b9e21248bf86d5a050993795f42d508c4ea6b themes/minimal/versions/0.3.0/theme.css
48e062bdba9c19403d662dd01f08bd019e60b220edfac385899264b6a7d93e37 themes/minimal/versions/1.0.0/manifest.json
cfeeadce4397f215aa95d6f210ac913c235da707e1b6215dc274e6ee921cecb4 themes/minimal/versions/1.0.0/page-presets.json
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/versions/1.0.0/pages/about.html
fb9d0a3135c9704815775831653517b92f8a966733aa9c89bd98419e16c95fac themes/minimal/versions/1.0.0/pages/article.html
f2adc488fd7a1792e8d9ee78b21b32e914b80f77cbf17dbb1a8158b3ecbfc48a themes/minimal/versions/1.0.0/pages/brand.html
6906a9217a84d1f362d21f185dbd53361e806e5b52d096f1a1ce2b8908a4adbb themes/minimal/versions/1.0.0/pages/case.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/minimal/versions/1.0.0/pages/category.html
981e4242d2b485d764f53291ee2d6774719de7d127b63c405df9f3ac936fb16a themes/minimal/versions/1.0.0/pages/contact.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/versions/1.0.0/pages/faq.html
f254f6e1cd2c99f9815bbb3771ab327e7a8994cf3963763f829ca53142d4f60f themes/minimal/versions/1.0.0/pages/home.html
b9ff9f34dbc4a6f986d1bbccd2253bd4925f2e7dcad7891a129eb40c35117cb0 themes/minimal/versions/1.0.0/pages/listing.html
0cfcedc1a30c09e85f4b4bf5288a713572bec91ae26f54d7c60463dd15b218e2 themes/minimal/versions/1.0.0/pages/notfound.html
b4642518e596399065f00241207b28b0cf1ec3658b9ff8882e7b8a1bb8082966 themes/minimal/versions/1.0.0/pages/product.html
589cb00959c1593ad5999faa079be8cc6dd47c56216f552e0848846e31ea2978 themes/minimal/versions/1.0.0/pages/search.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/versions/1.0.0/pages/service.html
e53e513d4df9fbd19d45562c3c3b6c4ef1da5a0c9b47b6c6dc4b1ddb08008e21 themes/minimal/versions/1.0.0/pages/standard.html
fffe281295fcd095e1f2c9c63e949c633f87f72935df30b3f57c2e80025c6956 themes/minimal/versions/1.0.0/pro.css
68b779284e470cdcd33becb250bdc53b9fd61e8b0005d55caf48431e2ed27de3 themes/minimal/versions/1.0.0/pro.html
59dd9ac5e98b580f414585820813e8a8e47053e5e4e75d3a55d26e4f8ed50853 themes/minimal/versions/1.0.0/site.html
de2d571429238c261a8007c4ab0b9e21248bf86d5a050993795f42d508c4ea6b themes/minimal/versions/1.0.0/theme.css
a6354fbf3249d11acf8d3aa77bc9f541a1db37d2f2a5dc37141509635cf428c1 themes/minimal/visual/hero.svg
+68 -11
View File
@@ -1,7 +1,7 @@
"""Declarative mapping from arbitrary tabular source data to SiteForge content. """Declarative mapping from arbitrary tabular source data to SiteForge content.
Never eval mappings, no AI hallucinated fields, preview shows all errors. Never eval mappings, no AI hallucinated fields, preview shows all errors.
""" """
import json,re import json,re,hashlib
from . import db from . import db
from .core import route_path,slug_of,save_entry from .core import route_path,slug_of,save_entry
@@ -53,14 +53,41 @@ def suggest(columns):
return mapping return mapping
def source_column(spec):
return spec if isinstance(spec,str) else spec['source']
def validate_rules(rules,columns): def validate_rules(rules,columns):
if not isinstance(rules,dict) or len(rules)>100:raise ValueError('字段映射规则不合法') if not isinstance(rules,dict) or len(rules)>100:raise ValueError('字段映射规则不合法')
for dest,src in rules.items(): for dest,spec in rules.items():
if not re.fullmatch(r'[\w\-]{1,60}',dest,re.UNICODE):raise ValueError('目标字段名称不合法') if not isinstance(dest,str) or not re.fullmatch(r'[\w\-]{1,60}',dest,re.UNICODE):
raise ValueError('目标字段名称不合法')
if isinstance(spec,dict):
allowed={'source','trim','case','default','prefix','suffix'}
if set(spec)-allowed or 'source' not in spec:raise ValueError('包含不支持的转换配置')
if type(spec.get('trim',True)) is not bool:raise ValueError('trim 必须为布尔值')
if spec.get('case','none') not in ('none','upper','lower'):raise ValueError('不支持的大小写处理')
for key in ('default','prefix','suffix'):
if key in spec and (not isinstance(spec[key],str) or len(spec[key])>500):
raise ValueError('转换字符串过长或格式非法')
elif not isinstance(spec,str):raise ValueError('映射字段格式非法')
src=source_column(spec)
if not isinstance(src,str) or src not in columns:raise ValueError(f'来源字段不存在: {src}') if not isinstance(src,str) or src not in columns:raise ValueError(f'来源字段不存在: {src}')
if 'title' not in rules:raise ValueError('必须映射标题字段') if 'title' not in rules:raise ValueError('必须映射标题字段')
def mapped_value(row,spec):
raw=row.get(source_column(spec))
value=json.dumps(raw,ensure_ascii=False) if isinstance(raw,(dict,list)) else str(raw if raw is not None else '')
if isinstance(spec,str):return value.strip()
if spec.get('trim',True):value=value.strip()
if not value:value=spec.get('default','')
case=spec.get('case','none')
if case=='upper':value=value.upper()
elif case=='lower':value=value.lower()
return spec.get('prefix','')+value+spec.get('suffix','')
def transform(rows,rules,kind='product',prefix=None): def transform(rows,rules,kind='product',prefix=None):
if kind not in ('product','post','page','brand','vehicle','service','case','other'):raise ValueError('不支持的内容类型') if kind not in ('product','post','page','brand','vehicle','service','case','other'):raise ValueError('不支持的内容类型')
columns=set().union(*(r.keys() for r in rows)) if rows else set() columns=set().union(*(r.keys() for r in rows)) if rows else set()
@@ -68,9 +95,8 @@ def transform(rows,rules,kind='product',prefix=None):
seen=set();out=[];errors=[] seen=set();out=[];errors=[]
for index,row in enumerate(rows): for index,row in enumerate(rows):
vals={} vals={}
for target,src in rules.items(): for target,spec in rules.items():
raw=row.get(src) vals[target]=mapped_value(row,spec)
vals[target]=(json.dumps(raw,ensure_ascii=False) if isinstance(raw,(dict,list)) else str(raw if raw is not None else '')).strip()
title=vals.get('title','') title=vals.get('title','')
if not title: if not title:
errors.append({'row':index+1,'error':'标题为空'});continue errors.append({'row':index+1,'error':'标题为空'});continue
@@ -97,30 +123,61 @@ def transform(rows,rules,kind='product',prefix=None):
return out,errors return out,errors
def _state_token(site_id,items):
"""Preview token binds mapped payload and existing rows' revision metadata.
This is advisory optimistic checking, not a global distributed lock.
"""
routes=[x['route'] for x in items]
existing={r['route']:r for r in
db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
if r['route'] in routes}
state={'site':site_id,'items':items,'existing':existing}
return hashlib.sha256(json.dumps(state,ensure_ascii=False,sort_keys=True,
separators=(',',':')).encode('utf-8')).hexdigest()
def dry_run(site_id,rows,rules,kind='product',prefix=None): def dry_run(site_id,rows,rules,kind='product',prefix=None):
if len(rows)>1000:raise ValueError('单批最多1000条内容') if len(rows)>1000:raise ValueError('单批最多1000条内容')
items,errors=transform(rows,rules,kind,prefix) items,errors=transform(rows,rules,kind,prefix)
existing={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))} existing={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))}
collisions=[r['route'] for r in items if r['route'] in existing] collisions=[r['route'] for r in items if r['route'] in existing]
return {'input':len(rows),'mapped':len(items),'errors':errors,'conflicts':collisions, return {'input':len(rows),'mapped':len(items),'errors':errors,'conflicts':collisions,
'unmapped_columns':sorted(set().union(*(x.keys() for x in rows)) - set(rules.values())) if rows else [], 'unmapped_columns':sorted(set().union(*(x.keys() for x in rows)) -
'sample':items[:10], 'items':items} {source_column(v) for v in rules.values()}) if rows else [],
'sample':items[:10], 'items':items,
'plan_hash':_state_token(site_id,items),
'supported_transforms':['trim','default','upper','lower','prefix','suffix'],
'note':'提交时携带 expected_plan_hash 可阻止预检后发生变化的数据覆盖'}
def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None): def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None,expected_plan_hash=None):
if on_conflict not in ('skip','replace'):raise ValueError('冲突策略只能为 skip 或 replace') if on_conflict not in ('skip','replace','abort'):
raise ValueError('冲突策略只能为 skip、replace 或 abort')
report=dry_run(site_id,rows,rules,kind,prefix) report=dry_run(site_id,rows,rules,kind,prefix)
if expected_plan_hash is not None:
if not isinstance(expected_plan_hash,str) or not re.fullmatch(r'[a-f0-9]{64}',expected_plan_hash):
raise ValueError('预检令牌格式无效')
if expected_plan_hash!=report['plan_hash']:
raise ValueError('导入预检已过期:来源数据或已有内容发生变化,请重新预览')
if on_conflict=='abort' and (report['conflicts'] or report['errors']):
raise ValueError('严格导入被阻断:存在 URL 冲突或无效数据,请处理后重新预览')
created=0;updated=0;skipped=0;errors=list(report['errors']) created=0;updated=0;skipped=0;errors=list(report['errors'])
for idx,item in enumerate(report['items'],1): for idx,item in enumerate(report['items'],1):
try: try:
existing=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,item['route'])) existing=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,item['route']))
if existing and on_conflict=='skip':skipped+=1;continue if existing and on_conflict=='skip':skipped+=1;continue
# abort semantics require a preflight without errors; do not turn
# this into an automatic 'replace' policy on a colliding route.
if existing and on_conflict=='abort':raise ValueError('提交期间出现 URL 冲突')
save_entry(site_id,item,existing_id=existing['id'] if existing else None,source='mapped-import') save_entry(site_id,item,existing_id=existing['id'] if existing else None,source='mapped-import')
if existing:updated+=1 if existing:updated+=1
else:created+=1 else:created+=1
except Exception as exc: except Exception as exc:
errors.append({'row':idx,'error':str(exc)[:250]}) errors.append({'row':idx,'error':str(exc)[:250]})
summary={'created':created,'updated':updated,'skipped':skipped,'errors':errors,'mapped':report['mapped']} if on_conflict=='abort':break
summary={'created':created,'updated':updated,'skipped':skipped,'errors':errors,'mapped':report['mapped'],
'plan_hash':report['plan_hash']}
from .core import uid from .core import uid
db.insert('INSERT INTO import_batches VALUES (?,?,?,?,?,?)',(uid(),site_id,'',f'content-map:{kind}',json.dumps(summary,ensure_ascii=False),db.now())) db.insert('INSERT INTO import_batches VALUES (?,?,?,?,?,?)',(uid(),site_id,'',f'content-map:{kind}',json.dumps(summary,ensure_ascii=False),db.now()))
return summary return summary
+3
View File
@@ -9,6 +9,9 @@ from . import db
def backup(): def backup():
# Never manufacture an empty SQLite file while the real application uses PostgreSQL.
if db.POSTGRES:
raise RuntimeError('PostgreSQL 模式不得使用 SQLite 备份;请使用 scripts/pg_disaster_recovery.py backup')
db.init() db.init()
storage=Path(db.DATA).resolve() storage=Path(db.DATA).resolve()
path=storage/'backups' path=storage/'backups'
+16 -10
View File
@@ -14,7 +14,7 @@ import httpx
from . import safe_http from . import safe_http
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup from markupsafe import Markup
from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87 from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87, multilingual
from .core import uid, route_path, ensure_public_url, sanitize, THEMES from .core import uid, route_path, ensure_public_url, sanitize, THEMES
ROOT=Path(__file__).resolve().parent.parent ROOT=Path(__file__).resolve().parent.parent
@@ -23,6 +23,12 @@ ENV=Environment(loader=FileSystemLoader(str(THEME_DIR)),autoescape=select_autoes
TYPE_NAMES={'page':'页面','post':'新闻','product':'产品','service':'服务','case':'案例','brand':'品牌','vehicle':'车型','other':'内容'} TYPE_NAMES={'page':'页面','post':'新闻','product':'产品','service':'服务','case':'案例','brand':'品牌','vehicle':'车型','other':'内容'}
def copy_blocks_css(theme_dir,dist_assets):
"""Theme-provided blocks.css wins; platform default is the fallback."""
candidate=theme_dir/'blocks.css'
src=candidate if candidate.is_file() else ROOT/'app'/'static'/'blocks.css'
shutil.copy2(src,dist_assets/'blocks.css')
def output_path(dist,route): def output_path(dist,route):
path=route_path(route) path=route_path(route)
if path=='/':return dist/'index.html' if path=='/':return dist/'index.html'
@@ -36,12 +42,6 @@ def output_path(dist,route):
if not target.is_relative_to(dist.resolve()):raise ValueError('非法生成目标目录') if not target.is_relative_to(dist.resolve()):raise ValueError('非法生成目标目录')
return out return out
def copy_blocks_css(theme_dir,dist_assets):
"""Theme-provided blocks.css wins; platform default is the fallback."""
candidate=theme_dir/'blocks.css'
src=candidate if candidate.is_file() else ROOT/'app'/'static'/'blocks.css'
shutil.copy2(src,dist_assets/'blocks.css')
def link(path): def link(path):
return (path or '/').lstrip('/') or './' return (path or '/').lstrip('/') or './'
@@ -166,6 +166,9 @@ def build(site_id,mirror=False):
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,)) site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not site:raise ValueError('网站不存在') if not site:raise ValueError('网站不存在')
if site['theme'] not in theme_store.ids():raise ValueError('无效的主题') if site['theme'] not in theme_store.ids():raise ValueError('无效的主题')
multilingual.assert_build_safe(site_id)
locale=multilingual.site_locale(site_id)
language_alternates=multilingual.alternates(site_id)
records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published' ORDER BY type,title",(site_id,)) records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published' ORDER BY type,title",(site_id,))
redirects=db.rows('SELECT * FROM redirects WHERE site_id=?',(site_id,)) redirects=db.rows('SELECT * FROM redirects WHERE site_id=?',(site_id,))
settings=json.loads(site['settings_json'] or '{}') settings=json.loads(site['settings_json'] or '{}')
@@ -309,6 +312,7 @@ def build(site_id,mirror=False):
# global custom.css. Reused templates can share block IDs safely. # global custom.css. Reused templates can share block IDs safely.
html=__import__('re').sub(r'<body(?=[\s>])', html=__import__('re').sub(r'<body(?=[\s>])',
'<body data-siteforge-route="'+responsive.route_key(page['route'])+'"',html,count=1,flags=__import__('re').I) '<body data-siteforge-route="'+responsive.route_key(page['route'])+'"',html,count=1,flags=__import__('re').I)
html=multilingual.inject_seo(html,locale,language_alternates.get(page['route'],[]) if 'noindex' not in (page.get('robots') or '').lower() else [])
target=output_path(dist,page['route']) target=output_path(dist,page['route'])
if target.exists():raise ValueError('多个路由写入同一文件: '+page['route']) if target.exists():raise ValueError('多个路由写入同一文件: '+page['route'])
target.parent.mkdir(parents=True,exist_ok=True) target.parent.mkdir(parents=True,exist_ok=True)
@@ -385,6 +389,7 @@ def build(site_id,mirror=False):
missing_html=env.get_template('pro.html').render(**ctx) missing_html=env.get_template('pro.html').render(**ctx)
else: else:
missing_html=template.render(site=site,page=notfound,mode='notfound',content='',blocks='',blueprint_mode=False,entries=local_records,listing_items=[],type_names=TYPE_NAMES,nav=[],link=link,seo_title='404',seo_description='Page not found',canonical='') missing_html=template.render(site=site,page=notfound,mode='notfound',content='',blocks='',blueprint_mode=False,entries=local_records,listing_items=[],type_names=TYPE_NAMES,nav=[],link=link,seo_title='404',seo_description='Page not found',canonical='')
missing_html=multilingual.inject_seo(missing_html,locale,[])
(dist/'404.html').write_text(missing_html,encoding='utf-8') (dist/'404.html').write_text(missing_html,encoding='utf-8')
# A complete static search page is packaged for every theme, not a live API. # A complete static search page is packaged for every theme, not a live API.
static_site.write_assets(dist,local_records) static_site.write_assets(dist,local_records)
@@ -402,13 +407,14 @@ def build(site_id,mirror=False):
search_html=env.get_template('pro.html').render(**ctx) search_html=env.get_template('pro.html').render(**ctx)
else: else:
search_html=static_site.search_page(site) search_html=static_site.search_page(site)
search_html=multilingual.inject_seo(search_html,locale,[])
search_file.write_text(search_html,encoding='utf-8') search_file.write_text(search_html,encoding='utf-8')
route_set.add('/search/') route_set.add('/search/')
noindex_routes={e['route'] for e in records if 'noindex' in (e['robots'] or '').lower()} | {'/search/'} | generated_noindex noindex_routes={e['route'] for e in records if 'noindex' in (e['robots'] or '').lower()} | {'/search/'} | generated_noindex
canonical_routes=sorted([x for x in route_set if not x.startswith('/404') and x not in noindex_routes]) canonical_routes=sorted([x for x in route_set if not x.startswith('/404') and x not in noindex_routes])
if site['domain'].startswith(('https://','http://')): if site['domain'].startswith(('https://','http://')):
urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc></url>' for r in canonical_routes) urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc>'+multilingual.sitemap_alternates(language_alternates.get(r,[]))+'</url>' for r in canonical_routes)
sitemap='<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+urls+'</urlset>' sitemap='<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'+urls+'</urlset>'
(dist/'sitemap.xml').write_text(sitemap,encoding='utf-8') (dist/'sitemap.xml').write_text(sitemap,encoding='utf-8')
(dist/'robots.txt').write_text('User-agent: *\nAllow: /\n'+(f'Sitemap: {site["domain"].rstrip("/")}/sitemap.xml\n' if site['domain'].startswith('http') else ''),encoding='utf-8') (dist/'robots.txt').write_text('User-agent: *\nAllow: /\n'+(f'Sitemap: {site["domain"].rstrip("/")}/sitemap.xml\n' if site['domain'].startswith('http') else ''),encoding='utf-8')
rlist=[] rlist=[]
@@ -452,7 +458,7 @@ def build(site_id,mirror=False):
if canonical_mismatches:warnings.append(f'{len(canonical_mismatches)} 个页面 canonical 域名与新站域名不一致') if canonical_mismatches:warnings.append(f'{len(canonical_mismatches)} 个页面 canonical 域名与新站域名不一致')
if missing:warnings.append(f'{len(missing)} 个本地引用图片缺失') if missing:warnings.append(f'{len(missing)} 个本地引用图片缺失')
if slashless:warnings.append(f'{len(slashless)} 个无后缀、无尾斜杠 URL 需要按照生成的 Nginx 规则部署') if slashless:warnings.append(f'{len(slashless)} 个无后缀、无尾斜杠 URL 需要按照生成的 Nginx 规则部署')
report={'site':site['name'],'version':bid,'pages':totals,'theme':site['theme'],'theme_version':theme_pin['version'] if theme_pin else theme_store.latest(site['theme']), report={'site':site['name'],'locale':locale,'alternate_pages':len(language_alternates),'version':bid,'pages':totals,'theme':site['theme'],'theme_version':theme_pin['version'] if theme_pin else theme_store.latest(site['theme']),
'warnings':warnings,'errors':issues,'external_assets':external[:200], 'warnings':warnings,'errors':issues,'external_assets':external[:200],
'missing_local_images':missing[:200],'broken_links':broken_links[:200],'legacy_links':legacy_links[:200],'canonical_mismatches':canonical_mismatches[:200],'slashless_routes':slashless, 'missing_local_images':missing[:200],'broken_links':broken_links[:200],'legacy_links':legacy_links[:200],'canonical_mismatches':canonical_mismatches[:200],'slashless_routes':slashless,
'redirects':len(redirects),'mirrored_images':len(remote_cache), 'redirects':len(redirects),'mirrored_images':len(remote_cache),
+74
View File
@@ -0,0 +1,74 @@
"""Authorized, versioned navigation contract for the single SiteForge console.
The frontend is not an authorization authority: every business API keeps its
own server-side membership checks. This endpoint only returns allowed context.
"""
from fastapi import APIRouter, HTTPException, Request
from . import db, enterprise
from .version import VERSION
router = APIRouter(prefix='/api/console', tags=['Console'])
NAVIGATION = (
('dashboard', '工作台', '/siteforge/dashboard', None),
('sites', '网站管理', '/siteforge/factory', None),
('content', '内容管理', '/siteforge/content', 'viewer'),
('design', '网站设计', '/siteforge/editor', 'viewer'),
('migration', '数据与迁移', '/siteforge/migration', 'viewer'),
('release', '构建与发布', '/siteforge/releases', 'viewer'),
('system', '系统管理', '/siteforge/enterprise', 'owner'),
)
def available_sites(request: Request):
if request.state.is_superadmin:
return db.rows('SELECT id,name,domain FROM sites ORDER BY updated_at DESC,id')
return db.rows('''SELECT s.id,s.name,s.domain FROM sites s
JOIN team_memberships m ON m.site_id=s.id
JOIN team_users u ON u.id=m.user_id
WHERE m.user_id=? AND u.disabled=0
ORDER BY s.updated_at DESC,s.id''', (request.state.account_id,))
def capabilities(request: Request, site_id: str | None):
role = 'superadmin' if request.state.is_superadmin else 'none'
if site_id and not request.state.is_superadmin:
membership = db.one('SELECT role FROM team_memberships WHERE site_id=? AND user_id=?',
(site_id, request.state.account_id))
role = membership['role'] if membership else 'none'
level = enterprise.ACCESS.get(role, -1)
return dict(role=role, view=level>=0, edit=level>=enterprise.ACCESS['editor'],
review=level>=enterprise.ACCESS['reviewer'],
deploy=level>=enterprise.ACCESS['owner'],
manage_members=level>=enterprise.ACCESS['owner'],
create_site=bool(request.state.is_superadmin),
manage_users=bool(request.state.is_superadmin))
@router.get('/bootstrap')
def bootstrap(request: Request, site_id: str = ''):
"""Return user-scoped menus, never expose unassigned websites or credentials."""
sites = available_sites(request)
ids = {x['id'] for x in sites}
if site_id and site_id not in ids:
raise HTTPException(403, '没有访问该网站的权限')
selected = site_id or (sites[0]['id'] if sites else None)
rights = capabilities(request, selected)
sections = []
for key, title, path, required in NAVIGATION:
if required and not selected:
continue
if required and not rights['view']:
continue
if key == 'system' and not (rights['manage_members'] or rights['manage_users']):
continue
sections.append({'key': key, 'title': title, 'path': path, 'requires_site': bool(required)})
return {
'version': VERSION,
'user': request.state.actor,
'superadmin': bool(request.state.is_superadmin),
'sites': sites,
'selected_site_id': selected,
'capabilities': rights,
'navigation': sections,
}
+2 -3
View File
@@ -4,12 +4,12 @@ import json
import re import re
import socket import socket
import uuid import uuid
from pathlib import Path
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
import bleach import bleach
from . import db from . import db
ROOT = Path(__file__).resolve().parent.parent TYPES = ('page','product','post','service','case','brand','vehicle','other')
ROOT = __import__('pathlib').Path(__file__).resolve().parent.parent
def discover_themes(): def discover_themes():
"""Discover built-in themes from themes/*/manifest.json (single source of truth).""" """Discover built-in themes from themes/*/manifest.json (single source of truth)."""
@@ -29,7 +29,6 @@ def discover_themes():
found.append(entry.name) found.append(entry.name)
return tuple(found) return tuple(found)
TYPES = ('page','product','post','service','case','brand','vehicle','other')
THEMES = discover_themes() THEMES = discover_themes()
ALLOWED_TAGS = ['p','br','b','i','u','em','strong','h1','h2','h3','h4','h5','h6','ul','ol','li','blockquote','pre','code','a','img','figure','figcaption','table','thead','tbody','tr','th','td','hr','div','span'] ALLOWED_TAGS = ['p','br','b','i','u','em','strong','h1','h2','h3','h4','h5','h6','ul','ol','li','blockquote','pre','code','a','img','figure','figcaption','table','thead','tbody','tr','th','td','hr','div','span']
ALLOWED_ATTRS = {'a':['href','title','target','rel'], 'img':['src','alt','title','loading','width','height'], 'td':['colspan','rowspan'], 'th':['colspan','rowspan']} ALLOWED_ATTRS = {'a':['href','title','target','rel'], 'img':['src','alt','title','loading','width','height'], 'td':['colspan','rowspan'], 'th':['colspan','rowspan']}
+5 -1
View File
@@ -75,9 +75,13 @@ def init():
result_json TEXT NOT NULL DEFAULT '{}' result_json TEXT NOT NULL DEFAULT '{}'
); );
''') ''')
if not POSTGRES: # Migration ledger is shared by SQLite and PostgreSQL.
from . import schema_registry from . import schema_registry
schema_registry.ensure(c) schema_registry.ensure(c)
# The multilingual schema is a non-destructive additive migration.
# Direct CLI builders, tests and web startup all initialize it.
from . import multilingual
multilingual.setup()
def rows(sql, params=()): def rows(sql, params=()):
with connect() as c: with connect() as c:
+105 -13
View File
@@ -23,7 +23,12 @@ def signature(secret,site,timestamp,body,method,path):
return hmac.new(secret.encode(),envelope,hashlib.sha256).hexdigest() return hmac.new(secret.encode(),envelope,hashlib.sha256).hexdigest()
def authorize(site,request,body): def authorize(site,request,body):
secret=os.environ.get('SITEFORGE_DEPLOY_SECRET','') ref=os.environ.get('SITEFORGE_DEPLOY_SECRET_REF','')
if ref:
from .secret_refs import resolve,SecretReferenceError
try:secret=resolve(ref)
except SecretReferenceError:raise HTTPException(503,'Release Agent secret unavailable')
else:secret=os.environ.get('SITEFORGE_DEPLOY_SECRET','')
if len(secret)<32:raise HTTPException(503,'Release agent secret not configured') if len(secret)<32:raise HTTPException(503,'Release agent secret not configured')
stamp=request.headers.get('x-siteforge-timestamp','') stamp=request.headers.get('x-siteforge-timestamp','')
try: try:
@@ -37,12 +42,16 @@ def validate_archive(raw):
if len(raw)>MAX_ZIP:raise ValueError('Archive too large') if len(raw)>MAX_ZIP:raise ValueError('Archive too large')
with zipfile.ZipFile(io.BytesIO(raw)) as z: with zipfile.ZipFile(io.BytesIO(raw)) as z:
if len(z.infolist())>20000:raise ValueError('Too many files') if len(z.infolist())>20000:raise ValueError('Too many files')
total=0 total=0;seen=set()
for info in z.infolist(): for info in z.infolist():
name=info.filename name=info.filename
path=PurePosixPath(name) path=PurePosixPath(name)
if name.startswith('/') or '\\' in name or '..' in path.parts or not name or ':' in name: if name.startswith('/') or '\\' in name or '..' in path.parts or not name or ':' in name or name.startswith('./'):
raise ValueError('Unsafe archive path') raise ValueError('Unsafe archive path')
key=name.lower().rstrip('/')
if key in seen or key=='.siteforge-manifest.json':
raise ValueError('Duplicate or reserved file in archive')
seen.add(key)
# Unix symlink type is 0o120000. # Unix symlink type is 0o120000.
if ((info.external_attr>>16)&0o170000)==0o120000:raise ValueError('Symlinks forbidden') if ((info.external_attr>>16)&0o170000)==0o120000:raise ValueError('Symlinks forbidden')
total+=info.file_size total+=info.file_size
@@ -53,7 +62,25 @@ def validate_archive(raw):
return True return True
def install(site,raw): def _pointer(site):
parent=ROOT/site
current=parent/'current'
version=current.read_text(encoding='ascii').strip() if current.is_file() else None
return version if version and re.fullmatch('[0-9a-f]{20}',version) else None
def _manifest(root):
"""Hash all files of an immutable release. Called on a limited archive."""
hashes={}
for path in sorted(root.rglob('*')):
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise ValueError('Release contains unsafe link')
if path.is_file():hashes[path.relative_to(root).as_posix()]=hashlib.sha256(path.read_bytes()).hexdigest()
return hashes
def stage(site,raw):
"""Stage an immutable release without changing current traffic."""
if not SITE.fullmatch(site):raise ValueError('Bad site ID') if not SITE.fullmatch(site):raise ValueError('Bad site ID')
validate_archive(raw) validate_archive(raw)
digest=hashlib.sha256(raw).hexdigest()[:20] digest=hashlib.sha256(raw).hexdigest()[:20]
@@ -61,19 +88,54 @@ def install(site,raw):
releases=parent/'releases' releases=parent/'releases'
releases.mkdir(parents=True,exist_ok=True) releases.mkdir(parents=True,exist_ok=True)
target=releases/digest target=releases/digest
if target.is_symlink():raise ValueError('Invalid immutable release directory')
if not target.is_dir(): if not target.is_dir():
stage=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent))) stage_dir=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent)))
try: try:
with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage) with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage_dir)
if not (stage/'index.html').is_file():raise ValueError('Invalid site') if not (stage_dir/'index.html').is_file():raise ValueError('Invalid site')
try:stage.rename(target) hashes=_manifest(stage_dir)
(stage_dir/'.siteforge-manifest.json').write_text(json.dumps({'sha256':hashlib.sha256(raw).hexdigest(),
'files':hashes},sort_keys=True),encoding='utf-8')
try:stage_dir.rename(target)
except FileExistsError:pass except FileExistsError:pass
finally: finally:
if stage.exists():shutil.rmtree(stage) if stage_dir.exists():shutil.rmtree(stage_dir)
pointer=parent/'.current.tmp' return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(),
pointer.write_text(digest,encoding='ascii') 'staged':True,'current':_pointer(site)}
os.replace(pointer,parent/'current')
return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(),'published':True}
def verify_release(site,release):
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise ValueError('Invalid release')
folder=ROOT/site/'releases'/release
meta=folder/'.siteforge-manifest.json'
if not meta.is_file():raise ValueError('Release manifest missing')
manifest=json.loads(meta.read_text(encoding='utf-8'))
expected=manifest.get('files',{})
current=_manifest(folder)
current.pop('.siteforge-manifest.json',None)
if expected != current or 'index.html' not in current:raise ValueError('Release file integrity failure')
return {'site':site,'release':release,'sha256':manifest['sha256'],
'files':len(current),'verified':True,'active':_pointer(site)==release,
'current':_pointer(site)}
def activate(site,release):
verified=verify_release(site,release)
parent=ROOT/site
previous=_pointer(site)
tmp=parent/('.current-'+re.sub('[^0-9a-f]','',release)+'.tmp')
tmp.write_text(release,encoding='ascii')
os.replace(tmp,parent/'current')
return {**verified,'active':True,'previous':previous,'current':release}
def install(site,raw):
"""Compatibility endpoint used by existing clients; new Worker uses stage/verify/activate."""
staged=stage(site,raw)
active=activate(site,staged['release'])
return {**active,'published':True}
@app.get('/health') @app.get('/health')
def health():return {'healthy':True} def health():return {'healthy':True}
@@ -92,6 +154,33 @@ async def deploy(site:str,request:Request):
try:return install(site,data) try:return install(site,data)
except (ValueError,zipfile.BadZipFile) as e:raise HTTPException(400,str(e)) except (ValueError,zipfile.BadZipFile) as e:raise HTTPException(400,str(e))
@app.post('/staging/{site}')
async def stage_upload(site:str,request:Request):
if not SITE.fullmatch(site):raise HTTPException(400)
parts=[];size=0
async for chunk in request.stream():
size+=len(chunk)
if size>MAX_ZIP:raise HTTPException(413)
parts.append(chunk)
raw=b''.join(parts)
authorize(site,request,raw)
try:return stage(site,raw)
except (ValueError,zipfile.BadZipFile) as exc:raise HTTPException(400,str(exc))
@app.get('/sites/{site}/verify/{release}')
def verify_route(site:str,release:str,request:Request):
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400)
authorize(site,request,b'')
try:return verify_release(site,release)
except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc))
@app.post('/sites/{site}/activate/{release}')
def activate_route(site:str,release:str,request:Request):
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400)
authorize(site,request,b'')
try:return activate(site,release)
except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc))
@app.get('/sites/{site}/releases') @app.get('/sites/{site}/releases')
def versions(site:str,request:Request): def versions(site:str,request:Request):
# No public access to release inventory. Require same signed request semantics. # No public access to release inventory. Require same signed request semantics.
@@ -107,6 +196,9 @@ async def rollback(site:str,release:str,request:Request):
authorize(site,request,b'') authorize(site,request,b'')
parent=ROOT/site parent=ROOT/site
if not (parent/'releases'/release/'index.html').is_file():raise HTTPException(404) if not (parent/'releases'/release/'index.html').is_file():raise HTTPException(404)
if (parent/'releases'/release/'.siteforge-manifest.json').exists():
try:verify_release(site,release)
except (ValueError,OSError,KeyError):raise HTTPException(409,'Historic release failed integrity verification')
tmp=parent/'.current.tmp';tmp.write_text(release);os.replace(tmp,parent/'current') tmp=parent/'.current.tmp';tmp.write_text(release);os.replace(tmp,parent/'current')
return {'site':site,'release':release,'rolled_back':True} return {'site':site,'release':release,'rolled_back':True}
+5 -1
View File
@@ -134,7 +134,11 @@ def create_user(request:Request,payload:UserCreate):
person=uid() person=uid()
db.insert('INSERT INTO team_users(id,username,password_hash,display_name,created_at) VALUES (?,?,?,?,?)', db.insert('INSERT INTO team_users(id,username,password_hash,display_name,created_at) VALUES (?,?,?,?,?)',
(person,payload.username,hash_password(payload.password),payload.display_name,db.now())) (person,payload.username,hash_password(payload.password),payload.display_name,db.now()))
except sqlite3.IntegrityError:raise HTTPException(409,'用户名已存在') except Exception as exc:
# PostgreSQL uses psycopg.errors.UniqueViolation rather than sqlite3.IntegrityError.
if isinstance(exc,sqlite3.IntegrityError) or getattr(exc,'sqlstate',None)=='23505':
raise HTTPException(409,'用户名已存在') from exc
raise
return {'id':person,'username':payload.username,'display_name':payload.display_name} return {'id':person,'username':payload.username,'display_name':payload.display_name}
@router.patch('/team/users/{user_id}/disable') @router.patch('/team/users/{user_id}/disable')
+4 -2
View File
@@ -1,5 +1,6 @@
"""Factory V2 endpoints: themes, mappings, build comparisons, team release readiness.""" """Factory V2 endpoints: themes, mappings, build comparisons, team release readiness."""
import json,csv,io import json,csv,io
from typing import Any
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
from fastapi.responses import HTMLResponse, FileResponse from fastapi.responses import HTMLResponse, FileResponse
@@ -88,10 +89,11 @@ def save_profile(request:Request,site_id:str,payload:dict=Body(...)):
class MapRows(BaseModel): class MapRows(BaseModel):
rows:list[dict]=Field(max_length=1000) rows:list[dict]=Field(max_length=1000)
rules:dict[str,str]=Field(default_factory=dict) rules:dict[str,Any]=Field(default_factory=dict)
content_type:str='product' content_type:str='product'
prefix:str|None=None prefix:str|None=None
on_conflict:str='skip' on_conflict:str='skip'
expected_plan_hash:str|None=None
dry_run:bool=True dry_run:bool=True
@router.post('/sites/{site_id}/mapping/parse-file') @router.post('/sites/{site_id}/mapping/parse-file')
@@ -115,7 +117,7 @@ def suggested_map(request:Request,site_id:str,payload:dict=Body(...)):
def map_rows(request:Request,site_id:str,payload:MapRows): def map_rows(request:Request,site_id:str,payload:MapRows):
enterprise.require(request,site_id,'viewer' if payload.dry_run else 'editor') enterprise.require(request,site_id,'viewer' if payload.dry_run else 'editor')
try: try:
return assembly.dry_run(site_id,payload.rows,payload.rules,payload.content_type,payload.prefix) if payload.dry_run else assembly.commit(site_id,payload.rows,payload.rules,payload.content_type,payload.on_conflict,payload.prefix) return assembly.dry_run(site_id,payload.rows,payload.rules,payload.content_type,payload.prefix) if payload.dry_run else assembly.commit(site_id,payload.rows,payload.rules,payload.content_type,payload.on_conflict,payload.prefix,payload.expected_plan_hash)
except ValueError as exc:raise HTTPException(400,str(exc)) except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/migration/readiness') @router.get('/sites/{site_id}/migration/readiness')
+25
View File
@@ -0,0 +1,25 @@
"""Infrastructure readiness distinct from lightweight process liveness.
Do not expose DB DSNs, Redis endpoints or filesystem paths in the public response.
Redis wake-ups are optional and are not a readiness requirement.
"""
from pathlib import Path
import tempfile
from . import db
def readiness() -> bool:
try:
# SELECT 1 works on SQLite and PostgreSQL and checks actual connectivity.
with db.connect() as conn:
if conn.execute('SELECT 1 AS ready').fetchone()[0] != 1:
return False
data=Path(db.DATA)
data.mkdir(parents=True,exist_ok=True)
# Build artifacts and job results require writable shared storage.
with tempfile.NamedTemporaryFile(dir=data, prefix='.ready-', delete=True) as probe:
probe.write(b'1')
probe.flush()
return True
except Exception:
return False
+12 -4
View File
@@ -11,7 +11,7 @@ from bs4 import BeautifulSoup
import httpx import httpx
from openpyxl import load_workbook from openpyxl import load_workbook
from .core import route_path, save_entry, ensure_public_url, slug_of from .core import route_path, save_entry, ensure_public_url, slug_of
from . import db,wordpress_compat,safe_http from . import db,wordpress_compat,safe_http,wp_complex_v820
KEY_ALIASES={ KEY_ALIASES={
'title':('title','name','标题','名称','产品名称','post_title'), 'title':('title','name','标题','名称','产品名称','post_title'),
@@ -114,7 +114,10 @@ def import_wxr(site_id,content,conflict='skip'):
title=it.findtext('title') or slug title=it.findtext('title') or slug
link=it.findtext('link') or '' link=it.findtext('link') or ''
path=urlparse(link).path if link.startswith(('http://','https://')) else '' path=urlparse(link).path if link.startswith(('http://','https://')) else ''
if not path: path=f'/{slug}/' if typ=='page' else f'/{typ}/{slug}/' if urlparse(link).query or urlparse(link).fragment:
raise ValueError(f'WXR 记录 {title[:80]} 使用动态参数 URL;请先做 V8.20 预检和显式路由映射')
if not path:
raise ValueError(f'WXR 记录 {title[:80]} 缺少原始 URL,无法保证旧路径不变')
wpmeta={} wpmeta={}
for pm in it.findall('wp:postmeta',ns): for pm in it.findall('wp:postmeta',ns):
key=pm.findtext('wp:meta_key',namespaces=ns) key=pm.findtext('wp:meta_key',namespaces=ns)
@@ -133,11 +136,16 @@ def import_wxr(site_id,content,conflict='skip'):
('_stock_status','stock_status'),('_weight','weight')): ('_stock_status','stock_status'),('_weight','weight')):
if wpmeta.get(source):fields[dest]=str(wpmeta[source])[:1000] if wpmeta.get(source):fields[dest]=str(wpmeta[source])[:1000]
fields.update(taxonomy) fields.update(taxonomy)
acf=wp_complex_v820.extract_acf(wpmeta)
for key,value in acf['fields'].items():
fields.setdefault(key,value)
for key,value in acf['repeaters'].items():
fields[key]=value
wp_featured=media_index.get(wpmeta.get('_thumbnail_id',''),'') wp_featured=media_index.get(wpmeta.get('_thumbnail_id',''),'')
gallery_ids=[x.strip() for x in wpmeta.get('_product_image_gallery','').split(',') if x.strip()] gallery_ids=[x.strip() for x in wpmeta.get('_product_image_gallery','').split(',') if x.strip()]
gallery=[media_index[x] for x in gallery_ids if x in media_index] gallery=[media_index[x] for x in gallery_ids if x in media_index]
if gallery:fields['gallery']=gallery if gallery:fields['gallery']=gallery
warnings=[] warnings=list(acf['warnings'])
if wpmeta.get('_elementor_data'): if wpmeta.get('_elementor_data'):
elementor=wordpress_compat.elementor_to_html(wpmeta['_elementor_data']) elementor=wordpress_compat.elementor_to_html(wpmeta['_elementor_data'])
if not body.strip() and elementor['html']: if not body.strip() and elementor['html']:
@@ -155,7 +163,7 @@ def import_wxr(site_id,content,conflict='skip'):
'canonical':wpmeta.get('_yoast_wpseo_canonical') or wpmeta.get('rank_math_canonical_url') or '', 'canonical':wpmeta.get('_yoast_wpseo_canonical') or wpmeta.get('rank_math_canonical_url') or '',
'robots':'noindex,follow' if wpmeta.get('_yoast_wpseo_meta-robots-noindex')=='1' or wpmeta.get('rank_math_robots')=='noindex' else 'index,follow', 'robots':'noindex,follow' if wpmeta.get('_yoast_wpseo_meta-robots-noindex')=='1' or wpmeta.get('rank_math_robots')=='noindex' else 'index,follow',
'status':'published' if status=='publish' else 'draft', 'status':'published' if status=='publish' else 'draft',
'meta':{'elementor_widgets':elementor['widgets'] if wpmeta.get('_elementor_data') else 0,'wp_post_id':it.findtext('wp:post_id',default='',namespaces=ns),'wp_type':typ,'wp_metadata':wpmeta,'fields':fields,'migration_warnings':warnings}}) 'meta':{'elementor_widgets':elementor['widgets'] if wpmeta.get('_elementor_data') else 0,'wp_post_id':it.findtext('wp:post_id',default='',namespaces=ns),'wp_type':typ,'wp_metadata':wpmeta,'fields':fields,'acf_repeaters':acf['repeaters'],'migration_warnings':warnings}})
return import_records(site_id,entries,'wordpress-wxr',conflict) return import_records(site_id,entries,'wordpress-wxr',conflict)
def safe_get(client,url,max_bytes=3_000_000): def safe_get(client,url,max_bytes=3_000_000):
+3
View File
@@ -24,6 +24,9 @@ def main():
elif row['kind']=='mysql_scan': elif row['kind']=='mysql_scan':
from . import connectors_v85 from . import connectors_v85
value=connectors_v85.run_scan(row['site_id'],opts['source_id'],job_id) value=connectors_v85.run_scan(row['site_id'],opts['source_id'],job_id)
elif row['kind'] in ('remote_deploy','remote_rollback'):
from . import release_api
value=release_api.run_delivery(row['site_id'],row['kind'],opts,job_id)
elif row['kind']=='build': elif row['kind']=='build':
value=builder.build(row['site_id'],bool(opts.get('mirror_images',False))) value=builder.build(row['site_id'],bool(opts.get('mirror_images',False)))
else:raise ValueError('未知任务类型') else:raise ValueError('未知任务类型')
+42 -5
View File
@@ -1,7 +1,7 @@
'''Durable SQLite build queue. A single job is at most running once per site. '''Durable build queue: SQLite single-host or PostgreSQL with DB-owned leases.
This implementation is deliberately single-host. For multi-host HA, replace Redis is an optional wake-up signal, not an authoritative queue. PostgreSQL
SQLite with PostgreSQL and Redis (outbox+leases) before production scaling. lock/lease semantics require actual multi-worker acceptance on infrastructure.
''' '''
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@@ -101,9 +101,15 @@ def recover_expired():
if row['cancel_requested']: if row['cancel_requested']:
state='cancelled' state='cancelled'
else: else:
state='retrying' if row['attempts']<row['max_attempts'] else 'failed' # Publishing is an external side effect: it may have completed remotely
# even though the Worker lost its lease. Never automatically replay it.
state=('failed' if row['kind'] in ('remote_deploy','remote_rollback')
else ('retrying' if row['attempts']<row['max_attempts'] else 'failed'))
message=('Release lease expired; reconcile remote release before manual retry'
if row['kind'] in ('remote_deploy','remote_rollback') else
'Worker lease expired; verify partial builds')
c.execute('UPDATE build_jobs SET state=?,worker_id=NULL,lease_until=NULL,available_at=?,error=? WHERE id=?', c.execute('UPDATE build_jobs SET state=?,worker_id=NULL,lease_until=NULL,available_at=?,error=? WHERE id=?',
(state,db.now(),'Worker lease expired; verify partial builds',row['id'])) (state,db.now(),message,row['id']))
return len(rows) return len(rows)
@@ -140,3 +146,34 @@ def submit_migration(site_id,session_id,actor,key=None):
from . import redis_signal from . import redis_signal
redis_signal.notify() redis_signal.notify()
return {'id':job_id,'state':'queued','existing':False} return {'id':job_id,'state':'queued','existing':False}
def submit_delivery(site_id,kind,payload,actor,key=None):
# One logical release per job; serialized with build jobs by Worker.
if kind not in ('remote_deploy','remote_rollback'):
raise JobConflict('Unknown delivery job kind')
if key is not None and not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
raise JobConflict('无效的幂等键')
normalized=json.dumps(payload,sort_keys=True,separators=(',',':'))
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
if key:
row=c.execute('SELECT * FROM build_jobs WHERE site_id=? AND idempotency_key=?',(site_id,key)).fetchone()
if row:
if row['kind']!=kind or row['payload_json']!=normalized:raise JobConflict('幂等键存在冲突')
return {'id':row['id'],'state':row['state'],'existing':True}
row=c.execute("SELECT id,state FROM build_jobs WHERE site_id=? AND kind=? AND payload_json=? AND state IN ('queued','running','retrying') ORDER BY created_at LIMIT 1",(site_id,kind,normalized)).fetchone()
if row:return {'id':row['id'],'state':row['state'],'existing':True}
active="state IN ('queued','running','retrying')"
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE {active}').fetchone()[0]>=_limit('SITEFORGE_MAX_PENDING_JOBS',100):
raise JobCapacityError('系统任务队列已满')
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE site_id=? AND {active}',(site_id,)).fetchone()[0]>=_limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5):
raise JobCapacityError('网站任务队列已满')
job_id=uuid.uuid4().hex[:16]
c.execute('''INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at,payload_json,
idempotency_key,max_attempts,timeout_seconds,available_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)''',
(job_id,site_id,kind,'queued',actor,db.now(),normalized,key,3,900,db.now()))
from . import redis_signal
redis_signal.notify()
return {'id':job_id,'state':'queued','existing':False}
+24 -3
View File
@@ -12,7 +12,7 @@ from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, Resp
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from . import db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables, jobs, release_api, cms_pipeline, v8_workflows, v8_templates, wp_migration_v81, wp_migration_v82, wp_migration_v83, connectors_v84, connectors_v85, studio_v86, site_design_v87 from . import console_v819, wp_complex_v820, db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables, jobs, release_api, cms_pipeline, v8_workflows, v8_templates, wp_migration_v81, wp_migration_v82, wp_migration_v83, connectors_v84, connectors_v85, studio_v86, site_design_v87, multilingual
from .version import VERSION, BUILD_SHA from .version import VERSION, BUILD_SHA
from .request_trace import TraceMiddleware from .request_trace import TraceMiddleware
from .preview_rewrite import rewrite_preview_html from .preview_rewrite import rewrite_preview_html
@@ -38,6 +38,7 @@ async def lifespan(app):
connectors_v85.setup() connectors_v85.setup()
studio_v86.setup() studio_v86.setup()
site_design_v87.setup() site_design_v87.setup()
multilingual.setup()
yield yield
app=FastAPI(title='SiteForge 网站工厂',description='Docker-first visual website factory',version=VERSION,lifespan=lifespan) app=FastAPI(title='SiteForge 网站工厂',description='Docker-first visual website factory',version=VERSION,lifespan=lifespan)
@@ -58,15 +59,18 @@ app.include_router(v8_templates.router)
app.include_router(wp_migration_v81.router) app.include_router(wp_migration_v81.router)
app.include_router(wp_migration_v82.router) app.include_router(wp_migration_v82.router)
app.include_router(wp_migration_v83.router) app.include_router(wp_migration_v83.router)
app.include_router(wp_complex_v820.router)
app.include_router(connectors_v84.router) app.include_router(connectors_v84.router)
app.include_router(connectors_v85.router) app.include_router(connectors_v85.router)
app.include_router(studio_v86.router) app.include_router(studio_v86.router)
app.include_router(site_design_v87.router) app.include_router(site_design_v87.router)
app.include_router(multilingual.router)
app.include_router(console_v819.router)
@app.middleware('http') @app.middleware('http')
async def require_login(request:Request, call_next): async def require_login(request:Request, call_next):
path = request.url.path path = request.url.path
public = path in ('/login', '/api/auth/login', '/api/health', '/favicon.ico') or path.startswith('/ui/') public = path in ('/login', '/api/auth/login', '/api/health', '/api/ready', '/favicon.ico') or path.startswith('/ui/')
if not public: if not public:
session = security.read_session(request.cookies.get(security.COOKIE)) session = security.read_session(request.cookies.get(security.COOKIE))
identity = enterprise.account_from_session(session) if session else None identity = enterprise.account_from_session(session) if session else None
@@ -261,6 +265,12 @@ def visual_editor(site_id:str,entry_id:str):
@app.get('/api/health') @app.get('/api/health')
def health():return {'status':'ok','name':'SiteForge','version':VERSION,'commit':BUILD_SHA} def health():return {'status':'ok','name':'SiteForge','version':VERSION,'commit':BUILD_SHA}
@app.get('/api/ready')
def ready():
from .healthchecks import readiness
ok=readiness()
return JSONResponse({'status':'ready' if ok else 'unavailable'},status_code=200 if ok else 503)
@app.get('/api/themes') @app.get('/api/themes')
def themes(): def themes():
manifests=[] manifests=[]
@@ -536,7 +546,15 @@ def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload):
html=html.replace('</head>','<style>'+styles+'</style></head>') html=html.replace('</head>','<style>'+styles+'</style></head>')
bridge = r'''<script>(function(){ bridge = r'''<script>(function(){
const fields={'title':'h1,h2','description':'.sf-hero-copy p,.sf-image-text p'}; const fields={'title':'h1,h2','description':'.sf-hero-copy p,.sf-image-text p'};
document.querySelectorAll('[data-block]').forEach(block=>{ const sections=Array.from(document.querySelectorAll('[data-block]'));
sections.forEach(block=>{
block.setAttribute('data-studio-selectable','true');
block.addEventListener('click',event=>{
if(event.target.closest('a'))event.preventDefault();
sections.forEach(item=>item.classList.remove('sf-editor-selected'));
block.classList.add('sf-editor-selected');
parent.postMessage({source:'siteforge-select-block',blockId:block.dataset.block},'*');
});
for(const [field,selector] of Object.entries(fields)){ for(const [field,selector] of Object.entries(fields)){
const target=block.querySelector(selector);if(!target)continue; const target=block.querySelector(selector);if(!target)continue;
target.title='双击直接修改文字';target.addEventListener('dblclick',()=>{ target.title='双击直接修改文字';target.addEventListener('dblclick',()=>{
@@ -550,6 +568,9 @@ def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload):
}); });
} }
}); });
const style=document.createElement('style');
style.textContent='[data-studio-selectable]:hover{outline:2px dashed #e98b45!important;outline-offset:-2px;cursor:pointer}[data-studio-selectable].sf-editor-selected{outline:2px solid #ea8138!important;outline-offset:-2px}';
document.head.appendChild(style);
})()</script>''' })()</script>'''
# The static site's /media/ assets are hosted by the admin at # The static site's /media/ assets are hosted by the admin at
# /local-media/{site}/. Rewrite only the editor response, never builds. # /local-media/{site}/. Rewrite only the editor response, never builds.
+265
View File
@@ -0,0 +1,265 @@
"""Explicit multilingual relationships. No automatic fact translation.
One site per locale. Only approved, published and indexed content receives
alternate links. Pending translation records cannot accidentally enter a build.
"""
import json
import re
from urllib.parse import urlparse
from html import escape as html_escape
from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import db, enterprise
from .core import uid
router=APIRouter(prefix='/api/i18n', tags=['Multilingual'])
LOCALE_PATTERN=re.compile(r'^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$')
KEY_PATTERN=re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$')
def locale_value(locale):
value=str(locale or '').strip()
if not LOCALE_PATTERN.fullmatch(value):
raise ValueError('语言标识必须是 BCP47 格式,例如 zh-CN、en、fr-FR')
return value
def _domain(site):
value=(site.get('domain') or '').strip().rstrip('/')
parsed=urlparse(value)
if parsed.scheme!='https' or not parsed.hostname or parsed.username or parsed.password or parsed.path not in ('','/') or parsed.query or parsed.fragment:
raise ValueError('多语言正式站点必须配置独立 HTTPS 域名(不允许路径、账号或查询参数)')
return value
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS language_groups(
id TEXT PRIMARY KEY, name TEXT NOT NULL, default_locale TEXT NOT NULL,
created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS language_sites(
group_id TEXT NOT NULL REFERENCES language_groups(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
locale TEXT NOT NULL, PRIMARY KEY(group_id,site_id),
UNIQUE(group_id,locale), UNIQUE(site_id));
CREATE TABLE IF NOT EXISTS translation_units(
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES language_groups(id) ON DELETE CASCADE,
reference_key TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(group_id,reference_key));
CREATE TABLE IF NOT EXISTS translation_members(
unit_id TEXT NOT NULL REFERENCES translation_units(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
state TEXT NOT NULL CHECK(state IN ('pending','approved')),
PRIMARY KEY(unit_id,site_id),UNIQUE(site_id,entry_id));
CREATE INDEX IF NOT EXISTS idx_translations_site ON translation_members(site_id,state);
''')
def require_group(group_id):
group=db.one('SELECT * FROM language_groups WHERE id=?',(group_id,))
if not group:raise HTTPException(404,'语言组不存在')
return group
def _authorized(request,group_id,role='viewer'):
sites=db.rows('SELECT site_id FROM language_sites WHERE group_id=?',(group_id,))
# At least one managed member is needed to read or edit the group.
if not getattr(request.state,'is_superadmin',False):
if not any(enterprise.level(request,s['site_id'])>=enterprise.ACCESS[role] for s in sites):
raise HTTPException(403,'无权管理此语言组')
return sites
class GroupIn(BaseModel):
name:str=Field(min_length=2,max_length=120)
default_locale:str='en'
class GroupSiteIn(BaseModel):
site_id:str
locale:str
class UnitIn(BaseModel):
reference_key:str
class MemberIn(BaseModel):
site_id:str
entry_id:str
state:str='pending'
@router.post('/groups',status_code=201)
def create_group(data:GroupIn,request:Request):
if not request.state.is_superadmin:raise HTTPException(403,'仅管理员可创建语言组')
try:locale=locale_value(data.default_locale)
except ValueError as exc:raise HTTPException(400,str(exc))
gid=uid()
db.insert('INSERT INTO language_groups VALUES (?,?,?,?)',(gid,data.name,locale,db.now()))
return {'id':gid,'name':data.name,'default_locale':locale}
class LocaleIn(BaseModel):
locale:str
@router.put('/sites/{site_id}/locale')
def set_site_locale(site_id:str,data:LocaleIn,request:Request):
"""Declare this site's content language (BCP47). Used for <html lang>, defaults zh-CN."""
enterprise.require(request,site_id,'owner')
try:locale=locale_value(data.locale)
except ValueError as exc:raise HTTPException(400,str(exc))
site=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404,'网站不存在')
settings=json.loads(site['settings_json'] or '{}')
settings['locale']=locale
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'site_id':site_id,'locale':locale}
@router.get('/groups')
def groups(request:Request):
gs=db.rows('SELECT * FROM language_groups ORDER BY created_at DESC')
if not request.state.is_superadmin:
gs=[g for g in gs if any(enterprise.level(request,s['site_id'])>=enterprise.ACCESS['viewer'] for s in db.rows('SELECT site_id FROM language_sites WHERE group_id=?',(g['id'],)))]
return gs
@router.get('/sites/{site_id}')
def site_language(site_id:str,request:Request):
enterprise.require(request,site_id,'viewer')
membership=db.one('''SELECT s.locale,s.group_id,g.name,g.default_locale FROM language_sites s
JOIN language_groups g ON g.id=s.group_id WHERE s.site_id=?''',(site_id,))
if not membership:return {'group':None,'sites':[],'units':[]}
gid=membership['group_id']
_authorized(request,gid)
sites=db.rows('''SELECT s.locale,s.site_id,t.name,t.domain FROM language_sites s
JOIN sites t ON t.id=s.site_id WHERE s.group_id=? ORDER BY s.locale''',(gid,))
units=db.rows('''SELECT u.id,u.reference_key,m.site_id,m.entry_id,m.state,e.title,e.route,s.locale
FROM translation_units u JOIN translation_members m ON m.unit_id=u.id
JOIN entries e ON e.id=m.entry_id JOIN language_sites s ON s.site_id=m.site_id
WHERE u.group_id=? ORDER BY u.reference_key,s.locale''',(gid,))
return {'group':membership,'sites':sites,'units':units}
@router.post('/groups/{group_id}/sites',status_code=201)
def join_site(group_id:str,data:GroupSiteIn,request:Request):
require_group(group_id)
_authorized(request,group_id,'owner')
enterprise.require(request,data.site_id,'owner')
site=db.one('SELECT * FROM sites WHERE id=?',(data.site_id,))
if not site:raise HTTPException(404,'网站不存在')
try:
locale=locale_value(data.locale)
domain=_domain(site)
except ValueError as exc:raise HTTPException(400,str(exc))
existing=db.rows('''SELECT x.site_id,t.domain FROM language_sites x JOIN sites t ON t.id=x.site_id
WHERE x.group_id=?''',(group_id,))
if any(_domain(s)==domain and s['site_id']!=data.site_id for s in existing):
raise HTTPException(409,'语言组内两个网站不能使用相同域名')
if db.one('SELECT site_id FROM language_sites WHERE site_id=?',(data.site_id,)):
raise HTTPException(409,'该站点已经属于语言组')
try:db.insert('INSERT INTO language_sites VALUES (?,?,?)',(group_id,data.site_id,locale))
except Exception as exc:
if 'UNIQUE' in str(exc).upper():raise HTTPException(409,'组内语言标识已存在')
raise
return {'group_id':group_id,'site_id':data.site_id,'locale':locale}
@router.post('/groups/{group_id}/units',status_code=201)
def create_unit(group_id:str,data:UnitIn,request:Request):
require_group(group_id);_authorized(request,group_id,'editor')
key=data.reference_key.strip()
if not KEY_PATTERN.fullmatch(key):raise HTTPException(400,'翻译组标识只能包含字母、数字、短横线、下划线、点和冒号')
found=db.one('SELECT id FROM translation_units WHERE group_id=? AND reference_key=?',(group_id,key))
if found:return {'id':found['id'],'reference_key':key,'existing':True}
id=uid();db.insert('INSERT INTO translation_units VALUES (?,?,?,?)',(id,group_id,key,db.now()))
return {'id':id,'reference_key':key,'existing':False}
@router.post('/groups/{group_id}/units/{unit_id}/members')
def link_member(group_id:str,unit_id:str,data:MemberIn,request:Request):
require_group(group_id);_authorized(request,group_id,'editor')
enterprise.require(request,data.site_id,'editor')
unit=db.one('SELECT * FROM translation_units WHERE id=? AND group_id=?',(unit_id,group_id))
if not unit:raise HTTPException(404,'翻译内容组不存在')
if not db.one('SELECT site_id FROM language_sites WHERE site_id=? AND group_id=?',(data.site_id,group_id)):
raise HTTPException(400,'网站不属于该语言组')
entry=db.one('SELECT * FROM entries WHERE id=? AND site_id=?',(data.entry_id,data.site_id))
if not entry:raise HTTPException(404,'内容不存在或不属于该网站')
if data.state not in ('pending','approved'):raise HTTPException(400,'无效的翻译状态')
if data.state=='pending' and entry['status']=='published':
raise HTTPException(409,'待翻译内容必须先保存为草稿,避免泄露未完成翻译')
if data.state=='approved' and entry['status']!='published':
raise HTTPException(409,'只有已发布的正式内容才能审核通过翻译关系')
prior=db.one('SELECT unit_id FROM translation_members WHERE site_id=? AND entry_id=?',(data.site_id,data.entry_id))
if prior and prior['unit_id']!=unit_id:raise HTTPException(409,'内容已经属于另一翻译组')
# Atomic upsert: one locale / site has at most one entry in a semantic group.
with db.connect() as c:
c.execute('''INSERT INTO translation_members(unit_id,site_id,entry_id,state) VALUES (?,?,?,?)
ON CONFLICT(unit_id,site_id) DO UPDATE SET entry_id=excluded.entry_id,state=excluded.state''',
(unit_id,data.site_id,data.entry_id,data.state))
return {'unit_id':unit_id,'site_id':data.site_id,'entry_id':data.entry_id,'state':data.state}
@router.delete('/groups/{group_id}/units/{unit_id}/members/{site_id}')
def unlink_member(group_id:str,unit_id:str,site_id:str,request:Request):
require_group(group_id);_authorized(request,group_id,'editor');enterprise.require(request,site_id,'editor')
deleted=db.execute('DELETE FROM translation_members WHERE unit_id=? AND site_id=?',(unit_id,site_id))
if not deleted:raise HTTPException(404,'没有匹配的翻译关联')
return {'removed':True}
def site_locale(site_id):
row=db.one('SELECT locale FROM language_sites WHERE site_id=?',(site_id,))
if row:return row['locale']
# Per-site locale override (e.g. standalone English sites outside any language group)
site=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
try:override=json.loads(site['settings_json'] or '{}').get('locale') if site else None
except ValueError:override=None
if override:
try:return locale_value(override)
except ValueError:pass
return 'zh-CN'
def assert_build_safe(site_id):
invalid=db.rows('''SELECT e.route,e.title FROM translation_members m JOIN entries e ON e.id=m.entry_id
WHERE m.site_id=? AND m.state='pending' AND e.status='published' ''',(site_id,))
if invalid:
raise ValueError('有待翻译内容被设为已发布,禁止构建:'+', '.join(x['route'] for x in invalid[:8]))
def alternates(site_id):
"""Get {route: [(locale,absolute_url)]} with approved cross-site peers only.
Return zero for unlinked/draft/noindex/invalid domain pages, including for
pending translation records. All members use the same symmetric set.
"""
row=db.one('SELECT group_id FROM language_sites WHERE site_id=?',(site_id,))
if not row:return {}
gid=row['group_id'];group=db.one('SELECT * FROM language_groups WHERE id=?',(gid,))
members=db.rows('''SELECT m.unit_id,s.locale,e.route,e.status,e.robots,e.canonical,t.domain,t.id AS site_id
FROM translation_members m JOIN entries e ON e.id=m.entry_id AND e.site_id=m.site_id
JOIN language_sites s ON s.site_id=m.site_id JOIN sites t ON t.id=m.site_id
JOIN translation_units u ON u.id=m.unit_id
WHERE u.group_id=? AND m.state='approved' ''',(gid,))
by_unit={}
for item in members:
if item['status']!='published' or 'noindex' in item['robots'].lower():continue
try:domain=_domain(item)
except ValueError:continue
if item['canonical'] and item['canonical'].rstrip('/') != (domain+item['route']).rstrip('/'):
continue # a canonical elsewhere is not an indexable translation target
by_unit.setdefault(item['unit_id'],[]).append((item['site_id'],item['route'],item['locale'],domain+item['route']))
result={}
for linked in by_unit.values():
if len(linked)<2:continue
peers=sorted([(locale,url) for _,_,locale,url in linked],key=lambda x:x[0])
# x-default is explicit only if an approved default-language peer exists.
default=next((url for locale,url in peers if locale==group['default_locale']),None)
if default:peers.append(('x-default',default))
for sid,route,_,_ in linked:
if sid==site_id:result[route]=peers
return result
def inject_seo(html,locale,links):
"""Lightweight escape-safe HTML head augmentation without reparsing 5k pages."""
html=re.sub(r"(<html\b[^>]*?\blang=)[\"'\"][^\"']*[\"'\"]",lambda m:m.group(1)+'"'+html_escape(locale,quote=True)+'"',html,count=1,flags=re.I)
if not re.search(r'<html\b[^>]*\blang=',html,flags=re.I):
html=re.sub(r'<html\b','<html lang="'+html_escape(locale,quote=True)+'"',html,count=1,flags=re.I)
if links and '</head>' in html.lower():
tags=''.join('<link rel="alternate" hreflang="'+html_escape(language,quote=True)+'" href="'+html_escape(url,quote=True)+'">' for language,url in links)
html=re.sub(r'</head>',tags+'</head>',html,count=1,flags=re.I)
# Visible accessible language switch; never fabricate links for missing translations.
labels={'zh-CN':'简体中文','en':'English','fr-FR':'Français','ar':'العربية',
'es':'Español','ru':'Русский','pt':'Português'}
choices=[(language,url) for language,url in links if language!='x-default']
nav='<nav class="sf-language-switch" aria-label="选择网站语言">'+''.join(
'<a lang="'+html_escape(lang,quote=True)+'" hreflang="'+html_escape(lang,quote=True)+
'" href="'+html_escape(url,quote=True)+'"'+(' aria-current="page"' if lang==locale else '')+
'>'+html_escape(labels.get(lang,lang))+'</a>' for lang,url in choices)+'</nav>'
html=re.sub(r'</header>',lambda _m:nav+'</header>',html,count=1,flags=re.I)
return html
def sitemap_alternates(links):
return ''.join('<xhtml:link rel="alternate" hreflang="'+xml_escape(locale)+'" href="'+xml_escape(url)+'"/>' for locale,url in links)
+77 -24
View File
@@ -1,32 +1,87 @@
"""PostgreSQL DB-API bridge for legacy '?' SQL (optional, requires psycopg[binary]). """Conservative PostgreSQL DB-API bridge for the existing SQLite-shaped application.
No automatic conversion of existing SQLite files is attempted. Production migration The row adapter intentionally supports BOTH row['column'] and row[0], because
requires a tested one-time data migration and reconciliation. several existing queue paths use positional aggregate results while API paths
serialize rows as dictionaries. PostgreSQL data migration remains explicit.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from contextlib import contextmanager
class PGRow(dict):
"""Mapping-compatible row with SQLite-style positional indexing."""
__slots__ = ('_values',)
def __init__(self, columns, values):
values = tuple(values)
super().__init__(zip(columns, values))
self._values = values
def __getitem__(self, key):
if isinstance(key, int):
return self._values[key]
return super().__getitem__(key)
def row_factory(cursor):
columns = [col.name for col in (cursor.description or ())]
return lambda values: PGRow(columns, values)
def placeholders(sql: str) -> str: def placeholders(sql: str) -> str:
"""Translate SQLite positional placeholders outside SQL quoted strings.""" """Translate ? placeholders without modifying quoted strings or comments."""
out=[];quote=None;i=0 out=[]; i=0; state='code'
while i<len(sql): while i<len(sql):
ch=sql[i] ch=sql[i]; nxt=sql[i+1] if i+1<len(sql) else ''
if quote: if state=='code':
out.append(ch) if ch in ("'",'"'):
if ch==quote: state=ch;out.append(ch)
if i+1<len(sql) and sql[i+1]==quote: elif ch=='-' and nxt=='-':
out.append(sql[i+1]);i+=1 state='line';out.extend((ch,nxt));i+=1
else:quote=None elif ch=='/' and nxt=='*':
elif ch in ("'",'"'): state='block';out.extend((ch,nxt));i+=1
quote=ch;out.append(ch)
elif ch=='?':out.append('%s') elif ch=='?':out.append('%s')
else:out.append(ch) else:out.append(ch)
elif state in ("'",'"'):
out.append(ch)
if ch==state:
if nxt==state:out.append(nxt);i+=1
else:state='code'
elif state=='line':
out.append(ch)
if ch=='\n':state='code'
elif state=='block':
out.append(ch)
if ch=='*' and nxt=='/':out.append(nxt);i+=1;state='code'
i+=1 i+=1
return ''.join(out) return ''.join(out)
def statements(script: str):
"""Split migration DDL at top-level semicolons, not inside quoted literals."""
start=0;i=0;state='code'
while i<len(script):
ch=script[i];nxt=script[i+1] if i+1<len(script) else ''
if state=='code':
if ch in ("'",'"'):state=ch
elif ch=='-' and nxt=='-':state='line';i+=1
elif ch=='/' and nxt=='*':state='block';i+=1
elif ch==';':
candidate=script[start:i].strip()
if candidate:yield candidate
start=i+1
elif state in ("'",'"'):
if ch==state:
if nxt==state:i+=1
else:state='code'
elif state=='line':
if ch=='\n':state='code'
elif state=='block':
if ch=='*' and nxt=='/':i+=1;state='code'
i+=1
candidate=script[start:].strip()
if candidate:yield candidate
def normalize(sql: str) -> str: def normalize(sql: str) -> str:
sql=sql.strip() sql=sql.strip()
if re.match(r'^INSERT\s+OR\s+IGNORE\s+INTO\b',sql,re.I): if re.match(r'^INSERT\s+OR\s+IGNORE\s+INTO\b',sql,re.I):
@@ -38,8 +93,7 @@ def normalize(sql: str) -> str:
class PGConnection: class PGConnection:
def __init__(self,dsn): def __init__(self,dsn):
import psycopg import psycopg
from psycopg.rows import dict_row self.connection=psycopg.connect(dsn,row_factory=row_factory,connect_timeout=5)
self.connection=psycopg.connect(dsn,row_factory=dict_row,connect_timeout=5)
self.total_changes=0 self.total_changes=0
def execute(self,sql,params=()): def execute(self,sql,params=()):
@@ -49,20 +103,19 @@ class PGConnection:
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',table):raise ValueError('Invalid table name') if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',table):raise ValueError('Invalid table name')
return self.connection.execute('SELECT column_name AS name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=%s',(table,)) return self.connection.execute('SELECT column_name AS name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=%s',(table,))
if stripped.upper()=='BEGIN IMMEDIATE': if stripped.upper()=='BEGIN IMMEDIATE':
# Conservative transaction-wide lock while porting SQLite queue semantics. # Serialize short queue claim/limit transactions, not the worker execution.
# Replace with per-site advisory locks before large multi-worker scale. # This prevents per-site races between concurrent enqueuers and claimers.
return self.connection.execute('SELECT pg_advisory_xact_lock(823947231)') return self.connection.execute('SELECT pg_advisory_xact_lock(823947231)')
if stripped.upper().startswith('PRAGMA '): if stripped.upper().startswith('PRAGMA '):return _EmptyResult()
return _EmptyResult()
cursor=self.connection.execute(normalize(sql),params) cursor=self.connection.execute(normalize(sql),params)
if cursor.rowcount and cursor.rowcount>0 and stripped.split(None,1)[0].upper() in ('UPDATE','INSERT','DELETE'): if cursor.rowcount and cursor.rowcount>0 and stripped.split(None,1)[0].upper() in ('UPDATE','INSERT','DELETE'):
self.total_changes+=cursor.rowcount self.total_changes+=cursor.rowcount
return cursor return cursor
def executescript(self,script): def executescript(self,script):
# Schema migrations contain only simple DDL and SQLite PRAGMA statements. for statement in statements(script):
for statement in script.split(';'): if not statement.lstrip().startswith('--') or '\n' in statement:
if statement.strip():self.execute(statement) self.execute(statement)
def __enter__(self):return self def __enter__(self):return self
def __exit__(self,typ,val,tb): def __exit__(self,typ,val,tb):
+239
View File
@@ -0,0 +1,239 @@
"""V8.18 PostgreSQL disaster recovery, deliberately OFFLINE and fail-closed.
The management process and all Workers must be stopped while backing up the
application database and shared filesystem to guarantee an application-level
consistent pair. No switch-over to production is performed by this module.
pg_dump/pg_restore must be installed on the operator's administrative host.
Credentials are passed in child environment (not command arguments or logs).
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import re
import shutil
import stat
import subprocess
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from urllib.parse import parse_qsl, unquote, urlsplit
FORMAT = 'siteforge.pg.offline.v1'
MEDIA_ROOTS = ('media', 'legacy', 'builds', 'archives')
ALLOWED_EXTRA = {'manifest.json', 'database.dump'}
MAX_FILES = 250_000
MAX_UNCOMPRESSED = 30_000_000_000
MAX_RATIO = 1000
class RecoveryError(RuntimeError):
pass
def connection_env(url: str, base: dict | None = None) -> dict:
"""PostgreSQL URL => libpq process env; never include password on argv."""
p = urlsplit(url)
if p.scheme not in ('postgresql', 'postgres') or not p.hostname or not p.path.strip('/'):
raise RecoveryError('Require valid PostgreSQL URL with hostname and database')
if '/' in unquote(p.path[1:]):
raise RecoveryError('Invalid database name')
opts = dict(parse_qsl(p.query, keep_blank_values=True))
allowed = {'sslmode': 'PGSSLMODE', 'sslrootcert': 'PGSSLROOTCERT',
'sslcert': 'PGSSLCERT', 'sslkey': 'PGSSLKEY',
'connect_timeout': 'PGCONNECT_TIMEOUT'}
if set(opts) - set(allowed):
raise RecoveryError('Unsupported PostgreSQL URL options')
try:
port = str(p.port or 5432)
except ValueError as exc:
raise RecoveryError('Invalid PostgreSQL port') from exc
env = dict(os.environ if base is None else base)
env.update({'PGHOST': p.hostname, 'PGPORT': port,
'PGDATABASE': unquote(p.path[1:]),
'PGUSER': unquote(p.username or ''),
'PGPASSWORD': unquote(p.password or ''),
'PGAPPNAME': 'siteforge-v818-maintenance'})
env.update({allowed[k]: v for k, v in opts.items()})
# Maintenance must never silently fall back to a developer's ~/.pgpass.
env['PGPASSFILE'] = os.devnull
return env
def run_pg(argv: list[str], env: dict, timeout: int = 1800) -> subprocess.CompletedProcess:
executable = argv[0]
if shutil.which(executable) is None:
raise RecoveryError(f'{executable} not installed on this host')
try:
result = subprocess.run(argv, env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout, check=False)
except subprocess.TimeoutExpired as exc:
raise RecoveryError(f'{executable} timed out') from exc
except OSError as exc:
raise RecoveryError(f'{executable} execution failed') from exc
if result.returncode:
# pg tools can echo target location or credentials through external configs;
# do not include untrusted tool stderr in user-facing errors.
raise RecoveryError(f'{executable} returned non-zero exit code {result.returncode}')
return result
def sha256_stream(stream) -> str:
digest = hashlib.sha256()
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def sha256_path(path: Path) -> str:
with path.open('rb') as f:
return sha256_stream(f)
def media_files(source: Path):
for folder in MEDIA_ROOTS:
root = source / folder
if root.is_symlink():
raise RecoveryError(f'Symlinked storage root not allowed: {folder}')
if not root.exists():
continue
for node in sorted(root.rglob('*')):
if node.is_symlink():
raise RecoveryError('Symlinked file in storage tree')
if node.is_file():
yield node, node.relative_to(source).as_posix()
def safe_member(name: str) -> bool:
p = PurePosixPath(name)
return bool(name and not name.endswith('/') and '\\' not in name and '\0' not in name
and not p.is_absolute() and ':' not in name and
all(part not in ('', '.', '..') for part in name.split('/')) and
(name in ALLOWED_EXTRA or p.parts[0] in MEDIA_ROOTS))
def _inspect(path: Path):
try:
with zipfile.ZipFile(path) as z:
infos = z.infolist()
if len(infos) > MAX_FILES or sum(i.file_size for i in infos) > MAX_UNCOMPRESSED:
raise RecoveryError('Backup resource limits exceeded')
items = {}
for info in infos:
if not safe_member(info.filename) or info.filename in items:
raise RecoveryError('Unsafe or duplicate archive member')
if stat.S_ISLNK(info.external_attr >> 16):
raise RecoveryError('Symlink in backup')
if info.file_size > 5_000_000 and info.compress_size == 0:
raise RecoveryError('Invalid compressed member')
if info.file_size > 5_000_000 and info.file_size / max(info.compress_size, 1) > MAX_RATIO:
raise RecoveryError('Unreasonable compression ratio')
items[info.filename] = info
if set(ALLOWED_EXTRA) - set(items):
raise RecoveryError('Database dump or manifest missing')
if items['manifest.json'].file_size > 2_000_000:
raise RecoveryError('Manifest exceeds size limit')
manifest = json.loads(z.read('manifest.json'))
if manifest.get('format') != FORMAT or not isinstance(manifest.get('files'), dict):
raise RecoveryError('Not a compatible SiteForge PG backup')
documented = manifest['files']
if set(documented) != set(items) - {'manifest.json'}:
raise RecoveryError('Manifest and ZIP files differ')
for name, rec in documented.items():
if (not isinstance(rec, dict) or not isinstance(rec.get('size'), int)
or rec['size'] != items[name].file_size or
not re.fullmatch('[0-9a-f]{64}', str(rec.get('sha256', '')))):
raise RecoveryError('Invalid file metadata')
with z.open(name) as f:
digest = sha256_stream(f)
if digest != rec['sha256']:
raise RecoveryError('Checksum mismatch: ' + name[:100])
return manifest
except (zipfile.BadZipFile, json.JSONDecodeError, KeyError, UnicodeError) as exc:
raise RecoveryError('Corrupted PostgreSQL backup') from exc
def verify(path: str | Path) -> dict:
return _inspect(Path(path).resolve())
def backup(url: str, data: str | Path, archive: str | Path, *, confirmed_offline: bool = False,
timeout: int = 1800) -> dict:
if not confirmed_offline:
raise RecoveryError('Stop all Web/Workers and pass --confirmed-offline')
env = connection_env(url)
source = Path(data).resolve()
if not source.is_dir():
raise RecoveryError('Shared storage directory does not exist')
destination = Path(archive).resolve()
if destination.exists():
raise RecoveryError('Refusing to overwrite an existing backup')
destination.parent.mkdir(parents=True, exist_ok=True)
staging = destination.with_name(destination.name + '.partial')
if staging.exists():
raise RecoveryError('Partial backup file already exists')
with tempfile.TemporaryDirectory(prefix='sf-pg-backup-') as temp:
dump = Path(temp) / 'database.dump'
run_pg(['pg_dump', '--format=custom', '--no-owner', '--no-acl',
'--file', str(dump)], env, timeout)
if not dump.is_file() or dump.stat().st_size == 0:
raise RecoveryError('Empty database dump')
run_pg(['pg_restore', '--list', str(dump)], env, min(120, timeout))
members = [(dump, 'database.dump'), *media_files(source)]
if len(members) > MAX_FILES:
raise RecoveryError('Too many storage files')
if sum(p.stat().st_size for p, _ in members) > MAX_UNCOMPRESSED:
raise RecoveryError('Too much data for one backup')
manifest = {'format': FORMAT, 'created_at': datetime.now(timezone.utc).isoformat(),
'database': 'postgresql', 'consistent': 'operator-confirmed-offline',
'files': {}}
try:
with zipfile.ZipFile(staging, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=5,
allowZip64=True) as z:
for path, name in members:
manifest['files'][name] = {'size': path.stat().st_size, 'sha256': sha256_path(path)}
z.write(path, name)
z.writestr('manifest.json', json.dumps(manifest, ensure_ascii=False, sort_keys=True))
_inspect(staging)
staging.replace(destination)
finally:
staging.unlink(missing_ok=True)
return {'archive': str(destination), 'files': len(members),
'bytes': sum(x['size'] for x in manifest['files'].values())}
def restore_new(url: str, archive: str | Path, data: str | Path, *, confirmed_offline: bool = False,
timeout: int = 1800) -> dict:
"""Restore ONLY into new, EMPTY PG database and non-existing storage path."""
if not confirmed_offline:
raise RecoveryError('Explicit offline confirmation is required')
path = Path(archive).resolve()
manifest = verify(path)
dest = Path(data).resolve()
if dest.exists():
raise RecoveryError('Target storage must not exist (restore into new directory)')
dest.parent.mkdir(parents=True, exist_ok=True)
env = connection_env(url)
# Empty target database check: exclude PostgreSQL system/extension internals.
sql = "SELECT COUNT(*) FROM pg_catalog.pg_class WHERE relnamespace = 'public'::regnamespace AND relkind IN ('r','p','v','m','S','f')"
result = run_pg(['psql', '--no-psqlrc', '--tuples-only', '--no-align', '--command', sql], env, 40)
if result.stdout.strip() != b'0':
raise RecoveryError('Target database public schema must be empty')
with tempfile.TemporaryDirectory(prefix='.sf-pg-restore-', dir=dest.parent) as tmp:
stage = Path(tmp) / 'siteforge'; stage.mkdir()
with zipfile.ZipFile(path) as z:
for name in manifest['files']:
target = stage / name
target.parent.mkdir(parents=True, exist_ok=True)
with z.open(name) as src, target.open('wb') as out:
shutil.copyfileobj(src, out, 1024 * 1024)
dump = stage / 'database.dump'
run_pg(['pg_restore', '--exit-on-error', '--single-transaction', '--no-owner', '--no-acl',
'--dbname', env['PGDATABASE'], str(dump)], env, timeout)
dump.unlink()
stage.replace(dest)
return {'database': 'restored-into-empty-target', 'storage': str(dest),
'media_files': len(manifest['files']) - 1}
+175 -41
View File
@@ -3,9 +3,9 @@ import hashlib,json,os,time
from pathlib import Path from pathlib import Path
from fastapi import APIRouter,HTTPException,Request,Body from fastapi import APIRouter,HTTPException,Request,Body
from pydantic import BaseModel,Field from pydantic import BaseModel,Field
import httpx from . import db,enterprise,quality,secret_refs,safe_http,jobs
from . import db,enterprise,quality,secret_refs,safe_http
from .deploy_agent import signature from .deploy_agent import signature
from . import release_targets
router=APIRouter(prefix='/api/production',tags=['Production delivery']) router=APIRouter(prefix='/api/production',tags=['Production delivery'])
@@ -50,55 +50,189 @@ def approved(site_id,build_id):
if not request:raise HTTPException(409,'Independent release approval required') if not request:raise HTTPException(409,'Independent release approval required')
return row return row
@router.post('/sites/{site_id}/deploy')
def _target_config(target, *, resolve_secret=True):
if target not in ('staging','production'):
raise HTTPException(400,'无效的目标环境')
prefix='SITEFORGE_DEPLOY_'+target.upper()
url=os.environ.get(prefix+'_URL','').rstrip('/')
ref=os.environ.get(prefix+'_SECRET_REF','')
if not resolve_secret:
if ref:
try:secret_refs.validate_reference(ref)
except secret_refs.SecretReferenceError:raise HTTPException(503,'密钥引用格式无效')
if not url.startswith('https://') or not (ref or os.environ.get(prefix+'_SECRET')):
raise HTTPException(503,'请先配置 HTTPS 发布目标和密钥引用')
return url,''
try:secret=secret_refs.resolve(ref) if ref else os.environ.get(prefix+'_SECRET','')
except secret_refs.SecretReferenceError:raise HTTPException(503,'未能读取远程发布密钥')
if not url.startswith('https://') or len(secret)<32:
raise HTTPException(503,'请先配置 HTTPS 发布目标和长度不少于32位的密钥')
return url,secret
def _remote(site,url,secret,method,path,body=b''):
stamp=str(int(time.time()))
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site,stamp,body,method,path)}
if body:headers['Content-Type']='application/zip'
try:
if method=='POST':response=safe_http.post(url+path,body=body,headers=headers,timeout=120)
else:response=safe_http.fetch(url+path,headers=headers,method='GET',timeout=30)
# 4xx are validation failures, not retryable network errors.
if response.status_code in (400,401,403,404,409,413):
raise HTTPException(409,'远程发布端拒绝操作,请检查签名、存档完整性或版本状态')
response.raise_for_status()
return response.json()
except HTTPException:raise
except (safe_http.OutboundError,OSError,ValueError) as ex:
# Never include URL, secret, request headers or upstream response in error message.
raise HTTPException(502,'远程连接或回应失败,请从任务中心重试') from ex
def _archive_for(site_id,record):
archive=Path(record['zip_path']).resolve()
expected_root=(db.DATA/'archives').resolve()
if not archive.is_relative_to(expected_root) or not archive.name.startswith(site_id+'-') or not archive.is_file():
raise HTTPException(409,'构建 ZIP 不存在或地址不安全')
if archive.stat().st_size>120*1024*1024:
raise HTTPException(409,'构建包超出 Agent 上传限制')
return archive
def run_delivery(site_id,kind,opts,job_id):
"""Called only by Worker subprocess; re-check approvals before changing live pointer."""
target=opts['target']
mode=release_targets.backend(target)
if mode=='agent':url,secret=_target_config(target)
if kind=='remote_deploy':
build_id=opts['build_id']
record=approved(site_id,build_id)
archive=_archive_for(site_id,record)
if mode!='agent':
value=release_targets.deploy(target,site_id,archive)
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,build_id,'worker:'+job_id,'remote:'+target,db.now()))
db.insert('''INSERT INTO remote_releases VALUES (?,?,?,?,?,?)
ON CONFLICT(site_id,target,release_id) DO UPDATE SET
deployed_at=excluded.deployed_at,actor=excluded.actor''',
(site_id,build_id,target,value['release'],'worker:'+job_id,db.now()))
return {**value,'build_id':build_id}
body=archive.read_bytes()
digest=hashlib.sha256(body).hexdigest()
# Stage only: no traffic change on upload or checksum failure.
staged=_remote(site_id,url,secret,'POST',f'/staging/{site_id}',body)
release=staged['release']
if digest!=staged.get('sha256') or release!=digest[:20]:
raise HTTPException(409,'远程存档摘要不匹配,未激活')
verified=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
if not verified.get('verified') or verified.get('sha256')!=digest:
raise HTTPException(409,'远程文件校验失败,未激活')
activated=_remote(site_id,url,secret,'POST',f'/sites/{site_id}/activate/{release}')
if activated.get('current')!=release or not activated.get('active'):
raise HTTPException(502,'远程版本切换未确认')
confirmation=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
# Verify public document is actually served, not only that the pointer changed.
with __import__('zipfile').ZipFile(archive) as package:
index_hash=hashlib.sha256(package.read('index.html')).hexdigest()
probe_ok=False
try:
response=safe_http.fetch(f'{url}/public/{site_id}/',timeout=20,max_bytes=8_000_000)
probe_ok=(response.status_code==200 and hashlib.sha256(response.content).hexdigest()==index_hash)
except (safe_http.OutboundError,OSError,ValueError):
pass
if not confirmation.get('active') or confirmation.get('sha256')!=digest or not probe_ok:
previous=activated.get('previous')
if previous and previous!=release:
try:_remote(site_id,url,secret,'POST',f'/sites/{site_id}/rollback/{previous}')
except Exception:pass
raise HTTPException(502,'发布后内容验证未通过,已尝试恢复旧版本,请人工复核')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,build_id,'worker:'+job_id,'remote:'+target,db.now()))
db.insert('''INSERT INTO remote_releases VALUES (?,?,?,?,?,?)
ON CONFLICT(site_id,target,release_id) DO UPDATE SET
deployed_at=excluded.deployed_at,actor=excluded.actor''',
(site_id,build_id,target,release,'worker:'+job_id,db.now()))
return {'published':True,'release':release,'build_id':build_id,'target':target,
'files':confirmation['files'],'sha256':digest,'previous':activated.get('previous')}
if kind=='remote_rollback':
release=opts['release_id']
old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?',
(site_id,target,release))
if not old:raise HTTPException(404,'只能回滚至本站曾发布的历史版本')
if mode!='agent':
value=release_targets.rollback(target,site_id,release)
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,old['build_id'],'worker:'+job_id,
'remote-rollback:'+target,db.now()))
return {**value,'build_id':old['build_id']}
verified=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
if not verified.get('verified'):raise HTTPException(409,'旧版本文件校验失败')
result=_remote(site_id,url,secret,'POST',f'/sites/{site_id}/rollback/{release}')
confirmation=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
if not confirmation.get('active'):raise HTTPException(502,'回滚后未能确认线上版本')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,old['build_id'],'worker:'+job_id,'remote-rollback:'+target,db.now()))
return {'rolled_back':True,'target':target,'release':release,'build_id':old['build_id']}
raise ValueError('Unknown delivery job kind')
@router.get('/sites/{site_id}/delivery-status')
def delivery_status(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
active=db.rows("SELECT id,kind,state,attempts,error,created_at,result_json FROM build_jobs WHERE site_id=? AND kind IN ('remote_deploy','remote_rollback') ORDER BY created_at DESC LIMIT 30",(site_id,))
history=db.rows('SELECT * FROM remote_releases WHERE site_id=? ORDER BY deployed_at DESC LIMIT 30',(site_id,))
# Return target readiness without revealing hostnames or credential refs.
configured={}
modes={}
for t in ('staging','production'):
modes[t]=release_targets.backend(t)
try:
if modes[t]=='agent':_target_config(t,resolve_secret=False)
else:release_targets.config(t)
configured[t]=True
except HTTPException:configured[t]=False
return {'jobs':active,'history':history,'configured':configured,'backends':modes}
@router.get('/sites/{site_id}/preflight/{build_id}')
def preflight(request:Request,site_id:str,build_id:str):
enterprise.require(request,site_id,'viewer')
try:
record=approved(site_id,build_id)
_archive_for(site_id,record)
return {'ready':True,'build_id':build_id,'message':'已满足构建、质量、迁移和独立审批条件'}
except HTTPException as exc:
return {'ready':False,'build_id':build_id,'message':str(exc.detail)}
@router.post('/sites/{site_id}/deploy',status_code=202)
def push(request:Request,site_id:str,payload:DeployRequest): def push(request:Request,site_id:str,payload:DeployRequest):
enterprise.require(request,site_id,'owner') enterprise.require(request,site_id,'owner')
record=approved(site_id,payload.build_id) record=approved(site_id,payload.build_id)
# Environment-specific endpoint is operator managed, never accepted from user JSON. _archive_for(site_id,record)
prefix='SITEFORGE_DEPLOY_'+payload.target.upper() if release_targets.backend(payload.target)=='agent':_target_config(payload.target,resolve_secret=False)
url=os.environ.get(prefix+'_URL','').rstrip('/') else:release_targets.config(payload.target)
secret_ref=os.environ.get(prefix+'_SECRET_REF','') key=request.headers.get('idempotency-key')
try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','') try:return jobs.submit_delivery(site_id,'remote_deploy',
except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable') {'target':payload.target,'build_id':payload.build_id},request.state.actor,key)
if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured') except jobs.JobConflict as exc:raise HTTPException(409,str(exc))
archive=Path(record['zip_path']).resolve() except jobs.JobCapacityError as exc:raise HTTPException(429,str(exc))
expected_root=(db.DATA/'archives').resolve()
if (not archive.is_relative_to(expected_root) or not archive.name.startswith(site_id+'-') or not archive.is_file()):raise HTTPException(409,'Build artifact not found')
data=archive.read_bytes()
stamp=str(int(time.time()))
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site_id,stamp,data,'POST',f'/deploy/{site_id}'),'Content-Type':'application/zip'}
try:
response=safe_http.post(f'{url}/deploy/{site_id}',body=data,headers=headers,timeout=120)
response.raise_for_status()
result=response.json()
except (safe_http.OutboundError,OSError,ValueError) as ex:raise HTTPException(502,'Remote release failed')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,payload.build_id,request.state.actor,'remote:'+payload.target,db.now()))
db.insert('INSERT INTO remote_releases VALUES (?,?,?,?,?,?)',(site_id,payload.build_id,payload.target,result['release'],request.state.actor,db.now()))
return {'published':True,'target':payload.target,'build':payload.build_id,'remote':result}
class RollbackRequest(BaseModel): class RollbackRequest(BaseModel):
release_id:str=Field(pattern=r'^[0-9a-f]{20}$') release_id:str=Field(pattern=r'^[0-9a-f]{20}$')
target:str=Field(default='staging',pattern='^(staging|production)$') target:str=Field(default='staging',pattern='^(staging|production)$')
@router.post('/sites/{site_id}/rollback') @router.post('/sites/{site_id}/rollback',status_code=202)
def rollback_remote(request:Request,site_id:str,payload:RollbackRequest): def rollback_remote(request:Request,site_id:str,payload:RollbackRequest):
enterprise.require(request,site_id,'owner') enterprise.require(request,site_id,'owner')
old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?', old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?',
(site_id,payload.target,payload.release_id)) (site_id,payload.target,payload.release_id))
if not old:raise HTTPException(404,'仅允许回滚到本站曾经部署过的版本') if not old:raise HTTPException(404,'仅允许回滚到本站曾部署过的版本')
prefix='SITEFORGE_DEPLOY_'+payload.target.upper() if release_targets.backend(payload.target)=='agent':_target_config(payload.target,resolve_secret=False)
url=os.environ.get(prefix+'_URL','').rstrip('/') else:release_targets.config(payload.target)
secret_ref=os.environ.get(prefix+'_SECRET_REF','') key=request.headers.get('idempotency-key')
try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','') try:return jobs.submit_delivery(site_id,'remote_rollback',
except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable') {'target':payload.target,'release_id':payload.release_id},request.state.actor,key)
if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured') except jobs.JobConflict as exc:raise HTTPException(409,str(exc))
stamp=str(int(time.time())) except jobs.JobCapacityError as exc:raise HTTPException(429,str(exc))
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site_id,stamp,b'','POST',f'/sites/{site_id}/rollback/{payload.release_id}')}
try:
r=safe_http.post(f'{url}/sites/{site_id}/rollback/{payload.release_id}',headers=headers,timeout=30)
r.raise_for_status()
result=r.json()
except (safe_http.OutboundError,OSError,ValueError) as ex:raise HTTPException(502,'Remote rollback failed')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,old['build_id'],request.state.actor,'remote-rollback:'+payload.target,db.now()))
return result
+367
View File
@@ -0,0 +1,367 @@
"""Native publication backends. Only Worker calls these methods.
SFTP: verified immutable directories and an atomic POSIX symlink switch.
S3: verified immutable object prefixes with a conditional current.json pointer.
S3 requires a pointer-aware edge/gateway; it is not a naked S3 website switch.
"""
from __future__ import annotations
import hashlib, io, json, os, posixpath, re, stat, uuid, zipfile
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlsplit
from fastapi import HTTPException
from . import secret_refs, safe_http
from .deploy_agent import validate_archive
SITE_RE=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$')
RELEASE_RE=re.compile(r'^[0-9a-f]{20}$')
MAX_ARCHIVE=120*1024*1024
def _env(target,field):
return os.getenv(f'SITEFORGE_DEPLOY_{target.upper()}_{field}','').strip()
def backend(target):
if target not in ('staging','production'):raise HTTPException(400,'未知发布环境')
value=_env(target,'TYPE') or 'agent'
if value not in ('agent','sftp','s3'):raise HTTPException(503,'发布目标类型无效')
return value
def _root(value):
if not value.startswith('/') or value=='/' or '\\' in value or '..' in value.split('/'):
raise HTTPException(503,'远程发布根目录必须为安全的绝对路径')
normalized=posixpath.normpath(value)
if normalized in ('/','/tmp') or len(normalized)<4:raise HTTPException(503,'远程发布根目录范围过大')
return normalized
def config(target, *, check_files=False):
kind=backend(target)
if kind=='agent':return {'type':'agent'}
public=_env(target,'PUBLIC_URL')
parsed=urlsplit(public)
if parsed.scheme!='https' or not parsed.netloc or parsed.username or parsed.password:
raise HTTPException(503,'原生 SFTP/S3 发布必须提前配置 HTTPS PUBLIC_URL')
if kind=='sftp':
host=_env(target,'HOST');user=_env(target,'USERNAME')
root=_root(_env(target,'REMOTE_ROOT'))
known=_env(target,'KNOWN_HOSTS_FILE');key=_env(target,'SSH_KEY_FILE')
if not host or not user or not known or not key or '/' in host or ':' in host:
raise HTTPException(503,'SFTP 缺少主机、用户、known_hosts 或私钥配置')
try:port=int(_env(target,'PORT') or 22)
except ValueError:raise HTTPException(503,'SFTP 端口无效')
if not 1<=port<=65535:raise HTTPException(503,'SFTP 端口无效')
if check_files and (not Path(known).is_file() or not Path(key).is_file()):
raise HTTPException(503,'SFTP 私钥或 known_hosts 文件未挂载')
return {'type':kind,'host':host,'user':user,'root':root,'known_hosts':known,'ssh_key':key,'port':port}
bucket=_env(target,'BUCKET');region=_env(target,'REGION');prefix=_env(target,'PREFIX') or 'siteforge'
if not bucket or not region or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9._-]{1,62}',bucket):
raise HTTPException(503,'S3 存储桶或区域配置不完整')
if prefix.startswith('/') or '..' in prefix.split('/') or not re.fullmatch(r'[a-zA-Z0-9/_-]+',prefix):
raise HTTPException(503,'S3 对象前缀不安全')
endpoint=_env(target,'ENDPOINT_URL')
if endpoint:
parsed=urlsplit(endpoint)
if parsed.scheme!='https' or not parsed.netloc or parsed.username or parsed.password or parsed.path.strip('/'):
raise HTTPException(503,'S3 兼容服务端点必须为 HTTPS 根地址')
if _env(target,'POINTER_AWARE')!='1':
raise HTTPException(503,'S3 必须配置可解析 current.json 的发布网关(POINTER_AWARE=1)')
return {'type':kind,'bucket':bucket,'region':region,'prefix':prefix.strip('/'), 'endpoint':endpoint}
def _archive(raw:bytes):
if len(raw)>MAX_ARCHIVE:raise ValueError('超出单次发布大小上限')
validate_archive(raw)
output={}
with zipfile.ZipFile(io.BytesIO(raw)) as z:
for member in z.infolist():
if member.is_dir():continue
path=member.filename
# validate_archive already checked all paths, duplicates, symlinks and size bounds.
output[path]=z.read(member)
hashes={k:hashlib.sha256(v).hexdigest() for k,v in output.items()}
digest=hashlib.sha256(raw).hexdigest()
return digest[:20],digest,output,hashes
def _manifest(digest,hashes):
return json.dumps({'sha256':digest,'files':hashes},sort_keys=True,separators=(',',':')).encode()
def _probe(target,site,body):
public_url=_env(target,'PUBLIC_URL')
if not public_url:
raise HTTPException(503,'正式发布必须配置站点 PUBLIC_URL 以执行上线探测')
parsed=urlsplit(public_url)
if parsed.scheme!='https' or not parsed.netloc or parsed.username or parsed.password:
raise HTTPException(503,'PUBLIC_URL 必须为 HTTPS 网站地址')
url=public_url.rstrip('/')+'/'
try:
response=safe_http.fetch(url,timeout=25,max_bytes=8_000_000)
if response.status_code!=200 or hashlib.sha256(response.content).digest()!=hashlib.sha256(body).digest():
raise HTTPException(502,'上线首页内容与构建版本不一致')
except safe_http.OutboundError as exc:
raise HTTPException(502,'上线地址不可访问,需检查发布和 DNS/CDN 状态') from exc
return True
# ---------------- SFTP ----------------
@contextmanager
def _sftp_connection(cfg):
"""Strict host-key verification; private key mounted into Worker, never transmitted to API."""
try:import paramiko
except ImportError as exc:raise HTTPException(503,'缺少可选的 Paramiko SFTP 依赖') from exc
client=paramiko.SSHClient()
client.load_host_keys(cfg['known_hosts'])
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(cfg['host'],port=cfg['port'],username=cfg['user'],
key_filename=cfg['ssh_key'],look_for_keys=False,allow_agent=False,
timeout=12,auth_timeout=12,banner_timeout=12)
conn=client.open_sftp()
try:yield conn
finally:conn.close()
finally:client.close()
def _sftp_type(sftp,path):
try:return sftp.lstat(path).st_mode
except OSError:return None
def _mkdirs(sftp,path):
cur=''
for part in path.split('/'):
if not part:continue
cur+='/'+part
mode=_sftp_type(sftp,cur)
if mode is None:
sftp.mkdir(cur)
mode=_sftp_type(sftp,cur)
if not mode or not stat.S_ISDIR(mode):raise ValueError('远端目录包含符号链接或非目录')
def _read(sftp,path,limit=4*1024*1024):
with sftp.file(path,'rb') as f:raw=f.read(limit+1)
if len(raw)>limit:raise ValueError('远程校验文件过大')
return raw
def _write(sftp,path,data):
with sftp.file(path,'wb') as f:f.write(data);f.flush()
def _current_sftp(sftp,root):
pointer=root+'/current'
mode=_sftp_type(sftp,pointer)
if mode is None:return None
if not stat.S_ISLNK(mode):raise ValueError('current 不是受管符号链接')
link=sftp.readlink(pointer)
if not re.fullmatch(r'releases/[0-9a-f]{20}',link):raise ValueError('current 指向不安全')
return link.rsplit('/',1)[1]
def _verify_sftp(sftp,root,release):
folder=root+'/releases/'+release
mode=_sftp_type(sftp,folder)
if mode is None or not stat.S_ISDIR(mode):raise ValueError('远程版本目录缺失或不安全')
raw=_read(sftp,folder+'/.siteforge-manifest.json')
obj=json.loads(raw)
checks=obj.get('files')
if not isinstance(checks,dict) or 'index.html' not in checks:raise ValueError('远端文件清单无效')
for name,expected in checks.items():
if name.startswith('/') or '..' in name.split('/') or not re.fullmatch('[a-f0-9]{64}',str(expected)):
raise ValueError('远端文件清单包含非法路径或哈希')
mode=_sftp_type(sftp,folder+'/'+name)
if mode is None or not stat.S_ISREG(mode):raise ValueError('远端发布资源缺失或不是普通文件')
with sftp.file(folder+'/'+name,'rb') as f:
dig=hashlib.sha256()
while True:
chunk=f.read(256*1024)
if not chunk:break
dig.update(chunk)
if dig.hexdigest()!=expected:raise ValueError('远端文件哈希不匹配')
return obj
def _activate_sftp(sftp,root,release):
previous=_current_sftp(sftp,root)
candidate=root+'/current-'+release+'.next'
if _sftp_type(sftp,candidate) is not None:raise ValueError('发现冲突的发布指针临时文件')
sftp.symlink('releases/'+release,candidate)
try:
# OpenSSH posix-rename is atomic and replaces current. Fail closed if unsupported.
sftp.posix_rename(candidate,root+'/current')
finally:
if _sftp_type(sftp,candidate) is not None:sftp.remove(candidate)
if _current_sftp(sftp,root)!=release:raise ValueError('发布指针切换未确认')
return previous
def deploy_sftp(target,site,raw,cfg=None):
cfg=cfg or config(target,check_files=True)
release,digest,files,hashes=_archive(raw)
root=cfg['root'].rstrip('/')+'/'+site
with _sftp_connection(cfg) as sftp:
_mkdirs(sftp,root+'/releases')
folder=root+'/releases/'+release
if _sftp_type(sftp,folder) is None:
tmp=root+'/releases/.staging-'+release+'-'+uuid.uuid4().hex[:12]
_mkdirs(sftp,tmp)
try:
for name,body in files.items():
parent=posixpath.dirname(name)
if parent:_mkdirs(sftp,tmp+'/'+parent)
_write(sftp,tmp+'/'+name,body)
_write(sftp,tmp+'/.siteforge-manifest.json',_manifest(digest,hashes))
# Immutable version path is not overwritten on retry.
sftp.rename(tmp,folder)
except Exception:
# No traffic change on failure. Interrupted transfers leave an
# isolated temporary directory; a retry uses a fresh name.
raise
meta=_verify_sftp(sftp,root,release)
if meta.get('sha256')!=digest:raise ValueError('现有版本摘要与当前构建不一致')
previous=_current_sftp(sftp,root)
if previous!=release:_activate_sftp(sftp,root,release)
try:_probe(target,site,files['index.html'])
except Exception:
if previous and previous!=release:
with _sftp_connection(cfg) as sftp:
_verify_sftp(sftp,root,previous)
_activate_sftp(sftp,root,previous)
raise
return {'published':True,'release':release,'previous':previous,'files':len(files),'sha256':digest,'target':target,'backend':'sftp'}
def rollback_sftp(target,site,release,cfg=None,expected_home=None):
if not RELEASE_RE.fullmatch(release):raise ValueError('无效的版本号')
cfg=cfg or config(target,check_files=True)
root=cfg['root'].rstrip('/')+'/'+site
with _sftp_connection(cfg) as sftp:
_verify_sftp(sftp,root,release)
previous=_current_sftp(sftp,root)
if previous!=release:_activate_sftp(sftp,root,release)
content=_read(sftp,root+'/releases/'+release+'/index.html',8*1024*1024)
try:_probe(target,site,expected_home if expected_home is not None else content)
except Exception:
if previous and previous!=release:
with _sftp_connection(cfg) as sftp:_activate_sftp(sftp,root,previous)
raise
return {'rolled_back':True,'release':release,'backend':'sftp','target':target}
# ---------------- S3 / compatible object store ----------------
def _s3_client(cfg):
try:import boto3
except ImportError as exc:raise HTTPException(503,'缺少可选的 boto3 S3 依赖') from exc
return boto3.client('s3',region_name=cfg['region'],endpoint_url=cfg['endpoint'] or None)
def _s3_key(cfg,site,path):return cfg['prefix']+'/sites/'+site+'/'+path
def _s3_bytes(s3,bucket,key,max_bytes=4*1024*1024):
response=s3.get_object(Bucket=bucket,Key=key)
data=response['Body'].read(max_bytes+1)
if len(data)>max_bytes:raise ValueError('S3 读取超过限制')
return data
def _s3_current(s3,cfg,site):
key=_s3_key(cfg,site,'current.json')
try:
response=s3.get_object(Bucket=cfg['bucket'],Key=key)
except Exception as exc:
code=getattr(exc,'response',{}).get('Error',{}).get('Code','')
if str(code) in ('404','NoSuchKey','NotFound'):return None,None
raise
data=response['Body'].read(4096)
obj=json.loads(data)
if not RELEASE_RE.fullmatch(obj.get('release','')):raise ValueError('当前 S3 发布指针无效')
return obj['release'],response.get('ETag')
def _s3_verify(s3,cfg,site,release):
path=_s3_key(cfg,site,'releases/'+release+'/')
obj=json.loads(_s3_bytes(s3,cfg['bucket'],path+'.siteforge-manifest.json'))
checks=obj['files']
if 'index.html' not in checks:raise ValueError('S3 版本没有入口页')
for name,digest in checks.items():
if name.startswith('/') or '..' in name.split('/') or not re.fullmatch('[a-f0-9]{64}',str(digest)):
raise ValueError('S3 文件清单路径或哈希非法')
response=s3.get_object(Bucket=cfg['bucket'],Key=path+name)
hasher=hashlib.sha256()
while True:
chunk=response['Body'].read(256*1024)
if not chunk:break
hasher.update(chunk)
if hasher.hexdigest()!=digest:raise ValueError('S3 对象校验失败')
return obj
def _s3_point(s3,cfg,site,release,previous,etag):
key=_s3_key(cfg,site,'current.json')
body=json.dumps({'release':release,'previous':previous},sort_keys=True).encode()
args={'Bucket':cfg['bucket'],'Key':key,'Body':body,'ContentType':'application/json','CacheControl':'no-store'}
if etag:args['IfMatch']=etag
else:args['IfNoneMatch']='*'
s3.put_object(**args)
current,_=_s3_current(s3,cfg,site)
if current!=release:raise ValueError('S3 发布指针未确认')
def _s3_immutable_put(s3,bucket,key,body,**extra):
"""Create-only object write. A retry can reuse only byte-identical keys."""
try:
s3.put_object(Bucket=bucket,Key=key,Body=body,IfNoneMatch='*',**extra)
return
except Exception as exc:
code=str(getattr(exc,'response',{}).get('Error',{}).get('Code',''))
if code not in ('PreconditionFailed','412','ConditionalRequestConflict','409'):
raise
existing=_s3_bytes(s3,bucket,key,max_bytes=max(8_000_000,len(body)))
if hashlib.sha256(existing).digest()!=hashlib.sha256(body).digest():
raise ValueError('现有 S3 对象与当前发布文件不一致,禁止覆盖')
def deploy_s3(target,site,raw,cfg=None):
cfg=cfg or config(target)
release,digest,files,hashes=_archive(raw)
s3=_s3_client(cfg);bucket=cfg['bucket']
base=_s3_key(cfg,site,'releases/'+release+'/')
manifest_key=base+'.siteforge-manifest.json'
try:
exists=_s3_bytes(s3,bucket,manifest_key)
except Exception as exc:
code=getattr(exc,'response',{}).get('Error',{}).get('Code','')
if str(code) not in ('404','NoSuchKey','NotFound'):raise
exists=None
if exists is None:
for name,body in files.items():
import mimetypes
ct=mimetypes.guess_type(name)[0] or 'application/octet-stream'
cache='no-cache' if name.endswith('.html') else 'public, max-age=31536000, immutable'
_s3_immutable_put(s3,bucket,base+name,body,ContentType=ct,CacheControl=cache)
_s3_immutable_put(s3,bucket,manifest_key,_manifest(digest,hashes),
ContentType='application/json',CacheControl='no-store')
manifest=_s3_verify(s3,cfg,site,release)
if manifest.get('sha256')!=digest:raise ValueError('S3 版本摘要冲突')
previous,etag=_s3_current(s3,cfg,site)
if previous!=release:_s3_point(s3,cfg,site,release,previous,etag)
try:_probe(target,site,files['index.html'])
except Exception:
if previous and previous!=release:
current,next_etag=_s3_current(s3,cfg,site)
if current==release:_s3_point(s3,cfg,site,previous,release,next_etag)
raise
return {'published':True,'release':release,'previous':previous,'files':len(files),'sha256':digest,'target':target,'backend':'s3'}
def rollback_s3(target,site,release,cfg=None,expected_home=None):
if not RELEASE_RE.fullmatch(release):raise ValueError('无效的版本号')
cfg=cfg or config(target)
s3=_s3_client(cfg)
_s3_verify(s3,cfg,site,release)
previous,etag=_s3_current(s3,cfg,site)
if previous!=release:_s3_point(s3,cfg,site,release,previous,etag)
body=_s3_bytes(s3,cfg['bucket'],_s3_key(cfg,site,'releases/'+release+'/index.html'),8*1024*1024)
try:_probe(target,site,expected_home if expected_home is not None else body)
except Exception:
if previous and previous!=release:
current,new_etag=_s3_current(s3,cfg,site)
if current==release:_s3_point(s3,cfg,site,previous,release,new_etag)
raise
return {'rolled_back':True,'release':release,'target':target,'backend':'s3'}
def deploy(target,site,archive):
if not SITE_RE.fullmatch(site):raise ValueError('无效的网站标识')
cfg=config(target,check_files=True)
raw=Path(archive).read_bytes()
if cfg['type']=='sftp':return deploy_sftp(target,site,raw,cfg)
if cfg['type']=='s3':return deploy_s3(target,site,raw,cfg)
raise ValueError('不是原生发布目标')
def rollback(target,site,release):
if not SITE_RE.fullmatch(site):raise ValueError('无效的网站标识')
cfg=config(target,check_files=True)
if cfg['type']=='sftp':return rollback_sftp(target,site,release,cfg)
if cfg['type']=='s3':return rollback_s3(target,site,release,cfg)
raise ValueError('不是原生发布目标')
+71
View File
@@ -0,0 +1,71 @@
"""Pointer-aware, read-only static website gateway for SiteForge S3 releases.
This process requires ONLY S3 read permissions. It never takes publishing
credentials and never writes to the bucket. Deploy behind HTTPS reverse proxy.
"""
from __future__ import annotations
import json,os,re
from urllib.parse import unquote
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from .release_targets import SITE_RE, RELEASE_RE
app=FastAPI(title='SiteForge S3 read-only site gateway',docs_url=None,redoc_url=None,openapi_url=None)
def gateway_config():
bucket=os.getenv('SITEFORGE_S3_GATEWAY_BUCKET','').strip()
region=os.getenv('SITEFORGE_S3_GATEWAY_REGION','').strip()
prefix=os.getenv('SITEFORGE_S3_GATEWAY_PREFIX','siteforge').strip('/')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{1,62}',bucket) or not region:
raise HTTPException(503,'S3 gateway storage not configured')
if not prefix or not re.fullmatch(r'[A-Za-z0-9/_-]+',prefix) or '..' in prefix.split('/'):
raise HTTPException(503,'Invalid S3 gateway prefix')
return bucket,region,prefix
def client(region):
try:import boto3
except ImportError as ex:raise HTTPException(503,'boto3 missing from release image') from ex
endpoint=os.getenv('SITEFORGE_S3_GATEWAY_ENDPOINT_URL','').strip()
if endpoint and not endpoint.startswith('https://'):
raise HTTPException(503,'S3 gateway endpoint must use TLS')
return boto3.client('s3',region_name=region,endpoint_url=endpoint or None)
def _read(s3,bucket,key,limit=32*1024*1024):
try:
result=s3.get_object(Bucket=bucket,Key=key)
body=result['Body'].read(limit+1)
if len(body)>limit:raise HTTPException(413,'Static object exceeds gateway response limit')
return body, result
except HTTPException:raise
except Exception as ex:
code=str(getattr(ex,'response',{}).get('Error',{}).get('Code',''))
if code in ('404','NoSuchKey','NotFound'):raise HTTPException(404,'Static resource not found') from ex
raise HTTPException(502,'Storage unavailable') from ex
def resolve_object(site,path,s3,bucket,prefix):
if not SITE_RE.fullmatch(site):raise HTTPException(404,'Invalid site')
path=unquote(path or '').lstrip('/')
if '\\' in path or '%' in path or '..' in path.split('/') or len(path)>2048:
raise HTTPException(404,'Invalid static path')
if not path or path.endswith('/'):path+='index.html'
pointer,_=_read(s3,bucket,f'{prefix}/sites/{site}/current.json',4096)
try:release=json.loads(pointer)['release']
except (ValueError,TypeError,KeyError) as ex:raise HTTPException(502,'Invalid release pointer') from ex
if not RELEASE_RE.fullmatch(release):raise HTTPException(502,'Invalid release pointer')
objkey=f'{prefix}/sites/{site}/releases/{release}/{path}'
body,meta=_read(s3,bucket,objkey)
return body, meta.get('ContentType') or 'application/octet-stream', release
@app.get('/health')
def health():return {'healthy':True,'gateway':'read-only-s3'}
@app.get('/{site}/{path:path}')
def static_site(site:str,path:str=''):
bucket,region,prefix=gateway_config()
body,content_type,release=resolve_object(site,path,client(region),bucket,prefix)
return Response(content=body,media_type=content_type,headers={
# URLs stay stable across releases, therefore cache must be revalidated.
'Cache-Control':'no-cache, must-revalidate',
'X-SiteForge-Release':release,
'X-Content-Type-Options':'nosniff',
})
+44
View File
@@ -19,6 +19,7 @@ DEFAULTS={
'show_cta':True,'cta_text':'联系我们','cta_href':'/contact/'}, 'show_cta':True,'cta_text':'联系我们','cta_href':'/contact/'},
'footer':{'title':'','description':'','email':'','show_nav':True}, 'footer':{'title':'','description':'','email':'','show_nav':True},
'nav':[], 'nav':[],
'mobile_tabs':{'enabled':False,'items':[]},
'tokens':{'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72, 'tokens':{'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,
'container_width':1200,'button_style':'rounded','header_style':'standard'}, 'container_width':1200,'button_style':'rounded','header_style':'standard'},
'responsive':{'tablet_gap':64,'mobile_gap':40,'mobile_hide_announcement':True, 'responsive':{'tablet_gap':64,'mobile_gap':40,'mobile_hide_announcement':True,
@@ -82,6 +83,22 @@ def _nav(rows,depth=0,counter=None):
'children':_nav(children,depth+1,counter) if depth<2 else []}) 'children':_nav(children,depth+1,counter) if depth<2 else []})
return result return result
def _mobile_tab_url(value):
"""Allow site-internal routes plus safe, explicit user-facing contact links."""
from urllib.parse import urlsplit
if not isinstance(value,str) or not value or len(value)>350 or any(c in value for c in ('<','>','"',"'",'\\','\n','\r','\t')):
raise HTTPException(400,'底部导航网址不安全')
if value.startswith('/') and not value.startswith('//'):
return _url(value)
if re.fullmatch(r'tel:\+?[0-9()\- ]{5,30}',value):
return value
if re.fullmatch(r'mailto:[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}',value):
return value
parsed=urlsplit(value)
if parsed.scheme=='https' and parsed.hostname and not parsed.username and not parsed.password and not any(c.isspace() for c in value):
return value
raise HTTPException(400,'仅支持站内路径、HTTPS、电话或邮箱链接')
def validate(raw): def validate(raw):
if not isinstance(raw,dict) or set(raw)-set(DEFAULTS):raise HTTPException(400,'未知的全站设计设置') if not isinstance(raw,dict) or set(raw)-set(DEFAULTS):raise HTTPException(400,'未知的全站设计设置')
normalized={} normalized={}
@@ -117,6 +134,33 @@ def validate(raw):
if type(v) not in (float,int) or not lo<=v<=hi:raise HTTPException(400,'设计变量超出范围: '+field) if type(v) not in (float,int) or not lo<=v<=hi:raise HTTPException(400,'设计变量超出范围: '+field)
normalized['tokens']=tokens normalized['tokens']=tokens
normalized['nav']=_nav(raw.get('nav',[])) normalized['nav']=_nav(raw.get('nav',[]))
bar=raw.get('mobile_tabs',{})
if not isinstance(bar,dict) or set(bar)-{'enabled','items'}:
raise HTTPException(400,'底部导航配置格式不正确')
enabled=bar.get('enabled',False)
items=bar.get('items',[])
if type(enabled) is not bool or not isinstance(items,list) or len(items)>5:
raise HTTPException(400,'底部导航仅支持 0 至 5 项')
allowed_icons={'home','grid','products','news','contact','phone','info','search','menu','star'}
result=[]
for item in items:
if not isinstance(item,dict) or set(item)-{'title','route','icon','action'}:
raise HTTPException(400,'底部导航项目格式不正确')
title=item.get('title')
icon=item.get('icon','grid')
action=item.get('action','link')
if not isinstance(title,str) or not 1<=len(title.strip())<=12:
raise HTTPException(400,'底部导航标题须为 1 至 12 字')
if icon not in allowed_icons or action not in ('link','menu'):
raise HTTPException(400,'底部导航图标或操作不受支持')
if action=='menu':
route='/'
else:
route=_mobile_tab_url(item.get('route','/'))
result.append({'title':title.strip(),'icon':icon,'route':route,'action':action})
if enabled and not result:
raise HTTPException(400,'启用底部导航时至少添加一项;不需要可直接关闭')
normalized['mobile_tabs']={'enabled':enabled,'items':result}
if len(json.dumps(normalized,ensure_ascii=False))>35_000:raise HTTPException(400,'全站设计过大') if len(json.dumps(normalized,ensure_ascii=False))>35_000:raise HTTPException(400,'全站设计过大')
return normalized return normalized
+154
View File
@@ -0,0 +1,154 @@
"""Offline, verifiable SQLite -> EMPTY PostgreSQL database migration (V8.18).
Data only. Shared files (/data/media, builds, archives, legacy) must be moved
separately, under the same maintenance window, before site activation.
This never clears target rows. PostgreSQL target schema is bootstrapped by SiteForge
before copying; application must be fully stopped in both installations.
"""
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
import tempfile
from pathlib import Path
from .pg_disaster_recovery import RecoveryError
IDENT=re.compile(r'^[a-z_][a-z0-9_]*$',re.I)
LEDGER='schema_migrations'
def quote(name):
if not IDENT.fullmatch(name):
raise RecoveryError('Unsupported table or column identifier')
return '"'+name+'"'
def plan_tables(connection: sqlite3.Connection) -> list[str]:
tables=[r[0] for r in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
if 'sites' not in tables:raise RecoveryError('SQLite source has no sites table')
graph={t:set() for t in tables}
for table in tables:
for dep in connection.execute(f'PRAGMA foreign_key_list({quote(table)})'):
parent=dep[2]
if parent != table:
if parent not in graph:raise RecoveryError('Missing FK table '+parent)
graph[table].add(parent)
result=[]
while graph:
ready=sorted(name for name,deps in graph.items() if not deps)
if not ready:raise RecoveryError('Circular foreign keys require separate migration')
for name in ready:
result.append(name);graph.pop(name)
for refs in graph.values():refs.difference_update(ready)
return result
def columns(conn: sqlite3.Connection, table: str) -> list[str]:
return [row[1] for row in conn.execute('PRAGMA table_info('+quote(table)+')')]
def _hash_rows(rows) -> str:
"""Order independent deterministic content hash, for migration verification."""
canonical=[]
for row in rows:
canonical.append(json.dumps(list(row),sort_keys=True,ensure_ascii=False,
separators=(',',':'),default=str))
digest=hashlib.sha256()
for line in sorted(canonical):digest.update((line+'\n').encode('utf-8'))
return digest.hexdigest()
def bootstrap_target():
# Same ordered migrations as FastAPI lifespan and PostgreSQL bootstrap smoke.
from . import (db,enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual)
if not db.POSTGRES:raise RecoveryError('SITEFORGE_DATABASE_URL must point at PostgreSQL')
db.init()
for module in (enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual):
module.setup()
def check_destination_empty(pg, source: sqlite3.Connection, tables: list[str]) -> None:
for table in tables:
name=quote(table)
# Check source column coverage. Unknown source columns mean unsafe truncation.
target_cols={r['column_name'] for r in pg.execute(
'SELECT column_name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=?',
(table,)).fetchall()}
src_cols=set(columns(source,table))
if not src_cols or not src_cols.issubset(target_cols):
raise RecoveryError('PostgreSQL schema mismatch at table '+table)
if table == LEDGER:
# Destination is bootstrapped and has its own required migration ledger.
old=source.execute('SELECT version,checksum FROM schema_migrations').fetchall()
for version,checksum in old:
row=pg.execute('SELECT checksum FROM schema_migrations WHERE version=?',(version,)).fetchone()
if not row or row[0]!=checksum:
raise RecoveryError('Migration ledger mismatch: '+version)
continue
count=pg.execute(f'SELECT COUNT(*) FROM {name}').fetchone()[0]
if count:
raise RecoveryError('Destination is not empty: '+table)
def transfer_rows(pg, source, tables, batch=250) -> dict:
if not (1<=batch<=500):raise RecoveryError('Batch size must be between 1 and 500')
result={}
for table in tables:
if table==LEDGER:continue
cols=columns(source,table)
select=f'SELECT {", ".join(map(quote,cols))} FROM {quote(table)}'
original=source.execute(select)
n=0
# Keep below PostgreSQL's 65,535 bind-parameter limit.
limit=min(batch,max(1,65000//len(cols)))
while True:
rows=original.fetchmany(limit)
if not rows:break
placeholders='('+','.join('?' for _ in cols)+')'
insert=f'INSERT INTO {quote(table)} ({", ".join(map(quote,cols))}) VALUES '+','.join([placeholders]*len(rows))
pg.execute(insert,[value for row in rows for value in row])
n+=len(rows)
source_hash=_hash_rows(source.execute(select))
destination_rows=pg.execute(select).fetchall()
destination_hash=_hash_rows([[row[col] for col in cols] for row in destination_rows])
if n!=len(destination_rows) or source_hash!=destination_hash:
raise RecoveryError('Data validation failed at table '+table)
result[table]={'rows':n,'sha256':source_hash}
return result
def migrate(source_path: str | Path, *, confirmed_offline=False, batch=250,
connect=None, bootstrap=True) -> dict:
"""Caller supplies PG connection in tests; production uses app.db.connect."""
if not confirmed_offline:raise RecoveryError('Stop all services and use --confirmed-offline')
src=Path(source_path).resolve()
if not src.is_file():raise RecoveryError('SQLite source file not found')
from . import db
if connect is None and not db.POSTGRES:
raise RecoveryError('PostgreSQL URL is required')
# Capture an immutable consistent snapshot; does not create or change source DB.
with tempfile.TemporaryDirectory(prefix='siteforge-sqlite-pg-') as temp:
snapshot=Path(temp)/'snapshot.sqlite3'
with sqlite3.connect(src.as_uri()+'?mode=ro',uri=True) as old, sqlite3.connect(snapshot) as new:
old.backup(new)
with sqlite3.connect(snapshot.as_uri()+'?mode=ro',uri=True) as sqlite:
if sqlite.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
raise RecoveryError('SQLite integrity check failed')
ordered=plan_tables(sqlite)
if bootstrap:bootstrap_target()
connect_fn=connect or db.connect
# All data inserts and verification occur inside ONE PG transaction.
with connect_fn() as target:
check_destination_empty(target,sqlite,ordered)
counts=transfer_rows(target,sqlite,ordered,batch)
return {'source':'sqlite', 'destination':'postgresql',
'tables':len(counts),'records':sum(x['rows'] for x in counts.values()),
'verified':True,'details':counts,
'note':'Database only. Transfer shared files before traffic cutover.'}
+51
View File
@@ -31,3 +31,54 @@ a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,te
.pro-pages-nav a{padding:.7rem 1.1rem;border:1px solid currentColor;border-radius:.4rem;text-decoration:none} .pro-pages-nav a{padding:.7rem 1.1rem;border:1px solid currentColor;border-radius:.4rem;text-decoration:none}
.pro-pages-nav a:focus-visible{outline:3px solid var(--accent,#2563eb);outline-offset:3px} .pro-pages-nav a:focus-visible{outline:3px solid var(--accent,#2563eb);outline-offset:3px}
.pro-pages-nav span{font-size:.875rem} .pro-pages-nav span{font-size:.875rem}
/* P6 international sites: small, keyboard-operable language switch. */
.sf-language-switch{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;margin:8px 12px;padding:4px;border:1px solid rgba(120,130,145,.22);border-radius:10px;background:rgba(255,255,255,.08)}
.sf-language-switch a{font:inherit;font-size:12px;line-height:1.5;padding:4px 7px;border-radius:6px;color:inherit;text-decoration:none}
.sf-language-switch a[aria-current="page"]{font-weight:700;text-decoration:underline;text-underline-offset:3px}
.sf-language-switch a:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
@media(max-width:640px){.sf-language-switch{margin:6px 0;width:auto}}
/* V8.21.3 — Four-theme unified mobile off-canvas navigation & optional tab bar.
Desktop remains unchanged. Phone / tablet use one accessible drawer. */
.sf-mobile-tabs{display:none!important}
.sf-drawer-header,.sf-mobile-drawer-backdrop,.sf-drawer-extra{display:none}
@media(max-width:1024px){
.pro-head-inner{flex-wrap:nowrap!important;gap:12px!important}
.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto!important;flex:0 0 44px;min-width:44px;min-height:44px;position:relative;z-index:3}
.pro-catalog-toggle{display:none!important}
.pro-navigation,.pro-navigation.is-open{display:flex!important;position:fixed!important;inset:0 0 0 auto!important;width:min(88vw,360px)!important;height:100dvh!important;height:100vh;max-height:100dvh!important;flex:unset!important;flex-direction:column!important;align-items:stretch!important;order:unset!important;margin:0!important;padding:0 0 calc(20px + env(safe-area-inset-bottom,0px))!important;overflow-x:hidden;overflow-y:auto!important;background:var(--pro-background,#fff)!important;color:var(--pro-text,#19283c)!important;border:0!important;border-left:1px solid var(--pro-border,#ddd)!important;box-shadow:-15px 0 45px #0002!important;z-index:1201!important;transform:translateX(105%);visibility:hidden;pointer-events:none;transition:transform .24s ease,visibility .24s ease!important;overscroll-behavior:contain}
.pro-navigation.is-open{transform:translateX(0);visibility:visible;pointer-events:auto}
.sf-drawer-header{display:flex!important;align-items:center;justify-content:space-between;gap:12px;padding:20px 18px;border-bottom:1px solid var(--pro-border,#dde3e9);font-size:17px;font-weight:750;position:sticky;top:0;background:var(--pro-background,#fff);z-index:2}
.sf-drawer-close{min-width:44px;min-height:44px;border:1px solid var(--pro-border,#dde3e9);color:inherit;background:transparent;border-radius:10px;font-size:24px;cursor:pointer}
.sf-mobile-drawer-backdrop{display:block;position:fixed;inset:0;z-index:1200;background:#0b1b31a3;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .24s ease,visibility .24s ease}
.sf-mobile-drawer-backdrop.is-open{opacity:1;visibility:visible;pointer-events:auto}
body.sf-drawer-open{overflow:hidden!important}
.pro-navigation>a,.pro-navigation .pro-nav-group>a,.pro-navigation .sf-drawer-extra>a{display:flex;align-items:center;min-height:50px;padding:13px 20px!important;font-size:15px!important;color:inherit!important;white-space:normal;border-bottom:1px solid var(--pro-border,#dde3e9)!important;text-decoration:none!important}
.pro-navigation .pro-nav-group{display:flex;position:relative;flex-wrap:wrap!important;width:100%;gap:0;align-items:center;justify-content:space-between}
.pro-navigation .pro-nav-group>a{flex:1;min-width:0}
.pro-navigation .pro-nav-disclose{margin-right:12px;min-width:44px;min-height:44px;border-radius:9px;background:var(--pro-surface,#f4f6f8)}
.pro-navigation .pro-nav-children{position:static!important;width:100%!important;max-width:100%!important;box-shadow:none!important;border-left:3px solid var(--pro-accent,#e77730);border-radius:0!important;margin:0 0 8px 14px!important;flex-basis:calc(100% - 14px)!important;background:var(--pro-surface,#f6f8fa)}
.pro-navigation .pro-nav-children a{padding:12px 16px!important;font-size:14px!important;color:inherit!important;min-height:44px}
.sf-drawer-extra{display:block;padding:12px 0 5px}.sf-drawer-extra strong{display:block;font-size:12px;letter-spacing:.06em;padding:12px 20px;opacity:.65}.sf-drawer-extra a{font-size:14px!important}
.sf-drawer-close:focus-visible,.pro-mobile-toggle:focus-visible,.pro-nav-disclose:focus-visible,.sf-mobile-tab:focus-visible{outline:3px solid var(--pro-accent,#ff8200);outline-offset:2px}
}
@media(max-width:767px){
.pro-header-cta{display:none!important}
.pro-head-inner .pro-logo{min-width:0;max-width:calc(100% - 65px)}
.pro-logo-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pro-home-hero .pro-home-grid{min-height:0!important}
.pro-home-hero .pro-hero-art{min-height:230px}
.pro-card,.pro-card-cover,.pro-product-media{max-width:100%;min-width:0}
.pro-rich img,.pro-content img,.sf-canvas img{max-width:100%;height:auto}
.pro-rich table{max-width:100%;overflow:auto}
.pro-grid,.pro-footer-top{min-width:0}
.sf-mobile-tabs{display:grid!important;position:fixed;z-index:1100;bottom:0;left:0;right:0;grid-template-columns:repeat(var(--sf-mobile-tab-count,4),minmax(0,1fr));align-items:stretch;padding:5px 6px calc(5px + env(safe-area-inset-bottom,0px));min-height:63px;background:var(--pro-background,#fff);color:var(--pro-text,#263446);border-top:1px solid var(--pro-border,#dfe4eb);box-shadow:0 -6px 24px #13223914}
.sf-mobile-tab{appearance:none;border:0!important;background:transparent!important;color:inherit!important;text-decoration:none!important;padding:6px 2px;min-height:50px;min-width:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:2px;border-radius:9px;cursor:pointer;font-family:inherit}
.sf-tab-icon{font-size:23px;line-height:1.15;font-weight:500}
.sf-tab-label{font-size:11px;font-weight:600;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sf-mobile-tab[aria-current=page]{color:var(--pro-accent,#e47730)!important;background:var(--pro-surface,#f5f6f8)!important}
body:has(> .sf-mobile-tabs){padding-bottom:calc(76px + env(safe-area-inset-bottom,0px))!important}
}
@media(min-width:768px){.sf-mobile-tabs{display:none!important}}
@media(prefers-reduced-motion:reduce){.pro-navigation,.sf-mobile-drawer-backdrop{transition:none!important}}
+39
View File
@@ -0,0 +1,39 @@
/* Safe, deterministic editor operations. Exported for Node regression tests. */
(function (root, factory) {
const api=factory();
if(typeof module==='object' && module.exports)module.exports=api;
root.SiteForgeEditorHelpers=api;
})(typeof window!=='undefined'?window:globalThis, function(){
'use strict';
const deepCopy=value=>JSON.parse(JSON.stringify(value));
function reorder(items, from, to){
if(!Array.isArray(items)||!Number.isInteger(from)||!Number.isInteger(to)||from<0||to<0||from>=items.length||to>=items.length)return null;
if(from===to)return items.slice();
const next=items.slice();next.splice(to,0,next.splice(from,1)[0]);return next;
}
function duplicate(block, idFactory){
if(!block || typeof idFactory!=='function')throw Error('无效的区块');
const item=deepCopy(block);
const rewrite=node=>{
node.id=idFactory();
if(node.type==='layout')for(const col of node.columns||[])for(const child of col.blocks||[])rewrite(child);
};
rewrite(item);return item;
}
function filterBlocks(blocks, term, names={}){
const query=String(term||'').trim().toLocaleLowerCase();
if(!query)return blocks.map((block,index)=>({block,index}));
return blocks.flatMap((block,index)=>{
const description=[block.title,block.subtitle,block.type,names[block.type]?.name]
.concat(block.type==='layout'?(block.columns||[]).flatMap(c=>(c.blocks||[]).map(b=>b.title||b.type)):[])
.filter(x=>typeof x==='string').join(' ').toLocaleLowerCase();
return description.includes(query)?[{block,index}]:[];
});
}
function historyState(snapshots,cursor){return {canUndo:cursor>0,canRedo:cursor>=0&&cursor<snapshots.length-1};}
function clampNumber(value,min,max,fallback){const n=Number(value);return Number.isFinite(n)?Math.max(min,Math.min(max,n)):fallback;}
function shouldAcceptPreview(requestedPage,requestedSeq,currentPage,currentSeq){
return requestedPage===currentPage&&requestedSeq===currentSeq;
}
return {reorder,duplicate,filterBlocks,historyState,clampNumber,shouldAcceptPreview};
});
+86
View File
@@ -2,3 +2,89 @@
@media(max-width:1200px){.studio-body{grid-template-columns:240px minmax(0,1fr) 290px}.preview-note{display:none}.studio-top{gap:8px}.file-name{max-width:180px}}@media(max-width:950px){.studio-body{grid-template-columns:210px minmax(0,1fr)}.right-pane{position:fixed;right:0;top:72px;bottom:0;width:310px;z-index:3;box-shadow:-8px 0 22px #0001}.studio-top .ui-btn.ghost{display:none}}@media(max-width:650px){.studio-body{grid-template-columns:1fr}.left-pane{display:none}.right-pane{display:none}.studio-top{padding:0 12px}.file-name{display:none}.save-status{display:none}} @media(max-width:1200px){.studio-body{grid-template-columns:240px minmax(0,1fr) 290px}.preview-note{display:none}.studio-top{gap:8px}.file-name{max-width:180px}}@media(max-width:950px){.studio-body{grid-template-columns:210px minmax(0,1fr)}.right-pane{position:fixed;right:0;top:72px;bottom:0;width:310px;z-index:3;box-shadow:-8px 0 22px #0001}.studio-top .ui-btn.ghost{display:none}}@media(max-width:650px){.studio-body{grid-template-columns:1fr}.left-pane{display:none}.right-pane{display:none}.studio-top{padding:0 12px}.file-name{display:none}.save-status{display:none}}
.layout-editor-col{border:1px solid #dfe5ed;border-radius:12px;padding:10px;margin:12px 0;background:#f8fafc}.layout-editor-title{display:flex;align-items:center;gap:7px;flex-wrap:wrap;justify-content:space-between}.layout-editor-title input{width:56px}.layout-editor-title button,.layout-child button{border:0;background:none;color:#54647a;cursor:pointer}.layout-child{background:white;border:1px solid #e5eaf3;border-radius:9px;margin:8px 0;padding:9px}.layout-child label{display:block;font-size:11px;color:#475569;margin:7px 0}.layout-child input,.layout-child select{display:block;width:100%;margin-top:3px;padding:7px;border:1px solid #e1e6ee;border-radius:6px}.layout-child-toolbar{display:flex;flex-wrap:wrap;gap:4px}.layout-child-toolbar button{background:#e8effa;border:0;padding:5px 7px;border-radius:6px;cursor:pointer;font-size:11px} .layout-editor-col{border:1px solid #dfe5ed;border-radius:12px;padding:10px;margin:12px 0;background:#f8fafc}.layout-editor-title{display:flex;align-items:center;gap:7px;flex-wrap:wrap;justify-content:space-between}.layout-editor-title input{width:56px}.layout-editor-title button,.layout-child button{border:0;background:none;color:#54647a;cursor:pointer}.layout-child{background:white;border:1px solid #e5eaf3;border-radius:9px;margin:8px 0;padding:9px}.layout-child label{display:block;font-size:11px;color:#475569;margin:7px 0}.layout-child input,.layout-child select{display:block;width:100%;margin-top:3px;padding:7px;border:1px solid #e1e6ee;border-radius:6px}.layout-child-toolbar{display:flex;flex-wrap:wrap;gap:4px}.layout-child-toolbar button{background:#e8effa;border:0;padding:5px 7px;border-radius:6px;cursor:pointer;font-size:11px}
/* V8.21.1 visual studio usability refresh — no saved blueprint schema changes. */
:root{--studio-accent:#ed742d;--studio-focus:#e48b42;--studio-border:#e6eaf0;--studio-shadow:0 20px 54px rgba(31,48,69,.08)}
.studio-top{box-shadow:0 1px 9px rgba(29,47,75,.035);padding-inline:18px;gap:9px}
.brand>a{background:#252e3c;border-radius:10px;color:#ff9959}
.top-center{margin:auto;background:#edf1f6}
.icon-btn.on{color:#b95722}
.ui-btn.primary{background:#dc6b2b;border-color:#dc6b2b}
.ui-btn:focus-visible,.icon-btn:focus-visible,.block-row:focus-visible,.palette button:focus-visible,.row-actions button:focus-visible,.inspector-nav button:focus-visible{outline:2px solid var(--studio-focus);outline-offset:2px}
.ui-btn:disabled,.row-actions button:disabled{opacity:.3;cursor:not-allowed}
.pane-switcher{display:flex;gap:3px}
.pane-switcher .ui-btn{padding:8px 10px}
.studio-body{grid-template-columns:minmax(270px,290px) minmax(0,1fr) minmax(320px,350px)}
.left-pane,.right-pane{background:#fff}
.pane-heading{padding:22px 18px 14px}
.pane-heading h3{font-size:16px;letter-spacing:0}
.section-label{display:flex;justify-content:space-between;align-items:center;padding:14px 19px 9px;color:#56677b;font-size:11px;letter-spacing:0}
.section-caption{font-size:10px;color:#a0abbb;font-weight:500}
.structure-filter{margin:4px 13px 10px;display:flex;align-items:center;border:1px solid #dfe6ee;background:#f7f9fc;border-radius:9px;overflow:hidden;min-height:38px}
.structure-filter:focus-within{border-color:var(--studio-focus);box-shadow:0 0 0 3px #f39a4923}
.structure-filter input{background:transparent;border:0;outline:0;min-width:0;flex:1;padding:9px 12px;font-size:12px;color:#25364e}
.structure-filter button{border:0;background:transparent;font-size:19px;color:#8796a7;width:34px}
.structure-hint{color:#8190a3;font-size:10px;padding:8px 19px 17px;line-height:1.7}
.block-list{padding:0 11px;min-height:60px}
.block-row{background:#fff;box-shadow:0 2px 10px #334b6207;padding:9px 8px;margin-bottom:6px;gap:7px;border-color:#e9edf2;transition:border-color .15s,background .15s}
.block-row:hover{border-color:#c4cedc;background:#fafbfd}
.block-row.selected{background:#fff7f1;border-color:#f19b59;box-shadow:0 0 0 1px #f8e3d5}
.block-row.is-hidden{opacity:.55}
.block-icon{width:29px;height:29px;color:#d67b35;background:#fff8f2;flex-shrink:0}
.block-copy strong{font-size:12px;color:#303e51}
.block-copy small{font-size:10px;color:#7e90a5}
.drag-handle{font-size:14px}
.row-actions{display:flex;gap:0;flex-shrink:0}
.row-actions button{font-size:12px;min-width:18px;padding:4px 2px;border-radius:5px}
.row-actions button:hover:not(:disabled){background:#fff0e4;color:#c96728}
.block-list .no-selection{padding:15px;margin:0 2px 10px}
.palette{grid-template-columns:repeat(2,minmax(0,1fr));padding-inline:13px}
.palette button{padding:10px 9px;border-style:solid}
.palette button:hover{border-color:#e8aa76;background:#fff8f3}
.palette button span{color:#d77936}
.preview-workspace{background:#eff2f6}
.preview-head{padding:0 16px;gap:9px}
.preview-note{max-width:260px;line-height:1.3;text-align:right}
.live-dot{background:#3aa688}
.live-dot.loading{background:#db983a;animation:studio-pulse 1.25s infinite}
.live-dot.error{background:#cb4e4e}
@keyframes studio-pulse{50%{opacity:.3;box-shadow:0 0 0 7px #efab4626}}
.stage{padding:22px 15px}
.device-frame{border-radius:8px;box-shadow:var(--studio-shadow);overflow:hidden;min-height:300px}
.device-frame.tablet,.device-frame.phone{max-width:calc(100% - 4px)}
iframe{min-height:590px;height:calc(100vh - 224px)}
.stage-footer{min-height:35px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 19px;font-size:10px;color:#8b9bad;border-top:1px solid #e1e7ee}
.inspector-nav{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;padding:4px;margin:0 16px 17px;border-radius:9px;background:#f1f4f8}
.inspector-nav button{background:none;border:0;border-radius:6px;padding:10px 5px;font-size:12px;color:#78869a;font-weight:650}
.inspector-nav button[aria-selected=true]{background:white;box-shadow:0 1px 5px #233d5f12;color:#b95c2b}
.inspector{padding:0 19px}
.inspector [data-inspector-section][hidden]{display:none!important}
.inspector-title{margin:0 0 16px}
.inspector-title h3{font-size:14px}
.right-foot{background:#faf8f5;border-color:#f0e6d7;margin-top:auto}
.studio .section-label,.studio .pane-heading h3{user-select:none}
.sr-only{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important}
@media(max-width:1400px){.studio-body{grid-template-columns:255px minmax(0,1fr) 310px}.studio-top{gap:6px}.studio-top>.ui-btn{padding:9px 10px}}
@media(max-width:1130px){.studio-top>.ui-btn.ghost:not(#studioUndo):not(#studioRedo){display:none}.studio-body{grid-template-columns:245px minmax(0,1fr) 295px}.file-name{max-width:150px}.pane-switcher{display:flex}}
@media(max-width:950px){.studio-body{grid-template-columns:230px minmax(0,1fr)}.right-pane{transform:translateX(105%);transition:transform .2s;display:flex!important;z-index:7;top:72px;width:min(340px,92vw)}body.show-inspector .right-pane{transform:translateX(0)}.preview-note{display:none}.studio-top>.ui-btn.ghost{display:none}}
@media(max-width:650px){.studio{min-height:480px}.studio-body{grid-template-columns:minmax(0,1fr)}.left-pane{display:flex!important;position:fixed;z-index:7;top:72px;bottom:0;left:0;width:min(315px,88vw);transform:translateX(-106%);transition:transform .2s;box-shadow:6px 0 20px #17283f12}body.show-structure .left-pane{transform:translateX(0)}.right-pane{display:flex!important}.studio-top{padding-inline:8px}.pane-switcher .ui-btn{padding:8px}.studio-top .brand{display:none}.top-center{margin-inline:auto}.studio-top .ui-btn.primary{padding:9px 10px}.preview-head{height:42px;min-height:42px;padding:0 9px}.stage{padding:10px 6px}.device-frame.phone,.device-frame.tablet{max-width:100%}.stage-footer{padding:8px 10px}.studio-top .save-status{display:none}}
@media(max-width:950px){.studio-top>#saveDraftButton.ui-btn.ghost{display:inline-flex!important;align-items:center;padding:9px 10px;color:#c5652d;background:#fff6ed;border-color:#eec49e}}
@media(max-width:430px){.studio-top #saveDraftButton{font-size:11px}.studio-top .ui-btn.primary{font-size:11px}.icon-btn{padding:6px 7px}}
.studio-actions{position:relative;flex-shrink:0}.studio-actions summary{display:flex;gap:6px;align-items:center;list-style:none;border:1px solid #e5eaf0;border-radius:8px;background:#fff;color:#394a60;padding:10px 13px;font-weight:650;font-size:12px;cursor:pointer;user-select:none}.studio-actions summary::-webkit-details-marker{display:none}.studio-actions[open] summary{border-color:#dbaa89;background:#fff8f1}.studio-actions-menu{position:absolute;right:0;top:calc(100% + 8px);display:grid;gap:5px;min-width:205px;z-index:12;padding:9px;border:1px solid #e2e7ed;border-radius:12px;background:#fff;box-shadow:0 15px 42px #283b5328}.studio-actions-menu .ui-btn{width:100%;text-align:left;border:0;padding:11px 12px;background:transparent}.studio-actions-menu .ui-btn:hover{background:#f5f7fa}.studio-actions-menu .ui-btn.ghost{display:block!important}@media(max-width:650px){.studio-actions summary{font-size:11px;padding:9px 7px}.studio-actions-menu{right:-15px;min-width:185px}}
/* V8.21.2 Easy Studio — task-first UX; no schema or API changes */
.sf-easy-top{height:70px;min-height:70px;padding:0 20px;gap:12px;z-index:15}
.sf-easy-top .brand{min-width:138px}.sf-easy-top .brand small{font-size:10px;color:#a1aab7}.sf-easy-file{display:flex;flex-direction:column;gap:3px;min-width:130px;max-width:210px}.sf-easy-file small{color:#8b98a9;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-easy-file strong{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-easy-device{margin-left:auto;margin-right:auto;flex:0 0 auto;display:flex}.sf-easy-device .icon-btn{font-size:12px;padding:8px 12px;font-weight:700}.sf-easy-save-status{padding:7px 10px;max-width:120px;overflow:hidden;text-overflow:ellipsis;background:#f3f6f9;border-radius:7px}.sf-easy-save-status.dirty{color:#b96d21;background:#fff5e8}.sf-save-btn{border-color:#e7a36d!important;color:#b65d23!important;background:#fffaf5!important;padding-inline:15px}.sf-publish-btn{padding-inline:19px;background:#df752f!important}.sf-easy-top .studio-actions{margin:0}.sf-easy-top .studio-actions summary{padding:10px 11px}.sf-easy-top .studio-actions-menu{min-width:195px;max-height:80vh;overflow-y:auto}.sf-easy-top .studio-actions-menu .ui-btn{font-size:12px}
.sf-steps{display:flex;align-items:center;gap:13px;padding:10px 22px;background:#fffaf6;border-bottom:1px solid #f2e3d7;color:#7d8290;min-height:45px;font-size:12px}.sf-steps-label{color:#b2683a;font-weight:800;flex-shrink:0}.sf-step{white-space:nowrap;font-weight:640;border-radius:8px;padding:5px 8px}.sf-step.active{background:#fce7d7;color:#b85b23}.sf-step-separator{color:#d6ad90}.sf-guide-link{margin-left:auto;border:0;background:none;color:#bc6529;font-weight:750;font-size:12px;white-space:nowrap;padding:5px 8px;text-decoration:underline;text-underline-offset:3px}
.studio-body{min-height:0;grid-template-columns:270px minmax(0,1fr) 320px}.left-pane,.right-pane{min-height:0}.left-pane .pane-heading{padding-bottom:10px}.left-pane .pane-heading h3,.right-pane .pane-heading h3{font-size:17px}.sf-page-picker{margin-bottom:10px!important}.sf-page-picker label{color:#5f7184;font-size:11px}.sf-page-picker select{font-size:12px}.sf-add-area{padding:2px 15px 15px}.sf-add-button{display:block;width:100%;border:1px solid #dc732f;background:#e47b38;color:#fff;border-radius:10px;padding:14px 12px;font-size:14px;font-weight:800;box-shadow:0 4px 11px #dc732f25;text-align:center;cursor:pointer}.sf-add-button:hover{background:#ce6427}.sf-add-area p{margin:9px 1px 0;color:#8a97a8;font-size:11px;line-height:1.6}.sf-library-panel{margin:0 13px 12px;border:1px solid #f2cfb4;background:#fffaf6;border-radius:12px;padding:9px 0;box-shadow:0 7px 15px #b55e1f12}.sf-library-panel[hidden]{display:none!important}.sf-panel-caption{display:flex;justify-content:space-between;gap:5px;align-items:center;padding:4px 12px 10px;font-size:11px;font-weight:700;color:#895b3c}.sf-panel-caption button{border:none;background:transparent;color:#b86327;font-size:11px}.sf-library-panel .palette{grid-template-columns:repeat(2,minmax(0,1fr));padding:2px 9px 12px;gap:7px}.sf-library-panel .palette button{min-height:42px;background:#fff;border-style:solid;font-size:11px}.sf-subsection{margin:0 9px 6px;padding-top:8px;border-top:1px solid #f2ddc7}.sf-subsection>summary{padding:7px 5px;cursor:pointer;color:#85624b;font-size:11px;font-weight:750}.sf-subsection .palette{padding:8px 0;grid-template-columns:1fr}.left-pane .section-label{padding-top:13px;font-size:11px;color:#56677b}.block-list{padding:0 12px;min-height:75px}.block-row{min-height:54px}.block-copy strong{font-size:12px}.row-actions button{min-width:19px}.sf-advanced{margin:6px 13px 18px;border:1px solid #e6eaf0;border-radius:10px;background:#fafbfc}.sf-advanced summary{display:flex;align-items:center;justify-content:space-between;gap:5px;padding:13px;cursor:pointer;font-weight:750;font-size:12px;color:#67758b}.sf-advanced summary::after{content:'⌄';font-size:15px;margin-left:auto;color:#99a7b9}.sf-advanced summary span{font-size:10px;font-weight:400;color:#9aa6b5}.sf-advanced[open]{background:#fff}.sf-advanced[open] summary{border-bottom:1px solid #edf0f4}.sf-advanced .field{margin:13px}.sf-advanced-buttons{display:grid;gap:7px;padding:0 13px 10px}.sf-advanced-buttons button{white-space:normal;text-align:left}.sf-advanced-note{font-size:10px;color:#9aa6b5;line-height:1.7;margin:4px 13px 13px}
.structure-hint{padding-bottom:12px}.preview-workspace{min-width:0}.preview-head{min-height:48px;height:48px}.preview-note{font-size:10px}.right-pane .pane-heading{padding-bottom:15px}.right-pane .overline{letter-spacing:.4px;color:#d67832;font-size:10px}.right-pane .inspector-nav{margin-top:0}.sf-advanced-foot{display:block;padding:0;margin:22px 15px;border:1px solid #eceff2;background:#fafbfc}.sf-advanced-foot summary{padding:13px 15px;cursor:pointer;font-size:11px;font-weight:750;color:#617084}.sf-advanced-foot p{margin:0 15px 14px}.right-pane .no-selection b{display:block;color:#53667d;font-size:14px}.right-pane .no-selection p{font-size:12px;line-height:1.7}.right-pane .no-selection .ui-btn{margin-top:8px}.right-pane .prop label{font-size:12px;color:#5c6a7f}.right-pane .prop input,.right-pane .prop textarea,.right-pane .prop select{font-size:13px;padding:11px 12px}.sf-help-dialog{border:1px solid #dfe4eb;border-radius:18px;width:min(535px,94vw);padding:26px;box-shadow:0 25px 100px #23324835}.sf-help-dialog::backdrop{background:#1c2c405a}.sf-help-head{display:flex;align-items:start;justify-content:space-between;gap:12px}.sf-help-head h2{margin:4px 0 15px;font-size:23px;color:#28364a}.sf-help-steps{padding-left:29px;line-height:1.65}.sf-help-steps li{margin:11px 0 17px;padding-left:5px;font-size:13px}.sf-help-steps b{font-size:15px;color:#c8682a}.sf-help-steps p{margin:5px 0;color:#6a7a8b}.sf-guide-note{border-radius:10px;padding:12px 15px;background:#f6f7f9;color:#718091;font-size:12px;line-height:1.6}.sf-guide-done{width:100%;margin-top:18px;font-size:14px}
@media(max-width:1330px){.studio-body{grid-template-columns:245px minmax(0,1fr) 305px}.sf-easy-top{gap:8px;padding:0 12px}.sf-easy-top .brand{min-width:100px}.sf-easy-file{max-width:148px}.sf-easy-device .icon-btn{padding:8px}}
@media(max-width:1060px){.studio-body{grid-template-columns:230px minmax(0,1fr)}.right-pane{position:fixed;right:0;top:115px;bottom:0;width:min(338px,92vw);display:flex!important;transform:translateX(106%);z-index:11;box-shadow:-10px 0 25px #1c2c4025}body.show-inspector .right-pane{transform:translateX(0)}.sf-easy-top .brand small,.sf-easy-save-status{display:none}.sf-easy-file{max-width:120px}.studio-top .ui-btn.ghost.sf-save-btn{display:inline-flex!important}.sf-steps{font-size:11px;gap:7px}}
@media(max-width:720px){.sf-steps{min-height:39px;padding:5px 10px;gap:5px}.sf-step-separator,.sf-step,.sf-steps-label{display:none}.sf-steps::before{content:'① 选内容 → ② 改文字与样式 → ③ 保存发布';font-weight:700;font-size:11px;color:#a4653b}.sf-guide-link{font-size:11px}.studio-body{grid-template-columns:minmax(0,1fr)}.left-pane{position:fixed;top:109px;bottom:0;left:0;width:min(315px,92vw);z-index:11;display:flex!important;transform:translateX(-106%);box-shadow:10px 0 25px #1c2c4020}body.show-structure .left-pane{transform:translateX(0)}.right-pane{top:109px}.sf-easy-top{height:70px;min-height:70px;padding-inline:8px}.sf-easy-top .brand,.sf-easy-file{display:none}.sf-easy-device{margin-left:0;margin-right:auto}.sf-easy-device .icon-btn{font-size:11px;padding:7px}.sf-save-btn,.sf-publish-btn{font-size:11px;padding:9px!important}.sf-easy-top .studio-actions summary{padding:9px 8px;font-size:11px}.sf-easy-top .studio-actions-menu{right:0}.pane-switcher{display:none}.stage{padding:9px 6px}.device-frame.phone,.device-frame.tablet{max-width:100%}}
@media(max-width:395px){.sf-easy-device .icon-btn{padding:6px;font-size:10px}.sf-guide-link{font-size:10px}.sf-save-btn{padding:8px 6px!important}.sf-publish-btn{padding:8px 6px!important}.sf-steps::before{content:'选区块 → 修改 → 保存发布';font-size:10px}}
/* Responsive panel toggle is always available on narrow screens. */
.sf-easy-top .sf-pane-controls{display:none;gap:5px}
@media(max-width:1060px){.sf-easy-top .sf-pane-controls{display:flex}.sf-easy-top .sf-pane-controls button{padding:7px 9px;font-size:11px;border:1px solid #e2e6ed;background:white;border-radius:8px}}
/* Accessible repeated-item forms for galleries, carousels, FAQs and features */
.sf-array-editor{display:grid;gap:10px}.sf-array-hint{font-size:11px;line-height:1.6;color:#8391a3;margin:0 0 4px}.sf-array-card{border:1px solid #e5eaf0;background:#fafbfd;padding:11px;border-radius:10px}.sf-array-card-head{display:flex;align-items:center;justify-content:space-between;gap:7px;margin-bottom:12px}.sf-array-card-head b{font-size:12px;color:#586a7f}.sf-array-card-head div{display:flex;gap:3px}.sf-array-card-head button{border:1px solid #e2e7ed;border-radius:5px;color:#6c7a8e;background:white;padding:4px 7px;font-size:11px}.sf-array-card-head button:last-child{color:#b95d62}.sf-array-card-head button:disabled{opacity:.3;cursor:not-allowed}.sf-array-field{display:block!important;margin:10px 0!important;font-size:11px!important;font-weight:650!important}.sf-array-field input,.sf-array-field textarea{display:block;width:100%;margin-top:5px;background:white}.sf-array-field textarea{min-height:65px}.sf-array-add{border:1px dashed #e5a77c;border-radius:9px;background:#fff8f2;color:#be672a;padding:13px 9px;font-size:12px;font-weight:800}
+144 -20
View File
@@ -8,31 +8,67 @@ const api=async(p,opt={})=>{if(!['GET','HEAD','OPTIONS'].includes((opt.method||'
const req=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const req=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const toast=(s,err=false)=>{const t=$('#toast');t.textContent=s;t.style.background=err?'#a23c44':'#1c3555';t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3400)}; const toast=(s,err=false)=>{const t=$('#toast');t.textContent=s;t.style.background=err?'#a23c44':'#1c3555';t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3400)};
const activePage=()=>pages.find(e=>e.id===pageId); const activePage=()=>pages.find(e=>e.id===pageId);
const editorTools=window.SiteForgeEditorHelpers;
let libraryOpen=false;
let inspectorTab='content',blockSearch='',previewRequest=0,previewController=null,savePromise=null,lastContinuousEdit=0;
let snapshots=[],snapshotCursor=-1,draggedChild=null,draftRevision=0,siteDesign=null,siteDesignRevision=0,siteDesignPreview=null; let snapshots=[],snapshotCursor=-1,draggedChild=null,draftRevision=0,siteDesign=null,siteDesignRevision=0,siteDesignPreview=null;
function recordSnapshot(){const current=JSON.stringify({blocks,kind,layoutMode});if(snapshotCursor>=0 && snapshots[snapshotCursor]===current)return;snapshots=snapshots.slice(0,snapshotCursor+1);snapshots.push(current);if(snapshots.length>75)snapshots.shift();snapshotCursor=snapshots.length-1} function recordSnapshot(){const current=JSON.stringify({blocks,kind,layoutMode});if(snapshotCursor>=0 && snapshots[snapshotCursor]===current)return;snapshots=snapshots.slice(0,snapshotCursor+1);snapshots.push(current);if(snapshots.length>75)snapshots.shift();snapshotCursor=snapshots.length-1}
function restoreSnapshot(index){if(index<0||index>=snapshots.length)return;snapshotCursor=index;const state=JSON.parse(snapshots[index]);blocks=state.blocks;kind=state.kind;layoutMode=state.layoutMode;selected=Math.min(selected,blocks.length-1);$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';Studio.draw();Studio.refreshPreview()} function restoreSnapshot(index){if(index<0||index>=snapshots.length)return;snapshotCursor=index;const state=JSON.parse(snapshots[index]);blocks=state.blocks;kind=state.kind;layoutMode=state.layoutMode;selected=Math.min(selected,blocks.length-1);$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');Studio.updateHistoryButtons();Studio.draw();Studio.refreshPreview()}
const Studio={ const Studio={
async init(){try{csrf=(await api('/api/auth/me')).csrf;site=await api(`/api/sites/${siteId}`);pages=await api(`/api/sites/${siteId}/entries`);schema=await api('/api/editor/blocks');$('#siteName').textContent=site.name;const select=$('#pageSelect');select.innerHTML=pages.map(e=>`<option value="${html(e.id)}">${html(e.title)} (${html(e.route)})</option>`).join('');for(let [k,v] of Object.entries(schema.page_templates))$('#pageTemplate').innerHTML+=`<option value="${k}">${html(v.name)}</option>`;Studio.renderPalette();await Studio.loadReusables();await Studio.choosePage(pageId||pages[0]?.id)}catch(e){toast('初始化失败:'+e.message,true);console.error(e)}}, async init(){try{csrf=(await api('/api/auth/me')).csrf;site=await api(`/api/sites/${siteId}`);pages=await api(`/api/sites/${siteId}/entries`);schema=await api('/api/editor/blocks');$('#siteName').textContent=site.name;const select=$('#pageSelect');select.innerHTML=pages.map(e=>`<option value="${html(e.id)}">${html(e.title)} (${html(e.route)})</option>`).join('');for(let [k,v] of Object.entries(schema.page_templates))$('#pageTemplate').innerHTML+=`<option value="${k}">${html(v.name)}</option>`;Studio.renderPalette();await Studio.loadReusables();await Studio.choosePage(pageId||pages[0]?.id)}catch(e){toast('初始化失败:'+e.message,true);console.error(e)}},
async choosePage(id){if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/studio-draft`);draftRevision=data.revision;blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;snapshots=[];snapshotCursor=-1;recordSnapshot();Studio.draw();Studio.refreshPreview()}, async choosePage(id){if(savePromise){toast('保存仍在进行,请稍后切换页面',true);$('#pageSelect').value=pageId;return}if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/studio-draft`);draftRevision=data.revision;blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;snapshots=[];snapshotCursor=-1;lastContinuousEdit=0;blockSearch='';$('#blockSearch').value='';recordSnapshot();Studio.updateHistoryButtons();$('#saveStatus').textContent=draftRevision?'草稿 v'+draftRevision:'未修改';$('#saveStatus').classList.remove('dirty');Studio.draw();Studio.refreshPreview()},
async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<button onclick="Studio.insertReusable('${html(x.id)}')">✦ ${html(x.name)} <small>v${x.version}</small></button>`).join('')||'<span style="font-size:12px;opacity:.55;padding:10px">暂无保存的组件</span>'}catch(e){console.warn(e)}}, async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<button onclick="Studio.insertReusable('${html(x.id)}')">✦ ${html(x.name)} <small>v${x.version}</small></button>`).join('')||'<span style="font-size:12px;opacity:.55;padding:10px">暂无保存的组件</span>'}catch(e){console.warn(e)}},
async saveReusable(){const block=blocks[selected];if(!block)return toast('请先选择一个区块',true);const name=prompt('为此组件命名',block.title||schema.blocks[block.type]?.name);if(!name?.trim())return;try{await api(`/api/sites/${siteId}/components`,req('POST',{name,block}));await Studio.loadReusables();toast('组件已保存到本站组件库')}catch(e){toast(e.message,true)}}, async saveReusable(){const block=blocks[selected];if(!block)return toast('请先选择一个区块',true);const name=prompt('为此组件命名',block.title||schema.blocks[block.type]?.name);if(!name?.trim())return;try{await api(`/api/sites/${siteId}/components`,req('POST',{name,block}));await Studio.loadReusables();toast('组件已保存到本站组件库')}catch(e){toast(e.message,true)}},
insertReusable(id){const saved=reusables.find(x=>x.id===id);if(!saved)return;const copy=structuredClone(saved.block);const refreshIds=item=>{item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);if(item.type==='layout')(item.columns||[]).forEach(c=>(c.blocks||[]).forEach(refreshIds))};refreshIds(copy);blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();}, insertReusable(id){const saved=reusables.find(x=>x.id===id);if(!saved)return;const copy=editorTools.duplicate(saved.block,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();},
renderPalette(){let p=$('#palette');p.innerHTML=Object.entries(schema.blocks).map(([key,b])=>`<button onclick="Studio.add('${key}')"><span>${html(b.icon)}</span>${html(b.name)}</button>`).join('')}, renderPalette(){let p=$('#palette');p.innerHTML=Object.entries(schema.blocks).map(([key,b])=>`<button onclick="Studio.add('${key}')"><span>${html(b.icon)}</span>${html(b.name)}</button>`).join('')},
draw(){let list=$('#blockList');$('#blockCount').textContent=blocks.length+' 区块';list.innerHTML=blocks.map((b,i)=>`<div class="block-row ${selected===i?'selected':''}" draggable="true" data-index="${i}" ondragstart="Studio.drag(event,${i})" ondragover="event.preventDefault()" ondrop="Studio.drop(event,${i})" onclick="Studio.select(${i})"><div class="drag-handle" title="拖动排序">⠿</div><div class="block-icon">${html(schema.blocks[b.type]?.icon||'▣')}</div><div class="block-copy"><strong>${html(schema.blocks[b.type]?.name||b.type)}</strong><small>${html(b.title||b.subtitle||b.type)}</small></div><div class="row-actions"><button title="复制" onclick="event.stopPropagation();Studio.duplicate(${i})">⧉</button><button title="删除" onclick="event.stopPropagation();Studio.remove(${i})">×</button></div></div>`).join('')||'<div class="no-selection">当前没有区块,点击下方按钮添加。</div>';Studio.inspector()}, toggleLibrary(force){libraryOpen=typeof force==='boolean'?force:!libraryOpen;const el=$('#sfLibraryPanel'),btn=$('#sfAddBlock');if(el)el.hidden=!libraryOpen;if(btn){btn.setAttribute('aria-expanded',String(libraryOpen));btn.textContent=libraryOpen?'× 收起区块库':'+ 添加内容区块'};Studio.updateGuidance()},
select(i){selected=i;Studio.draw()}, showHelp(){const dlg=$('#sfGuideDialog');if(dlg&&!dlg.open)dlg.showModal()},
add(type){let data=structuredClone(schema.blocks[type].defaults);data.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);data.type=type;data.visible=true;blocks.push(data);selected=blocks.length-1;Studio.modified();Studio.draw()}, updateGuidance(){const one=$('#sfStep1'),two=$('#sfStep2'),three=$('#sfStep3');if(!one||!two||!three)return;one.classList.toggle('active',selected<0||libraryOpen);two.classList.toggle('active',selected>=0&&!dirty&&!libraryOpen);three.classList.toggle('active',dirty);},
duplicate(i){let b=structuredClone(blocks[i]);b.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);blocks.splice(i+1,0,b);selected=i+1;Studio.modified();Studio.draw()}, draw(){
const list=$('#blockList');$('#blockCount').textContent=blocks.length+' 区块';
const found=editorTools.filterBlocks(blocks,blockSearch,schema.blocks);
list.innerHTML=found.map(({block:b,index:i})=>`<div class="block-row ${selected===i?'selected':''} ${b.visible===false?'is-hidden':''}" role="listitem" tabindex="0" draggable="true" data-index="${i}" ondragstart="Studio.drag(event,${i})" ondragend="this.classList.remove('dragging')" ondragover="event.preventDefault()" ondrop="Studio.drop(event,${i})" onkeydown="Studio.blockKeydown(event,${i})" onclick="Studio.select(${i})"><div class="drag-handle" title="拖动排序" aria-hidden="true">⠿</div><div class="block-icon">${html(schema.blocks[b.type]?.icon||'▣')}</div><div class="block-copy"><strong>${html(schema.blocks[b.type]?.name||b.type)}</strong><small>${html(b.title||b.subtitle||b.type)}</small></div><div class="row-actions"><button type="button" aria-label="向上移动区块" title="上移" ${i===0?'disabled':''} onclick="event.stopPropagation();Studio.moveBlock(${i},-1)">↑</button><button type="button" aria-label="向下移动区块" title="下移" ${i===blocks.length-1?'disabled':''} onclick="event.stopPropagation();Studio.moveBlock(${i},1)">↓</button><button type="button" title="${b.visible===false?'显示':'隐藏'}" aria-label="切换区块可见性" onclick="event.stopPropagation();Studio.toggleVisible(${i})">${b.visible===false?'◌':'◉'}</button><button type="button" title="复制" aria-label="复制区块" onclick="event.stopPropagation();Studio.duplicate(${i})">⧉</button><button type="button" title="删除" aria-label="删除区块" onclick="event.stopPropagation();Studio.remove(${i})">×</button></div></div>`).join('')||`<div class="no-selection">${blockSearch?'没有找到匹配区块,试试清除搜索。':'当前没有区块,点击下方按钮添加。'}</div>`;
Studio.inspector();Studio.updateGuidance();
},
filter(value){blockSearch=String(value||'').slice(0,100);Studio.draw()},
clearFilter(){blockSearch='';$('#blockSearch').value='';Studio.draw();$('#blockSearch').focus()},
moveBlock(i,offset){const target=i+offset;const next=editorTools.reorder(blocks,i,target);if(!next||target===i)return;blocks=next;selected=target;Studio.modified();Studio.draw();},
toggleVisible(i){if(!blocks[i])return;blocks[i].visible=blocks[i].visible===false;Studio.modified();Studio.draw()},
blockKeydown(event,i){if(event.target!==event.currentTarget)return;if(event.key==='Enter'||event.key===' '){event.preventDefault();Studio.select(i)}else if(event.altKey && (event.key==='ArrowUp'||event.key==='ArrowDown')){event.preventDefault();Studio.moveBlock(i,event.key==='ArrowUp'?-1:1)}},
changeInspectorTab(tab){if(!['content','style','responsive'].includes(tab))return;inspectorTab=tab;Studio.applyInspectorTab()},
applyInspectorTab(){for(const key of ['content','style','responsive']){const active=key===inspectorTab;const btn=$('#tab'+key.charAt(0).toUpperCase()+key.slice(1));btn?.setAttribute('aria-selected',String(active));const pane=$('[data-inspector-section="'+key+'"]');if(pane)pane.hidden=!active}},
togglePane(which){const type=which==='left'?'show-structure':'show-inspector';document.body.classList.toggle(type);if(type==='show-structure')document.body.classList.remove('show-inspector');else document.body.classList.remove('show-structure');},
updateHistoryButtons(){const state=editorTools.historyState(snapshots,snapshotCursor);$('#studioUndo').disabled=!state.canUndo;$('#studioRedo').disabled=!state.canRedo},
select(i){selected=i;Studio.draw();if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
add(type){let data=structuredClone(schema.blocks[type].defaults);data.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);data.type=type;data.visible=true;blocks.push(data);selected=blocks.length-1;Studio.modified();Studio.draw();Studio.toggleLibrary(false);if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
duplicate(i){const b=editorTools.duplicate(blocks[i],()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));blocks.splice(i+1,0,b);selected=i+1;Studio.modified();Studio.draw()},
remove(i){if(!confirm('确定删除这个区块?'))return;blocks.splice(i,1);selected=Math.min(i,blocks.length-1);Studio.modified();Studio.draw()}, remove(i){if(!confirm('确定删除这个区块?'))return;blocks.splice(i,1);selected=Math.min(i,blocks.length-1);Studio.modified();Studio.draw()},
drag(e,i){e.dataTransfer.setData('text/plain',String(i));e.dataTransfer.effectAllowed='move';e.currentTarget.classList.add('dragging')}, drag(e,i){e.dataTransfer.setData('text/plain',String(i));e.dataTransfer.effectAllowed='move';e.currentTarget.classList.add('dragging')},
drop(e,i){e.preventDefault();let src=Number(e.dataTransfer.getData('text/plain'));if(!Number.isInteger(src)||src<0||src>=blocks.length||src===i)return;let [b]=blocks.splice(src,1);blocks.splice(i,0,b);selected=i;Studio.modified();Studio.draw()}, drop(e,i){e.preventDefault();let src=Number(e.dataTransfer.getData('text/plain'));if(!Number.isInteger(src)||src<0||src>=blocks.length||src===i)return;const moved=editorTools.reorder(blocks,src,i);if(!moved)return;blocks=moved;selected=i;Studio.modified();Studio.draw()},
field(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();Studio.draw()}, field(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();Studio.draw()},
fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();const row=$('#blockList').children[selected];if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type}, fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified(true);const row=$('#blockList').querySelector(`[data-index="${selected}"]`);if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type},
arrayEditor(field,value,type){
const rows=Array.isArray(value)?value:[];
const labels={title:'标题',text:'文字介绍',value:'数字',label:'名称',question:'问题',answer:'回答',url:'图片地址',alt:'图片说明',image:'图片地址',caption:'图片说明',name:'名称',price:'价格',features:'方案包含的服务',href:'按钮跳转路径'};
const spec=schema.blocks[type];const sample=(spec?.defaults?.[field]||[])[0]||{url:'',alt:''};
const keys=Object.keys(sample);
return `<div class="sf-array-editor"><p class="sf-array-hint">无需填写 JSON:按顺序编辑每一项,修改会自动显示在预览中。</p>`+
rows.map((row,i)=>`<div class="sf-array-card"><div class="sf-array-card-head"><b>第 ${i+1} 项</b><div><button type="button" title="上移" ${i===0?'disabled':''} onclick="Studio.moveItem('${field}',${i},-1)">↑</button><button type="button" title="下移" ${i===rows.length-1?'disabled':''} onclick="Studio.moveItem('${field}',${i},1)">↓</button><button type="button" title="删除这一项" onclick="Studio.removeItem('${field}',${i})">删除</button></div></div>`+
keys.map(key=>`<label class="sf-array-field">${html(labels[key]||key)}${key==='text'||key==='answer'||key==='features'?`<textarea oninput="Studio.itemField('${field}',${i},'${key}',this.value)">${html(Array.isArray(row?.[key])?(row[key]||[]).join('\n'):row?.[key]??'')}</textarea>`:`<input type="text" value="${html(row?.[key]??'')}" oninput="Studio.itemField('${field}',${i},'${key}',this.value)">`}</label>`).join('')+
`</div>`).join('')+`<button type="button" class="sf-array-add" onclick="Studio.addItem('${field}')">+ 添加${field==='images'?'图片':'一项'}</button></div>`;
},
itemField(field,index,key,value){const block=blocks[selected],row=block?.[field]?.[index];if(!row)return;row[key]=key==='features'?String(value).split('\n').map(x=>x.trim()).filter(Boolean):String(value).slice(0,1500);Studio.modified(true)},
addItem(field){const block=blocks[selected];if(!block)return;const rows=block[field];if(!Array.isArray(rows))return;const limit=field==='images'?24:16;if(rows.length>=limit)return toast(`最多添加 ${limit} 项`,true);const sample=schema.blocks[block.type]?.defaults?.[field]?.[0]||{url:'',alt:''};const next={};for(const [key,val] of Object.entries(sample))next[key]=Array.isArray(val)?[]:'';rows.push(next);Studio.modified();Studio.inspector()},
removeItem(field,index){const rows=blocks[selected]?.[field];if(!Array.isArray(rows)||!rows[index])return;rows.splice(index,1);Studio.modified();Studio.inspector()},
moveItem(field,index,by){const rows=blocks[selected]?.[field],target=index+by;if(!Array.isArray(rows)||target<0||target>=rows.length)return;[rows[index],rows[target]]=[rows[target],rows[index]];Studio.modified();Studio.inspector()},
jsonField(key,value){try{let data=JSON.parse(value);if(!Array.isArray(data))throw Error('必须为数组');Studio.fieldQuiet(key,data);$('#jsonError').textContent='✓ JSON 格式有效';$('#jsonError').style.color='#238460'}catch(e){$('#jsonError').textContent='JSON 格式有误:'+e.message;$('#jsonError').style.color='#bf5a58'}}, jsonField(key,value){try{let data=JSON.parse(value);if(!Array.isArray(data))throw Error('必须为数组');Studio.fieldQuiet(key,data);$('#jsonError').textContent='✓ JSON 格式有效';$('#jsonError').style.color='#238460'}catch(e){$('#jsonError').textContent='JSON 格式有误:'+e.message;$('#jsonError').style.color='#bf5a58'}},
styleField(key,value){const b=blocks[selected];b.style={...(b.style||{}),[key]:value};Studio.modified()}, styleField(key,value){const b=blocks[selected];b.style={...(b.style||{}),[key]:value};Studio.modified()},
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection">请从左侧选择区块并编辑属性。</div>';return}const spec=schema.blocks[b.type];let out=`<div class="inspector-title"><h3>${html(spec.name)}</h3><label class="toggle"><input type="checkbox" ${b.visible!==false?'checked':''} onchange="Studio.field('visible',this.checked)">显示</label></div>`; inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection"><b>还没有选中内容</b><p>先到左侧选择一个区块,或点击“添加内容区块”。</p><button type="button" class="ui-btn primary" onclick="Studio.toggleLibrary(true)">+ 添加内容</button></div>';return}const spec=schema.blocks[b.type];let out=`<div class="inspector-title"><h3>${html(spec.name)}</h3><label class="toggle"><input type="checkbox" ${b.visible!==false?'checked':''} onchange="Studio.field('visible',this.checked)">显示</label></div><div data-inspector-section="content">`;
for(const field of spec.fields){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度'})[field]||field; for(const field of spec.fields){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度'})[field]||field;
out+=`<div class="prop"><label>${html(label)}</label>`; out+=`<div class="prop"><label>${html(label)}</label>`;
if(field==='items'||field==='images')out+=`<textarea spellcheck="false" onchange="Studio.jsonField('${field}',this.value)">${html(JSON.stringify(value,null,2))}</textarea><span id="jsonError" class="prop-tip">请输入 JSON 数组,可编辑多个项目</span>`; if(field==='items'||field==='images')out+=Studio.arrayEditor(field,value,b.type);
else if(field==='source')out+=`<select onchange="Studio.field('${field}',this.value)">${['product','post','page','vehicle','brand','case','service','other','all'].map(t=>`<option value="${t}" ${t===value?'selected':''}>${({product:'产品',post:'文章',page:'普通页面',vehicle:'车型',brand:'品牌',case:'案例',service:'服务',other:'其他',all:'全部类型'})[t]}</option>`).join('')}</select><span class="prop-tip">网站已有内容将自动绑定并生成卡片。</span>`; else if(field==='source')out+=`<select onchange="Studio.field('${field}',this.value)">${['product','post','page','vehicle','brand','case','service','other','all'].map(t=>`<option value="${t}" ${t===value?'selected':''}>${({product:'产品',post:'文章',page:'普通页面',vehicle:'车型',brand:'品牌',case:'案例',service:'服务',other:'其他',all:'全部类型'})[t]}</option>`).join('')}</select><span class="prop-tip">网站已有内容将自动绑定并生成卡片。</span>`;
else if(field==='image_side'||field==='align')out+=`<select onchange="Studio.field('${field}',this.value)">${(field==='align'?['left','center']:['left','right']).map(v=>`<option value="${v}" ${value===v?'selected':''}>${v==='left'?'左侧':v==='right'?'右侧':'居中'}</option>`).join('')}</select>`; else if(field==='image_side'||field==='align')out+=`<select onchange="Studio.field('${field}',this.value)">${(field==='align'?['left','center']:['left','right']).map(v=>`<option value="${v}" ${value===v?'selected':''}>${v==='left'?'左侧':v==='right'?'右侧':'居中'}</option>`).join('')}</select>`;
else if(field==='body'||field==='description')out+=`<textarea onchange="Studio.fieldQuiet('${field}',this.value)">${html(value)}</textarea>`; else if(field==='body'||field==='description')out+=`<textarea onchange="Studio.fieldQuiet('${field}',this.value)">${html(value)}</textarea>`;
@@ -41,6 +77,7 @@ const Studio={
if(['title','subtitle','description','image','body'].includes(field))out+='<small class="prop-tip">可输入 {{site.name}}、{{page.title}} 等变量。</small>'; if(['title','subtitle','description','image','body'].includes(field))out+='<small class="prop-tip">可输入 {{site.name}}、{{page.title}} 等变量。</small>';
out+='</div>'; out+='</div>';
} }
out+='</div><div data-inspector-section="responsive">';
if(b.type==='layout'){ if(b.type==='layout'){
out+=`<div class="subheading">列内组件 · 按设备排版</div><p class="prop-tip">分别调整平板/手机的列数、顺序、跨度与显隐。保存草稿后在设备预览切换查看效果。</p><div class="prop"><label>平板每行列数 <input type="number" min="1" max="3" value="${html(b.tablet_columns||2)}" onchange="Studio.setDeviceColumns('tablet',this.value)"></label><label>手机每行列数 <input type="number" min="1" max="2" value="${html(b.mobile_columns||1)}" onchange="Studio.setDeviceColumns('mobile',this.value)"></label></div>`; out+=`<div class="subheading">列内组件 · 按设备排版</div><p class="prop-tip">分别调整平板/手机的列数、顺序、跨度与显隐。保存草稿后在设备预览切换查看效果。</p><div class="prop"><label>平板每行列数 <input type="number" min="1" max="3" value="${html(b.tablet_columns||2)}" onchange="Studio.setDeviceColumns('tablet',this.value)"></label><label>手机每行列数 <input type="number" min="1" max="2" value="${html(b.mobile_columns||1)}" onchange="Studio.setDeviceColumns('mobile',this.value)"></label></div>`;
b.columns.forEach((column,ci)=>{ b.columns.forEach((column,ci)=>{
@@ -55,20 +92,72 @@ const Studio={
}); });
out+='<button class="ui-btn ghost" onclick="Studio.addColumn()">+ 添加列</button>'; out+='<button class="ui-btn ghost" onclick="Studio.addColumn()">+ 添加列</button>';
} }
out+=`<div class="subheading">区块设计(按设备覆盖)</div><div class="prop"><label>背景颜色</label><input type="color" value="${html(b.style?.background||'#ffffff')}" onchange="Studio.styleField('background',this.value)"></div><div class="prop"><label>上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.padding??82)}" onchange="Studio.styleField('padding',Number(this.value))"></div><div class="prop"><label>平板上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.tablet_padding??50)}" onchange="Studio.styleField('tablet_padding',Number(this.value))"></div><div class="prop"><label>手机上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.mobile_padding??28)}" onchange="Studio.styleField('mobile_padding',Number(this.value))"></div><div class="prop"><label><input type="checkbox" ${b.style?.hide_mobile?'checked':''} onchange="Studio.styleField('hide_mobile',this.checked)"> 手机上隐藏区块</label></div><div class="prop"><label>已上传图片</label><button class="ui-btn ghost" onclick="Studio.chooseMedia()">打开本站素材库</button></div><div class="prop"><label>上传区块图片</label><input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0])"></div>`; out+=`<div class="subheading">响应式样式</div><div class="prop"><label>平板上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.tablet_padding??50)}" onchange="Studio.styleField('tablet_padding',Number(this.value))"></div><div class="prop"><label>手机上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.mobile_padding??28)}" onchange="Studio.styleField('mobile_padding',Number(this.value))"></div><div class="prop"><label><input type="checkbox" ${b.style?.hide_mobile?'checked':''} onchange="Studio.styleField('hide_mobile',this.checked)"> 手机上隐藏区块</label></div>`;
out+=`</div><div data-inspector-section="style"><div class="subheading">外观设置</div><div class="prop"><label>背景颜色</label><input type="color" value="${html(b.style?.background||'#ffffff')}" onchange="Studio.styleField('background',this.value)"></div><div class="prop"><label>上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.padding??82)}" onchange="Studio.styleField('padding',Number(this.value))"></div><div class="prop"><label>从图库选择图片</label><button type="button" class="ui-btn ghost" onclick="Studio.chooseMedia()">打开本站素材库</button></div><div class="prop"><label>上传图片</label><input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0])"></div></div>`;
out+='<div style="display:flex;gap:8px;margin-bottom:22px"><button class="small-btn" onclick="Studio.duplicate('+selected+')">复制区块</button><button class="small-btn red-btn" onclick="Studio.remove('+selected+')">删除区块</button></div>'; out+='<div style="display:flex;gap:8px;margin-bottom:22px"><button class="small-btn" onclick="Studio.duplicate('+selected+')">复制区块</button><button class="small-btn red-btn" onclick="Studio.remove('+selected+')">删除区块</button></div>';
target.innerHTML=out; target.innerHTML=out;Studio.applyInspectorTab();
}, },
undo(){restoreSnapshot(snapshotCursor-1)}, undo(){restoreSnapshot(snapshotCursor-1)},
redo(){restoreSnapshot(snapshotCursor+1)}, redo(){restoreSnapshot(snapshotCursor+1)},
modified(){recordSnapshot();dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),500)}, modified(continuous=false){
async refreshPreview(){if(!pageId)return;try{let frame=$('#sitePreview');let doc=await api(`/api/sites/${siteId}/pages/${pageId}/editor-preview`,req('POST',{blocks,page_template:kind,layout_mode:layoutMode,site_design:siteDesignPreview}));frame.srcdoc=doc}catch(e){toast('预览失败:'+e.message,true)}}, const now=Date.now(),snap=JSON.stringify({blocks,kind,layoutMode});
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)}, if(continuous&&lastContinuousEdit&&now-lastContinuousEdit<850&&snapshotCursor>0&&snapshotCursor===snapshots.length-1){snapshots[snapshotCursor]=snap;}
else recordSnapshot();
lastContinuousEdit=continuous?now:0;
dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');
Studio.updateHistoryButtons();Studio.updateGuidance();clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),550);
},
async refreshPreview(){
if(!pageId)return;
const page=pageId,seq=++previewRequest;
if(previewController)previewController.abort();previewController=new AbortController();
const frame=$('#sitePreview');$('#previewState').textContent='正在更新预览…';$('#previewDot').classList.add('loading');
try{
const doc=await api(`/api/sites/${siteId}/pages/${page}/editor-preview`,{...req('POST',{blocks,page_template:kind,layout_mode:layoutMode,site_design:siteDesignPreview}),signal:previewController.signal});
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest))return;
frame.srcdoc=doc;$('#previewState').textContent='实时预览 · 已更新';$('#previewDot').classList.remove('loading','error');
}catch(e){
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest)||e.name==='AbortError')return;
$('#previewState').textContent='预览更新失败';$('#previewDot').classList.remove('loading');$('#previewDot').classList.add('error');toast('预览失败:'+e.message,true);
}
},
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);$('#deviceDetails').textContent=({desktop:'桌面端 · 自适应宽度',tablet:'平板端 · 780px',phone:'手机端 · 390px'})[type]||'桌面端';for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)},
changeTemplateKind(t){kind=t;Studio.modified()}, changeTemplateKind(t){kind=t;Studio.modified()},
setLayoutMode(v){layoutMode=v;Studio.modified()}, setLayoutMode(v){layoutMode=v;Studio.modified()},
async resetPreset(){if(!confirm('用当前主题的预设布局替换草稿?不会更改正式页面。'))return;try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/preset?page_template=${encodeURIComponent(kind)}`);blocks=result.blocks;selected=blocks.length?0:-1;Studio.modified();Studio.draw();toast('已加载预设到草稿,记得保存')}catch(e){toast(e.message,true)}}, async resetPreset(){if(!confirm('用当前主题的预设布局替换草稿?不会更改正式页面。'))return;try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/preset?page_template=${encodeURIComponent(kind)}`);blocks=result.blocks;selected=blocks.length?0:-1;Studio.modified();Studio.draw();toast('已加载预设到草稿,记得保存')}catch(e){toast(e.message,true)}},
async save(){try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft`,req('PUT',{blocks,page_template:kind,layout_mode:layoutMode,expected_revision:draftRevision}));draftRevision=result.revision;dirty=false;$('#saveStatus').textContent='草稿 v'+draftRevision;$('#saveStatus').classList.remove('dirty');toast('设计草稿已保存,正式构建不受影响')}catch(e){toast('草稿保存失败:'+e.message,true)}}, async showHistory(){
async publishDesign(){if(dirty){await Studio.save();if(dirty)return}if(!draftRevision)return toast('当前没有待发布草稿');if(!confirm('发布设计将更新内容库中的页面布局,下次静态构建才会生效。是否发布?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/publish`,req('POST',{expected_revision:draftRevision}));draftRevision=0;$('#saveStatus').textContent='已发布到内容库';toast('设计已发布;请构建网站生成新的正式静态文件')}catch(e){toast('发布失败:'+e.message,true)}}, if(!pageId)return toast('请先选择页面',true);
$('#studioHistoryModal').showModal();$('#studioHistoryItems').textContent='读取历史记录中…';
try{const data=await api(`/api/sites/${siteId}/pages/${pageId}/studio-history?limit=50`);
$('#studioHistoryItems').innerHTML=(data.items||[]).map(x=>`<div style="padding:12px 0;border-bottom:1px solid #e2e8f0;display:flex;gap:12px;align-items:center;justify-content:space-between"><div><strong>${x.kind==='published'?'已发布':'草稿'} · v${html(x.revision)}</strong><div style="font-size:12px;color:#64748b">${html(x.created_at)} · ${html(x.actor)} · SHA ${html(x.snapshot_hash.slice(0,12))}</div></div><button class="ui-btn ghost" type="button" onclick="Studio.restoreHistory('${html(x.id)}')">恢复为新草稿</button></div>`).join('')||'<p>当前页面尚无保存记录。</p>';
}catch(e){$('#studioHistoryItems').textContent='读取失败:'+e.message}
},
async restoreHistory(id){
if(dirty&&!confirm('当前有未保存的本地修改。恢复历史记录将舍弃这些本地修改,继续吗?'))return;
if(!confirm('确定以这个历史快照创建一个新的设计草稿?已发布的页面不会立即改变。'))return;
try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-history/${encodeURIComponent(id)}/restore`,req('POST',{expected_revision:draftRevision}));
dirty=false;$('#studioHistoryModal').close();await Studio.choosePage(pageId);
toast('历史版本已恢复为新草稿。核对后再点击发布设计。');
}catch(e){toast('历史恢复失败:'+e.message,true)}
},
async save(){
if(savePromise)return savePromise;
const page=pageId;const snapshot=JSON.stringify({blocks,kind,layoutMode});const frozen=JSON.parse(snapshot);
$('#saveStatus').textContent='保存中…';
savePromise=(async()=>{
try{
const result=await api(`/api/sites/${siteId}/pages/${page}/studio-draft`,req('PUT',{blocks:frozen.blocks,page_template:frozen.kind,layout_mode:frozen.layoutMode,expected_revision:draftRevision}));
if(page!==pageId)return;
draftRevision=result.revision;
const unchanged=snapshot===JSON.stringify({blocks,kind,layoutMode});
dirty=!unchanged;$('#saveStatus').textContent=unchanged?'草稿 v'+draftRevision:'有新修改待保存';
$('#saveStatus').classList.toggle('dirty',dirty);
toast(unchanged?'设计草稿已保存,正式构建不受影响':'草稿已保存,但有新修改,需再次保存');
}catch(e){dirty=true;$('#saveStatus').textContent='保存失败';$('#saveStatus').classList.add('dirty');toast('草稿保存失败:'+e.message,true)}
finally{savePromise=null;Studio.updateGuidance()}
})();return savePromise;
},
async publishDesign(){if(dirty){await Studio.save();if(dirty)return}if(!draftRevision)return toast('当前没有待发布草稿');if(!confirm('发布设计将更新内容库中的页面布局,下次静态构建才会生效。是否发布?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/publish`,req('POST',{expected_revision:draftRevision}));draftRevision=0;$('#saveStatus').textContent='已发布到内容库';Studio.updateGuidance();toast('设计已发布到内容库。要更新线上网站,请在“更多”中构建并部署。')}catch(e){toast('发布失败:'+e.message,true)}},
async discardDraft(){if(!draftRevision)return;if(!confirm('确定放弃草稿?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft`,{method:'DELETE'});dirty=false;await Studio.choosePage(pageId);toast('草稿已放弃')}catch(e){toast(e.message,true)}}, async discardDraft(){if(!draftRevision)return;if(!confirm('确定放弃草稿?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft`,{method:'DELETE'});dirty=false;await Studio.choosePage(pageId);toast('草稿已放弃')}catch(e){toast(e.message,true)}},
dragChild(event,col,index){draggedChild={container:selected,col,index};event.dataTransfer.setData('application/siteforge-child','1');event.dataTransfer.effectAllowed='move';event.stopPropagation()}, dragChild(event,col,index){draggedChild={container:selected,col,index};event.dataTransfer.setData('application/siteforge-child','1');event.dataTransfer.effectAllowed='move';event.stopPropagation()},
dropChild(event,destCol){if(!draggedChild)return;event.preventDefault();event.stopPropagation();const drag=draggedChild;draggedChild=null;const src=blocks[drag.container],dest=blocks[selected];if(!src||!dest||src.type!=='layout'||dest.type!=='layout')return;const moved=src.columns?.[drag.col]?.blocks.splice(drag.index,1)[0];if(!moved)return;dest.columns[destCol].blocks.push(moved);Studio.modified();Studio.draw()}, dropChild(event,destCol){if(!draggedChild)return;event.preventDefault();event.stopPropagation();const drag=draggedChild;draggedChild=null;const src=blocks[drag.container],dest=blocks[selected];if(!src||!dest||src.type!=='layout'||dest.type!=='layout')return;const moved=src.columns?.[drag.col]?.blocks.splice(drag.index,1)[0];if(!moved)return;dest.columns[destCol].blocks.push(moved);Studio.modified();Studio.draw()},
@@ -80,15 +169,36 @@ const Studio={
for(const [name,value] of Object.entries(f)){const key='footer_'+name,el=form.elements.namedItem(key);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}} for(const [name,value] of Object.entries(f)){const key='footer_'+name,el=form.elements.namedItem(key);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}}
for(const [name,value] of Object.entries(r)){const el=form.elements.namedItem(name);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}} for(const [name,value] of Object.entries(r)){const el=form.elements.namedItem(name);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}}
form.elements.namedItem('nav').value=JSON.stringify(siteDesign.nav,null,2); form.elements.namedItem('nav').value=JSON.stringify(siteDesign.nav,null,2);
form.elements.namedItem('mobile_tabs_enabled').checked=Boolean(siteDesign.mobile_tabs?.enabled);
Studio.drawMobileTabs(siteDesign.mobile_tabs?.items||[]);
Studio.toggleMobileTabsForm();
$('#siteDesignStatus').textContent=result.state==='draft'?`待发布的全站草稿 v${siteDesignRevision}${result.conflict?' · 网站已被其他人修改':''}`:'当前为已发布设计'; $('#siteDesignStatus').textContent=result.state==='draft'?`待发布的全站草稿 v${siteDesignRevision}${result.conflict?' · 网站已被其他人修改':''}`:'当前为已发布设计';
$('#siteDesignModal').showModal(); $('#siteDesignModal').showModal();
}catch(e){toast('全站设计读取失败:'+e.message,true)}}, }catch(e){toast('全站设计读取失败:'+e.message,true)}},
drawMobileTabs(items){const root=$('#mobileTabsRows');root.replaceChildren();items.forEach((item,i)=>Studio.addMobileTab(item,i));},
toggleMobileTabsForm(){const on=$('#siteDesignForm').elements.namedItem('mobile_tabs_enabled').checked;$('#mobileTabsEditor').hidden=!on;},
addMobileTab(item={title:'新导航',icon:'grid',route:'/',action:'link'},index){
const root=$('#mobileTabsRows');if(root.children.length>=5){toast('底部最多支持 5 个 Tab',true);return}
const n=root.children.length;
const row=document.createElement('div');row.className='mobile-tab-row';row.style.cssText='display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border:1px solid #dce3ed;border-radius:10px;background:#fafcff';
const label=document.createElement('label');label.textContent='按钮名称';const title=document.createElement('input');title.className='mt-title';title.maxLength=12;title.value=item.title||'';label.append(title);
const iconLabel=document.createElement('label');iconLabel.textContent='按钮图标';const icon=document.createElement('select');icon.className='mt-icon';
for(const [v,t] of Object.entries({home:'⌂ 首页',grid:'▦ 栏目',products:'▤ 产品',news:'☷ 文章',contact:'✉ 联系',phone:'☎ 电话',info:'ⓘ 关于',search:'⌕ 搜索',menu:'☰ 菜单',star:'☆ 收藏'})){const opt=document.createElement('option');opt.value=v;opt.textContent=t;icon.append(opt)}icon.value=item.icon||'grid';iconLabel.append(icon);
const actionLabel=document.createElement('label');actionLabel.textContent='点击操作';const action=document.createElement('select');action.className='mt-action';for(const [v,t] of [['link','打开站内页面'],['menu','打开导航抽屉']]){const o=document.createElement('option');o.value=v;o.textContent=t;action.append(o)}action.value=item.action||'link';actionLabel.append(action);
const routeLabel=document.createElement('label');routeLabel.textContent='链接地址';const route=document.createElement('input');route.className='mt-route';route.placeholder='/products/、https://wa.me/... 或 tel:+...';route.value=item.route||'/';routeLabel.append(route);
function updateRoute(){routeLabel.hidden=action.value==='menu'}action.addEventListener('change',updateRoute);updateRoute();
const tools=document.createElement('div');tools.style.cssText='grid-column:1/-1;display:flex;gap:7px;justify-content:flex-end';
for(const [name,fn] of [['↑ 上移',()=>{const prev=row.previousElementSibling;if(prev)root.insertBefore(row,prev)}],['↓ 下移',()=>{const next=row.nextElementSibling;if(next)root.insertBefore(next,row)}],['删除',()=>row.remove()]]){const b=document.createElement('button');b.type='button';b.className='ui-btn ghost';b.textContent=name;b.addEventListener('click',fn);tools.append(b)}
row.append(label,iconLabel,actionLabel,routeLabel,tools);root.append(row);
},
collectSiteDesign(){const form=$('#siteDesignForm');if(!siteDesign)throw Error('请先打开全站设计'); collectSiteDesign(){const form=$('#siteDesignForm');if(!siteDesign)throw Error('请先打开全站设计');
const v=(n)=>form.elements.namedItem(n).value; const v=(n)=>form.elements.namedItem(n).value;
const yes=(n)=>form.elements.namedItem(n).checked; const yes=(n)=>form.elements.namedItem(n).checked;
let nav;try{nav=JSON.parse(v('nav'))}catch(e){throw Error('导航必须是合法 JSON:'+e.message)} let nav;try{nav=JSON.parse(v('nav'))}catch(e){throw Error('导航必须是合法 JSON:'+e.message)}
if(!Array.isArray(nav))throw Error('导航必须是数组'); if(!Array.isArray(nav))throw Error('导航必须是数组');
return {header:{announcement:v('announcement'),show_announcement:yes('show_announcement'),show_search:yes('show_search'), const tabs=[...$('#mobileTabsRows').children].map(row=>({title:row.querySelector('.mt-title').value.trim(),icon:row.querySelector('.mt-icon').value,route:row.querySelector('.mt-route').value.trim(),action:row.querySelector('.mt-action').value}));
if(yes('mobile_tabs_enabled')&&!tabs.length)throw Error('开启底部 Tabs 后,请至少添加一个按钮');
return {tokens:structuredClone(siteDesign.tokens),mobile_tabs:{enabled:yes('mobile_tabs_enabled'),items:tabs},header:{announcement:v('announcement'),show_announcement:yes('show_announcement'),show_search:yes('show_search'),
show_cta:yes('show_cta'),cta_text:v('cta_text'),cta_href:v('cta_href')}, show_cta:yes('show_cta'),cta_text:v('cta_text'),cta_href:v('cta_href')},
footer:{title:v('footer_title'),description:v('footer_description'),email:v('footer_email'),show_nav:yes('footer_show_nav')},nav, footer:{title:v('footer_title'),description:v('footer_description'),email:v('footer_email'),show_nav:yes('footer_show_nav')},nav,
responsive:{tablet_gap:Number(v('tablet_gap')),mobile_gap:Number(v('mobile_gap')), responsive:{tablet_gap:Number(v('tablet_gap')),mobile_gap:Number(v('mobile_gap')),
@@ -143,6 +253,12 @@ window.addEventListener('message',event=>{
const frame=document.querySelector('#sitePreview'); const frame=document.querySelector('#sitePreview');
if(!frame||event.source!==frame.contentWindow)return; if(!frame||event.source!==frame.contentWindow)return;
const data=event.data||{}; const data=event.data||{};
if(data.source==='siteforge-select-block'){
if(typeof data.blockId!=='string'||data.blockId.length>150)return;
const findTop=rows=>{for(let i=0;i<rows.length;i++){const row=rows[i];if(row.id===data.blockId)return i;if(row.type==='layout'&&row.columns?.some(col=>col.blocks?.some(child=>child.id===data.blockId)))return i}return -1};
const index=findTop(blocks);if(index>=0){selected=index;Studio.draw();if(window.matchMedia('(max-width:1060px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}}
return;
}
if(data.source!=='siteforge-inline-editor')return; if(data.source!=='siteforge-inline-editor')return;
const findChild=rows=>{for(const row of rows){if(row.id===data.blockId)return {block:row,parent:row};if(row.type==='layout'){for(const col of row.columns||[]){const child=findChild(col.blocks||[]);if(child)return {block:child.block,parent:row};}}}return null}; const findChild=rows=>{for(const row of rows){if(row.id===data.blockId)return {block:row,parent:row};if(row.type==='layout'){for(const col of row.columns||[]){const child=findChild(col.blocks||[]);if(child)return {block:child.block,parent:row};}}}return null};
const found=findChild(blocks),b=found?.block; const found=findChild(blocks),b=found?.block;
@@ -152,4 +268,12 @@ window.addEventListener('message',event=>{
toast('已修改文字,点击保存页面写入数据库'); toast('已修改文字,点击保存页面写入数据库');
}); });
window.addEventListener('keydown',event=>{if(!(event.ctrlKey||event.metaKey)||event.altKey)return;const el=document.activeElement;if(el&&['INPUT','TEXTAREA'].includes(el.tagName))return;if(event.key.toLowerCase()==='z'){event.preventDefault();event.shiftKey?Studio.redo():Studio.undo()}else if(event.key.toLowerCase()==='y'){event.preventDefault();Studio.redo()}}); window.addEventListener('keydown',event=>{
if(!(event.ctrlKey||event.metaKey)||event.altKey)return;
const key=event.key.toLowerCase();
if(key==='s'){event.preventDefault();Studio.save();return}
const el=document.activeElement;
if(el&&(['INPUT','TEXTAREA'].includes(el.tagName)||el.isContentEditable))return;
if(key==='z'){event.preventDefault();event.shiftKey?Studio.redo():Studio.undo()}
else if(key==='y'){event.preventDefault();Studio.redo()}
});
+18 -5
View File
@@ -43,11 +43,24 @@ async function readData(){let f=$('#dataFile').files?.[0];if(!f)return toast('
async function previewRelations(){try{const r=await api(factoryUrl('/relationships'));$('#relationReport').innerHTML=`<div class="card"><b>产品-品牌关联检查</b><p>已有品牌 ${r.brands.length} 个、分类 ${r.categories.length} 个;缺少独立品牌页 ${r.brand_pages_missing.length} 个</p><p>${H(r.brand_pages_missing.map(x=>x.brand).join(' / ')||'无')}</p>${r.issues.slice(0,10).map(x=>`<p>${H(x.route)} · ${H(x.message)}</p>`).join('')}</div>`;}catch(e){toast(e.message,true)}} async function previewRelations(){try{const r=await api(factoryUrl('/relationships'));$('#relationReport').innerHTML=`<div class="card"><b>产品-品牌关联检查</b><p>已有品牌 ${r.brands.length} 个、分类 ${r.categories.length} 个;缺少独立品牌页 ${r.brand_pages_missing.length} 个</p><p>${H(r.brand_pages_missing.map(x=>x.brand).join(' / ')||'无')}</p>${r.issues.slice(0,10).map(x=>`<p>${H(x.route)} · ${H(x.message)}</p>`).join('')}</div>`;}catch(e){toast(e.message,true)}}
async function assembleRelations(){if(!confirm('依据真实产品品牌字段创建缺少的品牌页面?不会自动生成虚构文案。'))return;try{const r=await api(factoryUrl('/relationships/assemble'),'POST',{});toast(`已创建 ${r.count} 个品牌页面`);await previewRelations()}catch(e){toast(e.message,true)}} async function assembleRelations(){if(!confirm('依据真实产品品牌字段创建缺少的品牌页面?不会自动生成虚构文案。'))return;try{const r=await api(factoryUrl('/relationships/assemble'),'POST',{});toast(`已创建 ${r.count} 个品牌页面`);await previewRelations()}catch(e){toast(e.message,true)}}
function drawMapping(){let fields=['title','route','summary','body','cover','brand','category','model','energy','range','gallery','specs','sku','price','seo_title','seo_description','canonical'];let labels={title:'标题(必填)',route:'旧站 URL',summary:'摘要',body:'详情正文',cover:'封面',brand:'品牌',category:'分类',seo_title:'SEO标题',seo_description:'SEO描述',canonical:'Canonical',model:'型号',energy:'动力形式',range:'续航',gallery:'图库(JSON数组或分号分隔)',specs:'技术参数',sku:'SKU',price:'价格'}; function drawMapping(){
$('#mappingUI').innerHTML=`<div style="margin-top:27px"><h2>字段映射(已读取 ${state.importRows.length} 行)</h2>${fields.map(t=>`<div class="rule"><strong>${H(labels[t])}</strong><select data-target="${t}" class="mapfield"><option value="">不映射</option>${state.fields.map(x=>`<option value="${H(x)}" ${state.mapping[t]===x?'selected':''}>${H(x)}</option>`).join('')}</select></div>`).join('')}<button class="outline" onclick="mappingPreview()">预览数据和错误</button><button class="primary" onclick="mappingCommit()">确认导入</button><div id="mappingReport" style="margin-top:16px"></div></div>`;} const fields=['title','route','summary','body','cover','brand','category','model','year','energy','sku','seo_title','seo_description'];
function gather(){let rules={};$$('.mapfield').forEach(x=>{if(x.value)rules[x.dataset.target]=x.value});return {rows:state.importRows,rules,content_type:$('#mapKind').value}} const labels={title:'标题',route:'URL 原路径',summary:'摘要',body:'正文',cover:'封面',brand:'品牌',category:'分类',model:'型号',year:'年份',energy:'动力类型',sku:'SKU',seo_title:'SEO 标题',seo_description:'SEO 描述'};
async function mappingPreview(){try{let r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:true});$('#mappingReport').innerHTML=`<div class="card"><b>预览:${r.mapped} 条可映射</b><p>错误 ${r.errors.length} 条,URL 冲突 ${r.conflicts.length} 条</p><div class="scroll"><table><thead><tr><th>标题</th><th>新 URL</th><th>内容类型</th></tr></thead><tbody>${r.sample.map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.route)}</td><td>${H(x.type)}</td></tr>`).join('')}</tbody></table></div>${r.errors.length?'<p class="error">'+H(JSON.stringify(r.errors.slice(0,6)))+'</p>':''}</div>`}catch(e){toast(e.message,true)}} state.mappingPlanHash=null;
async function mappingCommit(){if(!confirm('确认将已映射的数据写入当前网站?'))return;try{let r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:false,on_conflict:'skip'});toast(`新增 ${r.created} 条,跳过 ${r.skipped} 条,错误 ${r.errors.length} 条`);await render()}catch(e){toast(e.message,true)}} $('#mappingUI').innerHTML=`<div style="margin-top:27px"><h2>字段映射(已读取 ${state.importRows.length} 行)</h2><p class="muted">可以为每个字段选择大小写变换;所有转换都由固定规则执行,不运行代码。</p>${fields.map(t=>`<div class="rule"><strong>${H(labels[t])}</strong><select data-target="${t}" class="mapfield" onchange="state.mappingPlanHash=null"><option value="">不映射</option>${state.fields.map(x=>`<option value="${H(x)}" ${state.mapping[t]===x?'selected':''}>${H(x)}</option>`).join('')}</select><select data-transform="${t}" class="mapcase" aria-label="${H(labels[t])}的大小写转换" onchange="state.mappingPlanHash=null"><option value="none">原样</option><option value="upper">英文大写</option><option value="lower">英文小写</option></select></div>`).join('')}<div class="field"><label>URL 冲突处理<select id="mapConflict" onchange="state.mappingPlanHash=null"><option value="abort">遇到冲突立即阻断(推荐)</option><option value="skip">跳过已有 URL</option><option value="replace">覆盖已有 URL(谨慎)</option></select></label></div><button class="outline" onclick="mappingPreview()">预览数据和错误</button><button class="primary" onclick="mappingCommit()">按本次预检确认导入</button><div id="mappingReport" style="margin-top:16px"></div></div>`;
}
function gather(){let rules={};$$('.mapfield').forEach(x=>{if(x.value){const mode=document.querySelector(`[data-transform="${x.dataset.target}"]`)?.value||'none';rules[x.dataset.target]=mode==='none'?x.value:{source:x.value,case:mode}}});return {rows:state.importRows,rules,content_type:$('#mapKind').value}}
async function mappingPreview(){try{const payload=gather(),r=await api(factoryUrl('/mapping/run'),'POST',{...payload,dry_run:true});state.mappingPlanHash=r.plan_hash;$('#mappingReport').innerHTML=`<div class="card"><b>预览:${r.mapped} 条可映射</b><p>错误 ${r.errors.length} 条,URL 冲突 ${r.conflicts.length} 条</p><p>预检指纹:${H(r.plan_hash.slice(0,16))}…(提交时验证内容未变化)</p><div class="scroll"><table><thead><tr><th>标题</th><th>新 URL</th><th>内容类型</th></tr></thead><tbody>${r.sample.map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.route)}</td><td>${H(x.type)}</td></tr>`).join('')}</tbody></table></div>${r.errors.length?'<p class="error">'+H(JSON.stringify(r.errors.slice(0,6)))+'</p>':''}</div>`}catch(e){state.mappingPlanHash=null;toast(e.message,true)}}
async function mappingCommit(){
if(!state.mappingPlanHash)return toast('请先执行映射预览,确认 URL 和冲突后再导入',true);
const policy=$('#mapConflict').value;
if(policy==='replace'&&!confirm('覆盖模式将修改已有同 URL 内容。请确认已备份并核对预览。'))return;
if(!confirm('将当前预检结果写入当前网站?'))return;
try{const r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:false,on_conflict:policy,expected_plan_hash:state.mappingPlanHash});state.mappingPlanHash=null;toast(`新增 ${r.created} 条,更新 ${r.updated} 条,跳过 ${r.skipped} 条,错误 ${r.errors.length} 条`);await render()}
catch(e){state.mappingPlanHash=null;toast('导入失败,请重新预检:'+e.message,true)}
}
async function migration(){let [data,assets,builds]=await Promise.all([api(factoryUrl('/migration/readiness')),api(factoryUrl('/assets/inventory')),api(siteUrl('/builds'))]); async function migration(){let [data,assets,builds]=await Promise.all([api(factoryUrl('/migration/readiness')),api(factoryUrl('/assets/inventory')),api(siteUrl('/builds'))]);
let audit=builds.length?await api(`/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report`).catch(()=>null):null;const gate=data.gates||[];$('#panel').innerHTML=`<div class="cards"><div class="card"><span class="overline">旧站 URL 基线</span><strong>${data.baseline.baseline_count}</strong><small>应保持有效的历史路径</small></div><div class="card"><span class="overline">缺失 URL</span><strong>${data.baseline.errors}</strong><small>发布阻断项</small></div><div class="card"><span class="overline">SEO 差异</span><strong>${data.baseline.warnings}</strong><small>需要人工核查</small></div><div class="card"><span class="overline">资源引用</span><strong>${assets.references}</strong><small>实际已识别的内容图片</small></div></div><div class="columns"><div class="section"><h2>迁移验收门禁</h2>${gate.length?gate.map(g=>`<div class="row"><div><span class="badge ${g.level==='block'?'danger':'warn'}">${g.level==='block'?'阻止发布':'需人工复核'}</span><p>${H(g.message)}</p></div></div>`).join(''):'<p class="success">当前已检测范围内未发现阻断项。</p>'}<div class="toolbar"><label>上传原站清单(WXR / Sitemap XML / CSV / JSON)<input type="file" id="baselineFile" accept=".xml,.csv,.json"></label><button class="primary" onclick="uploadBaseline()">导入原站 URL 基线</button></div><div class="toolbar"><button class="outline" onclick="snapshot()">用当前内容建立 URL 基线(仅限新站)</button><a class="outline" href="${factoryUrl('/assets/inventory')}" target="_blank">资源清单 JSON</a><button class="outline" onclick="openLatestAudit()">构建后查看完整差异报告</button></div><div id="baselineImportResult"></div><p>提示:完整旧站 URL 基线应来自迁移前的原网站清单,不要用迁移后不完整的网站替代。</p></div><div class="section"><h2>实际生成 HTML 与旧站对比</h2>${audit?`<p>旧站 ${audit.baseline_count} 条 / 已匹配 ${audit.matched_pages||0} 条;阻断 ${audit.errors} 项;待复核 ${audit.unreviewed_warnings||0} 项</p><a class="outline" href="/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report.csv">下载差异 CSV</a><div class="scroll"><table><thead><tr><th>问题</th><th>路径</th><th>操作</th></tr></thead><tbody>${audit.issues.slice(0,40).map(i=>`<tr><td>${H(i.field)} · ${H(i.message)}</td><td>${H(i.route)}</td><td>${i.severity==='warning'&&!i.reviewed?`<button class="outline" onclick="approveAuditWarning('${H(builds[0].id)}','${H(i.issue_id)}')">复核接受</button>`:i.reviewed?'已复核':'必须修复'}</td></tr>`).join('')}</tbody></table></div>`:'<p>请先构建新站,再与旧站基线对比。</p>'}</div><div class="section"><h2>前 12 项差异</h2>${data.baseline.issues.slice(0,12).map(x=>`<div class="row"><div><strong>${H(x.route)}</strong><small>${H(x.field)} · ${H(x.severity)}</small></div></div>`).join('')||'<p>没有基线差异。</p>'}</div></div>`;} let audit=builds.length?await api(`/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report`).catch(()=>null):null;const gate=data.gates||[];$('#panel').innerHTML=`<div class="cards"><div class="card"><span class="overline">旧站 URL 基线</span><strong>${data.baseline.baseline_count}</strong><small>应保持有效的历史路径</small></div><div class="card"><span class="overline">缺失 URL</span><strong>${data.baseline.errors}</strong><small>发布阻断项</small></div><div class="card"><span class="overline">SEO 差异</span><strong>${data.baseline.warnings}</strong><small>需要人工核查</small></div><div class="card"><span class="overline">资源引用</span><strong>${assets.references}</strong><small>实际已识别的内容图片</small></div></div><div class="columns"><div class="section"><h2>迁移验收门禁</h2>${gate.length?gate.map(g=>`<div class="row"><div><span class="badge ${g.level==='block'?'danger':'warn'}">${g.level==='block'?'阻止发布':'需人工复核'}</span><p>${H(g.message)}</p></div></div>`).join(''):'<p class="success">当前已检测范围内未发现阻断项。</p>'}<div class="toolbar"><label>上传原站清单(WXR / Sitemap XML / CSV / JSON)<input type="file" id="baselineFile" accept=".xml,.csv,.json"></label><button class="primary" onclick="uploadBaseline()">导入原站 URL 基线</button></div><div class="toolbar"><button class="outline" onclick="snapshot()">用当前内容建立 URL 基线(仅限新站)</button><a class="outline" href="${factoryUrl('/assets/inventory')}" target="_blank">资源清单 JSON</a><button class="outline" onclick="openLatestAudit()">构建后查看完整差异报告</button></div><div id="baselineImportResult"></div><p>提示:完整旧站 URL 基线应来自迁移前的原网站清单,不要用迁移后不完整的网站替代。</p></div><div class="section"><h2>实际生成 HTML 与旧站对比</h2>${audit?`<p>旧站 ${audit.baseline_count} 条 / 已匹配 ${audit.matched_pages||0} 条;阻断 ${audit.errors} 项;待复核 ${audit.unreviewed_warnings||0} 项</p><a class="outline" href="/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report.csv">下载差异 CSV</a><div class="scroll"><table><thead><tr><th>问题</th><th>路径</th><th>操作</th></tr></thead><tbody>${audit.issues.slice(0,40).map(i=>`<tr><td>${H(i.field)} · ${H(i.message)}</td><td>${H(i.route)}</td><td>${i.severity==='warning'&&!i.reviewed?`<button class="outline" onclick="approveAuditWarning('${H(builds[0].id)}','${H(i.issue_id)}')">复核接受</button>`:i.reviewed?'已复核':'必须修复'}</td></tr>`).join('')}</tbody></table></div>`:'<p>请先构建新站,再与旧站基线对比。</p>'}</div><div class="section"><h2>前 12 项差异</h2>${data.baseline.issues.slice(0,12).map(x=>`<div class="row"><div><strong>${H(x.route)}</strong><small>${H(x.field)} · ${H(x.severity)}</small></div></div>`).join('')||'<p>没有基线差异。</p>'}</div></div>`;}
async function saveContactSettings(){try{await api(siteUrl('/contact-settings'),'PUT',{email:$('#configEmail').value,form_endpoint:$('#configForm').value,footer_text:$('#configFooter').value});toast('网站联系配置已保存');await refresh()}catch(e){toast(e.message,true)}} async function saveContactSettings(){try{await api(siteUrl('/contact-settings'),'PUT',{email:$('#configEmail').value,form_endpoint:$('#configForm').value,footer_text:$('#configFooter').value});toast('网站联系配置已保存');await refresh()}catch(e){toast(e.message,true)}}
+8 -4
View File
@@ -14,14 +14,15 @@ function formatJobs(list){return list.length?list.map(j=>`<div class="row"><div>
async function chooseSite(){siteId=$('#site').value;window.SiteForgeShell?.selectSite(siteId);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 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{ async function reload(){if(!siteId)return;try{
const [list,jobsData,buildData,approvals,history,reviewData]=await Promise.all([ const [list,jobsData,buildData,approvals,history,reviewData,deliveryData]=await Promise.all([
request(`/api/sites/${siteId}`),request(`/api/enterprise/sites/${siteId}/jobs`),request(`/api/sites/${siteId}/builds`),request(`/api/factory/sites/${siteId}/release-requests`),request(`/api/enterprise/sites/${siteId}/releases`),request(`/api/migrations/sites/${siteId}/reviews`)]); request(`/api/sites/${siteId}`),request(`/api/enterprise/sites/${siteId}/jobs`),request(`/api/sites/${siteId}/builds`),request(`/api/factory/sites/${siteId}/release-requests`),request(`/api/enterprise/sites/${siteId}/releases`),request(`/api/migrations/sites/${siteId}/reviews`),request(`/api/production/sites/${siteId}/delivery-status`)]);
jobs=jobsData;builds=buildData;releases=history;$('#statBuild').textContent=builds.length;$('#statJobs').textContent=jobs.filter(j=>['queued','running','retrying'].includes(j.state)).length;$('#statApprovals').textContent=approvals.filter(j=>j.state==='pending').length;$('#statMode').textContent=list.mode==='migration'?'迁移':'新建';$('#jobList').innerHTML=formatJobs(jobs); jobs=jobsData;builds=buildData;releases=history;$('#statBuild').textContent=builds.length;$('#statJobs').textContent=jobs.filter(j=>['queued','running','retrying'].includes(j.state)).length;$('#statApprovals').textContent=approvals.filter(j=>j.state==='pending').length;$('#statMode').textContent=list.mode==='migration'?'迁移':'新建';$('#jobList').innerHTML=formatJobs(jobs);
$('#buildList').innerHTML=builds.length?builds.slice(0,14).map(b=>`<div class="row"><div><b>${esc(b.id)}</b><small>${esc(b.created_at)} · ${esc(b.total_pages)} 页</small></div><button class="secondary" onclick="requestApproval('${esc(b.id)}')">提交审批</button></div>`).join(''):'当前没有构建'; $('#buildList').innerHTML=builds.length?builds.slice(0,14).map(b=>`<div class="row"><div><b>${esc(b.id)}</b><small>${esc(b.created_at)} · ${esc(b.total_pages)} 页</small></div><button class="secondary" onclick="requestApproval('${esc(b.id)}')">提交审批</button></div>`).join(''):'当前没有构建';
$('#releaseBuild').innerHTML=builds.map(b=>`<option value="${esc(b.id)}">${esc(b.id)} · ${esc(b.total_pages)} 页</option>`).join(''); $('#releaseBuild').innerHTML=builds.map(b=>`<option value="${esc(b.id)}">${esc(b.id)} · ${esc(b.total_pages)} 页</option>`).join('');
$('#approvals').innerHTML=approvals.length?approvals.slice(0,12).map(a=>`<div class="row"><div><b>${esc(a.build_id)} <span class="flag ${a.state==='pending'?'warn':''}">${esc(a.state)}</span></b><small>由 ${esc(a.requested_by)} 提交</small></div>${a.state==='pending'?`<button class="secondary" onclick="approve('${esc(a.id)}')">批准</button>`:''}</div>`).join(''):'暂无审批记录'; $('#approvals').innerHTML=approvals.length?approvals.slice(0,12).map(a=>`<div class="row"><div><b>${esc(a.build_id)} <span class="flag ${a.state==='pending'?'warn':''}">${esc(a.state)}</span></b><small>由 ${esc(a.requested_by)} 提交</small></div>${a.state==='pending'?`<button class="secondary" onclick="approve('${esc(a.id)}')">批准</button>`:''}</div>`).join(''):'暂无审批记录';
$('#reviews').innerHTML=reviewData.items.length?reviewData.items.map(x=>`<div class="row"><div><b>${esc(x.platform)} · 第 ${esc(x.row_number)} 条</b><small>${esc(x.issue)}</small></div><button class="secondary" onclick="resolveReview('${esc(x.id)}')">已检查并处理</button></div>`).join(''):'暂无待复核事项'; $('#reviews').innerHTML=reviewData.items.length?reviewData.items.map(x=>`<div class="row"><div><b>${esc(x.platform)} · 第 ${esc(x.row_number)} 条</b><small>${esc(x.issue)}</small></div><button class="secondary" onclick="resolveReview('${esc(x.id)}')">已检查并处理</button></div>`).join(''):'暂无待复核事项';
$('#releases').innerHTML=history.length?history.slice(0,10).map(x=>`<div class="row"><div><b>${esc(x.build_id)} · ${esc(x.action)}</b><small>${esc(x.created_at)}</small></div></div>`).join(''):'暂无部署记录'; $('#releaseBackend').textContent=['staging','production'].map(t=>`${t}: ${deliveryData.backends?.[t]||'agent'} · ${deliveryData.configured?.[t]?'已配置':'未配置'}`).join(' | ');
$('#releases').innerHTML=deliveryData.history?.length?deliveryData.history.slice(0,10).map(x=>`<div class="row"><div><b>${esc(x.build_id)} · ${esc(x.target)} · ${esc(x.release_id)}</b><small>${esc(x.deployed_at)}</small></div><button class="secondary" onclick="rollbackRelease('${esc(x.release_id)}','${esc(x.target)}')">回滚</button></div>`).join(''): '暂无远程发布记录';
const entries=await request(`/api/sites/${siteId}/entries`);const entry=entries.find(p=>p.route==='/')||entries[0];$('#edit').href=entry?`/editor/${siteId}/${entry.id}`:'/factory-studio'; const entries=await request(`/api/sites/${siteId}/entries`);const entry=entries.find(p=>p.route==='/')||entries[0];$('#edit').href=entry?`/editor/${siteId}/${entry.id}`:'/factory-studio';
}catch(e){notify(e.message,true)}} }catch(e){notify(e.message,true)}}
async function importJSON(commit){const file=$('#file').files?.[0];if(!file)return notify('请选择 JSON 文件',true);if(!siteId)return;const platform=$('#platform').value; async function importJSON(commit){const file=$('#file').files?.[0];if(!file)return notify('请选择 JSON 文件',true);if(!siteId)return;const platform=$('#platform').value;
@@ -37,6 +38,9 @@ async function buildSite(){try{const task=await post(`/api/sites/${siteId}/build
async function cancelJob(id){try{await post(`/api/enterprise/sites/${siteId}/jobs/${id}/cancel`,{});await reload()}catch(e){notify(e.message,true)}} async function cancelJob(id){try{await post(`/api/enterprise/sites/${siteId}/jobs/${id}/cancel`,{});await reload()}catch(e){notify(e.message,true)}}
async function requestApproval(build){try{await post(`/api/factory/sites/${siteId}/release-requests`,{build_id:build});notify('已提交发布审批');await reload()}catch(e){notify(e.message,true)}} async function requestApproval(build){try{await post(`/api/factory/sites/${siteId}/release-requests`,{build_id:build});notify('已提交发布审批');await reload()}catch(e){notify(e.message,true)}}
async function approve(id){if(!confirm('审批人必须与申请人不同。确定审批通过吗?'))return;try{await post(`/api/factory/sites/${siteId}/release-requests/${id}/review`,{state:'approved'});notify('审批已完成');await reload()}catch(e){notify(e.message,true)}} async function approve(id){if(!confirm('审批人必须与申请人不同。确定审批通过吗?'))return;try{await post(`/api/factory/sites/${siteId}/release-requests/${id}/review`,{state:'approved'});notify('审批已完成');await reload()}catch(e){notify(e.message,true)}}
async function deploySite(){if(!confirm('请确保目标环境已配置 HTTPS 和发布 Agent。继续发布?'))return;try{const data=await post(`/api/production/sites/${siteId}/deploy`,{build_id:$('#releaseBuild').value,target:$('#target').value});notify(`已发布:${data.remote.release}`);await reload()}catch(e){notify(e.message,true)}} async function deploySite(){if(!confirm('系统将使用目标环境预先配置的发布适配器。请确认构建已审批、DNS 与版本回退方案已准备好。继续发布?'))return;try{const data=await post(`/api/production/sites/${siteId}/deploy`,{build_id:$('#releaseBuild').value,target:$('#target').value});notify(`发布任务 ${data.id} 已提交,Worker 将先上传、校验,再切换线上版本。`);await reload()}catch(e){notify(e.message,true)}}
async function rollbackRelease(release,target){if(!confirm(`确认将 ${target} 恢复为历史版本 ${release}?`))return;
try{const result=await post(`/api/production/sites/${siteId}/rollback`,{release_id:release,target});notify(`回滚任务 ${result.id} 已进入队列`);await reload()}catch(e){notify(e.message,true)}}
window.rollbackRelease=rollbackRelease;
window.resolveReview=resolveReview;window.cancelJob=cancelJob;window.requestApproval=requestApproval;window.approve=approve; window.resolveReview=resolveReview;window.cancelJob=cancelJob;window.requestApproval=requestApproval;window.approve=approve;
window.addEventListener('DOMContentLoaded',init); window.addEventListener('DOMContentLoaded',init);
+46
View File
@@ -1,6 +1,52 @@
'use strict';(()=>{ 'use strict';(()=>{
const menu=document.querySelector('[data-pro-menu]'); const nav=document.querySelector('#pro-navigation'); const menu=document.querySelector('[data-pro-menu]'); const nav=document.querySelector('#pro-navigation');
if(menu&&nav){menu.addEventListener('click',()=>{const open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));nav.classList.toggle('is-open',open);menu.setAttribute('aria-label',open?'关闭导航':'打开导航');});} if(menu&&nav){menu.addEventListener('click',()=>{const open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));nav.classList.toggle('is-open',open);menu.setAttribute('aria-label',open?'关闭导航':'打开导航');});}
// Drawer navigation. The same markup is used across the four professional themes.
const mobileNav=document.querySelector('#pro-navigation');
let drawerBackdrop=null,drawerClose=null,returnFocus=null;
if(menu&&mobileNav){
const originalParent=mobileNav.parentNode,originalNext=mobileNav.nextSibling;
const heading=document.createElement('div');heading.className='sf-drawer-header';
const title=document.createElement('strong');title.textContent='网站导航';
drawerClose=document.createElement('button');drawerClose.type='button';drawerClose.className='sf-drawer-close';drawerClose.textContent='×';drawerClose.setAttribute('aria-label','关闭网站导航');
heading.append(title,drawerClose);mobileNav.prepend(heading);
drawerBackdrop=document.createElement('div');drawerBackdrop.className='sf-mobile-drawer-backdrop';drawerBackdrop.hidden=false;document.body.append(drawerBackdrop);
function setDrawer(open,focusBack=true){
const isMobile=window.matchMedia('(max-width:1024px)').matches;
open=Boolean(open&&isMobile);
if(open){returnFocus=document.activeElement;document.body.append(mobileNav);}
else if(mobileNav.parentNode!==originalParent){if(originalNext&&originalNext.parentNode===originalParent)originalParent.insertBefore(mobileNav,originalNext);else originalParent.append(mobileNav);}
mobileNav.classList.toggle('is-open',open);drawerBackdrop.classList.toggle('is-open',open);
document.body.classList.toggle('sf-drawer-open',open);
menu.setAttribute('aria-expanded',String(open));menu.setAttribute('aria-label',open?'关闭导航':'打开导航');
mobileNav.setAttribute('aria-hidden',String(!open&&isMobile));
if('inert' in mobileNav)mobileNav.inert=!open&&isMobile;
if(open)drawerClose.focus();
else if(focusBack&&returnFocus instanceof HTMLElement){returnFocus.focus();returnFocus=null;}
}
// Replace the old inline-expansion listener: use capture to stop the old listener.
menu.addEventListener('click',e=>{if(!window.matchMedia('(max-width:1024px)').matches)return;e.stopImmediatePropagation();setDrawer(!mobileNav.classList.contains('is-open'));},true);
drawerClose.addEventListener('click',()=>setDrawer(false));
drawerBackdrop.addEventListener('click',()=>setDrawer(false));
document.querySelectorAll('[data-mobile-open-menu]').forEach(b=>b.addEventListener('click',()=>setDrawer(true)));
mobileNav.addEventListener('click',e=>{if(e.target.closest('a'))setDrawer(false,false)});
document.addEventListener('keydown',e=>{
if(e.key==='Escape'&&mobileNav.classList.contains('is-open')){e.preventDefault();setDrawer(false);}
if(e.key!=='Tab'||!mobileNav.classList.contains('is-open'))return;
const els=[...mobileNav.querySelectorAll('button:not([disabled]),a[href]')].filter(el=>el.getClientRects().length&&!el.closest('[hidden]'));
if(!els.length)return;const first=els[0],last=els[els.length-1];
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
});
window.matchMedia('(max-width:1024px)').addEventListener('change',()=>setDrawer(false,false));
if('inert' in mobileNav&&window.matchMedia('(max-width:1024px)').matches)mobileNav.inert=true;
// Do not strand catalogue links when the old mobile mega-button is hidden.
const megaLinks=document.querySelectorAll('[data-pro-mega] a');
if(megaLinks.length){const wrap=document.createElement('div');wrap.className='sf-drawer-extra';const h=document.createElement('strong');h.textContent='更多分类与系列';wrap.append(h);
[...megaLinks].slice(0,20).forEach(a=>{const clone=a.cloneNode(true);wrap.append(clone);});mobileNav.append(wrap);}
}
const tabbar=document.querySelector('[data-mobile-tabs]');
if(tabbar){tabbar.style.setProperty('--sf-mobile-tab-count',String(tabbar.children.length));}
const megaButton=document.querySelector('[data-pro-catalog]'), mega=document.querySelector('[data-pro-mega]'); const megaButton=document.querySelector('[data-pro-catalog]'), mega=document.querySelector('[data-pro-mega]');
if(megaButton&&mega){function setMega(open){mega.hidden=!open;mega.classList.toggle('is-open',open);megaButton.setAttribute('aria-expanded',String(open));} if(megaButton&&mega){function setMega(open){mega.hidden=!open;mega.classList.toggle('is-open',open);megaButton.setAttribute('aria-expanded',String(open));}
megaButton.addEventListener('click',()=>setMega(mega.hidden)); megaButton.addEventListener('click',()=>setMega(mega.hidden));
+1
View File
@@ -2,4 +2,5 @@
/* V8.12: task progress and dangerous actions, consistent focus and mobile sizing */ /* V8.12: task progress and dangerous actions, consistent focus and mobile sizing */
.btn-danger{border:1px solid #f1c3c7;color:#a92840;background:#fff5f6}.btn-danger:hover{background:#feecee}.btn:disabled{opacity:.55;cursor:not-allowed}.build-row{min-width:0}.build-meta{overflow-wrap:anywhere}.site-card:focus-visible,.quick-card:focus-visible{outline:3px solid #80aaf6;outline-offset:2px}@media(max-width:570px){.build-row{align-items:flex-start;flex-wrap:wrap}.build-row>.inline-actions{width:100%;padding-left:0}.site-picker-wrap select{max-width:130px}} .btn-danger{border:1px solid #f1c3c7;color:#a92840;background:#fff5f6}.btn-danger:hover{background:#feecee}.btn:disabled{opacity:.55;cursor:not-allowed}.build-row{min-width:0}.build-meta{overflow-wrap:anywhere}.site-card:focus-visible,.quick-card:focus-visible{outline:3px solid #80aaf6;outline-offset:2px}@media(max-width:570px){.build-row{align-items:flex-start;flex-wrap:wrap}.build-row>.inline-actions{width:100%;padding-left:0}.site-picker-wrap select{max-width:130px}}
.theme-preview.trade-pro{background:linear-gradient(125deg,#0b3d91,#3d6dc1);color:#e8f1fb}.site-icon.trade-pro{background:#e8f1fb;color:#0b3d91} .theme-preview.trade-pro{background:linear-gradient(125deg,#0b3d91,#3d6dc1);color:#e8f1fb}.site-icon.trade-pro{background:#e8f1fb;color:#0b3d91}
+53 -14
View File
@@ -7,8 +7,8 @@ const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',br
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 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 sfSiteKey='siteforge:selected-site'; const sfSiteKey='siteforge:selected-site';
const sfRemember=id=>{try{if(id)localStorage.setItem(sfSiteKey,id)}catch{}}; 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 state={sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null,selectionToken:0,building:false,delivery:{jobs:[],history:[],configured:{}},language:{group:null,sites:[],units:[]},languageGroups:[],languageEntries:[]};
const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',settings:'网站设置'}; const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',languages:'多语言管理',settings:'网站设置'};
async function api(url,method='GET',data=null,extraHeaders={}){ async function api(url,method='GET',data=null,extraHeaders={}){
const headers={...extraHeaders};if(method!=='GET'&&method!=='HEAD')headers['X-SiteForge-CSRF']=state.csrf; const headers={...extraHeaders};if(method!=='GET'&&method!=='HEAD')headers['X-SiteForge-CSRF']=state.csrf;
let body=data;if(data!=null && !(data instanceof FormData)){headers['Content-Type']='application/json';body=JSON.stringify(data)} let body=data;if(data!=null && !(data instanceof FormData)){headers['Content-Type']='application/json';body=JSON.stringify(data)}
@@ -86,28 +86,61 @@ function builds(){if(!currentSite())return noSite();const latest=latestDone();co
(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>`+ (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>`+ `<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('尚无完成版本','准备好网站数据后,在上方提交构建任务。'))+ (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>`; `<div id="reportContent"></div></div>`+
`<div class="panel" style="margin-top:18px"><div class="panel-head"><div><h2>正式发布与回滚</h2><p>先完成质量检查与独立审批,Worker 负责上传、校验和切换版本。没有配置目标服务器时不能发布。</p></div><a href="/operations" class="btn btn-tiny">审批与复核工具 ↗</a></div>
<div class="field-grid"><label>选择已完成构建<select id="deliveryBuild">${state.builds.filter(b=>b.status==='complete').map(b=>`<option value="${esc(b.id)}">${esc(b.id.slice(0,12))} · ${esc(fmtDate(b.created_at))}</option>`).join('')}</select></label>
<label>目标环境<select id="deliveryTarget"><option value="staging">测试环境(Staging)</option><option value="production">正式环境(Production)</option></select></label></div>
<div class="form-actions"><button class="btn" data-action="release-preflight">检查发布条件</button><button class="btn" data-action="release-approval">提交独立审批</button><button class="btn btn-primary" data-action="release-deploy">提交发布任务</button></div>
<div id="deliveryNotice" class="message" aria-live="polite" hidden></div>
<h3>最近发布任务</h3>${(state.delivery.jobs||[]).length?state.delivery.jobs.slice(0,8).map(j=>`<div class="build-row"><div class="build-meta"><strong>${j.kind==='remote_rollback'?'回滚':'部署'} · ${esc(buildStatusLabel(j.state))}</strong><p>${esc(j.id.slice(0,12))} · ${esc(j.created_at)}${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>'}
<h3>已发布版本</h3>${(state.delivery.history||[]).length?state.delivery.history.slice(0,8).map(r=>`<div class="build-row"><div class="build-meta"><strong>${esc(r.target)} · ${esc(r.release_id)}</strong><p>构建 ${esc(r.build_id.slice(0,12))} · ${esc(r.deployed_at)}</p></div><button class="btn btn-tiny" data-action="release-rollback" data-target="${esc(r.target)}" data-id="${esc(r.release_id)}">回滚到此版本</button></div>`).join(''):'<p class="muted">当前没有远程部署记录。</p>'}</div>`;
} }
function settings(){if(!currentSite())return noSite();const s=state.site;return htmlHeading('网站设置','这里只管理公司资料、域名与站点属性。网页样式请到“外观与设计”修改。')+ 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>`; `<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>`;
} }
function languages(){
if(!currentSite())return noSite();
const d=state.language||{group:null,sites:[],units:[]};const g=d.group;
const groups=state.languageGroups||[];
const header=htmlHeading('多语言管理','多个语言版本分别拥有独立的网站、域名和内容。系统只关联已审核的翻译,不会自动翻译企业事实。','<button class="btn" data-action="language-refresh">刷新语言状态</button>');
if(!g)return header+`<div class="panel"><h2>第一步 · 建立语言组</h2><p class="panel-desc">例如“集团全球网站”包含中文版、英文版、法文版。创建语言组后将网站加入对应语言。</p><form id="langGroupForm"><div class="field-grid"><label>语言组名称<input name="name" required minlength="2" placeholder="HESU 国际站"></label><label>默认语言<select name="default_locale"><option value="en">English (en)</option><option value="zh-CN">简体中文 (zh-CN)</option><option value="fr-FR">Français (fr-FR)</option><option value="ar">العربية (ar)</option></select></label><label>当前网站的实际语言<input name="current_locale" value="zh-CN" required placeholder="例如 zh-CN 或 en"></label></div><div class="form-actions"><button class="btn btn-primary">创建语言组并关联当前网站</button></div></form></div>`+
(groups.length?`<div class="panel"><h2>加入已有语言组</h2><p class="panel-desc">仅可关联您有管理权限、且已配置独立 HTTPS 域名的网站。</p><form id="langJoinForm"><div class="field-grid"><label>语言组<select name="group_id">${groups.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></label><label>当前网站的语言<input name="locale" value="zh-CN" required placeholder="zh-CN / en / fr-FR"></label></div><div class="form-actions"><button class="btn btn-primary">加入语言组</button></div></form></div>`:'')+
`<div class="help-hint"><strong>上线前提示:</strong>网站设置中的正式域名应为 https://example.com 形式;不同语言网站不能共用一个域名。当前域名:${esc(state.site.domain||'未设置')}</div>`;
const groupSites=d.sites||[]; const uniqueUnits=[...new Map((d.units||[]).map(u=>[u.id,u])).values()];
const canJoin=state.sites.filter(x=>!groupSites.some(m=>m.site_id===x.id));
return header+`<div class="panel"><div class="panel-head"><div><h2>${esc(g.name)}</h2><p>当前站语言 ${esc(g.locale)} · 默认语言 ${esc(g.default_locale)} · ${groupSites.length} 个语言站点</p></div></div><div class="table-wrap"><table class="data-table"><thead><tr><th>语言</th><th>网站</th><th>正式域名</th></tr></thead><tbody>${groupSites.map(x=>`<tr><td>${esc(x.locale)}</td><td>${esc(x.name)}</td><td>${esc(x.domain)}</td></tr>`).join('')}</tbody></table></div></div>`+
`<div class="panel"><h2>第二步 · 添加其他语言网站</h2><form id="langJoinForm"><div class="field-grid"><input type="hidden" name="group_id" value="${esc(g.group_id)}"><label>已有网站<select name="site_id">${canJoin.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></label><label>对应语言<input name="locale" required placeholder="例如 en、fr-FR"></label></div><div class="form-actions"><button class="btn btn-primary" ${!canJoin.length?'disabled':''}>加入语言站</button></div></form><p class="muted">先通过“我的网站”创建另一语言站并设置 HTTPS 域名,再加入语言组。</p></div>`+
`<div class="panel"><h2>第三步 · 建立翻译内容关系</h2><p class="panel-desc">相同产品、文章或页面可以使用不同 URL。用一个内容标识关联它们,只有正式发布且审核通过的内容才会生成 hreflang。</p><form id="langUnitForm"><div class="field-grid"><label class="full">翻译组标识<input name="reference_key" required placeholder="例如 product:rox-01 或 about-page"></label></div><div class="form-actions"><button class="btn btn-primary">创建内容翻译组</button></div></form></div>`+
`<div class="panel"><h2>第四步 · 关联各语言的实际内容</h2><form id="langLinkForm"><div class="field-grid"><label>翻译内容组<select name="unit_id">${uniqueUnits.map(x=>`<option value="${esc(x.id)}">${esc(x.reference_key)}</option>`).join('')}</select></label><label>语言网站<select id="langMemberSite" name="site_id">${groupSites.map(x=>`<option value="${esc(x.site_id)}" ${x.site_id===state.site.id?'selected':''}>${esc(x.locale)} · ${esc(x.name)}</option>`).join('')}</select></label><label class="full">对应内容<select id="langMemberEntry" name="entry_id">${(state.languageEntries||[]).map(x=>`<option value="${esc(x.id)}">${esc(x.title)} (${esc(x.route)}) · ${x.status==='published'?'正式':'草稿'}</option>`).join('')}</select></label><label>审核状态<select name="state"><option value="pending">待翻译(仅允许草稿)</option><option value="approved">翻译已确认(仅允许正式内容)</option></select></label></div><div class="form-actions"><button class="btn btn-primary" ${uniqueUnits.length?'':'disabled'}>保存关联</button></div></form></div>`+
`<div class="panel"><h2>已有语言关系</h2><p class="panel-desc">没有已审核的双向内容关系,就不会输出假语言链接。</p>${d.units.length?`<div class="table-wrap"><table class="data-table"><thead><tr><th>内容组</th><th>语言</th><th>内容 / URL</th><th>状态</th></tr></thead><tbody>${d.units.map(u=>`<tr><td>${esc(u.reference_key)}</td><td>${esc(u.locale)}</td><td>${esc(u.title)}<br><small>${esc(u.route)}</small></td><td>${u.state==='approved'?'已审核':'待翻译'}</td></tr>`).join('')}</tbody></table></div>`:'<p class="muted">还没有关联内容。</p>'}</div>`;
}
async function loadLanguage(){const sid=state.site?.id;if(!sid)return;try{
const [data,groups]=await Promise.all([api('/api/i18n/sites/'+encodeURIComponent(sid)),api('/api/i18n/groups')]);
if(state.site?.id!==sid)return;state.language=data;state.languageGroups=groups;await loadLanguageEntries(sid,false);if(state.view==='languages')rerender();
}catch(e){notifyError(e)}}
async function loadLanguageEntries(siteId,redraw=true){if(!siteId)return;try{const rows=await api('/api/sites/'+encodeURIComponent(siteId)+'/entries');state.languageEntries=rows;if(redraw){const target=$('#langMemberEntry');if(target)target.innerHTML=rows.map(x=>`<option value="${esc(x.id)}">${esc(x.title)} (${esc(x.route)}) · ${x.status==='published'?'正式':'草稿'}</option>`).join('')}}catch(e){notifyError(e);state.languageEntries=[]}}
async function submitLanguageGroup(ev){ev.preventDefault();if(!state.site)return;try{const d=Object.fromEntries(new FormData(ev.target));const locale=d.current_locale;delete d.current_locale;const group=await api('/api/i18n/groups','POST',d);await api('/api/i18n/groups/'+group.id+'/sites','POST',{site_id:state.site.id,locale});toast('语言组已创建');await loadLanguage()}catch(e){notifyError(e)}}
async function submitLanguageJoin(ev){ev.preventDefault();try{const d=Object.fromEntries(new FormData(ev.target));await api('/api/i18n/groups/'+d.group_id+'/sites','POST',{site_id:d.site_id||state.site.id,locale:d.locale});toast('语言站已关联');await loadLanguage()}catch(e){notifyError(e)}}
async function submitLanguageUnit(ev){ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));try{await api('/api/i18n/groups/'+state.language.group.group_id+'/units','POST',d);toast('翻译组已创建');await loadLanguage()}catch(e){notifyError(e)}}
async function submitLanguageLink(ev){ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));try{await api('/api/i18n/groups/'+state.language.group.group_id+'/units/'+encodeURIComponent(d.unit_id)+'/members','POST',{site_id:d.site_id,entry_id:d.entry_id,state:d.state});toast('内容翻译关系已保存');await loadLanguage()}catch(e){notifyError(e)}}
function rerender(){const area=$('#mainContent');const selected=state.view;$('#breadcrumbTitle').textContent=headings[selected]||'工作台';$('#sideSiteName').textContent=state.site?.name||'请先选择网站'; function rerender(){const area=$('#mainContent');const selected=state.view;$('#breadcrumbTitle').textContent=headings[selected]||'工作台';$('#sideSiteName').textContent=state.site?.name||'请先选择网站';
document.querySelectorAll('[data-view].nav-item').forEach(el=>{const yes=el.dataset.view===selected;el.classList.toggle('active',yes);yes?el.setAttribute('aria-current','page'):el.removeAttribute('aria-current')}); document.querySelectorAll('[data-view].nav-item').forEach(el=>{const yes=el.dataset.view===selected;el.classList.toggle('active',yes);yes?el.setAttribute('aria-current','page'):el.removeAttribute('aria-current')});
area.innerHTML=({home,sites,overview,content,design,import:imports,build:builds,settings}[selected]||home)(); area.innerHTML=({home,sites,overview,content,design,import:imports,build:builds,languages,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 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 readDelivery(siteId){try{return await api(`/api/production/sites/${encodeURIComponent(siteId)}/delivery-status`)}catch(e){return {jobs:[],history:[],configured:{}}}}
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 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'){ async function selectSite(id,go='overview',historyMode='push'){
const token=++state.selectionToken; 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(!id){state.site=null;state.entries=[];state.builds=[];state.jobs=[];state.delivery={jobs:[],history:[],configured:{}};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} 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)]); try{const [site,entries,builds,jobs,delivery]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`),readJobs(id),readDelivery(id)]);
if(token!==state.selectionToken)return; 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'}); state.site=site;sfRemember(id);state.entries=entries;state.builds=builds;state.jobs=jobs;state.delivery=delivery;state.job=null;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();if(historyMode!=='none')rememberView(historyMode==='replace');if(go==='languages')loadLanguage();closeMobile();window.scrollTo({top:0,behavior:'instant'});
}catch(e){if(token===state.selectionToken)notifyError(e)} }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'})} 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();if(to==='languages')loadLanguage();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)}} async function refreshBuilds(){const id=state.site?.id;if(!id)return;try{const previouslyRunning=activeJobs().length>0;const [jobs,builds,delivery]=await Promise.all([readJobs(id),api(`/api/sites/${encodeURIComponent(id)}/builds`),readDelivery(id)]);if(state.site?.id!==id)return;state.jobs=jobs;state.builds=builds;state.delivery=delivery;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 openModal(name){const node=$('#'+name);node.showModal();node.querySelector('input:not([type=hidden]),select')?.focus()}
function closeModal(name){$('#'+name)?.close()} 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')} 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')}
@@ -125,6 +158,12 @@ async function buildNow(){if(!state.site)return toast('请先选择网站',true)
try{navigate('build');state.job='正在提交构建任务…';rerender();const job=await api(`/api/sites/${encodeURIComponent(id)}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()}); 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(); 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()}} }catch(e){notifyError(e)}finally{state.building=false;if(state.site?.id===id&&state.view==='build')rerender()}}
function selectedReleaseBuild(){const id=$('#deliveryBuild')?.value;if(!id)throw Error('没有可发布的构建版本,请先完成网站构建');return id}
function showDeliveryMessage(message){const el=$('#deliveryNotice');if(el){el.hidden=false;el.textContent=message}toast(message)}
async function releasePreflight(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id;const r=await api(`/api/production/sites/${encodeURIComponent(s)}/preflight/${encodeURIComponent(id)}`);if(state.site?.id!==s)return;showDeliveryMessage(r.ready?'已通过发布前置检查:'+r.message:'尚不能发布:'+r.message)}catch(e){notifyError(e)}}
async function releaseApproval(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id;const r=await api(`/api/factory/sites/${encodeURIComponent(s)}/release-requests`,'POST',{build_id:id});showDeliveryMessage('审批申请已创建,请由另一名有审核权限的人员批准。申请编号:'+r.id)}catch(e){notifyError(e)}}
async function releaseDeploy(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id,target=$('#deliveryTarget')?.value;if(!state.delivery.configured?.[target])throw Error('尚未配置此目标服务器,请管理员配置目标发布方式(Agent/SFTP/S3)、服务器凭据与 HTTPS 验证地址');if(!confirm(`确定将构建 ${id.slice(0,12)} 提交到${target==='production'?'正式':'测试'}环境吗?必须已有独立审批。`))return;const r=await api(`/api/production/sites/${encodeURIComponent(s)}/deploy`,'POST',{build_id:id,target},{'Idempotency-Key':crypto.randomUUID()});showDeliveryMessage(`发布任务 ${r.id} 已排队。Worker 会先暂存和校验,再激活新版本。`);await refreshBuilds()}catch(e){notifyError(e)}}
async function releaseRollback(target,release){if(!state.site)return;if(!confirm(`确定在 ${target} 环境恢复历史版本 ${release} 吗?请确认其内容仍符合当前业务要求。`))return;try{const r=await api(`/api/production/sites/${encodeURIComponent(state.site.id)}/rollback`,'POST',{target,release_id:release},{'Idempotency-Key':crypto.randomUUID()});showDeliveryMessage(`回滚任务 ${r.id} 已排队,请在任务列表查看结果。`);await refreshBuilds()}catch(e){notifyError(e)}}
async function checkReadiness(){if(!state.site)return;const siteId=state.site.id; async function checkReadiness(){if(!state.site)return;const siteId=state.site.id;
try{const result=await api(`/api/factory/sites/${encodeURIComponent(siteId)}/migration/readiness`); try{const result=await api(`/api/factory/sites/${encodeURIComponent(siteId)}/migration/readiness`);
if(state.site?.id!==siteId)return; if(state.site?.id!==siteId)return;
@@ -139,21 +178,21 @@ function closeMobile(){$('#sideMenu').classList.remove('open');$('#mobileBackdro
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; 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;
// Restore URL first, then a previously selected site *only* after verifying /api/sites. // Restore URL first, then a previously selected site *only* after verifying /api/sites.
const params=new URLSearchParams(location.search),requested=params.get('site'); 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']); const target=params.get('view');const allowed=new Set(['home','sites','overview','content','design','import','build','languages','settings']);
let stored='';try{stored=localStorage.getItem(sfSiteKey)||''}catch{} let stored='';try{stored=localStorage.getItem(sfSiteKey)||''}catch{}
const chosen=[requested,stored].find(id=>id&&state.sites.some(x=>x.id===id)); 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')} if(chosen){await selectSite(chosen,allowed.has(target)?target:'overview','replace')}
else {state.view=target==='sites'?'sites':'home';rerender();rememberView(true)} 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>')}} }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. // 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==='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',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==='language-refresh')await loadLanguage();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);if(action==='release-preflight')await releasePreflight();if(action==='release-approval')await releaseApproval();if(action==='release-deploy')await releaseDeploy();if(action==='release-rollback')await releaseRollback(control.dataset.target,control.dataset.id)}catch(e){notifyError(e)}});
document.addEventListener('click',ev=>{const btn=ev.target.closest('[data-close]');if(btn)closeModal(btn.dataset.close)}); 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); $('#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('submit',ev=>{if(ev.target.id==='settingsForm')saveSettings(ev);if(ev.target.id==='importForm')importFile(ev);if(ev.target.id==='langGroupForm')submitLanguageGroup(ev);if(ev.target.id==='langJoinForm')submitLanguageJoin(ev);if(ev.target.id==='langUnitForm')submitLanguageUnit(ev);if(ev.target.id==='langLinkForm')submitLanguageLink(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('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()}}); document.addEventListener('change',ev=>{if(ev.target.id==='contentType'){state.typ=ev.target.value;rerender()}if(ev.target.id==='langMemberSite')loadLanguageEntries(ev.target.value)});
$('#sitePicker').addEventListener('change',ev=>selectSite(ev.target.value)); $('#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()}}); 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','languages','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); setInterval(()=>{if(!document.hidden&&state.view==='build'&&activeJobs().length)refreshBuilds()},5000);
$('#newSiteTop').addEventListener('click',()=>openModal('siteModal')); $('#newSiteTop').addEventListener('click',()=>openModal('siteModal'));
$('#logoutBtn').addEventListener('click',async()=>{try{await api('/api/auth/logout','POST');location.href='/login'}catch(e){notifyError(e)}}); $('#logoutBtn').addEventListener('click',async()=>{try{await api('/api/auth/logout','POST');location.href='/login'}catch(e){notifyError(e)}});
+70 -2
View File
@@ -6,6 +6,7 @@ studio uses only /studio-draft endpoints; a build never consumes these drafts.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json import json
import uuid
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
from . import db, blueprints, enterprise from . import db, blueprints, enterprise
from .core import save_entry from .core import save_entry
@@ -24,6 +25,14 @@ def setup():
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL, id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
revision INTEGER NOT NULL, actor TEXT NOT NULL, published_at TEXT NOT NULL, revision INTEGER NOT NULL, actor TEXT NOT NULL, published_at TEXT NOT NULL,
blueprint_hash TEXT NOT NULL)''') blueprint_hash TEXT NOT NULL)''')
c.execute('''CREATE TABLE IF NOT EXISTS studio_history (
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
revision INTEGER NOT NULL,kind TEXT NOT NULL,
snapshot_json TEXT NOT NULL,snapshot_hash TEXT NOT NULL,
actor TEXT NOT NULL,created_at TEXT NOT NULL)''')
c.execute('''CREATE INDEX IF NOT EXISTS idx_studio_history_site_page
ON studio_history(site_id,entry_id,created_at,revision)''')
def _entry(site_id, entry_id): def _entry(site_id, entry_id):
@@ -59,7 +68,7 @@ def read(site_id,entry_id):
'layout_mode':meta.get('layout_mode','append'),'revision':0,'conflict':False,'state':'published', 'entry':{'id':row['id'],'title':row['title'],'route':row['route']}} 'layout_mode':meta.get('layout_mode','append'),'revision':0,'conflict':False,'state':'published', 'entry':{'id':row['id'],'title':row['title'],'route':row['route']}}
def write(site_id,entry_id,payload): def write(site_id,entry_id,payload,actor="system"):
row=_entry(site_id,entry_id) row=_entry(site_id,entry_id)
plan=_validate(payload) plan=_validate(payload)
# A draft is detached from the public entry. Only the final publish # A draft is detached from the public entry. Only the final publish
@@ -76,6 +85,7 @@ def write(site_id,entry_id,payload):
ON CONFLICT(site_id,entry_id) DO UPDATE SET revision=excluded.revision, ON CONFLICT(site_id,entry_id) DO UPDATE SET revision=excluded.revision,
blueprint_json=excluded.blueprint_json,updated_at=excluded.updated_at''', blueprint_json=excluded.blueprint_json,updated_at=excluded.updated_at''',
(site_id,entry_id,next_rev,base,json.dumps(plan,ensure_ascii=False),db.now())) (site_id,entry_id,next_rev,base,json.dumps(plan,ensure_ascii=False),db.now()))
_journal(c,site_id,entry_id,next_rev,'draft',plan,actor)
return {'saved':True,'state':'draft','revision':next_rev,'published_unchanged':True} return {'saved':True,'state':'draft','revision':next_rev,'published_unchanged':True}
@@ -101,11 +111,69 @@ def publish(site_id,entry_id,expected_revision,actor):
pid=uuid.uuid4().hex pid=uuid.uuid4().hex
c.execute('INSERT INTO studio_publications VALUES (?,?,?,?,?,?,?)', c.execute('INSERT INTO studio_publications VALUES (?,?,?,?,?,?,?)',
(pid,site_id,entry_id,draft['revision'],actor,db.now(),hashlib.sha256(json.dumps(plan,sort_keys=True).encode()).hexdigest())) (pid,site_id,entry_id,draft['revision'],actor,db.now(),hashlib.sha256(json.dumps(plan,sort_keys=True).encode()).hexdigest()))
_journal(c,site_id,entry_id,draft['revision'],'published',plan,actor)
c.execute('DELETE FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id)) c.execute('DELETE FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
return {'published':True,'publication_id':pid,'revision':draft['revision'], return {'published':True,'publication_id':pid,'revision':draft['revision'],
'build_required':True,'message':'页面设计已发布到内容库;需要重新构建静态站点才会上线'} 'build_required':True,'message':'页面设计已发布到内容库;需要重新构建静态站点才会上线'}
def _journal(connection, site_id, entry_id, revision, kind, plan, actor):
# Store a complete, immutable validated snapshot; never executable user code.
canonical=json.dumps(plan,ensure_ascii=False,sort_keys=True,separators=(',',':'))
connection.execute('''INSERT INTO studio_history VALUES (?,?,?,?,?,?,?,?,?)''',
(uuid.uuid4().hex,site_id,entry_id,revision,kind,canonical,
hashlib.sha256(canonical.encode('utf-8')).hexdigest(),actor,db.now()))
def history(site_id,entry_id,limit=30):
_entry(site_id,entry_id)
if type(limit) is not int or not 1<=limit<=100:raise HTTPException(400,'历史条数必须为1-100')
rows=db.rows('''SELECT id,revision,kind,snapshot_hash,actor,created_at
FROM studio_history WHERE site_id=? AND entry_id=?
ORDER BY created_at DESC, revision DESC, id DESC LIMIT ?''',(site_id,entry_id,limit))
return {'items':rows,'note':'历史记录不会直接修改已发布页面;恢复时只生成新的草稿修订'}
def history_snapshot(site_id,entry_id,history_id):
_entry(site_id,entry_id)
row=db.one('SELECT * FROM studio_history WHERE site_id=? AND entry_id=? AND id=?',
(site_id,entry_id,history_id))
if not row:raise HTTPException(404,'历史记录不存在')
if hashlib.sha256(row['snapshot_json'].encode('utf-8')).hexdigest()!=row['snapshot_hash']:
raise HTTPException(409,'历史快照完整性校验失败,不允许恢复')
plan=_validate(json.loads(row['snapshot_json']))
return {'id':row['id'],'revision':row['revision'],'kind':row['kind'],
'snapshot_hash':row['snapshot_hash'],'plan':plan}
def restore_history(site_id,entry_id,history_id,expected_revision,actor):
if type(expected_revision) is not int:raise HTTPException(400,'必须指定当前草稿修订号')
previous=history_snapshot(site_id,entry_id,history_id)
# Optimistic revision is enforced by write(). Restore never directly alters
# published content and cannot bypass a conflicting published page.
plan={**previous['plan'],'expected_revision':expected_revision}
result=write(site_id,entry_id,plan,actor)
return {**result,'restored_from':history_id,'requires_publish':True}
@router.get('/{site_id}/pages/{entry_id}/studio-history')
def api_history(request:Request,site_id:str,entry_id:str,limit:int=30):
enterprise.require(request,site_id,'viewer')
return history(site_id,entry_id,limit)
@router.get('/{site_id}/pages/{entry_id}/studio-history/{history_id}')
def api_history_snapshot(request:Request,site_id:str,entry_id:str,history_id:str):
enterprise.require(request,site_id,'viewer')
return history_snapshot(site_id,entry_id,history_id)
@router.post('/{site_id}/pages/{entry_id}/studio-history/{history_id}/restore')
def api_history_restore(request:Request,site_id:str,entry_id:str,history_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return restore_history(site_id,entry_id,history_id,payload.get('expected_revision'),enterprise.actor(request))
@router.get('/{site_id}/pages/{entry_id}/studio-draft') @router.get('/{site_id}/pages/{entry_id}/studio-draft')
def api_read(request:Request,site_id:str,entry_id:str): def api_read(request:Request,site_id:str,entry_id:str):
enterprise.require(request,site_id,'viewer') enterprise.require(request,site_id,'viewer')
@@ -115,7 +183,7 @@ def api_read(request:Request,site_id:str,entry_id:str):
@router.put('/{site_id}/pages/{entry_id}/studio-draft') @router.put('/{site_id}/pages/{entry_id}/studio-draft')
def api_write(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)): def api_write(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor') enterprise.require(request,site_id,'editor')
return write(site_id,entry_id,payload) return write(site_id,entry_id,payload,enterprise.actor(request))
@router.post('/{site_id}/pages/{entry_id}/studio-draft/publish') @router.post('/{site_id}/pages/{entry_id}/studio-draft/publish')
+67 -18
View File
@@ -1,27 +1,61 @@
<!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> <!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-helpers.js"></script><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"> <body data-sf-page="editor">
<div class="studio"> <div class="studio">
<header class="studio-top"> <header class="studio-top sf-easy-top">
<div class="brand"><a href="/" title="返回网站管理">◈</a><div><b>SiteForge</b><small>VISUAL STUDIO</small></div></div> <div class="brand"><a href="/" title="返回网站管理">◈</a><div><b>SiteForge</b><small>页面编辑</small></div></div>
<span class="divide"></span><div class="file-name"><span id="siteName">加载中</span><small> / </small><strong id="pageName">页面编辑器</strong></div> <div class="file-name sf-easy-file"><small id="siteName">加载中</small><strong id="pageName">页面编辑器</strong></div>
<div class="top-center"><button id="deviceDesktop" class="icon-btn on" onclick="Studio.device('desktop')" title="PC预览">▣</button><button id="deviceTablet" class="icon-btn" onclick="Studio.device('tablet')" title="平板预览">▤</button><button id="devicePhone" class="icon-btn" onclick="Studio.device('phone')" title="手机预览">▯</button></div> <div class="sf-pane-controls"><button type="button" onclick="Studio.togglePane('left')">☰ 内容</button><button type="button" onclick="Studio.togglePane('right')">⚙ 编辑</button></div>
<button class="ui-btn ghost" onclick="Studio.undo()" title="撤销 Ctrl+Z">↶ 撤销</button><button class="ui-btn ghost" onclick="Studio.redo()" title="重做 Ctrl+Y">↷ 重做</button><button class="ui-btn ghost" onclick="Studio.showTokens()">设计规范</button><button class="ui-btn ghost" onclick="Studio.showSiteDesign()">全站页眉与页脚</button><span id="saveStatus" class="save-status">未修改</span><button class="ui-btn ghost" onclick="Studio.refreshPreview()">↻ 刷新预览</button><button class="ui-btn ghost" onclick="Studio.buildSite()">构建静态站</button><button class="ui-btn ghost" onclick="Studio.discardDraft()">放弃草稿</button><button class="ui-btn ghost" onclick="Studio.save()">保存草稿</button><button class="ui-btn primary" onclick="Studio.publishDesign()">发布设计 ↗</button> <div class="top-center sf-easy-device" role="group" aria-label="预览设备">
<button id="deviceDesktop" class="icon-btn on" onclick="Studio.device('desktop')" title="电脑预览" aria-label="电脑预览">电脑</button>
<button id="deviceTablet" class="icon-btn" onclick="Studio.device('tablet')" title="平板预览" aria-label="平板预览">平板</button>
<button id="devicePhone" class="icon-btn" onclick="Studio.device('phone')" title="手机预览" aria-label="手机预览">手机</button>
</div>
<span id="saveStatus" class="save-status sf-easy-save-status" role="status" aria-live="polite">读取中…</span>
<button id="saveDraftButton" type="button" class="ui-btn ghost sf-save-btn" onclick="Studio.save()">保存草稿</button>
<button id="publishDesignButton" type="button" class="ui-btn primary sf-publish-btn" onclick="Studio.publishDesign()">发布设计</button>
<details class="studio-actions"><summary title="其他功能">更多 ▾</summary><div class="studio-actions-menu">
<button id="studioUndo" class="ui-btn ghost" onclick="Studio.undo()" disabled>↶ 撤销</button>
<button id="studioRedo" class="ui-btn ghost" onclick="Studio.redo()" disabled>↷ 重做</button>
<button class="ui-btn ghost" onclick="Studio.showHistory()">版本历史</button>
<button class="ui-btn ghost" onclick="Studio.showSiteDesign()">全站页眉与页脚</button>
<button class="ui-btn ghost" onclick="Studio.showTokens()">全站设计规范</button>
<button class="ui-btn ghost" onclick="Studio.refreshPreview()">刷新预览</button>
<button class="ui-btn ghost" onclick="Studio.buildSite()">构建静态网站</button>
<button class="ui-btn ghost" onclick="Studio.discardDraft()">放弃草稿</button>
</div></details>
</header> </header>
<div class="sf-steps" aria-label="编辑操作指引"><div class="sf-steps-label">只需 3 步</div><span class="sf-step" id="sfStep1">① 选择或添加区块</span><span class="sf-step-separator">→</span><span class="sf-step" id="sfStep2">② 修改右侧内容</span><span class="sf-step-separator">→</span><span class="sf-step" id="sfStep3">③ 保存并发布</span><button type="button" class="sf-guide-link" onclick="Studio.showHelp()">新手使用指南 ?</button></div>
<div class="studio-body"> <div class="studio-body">
<aside class="left-pane"><div class="pane-heading"><div><span class="overline">STRUCTURE</span><h3>页面结构</h3></div><span class="chip" id="blockCount">0 区块</span></div> <aside class="left-pane" aria-label="页面内容和区块">
<div class="field"><label>页面</label><select id="pageSelect" onchange="Studio.choosePage(this.value)"></select></div> <div class="pane-heading"><div><span class="overline">页面内容</span><h3>编辑页面</h3></div><span class="chip" id="blockCount">0 个区块</span></div>
<div class="field"><label>页面模板</label><select id="pageTemplate" onchange="Studio.changeTemplateKind(this.value)"></select></div> <div class="field sf-page-picker"><label for="pageSelect">当前编辑的页面</label><select id="pageSelect" onchange="Studio.choosePage(this.value)"></select></div>
<div style="padding:0 19px 7px"><button class="ui-btn ghost" style="width:100%" onclick="Studio.resetPreset()">◈ 应用此页面模板的预设区块</button></div> <div class="sf-add-area"><button type="button" id="sfAddBlock" class="sf-add-button" aria-expanded="false" aria-controls="sfLibraryPanel" onclick="Studio.toggleLibrary()">+ 添加内容区块</button><p>例如图片横幅、公司简介、产品列表等</p></div>
<div class="field"><label>页面布局模式</label><select id="layoutMode" onchange="Studio.setLayoutMode(this.value)"><option value="append">保留主题原页面 + 追加区块</option><option value="replace">全页自由排版(保留导航与页脚)</option></select></div><div class="section-label">内容区块 · 拖拽可调整排序</div><div id="blockList" class="block-list"></div> <section id="sfLibraryPanel" class="sf-library-panel" hidden aria-label="添加区块"><div class="sf-panel-caption">点击类型即可添加到当前页面 <button type="button" onclick="Studio.toggleLibrary(false)" aria-label="收起添加面板">收起 ×</button></div><div class="palette" id="palette"></div><details class="sf-subsection"><summary>从我的组件库添加</summary><div class="palette" id="reusablePalette"></div></details></section>
<div class="section-label">添加功能区块</div><div class="palette" id="palette"></div><div class="section-label">可复用组件库</div><button class="ui-btn ghost" style="margin:10px" onclick="Studio.saveReusable()">将当前区块存为组件</button><div class="palette" id="reusablePalette"></div> <div class="section-label">当前页面的内容 <span class="section-caption">点击一项进行编辑</span></div>
<div class="sidebar-foot">页面由区块驱动,所见预览使用服务端与正式构建相同的数据绑定引擎。</div> <div class="structure-filter"><label for="blockSearch" class="sr-only">搜索页面内容</label><input id="blockSearch" type="search" placeholder="查找横幅、文字、产品…" autocomplete="off" oninput="Studio.filter(this.value)"><button type="button" onclick="Studio.clearFilter()" aria-label="清除搜索">×</button></div>
<div id="blockList" class="block-list" role="list" aria-label="当前页面区块"></div>
<p class="structure-hint">点击区块进入右侧编辑;拖动 ⠿ 或使用 ↑ ↓ 调整顺序。</p>
<details class="sf-advanced"><summary>高级页面设置 <span>模板、布局和组件保存</span></summary>
<div class="field"><label for="pageTemplate">页面模板</label><select id="pageTemplate" onchange="Studio.changeTemplateKind(this.value)"></select></div>
<div class="field"><label for="layoutMode">排版方式</label><select id="layoutMode" onchange="Studio.setLayoutMode(this.value)"><option value="append">保留原页面,追加区块</option><option value="replace">用这些区块重新排版页面</option></select></div>
<div class="sf-advanced-buttons"><button type="button" class="ui-btn ghost" onclick="Studio.resetPreset()">使用模板预设布局</button><button type="button" class="ui-btn ghost" onclick="Studio.saveReusable()">将所选区块存为组件</button></div>
<p class="sf-advanced-note">更换模板预设可能替换当前草稿,请先保存;不会直接改变在线网站。</p>
</details>
</aside> </aside>
<section class="preview-workspace"><div class="preview-head"><span class="live-dot"></span><span>实时预览</span><code id="routeDisplay">/</code><span class="preview-note">正在展示正式主题中的完整页面,草稿不会影响正式构建,发布设计后才会参与下次构建</span></div><div class="stage"><div class="device-frame" id="deviceFrame"><iframe id="sitePreview" title="页面预览" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div></div></section> <section class="preview-workspace"><div class="preview-head"><span class="live-dot" id="previewDot"></span><span id="previewState" role="status" aria-live="polite">实时预览</span><code id="routeDisplay">/</code><span class="preview-note">修改后自动预览 · 不会直接改变线上网站</span></div><div class="stage"><div class="device-frame" id="deviceFrame"><iframe id="sitePreview" title="页面预览" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div></div>
<aside class="right-pane"><div class="pane-heading"><div><span class="overline">PROPERTIES</span><h3>属性设置</h3></div><span class="chip" id="blockTypeLabel">页面</span></div> <div class="stage-footer"><span id="deviceDetails">桌面端 · 自适应宽度</span><span>当前是草稿预览,不是线上网站</span></div></section>
<div class="inspector" id="inspector"><div class="no-selection">在左侧选择一个区块,在这里编辑它的文字、图片、布局及数据来源。</div></div> <aside class="right-pane"><div class="pane-heading"><div><span class="overline">步骤 2 · 修改内容</span><h3>编辑选中的区块</h3></div><span class="chip" id="blockTypeLabel">页面</span></div>
<div class="right-foot"><div>动态数据绑定</div><p>支持 <code>{{site.name}}</code>、<code>{{page.title}}</code> 等变量;“动态内容列表”按类型自动查询当前网站的数据。</p></div> <div class="inspector-nav" role="tablist" aria-label="属性编辑分类"><button type="button" role="tab" id="tabContent" aria-selected="true" onclick="Studio.changeInspectorTab('content')">内容</button><button type="button" role="tab" id="tabStyle" aria-selected="false" onclick="Studio.changeInspectorTab('style')">样式</button><button type="button" role="tab" id="tabResponsive" aria-selected="false" onclick="Studio.changeInspectorTab('responsive')">手机/平板</button></div>
<div class="inspector" id="inspector"><div class="no-selection">先从左侧点击一个内容区块,或者点击“添加内容区块”。</div></div>
<details class="right-foot sf-advanced-foot"><summary>高级:动态数据变量</summary><p>支持 <code>{{site.name}}</code>、<code>{{page.title}}</code> 等变量;动态内容列表可以按网站内容类型绑定。</p></details>
</aside> </aside>
</div> </div>
<dialog id="sfGuideDialog" class="sf-help-dialog" aria-labelledby="sfGuideTitle">
<div class="sf-help-head"><div><span class="overline">新手指南</span><h2 id="sfGuideTitle">3 分钟学会编辑页面</h2></div><button type="button" class="ui-btn ghost" onclick="document.querySelector('#sfGuideDialog').close()">关闭 ×</button></div>
<ol class="sf-help-steps"><li><b>先选页面</b><p>左侧顶部切换要编辑的页面;选择现有内容区块,或点击橙色的“添加内容区块”。</p></li><li><b>修改内容</b><p>在右侧“内容”里改标题、文案和图片;切换“样式”或“手机/平板”调整外观。中间自动展示效果。</p></li><li><b>保存草稿,再发布</b><p>点击右上角“保存草稿”可以保留工作。核对后点击“发布设计”,它会更新内容库;正式网站还需要执行静态构建和部署。</p></li></ol>
<div class="sf-guide-note">撤销、版本历史、全站页眉、静态构建等低频功能在右上角「更多」。</div>
<button type="button" class="ui-btn primary sf-guide-done" onclick="document.querySelector('#sfGuideDialog').close()">知道了,开始编辑</button>
</dialog>
<dialog id="designModal" style="border:1px solid #e2e8f0;border-radius:14px;padding:24px;width:min(460px,90vw);box-shadow:0 16px 52px #142a492f"><h2>全站设计规范</h2><p>调整后保存并刷新预览,发布构建自动采用该规范。</p><form id="designForm" method="dialog" style="display:grid;gap:12px"><label>标题缩放 <input name="heading_scale" type="number" min="0.8" max="1.5" step="0.1"></label><label>圆角 <input name="radius" type="number" min="0" max="32"></label><label>版心宽度 <input name="container_width" type="number" min="880" max="1600"></label><label>板块间距 <input name="section_spacing" type="number" min="20" max="140"></label><label>字体 <select name="font"><option value="system">系统字体</option><option value="serif">衬线字体</option><option value="modern">现代字体</option></select></label><label>按钮风格 <select name="button_style"><option value="rounded">圆角</option><option value="square">直角</option><option value="pill">胶囊</option></select></label><label>导航风格 <select name="header_style"><option value="standard">标准</option><option value="compact">紧凑</option></select></label><div style="display:flex;gap:12px"><button class="ui-btn primary" type="button" onclick="Studio.saveTokens()">保存全站规范</button><button class="ui-btn ghost" value="cancel">取消</button></div></form></dialog> <dialog id="designModal" style="border:1px solid #e2e8f0;border-radius:14px;padding:24px;width:min(460px,90vw);box-shadow:0 16px 52px #142a492f"><h2>全站设计规范</h2><p>调整后保存并刷新预览,发布构建自动采用该规范。</p><form id="designForm" method="dialog" style="display:grid;gap:12px"><label>标题缩放 <input name="heading_scale" type="number" min="0.8" max="1.5" step="0.1"></label><label>圆角 <input name="radius" type="number" min="0" max="32"></label><label>版心宽度 <input name="container_width" type="number" min="880" max="1600"></label><label>板块间距 <input name="section_spacing" type="number" min="20" max="140"></label><label>字体 <select name="font"><option value="system">系统字体</option><option value="serif">衬线字体</option><option value="modern">现代字体</option></select></label><label>按钮风格 <select name="button_style"><option value="rounded">圆角</option><option value="square">直角</option><option value="pill">胶囊</option></select></label><label>导航风格 <select name="header_style"><option value="standard">标准</option><option value="compact">紧凑</option></select></label><div style="display:flex;gap:12px"><button class="ui-btn primary" type="button" onclick="Studio.saveTokens()">保存全站规范</button><button class="ui-btn ghost" value="cancel">取消</button></div></form></dialog>
<dialog id="siteDesignModal" style="border:1px solid #e2e8f0;border-radius:14px;padding:22px;width:min(750px,96vw);max-height:88vh;overflow:auto;box-shadow:0 18px 56px #142a492f"> <dialog id="siteDesignModal" style="border:1px solid #e2e8f0;border-radius:14px;padding:22px;width:min(750px,96vw);max-height:88vh;overflow:auto;box-shadow:0 18px 56px #142a492f">
@@ -53,6 +87,15 @@
<label><input name="mobile_hide_announcement" type="checkbox"> 手机隐藏提示栏</label> <label><input name="mobile_hide_announcement" type="checkbox"> 手机隐藏提示栏</label>
<label><input name="mobile_hide_footer_links" type="checkbox"> 手机隐藏页脚导航</label> <label><input name="mobile_hide_footer_links" type="checkbox"> 手机隐藏页脚导航</label>
</fieldset> </fieldset>
<fieldset style="border:1px solid #e2e8f0;border-radius:10px;padding:15px;display:grid;gap:12px"><legend>手机底部快捷导航 Tabs(可选)</legend>
<p style="margin:0;color:#64748b;font-size:13px">默认关闭。需要时开启后添加 1–5 个按钮。它们只显示在手机上,平板和电脑不显示。</p>
<label><input name="mobile_tabs_enabled" type="checkbox" onchange="Studio.toggleMobileTabsForm()"> 开启手机底部 Tabs</label>
<div id="mobileTabsEditor" hidden style="display:grid;gap:10px">
<div id="mobileTabsRows" style="display:grid;gap:9px"></div>
<button class="ui-btn ghost" type="button" onclick="Studio.addMobileTab()">+ 添加一个 Tab(最多 5 个)</button>
<small style="color:#64748b">标题建议不超过 4 个字;链接使用站内路径(如 /products/)。菜单按钮会打开右侧导航抽屉。</small>
</div>
</fieldset>
<div style="display:flex;flex-wrap:wrap;gap:8px"> <div style="display:flex;flex-wrap:wrap;gap:8px">
<button class="ui-btn ghost" type="button" onclick="Studio.previewSiteDesign()">预览草稿</button> <button class="ui-btn ghost" type="button" onclick="Studio.previewSiteDesign()">预览草稿</button>
<button class="ui-btn ghost" type="button" onclick="Studio.discardSiteDesign()">放弃全站草稿</button> <button class="ui-btn ghost" type="button" onclick="Studio.discardSiteDesign()">放弃全站草稿</button>
@@ -67,5 +110,11 @@
<div id="siteMediaGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(135px,1fr));gap:12px"></div> <div id="siteMediaGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(135px,1fr));gap:12px"></div>
<button type="button" class="ui-btn ghost" onclick="document.querySelector('#siteMediaModal').close()" style="margin-top:15px">关闭</button> <button type="button" class="ui-btn ghost" onclick="document.querySelector('#siteMediaModal').close()" style="margin-top:15px">关闭</button>
</dialog> </dialog>
<div id="toast" class="toast"></div> <dialog id="studioHistoryModal" aria-labelledby="studioHistoryTitle" style="border:1px solid #dce5f1;border-radius:14px;padding:24px;width:min(680px,95vw);max-height:82vh;overflow:auto">
<h2 id="studioHistoryTitle">页面设计版本历史</h2>
<p style="color:#64748b">保存草稿、发布设计都会创建不可变快照。恢复只写入新草稿,不会直接修改已发布站点。</p>
<div id="studioHistoryItems" aria-live="polite"></div>
<button class="ui-btn ghost" type="button" onclick="document.querySelector('#studioHistoryModal').close()">关闭</button>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</div></body></html> </div></body></html>
+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><style> <!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录 · SiteForge 网站工厂</title><style>
:root{font-family:Inter,system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#263952}*{box-sizing:border-box}body{margin:0;background:#f5f7fa;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(430px,100%);background:#fff;border:1px solid #e1e8ef;border-radius:20px;box-shadow:0 25px 70px #19345512;padding:42px}.symbol{width:56px;height:56px;border-radius:15px;background:#173250;color:#fff;display:grid;place-items:center;font-size:26px;font-weight:700;margin-bottom:24px}h1{font-size:27px;margin:0 0 6px}p{font-size:13px;color:#7c8ca0;line-height:1.8;margin:0 0 29px}label{display:block;font-size:13px;font-weight:600;margin-bottom:9px}input{width:100%;height:48px;border:1px solid #d8e0e9;border-radius:10px;padding:0 14px;font-size:15px;outline:0}input:focus{border-color:#386aab;box-shadow:0 0 0 3px #417cbc1c}button{width:100%;margin-top:22px;background:#19446f;color:#fff;height:48px;border:0;border-radius:10px;font-size:14px;font-weight:650;cursor:pointer}button:disabled{opacity:.65}small{display:block;text-align:center;margin-top:23px;color:#96a4b6}#error{margin:12px 0 0;color:#b63d3d;min-height:18px;font-size:13px} :root{font-family:Inter,system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#263952}*{box-sizing:border-box}body{margin:0;background:#f5f7fa;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(430px,100%);background:#fff;border:1px solid #e1e8ef;border-radius:20px;box-shadow:0 25px 70px #19345512;padding:42px}.symbol{width:56px;height:56px;border-radius:15px;background:#173250;color:#fff;display:grid;place-items:center;font-size:26px;font-weight:700;margin-bottom:24px}h1{font-size:27px;margin:0 0 6px}p{font-size:13px;color:#7c8ca0;line-height:1.8;margin:0 0 29px}label{display:block;font-size:13px;font-weight:600;margin-bottom:9px}input{width:100%;height:48px;border:1px solid #d8e0e9;border-radius:10px;padding:0 14px;font-size:15px;outline:0}input:focus{border-color:#386aab;box-shadow:0 0 0 3px #417cbc1c}button{width:100%;margin-top:22px;background:#19446f;color:#fff;height:48px;border:0;border-radius:10px;font-size:14px;font-weight:650;cursor:pointer}button:disabled{opacity:.65}small{display:block;text-align:center;margin-top:23px;color:#96a4b6}#error{margin:12px 0 0;color:#b63d3d;min-height:18px;font-size:13px}
</style></head><body><main><div class="symbol">S✳</div><h1>欢迎使用 SiteForge</h1><p>登录网站生产工作台,管理网站、内容、主题和静态构建版本。</p><form id="auth"><label for="pass">账号密码</label><input id="username" type="text" value="admin" required autocomplete="username" placeholder="管理员或团队用户名"><label for="pass">管理员密码 / 团队密码</label><input id="pass" type="password" required autofocus autocomplete="current-password" placeholder="请输入部署时设置的密码"><div id="error" role="alert"></div><button id="submit" type="submit">登录工作台 →</button></form><small>SiteForge Web · Factory 7.1.0</small></main><script> </style></head><body><main><div class="symbol">S✳</div><h1>欢迎使用 SiteForge</h1><p>登录网站生产工作台,管理网站、内容、主题和静态构建版本。</p><form id="auth"><label for="pass">账号密码</label><input id="username" type="text" value="admin" required autocomplete="username" placeholder="管理员或团队用户名"><label for="pass">管理员密码 / 团队密码</label><input id="pass" type="password" required autofocus autocomplete="current-password" placeholder="请输入部署时设置的密码"><div id="error" role="alert"></div><button id="submit" type="submit">登录工作台 →</button></form><small>SiteForge Web · Enterprise 8.19</small></main><script>
document.getElementById('auth').addEventListener('submit',async e=>{e.preventDefault();const b=document.getElementById('submit'),err=document.getElementById('error');b.disabled=true;err.textContent='';try{const res=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('username').value,password:document.getElementById('pass').value})});if(!res.ok){const d=await res.json();throw new Error(d.detail||'登录失败')}location.replace('/')}catch(e){err.textContent=e.message;b.disabled=false}}); document.getElementById('auth').addEventListener('submit',async e=>{e.preventDefault();const b=document.getElementById('submit'),err=document.getElementById('error');b.disabled=true;err.textContent='';try{const res=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('username').value,password:document.getElementById('pass').value})});if(!res.ok){const d=await res.json();throw new Error(d.detail||'登录失败')}const next=new URLSearchParams(location.search).get('next')||'/';const safe=next==='/'||next.startsWith('/siteforge/')&&!next.startsWith('//');location.replace(safe?next:'/')}catch(e){err.textContent=e.message;b.disabled=false}});
</script></body></html> </script></body></html>
+1 -1
View File
@@ -5,6 +5,6 @@
<div class="two"><section><div class="section-head"><h2>01 · CMS 数据迁移</h2><small>先预演,再写入网站数据库</small></div><div class="fields"><label>来源系统<select id="platform"><option value="wordpress-rest">WordPress REST JSON</option><option value="woocommerce">WooCommerce 产品 JSON</option><option value="drupal-jsonapi">Drupal JSON:API</option><option value="shopify">Shopify 商品 JSON</option></select></label><label>导出 JSON<input id="file" type="file" accept=".json,application/json"></label></div><div class="actions"><button id="previewImport">预演与查错</button><button class="secondary" id="commitImport" disabled>确认导入</button></div><div id="report" class="report">选择文件后,预演将显示内容数量、URL 错误和复杂字段待复核事项。</div></section> <div class="two"><section><div class="section-head"><h2>01 · CMS 数据迁移</h2><small>先预演,再写入网站数据库</small></div><div class="fields"><label>来源系统<select id="platform"><option value="wordpress-rest">WordPress REST JSON</option><option value="woocommerce">WooCommerce 产品 JSON</option><option value="drupal-jsonapi">Drupal JSON:API</option><option value="shopify">Shopify 商品 JSON</option></select></label><label>导出 JSON<input id="file" type="file" accept=".json,application/json"></label></div><div class="actions"><button id="previewImport">预演与查错</button><button class="secondary" id="commitImport" disabled>确认导入</button></div><div id="report" class="report">选择文件后,预演将显示内容数量、URL 错误和复杂字段待复核事项。</div></section>
<section><div class="section-head"><h2>02 · 导入风险复核</h2><small>处理插件字段与内容关系;迁移站上线前必须清零</small></div><div id="reviews" class="job-list">暂无待复核事项</div><p class="muted">复核人必须填写处理说明;仅更改记录状态不会自动修复旧站内容,请人工检查预览。</p></section></div> <section><div class="section-head"><h2>02 · 导入风险复核</h2><small>处理插件字段与内容关系;迁移站上线前必须清零</small></div><div id="reviews" class="job-list">暂无待复核事项</div><p class="muted">复核人必须填写处理说明;仅更改记录状态不会自动修复旧站内容,请人工检查预览。</p></section></div>
<div class="two"><section><div class="section-head"><h2>03 · 生产构建任务</h2><small>由独立 Worker 处理</small></div><p class="muted">保存内容后提交任务。相同的构建请求会去重,构建可取消、重试或在超时后恢复。</p><div class="actions"><button id="build">提交异步构建</button><a id="edit" class="secondary link" href="#">打开网站编辑器 ↗</a></div><div id="jobList" class="job-list">加载中…</div></section></div> <div class="two"><section><div class="section-head"><h2>03 · 生产构建任务</h2><small>由独立 Worker 处理</small></div><p class="muted">保存内容后提交任务。相同的构建请求会去重,构建可取消、重试或在超时后恢复。</p><div class="actions"><button id="build">提交异步构建</button><a id="edit" class="secondary link" href="#">打开网站编辑器 ↗</a></div><div id="jobList" class="job-list">加载中…</div></section></div>
<div class="two"><section><div class="section-head"><h2>04 · 构建质量与审批</h2><small>分权审核发布</small></div><div id="buildList" class="job-list"></div><h3>发布审批记录</h3><div id="approvals" class="job-list"></div></section><section><div class="section-head"><h2>05 · 远程发布</h2><small>HTTPS 签名发布 Agent</small></div><p class="muted">仅能部署已审批、质量检查通过的构建;目标服务器地址由管理员提前设置,不能在这里随意填写。</p><div class="fields"><label>目标环境<select id="target"><option value="staging">测试环境 staging</option><option value="production">生产环境 production</option></select></label><label>已构建版本<select id="releaseBuild"></select></label></div><div class="actions"><button id="deploy">发布所选版本</button><button class="secondary" id="refreshRelease">刷新部署历史</button></div><h3>已发布版本</h3><div id="releases" class="job-list">暂无部署记录</div></section></div> <div class="two"><section><div class="section-head"><h2>04 · 构建质量与审批</h2><small>分权审核发布</small></div><div id="buildList" class="job-list"></div><h3>发布审批记录</h3><div id="approvals" class="job-list"></div></section><section><div class="section-head"><h2>05 · 远程发布</h2><small>Agent / SFTP / S3</small></div><p class="muted">只能部署已审批且质量检查通过的构建;发布方式由管理员预先配置,S3 需指针感知的 HTTPS 网关。</p><div class="fields"><label>目标环境<select id="target"><option value="staging">测试环境 staging</option><option value="production">生产环境 production</option></select></label><label>已构建版本<select id="releaseBuild"></select></label></div><div class="actions"><button id="deploy">发布所选版本</button><button class="secondary" id="refreshRelease">刷新部署历史</button></div><p class="muted" id="releaseBackend" aria-live="polite">读取发布目标配置…</p><h3>已发布版本与回滚</h3><div id="releases" class="job-list">暂无部署记录</div></section></div>
<footer>企业运行与迁移中心 · 第三方 CMS 复杂插件、PostgreSQL/Redis 和公网部署应分别完成真实环境验收。</footer> <footer>企业运行与迁移中心 · 第三方 CMS 复杂插件、PostgreSQL/Redis 和公网部署应分别完成真实环境验收。</footer>
</main></div></body></html> </main></div></body></html>
+7 -1
View File
@@ -4,7 +4,7 @@
[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}} [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><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head><body data-sf-page="production"> </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> <header><div><h1>SiteForge · 网站生产控制台 <span style="font-size:11px;color:#acd5ff">V8.21 · 高级工具</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> <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> <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> <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>
@@ -51,6 +51,11 @@
<button id="wpCancel" class="danger">取消迁移</button> <button id="wpCancel" class="danger">取消迁移</button>
<button id="wpReload">刷新状态</button><button id="wpAudit">对比最新构建的 URL/SEO</button> <button id="wpReload">刷新状态</button><button id="wpAudit">对比最新构建的 URL/SEO</button>
</div> </div>
<div class="row" style="margin-top:12px;align-items:flex-end">
<div style="flex:2;min-width:250px"><label for="wpPreflightFile">V8.20 离线 WXR 兼容预检(不会导入或修改内容)</label><input id="wpPreflightFile" type="file" accept=".xml,application/xml"></div>
<button id="wpPreflight" class="primary">检查旧 URL / Elementor / ACF / 动态功能</button>
</div>
<pre id="wpPreflightResult" class="message">上传有权迁移的 WordPress WXR 文件,可检查路径冲突、复杂字段和动态功能。预检不等于迁移完成。</pre>
<pre id="wpResult" class="message">选择站点并新建迁移会话。每页请求独立保存检查点;采集完成后可明确审核 URL、SEO、缺失内容。</pre> <pre id="wpResult" class="message">选择站点并新建迁移会话。每页请求独立保存检查点;采集完成后可明确审核 URL、SEO、缺失内容。</pre>
</section> </section>
@@ -128,6 +133,7 @@ $('preflight').onclick=()=>applyTemplate(false);$('apply').onclick=()=>applyTemp
$('build').onclick=async()=>{try{show('buildResult',await api(`/api/sites/${current()}/builds`,'POST',{mirror_images:false}))}catch(e){show('buildResult','错误:'+e.message)}}; $('build').onclick=async()=>{try{show('buildResult',await api(`/api/sites/${current()}/builds`,'POST',{mirror_images:false}))}catch(e){show('buildResult','错误:'+e.message)}};
async function loadWpSessions(){try{if(!current())return;const result=await api(`/api/migrations/sites/${current()}/wordpress/sessions`);const select=$('wpSession');const chosen=select.value;select.replaceChildren(...result.items.map(s=>opt(s.id,`${s.origin} · ${s.state} · ${s.fetched} 条`)));if(result.items.some(s=>s.id===chosen))select.value=chosen;if(select.value){const item=await api(`/api/migrations/sites/${current()}/wordpress/sessions/${select.value}`);show('wpResult',item)}}catch(e){show('wpResult','查询错误:'+e.message)}} async function loadWpSessions(){try{if(!current())return;const result=await api(`/api/migrations/sites/${current()}/wordpress/sessions`);const select=$('wpSession');const chosen=select.value;select.replaceChildren(...result.items.map(s=>opt(s.id,`${s.origin} · ${s.state} · ${s.fetched} 条`)));if(result.items.some(s=>s.id===chosen))select.value=chosen;if(select.value){const item=await api(`/api/migrations/sites/${current()}/wordpress/sessions/${select.value}`);show('wpResult',item)}}catch(e){show('wpResult','查询错误:'+e.message)}}
async function wpAction(action){const sid=$('wpSession').value;if(!sid)throw Error('请先创建迁移会话');return api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/${action}`,'POST',{})} async function wpAction(action){const sid=$('wpSession').value;if(!sid)throw Error('请先创建迁移会话');return api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/${action}`,'POST',{})}
$('wpPreflight').onclick=async()=>{try{if(!current())throw Error('请先选择网站');const file=$('wpPreflightFile').files[0];if(!file)throw Error('请选择 WordPress WXR XML 文件');const form=new FormData();form.append('file',file);const result=await api(`/api/migrations/sites/${current()}/wordpress/wxr-preflight`,'POST',form);show('wpPreflightResult',result)}catch(e){show('wpPreflightResult','兼容性预检失败:'+e.message)}};
$('wpCreate').onclick=async()=>{try{const data=await api(`/api/migrations/sites/${current()}/wordpress/sessions`,'POST',{origin:$('wpOrigin').value,max_items:Number($('wpLimit').value)});await loadWpSessions();$('wpSession').value=data.id;show('wpResult',data)}catch(e){show('wpResult','错误:'+e.message)}}; $('wpCreate').onclick=async()=>{try{const data=await api(`/api/migrations/sites/${current()}/wordpress/sessions`,'POST',{origin:$('wpOrigin').value,max_items:Number($('wpLimit').value)});await loadWpSessions();$('wpSession').value=data.id;show('wpResult',data)}catch(e){show('wpResult','错误:'+e.message)}};
let wpAutoRunning=false; let wpAutoRunning=false;
$('wpAuto').onclick=async()=>{if(wpAutoRunning)return;wpAutoRunning=true;let count=0;try{while(wpAutoRunning&&count<200){const r=await wpAction('advance');show('wpResult',r);count++;if(['ready','completed','cancelled','limit_reached'].includes(r.state))break;await new Promise(done=>setTimeout(done,350))}await loadWpSessions()}catch(e){show('wpResult','暂停:'+e.message)}finally{wpAutoRunning=false}}; $('wpAuto').onclick=async()=>{if(wpAutoRunning)return;wpAutoRunning=true;let count=0;try{while(wpAutoRunning&&count<200){const r=await wpAction('advance');show('wpResult',r);count++;if(['ready','completed','cancelled','limit_reached'].includes(r.state))break;await new Promise(done=>setTimeout(done,350))}await loadWpSessions()}catch(e){show('wpResult','暂停:'+e.message)}finally{wpAutoRunning=false}};
+1
View File
@@ -16,6 +16,7 @@
<button data-view="design" class="nav-item"><span class="nav-ico">◈</span><span>外观与设计</span></button> <button data-view="design" class="nav-item"><span class="nav-ico">◈</span><span>外观与设计</span></button>
<button data-view="import" class="nav-item"><span class="nav-ico">⇩</span><span>导入与迁移</span></button> <button data-view="import" class="nav-item"><span class="nav-ico">⇩</span><span>导入与迁移</span></button>
<button data-view="build" class="nav-item"><span class="nav-ico">↗</span><span>构建与交付</span></button> <button data-view="build" class="nav-item"><span class="nav-ico">↗</span><span>构建与交付</span></button>
<button data-view="languages" class="nav-item"><span class="nav-ico">文</span><span>多语言管理</span></button>
<button data-view="settings" class="nav-item"><span class="nav-ico">⚙</span><span>网站设置</span></button> <button data-view="settings" class="nav-item"><span class="nav-ico">⚙</span><span>网站设置</span></button>
</div> </div>
<details class="advanced"><summary><span class="nav-ico">⌘</span>高级工具 <span class="chevron">⌄</span></summary> <details class="advanced"><summary><span class="nav-ico">⌘</span>高级工具 <span class="chevron">⌄</span></summary>
+5 -4
View File
@@ -16,15 +16,15 @@ from markupsafe import Markup
from .core import sanitize, route_path, THEMES from .core import sanitize, route_path, THEMES
ROOT=Path(__file__).resolve().parent.parent ROOT=Path(__file__).resolve().parent.parent
LOADER=Environment(loader=FileSystemLoader(str(ROOT/'themes')),autoescape=select_autoescape(['html','xml']))
_ENVS={} _ENVS={}
def env_for(theme): def env_for(theme):
"""Per-theme Jinja env: theme dir first, shared themes/ root as fallback. """Per-theme Jinja env: theme dir first, shared themes/ root as fallback.
Lets a theme override shared macros (_professional_components.html) while Lets a theme override shared macros (_professional_components.html) while
inheriting everything else. Cached: one env per theme across large builds. inheriting everything else. Cached per theme; degraded envs are not cached.
Only built-in theme names are accepted; callers are expected to enforce the
THEMES whitelist too (render does), this is defense in depth.
""" """
if theme not in THEMES:raise ValueError('未知主题') if theme not in THEMES:raise ValueError('未知主题')
env=_ENVS.get(theme) env=_ENVS.get(theme)
@@ -33,7 +33,7 @@ def env_for(theme):
canonical=theme_dir.is_dir() canonical=theme_dir.is_dir()
search=[str(theme_dir),str(ROOT/'themes')] if canonical else [str(ROOT/'themes')] search=[str(theme_dir),str(ROOT/'themes')] if canonical else [str(ROOT/'themes')]
env=Environment(loader=FileSystemLoader(search),autoescape=select_autoescape(['html','xml'])) env=Environment(loader=FileSystemLoader(search),autoescape=select_autoescape(['html','xml']))
if canonical:_ENVS[theme]=env # degraded envs are not cached if canonical:_ENVS[theme]=env
return env return env
TITLES={'product':'产品中心','vehicle':'车型中心','post':'新闻资讯','case':'项目案例','brand':'品牌中心','service':'服务与解决方案'} TITLES={'product':'产品中心','vehicle':'车型中心','post':'新闻资讯','case':'项目案例','brand':'品牌中心','service':'服务与解决方案'}
@@ -189,6 +189,7 @@ def make_context(site,page,records,mode='detail',listing_items=None,prepared=Non
'features':groups['service'][:3], 'stories':groups['post'][:3], 'cases':groups['case'][:3], 'features':groups['service'][:3], 'stories':groups['post'][:3], 'cases':groups['case'][:3],
'brands':assets['brands'],'categories':assets['categories'], 'nav':nav, 'brands':assets['brands'],'categories':assets['categories'], 'nav':nav,
'chrome':chrome,'chrome_is_custom':bool(settings.get('site_design')), 'chrome':chrome,'chrome_is_custom':bool(settings.get('site_design')),
'mobile_tab_icons':{'home':'⌂','grid':'▦','products':'▤','news':'☷','contact':'✉','phone':'☎','info':'ⓘ','search':'⌕','menu':'☰','star':'☆'},
'footer_nav':nav if chrome['footer']['show_nav'] else [],'specs':spec, 'footer_nav':nav if chrome['footer']['show_nav'] else [],'specs':spec,
'related':related,'fields':detail_fields,'content':Markup(sanitize(pg.get('body') or '')), 'related':related,'fields':detail_fields,'content':Markup(sanitize(pg.get('body') or '')),
'title':title, 'seo_title':pg.get('seo_title') or title,'seo_description':pg.get('seo_description') or pg.get('summary') or site.get('tagline') or '', 'title':title, 'seo_title':pg.get('seo_title') or title,'seo_description':pg.get('seo_description') or pg.get('summary') or site.get('tagline') or '',
+1 -1
View File
@@ -8,10 +8,10 @@ from __future__ import annotations
import io, json, re, zipfile, shutil import io, json, re, zipfile, shutil
from pathlib import Path from pathlib import Path
from . import db from . import db
from .core import THEMES as BUILTIN
BASE=Path(__file__).resolve().parent.parent/'themes' BASE=Path(__file__).resolve().parent.parent/'themes'
THEME_ROOT=db.DATA/'theme_packages' THEME_ROOT=db.DATA/'theme_packages'
from .core import THEMES as BUILTIN
THEME_ID=re.compile(r'^[a-z][a-z0-9-]{2,39}$') THEME_ID=re.compile(r'^[a-z][a-z0-9-]{2,39}$')
VERSION=re.compile(r'^\d+\.\d+\.\d+$') VERSION=re.compile(r'^\d+\.\d+\.\d+$')
+20 -2
View File
@@ -192,6 +192,13 @@ def parse_wxr(content):
if len(content)>MAX_UPLOAD:raise ValueError('XML 文件超过限制') if len(content)>MAX_UPLOAD:raise ValueError('XML 文件超过限制')
root=ET.fromstring(content) root=ET.fromstring(content)
if root.tag!='rss':raise ValueError('这不是 WordPress WXR RSS 导出') if root.tag!='rss':raise ValueError('这不是 WordPress WXR RSS 导出')
# Stage only unambiguous permanent paths; dynamic functions remain review items.
from .wp_complex_v820 import inspect_wxr
checks=inspect_wxr(content)
blockers=[x for x in checks['issues'] if x['code'] in ('invalid_permalink','duplicate_route')]
if blockers or (checks['findings_truncated'] and checks['blocking_issues']):
raise ValueError('存在不能安全映射的 WordPress URL,请先运行 V8.20 WXR 预检并修正:'+
', '.join(x['code']+':'+x['route'] for x in blockers[:5]))
rows=[];ids=[];warnings=[] rows=[];ids=[];warnings=[]
for it in root.findall('./channel/item'): for it in root.findall('./channel/item'):
get=lambda name:next((str(node.text or '') for node in it if node.tag.endswith('}'+name) or node.tag==name),'') get=lambda name:next((str(node.text or '') for node in it if node.tag.endswith('}'+name) or node.tag==name),'')
@@ -216,12 +223,23 @@ def parse_wxr(content):
if k:metas[k]=v[:10000] if k:metas[k]=v[:10000]
if any(k in metas for k in ('_elementor_data','_product_attributes')): if any(k in metas for k in ('_elementor_data','_product_attributes')):
warnings.append({'row':len(rows)+1,'issue':'含复杂 Elementor/WooCommerce 组件,导入后需人工核对'}) warnings.append({'row':len(rows)+1,'issue':'含复杂 Elementor/WooCommerce 组件,导入后需人工核对'})
rows.append({'标题':title,'路径':route,'正文':get('encoded'), rows.append({'标题':title,'路径':route,'正文':get('encoded'),'类型':raw_typ,
'SEO标题':metas.get('_yoast_wpseo_title') or metas.get('rank_math_title',''), 'SEO标题':metas.get('_yoast_wpseo_title') or metas.get('rank_math_title',''),
'SEO描述':metas.get('_yoast_wpseo_metadesc') or metas.get('rank_math_description',''), 'SEO描述':metas.get('_yoast_wpseo_metadesc') or metas.get('rank_math_description',''),
'分类':raw_typ}) '分类':raw_typ})
ids.append(f'wordpress:{wid}' if wid else f'wordpress:path:{route}') ids.append(f'wordpress:{wid}' if wid else f'wordpress:path:{route}')
return rows,ids,warnings for finding in checks['issues']:
if finding['severity'] not in ('review','blocker'):continue
if finding['code'] in ('invalid_permalink','duplicate_route'):continue
route=finding['route']
index=next((i+1 for i,row in enumerate(rows) if row['路径']==route),0)
if index:
if finding['code'].startswith('elementor') and any(w.get('row')==index and 'Elementor' in w.get('issue','') for w in warnings):
continue
warnings.append({'row':index,'issue':finding['code']+': '+finding['message']})
# Bound warnings written into batch metadata and shown for human review.
return rows,ids,warnings[:200]
@router.post('/{site_id}/staging') @router.post('/{site_id}/staging')
def api_stage(request:Request,site_id:str,payload:dict=Body(...)): def api_stage(request:Request,site_id:str,payload:dict=Body(...)):
+1 -1
View File
@@ -1,5 +1,5 @@
"""Single source of truth for the SiteForge platform release identity.""" """Single source of truth for the SiteForge platform release identity."""
import os import os
VERSION = "8.12.0-dev.1" VERSION = "8.21.3-dev.1"
PLATFORM_NAME = "SiteForge Factory" PLATFORM_NAME = "SiteForge Factory"
BUILD_SHA = os.environ.get("SITEFORGE_GIT_SHA", "unknown").strip()[:40] or "unknown" BUILD_SHA = os.environ.get("SITEFORGE_GIT_SHA", "unknown").strip()[:40] or "unknown"
+10 -4
View File
@@ -6,7 +6,7 @@ import subprocess
import sys import sys
import time import time
import uuid import uuid
from . import db, enterprise, builder, jobs from . import db, enterprise, builder, jobs, worker_health
running=True running=True
WORKER_ID=uuid.uuid4().hex[:12] WORKER_ID=uuid.uuid4().hex[:12]
@@ -17,7 +17,7 @@ def heartbeat():
This single-worker probe is deliberately not used as a multi-worker This single-worker probe is deliberately not used as a multi-worker
membership system; multi-worker orchestration needs separate leases. membership system; multi-worker orchestration needs separate leases.
""" """
path=db.DATA/'worker-heartbeat' path=worker_health.heartbeat_path()
path.parent.mkdir(parents=True,exist_ok=True) path.parent.mkdir(parents=True,exist_ok=True)
temp=path.with_suffix(f'.{WORKER_ID}.tmp') temp=path.with_suffix(f'.{WORKER_ID}.tmp')
temp.write_text(WORKER_ID,encoding='ascii') temp.write_text(WORKER_ID,encoding='ascii')
@@ -49,11 +49,17 @@ def complete(job, value=None, error=None, retryable=False, cancelled=False, time
current=c.execute('SELECT * FROM build_jobs WHERE id=?',(job['id'],)).fetchone() current=c.execute('SELECT * FROM build_jobs WHERE id=?',(job['id'],)).fetchone()
if not current or current['state']!='running' or current['worker_id']!=WORKER_ID: if not current or current['state']!='running' or current['worker_id']!=WORKER_ID:
return return
# A networked release may already have changed the remote pointer.
# Cancellation/timeout/crash is UNKNOWN remote outcome, not safe retry.
external=current['kind'] in ('remote_deploy','remote_rollback')
if cancelled or current['cancel_requested']: if cancelled or current['cancel_requested']:
state='cancelled' if external:
state='failed'
error='Release interrupted; reconcile target before manual retry'
else:state='cancelled'
elif error is None: elif error is None:
state='done' state='done'
elif retryable and current['attempts']<current['max_attempts']: elif retryable and not external and current['attempts']<current['max_attempts']:
state='retrying' state='retrying'
else:state='failed' else:state='failed'
delay=min(60,2**current['attempts']) if state=='retrying' else 0 delay=min(60,2**current['attempts']) if state=='retrying' else 0
+22 -5
View File
@@ -1,18 +1,35 @@
"""Docker worker liveness probe: no localhost Web dependency.""" """Docker worker liveness probe: no localhost Web dependency."""
import os import os
import time import time
import socket
import re
from pathlib import Path from pathlib import Path
from . import db from . import db
def healthy(path=None, max_age=None): def heartbeat_path():
file=Path(path) if path is not None else db.DATA/'worker-heartbeat' instance=os.environ.get('SITEFORGE_WORKER_INSTANCE', '')
if not instance and db.POSTGRES:
instance=socket.gethostname()
if instance:
if not re.fullmatch(r'[A-Za-z0-9._-]{1,120}',instance):
raise ValueError('Invalid worker instance identifier')
return db.DATA/'worker-heartbeats'/instance
return db.DATA/'worker-heartbeat'
def healthy(path=None, max_age=None, check_store=False):
file=Path(path) if path is not None else heartbeat_path()
limit=float(max_age if max_age is not None else os.getenv('SITEFORGE_WORKER_HEALTH_MAX_AGE','45')) limit=float(max_age if max_age is not None else os.getenv('SITEFORGE_WORKER_HEALTH_MAX_AGE','45'))
if limit <= 0:return False if limit <= 0:return False
try: try:
age=time.time()-file.stat().st_mtime age=time.time()-file.stat().st_mtime
return -5 <= age < limit fresh=-5 <= age < limit
except OSError: if not fresh:return False
if check_store:
from .healthchecks import readiness
return readiness()
return True
except (OSError,ValueError):
return False return False
if __name__=='__main__': if __name__=='__main__':
raise SystemExit(0 if healthy() else 1) raise SystemExit(0 if healthy(check_store=True) else 1)
+183
View File
@@ -0,0 +1,183 @@
"""V8.20 offline WordPress WXR compatibility preflight.
No network access, PHP evaluation or database writes. Findings are blockers or
explicit review items, never evidence of pixel-identical migration.
"""
from __future__ import annotations
import re
from collections import Counter
from urllib.parse import urlsplit
from defusedxml import ElementTree as SafeET
from bs4 import BeautifulSoup
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from . import enterprise
from .core import route_path
router = APIRouter(prefix='/api/migrations/sites',tags=['WordPress V8.20 preflight'])
MAX_XML = 16_000_000
MAX_ITEMS = 20_000
MAX_FINDINGS = 500
MAX_META = 500
SHORTCODE = re.compile(r'\[(?!/)(contact-form-7|gravityform|woocommerce|products|wpforms|elementor-template|rev_slider)\b',re.I)
SERIALIZED = re.compile(r'^(?:a:\d+:\{|O:\d+:|C:\d+:|s:\d+:|b:[01];)',re.S)
def postmeta(item):
"""Preserve duplicate keys as a finding instead of silently overwriting them."""
result={};duplicate=[]
for node in item:
if node.tag.rsplit('}',1)[-1]!='postmeta':continue
key=value=''
for field in node:
label=field.tag.rsplit('}',1)[-1]
if label=='meta_key':key=field.text or ''
elif label=='meta_value':value=field.text or ''
if key:
if key in result:duplicate.append(key[:100])
result[key]=value
if len(result)>MAX_META:raise ValueError('单篇文章元数据字段超过安全上限')
return result,duplicate[:30]
def extract_acf(meta):
"""Bounded, literal ACF postmeta extraction. Never PHP-unserialize or infer field schema.
Can safely represent flat fields and simple `repeater_0_subfield` records.
Repeater field types are not guaranteed by the exported meta alone.
"""
if len(meta)>MAX_META:raise ValueError('ACF meta exceeds safety limit')
scalar={};repeaters={};warnings=[]
referenced={k[1:] for k,v in meta.items() if k.startswith('_') and isinstance(v,str) and v.startswith('field_')}
for key in sorted(referenced):
if not key or key not in meta:continue
value=meta[key]
if not isinstance(value,str):continue
if SERIALIZED.match(value.strip()):
warnings.append(f'ACF 字段 {key[:75]} 含 PHP 序列化结构,未进行反序列化')
continue
if len(value.encode('utf-8'))>10_000:
warnings.append(f'ACF 字段 {key[:75]} 超出单值安全上限')
continue
match=re.fullmatch(r'(.{1,80})_(\d{1,2})_([A-Za-z][A-Za-z0-9_]*)',key)
if match:
group,index,field=match.group(1),int(match.group(2)),match.group(3)
if index>=50:continue
repeaters.setdefault(group,{}).setdefault(index,{})[field]=value
elif re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,100}',key):
scalar[key]=value
result={}
for group,values in sorted(repeaters.items()):
count=meta.get(group)
if not (isinstance(count,str) and count.isdigit() and 1<=int(count)<=50):
warnings.append(f'ACF 类重复字段 {group} 缺少有效行数,保留原始键供人工核对')
continue
expected=int(count)
if any(i>=expected for i in values):
warnings.append(f'ACF 类重复字段 {group} 存在超出声明行数的字段')
# Only expose a complete contiguous set. Partial repeaters remain metadata-only.
if any(i not in values for i in range(expected)):
warnings.append(f'ACF 类重复字段 {group} 行不连续,必须人工复核')
continue
if sum(len(x) for x in values.values())>1500:
warnings.append(f'ACF 类重复字段 {group} 子字段超出安全上限')
continue
result[group]=[values[i] for i in range(expected)]
if repeaters:
warnings.append('ACF Repeater 由字段命名模式推断;必须与源站字段组定义核对')
return {'fields':scalar,'repeaters':result,'warnings':list(dict.fromkeys(warnings))[:80]}
def inspect_wxr(data:bytes):
if not data or len(data)>MAX_XML:raise ValueError('WXR 必须非空且不超过 16 MB')
root=SafeET.fromstring(data)
if root.tag.rsplit('}',1)[-1]!='rss':raise ValueError('需要 WordPress WXR RSS XML')
items=root.findall('./channel/item')
if not items:raise ValueError('WXR 缺少 channel/item 记录')
if len(items)>MAX_ITEMS:raise ValueError('WXR 超过 20000 条记录上限')
counts=Counter();issues=[];routes={};features=Counter();example=[];hosts=set()
def issue(severity,code,wp_id,message,route=''):
counts['findings_'+severity]+=1
if len(issues)<MAX_FINDINGS:
issues.append({'severity':severity,'code':code,'wp_post_id':str(wp_id)[:50],
'route':route,'message':message[:240]})
from . import wordpress_compat
for node in items:
direct={n.tag.rsplit('}',1)[-1]:(n.text or '') for n in node if n.tag.rsplit('}',1)[-1] in ('post_type','status','post_id','link','title')}
kind=direct.get('post_type','post');status=direct.get('status','publish');wp_id=direct.get('post_id','')
counts['total_records']+=1;counts['type_'+kind[:40]]+=1
if kind in ('attachment','revision','nav_menu_item'):
if kind=='attachment':counts['attachments']+=1
continue
if status!='publish':
counts['non_public']+=1
continue
counts['published_content']+=1
link=direct.get('link','')
try:
p=urlsplit(link)
if p.scheme not in ('http','https') or not p.hostname or p.username or p.password or p.query or p.fragment or not p.path:
raise ValueError('缺少固定 HTTP(S) 路径或使用动态参数 URL')
route=route_path(p.path)
except (ValueError,TypeError):
issue('blocker','invalid_permalink',wp_id,'原链接无法无损映射为静态 URL;需要显式路径/301 映射')
continue
host=p.hostname.lower()
if hosts and host not in hosts:
issue('review','mixed_source_hosts',wp_id,'源站出现多个主机名,请核对 www/非 www、旧域和路径所属关系',route)
hosts.add(host)
if route in routes:
issue('blocker','duplicate_route',wp_id,'URL 路径与其他已发布内容冲突',route)
continue
routes[route]=wp_id
if len(example)<30:example.append({'route':route,'type':kind,'wp_post_id':wp_id})
meta,dup=postmeta(node)
if kind in ('product','product_variation') or '_product_attributes' in meta:
features['woocommerce']+=1
issue('review','woocommerce',wp_id,'WooCommerce 产品、变体、库存、定价与购物车行为需独立迁移验收',route)
for key in dup[:5]:issue('review','duplicate_postmeta',wp_id,'重复 WordPress 自定义字段:'+key,route)
acf=extract_acf(meta)
if acf['fields'] or acf['repeaters']:
features['acf']+=1
for warning in acf['warnings'][:5]:issue('review','acf_field',wp_id,warning,route)
for key,value in meta.items():
if not key.startswith('_') and isinstance(value,str) and SERIALIZED.match(value.strip()):
features['serialized_meta']+=1
issue('review','serialized_meta',wp_id,'含 PHP 序列化元数据:'+key[:60]+',不会执行或自动解析',route)
if features['serialized_meta']>10:break
if meta.get('_elementor_data'):
features['elementor']+=1
result=wordpress_compat.elementor_to_html(meta['_elementor_data'])
if result['warnings']:
issue('review','elementor',wp_id,'Elementor 非无损转换:'+result['warnings'][0],route)
else:issue('review','elementor_layout',wp_id,'Elementor 样式、容器、响应式布局需要视觉对照验收',route)
body=next((e.text or '' for e in node if e.tag.startswith('{http://purl.org/rss/1.0/modules/content/}') and e.tag.endswith('encoded')),'')
s=BeautifulSoup(body,'html.parser')
if SHORTCODE.search(body):
features['dynamic_shortcodes']+=1
issue('blocker','dynamic_shortcode',wp_id,'存在动态短代码,纯静态导出不能提供同等业务行为',route)
if s.select('form,iframe'):
features['dynamic_html']+=1
issue('review','dynamic_html',wp_id,'表单或 iframe 需单独确认服务、第三方依赖与可用性',route)
if any(n.get('src','').startswith(('http://','https://')) for n in s.select('img[src]')):
features['external_images']+=1
issue('review','external_media',wp_id,'正文使用远程图片;需执行本地化及完整性对账',route)
if kind not in ('post','page','product','service','case','brand','vehicle'):
features['custom_post_types']+=1
issue('review','custom_post_type',wp_id,'自定义文章类型 '+kind[:60]+' 将映射 other;模板/字段关系需人工确认',route)
blocked=counts['findings_blocker']
from .version import VERSION
return {'version':VERSION,'input_bytes':len(data),'counts':dict(counts),
'unique_published_routes':len(routes),'features':dict(features),
'issues':issues,'findings_truncated':sum(v for k,v in counts.items() if k.startswith('findings_'))>len(issues),
'sample_routes':example,'source_hosts':sorted(hosts),'ready_for_automatic_publish':False,
'blocking_issues':blocked,
'note':'仅离线识别兼容性与风险;预检不改动站点、不执行 PHP,不构成 1:1 迁移验收。'}
@router.post('/{site_id}/wordpress/wxr-preflight')
async def wxr_preflight(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
data=await file.read(MAX_XML+1)
try:return inspect_wxr(data)
except (ValueError,SafeET.ParseError) as exc:raise HTTPException(400,str(exc))
+33 -6
View File
@@ -3,6 +3,7 @@ and durable worker-backed REST scanning. All flows require explicit authorizatio
and remain subject to the existing SiteForge release-review gates. and remain subject to the existing SiteForge release-review gates.
""" """
from __future__ import annotations from __future__ import annotations
import re
import hashlib import hashlib
import json import json
import uuid import uuid
@@ -160,15 +161,36 @@ def _srcset_urls(value):
return result return result
# CSS background images are common in Elementor containers and ACF galleries.
# Restrict to syntactically explicit remote URLs, never evaluate arbitrary CSS.
CSS_URL = re.compile(r'''url\(\s*([\"\']?)((?:https?:)?//[^\"\')\s]+)\1\s*\)''', re.I)
def _css_urls(raw):
return [match.group(2) for match in CSS_URL.finditer(str(raw or ''))]
def _rewrite_css_urls(raw,converted):
def replace(match):
url=match.group(2)
if url not in converted:return match.group(0)
quote=match.group(1)
return f'url({quote}{converted[url]}{quote})'
updated=CSS_URL.sub(replace,str(raw or ''))
return updated,updated!=str(raw or '')
def _media_urls(content): def _media_urls(content):
"""Collect all remote media references, including responsive and lazy images.""" """Collect images plus responsive/lazy/CSS backgrounds for explicit review."""
urls=[content.get('cover') or ''] urls=[content.get('cover') or '']
soup=BeautifulSoup(content.get('body') or '','html.parser') soup=BeautifulSoup(content.get('body') or '','html.parser')
for tag in soup.find_all(['img','source','video']): for tag in soup.find_all(True):
for key in ('src','data-src','poster'): for key in ('src','data-src','poster','data-background','data-background-image'):
if tag.get(key):urls.append(tag[key]) if tag.get(key):urls.append(tag[key])
for key in ('srcset','data-srcset'): for key in ('srcset','data-srcset'):
urls.extend(_srcset_urls(tag.get(key))) urls.extend(_srcset_urls(tag.get(key)))
if tag.get('style'):urls.extend(_css_urls(tag['style']))
if tag.name=='style':urls.extend(_css_urls(tag.get_text()))
return list(dict.fromkeys(u for u in urls if isinstance(u,str) return list(dict.fromkeys(u for u in urls if isinstance(u,str)
and u.startswith(('https://','http://','//')))) and u.startswith(('https://','http://','//'))))
@@ -187,8 +209,8 @@ def _rewrite_srcset(value,converted):
def _rewrite_media_html(body,converted): def _rewrite_media_html(body,converted):
soup=BeautifulSoup(body,'html.parser');changed=False soup=BeautifulSoup(body,'html.parser');changed=False
for tag in soup.find_all(['img','source','video']): for tag in soup.find_all(True):
for key in ('src','data-src','poster'): for key in ('src','data-src','poster','data-background','data-background-image'):
if tag.get(key) in converted: if tag.get(key) in converted:
tag[key]=converted[tag[key]];changed=True tag[key]=converted[tag[key]];changed=True
for key in ('srcset','data-srcset'): for key in ('srcset','data-srcset'):
@@ -196,11 +218,16 @@ def _rewrite_media_html(body,converted):
new,updated=_rewrite_srcset(tag[key],converted) new,updated=_rewrite_srcset(tag[key],converted)
if updated: if updated:
tag[key]=new;changed=True tag[key]=new;changed=True
# A single redundant srcset has no value and can be removed.
if tag.name=='img' and key=='srcset' and len(new.split(','))==1: if tag.name=='img' and key=='srcset' and len(new.split(','))==1:
fields=new.strip().split() fields=new.strip().split()
if fields and fields[0]==tag.get('src'): if fields and fields[0]==tag.get('src'):
tag.attrs.pop(key,None) tag.attrs.pop(key,None)
if tag.get('style'):
value,updated=_rewrite_css_urls(tag['style'],converted)
if updated:tag['style']=value;changed=True
if tag.name=='style':
value,updated=_rewrite_css_urls(tag.string if tag.string is not None else tag.get_text(),converted)
if updated:tag.string=value;changed=True
return str(soup) if changed else body,changed return str(soup) if changed else body,changed
+5
View File
@@ -0,0 +1,5 @@
# Run on the isolated remote Agent host; never commit ./private-secrets.
services:
agent:
volumes:
- ./private-secrets:/run/secrets:ro
+10 -4
View File
@@ -1,4 +1,4 @@
name: siteforge-v8-12-enterprise name: siteforge-v8-18-enterprise
services: services:
siteforge: siteforge:
build: build:
@@ -6,7 +6,7 @@ services:
dockerfile: Dockerfile.enterprise dockerfile: Dockerfile.enterprise
args: args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown} SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.12.0-dev.1 image: siteforge/web:8.21.3-dev.1
init: true init: true
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -42,13 +42,13 @@ services:
cap_drop: [ALL] cap_drop: [ALL]
security_opt: ['no-new-privileges:true'] security_opt: ['no-new-privileges:true']
healthcheck: healthcheck:
test: ['CMD','python','-c',"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health',timeout=3)"] test: ['CMD','python','-c',"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/ready',timeout=3)"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s start_period: 30s
worker: worker:
image: siteforge/web:8.12.0-dev.1 image: siteforge/web:8.21.3-dev.1
command: ['python','-m','app.worker'] command: ['python','-m','app.worker']
init: true init: true
restart: unless-stopped restart: unless-stopped
@@ -64,6 +64,12 @@ services:
SITEFORGE_MYSQL_READ_PASSWORD: ${SITEFORGE_MYSQL_READ_PASSWORD:-} SITEFORGE_MYSQL_READ_PASSWORD: ${SITEFORGE_MYSQL_READ_PASSWORD:-}
SITEFORGE_DATABASE_URL: postgresql://${SITEFORGE_PG_USER:-siteforge}:${SITEFORGE_PG_PASSWORD:?Set PG password}@postgres:5432/${SITEFORGE_PG_DB:-siteforge} SITEFORGE_DATABASE_URL: postgresql://${SITEFORGE_PG_USER:-siteforge}:${SITEFORGE_PG_PASSWORD:?Set PG password}@postgres:5432/${SITEFORGE_PG_DB:-siteforge}
SITEFORGE_REDIS_URL: redis://redis:6379/0 SITEFORGE_REDIS_URL: redis://redis:6379/0
SITEFORGE_DEPLOY_STAGING_URL: ${SITEFORGE_DEPLOY_STAGING_URL:-}
SITEFORGE_DEPLOY_STAGING_SECRET_REF: ${SITEFORGE_DEPLOY_STAGING_SECRET_REF:-}
SITEFORGE_DEPLOY_PRODUCTION_URL: ${SITEFORGE_DEPLOY_PRODUCTION_URL:-}
SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF: ${SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF:-}
SITEFORGE_STAGING_TOKEN: ${SITEFORGE_STAGING_TOKEN:-}
SITEFORGE_PRODUCTION_TOKEN: ${SITEFORGE_PRODUCTION_TOKEN:-}
SITEFORGE_BUILD_TIMEOUT_SECONDS: ${SITEFORGE_BUILD_TIMEOUT_SECONDS:-300} SITEFORGE_BUILD_TIMEOUT_SECONDS: ${SITEFORGE_BUILD_TIMEOUT_SECONDS:-300}
SITEFORGE_BUILD_MAX_ATTEMPTS: ${SITEFORGE_BUILD_MAX_ATTEMPTS:-3} SITEFORGE_BUILD_MAX_ATTEMPTS: ${SITEFORGE_BUILD_MAX_ATTEMPTS:-3}
depends_on: depends_on:
+40
View File
@@ -0,0 +1,40 @@
# Example only: enable with docker compose -f compose.yaml -f compose.publish-targets.example.yaml up -d --build
# Web receives metadata/config; private keys and IAM credentials are used ONLY by worker.
services:
siteforge:
environment:
SITEFORGE_DEPLOY_STAGING_TYPE: sftp
SITEFORGE_DEPLOY_STAGING_HOST: ${SITEFORGE_STAGING_SFTP_HOST:?Provide authorized host}
SITEFORGE_DEPLOY_STAGING_PORT: "${SITEFORGE_STAGING_SFTP_PORT:-22}"
SITEFORGE_DEPLOY_STAGING_USERNAME: ${SITEFORGE_STAGING_SFTP_USER:?Provide publisher account}
SITEFORGE_DEPLOY_STAGING_REMOTE_ROOT: ${SITEFORGE_STAGING_REMOTE_ROOT:-/srv/siteforge/sites}
SITEFORGE_DEPLOY_STAGING_KNOWN_HOSTS_FILE: /run/secrets/siteforge_known_hosts
SITEFORGE_DEPLOY_STAGING_SSH_KEY_FILE: /run/secrets/siteforge_publish_key
SITEFORGE_DEPLOY_STAGING_PUBLIC_URL: ${SITEFORGE_STAGING_PUBLIC_URL:?Set HTTPS public site URL}
SITEFORGE_DEPLOY_PRODUCTION_TYPE: s3
SITEFORGE_DEPLOY_PRODUCTION_BUCKET: ${SITEFORGE_PRODUCTION_S3_BUCKET:?Set bucket}
SITEFORGE_DEPLOY_PRODUCTION_REGION: ${SITEFORGE_PRODUCTION_S3_REGION:?Set AWS region}
SITEFORGE_DEPLOY_PRODUCTION_PREFIX: ${SITEFORGE_PRODUCTION_S3_PREFIX:-siteforge}
SITEFORGE_DEPLOY_PRODUCTION_POINTER_AWARE: "1"
SITEFORGE_DEPLOY_PRODUCTION_PUBLIC_URL: ${SITEFORGE_PRODUCTION_PUBLIC_URL:?Set pointer-aware gateway HTTPS URL}
worker:
environment:
SITEFORGE_DEPLOY_STAGING_TYPE: sftp
SITEFORGE_DEPLOY_STAGING_HOST: ${SITEFORGE_STAGING_SFTP_HOST:?Provide authorized host}
SITEFORGE_DEPLOY_STAGING_PORT: "${SITEFORGE_STAGING_SFTP_PORT:-22}"
SITEFORGE_DEPLOY_STAGING_USERNAME: ${SITEFORGE_STAGING_SFTP_USER:?Provide publisher account}
SITEFORGE_DEPLOY_STAGING_REMOTE_ROOT: ${SITEFORGE_STAGING_REMOTE_ROOT:-/srv/siteforge/sites}
SITEFORGE_DEPLOY_STAGING_KNOWN_HOSTS_FILE: /run/secrets/siteforge_known_hosts
SITEFORGE_DEPLOY_STAGING_SSH_KEY_FILE: /run/secrets/siteforge_publish_key
SITEFORGE_DEPLOY_STAGING_PUBLIC_URL: ${SITEFORGE_STAGING_PUBLIC_URL:?Set HTTPS public site URL}
SITEFORGE_DEPLOY_PRODUCTION_TYPE: s3
SITEFORGE_DEPLOY_PRODUCTION_BUCKET: ${SITEFORGE_PRODUCTION_S3_BUCKET:?Set bucket}
SITEFORGE_DEPLOY_PRODUCTION_REGION: ${SITEFORGE_PRODUCTION_S3_REGION:?Set AWS region}
SITEFORGE_DEPLOY_PRODUCTION_PREFIX: ${SITEFORGE_PRODUCTION_S3_PREFIX:-siteforge}
SITEFORGE_DEPLOY_PRODUCTION_POINTER_AWARE: "1"
SITEFORGE_DEPLOY_PRODUCTION_PUBLIC_URL: ${SITEFORGE_PRODUCTION_PUBLIC_URL:?Set pointer-aware gateway HTTPS URL}
AWS_REGION: ${SITEFORGE_PRODUCTION_S3_REGION:?Set AWS region}
# Prefer IAM instance/service role; never put AWS static keys into .env.
volumes:
- ./secrets/publish_known_hosts:/run/secrets/siteforge_known_hosts:ro
- ./secrets/publish_key:/run/secrets/siteforge_publish_key:ro
+3 -2
View File
@@ -6,11 +6,12 @@ services:
dockerfile: Dockerfile.enterprise dockerfile: Dockerfile.enterprise
args: args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown} SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.12.0-dev.1 image: siteforge/web:8.21.0-dev.1
command: ['python','-m','uvicorn','app.deploy_agent:app','--host','0.0.0.0','--port','8790'] command: ['python','-m','uvicorn','app.deploy_agent:app','--host','0.0.0.0','--port','8790']
restart: unless-stopped restart: unless-stopped
environment: environment:
SITEFORGE_DEPLOY_SECRET: ${SITEFORGE_DEPLOY_SECRET:?Set 32+ character release secret} SITEFORGE_DEPLOY_SECRET: ${SITEFORGE_DEPLOY_SECRET:-}
SITEFORGE_DEPLOY_SECRET_REF: ${SITEFORGE_DEPLOY_SECRET_REF:-}
SITEFORGE_DEPLOY_ROOT: /srv/releases SITEFORGE_DEPLOY_ROOT: /srv/releases
volumes: volumes:
- releases:/srv/releases - releases:/srv/releases
+6
View File
@@ -0,0 +1,6 @@
# OPTIONAL deployment overlay; copy/rename and adjust paths under local administrator control.
# WARNING: Do not place private keys or real tokens in this repository.
services:
worker:
volumes:
- ./private-secrets:/run/secrets:ro
+27
View File
@@ -0,0 +1,27 @@
# Deploy separately behind a proper TLS proxy. No publication credentials.
# Example: docker compose -f compose.yaml -f compose.s3-gateway.example.yaml up -d --build
services:
s3-site-gateway:
image: siteforge/web:8.21.0-dev.1
depends_on:
siteforge: {condition: service_started}
command: ["python", "-m", "uvicorn", "app.s3_gateway:app", "--host", "0.0.0.0", "--port", "8791"]
restart: unless-stopped
environment:
SITEFORGE_S3_GATEWAY_BUCKET: ${SITEFORGE_PRODUCTION_S3_BUCKET:?Set S3 bucket}
SITEFORGE_S3_GATEWAY_REGION: ${SITEFORGE_PRODUCTION_S3_REGION:?Set region}
SITEFORGE_S3_GATEWAY_PREFIX: ${SITEFORGE_PRODUCTION_S3_PREFIX:-siteforge}
# Supply read-only IAM role via workload identity; do not put keys in source files.
ports:
- "127.0.0.1:${SITEFORGE_GATEWAY_PORT:-8791}:8791"
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 64
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8791/health', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
+13 -3
View File
@@ -6,7 +6,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown} SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.12.0-dev.1 image: siteforge/web:8.21.3-dev.1
init: true init: true
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -21,6 +21,10 @@ services:
SITEFORGE_ADMIN_PASSWORD: ${SITEFORGE_ADMIN_PASSWORD:?Set SITEFORGE_ADMIN_PASSWORD in .env} SITEFORGE_ADMIN_PASSWORD: ${SITEFORGE_ADMIN_PASSWORD:?Set SITEFORGE_ADMIN_PASSWORD in .env}
SITEFORGE_SESSION_SECRET: ${SITEFORGE_SESSION_SECRET:?Set SITEFORGE_SESSION_SECRET in .env} SITEFORGE_SESSION_SECRET: ${SITEFORGE_SESSION_SECRET:?Set SITEFORGE_SESSION_SECRET in .env}
SITEFORGE_COOKIE_SECURE: "${SITEFORGE_COOKIE_SECURE:-false}" SITEFORGE_COOKIE_SECURE: "${SITEFORGE_COOKIE_SECURE:-false}"
SITEFORGE_DEPLOY_STAGING_URL: ${SITEFORGE_DEPLOY_STAGING_URL:-}
SITEFORGE_DEPLOY_STAGING_SECRET_REF: ${SITEFORGE_DEPLOY_STAGING_SECRET_REF:-}
SITEFORGE_DEPLOY_PRODUCTION_URL: ${SITEFORGE_DEPLOY_PRODUCTION_URL:-}
SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF: ${SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF:-}
SITEFORGE_PUBLISH_ROOT: /published SITEFORGE_PUBLISH_ROOT: /published
SITEFORGE_REQUIRE_RELEASE_APPROVAL: "true" SITEFORGE_REQUIRE_RELEASE_APPROVAL: "true"
volumes: volumes:
@@ -35,13 +39,13 @@ services:
- ALL - ALL
pids_limit: 128 pids_limit: 128
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=3)"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/ready', timeout=3)"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s start_period: 30s
worker: worker:
image: siteforge/web:8.12.0-dev.1 image: siteforge/web:8.21.3-dev.1
depends_on: depends_on:
siteforge: {condition: service_started} siteforge: {condition: service_started}
init: true init: true
@@ -58,6 +62,12 @@ services:
# Development-only env secret references; use compose.mysql-secrets.yaml for production files. # Development-only env secret references; use compose.mysql-secrets.yaml for production files.
SITEFORGE_MYSQL_READ_USER: ${SITEFORGE_MYSQL_READ_USER:-} SITEFORGE_MYSQL_READ_USER: ${SITEFORGE_MYSQL_READ_USER:-}
SITEFORGE_MYSQL_READ_PASSWORD: ${SITEFORGE_MYSQL_READ_PASSWORD:-} SITEFORGE_MYSQL_READ_PASSWORD: ${SITEFORGE_MYSQL_READ_PASSWORD:-}
SITEFORGE_DEPLOY_STAGING_URL: ${SITEFORGE_DEPLOY_STAGING_URL:-}
SITEFORGE_DEPLOY_STAGING_SECRET_REF: ${SITEFORGE_DEPLOY_STAGING_SECRET_REF:-}
SITEFORGE_DEPLOY_PRODUCTION_URL: ${SITEFORGE_DEPLOY_PRODUCTION_URL:-}
SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF: ${SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF:-}
SITEFORGE_STAGING_TOKEN: ${SITEFORGE_STAGING_TOKEN:-}
SITEFORGE_PRODUCTION_TOKEN: ${SITEFORGE_PRODUCTION_TOKEN:-}
SITEFORGE_JOB_POLL_SECONDS: "1.5" SITEFORGE_JOB_POLL_SECONDS: "1.5"
SITEFORGE_BUILD_TIMEOUT_SECONDS: "${SITEFORGE_BUILD_TIMEOUT_SECONDS:-300}" SITEFORGE_BUILD_TIMEOUT_SECONDS: "${SITEFORGE_BUILD_TIMEOUT_SECONDS:-300}"
SITEFORGE_BUILD_MAX_ATTEMPTS: "${SITEFORGE_BUILD_MAX_ATTEMPTS:-3}" SITEFORGE_BUILD_MAX_ATTEMPTS: "${SITEFORGE_BUILD_MAX_ATTEMPTS:-3}"
@@ -0,0 +1,40 @@
# EXAMPLE ONLY. Replace DOMAIN, absolute path, and upstream host before use.
# The Art Design Pro SPA and SiteForge FastAPI MUST share an HTTPS origin.
# Restrict this admin hostname with your organization's access policy.
upstream siteforge_api {
server 127.0.0.1:8787;
keepalive 16;
}
server {
listen 443 ssl http2;
server_name admin.example.com;
ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;
root /srv/siteforge-art/dist;
index index.html;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Configure organization-specific access control, HSTS, rate limits and logs.
# Do not cache API responses or auth redirects.
location ~ ^/(api/|login$|editor/|preview/|ui/|local-media/|favicon\.ico$|factory-studio$|operations$|theme-gallery$|production-v8$) {
proxy_pass http://siteforge_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 8s;
proxy_read_timeout 90s;
add_header Cache-Control "private, no-store" always;
}
location / {
try_files $uri $uri/ /index.html;
}
}
server {
listen 80;
server_name admin.example.com;
return 301 https://$host$request_uri;
}
+64
View File
@@ -0,0 +1,64 @@
# V8.14 原生发布目标(SFTP / S3)运行配置
> 本文件包含可以部署的代码与操作步骤。真实服务器、证书、IAM、DNS 和缓存策略必须由部署人员配置并验证。
## 运行方式
默认仍采用原 V8.13 HTTPS 发布 Agent,不修改已有目标配置即可继续使用。每个目标分别设定 `SITEFORGE_DEPLOY_{STAGING|PRODUCTION}_TYPE` 为 `agent`、`sftp` 或 `s3`。
可选适配器及依赖版本已纳入 `requirements.lock` 和 Docker 镜像;如果镜像构建不能联网获取依赖,不会自动降级成无校验发布。
### SFTP
1. 创建**专用无 shell/受限权限**的发布账号,仅允许访问专属发布根目录;安装并配置支持 OpenSSH `posix-rename@openssh.com` 扩展的 SFTP 服务器。Worker 不执行任意远程 shell 命令。
2. 在管理服务器上由管理员手动核对目标主机 SSH 公钥指纹,保存 `secrets/publish_known_hosts`。不可使用 `AutoAddPolicy` 或 `StrictHostKeyChecking=no`。
3. 为 Worker 安装只读私钥文件 `secrets/publish_key`;强烈建议独立测试/生产密钥及不同账号,挂载到 `/run/secrets/siteforge_publish_key`。
4. 设置目标 `HOST`、`PORT`、`USERNAME`、`REMOTE_ROOT`、`KNOWN_HOSTS_FILE`、`SSH_KEY_FILE`、`PUBLIC_URL`;`PUBLIC_URL` 必须是**真实 HTTPS 站点首页地址**(如 `https://staging.example.com/`),必须映射到该站点 `current` 目录。
5. 执行 `docker compose -f compose.yaml -f compose.publish-targets.example.yaml up -d --build`。示例同时演示 Staging SFTP、Production S3,实际部署时应依安全需求拆分环境和变量。
6. 执行两次不同版本的审批发布、模拟校验失败与回滚;确认当前站点始终是已经完整验证的版本。
SFTP 文件布局:`REMOTE_ROOT/{site}/releases/{sha256[:20]}/...`,`current -> releases/{sha256[:20]}`。生成临时符号链接后使用原子 `posix_rename` 切换;若目标服务器不支持该扩展,则**拒绝部署**,不会使用有中间空窗的 fallback。
当前原生 SFTP 使用不带口令交互的 SSH 私钥文件。如果组织要求硬件密钥、交互式证书或私钥密码,需要在运维方案中对接安全代理,不要把口令写入前端或数据库。
### S3 / S3-compatible
1. 创建独立存储桶或隔离前缀,赋予发布 Worker 最小必要的对象读写权限;站点网关使用**不同的、只读** IAM 身份(建议使用实例角色/工作负载身份,不要硬编码 Access Key)。
2. 设置 `SITEFORGE_DEPLOY_PRODUCTION_TYPE=s3`、`BUCKET`、`REGION`、`PREFIX`、`POINTER_AWARE=1`、`PUBLIC_URL`。可选 `ENDPOINT_URL` 必须是 HTTPS 根地址。
3. 发布会上传不可变 `sites/{site}/releases/{digest}/` 对象,对每个文件进行 SHA256 复核;最后条件更新 `sites/{site}/current.json`。并发指针修改会被 S3 ETag/If-Match 限制阻断。
4. **必须部署指针感知网站网关** `app.s3_gateway:app`(示例 `compose.s3-gateway.example.yaml`),让 `https://web.example.com/{site}/` 根据 `current.json` 读取正确版本。不能直接把裸 S3 Static Website Hosting 端点当作已经实现原子切换。
5. 网关示例为读路径提供 no-cache 和安全路径验证。对 CDN 的改写规则、主站根目录映射、缓存失效与回滚速度,必须在真实域名上验收。S3-compatible 需要支持条件写入;不支持的供应商不得声明原子发布。
### 发布安全门禁
- 构建状态必须是 `complete`,ZIP 路径必须处于系统归档根目录,静态质量和迁移审计满足要求。
- 由独立审批人批准的版本才能部署;网页只提交异步任务,发布由 Worker 执行。
- 上传/完整性校验失败不得改变当前发布指针;上线后首页内容哈希不匹配会尝试切换回旧版本。
- 当前系统仍不保证 Worker 被强制杀死、网络分区发生在指针切换瞬间时具有跨进程数据库+远程站点的**严格分布式事务**;应当增加带审计的 reconciliation 机制后才能视为生产最终验收通过。
### 测试和运维验收
本地测试:`python -m pytest -q tests/test_release_native_v814.py tests/test_release_v813.py`。
生产验收必须测试:凭据错误、SSH 主机密钥变化、上传中断、哈希错误、审批撤销、重复任务、目标网关故障、回滚、旧版本路径、500/超时、CDN 缓存以及 API 日志中无凭据泄漏。
### S3 网站独立域名的反向代理(必做)
本项目生成的静态网站可能使用 `/products/`、`/about/` 等**根路径链接**。示例网关的内部路由是 `/{site}/products/`。正式域名必须在反向代理层把根路径映射到正确的站点前缀,不能直接将网关根目录暴露为某个企业网站。
以下仅是 Nginx 的 HTTP 内网转发结构示例,真实外网必须先配置受信 HTTPS 证书和正确的 DNS:
```nginx
server {
listen 127.0.0.1:8088;
server_name www.example.com;
location / {
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:8791/my_site_id/;
}
}
```
这样 `https://www.example.com/products/` 才会被反代为网关的 `/my_site_id/products/`。公网 443/TLS、缓存层和域名验证需由实际反向代理负责,**不要原样复制演示域名到生产环境**。
针对每个独立网站设置不同的 server_name、站点 ID 和相应的 `PUBLIC_URL`。使用 CDN 时,对根路径 HTML、current.json 的旧版本缓存必须进行专项验证;无明确缓存失效机制前使用 `no-cache` 更安全。
+298
View File
@@ -0,0 +1,298 @@
# SiteForge Enterprise V8.12.0-dev.1 — 工作台流程与任务状态增强
> **开发集成版,尚未通过 P0–P4 全部生产验收门禁。** 完整工程在 V8.11 基线上升级;P5/多语言和 Art Design Pro 官方工程等未在本轮解决。
## V8.11:管理后台重新设计(本轮重点)
默认 `http://localhost:8787/` 现使用 **简洁工作台**,保留原有业务 API 和数据结构,不迁移数据库。
- **工作台**:网站数量、操作引导、最近网站;不要求初学者理解 Worker/SEO/主题 Schema。
- **我的网站**:一个地方选择所有网站;创建新站的表单只保留必要字段。
- **网站概览**:四步流程(建立网站、准备内容、完善设计、构建预览),动态提示下一步。
- **内容与页面**:页面、产品、新闻统一管理,可快捷修改文字或进入可视化设计器。
- **外观与设计**:行业主题选择与实际页面编辑;草稿/发布隔离仍由原编辑器负责。
- **导入与迁移**:默认进入带预演、复核与批次回滚的导入工作台;小文件直写保留为高级操作且必须确认。
- **构建与交付**:使用既有异步 Worker,预览、静态 ZIP、质量报告集中呈现;正式审批和远程发布保留在企业交付中心。
- **网站设置**:站点名称、域名、公司标语、Logo、类型等从设计功能中独立出来。
- **高级工具**:折叠保留原有生产控制台、任务与审批、主题预览以及历史后台 `/classic`,确保不丢功能。
新增页面在 `app/templates/workspace.html`,样式和功能逻辑在 `app/static/workspace.css`、`app/static/workspace.js`。
专业页面已经使用 V8.11 的统一导航与分组交互;Art Design Pro 官方完整工程仍未正式编译整合。
**运行前后检查**:登录、创建站点、内容编辑、主题切换、文件导入、异步构建、预览/ZIP、手机宽度侧边栏。
**注意**:新 UI 不改变业务 API 或权限模型;`/classic` 留作过渡。真实浏览器/生产 Docker 的全面实机回归仍应在目标服务器执行。
---
## V8.9 已有能力(原样保留)
1. **P0 离线恢复安全**:恢复前校验 ZIP 路径、内容范围和数据库完整性;同文件系统暂存目录重命名切换,保留原数据目录以便回退,失败不先覆盖旧数据库。使用 `python scripts/restore_backup.py backup.zip --data /path --confirmed-offline`(务必先停 Web/Worker),完整备份生成用 `python -m app.backup`。
2. **P2 WordPress 响应式图片**:解析 `srcset`、`data-src`、`data-srcset`、`picture/source` 和视频 poster 的外部资源引用;受限本地化后保留宽度/倍数提示及懒加载 URL,无法处理的外链进入迁移复核,绝不静默删除 CDN 引用。外部 CSS 背景图、任意复杂插件布局仍未全面迁移。
3. **P3 5000 条真实构建**:独立 SQLite 夹具 10×500 记录实际导入 5,000 条并生成超 5,000 个静态 HTML;构建器预计算品牌/分类索引,交付检查和质量审计改为流式处理,避免随内容数平方增长;正式 SEO 警告仍需清理。
4. **P4 维持现有设计器与模板**:保留跨设备列设置、页面草稿、全站设计草稿、Owner 整站套版与事务回滚;针对优化过的构建器/质量检查加入回归测试。
**验收报告与限制:** [P0–P4 V8.9 评估](docs/P0_P4_V8_9_VERIFICATION.md) | [原验收矩阵](docs/P0_P4_ACCEPTANCE_MATRIX.md)
## 启动与测试(Windows PowerShell / Docker Desktop)
```powershell
# 在解压后的项目根目录执行,首次生成 .env
# 不能直接覆写旧项目数据;务必使用新测试卷
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
docker compose ps
```
- 简洁管理后台(默认推荐):`http://localhost:8787/`
- 旧版专业操作页:`http://localhost:8787/classic`
- 高级数据与迁移工具:`http://localhost:8787/production-v8`
- 主题中心:`http://localhost:8787/theme-gallery`
- `/api/health` 应返回平台版本 `8.10.0-dev.1` 和构建提交标识。
- 本地测试:`bash scripts/check.sh` 或 Windows PowerShell `scripts/check.ps1`。
- 独立 5000 条验收:`python scripts/pilot_5000.py --report pilot.json`(默认只验证 5,000 条导入)。有足够资源的测试机上加 `--build` 同时检查整站构建;仍需分开核查 SEO 警告。。
## 迁移及回滚注意
离线恢复在目标目录旁保留 `*.pre-restore-*` 原数据目录;恢复完成后先验证旧构建、媒体、权限和管理员登录,再确认是否可以清理原目录。请先把备份 ZIP 拷贝到 Docker 卷之外。此恢复脚本适用于 SQLite 单机数据;**不是 PostgreSQL 恢复工具**。恢复前 Web 与 Worker 必须全部停止。
## 开发验收边界
此版本未在当前环境执行 Docker Engine、GitHub Actions 云端 CI、真实授权 WordPress 全站迁移、MySQL TLS 实机、生产密钥轮换以及桌面/移动浏览器完整视觉验收。P0–P4 尚不能全部标为生产交付完成。跨域 CDN、CSS 背景资源、付费/私有 WordPress 数据及复杂 WooCommerce/Elementor 页仍需定制适配与人工复核。
---
## 历史功能说明(原 V8.8/之前版本,仅供追溯)
# SiteForge Enterprise V8.8.0-dev.1 — P0–P4 集成增强版
> **交付状态:开发集成版,尚未通过 P0–P4 全部门禁,不应作为正式商业验收报告。**
本版延续 V8.7 的完整源码,聚焦 P4(响应式独立列布局、图片素材库、整站模板两步审核与多页原子操作),同时修复深层页面 CTA/媒体根路径问题、版本与 Compose 遗留问题。旧站迁移、数据装配、安全 HTTP、Worker、四套主题继续保留。
- **P4 设备布局**:页面中的多列区块可设置平板/手机独立列数、排序、跨度和显示隐藏;所有自定义 CSS 经过严格参数校验,并按页面作用域生成。
- **P4 模板套版**:生产控制台先预检、再暂存,最后 Owner 发布;冲突拒绝、失败回滚,同步保证数据与 SEO 不被模板业务内容覆盖。旧版直接应用 API 需显式设置开发兼容开关。
- **P4 媒体素材**:编辑器新增本站图片库选择,支持图片组件、画廊和轮播;深层页面正确使用根路径。
- **验证状态与全部遗留**:[P0–P4 验收矩阵](docs/P0_P4_ACCEPTANCE_MATRIX.md)。
- **运行**:与 V8.7 一样,首先在独立测试目录执行 `python scripts/init_config.py`(或 Docker 运行该脚本),再 `docker compose up -d --build`。不得覆盖运行中的旧版数据卷。
---
## V8.7 历史项目说明(保留升级依据)
# SiteForge Enterprise V8.7.0-dev.1 — 全站设计草稿与响应式管理
> **交付状态:开发试点版。** 本轮基于 V8.6 延续原 Web + Docker 工程,不是单独演示网站。
## V8.7 本轮实际实现
- **全站设计草稿**:`GET/PUT/DELETE /api/sites/{site}/site-design` 管理独立草稿;`POST /site-design/publish` 才更新用于正式构建的全站配置。对 `revision` 和底层设置指纹做并发冲突检查,发布记录保存在 `site_design_publications`。
- **统一页眉/页脚/导航**:支持安全的三级导航、搜索显示、顶部公告、联系 CTA、页脚标题/介绍/邮箱、页脚菜单显示选项;四套专业主题的最终 HTML 均消费同一份配置。导航只允许站内路径,严禁任意脚本、外部链接注入。
- **响应式控制**:平板与手机的区块间距、手机内容左右内边距、移动端公告和页脚导航显隐;已写入最终静态 `assets/custom.css` 和可视化编辑器预览 CSS。它是可交付的响应式参数编辑,**并非**完整独立设备画布。
- **设计令牌统一入草稿**:字体、标题缩放、版心宽度、圆角等改为全站草稿保存/发布;未经发布不会更改新构建。编辑器内可在“设计规范”和“全站页眉与页脚”面板操作。
- **关闭旧版直写绕过**:`/blueprint` 直写、旧导航直写、旧设计令牌直写及 `PATCH entry.meta` 的布局直写,在生产环境默认返回 HTTP 409。旧脚本若必须临时过渡,可显式设 `SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE=1`;**正式生产不应启用此选项**。原后台导航表单与企业设计令牌页已改写全站草稿。
- **测试**:在现有 168 项回归基础上新增 13 项专项测试,验证四主题、草稿构建隔离、发布、设计令牌、安全输入、跨站隔离、并发拒绝、旧接口安全门禁。
### 使用方法
1. 解压后先在**独立测试环境**启动 Docker(以下为 PowerShell):
```powershell
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
```
2. 登录 `http://localhost:8787/`,进入任意站点页面的 `/editor/{site_id}/{entry_id}` 设计器。
3. 点击 **全站页眉与页脚**,调整导航/页眉/页脚/手机与平板设置,点击 **预览草稿**,随后 **保存全站草稿**。
4. 由网站 Owner 点击 **发布全站设计**,然后触发 Worker 网站构建。现有已发布静态版本不会因保存或发布设计而被原位篡改。
5. CI 中可执行 `pytest`、Python 编译检查、`node --check`;Docker、真实浏览器和服务器发布需要独立实机验收。
### API 及兼容性
| 方法与路径 | 行为 |
|---|---|
| `GET /api/sites/{site}/site-design` | 返回已发布配置或草稿、版本、冲突状态 |
| `PUT /api/sites/{site}/site-design` | 携带 `config` 与 `expected_revision` 保存草稿 |
| `POST /api/sites/{site}/site-design/publish` | Owner 审核发布,要求版本号 |
| `DELETE /api/sites/{site}/site-design` | 删除未发布草稿 |
| `GET /api/sites/{site}/site-design/publications` | 查询最近的发布审计记录 |
| `POST /api/sites/{site}/pages/{entry}/editor-preview` | 请求体的 `site_design` 可预览未发布的整站草稿 |
### 明确未完成
- 目前并非 Figma 式自由定位编辑;完整 PC/平板/手机**独立布局**、全站模板可视化拖拽、实时跨设备视觉验收仍待开发。
- 旧版批量套版及部分内容创建接口仍可能更新布局,需在后续将所有设计变更统一纳入发布工作流;本次仅限制上述直接绕过入口。
- Art Design Pro 官方工程、复杂 CMS 无损迁移、PostgreSQL/Redis 多实例、SFTP/S3 正式发布仍未完成完整实机验收。
- 当前环境无 Docker Engine,**不能宣称** `docker compose up` 已实机通过;亦未完成 GitHub 云端 CI 和真机 Chrome 视觉回归。
---
## 上一版 V8.6 的开发记录(保留供升级追踪)
# SiteForge Enterprise V8.6.0-dev.1
> **状态:开发试点版,P4 设计器与整站模板增强。** 同一主线延续 V8.5 的认证 API、只读 MySQL、V8.4 XML/API、WordPress 迁移、四套主题、构建 Worker 和发布审批。尚未完成正式商业/生产验收。
## 这一轮新增(P4)
- **六种声明式组件**:`carousel` 图片滚动展示、`video` YouTube/Vimeo 白名单视频、`tabs` 可访问详情分组、`pricing` 服务方案、`map` 可信地图外链、`form_link` 询盘入口。都使用可信渲染器输出,不允许用户提交 JavaScript。
- **独立设计草稿**:V8.6 可视化编辑器的“保存草稿”只写入 `studio_drafts`,不会修改正式 entries 或被静态构建读取;“发布设计”才会原子写入正式页面,随后需重新构建静态网站。
- **并发保护**:每次草稿更新携带 `expected_revision`;正式页面被其他人改动后,拒绝发布,防止静默覆盖。放弃草稿会恢复正式版设计。操作支持站点权限隔离与审计。
- **整站模板兼容性**:模板带主题版本声明;新增 `/whole-site-templates/preflight` 无副作用检查,验证主题是否存在、指定版本是否可用、已安装组件、已有路径,后台预检后再应用。
- **前端操作入口**:原 `/editor/{site_id}/{entry_id}` 页面新增保存草稿、发布设计、放弃草稿;整站模板控制台在应用前执行独立兼容性检查。原 `/blueprint` 直写接口保留兼容旧系统,尚未完全禁用。
### Docker 运行
Windows PowerShell(在解压后的项目根目录):
```powershell
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
docker compose ps
```
页面:管理台 `http://localhost:8787/`;生产控制台 `/production-v8`;主题 `/theme-gallery`;设计器从网站页面进入 `/editor/{site_id}/{entry_id}`;API 版本 `/api/health`。
首次升级必须使用**独立测试环境和数据副本**,备份 `.env`、数据库、媒体及归档。草稿表通过幂等方式初始化,未在原有生产数据库上完成实际迁移演练。
### P4 接口
| HTTP 接口 | 用途 |
|---|---|
| `GET /api/editor/blocks` | 读取组件注册表与属性契约 |
| `GET /api/sites/{site}/pages/{entry}/studio-draft` | 获取当前草稿或正式布局 |
| `GET /api/sites/{site}/pages/{entry}/studio-draft/preset?page_template=home` | 获取主题预设,不修改正式页 |
| `PUT /api/sites/{site}/pages/{entry}/studio-draft` | 保存草稿,要求 `expected_revision` |
| `POST /api/sites/{site}/pages/{entry}/studio-draft/publish` | 发布设计,需要 owner 角色和 `expected_revision` |
| `DELETE /api/sites/{site}/pages/{entry}/studio-draft` | 放弃草稿 |
| `POST /api/factory/sites/{site}/whole-site-templates/preflight` | 整站模板无副作用兼容检查 |
开发环境已经完成 **168 项本地 pytest**、全部 JavaScript 语法与版本检查,另有“草稿构建不包含未发布内容 → 发布后重新构建包含内容”的实测回归。
**未验收**:Docker Engine 实机、GitHub 云端 CI、浏览器 PC/手机端手工交互、真实客户内容视觉检查、Art Design Pro 官方工程编译、复杂 WordPress 无损迁移、PostgreSQL/Redis 多实例能力、SFTP/S3 正式发布。
详细信息见 [V8.6 验收与剩余需求](docs/V8_6_RELEASE_STATUS.md)。
---
## 历史版本功能参考(以下内容为 V8.5 及更早阶段的开发记录,不是 V8.6 最新状态)
---
## V8.4 历史能力(继续保留)
> 当前阶段:统一网站生产工厂,**P3 数据连接器第一轮(安全 XML、公开 JSON API + Worker)**。本版本是开发试点版,并非所有网站工厂需求均已实现。
## V8.4 本轮新增
- **安全 XML 导入**:`/production-v8` 可以上传 XML,选择记录节点、内容类型和字段映射,支持扁平或两级结构;使用 `defusedxml` 禁用 DTD 与外部实体,并限制最大 8 MB、1000 条记录及节点/深度上限。
- **公开 HTTPS JSON API**:设置 `url`、JSON 列表路径(例如 `data.items`)、字段映射和分页;创建后提交 Worker 后台采集,使用既有租约/取消/重试/超时机制。每页形成独立暂存批次,可以审批提交或回滚。所有采集均不自动上线。
- **进度和误差边界**:最多 50 页 × 每页 100 条,合计 5000 条;发生中断从已保存页码继续,复用已有同页批次避免重试重复写入;达到最大页数而上页满额会显示 `limit_reached`,不能视为采集完整。
- **统一后台**:新增 XML 文件上传、API 创建、采集任务、取消和进度查询操作区域,并保留 WordPress 迁移、主题和编辑器。
- **版本与 CI**:统一应用版本标记、Compose 名称、脚本和 CI 版本断言为 `8.5.0-dev.1`;**144 项本地自动化测试通过**。
[详细功能、接口、安全限制与剩余需求](docs/V8_4_RELEASE_STATUS.md)
**边界**:目前 API **只支持无需认证的公开 JSON**,不支持带密钥 API;MySQL 只读连接器、完整 Art Design Pro 编译和生产级外部发布也尚未完成。没有运行 Docker 实机或云端 CI,因此不得将该版本称为企业正式上线完成版。
---
## 既有 V8.3 迁移与建站功能(本轮保留)
> SiteForge Enterprise 统一开发主线:**WordPress 可恢复迁移 + Sitemap 完整性审计 + 有界媒体本地化 + Worker 后台采集**。
>
> 版本状态:开发试点候选版,不可直接对外宣称完整 WordPress 无损迁移或企业生产部署已验收。V8.1 网站、主题、编辑器、导入批次和静态构建功能均在此版本继续维护。
## 快速启动(Docker Desktop / Linux Docker)
项目根目录第一次初始化:
```bash
# PowerShell 将 `$(pwd)` 改为 `${PWD}`
docker run --rm -v "$(pwd):/project" -w /project python:3.12-slim python scripts/init_config.py
docker compose up -d --build
docker compose ps
```
- 登录:`http://localhost:8787/`
- **V8.4 生产控制台**:`http://localhost:8787/production-v8`
- 主题管理:`http://localhost:8787/theme-gallery`
- 页面编辑:`http://localhost:8787/factory-studio`
- 任务中心:`http://localhost:8787/operations`
- 构建版本:`http://localhost:8787/api/health`;当前应显示 `8.5.0-dev.1`。
**升级注意:** 不要直接覆盖 V8.1 正在运行的存储卷。先备份 `.env`、数据库、媒体和生成文件,在独立 Compose 项目中完成验收,然后安排升级与回滚。新版本表结构采用幂等的 `CREATE TABLE IF NOT EXISTS`,但并未进行真实旧数据库原地升级演练。
## V8.3 新增:WordPress 关系数据与归档 URL 保真
- 为每次 WordPress 迁移会话采集公开 REST 分类、标签和媒体库**元数据**,分别保存页码和已采集数量。关闭页面后可以继续下一页。
- 原始 WordPress ID、分类/标签/特色图片的关联 ID 写入批次记录和关系索引;导入确认前,在已同步名称的情况下补充分类/标签字段;绝不凭空创建不存在的分类内容。
- 分类/标签来源归档 URL 纳入旧站 SEO 基线。无对应的新页面或批准重定向时,需要通过差异检查处理。
- 对缺失的标签/分类、未知特色图片、ACF 和页面构建器内容保留人工审核事项。公开 ACF 只保留有界快照,不承诺原样转换。
- 控制台按钮:选择“分类 / 标签 / 媒体库”→ “同步目录下一页”→ “目录进度”,完成后再核对媒体和 Sitemap,最后确认导入。
详细的操作与验收边界见 [V8.3 开发验收报告](docs/V8_3_RELEASE_STATUS.md)。
## V8.3 迁移流程与增强
1. 创建迁移会话:指定授权 WordPress 来源和最多采集条数。
2. **提交 Worker 后台采集**:一次提交到统一数据库任务队列,Worker 子进程逐页采集 WordPress REST `pages/posts`,保持每页检查点。浏览器关闭后可以继续执行。
3. 在任务中心监控扫描的排队、执行、取消、重试、超时等状态。扫描任务不会自动提交暂存内容、不会直接上线。
4. **发现 Sitemap**:单独读取 WordPress `wp-sitemap.xml`;支持 `sitemapindex` 嵌套与有界 gzip XML,记录未从 REST 采集到的 URL。若使用 Yoast 或 RankMath 自定义 Sitemap,可以通过 API 指定同源 URL。
5. **媒体本地化**:在最终导入前,手动分批读取(每次最多 40 个)暂存文章中的图片,限制大小、格式、原站域名,自动重写正文及特色图片路径。其他 CDN 域名默认拒绝自动下载,并形成待复核项。
6. 检查扫描结果、Sitemap 缺页和图片缺失,明确点击“确认并导入”。将旧 URL / SEO 清单加入审计;存在未处理差异的迁移站继续受发布审批门禁限制。
7. 选择主题后使用原有 Worker 构建网站,查看 QA 和 ZIP。
**重要边界**:Sitemap 只用于发现遗漏,并不自动解析任意 Sitemap 页面内容;完整的 `srcset`、CSS 背景图片、跨域 CDN、附件、视频、登录后数据及复杂 Elementor/ACF/WooCommerce 迁移仍需要额外适配和人工复核。未运行 Sitemap 完整性审计时,系统不能保证完整发现旧站 URL。WP 媒体本地化是用户主动触发的、同步分批执行的步骤,不会在后台无界下载资源。
## V8.3 主要接口
所有接口需要原有 Session + CSRF + 网站权限;`POST` 不允许匿名调用。
| 接口 | 说明 |
|---|---|
| `POST /api/migrations/sites/{site}/wordpress/sessions` | 创建迁移会话 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/scan` | 将 REST 扫描加入 Worker 队列;可传 `idempotency_key` |
| `GET /api/migrations/sites/{site}/wordpress/sessions/{session}` | 查询检查点、页数和警告 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/sitemap` | 安全发现同源 Sitemap,JSON 可传 `url` |
| `GET /api/migrations/sites/{site}/wordpress/sessions/{session}/sitemap` | 已发现 URL 数、缺失计数、前 100 项示例 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/media/localize` | 分批媒体本地化,JSON `limit` 1–40 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/finalize` | 明确确认提交,建立 URL/SEO 基线与复核项 |
| `POST /api/migrations/sites/{site}/wordpress/sessions/{session}/cancel` | 取消会话;正在运行的任务另在任务中心申请取消 |
后台扫描任务使用 `build_jobs.kind='wordpress_scan'`,与原网站构建共享 Worker/容量限制/租约/退避。可使用 `/operations` 监控或使用既有企业任务 API。**扫描完成不代表迁移交付完成**。
## 测试与已知限制
```bash
bash scripts/check.sh
python scripts/verify_release.py
```
截至本次代码交付,130 项自动化测试在当前开发环境全部通过,包含 V8.2 的 120 项和本轮的 10 项专项测试。测试覆盖嵌套 Sitemap、压缩炸弹、XXE、缺页审计、媒体格式与同源限制、子进程任务派发、幂等、容量门禁、API 鉴权等。
本环境没有 Docker Engine;没有实际执行 Docker 实机启动、GitHub 云端 CI、官方 Art Design Pro 编译、真实 WordPress 全量迁移、浏览器全流程或远端正式发布。是否能交付企业生产仍需上述验收。
接下来的 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 实机与浏览器跨设备回归仍需部署后完成。
+12
View File
@@ -0,0 +1,12 @@
# P6 快速操作
登录 `http://localhost:8787/` → 我的站点 → 建立中文与英文两个独立 HTTPS 站。
1. 进入“多语言管理”,创建语言组与两个语言站成员。
2. 在中文站与英文站分别录入已翻译页面,各自设置 `status=published`。
3. 建立翻译内容组(例如 `product:example`),将两个实际页面 ID 分别关联为 `approved`。
4. 重新构建两个站点,并查看生成 HTML 与 Sitemap;无对应翻译的页面不会显示语言互链。
5. 待翻译文案先存为 `draft`,可以关联为 `pending`,待审核确认以后改为 `published` 并设为 `approved`。
6. 使用“构建与交付”审批发布。上线前仍需在真实域名上逐一核对 canonical、链接、翻译质量和 SEO。
数据库升级为加法建表,不会自动复制或修改原网站内容。升级前请先备份现有卷。
+43
View File
@@ -0,0 +1,43 @@
# SiteForge V8.13 — P5 发布闭环的交付与验收清单
版本:`8.13.0-dev.1`。此报告区分 **源代码实现/本地自动化测试** 与 **Docker/外部生产实机验收**。
## 本轮实现与已有能力
| 验收点 | 当前状态 | 证据 / 限制 |
|---|---|---|
| 构建完成且 ZIP 可用 | 已实现与单元测试 | `release_api.approved` + `_archive_for` |
| 静态质量、SEO 迁移审核、独立审批 | 已有代码 + 本轮 API 专项测试 | 未审批返回 409,迁移站差异会阻断 |
| Web 不执行远程大文件上传 | 已实现 | API 提交 `remote_deploy` / `remote_rollback` 任务,返回 HTTP 202 |
| 任务超时、幂等、容量、取消 | 已有 Worker + 本轮补充测试 | 单机 SQLite 租约;不是多区域事务 |
| 远程 ZIP 不可变暂存 | 已实现与签名 Agent 模拟测试 | 按存档 SHA256 前 20 位命名,不更改 current |
| 远程文件 SHA256、清单核验 | 已实现与模拟测试 | 篡改文件后拒绝激活 |
| 单次版本切换 | 已实现与模拟测试 | `current` 指针文件通过 `os.replace` 原子替换 |
| 激活后二次校验与首页探针 | 已实现与模拟测试 | 比对 Agent 公开首页的 SHA256;不等于真实域名探测 |
| 异步历史版本回滚 | 已实现与模拟测试 | 仅回滚数据库记录中该站点该目标的版本;验真后切换 |
| 目标密钥不进入前端 | 已实现的环境引用方案 | 推荐挂载到 `/run/secrets`,待生产轮换验收 |
| SFTP / S3 原生连接器 | **未实现** | HTTPS 发布 Agent 为当前唯一可运行的远程目标类型 |
| 真实 Docker、外网 TLS、CDN、域名 | **待实机验收** | 当前环境无 Docker Engine;没有公网发布测试服务器 |
| 发布取消在切换瞬间的跨服务一致性 | **未完全解决** | 需增加远程状态 reconciliation / 运维确认 |
| PostgreSQL/Redis 多 Worker | **待生产验收** | 独立 Compose 已配置但未完成真实高可用测试 |
## 确定性专项测试
- 不经过激活就上传两个版本时,当前线上指针保持不变。
- 篡改 staging 文件后,Agent 拒绝激活且旧站保持可访问。
- 伪造签名、非法 ZIP 路径、保留文件名、同名冲突会被拒绝。
- 已批准构建通过异步 API 提交,在队列中去重;未批准构建禁止提交。
- 运行签名 Agent 的完整暂存、校验、激活、再次验证、回滚逻辑(本地 TestClient 模拟)。
- 任务数量达到上限时拒绝新任务;取消排队任务不影响已发布内容。
## 正式上线前需要的人工/环境门禁
1. Docker 一条命令在全新宿主机成功启动 Web 与 Worker,并验证 `/api/health` 精确报告版本/提交。
2. 将 Agent 部署于真实 HTTPS 测试域名,独立密钥及 TLS 证书有效;仅开放预期入口。
3. 在授权测试网站完成两轮发布,故障注入覆盖上传中断、校验失败、切换成功后 Web 超时、Worker 取消。
4. 核对站点正式域名的首页、产品详情、404、canonical、sitemap、robots、媒体与链接。
5. 验证真实站点切换/回滚并确认时间、缓存失效、老 URL 保留。
6. 审计密钥轮换、管理员/审核人权限、备份/恢复和日志中的敏感数据。
7. 完成 SFTP/S3 等其它发布方式后,逐个通过等价事务性/故障恢复门禁。
**结论**:V8.13 的 HTTPS Agent 远程发布形成了本地可测试的发布闭环;尚不符合全部 P5 生产验收条件。
+32
View File
@@ -0,0 +1,32 @@
# SiteForge Enterprise V8.14 — P5 原生发布验收记录
版本:`8.14.0-dev.1`。本版在 V8.13 上添加 SFTP / S3 原生发布适配器、只读 S3 发布指针网关、统一发布目标状态与回滚入口。
## 本轮已实现且有确定性测试覆盖
| 能力 | 实际实现 | 测试边界 |
|---|---|---|
| 默认 HTTPS 签名 Agent 向后兼容 | 原发布 API、队列、签名与审批不变 | 继承 V8.13 专项回归 |
| SFTP 主机密钥校验 | Paramiko RejectPolicy + 必须预置 known_hosts 和 SSH 私钥文件 | 配置与仿真 SFTP 文件系统测试,**未真实握手** |
| SFTP 不可变目录与原子激活 | 分版本目录、SHA256 manifest、原子符号链接切换与回滚 | 本地仿真、注入损坏/探针失败;OpenSSH 实机待验收 |
| S3 分版本对象上传 | 不变对象键、SHA256 内容校验、条件写入 current.json | FakeS3 条件语义测试,真实 AWS API 待验收 |
| S3 网站读取服务 | 只读 gateway 根据版本指针解析站点资源,不直接曝光存储凭据 | ASGI TestClient + FakeS3 测试 |
| 远端失败恢复 | 上传/校验失败保持旧版本;探针失败尝试恢复前一版本 | 本地故障注入测试 |
| 审批、Worker、部署记录 | 原 API 选择后端、异步执行、记录发布与回滚 | 自动化测试 |
| 后台展示 | 新增发布类型/目标配置状态、已发布版本回滚按钮 | JS 语法与组件级回归,真实浏览器待验证 |
| 依赖与 Docker | 添加 boto3/Paramiko 完整依赖与 Compose 使用示例 | 配置审查;无 Docker Engine 实机验证 |
## 不应宣称已完成的事项
- SFTP 真实 SSH/TLS、主机指纹、私钥权限与 OpenSSH 原子扩展实测;环境中无法安装 Paramiko 验证真实服务器操作。
- AWS/S3 真实凭据、桶 IAM、S3-compatible 条件写入能力及 CDN/域名部署。
- Docker 单机真实启动、Linux CI 云端执行、浏览器桌面/移动端流程、各生产环境实际回滚演练。
- 发布切换瞬间 Worker 崩溃的跨系统状态对账、长时间任务协调、完整运维监控、缓存刷新和 DNS 自动化。
## 后续门禁
1. 实际 Docker Compose clean room 部署+版本核查;两个账户完成一次分权审批。
2. SFTP 容器夹具:上传、异常断网、错误 host key、目录符号链接攻击、原子切换和回滚。
3. MinIO/S3 实机夹具:不可变对象校验、并发指针更新、被篡改对象、只读网关、旧版本回滚。
4. 正式测试域名:主页、产品详情、404、canonical、sitemap、robots、媒体、缓存状态、安全响应头。
5. 中断/重试后远端实际 current 与数据库发布历史 reconciliation;完成后才能标记 P5 生产通过。
+34
View File
@@ -0,0 +1,34 @@
# SiteForge Enterprise V8.15 — P6 多语言建设与验收
## 本轮完成的能力
| 验收点 | 状态 | 证据 |
|---|---|---|
| 多语言站点组和默认语言 | 本地通过 | `language_groups` / `language_sites` / API |
| 同一内容关联不同语言页面 | 本地通过 | `translation_units` / `translation_members` |
| 已审核、已发布页面生成双向 hreflang | 本地通过 | 中英两站实际构建 + 自动化测试 |
| sitemap 含 XHTML hreflang | 本地通过 | 两站 sitemap.xml 验证 |
| 不存在的翻译不生成伪造链接 | 本地通过 | 缺失、草稿、noindex 测试 |
| 待翻译内容不能意外发布 | 本地通过 | 仅 draft 可 pending,误设 published 时拒绝静态构建 |
| x-default 指向默认语言 | 本地通过 | 明确默认语且存在审批页面才输出 |
| 前台语言切换导航 | 代码完成 | 动态注入,并需真实浏览器视觉验收 |
| 后台多语言菜单及表单 | 代码完成 | 集成 JS / API,真实浏览器端操作仍待验收 |
| 原 243 项测试兼容 | 本地通过 | 本地 pytest 248 passed(含 5 项 P6 专项) |
| 真实 Docker、Chrome、正式域名 | 待验收 | 当前环境无完整 Docker/远程域名实机测试 |
## 主要限制
1. **域名模式**:目前一个语言站一个独立 HTTPS 域名或子域名;同域子路径需进一步设计反向代理、站点构建根路径和 sitemap。
2. **翻译质量**:不自动翻译事实,`approved` 表示人工确认的状态标记,不包含另一人独立翻译审批的完整审计流程。
3. **动态语言链接**:已发布数据可生成对应 URL;必须额外核对真正上线服务器是否已有该版本,不等于远程可访问性证明。
4. **各主题国际化**:四套主题中部分组件和预设仍为中文。正式外语商业交付需按语言提供真实导航、字段标题和空状态文案。
5. **企业生产**:Art Design Pro 官方完整工程、PostgreSQL/Redis 多实例、真实 SFTP/S3 等依旧需要实机验收。
## 手工部署验收步骤
1. 在新 Docker 项目卷启动 V8.15;核对 `/api/health` 返回 `8.15.0-dev.1` 与期望提交 SHA。
2. 创建两个真实的 HTTPS 语言域名站,填写翻译过的公司主页和产品页。
3. 建立一组真实对应关系;分别构建和下载静态站,验证 URL、html lang、canonical、sitemap、hreflang 的对称性。
4. 随机挑选缺翻译、草稿和 noindex 的页面,确认其不会产生无效的互链;将 pending 内容错误设为 published,确认构建被拒绝。
5. Chrome/手机端点击前台语言切换,验证页面存在、焦点可达、移动布局无溢出。
6. 正式发布前审批,两站完成远程发布后从公网检查首页和内页的 hreflang 相互指向,不将示例 `.example.org` 域名当作有效服务。
+40
View File
@@ -0,0 +1,40 @@
# SiteForge Enterprise V8.16 — 企业部署基础验收记录
版本:`8.16.0-dev.1`;阶段:**开发集成(非生产认证)**。
## 本轮新增与测试门禁
| 验收项 | 验证路径 | 当前结果 |
| --- | --- | --- |
| PostgreSQL 查询行数字/字段访问 | `tests/test_production_v816.py` | 本地模拟测试通过 |
| PostgreSQL SQL 占位符、注释及 DDL 分句 | 同上 | 本地单元测试通过 |
| PostgreSQL 初始化及 schema ledger | `scripts/verify_pg_bootstrap.py` | 脚本已扩展;需要 PostgreSQL 实例实测 |
| PostgreSQL Worker `FOR UPDATE SKIP LOCKED`、事务与任务恢复 | CI `postgres-smoke` | 自动检查代码已接入;本机没有 PG 服务,未实际通过 |
| Worker 按实例隔离心跳 | `tests/test_production_v816.py` | 本地通过 |
| `/api/ready` 探针成功/失败及脱敏 | `tests/test_production_v816.py` | 本地通过 |
| 全平台自动化回归 | `scripts/check.sh` | 按模块三组通过:95+82+78 = **255 项** |
| HTTP + Worker 实际建站链路 | `python scripts/http_end_to_end.py` | **本地通过**:建站、保存、队列、静态 ZIP、预览 |
| Chromium 桌面/移动端真实交互 | `scripts/browser_smoke.py` | **环境阻断**:`ERR_BLOCKED_BY_ADMINISTRATOR`,未通过 |
| Docker Engine Web + Worker | `docker compose up -d --build` | 本机无 Docker Engine,未运行 |
| Redis 通知故障降级 | 历史单元测试 | 通过;真实 Redis 容器未运行 |
| Art Design Pro 官方构建 | 用户提供官方完整源码后执行 pnpm/构建 | **未完成**;交付包含安装脚本/适配页面,不含官方完整项目 |
| 公网 SFTP/S3/HTTPS 上线和回滚 | 独立预生产环境 | **未完成** |
说明:单进程连续执行所有 pytest 在约 84% 时超时;上述 255 项是三个独立 pytest 进程(95/82/78)分别完成并通过,不等同于单进程全量成功。新增真实 HTTP 集成构建了 10 个 HTML 页面,仍有缺少正式域名、未填充栏目内容的质量警告;只证明代码路径畅通。
## 生产环境验收操作建议(必须在预生产进行)
1. 对 V8.15 的 SQLite 数据卷和所有媒体、归档、发布目录做完整备份,先演练恢复;保留生产回退窗口。
2. 在独立 PG 16+ 测试数据库执行 `scripts/verify_pg_bootstrap.py`。验证 Web、Worker 启动与并发 claim;**不把原 SQLite 文件当作 PostgreSQL 数据迁移**。SQLite → PG 的正式数据迁移、外键校验、内容数量和媒体对账还未实现为可一键生产执行的迁移器。
3. `docker compose -f compose.enterprise.yaml config`、`up -d --build`,分别核对 Web `/api/ready`、Worker 本地健康以及 PostgreSQL/Redis 服务健康。
4. 将两个 Worker 实例分别配置唯一的 `SITEFORGE_WORKER_INSTANCE`,断开其中一个,确认另一个健康状态不会使坏实例误报正常;断电中断后要检查租约回收与远程发布状态对账。
5. 用完整真实数据做创建网站、WordPress 迁移、5000 条产品导入、构建、主题预览和多语言 SEO 检验。在 Chromium 1440/1024/390/360 视口进行后台与前台访问性/响应式验证。
6. 在预生产的实际 SFTP/S3/HTTPS Agent 发布,逐步演练上传失败、激活失败、CDN 缓存、健康检查失败及人工回滚;审批人与执行人分离。
7. 获取合法的 Art Design Pro 官方工程后执行 `scripts/install_into_art_design_pro.py`,完成实际路由/会话/CSRF 联调及 `pnpm build`。目前属于可选桥接,不要把它计为完成。
## 风险边界
- PostgreSQL 兼容桥目前在 PG + Psycopg 真机上仍需完整持续验证,不能因为 SQLite 测试通过就宣称 PG 生产稳定。
- Redis 当前主要用于任务唤醒,任务权威状态仍在数据库;并非完整的分布式消息可靠性交付。
- SFTP/S3 原生发布已支持本地仿真,但公网环境、强制 TLS、服务端权限和切换时缓存行为仍需验收。
- 新版未添加 SSO、MFA 或企业级集中监控系统,也未完成 Art Design Pro 官方源码集成。
+92
View File
@@ -0,0 +1,92 @@
# SiteForge V8.17 — 生产交付门禁执行手册
> 状态:独立增量验收工具;不包含 V8.16 原项目源码、Art Design Pro 官方完整工程或真实测试环境。本文的项目结构以所获 V8.16 README 为依据。生产演练与授权审批必须由真实环境执行并存档。
## 一、目标与范围
- V8.16 具备 HTTP `/api/health`、`/api/ready`,PG 兼容层、Worker 实例标识与 HTTP/PG 浏览器测试脚本。
- V8.17 增量提供不侵入应用数据的统一检查入口,报告 JSON + Markdown + 离线可视化 HTML。
- **不替代** `scripts/check.sh`、`scripts/http_end_to_end.py`、`scripts/browser_smoke.py`、`scripts/verify_pg_bootstrap.py`;这些是应用级端到端验证。
- **不会执行** Docker up/down/build、数据库迁移、写入、发布/回滚、备份/恢复、人工审批。
## 二、集成路径
1. 解压原 V8.16 完整工程到**隔离测试目录**,不要覆盖正式数据卷。
2. 将本补充包 `scripts/siteforge_v817_audit.py` 和 `tests/test_siteforge_v817_audit.py` 分别复制到原项目同名目录中;不会替换已有模块。
3. 按需复制 `docs/V8_17_ACCEPTANCE_RUNBOOK.md` 和 `examples/`。
4. 运行 `python -m unittest discover -s tests -p 'test_siteforge_v817_audit.py' -v`,再执行以下命令。
## 三、执行命令
最小安全检查(无网络、无需 Docker):
```bash
python scripts/siteforge_v817_audit.py --repo . --output reports/siteforge-v817
```
隔离 Docker 企业 Compose 已启动后:
```bash
python scripts/siteforge_v817_audit.py \
--repo . --docker --url http://127.0.0.1:8787 \
--output reports/enterprise-staging
```
使用已有隔离数据库配置;**PG 仅执行 SELECT 1(事务只读),Redis 仅执行 PING**:
```bash
# 以下环境变量需由运维通过私密渠道事先配置,切勿提交 .env 或 DSN 到仓库。
python scripts/siteforge_v817_audit.py \
--postgres --redis --output reports/datastores
```
静态构建产物 URL 校验(old_urls.txt 需人工从旧站导出,不自动抓取):
```bash
python scripts/siteforge_v817_audit.py \
--build-dir /path/to/generated/site \
--url-baseline old_urls.txt \
--output reports/static-seo
```
验收报告:`reports/*.html` 离线打开、`reports/*.json` 可机器读取、`reports/*.md` 可归档。返回码 2 代表存在 FAIL;WARN/SKIP 仅表示需要进一步处理,不视为生产通过。
## 四、关键门禁和接受标准
| 门禁 | 自动审计可做 | 仍需人工与真实环境执行 |
|---|---|---|
| 代码与 Compose | 文件完整性、Python AST、Compose 配置、容器状态 | 应用全测试集、供应链审计、CI 与镜像签名 |
| Web 与 Worker | HTTP readiness、容器运行/health、Worker 服务发现 | 真实异步任务、租约竞争、断网恢复、容量压测 |
| PostgreSQL | 真正连通后的 `SELECT 1` | **隔离测试库**运行原项目 PG bootstrap、迁移校验、恢复演练 |
| Redis | PING | 队列可靠性、幂等、故障切换、持久化和告警演练 |
| URL / SEO | html lang、canonical 规则、内部链接、旧路径覆盖、sitemap | 迁移映射审定、301/404、robots、实际源站比对、GSC 验收 |
| 多语言 | hreflang 格式与语言标签 | 关联双方对称、翻译审核、独立域名 HTTPS |
| 发布与回滚 | 读取人工证据表 | 真实 staging 激活、不可变版本、校验 SHA、回滚演练、审批隔离 |
| Art Design Pro | 检查当前 `workspace` 静态入口 | **获得官方工程并构建**,完成组件/路由/权限/API 接入和验收 |
## 五、证据记录规范
以 `examples/evidence.template.json` 为模板记录 10 个阶段。每阶段保留 `status`、`operator`、`date`、`evidence`,例如证据可以是带日期的运行日志路径或测试报告 URL。工具只验证字段存在,**不核验证据内容真实性**。`pending` 永远不等同 `passed`。
**回滚演练**:先备份隔离测试环境,生成构建 A,部署到 staging,生成 B,再回滚至 A;比对前后首页和关键 URL 的 SHA/状态码,确认旧版恢复。测试的真实日志由人工归档。不得自动在生产环境执行此脚本包的任何写入动作。
**SSRF 风险**:HTTP 模式默认仅 localhost。远端只允许在操作者明确指定 `--allow-remote` 且获得目标授权时检查;URL 不得包含用户名、密码、查询或 fragment。代理/CDN 的响应和 TLS 配置需人工复核。
## 六、管理后台 UX 接入约定(下一层)
- 导航继续沿用“工作台 / 我的网站 / 内容页面 / 外观设计 / 导入迁移 / 构建交付 / 网站设置”;技术工具统一收纳高级工具。
- 验收中心可在**构建交付 > 验收报告**展示 JSON:优先级为 FAIL、WARN、SKIP、PASS,附修复建议,保留站点上下文。
- 不在编辑页弹出多层确认;破坏性/正式发布才要求明确确认或审批。
- 未获得 Art Design Pro 官方完整源码前,该交付**不能称为官方集成**;正式集成需基于实际 Vue 项目文件、授权版本、组件结构和构建配置开展。
## 七、Known limitations
- 文件可用性为当前 V8.16 README 描述,不保证用户本地工程文件路径完全相同,缺项会被标注 WARN。
- 报告 **不是渗透测试、上线证明、真实发布演练或真实数据备份报告**。
- 单站静态链接检查不能证明跨语言站的双向 hreflang;多语言配对需单独验证。
- 对于在 CDN 或独立路径进行的预览,构建本地链接存在不代表预览 rewrite 逻辑完全正确,需要另行浏览器检查。
- 使用 JSON 证据表并不代表完成任何业务审批或合规认证。
## 八、隔离 CI
提供 `.github/workflows/siteforge-v817-acceptance.yml` 作为**人工触发**的独立验收工作流:一组运行只读源码检查/测试,一组在临时 PostgreSQL 16 和 Redis 7 容器中执行最小存活检查,并上传脱敏报告。该 CI **不能替代**在实际 SiteForge 企业 Compose 下的完整业务链路和故障注入。
+17
View File
@@ -0,0 +1,17 @@
# V8.17 Integration Release Notes
## 基线
- V8.16.0-dev.1 完整源码,提供 30 个 pytest 测试模块;V8.16 既有报告记录 255 例通过,但官方脚本曾偶发卡住。
- V8.17 以真实源码整合,而非仅以 Markdown 方案代替实现。
## 变更范围
- 新增 `scripts/regression_v817.py` 并统一 `.sh/.ps1` 回归入口;逐批超时、隔离诊断、可机器读取报告、失败保留证据。
- V8.17 audit、WordPress URL baseline 工具及单元测试集成。
- 修复 Chromium desktop/mobile 登录上下文共享,CI 增加真实浏览器检查。
- 主项目版本、Compose 镜像版本、相关断言同步更新。
## 未完成
- 当前工作容器不具备 Docker Engine、隔离 PostgreSQL/Redis 服务器和正常可访问 localhost 的 Chromium policy;这些并不作为通过项。
- 不自动覆盖生产卷或执行现有部署迁移。
- 不包含 Art Design Pro 官方可编译完整工程,不声称完成前后端集成。
- 完整历史版本说明保留在 README 后半部分。
+31
View File
@@ -0,0 +1,31 @@
# SiteForge Enterprise V8.17.0-dev.1 — 源码集成回归结论
**结论:V8.17 完整源码已整合并通过隔离本地集成回归。生产上线验收尚未完成。**
## 已实际执行
- 完整项目现有 + 新增自动化测试:**288 项/轮,连续 2 轮均通过**(总计 576 次通过,0 失败、0 超时)。详细见 `reports/v817-regression.json` / `*.html` 与 8 份 verbose 日志。
- V8.17 新增 33 项专项测试包含异常超时检测、报告落地与旧 URL 路径一致性检查。
- HTTP 真实端到端:`/api/ready` → 登录/CSRF → 新建站点、页面 → Worker 异步构建 → ZIP 下载 → HTML 预览,通过。
- `scripts/verify_release.py`、全体应用 Python 编译、前端 JS 语法检查:通过。
- V8.17 只读仓库审计:**12 PASS / 0 FAIL / 0 WARN / 2 SKIP**(未提供实际 `.env` 和正式发布证据)。
- GitHub Actions 两套 CI YAML 结构能正常解析,但**没有在 GitHub Actions 运行**。
## 仍然未通过 / 尚未执行
- Chromium 当前环境:使用 `/usr/bin/chromium` 仍遇到 `net::ERR_BLOCKED_BY_ADMINISTRATOR`,属于执行环境策略限制;新增独立浏览器 CI,需在允许访问 localhost 的 Runner 中验收。
- 当前容器没有 Docker Engine;PostgreSQL/Redis、多 Worker、远程 SFTP/S3/Agent 发布与真实回滚,均未执行真实集成验收。
- Art Design Pro 官方完整源代码未提供;没有进行官方前端编译集成。
- V8.16 曾完成的 5,000 车型导入/构建压力测试,本轮未重复执行,不把历史结果标记为新版本重测结果。
## 修改范围
- `scripts/regression_v817.py`、`.sh/.ps1`:可终止的批量回归和逐模块异常隔离,报告 JSON/Markdown/HTML + 日志。
- `scripts/siteforge_v817_audit.py`、`scripts/export_wordpress_url_baseline.py`:无写入生产副作用的验收与 URL 路径检查。
- `scripts/browser_smoke.py`:登录上下文复用、Playwright Chromium 自动查找、用户可覆写可执行文件路径。
- `.github/workflows/`:连续两轮 Python 测试、真实 Chromium CI 以及独立 PostgreSQL/Redis 只读检查。
- `app/version.py`、Compose 镜像、相关测试断言、README、文档:版本升级与项目交付文档。
## 推荐下一验收步骤
将此 ZIP 在隔离环境部署,先跑 `bash scripts/check.sh` 与 `python scripts/http_end_to_end.py`,随后用已配置安全凭据的独立测试 PG/Redis + Docker 验收,确认浏览器和部署链路;最后复核真实 WordPress URL 及正式发布、回滚和备份恢复。
+65
View File
@@ -0,0 +1,65 @@
# SiteForge Enterprise V8.18 — 企业底座实现与验收说明
> 2026-10-23 · 版本 `8.18.0-dev.1` · **开发集成版(不是生产上线批准)**
## 本轮已实施代码
1. **PostgreSQL 灾备误操作保护**:当 `SITEFORGE_DATABASE_URL` 指向 PostgreSQL,原 SQLite `python -m app.backup` 立即拒绝执行,不会错误创建一份空 SQLite 备份。
2. **PostgreSQL 专属离线灾备**:`app.pg_disaster_recovery` + `scripts/pg_disaster_recovery.py` 使用客户端 `pg_dump` custom 格式、`pg_restore --list`、媒体/构建文件清单和 SHA-256 校验,最后原子更名 ZIP;离线校验无需数据库。恢复仅允许**空 PostgreSQL 数据库 + 不存在的数据目录**,使用单事务 `pg_restore`,不覆盖线上站点。机密信息不会放在子进程命令行参数中。
3. **SQLite→PostgreSQL 内容迁移**:`app.sqlite_pg_transfer` + `scripts/migrate_sqlite_to_postgres.py`:SQLite 在线备份 API 制作快照(前提仍要求业务停写),按外键顺序处理当前 51 张表,检查列覆盖和目标表为空(迁移登记表例外、必须 checksum 匹配),单 PostgreSQL 事务写入,逐表行数与 SHA-256 对账。目标库必须在停机维护期间运行全套初始化。
4. **发布状态不确定时防重复执行**:`remote_deploy`/`remote_rollback` 在 Worker 租约过期、网络失败、超时或运行中取消后标记失败并要求管理员核对远端指针,禁止自动重试外部副作用。普通构建仍保留原有重试能力。
5. **CI 验收扩充**:新增隔离 PostgreSQL 数据迁移端到端验证脚本;本地测试使用模拟 PG 协议和工具进程,并且严格区分模拟与真实 PG 证据。
## 一、禁止直接更新生产卷
新建独立测试环境,备份 V8.17 的数据库和共享存储。切换 SQLite→PG 是**运维迁移**而非自动覆盖 `.env`:不能仅把 SQLite 环境变量指向 PostgreSQL 后直接运行现有容器。系统代码、数据库、媒体目录和 `published` 卷是不同组件,必须统一维护窗口。
## 二、PostgreSQL 备份(维护窗口)
以下命令在有 `pg_dump`、`pg_restore`、`psql` 的管理主机执行;客户端版本应不低于数据库服务器主版本(示例 PG 16)。须先停 Web 和所有 Worker,阻止数据库写入与媒体上传。
```bash
export SITEFORGE_DATABASE_URL='postgresql://backup_user:***@db-host:5432/siteforge?sslmode=verify-full&sslrootcert=/secure/ca.pem'
python scripts/pg_disaster_recovery.py backup --data /path/to/siteforge-data --output /external/backup-20261023.zip --confirmed-offline
python scripts/pg_disaster_recovery.py verify /external/backup-20261023.zip
```
不要把真实密码写到共享日志或 shell 历史;上例为占位符。建议使用安全的环境注入或管理员端凭据文件。`manifest.json` 的 SHA-256 证明读取后的完整性,**不证明备份来自可信发布者,也不代表进行了加密**。备份需转移到其他设备,配置保留策略和访问限制;`published` 独立卷还需要独立备份。
恢复必须**事先创建空 PostgreSQL 目标库**和**不存在的共享数据目录**,并使用新测试实例恢复验证:
```bash
python scripts/pg_disaster_recovery.py restore-new /external/backup-20261023.zip --data /path/to/new-storage --confirmed-offline
```
不会自动切换服务或覆盖目标数据库。恢复后需再跑 `/api/ready`、内容/媒体、构建和发布链路的 E2E。数据库恢复和文件重命名跨系统不属于一个原子事务,失败时目标必须保持隔离,直到人工对账完成。
## 三、SQLite 数据升级到 PostgreSQL(数据库部分)
1. **停站**:关闭 Web、所有 Worker、文件上传与计划任务,确保 SQLite 和媒体不再变动。
2. **双重备份**:运行既有 SQLite 备份与源文件目录副本;将外部备份单独存储。
3. **全新 PostgreSQL 库**:使用独立新库运行完整 SiteForge schema 初始化;允许 ledger 的既定行,其他业务表必须为空。
4. **迁移数据**:
```bash
export SITEFORGE_DATABASE_URL='postgresql://migration_user:***@db-host:5432/siteforge_stage?sslmode=require'
python scripts/migrate_sqlite_to_postgres.py --source /path/to/old/siteforge.sqlite3 --confirmed-offline --report /external/migration-evidence.json
```
5. **同步外部文件**:`/data/media`、`legacy`、`builds`、`archives` 与 `published` 卷独立同步/校验(迁移工具**不会**代替 rsync/对象存储迁移);不要把两个新旧服务同时指向可写目录。
6. **验证**:逐表行数/哈希、媒体 URL、静态质量报告和线上正式域名一致性;如有失败保持旧站继续服务。
7. **切换**:预生产全部通过后再人工批准业务切换,保留源库与可回滚路径。
工具对旧 SQLite 数据库进行**只读备份快照**;向新 PostgreSQL 写入是显式 `--confirmed-offline` 才能执行的操作。导入使用单事务;建表(初始化)先于该事务,不等于整个目标库已经过生产级迁移验收。
## 四、多 Worker / Redis
PostgreSQL 是权威任务队列,Redis 只负责唤醒通知;Redis 不可用时 Worker 回退数据库轮询,不能把 Redis 看作持久化任务存储。V8.18 对发布任务加上“结果不确定则不重试”防护,但**未获得真实 Docker 2+ Worker 并发、断电、丢包和故障切换证明**,不能宣布多实例高可用。
## 五、本轮验收证据限制
本地环境未安装 Docker Engine、PostgreSQL 客户端或 psycopg,无法声称完成真实 PG/Redis Docker 验收。新增脚本和自动化测试提供代码及 CI 入口;应在 GitHub Actions 隔离 postgres 服务上实际运行 `scripts/verify_pg_bootstrap.py` 和 `scripts/verify_sqlite_pg_transfer.py`,保存日志,然后在运维侧完成正式恢复演练。
## 六、尚未关闭的 V9.0 要求
Art Design Pro 官方前端完整工程编译集成、复杂 WordPress 插件迁移、企业 SSO/MFA、长期容灾、同域路径多语言、真实服务器发布验收,以及完整移动端体验均仍在后续工作范围内。
+78
View File
@@ -0,0 +1,78 @@
# SiteForge Enterprise V8.19 — 统一管理后台集成与验收手册
**版本**:8.19.0-dev.1(开发集成版)
**基线**:V8.18 完整源码
**日期**:2026-10-23
**状态**:业务代码、路由适配及后端接口已集成;Art Design Pro 官方完整源码不在交付包内,因此未执行官方 `pnpm build`,也未通过真实浏览器/线上反代验收。
## 1. 范围与已实现
- 新增 FastAPI `GET /api/console/bootstrap`:会话绑定的管理菜单、可见站点清单、指定站点权限能力清单;未授权站点返回 403。纯查询,不传递密钥或 CSRF token。
- 新增 Vue `ConsoleHome.vue` 工作台、`ContentCenter.vue` 内容 CRUD 页面(调用已有 API,不复制业务数据库逻辑)。
- 统一 Art 前端七个既有页面的请求错误/CSRF 同源处理与当前网站选择;通过 `_shared.ts` 获取服务端会话,权限**仍由后端检查**。
- 官方路由只注册 **一个 `/siteforge` 导航树**,含工作台、网站管理、内容管理、网站设计、数据与迁移、构建与发布、系统管理;主题与 V8 高级工具隐藏为子路由,避免重复一级菜单。
- 安装器支持 dry-run、幂等、文件冲突保护、备份、显式升级旧标记、SHA-256 安装核对。
- SiteForge 登录页支持同站安全 `next=/siteforge/...` 返回路径;无跨域任意跳转。
## 2. 前置要求
1. 从官方网站获取 **Art Design Pro 官方完整源代码**、合法依赖和版本锁文件,确认对应的 `src/router/routes/asyncRoutes.ts` 结构。未提供源码时不得称为官方已经集成、编译成功。
2. Node、pnpm 版本以该 Art 官方提交/锁文件要求为准。
3. 采用 **一个 HTTPS 域名**承载 Art SPA 和 FastAPI API,统一 Session Cookie、CSRF 与 Host;不要通过跨域代理绕过后端 Session 与 CSRF。
4. FastAPI 需保留 `/api/auth/login`、`/api/auth/me`、`/api/auth/logout` 权威登录路径。Art 演示账户和假 Token 不可作为正式 SiteForge 认证。
## 3. 安装程序
```bash
# 在 SiteForge 根目录(以下路径自行替换)
python scripts/install_into_art_design_pro.py /path/to/art-design-pro
python scripts/install_into_art_design_pro.py /path/to/art-design-pro --apply
python scripts/verify_art_design_pro_v819.py /path/to/art-design-pro
# 安装了官方 npm 依赖后运行真正的编译(不是模拟)
cd /path/to/art-design-pro
pnpm install --frozen-lockfile
cd /path/to/siteforge
python scripts/verify_art_design_pro_v819.py /path/to/art-design-pro --build --report reports/v819-art-build.json
```
如果官方源码里已经存在旧 SiteForge 路由,**先备份源码**,并显式执行:
```bash
python scripts/install_into_art_design_pro.py /path/to/art-design-pro --apply --migrate-legacy --overwrite-views
```
安装器不会删除第三方业务路由;当人工调整过已经安装的 V8.19 路由时,会拒绝自动覆盖,并要求人工审查差异。若旧业务页面曾被人工修改,升级前先对比 `.siteforge-backup`。
## 4. 同源反代
使用 `deploy/art-design-pro/siteforge-nginx.conf.example` 作为 **示例**,人工配置证书、SPA 根目录、Host、上游 IP。正式生产必须启用 `SITEFORGE_COOKIE_SECURE=true`。完整反向代理后逐一检查:
- 登录页 `/login`、认证 `/api/auth/me`、管理接口 `/api/console/bootstrap`;
- Art 页面 `/siteforge/dashboard` 与 `/siteforge/content`;
- 页面设计 `/editor/{site}/{entry}`、预览 `/preview/...`、媒体 `/local-media/...`;
- 静态站构建与文件下载;
- 401/403 行为、CSRF POST 保护、Session 过期后跳转,以及移动端导航。
注意:Art 自带演示登录/用户菜单与 SiteForge 账户是**两套系统**。正式部署时必须关闭演示权限作为真实权限的假设,使用 SiteForge 会话作为权威身份;Art 路由的可见性只是界面提示,不可替代服务端授权。尚未实施企业 SSO。
## 5. 已完成/未完成门禁
| 验收项 | 状态 |
|---|---|
| 原项目自动化测试 | 本地可重复回归通过;原始日志随包交付 |
| 新工作台授权 API | 自动化测试通过 |
| Art 安装器模拟工程测试 | 自动化测试通过(仅验证适配逻辑) |
| HTTP 站点创建 → Worker 构建 → 下载 → 预览 | 本地端到端通过 |
| Art 官方 `pnpm build` | **未执行**(官方完整源码/依赖未提供) |
| PC 与移动端真实 Art UI | **未执行**(需官方工程构建和正常浏览器环境) |
| Nginx HTTPS + SameSite + Cookie/CSRF | **未执行**(需独立测试域名) |
| PostgreSQL/Redis/多 Worker/发布灾备 | 延续 V8.18 的独立生产验收门禁 |
## 6. 尚需完善(下阶段)
- 将 Art 登录体验与 SiteForge 后端 Session 完整合一(实际官方仓库必须可编译、完成端到端路由守卫)。
- 企业功能导航按站点角色动态显示(本轮后端已提供能力清单,但 Art 全局动态菜单权限筛选未能在官方工程中实测)。
- 完整移动端和桌面端视觉验收、内容编辑历史、发布审批操作的一致性审查。
- 将高级生产控制台原有大部分遗留界面彻底迁入统一模块,避免跳出 SPA。
- 生产 Nginx、真实 Art build、数据库、Redis 和部署回滚的独立环境验收。
+48
View File
@@ -0,0 +1,48 @@
# SiteForge Enterprise V8.20 — WordPress 复杂迁移增强
版本:`8.20.0-dev.1`。此版本属于**开发集成版**,在 V8.19 完整源码上增加离线兼容预检、简单 ACF Repeater 结构保留、CSS 背景图本地化、批次暂存路径阻断。**不代表复杂 Elementor/ACF/WooCommerce 功能或设计已实现无损迁移。**
## 1. 新增实际功能
- 新增 `app/wp_complex_v820.py`,使用 `defusedxml` 安全解析最多 16 MB、20,000 条记录的 WXR,按已发布内容发现 URL 冲突、动态参数 URL、复杂 Elementor、ACF 字段、PHP 序列化、自定义文章类型、WooCommerce 和动态短代码。
- 新增后台 `POST /api/migrations/sites/{site_id}/wordpress/wxr-preflight`:`multipart/form-data` 的 `file` 字段,登录、CSRF 和站点 Editor 权限均必需。只读返回 JSON,不写站点数据,不访问外网。
- 现有 `/production-v8` WordPress 面板新增“兼容性预检”按钮和结果区。
- `app/importers.py` 直接 WXR 导入:带参数的动态 URL 或缺失原始路径现在拒绝导入,不再凭 slug 猜测路径;ACF 简单 Repeater 结构保留在 `meta.fields` 和 `meta.acf_repeaters` 中,原始字段仍保留供核对。
- `app/v8_workflows.py` WXR 暂存:复用预检阻断危险旧 URL;不再将自定义文章类型错当成分类;复杂组件形成发布审核项。
- `app/wp_migration_v82.py` 媒体本地化扩展至行内 CSS `url(https://...)`、`data-background`、`data-background-image` 与已有 `srcset`。只允许已授权主机的资源进入自动下载,其他 URL 需要单独审核。
- 新增 `scripts/wp_v820_preflight.py`:离线生成 JSON/HTML 风险报告;可选 `--dist` 对比静态 HTML 路径覆盖。
## 2. 直接运行
在项目根目录:
```bash
python scripts/wp_v820_preflight.py fixtures/v820/wordPress-complex-sample.xml --output reports/v820-demo.json
# 可选,待已经执行静态构建后:
python scripts/wp_v820_preflight.py export.xml --dist /absolute/path/to/build/dist --output reports/my-site.json
```
脚本返回码:`0` 表示没有识别到阻断问题且(如指定 `--dist`)没有发现缺失的 HTML 路径;`1` 表示出现阻断或缺失路径;`2` 表示输入、读取或 XML 解析失败。**退出码 0 不意味着布局或业务功能无损,人工审核仍是发布条件。**
在统一后台选择网站 → 网站生产控制台 → WordPress 迁移 → 选择 WXR 文件 → 点击“检查旧 URL / Elementor / ACF / 动态功能”。
## 3. 风险与未完成项目
| 方向 | 当前实现 | 未完成的生产验收 |
|---|---|---|
| Elementor | 识别已支持/未知组件,有限静态 HTML 转换,保留原始 JSON | 容器布局、动态标签、全局样式、响应式精确还原、交互插件 |
| ACF | 单值字段、简单 Repeater 的有界结构提取;PHP 序列化只标记,不执行 | 字段组 schema、Flexible Content、嵌套 Repeater、关系字段、文件字段 |
| WooCommerce | 识别产品相关数据并列入审核 | 变体、价格策略、库存、购物车、结算、订单等动态服务 |
| 媒体 | 同源 HTTPS/HTTP 图片与 CSS 背景图引用分析及本地化 | 跨域 CDN 授权、尺寸变体资源、未引用附件、CSS 外链文件及版权许可 |
| URL/SEO | WXR 旧路径预检,静态构建路径核查,原有 SEO 差异工具 | 真正旧站的完整 Sitemap + robots + 分页 + 301 + Canonical 实测核对 |
| 插件功能 | 表单、动态短代码、iframe 标为风险 | CRM/表单业务重建与实际浏览器 E2E |
WXR XML **不包含附件原文件,也不包含完整站点设置和全部插件配置**;需另行取得对应 WordPress 资源以及已获授权的插件数据。
## 4. 迁移作业建议
先备份 WordPress 原站、数据库与媒体文件;离线预检 WXR 和 Sitemap;修复阻断问题后按站点逐步暂存/提交数据;迁移 CSS/响应式图片及全部媒体;新旧站逐 URL 对账,再在独立预生产环境实测 PC/移动端布局、交互、SEO、动态表单及发布回滚。上线前必须完成每一个已识别风险的人工复核。
## 5. 测试证据边界
自动化使用本地固定 WXR、TestClient 和模拟媒体,禁止把这些结果描述为真实客户网站的迁移通过。V8.18 生产 PostgreSQL/Redis/Docker 多 Worker 与 Art Design Pro 官方工程编译仍未完成真实现场验收。本包不会自行连接或更改用户的生产网站。
+51
View File
@@ -0,0 +1,51 @@
# SiteForge V8.21.1 — 可视化编辑器体验增强
状态:`8.21.1-dev.1`,在 V8.21 源码基础上做前端编辑器的向后兼容升级。
## 已实际开发的内容
1. **页面区块面板**:按标题、类型、嵌套区块内容搜索;拖拽排序继续保留;新增上/下移动、显隐、复制和删除快捷操作;支持 Alt+↑/↓ 按键移动聚焦的结构行。
2. **属性分区**:从单一长滚动面板拆分为「内容」「样式」「响应式」,多列布局与平板/手机跨度和显隐均放在响应式面板。
3. **PC/平板/手机预览**:桌面自适应宽度、平板 780px、手机 390px;展示明确设备标签,窄屏可分别展开区块侧栏和属性侧栏。
4. **页面刷新安全性**:所有预览请求使用 `AbortController` 取消上一个请求,并通过请求序号和页面 ID 双重核对;旧请求不得覆盖新预览。
5. **草稿可靠保存**:调用保存接口前冻结请求数据,写入过程中若发生额外本地修改不会错误清除 dirty 标志;同一时间仅处理一项保存请求,页面切换时阻止混淆。
6. **撤销与重做**:历史操作按钮可用状态实时更新;连续输入编辑合并短时间内的快照,提供 Ctrl/Cmd+S 快捷保存(前端输入框内保留浏览器默认编辑撤销行为)。
7. **顶部操作层级**:保留高频保存/发布操作,低频的版本历史、全站设计、构建等放入「更多操作」菜单。移动端保存按钮仍可见。
8. **测试辅助**:纯函数 `editor-helpers.js` 以 Node Test Runner 检验,新增浏览器烟测脚本(需要运行环境允许本机 HTTP)和可离线打开的静态交互演示。
## 安全与兼容约束
- 不修改已有页面 `blocks` JSON 数据模型、不新增生产数据库迁移;旧页面和已有 V8.21 草稿可以继续使用。
- 仍使用原有 `PUT /studio-draft` 的 `expected_revision` 乐观锁、发布接口、站点权限及审批逻辑。页面草稿保存不会自动发布静态网站。
- 编辑器 iframe 仍使用 `sandbox="allow-scripts"`,未添加 `allow-same-origin`。UI 表单不执行用户自定义代码。
- **没有**实现无限自由定位、Figma 式像素编辑、复杂组件级协作锁、跨页面模板继承或官方 Art Design Pro 全工程真实编译。相关模块需要后续专项开发。
## 本次验证结果
- JavaScript 纯函数:6/6 通过(`node --test tests_js/editor_ux.test.cjs`)。
- Python 编辑器专项和既有历史/映射:9/9 通过;新静态界面测试:3/3。
- 现有 39 个 Python 测试模块**分段或独立运行**,累计 328 个用例通过;其中前六批计 215 项、批次 7 拆分运行计 26 项、余下独立模块 87 项。
- HTTP + Worker 实际建站构建:通过(隔离临时 SQLite 环境)。
- Python 编译、全部 `app/static/*.js` 语法校验、工程静态发布检查:通过。
- 一键完整回归:**未通过完整单次执行门槛**,第七批发生进程停滞,已记录。独立模块通过不代表一键执行器稳定。
- 真实 Chromium 操作:当前执行环境 `ERR_BLOCKED_BY_ADMINISTRATOR`,不计为通过;保留 `scripts/browser_studio_v8211.py` 供在 CI/开发机复测。
- Docker/PostgreSQL/Redis/Art Design Pro 官方工程:未进行本轮真实环境验收。
## 运行与使用
```bash
python scripts/http_end_to_end.py
node --test tests_js/editor_ux.test.cjs
python -m pytest -q tests/test_editor_ux_v8211.py
python scripts/browser_studio_v8211.py # 需本地 Chromium、Playwright、本机 HTTP 访问权限
```
网站编辑器继续通过 `/editor/{site_id}/{entry_id}` 访问。单独查看视觉交互可直接在浏览器打开 `examples/Studio_V8_21_1_交互预览.html`,该文件为**示例 UI**,不访问数据库也不是真实发布端。
## 后续计划
- 进行真实浏览器的 PC/平板/手机 E2E,并按操作可用性修复视觉缺陷;
- 实现嵌套 layout 子组件的独立属性选择、拖拽落点可视化与组件版本继承;
- 增加数据源联动的结构化表单编辑,而非复杂 JSON 文本框;
- 拿到 Art Design Pro 官方项目后进行完整编译及权限/路由集成验收;
- 排查一键测试执行器跨模块进程停滞并在 CI 设置稳定性门槛。
+61
View File
@@ -0,0 +1,61 @@
# SiteForge V8.21.2 — 新手友好可视化编辑器
> 基于 V8.21.1 完整源码的易用性专项迭代。交付性质:**开发集成版**,未改动正式生产数据和已有网站区块 JSON 模型。
## 一、面向使用者的新操作流程
**1. 选择内容**:左侧选择正在编辑的页面;点击一个已有内容区块,或点击醒目的「+ 添加内容区块」。区块库在不使用时自动折叠,左侧结构支持搜索和排序。
**2. 修改内容**:右侧默认展示内容字段;只有需要时才点击「样式」或「手机/平板」。支持在中间预览区直接单击一个带有 `data-block` 的区块,自动定位到右侧属性面板;原有双击文字编辑继续保留。
**3. 保存与发布**:右上角固定显示「保存草稿」「发布设计」。保存草稿使用原有 CSRF 与 `expected_revision` 乐观锁,不会自动改变正式构建;发布设计更新内容库,网站仍需完成后续静态构建与正式部署。版本历史、全站设置、构建等较少使用的操作集中在「更多」。
页眉下方新增 3 步提示,以及自愿打开的「新手使用指南」对话框,避免强制遮挡使用者。
## 二、实现改动
| 文件 | 说明 |
|---|---|
| `app/templates/editor.html` | 重排顶部主操作、三级简洁步骤、左侧页面/内容优先、结构化侧栏、帮助对话框;修复旧版更多菜单标记不清晰问题 |
| `app/static/editor.css` | 桌面/平板/手机布局、表单可读性、强调主操作、低频内容折叠、表单列表样式 |
| `app/static/editor.js` | 区块库显隐、步骤反馈、选择后定位属性面板、逐项编辑重复内容、预览点击区块的父页面消息处理 |
| `app/main.py` | 对编辑器预览中的 `[data-block]` 添加点击选择的桥接消息,并保持 `sandbox="allow-scripts"`(不启用同源权限) |
| `tests/test_editor_easy_v8212.py` | 布局唯一 ID、主操作位置、预览消息限制、后端兼容性等静态检查 |
| `examples/Studio_V8_21_2_新手友好版交互预览.html` | 离线、模拟数据的可操作示例;不写入任何真实站点 |
### 对非技术人员的重大改善
- `features` 核心优势、`stats` 数字指标、`faq` 常见问题、`gallery` 图片画廊、`carousel` 轮播、`tabs` 标签页、`pricing` 服务方案:从 JSON 数组文本框改为「第 N 项 / 普通输入框 / 添加 / 删除 / 上下移动」。
- 原有项目后台接口、页面数据结构、草稿快照及发布审批流程保持兼容;列表编辑只是前端输入方式改变。
- 中间预览可点击区块实现选中;安全边界在父窗口同时检查 `event.source === frame.contentWindow` 与真实已加载 block ID。
## 三、当前测试记录
- 编辑器/设计器相关 pytest:28 项通过。
- 内容历史、核心业务、旧版流程 pytest:30 项通过。
- Node.js 编辑器工具函数:6 项通过。
- Python 源码编译和 `editor.js` 语法检查通过;离线示例 JS 通过 Node.js 语法检查。
- `python scripts/http_end_to_end.py`:通过 HTTP/CSRF/建站/Worker 构建/ZIP/预览。
- **未执行**全项目一次性回归;既有版本的回归执行器有过偶发进程停滞,需在隔离 CI 上再做全量测试。
- **未完成**完整 Chromium PC/移动端操作验收:本运行环境的 Chromium 截图启动超时,故不能宣称浏览器 E2E 通过。
- **未完成**官方 Art Design Pro 全工程编译、生产 PostgreSQL/Redis 与真实发布环境验收。
## 四、运行方法
```bash
# 运行原 SiteForge 后端(环境配置保持原项目方式)
# 编辑器入口:/editor/{site_id}/{entry_id}
python -m pytest -q tests/test_editor_easy_v8212.py tests/test_editor_ux_v8211.py tests/test_studio_v86.py tests/test_site_design_v87.py
node --test tests_js/editor_ux.test.cjs
python scripts/http_end_to_end.py
```
离线预览可直接双击 `examples/Studio_V8_21_2_新手友好版交互预览.html`。演示中的保存/发布仅改变页面临时示例状态,不会调用任何线上 API。
## 五、仍需产品验收的体验问题
1. 真实 PC/移动端浏览器中验证侧栏展开、点击画布选中、键盘和焦点移动。
2. 对复杂 `layout` 嵌套子组件实现更细的画布选中与属性编辑(当前点击子组件会定位其父布局区块)。
3. 对图片上传和媒体选择增加直观的缩略图与拖拽;当前仍沿用旧媒体库交互。
4. 组织非技术同事完成一次创建区块→修改→保存→发布的实操测试,依据其反馈再做收敛。
5. 后续将本版业务页面真正装入 Art Design Pro 官方工程并执行 `pnpm build` 与浏览器集成。
+46
View File
@@ -0,0 +1,46 @@
# SiteForge V8.21.3 — 四套主题移动端统一优化说明
## 当前基线结论
原 V8.21.2 的 automotive、industrial、corporate、minimal 四套内置专业主题具有基本响应式 CSS(移动端单列卡片、缩小标题、部分间距调整),但原导航在手机上是页眉下方的纵向展开列表,**不是独立抽屉**;没有底部快捷 Tabs 配置;因此不能将旧版称为全面完成移动端优化。V8.21.3 针对这些明确缺口做了源码级修改,并完成指定场景的浏览器验收。不能把 4 种代表页的验收当成所有页面和所有机型的充分验收。
## 新版功能
- 四套主题在 1024px 及以下使用右侧抽屉,点击汉堡按钮展开,多级目录可单独展开;移动端原“分类 Mega 菜单”链接合并进入抽屉。
- 抽屉支持关闭按钮、遮罩、Escape、Tab 焦点约束以及关闭时焦点返回;支持页面跳转时关闭菜单。桌面导航保持原有样式。
- 手机 767px 及以下可显示可选底部 Tabs,**默认关闭**,无任何按钮时也无需配置。启用后可添加 1–5 项,删除、上下排序、选择标题和预置图标、填写 URL,也可以配置一个“打开菜单”操作。
- 链接允许站内路径(`/products/`)、`https://` 安全外链(如 WhatsApp)、`tel:` 与 `mailto:`;不允许 `javascript:`、`data:`、`http:`、协议相对地址或带用户名/密码的 HTTPS URL。存入数据库前全部由服务端校验。
- 底栏自动适配设备安全区,启用时为页面底部预留空间以免遮挡内容;停用后没有额外留白。
- 手机端卡片、图片、标题和文字区域增强窄屏 CSS;保留桌面模板、页面路径、SEO 数据、全站设计版本及发布审批模型。
## 管理后台操作
1. 进入目标网站的“可视化编辑器”,打开“全站页眉与页脚”。
2. 找到“手机底部快捷导航 Tabs(可选)”。不开启时,手机页面**不显示**底栏。
3. 开启后添加 1–5 个项目,分别填写文字、选择图标和点击操作;可上移、下移和删除。
4. 可点击“预览草稿”查看当前编辑器预览;“保存全站草稿”**不会**修改已生成的网站;“发布全站设计”后,还需要重新构建网站并按既有流程部署。
5. 手机菜单仍使用全站导航树,并继承二级、三级菜单结构。高级导航树的内容编辑入口仍保留原有 JSON 编辑方式;本轮重点是手机导航呈现,不是完全替换所有导航的配置方式。
## 实现文件
- `app/site_design_v87.py`:增加 `mobile_tabs` 配置、默认关闭、链接与图标字段校验、草稿隔离。
- `app/theme_pro.py`:四套主题渲染上下文与预置图标。
- `themes/{automotive,industrial,corporate,minimal}/pro.html`:按配置渲染底部栏与激活态。
- `app/static/commercial.css`:统一右侧抽屉、遮罩、Tabs、手机端布局。
- `app/static/pro.js`:菜单打开/关闭、焦点管理、Esc、滚动锁定、抽屉分类链接及底部菜单按钮。
- `app/templates/editor.html`、`app/static/editor.js`:非 JSON 的 Tabs 增删、排序、名称、图标、路径配置。
- `tests/test_mobile_tabs_v8213.py`、`scripts/browser_mobile_v8213.py`:专项验证。
## 验证范围
- Python:四个内置主题的真实静态构建、草稿隔离、发布生效、关闭后无 Tabs、非法链接与数据结构拦截。
- Chromium:利用实际模板 HTML/CSS/JS 在 `page.set_content` 离线渲染,验证抽屉、遮罩、Esc、底栏菜单、桌面隐藏、脚本错误。对首页、产品列表、产品详情、文章详情分别用 375/390/430px 宽度检测水平溢出。
- HTTP+Worker:原有登录、CSRF、建站、后台任务、构建、ZIP 和预览链路再次通过。
- 浏览器执行环境不允许 Chrome 导航到 `file://` 和 localhost,因此浏览器检查采用 HTML/CSS/JS 内联加载方式;这**不等于**真实域名/CDN/移动设备端到端验收。
## 未关闭事项
- 用户上传的自定义第三方主题不在本轮四套内置主题的统一抽屉改造范围内。
- 真实 iOS Safari、Android Chrome 实机和全部内页(如询盘、搜索、案例、FAQ)仍需专项验收。
- Art Design Pro 官方完整工程的构建验证、正式 PostgreSQL/Redis/Docker 生产环境验收沿用前一版未关闭项。
- 本版本只是开发集成版;发布静态站不会自动覆盖线上生产数据。
+50
View File
@@ -0,0 +1,50 @@
# SiteForge V8.21:可执行开发说明和验收边界
状态:**8.21.0-dev.1 开发集成版**。基于 V8.20 完整源码开发,未对用户生产环境执行升级。
## 页面编辑器历史
- 数据表 `studio_history`:site_id / entry_id / revision / kind(draft,published) / snapshot_json / snapshot_hash / actor / created_at。支持删除站点/页面后的级联清理,不额外泄露其他站点记录。
- `GET /api/sites/{site}/pages/{entry}/studio-history?limit=30`:查看当前网站当前页面最近版本(1–100);最低 viewer 权限。
- `GET .../studio-history/{history_id}`:读取单个历史快照并验证 SHA-256;最低 viewer 权限。
- `POST .../studio-history/{history_id}/restore`:请求体 `{"expected_revision":2}`;最低 editor 权限;采用乐观修订检查,只创建新的 studio_drafts 草稿,不调用静态构建,也不修改已发布内容。
- 发布仍需要 owner 权限;发布后的页面必须单独执行正式静态构建和审批才能上线。
- 历史表目前**未配置保留上限/外部归档**,大规模多人协作前应定义存储周期与审计导出策略。原先保存在本机浏览器会话里的撤销/重做仍可用,不属于持久化历史。
## 数据映射
旧式 `{ "title": "name" }` 规则保持兼容。新增可声明的字段处理(不能传入任意 SQL、JavaScript 或 Python):
```json
{
"title": {"source":"name", "trim":true, "case":"upper", "prefix":"MODEL: "},
"brand": {"source":"brand", "case":"lower", "default":"Unspecified"},
"route": "old_url"
}
```
- `POST /api/factory/sites/{site}/mapping/run` 带 `dry_run:true` 返回 `plan_hash`、冲突列表、错误、示例数据。
- 再调用该 API,传 `dry_run:false`、`expected_plan_hash` 及 `on_conflict: "abort"|"skip"|"replace"`。预检被其他人修改后拒绝旧指纹。建议以 `abort` 开始试点,数据修改需要明确选择 `replace`。
- `plan_hash` 包含映射后的内容及相关已有记录完整状态,不包含远程认证信息;它是乐观变更检查**不是防篡改签名**,也不是跨进程数据库锁。
- **限制**:当前装配持久化沿用原有逐条 `save_entry` 路径,不保证批次级 ACID 原子回滚。`abort` 可阻止预检时已知的错误和冲突,但不能承诺极端并发写入期间整批原子提交。批次回滚及并发事务化属于后续 P1 任务。
- 每批最多 1,000 条,保持原有容量上限;映射不运行代码,也不会自动创造业务数据。
## UI
- 原生编辑器 `/editor/{site}/{entry}` 新增「版本历史」弹窗与确认恢复;所有文案提示恢复仅生成草稿。
- 原生网站工厂 `/factory-studio` 的内容导入新增字段变换、严格冲突策略和预检绑定;原有 Art Design Pro 官方工程尚未获得,因此未验证其实际 `pnpm build` 和完整业务端集成。
## 本地验收记录
- `38` 个测试模块分 10 组独立运行,共 `325 passed`;所有分组原始输出见 `reports/v821_manual_verified/`。
- `python scripts/http_end_to_end.py`:通过登录/CSRF → 创建网站与页面 → Worker 构建 → ZIP → 预览。
- `python scripts/verify_release.py`、`python -m compileall -q app` 以及 `node --check app/static/*.js`:通过。
- 一键长流程在当前环境出现过停滞。已对 V8.21 执行器增加进程组超时清理,但其完整单次运行稳定性仍需独立 CI 复验;不计入上面的“通过”判定。
- **未验收**:真实 PostgreSQL/Redis、Docker 多 Worker、浏览器/移动端视觉交互、真实 WordPress 复杂迁移、官方 Art Design Pro 编译、远程发布与回滚。
## 后续门槛
1. 建立隔离生产仿真环境,在 PostgreSQL 模式跑迁移/数据装配/编辑历史多进程用例并演练恢复。
2. 使批量导入具有数据库级事务一致性、导入版本差异和可回滚工作流。
3. 将编辑器版本历史组件并入 **官方** Art Design Pro 工程,覆盖桌面与移动端真实浏览器 E2E。
4. 扩展整站模板与组件级版本继承、协作审批及内容设计依赖图。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
{
"isolated_pg": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"redis_ping": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"docker_health": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"backup_restore": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"browser_smoke": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"wordpress_url_parity": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"staging_deploy": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"rollback": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"production_approval": {"status": "pending", "operator": "", "date": "", "evidence": ""},
"art_design_pro": {"status": "pending", "operator": "", "date": "", "evidence": ""}
}
@@ -0,0 +1,13 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base href="./"><title>企业动态与行业资讯</title><meta name="description" content="了解最新项目与服务进展"><meta property="og:type" content="article"><meta property="og:title" content="企业动态与行业资讯"><meta property="og:description" content="了解最新项目与服务进展"><meta property="og:url" content="https://example.com/news/demo/"><meta name="theme-color" content="#0e1c2c"><meta name="robots" content="index,follow"><link rel="canonical" href="https://example.com/news/demo/"><link rel="stylesheet" href="assets/theme.css"><link rel="stylesheet" href="assets/blocks.css"><link rel="stylesheet" href="assets/factory.css"><link rel="stylesheet" href="assets/pro-base.css"><link rel="stylesheet" href="assets/pro.css"><link rel="stylesheet" href="assets/commercial.css"><link rel="stylesheet" href="assets/custom.css"></head><body class="sf-theme-automotive pro-automotive"><a class="pro-skip" href="#main-content">跳转至正文</a>
<div class="pro-utility" hidden><div class="pro-wrap"><span></span><span>专业车辆采购与全球交付</span></div></div>
<header class="sf-site-head pro-header"><div class="pro-wrap pro-head-inner"><a class="pro-logo" href="index.html" aria-label="汽车出口 首页"><span class="pro-logo-symbol">S<span>.</span></span><span class="pro-logo-name">汽车出口<small> AUTOMOTIVE</small></span></a><button class="pro-mobile-toggle" type="button" data-pro-menu aria-controls="pro-navigation" aria-expanded="false" aria-label="打开导航">☰</button><nav id="pro-navigation" class="pro-navigation" aria-label="主导航"><a href="index.html">首页</a><div class="pro-nav-group"><a href="listing.html">产品系列</a><button type="button" class="pro-nav-disclose" aria-expanded="false" aria-label="展开产品系列子菜单">▾</button><div class="pro-nav-children" hidden><a href="/classic/">经典系列</a><a href="/ev/">新能源</a></div></div><a href="/about/">品牌介绍</a><a href="article.html">新闻资讯</a><a href="article.html">联系我们</a><a class="pro-search-link" href="/search/" aria-label="站内搜索">⌕</a></nav><button class="pro-catalog-toggle" type="button" data-pro-catalog aria-controls="pro-mega-content" aria-expanded="false">品牌与系列 ▾</button><a href="article.html" class="pro-header-cta">联系我们 <span>↗</span></a></div><div class="pro-mega pro-wrap" data-pro-mega id="pro-mega-content" hidden><span>探索品牌与系列</span><div><a href="/brands/example/">EXAMPLE ↗</a><a href="/categories/term-36a4ff4ebeb5/">精选产品</a></div></div></header>
<section class="pro-article-hero"><div class="pro-wrap pro-article-width"><span class="pro-eyebrow">LATEST NEWS / INSIGHTS</span><h1>企业动态与行业资讯</h1><p>了解最新项目与服务进展</p></div></section><article class="pro-wrap pro-article-content"><div class="pro-rich"><h2>项目背景</h2><p>这是一段用于检查手机端资讯排版的样例正文。适当的字距、行距和图片宽度能够提升移动阅读体验。</p></div><a class="pro-text-link" href="article.html">← 返回新闻</a></article>
<main id="main-content" class="sf-canvas pro-custom-content" aria-hidden="true"></main>
<section class="pro-end-cta"><div class="pro-wrap"><div><span class="pro-eyebrow">LET'S CONNECT</span><h2>从这里开始下一步。</h2><p>专业车辆采购与全球交付</p></div><a href="article.html">联系我们 <span>↗</span></a></div></section>
<footer class="sf-site-foot pro-footer"><div class="pro-wrap"><div class="pro-footer-top"><div><span class="pro-eyebrow">DRIVEN BY PURPOSE</span><h2>汽车出口</h2><p>专业车辆采购与全球交付</p></div><div class="pro-footer-links"><ul><li><a href="index.html">首页</a></li><li><a href="listing.html">产品系列</a></li><li><a href="/about/">品牌介绍</a></li><li><a href="article.html">新闻资讯</a></li><li><a href="article.html">联系我们</a></li></ul></div><div><span class="pro-eyebrow">LET'S TALK</span><p>请在后台配置企业联系方式</p></div></div><div class="pro-footer-bottom"><span>© 2026 汽车出口</span><span>SITEFORGE / STATIC SITE</span></div></div></footer>
<nav class="sf-mobile-tabs" style="--sf-mobile-tab-count:4" aria-label="手机底部快捷导航" data-mobile-tabs>
<a class="sf-mobile-tab" href="index.html"><span class="sf-tab-icon" aria-hidden="true">⌂</span><span class="sf-tab-label">首页</span></a><a class="sf-mobile-tab" href="listing.html"><span class="sf-tab-icon" aria-hidden="true">▤</span><span class="sf-tab-label">产品</span></a><a class="sf-mobile-tab" href="article.html"><span class="sf-tab-icon" aria-hidden="true">✉</span><span class="sf-tab-label">联系我们</span></a><button type="button" class="sf-mobile-tab" data-mobile-open-menu aria-label="菜单"><span class="sf-tab-icon" aria-hidden="true">☰</span><span class="sf-tab-label">菜单</span></button></nav>
<script defer src="assets/site-runtime.js"></script><script defer src="assets/pro.js"></script></body></html>
@@ -0,0 +1,29 @@
/* Portable SiteForge blocks, loaded with every theme (all styles scoped to sf-). */
.sf-section{background:var(--sf-bg,transparent);color:var(--sf-fg,inherit);padding:var(--sf-pad,82px) 0;margin:0}.sf-section:nth-of-type(even){background:var(--sf-bg,#f4f6f9)}.sf-container{max-width:1200px;margin:auto;padding:0 32px}.sf-section h1,.sf-section h2,.sf-section h3,.sf-section p{color:inherit;line-height:1.3}.sf-section h1{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}.sf-section h2{font-size:clamp(27px,3.2vw,44px);font-weight:760;letter-spacing:-.037em}.sf-kicker{display:block;font-size:12px;letter-spacing:.17em;text-transform:uppercase;font-weight:740;color:var(--accent,#3569d3);margin-bottom:18px}.sf-hero-grid,.sf-image-text{display:grid;grid-template-columns:1.08fr .92fr;gap:68px;align-items:center}.sf-hero-copy p{font-size:18px;line-height:1.75;max-width:520px;opacity:.75}.sf-hero-img,.sf-image-side{overflow:hidden;border-radius:9px;min-height:380px;background:linear-gradient(130deg,#d5e1ee,#eef3f9);display:grid;place-items:center}.sf-hero-img img,.sf-image-side img{width:100%;height:100%;max-height:580px;object-fit:cover}.sf-hero-abstract{font-size:110px;color:#a7bdd4}.sf-button{display:inline-flex;align-items:center;justify-content:center;gap:28px;background:var(--accent,#315ee2);color:white!important;padding:16px 26px;border-radius:6px;font-size:14px;text-decoration:none;font-weight:720;margin-top:24px}.sf-section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:32px;gap:24px}.sf-section-head h2{margin:0}.sf-section-head>a,.sf-link{color:var(--accent,#3569d3);font-weight:700;text-decoration:none}.sf-prose{font-size:16px;line-height:1.85;max-width:930px}.sf-prose p{line-height:1.85}.sf-features,.sf-collection,.sf-stats{display:grid;gap:20px}.sf-features{grid-template-columns:repeat(3,minmax(0,1fr))}.sf-feature{border:1px solid #e0e6eb;padding:32px 28px;border-radius:8px;background:#fff;color:#253247}.sf-feature>span{font-size:12px;font-weight:800;color:var(--accent,#346add)}.sf-feature h3{font-size:22px;margin:30px 0 8px}.sf-feature p{font-size:14px;line-height:1.7;color:#647181}.sf-stats{grid-template-columns:repeat(4,1fr);padding:40px 0;border-top:1px solid #b7c0cd66;border-bottom:1px solid #b7c0cd66}.sf-stats>div{display:flex;flex-direction:column;gap:8px}.sf-stats strong{font-size:clamp(28px,4vw,53px);letter-spacing:-.04em}.sf-stats span{font-size:12px;opacity:.65;letter-spacing:.04em}.sf-collection{grid-template-columns:repeat(var(--sf-cols,3),minmax(0,1fr))}.sf-item{display:block;text-decoration:none;color:#233044;background:#fff;border:1px solid #e8edf1;border-radius:8px;overflow:hidden;transition:transform .2s,box-shadow .2s}.sf-item:hover{transform:translateY(-4px);box-shadow:0 18px 38px #344d7018}.sf-item-img{height:210px;overflow:hidden;background:#e4eaf0}.sf-item-img img{height:100%;width:100%;object-fit:cover}.sf-placeholder{display:grid;place-items:center;width:100%;height:100%;min-height:190px;background:linear-gradient(130deg,#e2eaf2,#c9d7e6);font-size:72px;color:#8399b2;font-weight:800}.sf-large{min-height:360px}.sf-item-copy{padding:25px}.sf-item-copy small{text-transform:uppercase;letter-spacing:.13em;color:var(--accent,#376edc)}.sf-item-copy h3{font-size:22px;margin:12px 0 7px}.sf-item-copy p{font-size:14px;line-height:1.6;color:#7a8796;min-height:42px}.sf-item-copy span{font-size:12px;color:var(--accent,#376edc);font-weight:800}.sf-image-text>div>p{font-size:17px;line-height:1.8;max-width:520px;opacity:.7}.sf-reverse>div:first-child{order:2}.sf-gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.sf-gallery img{width:100%;height:230px;object-fit:cover;border-radius:6px}.sf-faq{max-width:920px}.sf-faq details{border-bottom:1px solid #dce3eb;padding:22px 0}.sf-faq summary{font-size:17px;cursor:pointer;font-weight:700}.sf-faq p{color:#647181;line-height:1.75}.sf-cta-wrap{display:flex;justify-content:space-between;align-items:center;gap:24px}.sf-cta-wrap h2{margin:0}.sf-cta-wrap p{font-size:16px;opacity:.75}.sf-section a{cursor:pointer}.sf-muted{opacity:.65}
@media(max-width:820px){.sf-container{padding:0 20px}.sf-section{padding:50px 0}.sf-hero-grid,.sf-image-text{grid-template-columns:1fr;gap:26px}.sf-reverse>div:first-child{order:initial}.sf-hero-img,.sf-image-side{min-height:250px}.sf-features,.sf-collection{grid-template-columns:repeat(2,minmax(0,1fr))}.sf-stats{grid-template-columns:repeat(2,1fr)}.sf-cta-wrap{align-items:flex-start;flex-direction:column}}
@media(max-width:540px){.sf-features,.sf-collection,.sf-gallery{grid-template-columns:1fr}.sf-section-head{display:block}.sf-section-head>a{display:block;margin-top:13px}.sf-item-img{height:220px}.sf-hero-grid{gap:22px}.sf-hero-copy p{font-size:16px}.sf-stats{gap:28px}}
.sf-attributes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 32px;border-top:1px solid #e5eaf0}.sf-attribute{display:flex;justify-content:space-between;align-items:center;gap:18px;border-bottom:1px solid #e5eaf0;padding:19px 10px;font-size:14px}.sf-attribute span{color:#748497}.sf-attribute strong{font-weight:720;text-align:right}@media(max-width:640px){.sf-attributes{grid-template-columns:1fr}}
/* SiteForge responsive no-code grid container */
.sf-layout-grid{display:grid;grid-template-columns:var(--sf-layout-cols,1fr 1fr);gap:var(--sf-layout-gap,24px);align-items:stretch}
.sf-layout-column{min-width:0}.sf-layout-column>.sf-section{padding:0;margin:0 0 18px}.sf-layout-column .sf-container{width:100%;padding:0}
.sf-layout-column>.sf-section:last-child{margin-bottom:0}
@media(max-width:820px){.sf-layout-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
@media(max-width:540px){.sf-layout-grid{grid-template-columns:minmax(0,1fr)!important}}
/* V8.6: accessible, script-free commercial design blocks */
.sf-slides{display:flex;gap:18px;overflow-x:auto;scroll-snap-type:x mandatory;padding:6px 0 18px;scrollbar-width:thin}
.sf-slide{flex:0 0 min(78%,720px);min-width:220px;margin:0;scroll-snap-align:start}
.sf-slide img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px}
.sf-slide figcaption{padding:10px 2px;opacity:.7;font-size:14px}
.sf-video{aspect-ratio:16/9;max-width:1000px;background:#ebf0f5;border-radius:10px;overflow:hidden}
.sf-video iframe{width:100%;height:100%;border:0}
.sf-tab{padding:19px 0;border-bottom:1px solid #dde4ec;max-width:960px}
.sf-tab summary{font-size:18px;font-weight:700;cursor:pointer}.sf-tab p{max-width:850px;line-height:1.8}
.sf-prices{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
.sf-price{padding:28px;background:#fff;border:1px solid #dee5ef;border-radius:10px;color:#29394b}
.sf-price strong{display:block;font-size:27px;margin:14px 0}.sf-price li{margin:11px 0}.sf-price ul{padding-left:19px}
.sf-map-address{font-style:normal;font-size:18px;white-space:pre-line;line-height:1.8}
@media (prefers-reduced-motion:reduce){.sf-slides{scroll-behavior:auto}.sf-item{transition:none}}
@media(max-width:540px){.sf-slide{flex-basis:92%}}
@@ -0,0 +1,84 @@
/* SiteForge commercial theme UX layer: accessibility, layout rhythm, media, responsive navigation. */
:root{--sf-shadow:0 20px 55px rgba(17,33,55,.08);--sf-shadow-hover:0 26px 65px rgba(17,33,55,.15)}
html{scroll-padding-top:104px}body{font-feature-settings:"kern";text-rendering:optimizeLegibility}
a,button,input,select,textarea{-webkit-tap-highlight-color:transparent}
a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid var(--pro-accent);outline-offset:4px}
.pro-wrap{width:100%;max-width:min(var(--pro-wide),100%)}.pro-header{isolation:isolate;box-shadow:0 1px 0 rgba(15,35,60,.035)}.pro-head-inner{gap:clamp(15px,2vw,29px)}.pro-logo{min-width:0;flex-shrink:0;gap:12px}.pro-brand-logo{max-width:155px;max-height:46px;width:auto;height:auto;object-fit:contain}.pro-logo-name{overflow-wrap:anywhere}.pro-navigation{gap:clamp(11px,1.6vw,24px)}.pro-header-cta{white-space:nowrap}
.pro-mobile-toggle,.pro-catalog-toggle,.pro-nav-disclose{border:0;background:transparent;color:inherit;line-height:1.2}
.pro-mobile-toggle{min-height:44px;min-width:44px;border:1px solid currentColor;border-radius:6px;font-size:19px}.pro-nav-group{position:relative;display:flex;align-items:center;gap:2px}
.pro-nav-disclose{padding:9px;font-size:13px}.pro-nav-children{position:absolute;top:100%;left:0;min-width:215px;max-width:330px;display:grid;gap:2px;padding:13px;box-shadow:var(--sf-shadow);border-radius:10px;z-index:45;background:var(--pro-background);border:1px solid var(--pro-border);color:var(--pro-text)}
.pro-nav-children[hidden],.pro-mega[hidden]{display:none!important}.pro-nav-children a{display:block;font-size:13px;white-space:normal;padding:10px;border-radius:5px}.pro-nav-children a:hover{background:var(--pro-surface)}
.pro-catalog-toggle{padding:11px 3px;white-space:nowrap;font-size:12px;font-weight:700}.pro-mega{position:absolute;top:100%;left:50%;transform:translateX(-50%);width:min(100%,var(--pro-wide));box-shadow:var(--sf-shadow);z-index:41;display:grid;grid-template-columns:215px 1fr;background:var(--pro-background);color:var(--pro-text);border:1px solid var(--pro-border);border-radius:0 0 15px 15px;padding:29px;align-items:start}.pro-mega>span{font-size:12px;letter-spacing:.06em}.pro-mega>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px 22px;overflow:visible}.pro-mega>div a{white-space:normal;padding:10px 5px;border-bottom:1px solid var(--pro-border)}
.pro-home-hero{overflow:hidden}.pro-home-hero .pro-home-grid{min-height:660px;gap:5%}.pro-home-hero .pro-hero-copy{position:relative;z-index:2}.pro-home-hero .pro-hero-copy h1{font-size:clamp(46px,5.7vw,92px);line-height:1.06;letter-spacing:-.064em;max-width:15ch}.pro-home-hero .pro-hero-copy h1 em{font-style:normal;display:block}.pro-home-hero .pro-hero-copy p{font-size:clamp(15px,1.3vw,19px);line-height:1.75;max-width:44ch}.pro-home-hero .pro-hero-art{min-width:0;min-height:425px;position:relative;isolation:isolate}.pro-home-hero .pro-hero-art>img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0}.pro-hero-art:has(.pro-abstract-car),.pro-hero-art:has(.pro-technical),.pro-hero-art:has(.pro-corporate-shape){background-position:center;background-size:cover;background-repeat:no-repeat}.pro-abstract-car,.pro-technical,.pro-corporate-shape{position:relative;z-index:1;pointer-events:none}
.pro-hero-actions{display:flex;flex-wrap:wrap;align-items:center;gap:15px}.pro-button,.pro-header-cta,.pro-outline-button{min-height:48px;transition:transform .24s ease,box-shadow .24s ease,opacity .24s ease}.pro-button:hover,.pro-header-cta:hover,.pro-outline-button:hover{transform:translateY(-2px);box-shadow:0 10px 22px rgba(0,0,0,.10)}
.pro-section{padding-top:clamp(76px,7vw,112px)!important;padding-bottom:clamp(76px,7vw,112px)!important}.pro-section-head{gap:16px}.pro-section-head h2{font-size:clamp(32px,3.5vw,53px);max-width:22ch}.pro-section-head .pro-text-link{align-self:end}
.pro-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.pro-card{min-width:0;display:flex;flex-direction:column;transition:transform .28s ease,box-shadow .28s ease;border:1px solid var(--pro-border);background:var(--pro-background);overflow:hidden}.pro-card:hover{transform:translateY(-6px);box-shadow:var(--sf-shadow-hover)}.pro-card-cover{aspect-ratio:1.42;overflow:hidden;background:var(--pro-surface);position:relative}.pro-card-cover .pro-photo{width:100%;height:100%;object-fit:cover;transition:transform .45s ease}.pro-card:hover .pro-photo{transform:scale(1.045)}.pro-card-body{display:flex;flex-direction:column;flex:1}.pro-card-body h3{font-size:clamp(19px,1.6vw,24px);line-height:1.26}.pro-card-body p{font-size:14px}.pro-card-link{margin-top:auto;padding-top:14px;font-size:13px;font-weight:700}
.pro-art{min-height:100%;width:100%;position:relative;overflow:hidden;display:grid;place-items:center;isolation:isolate}.pro-art-ring{width:60%;aspect-ratio:1;border:1px solid currentColor;opacity:.13;border-radius:50%;transform:rotate(20deg);box-shadow:0 0 0 23px #ffffff10,0 0 0 57px #ffffff07}.pro-art-center{font-size:clamp(38px,5vw,80px);position:absolute;font-weight:800;letter-spacing:-.065em;opacity:.65}.pro-art-label{position:absolute;bottom:19px;left:22px;font-size:10px;letter-spacing:.22em;opacity:.7}
.pro-breadcrumb{display:flex;gap:11px;flex-wrap:wrap;font-size:12px;align-items:center}.pro-breadcrumb a{text-decoration:underline;text-underline-offset:4px}.pro-product-hero{align-items:start;gap:clamp(28px,5vw,80px)}.pro-product-media{aspect-ratio:1.12;background:var(--pro-surface);overflow:hidden}.pro-product-media>img,.pro-product-media>.pro-art{width:100%;height:100%;object-fit:contain}.pro-product-summary{padding:clamp(12px,3vw,50px) 0}.pro-product-summary h1{font-size:clamp(36px,4.3vw,72px)}.pro-detail-layout{border-top:1px solid var(--pro-border);padding-top:50px}.pro-specs>div{display:grid;grid-template-columns:minmax(100px,40%) 1fr;gap:10px;align-items:baseline;padding:15px 0;border-bottom:1px solid var(--pro-border)}.pro-specs strong{overflow-wrap:anywhere}.pro-gallery-thumbs button{flex-basis:96px;height:76px}.pro-gallery-lightbox{backdrop-filter:blur(6px)}
.pro-filter-panel{display:flex;align-items:end;flex-wrap:wrap;gap:16px;background:var(--pro-surface);border:1px solid var(--pro-border);padding:20px;margin-bottom:25px;border-radius:var(--pro-radius)}.pro-filter-panel label{display:grid;gap:7px;min-width:180px;flex:1}.pro-filter-panel input,.pro-filter-panel select{width:100%;background:var(--pro-background);border:1px solid var(--pro-border);padding:12px 14px;color:var(--pro-text);border-radius:6px}.pro-filter-count{white-space:nowrap;font-size:13px;padding:12px 0}.pro-card[hidden]{display:none!important}.pro-filter-empty{padding:38px;border:1px dashed var(--pro-border);text-align:center;grid-column:1/-1}
.pro-inquiry input,.pro-inquiry textarea{min-height:46px}.pro-inquiry textarea{min-height:150px}.pro-inquiry-wrap{gap:clamp(32px,7vw,110px)}.pro-inquiry .pro-button{border:0;justify-self:start}.pro-footer-links a{padding-block:5px;display:inline-block}
.pro-end-cta{overflow:hidden}.pro-end-cta h2{font-size:clamp(30px,4vw,64px)}
.pro-rich{line-height:1.85}.pro-rich img{border-radius:var(--pro-radius);margin-block:24px}.pro-rich table{width:100%;display:block;overflow:auto;border-collapse:collapse}.pro-rich td,.pro-rich th{border:1px solid var(--pro-border);padding:10px 14px}.pro-rich a{text-decoration:underline;text-underline-offset:3px}
@media(min-width:1025px){.pro-navigation>.pro-nav-group:hover .pro-nav-children:not([hidden]){visibility:visible}}
@media(max-width:1150px){.pro-head-inner{gap:15px}.pro-navigation{gap:12px}.pro-logo-name{font-size:15px}.pro-header-cta{padding:13px 16px}}
@media(max-width:1024px){.pro-head-inner{min-height:72px;flex-wrap:wrap}.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto}.pro-navigation{display:none;width:100%;flex-basis:100%;flex-direction:column;align-items:stretch;gap:0;order:5;margin:0;padding:8px 0 20px;border-top:1px solid var(--pro-border)}.pro-navigation.is-open{display:flex}.pro-navigation>a,.pro-nav-group>a{padding:14px 8px;font-size:15px}.pro-nav-group{display:flex;flex-wrap:wrap;justify-content:space-between}.pro-nav-children{position:static;flex-basis:100%;max-width:none;width:100%;box-shadow:none;border-radius:0;border-left:2px solid var(--pro-accent);margin:0 0 12px 8px}.pro-nav-disclose{min-width:44px;min-height:44px}.pro-catalog-toggle{margin-left:auto}.pro-mega{top:100%;display:block;padding:20px}.pro-mega>div{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:12px}.pro-home-hero .pro-home-grid{min-height:560px}.pro-home-hero .pro-hero-art{min-height:325px}.pro-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:700px){.pro-wrap{padding-left:20px;padding-right:20px}.pro-utility{display:none}.pro-head-inner{min-height:66px}.pro-logo-name{max-width:140px;font-size:14px}.pro-brand-logo{max-width:125px}.pro-header-cta{display:none}.pro-catalog-toggle{font-size:11px;padding-inline:7px;margin-left:0}.pro-home-hero .pro-home-grid{display:flex;flex-direction:column;gap:36px;padding-top:48px;padding-bottom:48px}.pro-home-hero .pro-hero-copy h1{font-size:clamp(43px,10vw,68px)}.pro-home-hero .pro-hero-copy p{font-size:15px}.pro-home-hero .pro-hero-art{width:100%;min-height:290px}.pro-home-hero .pro-hero-bottom{display:none}.pro-section{padding-top:68px!important;padding-bottom:68px!important}.pro-section-head{align-items:flex-start}.pro-grid{grid-template-columns:1fr;gap:18px}.pro-card-cover{aspect-ratio:1.45}.pro-product-hero,.pro-detail-layout,.pro-info-grid,.pro-inquiry-wrap{display:grid;grid-template-columns:1fr;gap:30px}.pro-product-summary{padding:0}.pro-specs>div{grid-template-columns:1fr 1fr}.pro-mega{top:100%;max-height:65vh;overflow-y:auto}.pro-mega>div{grid-template-columns:1fr}.pro-filter-panel{display:grid;grid-template-columns:1fr;gap:12px}.pro-filter-panel label{min-width:0}.pro-footer-top{display:grid;grid-template-columns:1fr;gap:27px}.pro-footer-bottom{gap:12px;flex-wrap:wrap}}
@media(max-width:410px){.pro-logo-name{max-width:115px}.pro-logo-symbol{font-size:28px}.pro-catalog-toggle{font-size:10px}.pro-mobile-toggle{min-width:42px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
/* Accessible static catalogue pagination for large inventories. */
.pro-pages-nav{display:flex;gap:1rem;justify-content:center;align-items:center;margin:2rem 0;flex-wrap:wrap}
.pro-pages-nav a{padding:.7rem 1.1rem;border:1px solid currentColor;border-radius:.4rem;text-decoration:none}
.pro-pages-nav a:focus-visible{outline:3px solid var(--accent,#2563eb);outline-offset:3px}
.pro-pages-nav span{font-size:.875rem}
/* P6 international sites: small, keyboard-operable language switch. */
.sf-language-switch{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;margin:8px 12px;padding:4px;border:1px solid rgba(120,130,145,.22);border-radius:10px;background:rgba(255,255,255,.08)}
.sf-language-switch a{font:inherit;font-size:12px;line-height:1.5;padding:4px 7px;border-radius:6px;color:inherit;text-decoration:none}
.sf-language-switch a[aria-current="page"]{font-weight:700;text-decoration:underline;text-underline-offset:3px}
.sf-language-switch a:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
@media(max-width:640px){.sf-language-switch{margin:6px 0;width:auto}}
/* V8.21.3 — Four-theme unified mobile off-canvas navigation & optional tab bar.
Desktop remains unchanged. Phone / tablet use one accessible drawer. */
.sf-mobile-tabs{display:none!important}
.sf-drawer-header,.sf-mobile-drawer-backdrop,.sf-drawer-extra{display:none}
@media(max-width:1024px){
.pro-head-inner{flex-wrap:nowrap!important;gap:12px!important}
.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto!important;flex:0 0 44px;min-width:44px;min-height:44px;position:relative;z-index:3}
.pro-catalog-toggle{display:none!important}
.pro-navigation,.pro-navigation.is-open{display:flex!important;position:fixed!important;inset:0 0 0 auto!important;width:min(88vw,360px)!important;height:100dvh!important;height:100vh;max-height:100dvh!important;flex:unset!important;flex-direction:column!important;align-items:stretch!important;order:unset!important;margin:0!important;padding:0 0 calc(20px + env(safe-area-inset-bottom,0px))!important;overflow-x:hidden;overflow-y:auto!important;background:var(--pro-background,#fff)!important;color:var(--pro-text,#19283c)!important;border:0!important;border-left:1px solid var(--pro-border,#ddd)!important;box-shadow:-15px 0 45px #0002!important;z-index:1201!important;transform:translateX(105%);visibility:hidden;pointer-events:none;transition:transform .24s ease,visibility .24s ease!important;overscroll-behavior:contain}
.pro-navigation.is-open{transform:translateX(0);visibility:visible;pointer-events:auto}
.sf-drawer-header{display:flex!important;align-items:center;justify-content:space-between;gap:12px;padding:20px 18px;border-bottom:1px solid var(--pro-border,#dde3e9);font-size:17px;font-weight:750;position:sticky;top:0;background:var(--pro-background,#fff);z-index:2}
.sf-drawer-close{min-width:44px;min-height:44px;border:1px solid var(--pro-border,#dde3e9);color:inherit;background:transparent;border-radius:10px;font-size:24px;cursor:pointer}
.sf-mobile-drawer-backdrop{display:block;position:fixed;inset:0;z-index:1200;background:#0b1b31a3;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .24s ease,visibility .24s ease}
.sf-mobile-drawer-backdrop.is-open{opacity:1;visibility:visible;pointer-events:auto}
body.sf-drawer-open{overflow:hidden!important}
.pro-navigation>a,.pro-navigation .pro-nav-group>a,.pro-navigation .sf-drawer-extra>a{display:flex;align-items:center;min-height:50px;padding:13px 20px!important;font-size:15px!important;color:inherit!important;white-space:normal;border-bottom:1px solid var(--pro-border,#dde3e9)!important;text-decoration:none!important}
.pro-navigation .pro-nav-group{display:flex;position:relative;flex-wrap:wrap!important;width:100%;gap:0;align-items:center;justify-content:space-between}
.pro-navigation .pro-nav-group>a{flex:1;min-width:0}
.pro-navigation .pro-nav-disclose{margin-right:12px;min-width:44px;min-height:44px;border-radius:9px;background:var(--pro-surface,#f4f6f8)}
.pro-navigation .pro-nav-children{position:static!important;width:100%!important;max-width:100%!important;box-shadow:none!important;border-left:3px solid var(--pro-accent,#e77730);border-radius:0!important;margin:0 0 8px 14px!important;flex-basis:calc(100% - 14px)!important;background:var(--pro-surface,#f6f8fa)}
.pro-navigation .pro-nav-children a{padding:12px 16px!important;font-size:14px!important;color:inherit!important;min-height:44px}
.sf-drawer-extra{display:block;padding:12px 0 5px}.sf-drawer-extra strong{display:block;font-size:12px;letter-spacing:.06em;padding:12px 20px;opacity:.65}.sf-drawer-extra a{font-size:14px!important}
.sf-drawer-close:focus-visible,.pro-mobile-toggle:focus-visible,.pro-nav-disclose:focus-visible,.sf-mobile-tab:focus-visible{outline:3px solid var(--pro-accent,#ff8200);outline-offset:2px}
}
@media(max-width:767px){
.pro-header-cta{display:none!important}
.pro-head-inner .pro-logo{min-width:0;max-width:calc(100% - 65px)}
.pro-logo-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pro-home-hero .pro-home-grid{min-height:0!important}
.pro-home-hero .pro-hero-art{min-height:230px}
.pro-card,.pro-card-cover,.pro-product-media{max-width:100%;min-width:0}
.pro-rich img,.pro-content img,.sf-canvas img{max-width:100%;height:auto}
.pro-rich table{max-width:100%;overflow:auto}
.pro-grid,.pro-footer-top{min-width:0}
.sf-mobile-tabs{display:grid!important;position:fixed;z-index:1100;bottom:0;left:0;right:0;grid-template-columns:repeat(var(--sf-mobile-tab-count,4),minmax(0,1fr));align-items:stretch;padding:5px 6px calc(5px + env(safe-area-inset-bottom,0px));min-height:63px;background:var(--pro-background,#fff);color:var(--pro-text,#263446);border-top:1px solid var(--pro-border,#dfe4eb);box-shadow:0 -6px 24px #13223914}
.sf-mobile-tab{appearance:none;border:0!important;background:transparent!important;color:inherit!important;text-decoration:none!important;padding:6px 2px;min-height:50px;min-width:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:2px;border-radius:9px;cursor:pointer;font-family:inherit}
.sf-tab-icon{font-size:23px;line-height:1.15;font-weight:500}
.sf-tab-label{font-size:11px;font-weight:600;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sf-mobile-tab[aria-current=page]{color:var(--pro-accent,#e47730)!important;background:var(--pro-surface,#f5f6f8)!important}
body:has(> .sf-mobile-tabs){padding-bottom:calc(76px + env(safe-area-inset-bottom,0px))!important}
}
@media(min-width:768px){.sf-mobile-tabs{display:none!important}}
@media(prefers-reduced-motion:reduce){.pro-navigation,.sf-mobile-drawer-backdrop{transition:none!important}}
@@ -0,0 +1 @@
body .pro-utility[hidden]{display:none!important}@media(max-width:1023px){.sf-canvas .sf-section{padding-block:64px}}@media(max-width:767px){.sf-canvas .sf-section{padding-block:40px}.pro-wrap,.sf-container{padding-left:20px;padding-right:20px}body .pro-utility{display:none!important}}
@@ -0,0 +1,27 @@
/* SiteForge trusted global shell. Site themes remain visually independent. */
:root{--sf-shell:#12243d;--sf-shell-accent:#dcae71;--sf-shell-soft:#edf2f8;--sf-shell-fg:#173049}
html{scroll-behavior:smooth}body{overflow-x:hidden}body>.header,body>.utility,body>.top,body>header,body>footer{display:none!important}
.sf-theme-industrial{--sf-shell:#114e79;--sf-shell-accent:#92d5ed;--sf-shell-soft:#ebf6fa;--sf-shell-fg:#0c4267}
.sf-theme-corporate{--sf-shell:#232d62;--sf-shell-accent:#b8bdff;--sf-shell-soft:#f3f3fa;--sf-shell-fg:#333468}
.sf-theme-minimal{--sf-shell:#1e1e1c;--sf-shell-accent:#ded6c5;--sf-shell-soft:#f6f4ef;--sf-shell-fg:#252520}
.sf-site-head{position:relative;z-index:25;background:var(--sf-shell);color:#fff;font-family:system-ui,-apple-system,sans-serif}
.sf-head-inner{max-width:1240px;margin:0 auto;min-height:78px;padding:0 28px;display:flex;align-items:center;gap:28px}
.sf-logo{display:flex;align-items:center;gap:11px;font-size:18px;text-decoration:none!important;color:#fff!important;white-space:nowrap}
.sf-logo-mark{width:36px;height:36px;display:grid;place-items:center;background:var(--sf-shell-accent);color:var(--sf-shell);font-size:22px;font-weight:900;border-radius:9px}
.sf-primary-links{display:flex;gap:23px;margin-left:auto;align-items:center}
.sf-primary-links a,.sf-mega a{color:#fff!important;text-decoration:none!important;font-size:13px;font-weight:650;white-space:nowrap}
.sf-primary-links a:hover{color:var(--sf-shell-accent)!important}
.sf-head-cta{font-size:13px;font-weight:750;text-decoration:none;background:var(--sf-shell-accent);color:var(--sf-shell)!important;padding:13px 18px;border-radius:4px;white-space:nowrap}
.sf-menu-toggle{display:none;margin-left:auto;color:white;border:1px solid #ffffff55;background:none;border-radius:4px;padding:8px 12px;font-size:19px;cursor:pointer}
.sf-mega{display:flex;gap:18px;max-width:1240px;margin:auto;padding:0 28px 9px;overflow-x:auto}
.sf-mega a{display:block;min-width:120px;border-top:1px solid #ffffff2e;padding:12px 8px}.sf-mega small{display:block;color:#ffffff99;max-width:160px;overflow:hidden;text-overflow:ellipsis;font-size:10px;margin-top:6px}
.sf-site-foot{background:var(--sf-shell);color:white!important;font:14px/1.7 system-ui,-apple-system,sans-serif;padding:72px 28px 26px}
.sf-foot-grid{max-width:1200px;margin:auto;display:grid;grid-template-columns:2fr 1fr 1fr;gap:70px}.sf-foot-grid h2{font-size:25px;color:white;margin:0 0 17px}.sf-foot-grid h4{font-size:13px;color:var(--sf-shell-accent);letter-spacing:.07em;margin:0 0 17px}.sf-foot-grid p{color:#ffffffae;max-width:380px}.sf-foot-grid a{display:block;width:fit-content;color:#fff!important;text-decoration:none;margin-bottom:8px}.sf-foot-lower{border-top:1px solid #ffffff33;max-width:1200px;margin:45px auto 0;padding-top:20px;display:flex;justify-content:space-between;color:#ffffff9e;font-size:12px}
.sf-filters{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin:0 0 30px}.sf-filters input,.sf-filters select{padding:11px 13px;border-radius:6px;border:1px solid #cfd9e4;background:#fff;color:#263a51;max-width:280px}.sf-filter-empty{padding:22px;color:#778598}
.sf-search-page{min-height:76vh;max-width:1060px;padding:90px 24px;margin:auto;font-family:system-ui,sans-serif;color:var(--sf-shell-fg)}.sf-search-page h1{font-size:clamp(35px,5vw,62px);margin:20px 0}.sf-search-page>label{display:block;margin:18px 0 8px}.sf-search-page input{display:block;width:100%;padding:17px;font-size:18px;border:1px solid #b7c6d7;border-radius:8px}.sf-search-page #sf-search-count{margin:22px 0;color:#607589}.sf-search-result{padding:22px 0;border-bottom:1px solid #dae1ea}.sf-search-result a{color:var(--sf-shell-fg);font-size:21px;font-weight:750;text-decoration:none}.sf-search-result small{display:block;color:#7992a7}.sf-search-result p{margin:7px 0;color:#657487}
@media(max-width:1050px){.sf-head-inner{gap:13px}.sf-primary-links{gap:12px}.sf-primary-links a{font-size:12px}.sf-head-cta{padding:10px}}
@media(max-width:850px){.sf-head-inner{min-height:68px}.sf-menu-toggle{display:block}.sf-head-cta{display:none}.sf-primary-links{display:none;position:absolute;top:68px;left:0;right:0;margin:0;background:var(--sf-shell);padding:20px 28px;flex-direction:column;align-items:stretch;box-shadow:0 20px 24px #0002}.sf-primary-links.is-open{display:flex}.sf-mega{display:none}.sf-foot-grid{grid-template-columns:1fr 1fr;gap:35px}}
@media(max-width:560px){.sf-foot-grid{grid-template-columns:1fr}.sf-foot-lower{display:block}.sf-site-foot{padding-top:48px}.sf-head-inner{padding:0 16px}}
.sf-page-intro{padding:80px 28px;background:var(--sf-shell-soft);color:var(--sf-shell-fg);font-family:system-ui,sans-serif}.sf-page-intro>div{max-width:1200px;margin:auto}.sf-page-intro p{font-size:11px;font-weight:850;letter-spacing:.19em;text-transform:uppercase;color:var(--sf-shell-fg);opacity:.58}.sf-page-intro h1{font-size:clamp(38px,5vw,67px);letter-spacing:-.04em;line-height:1.08;margin:13px 0}.sf-page-intro span{font-size:16px;opacity:.72}
.sf-item[hidden]{display:none!important}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
:root{--pro-text:#1c2a3b;--pro-muted:#718094;--pro-border:#e2e8ef;--pro-background:#fff;--pro-surface:#f2f5f9;--pro-accent:#bb8d50;--pro-hero:#0c1b2e;--pro-radius:5px;--pro-headings:'Arial Narrow',Arial,sans-serif;--pro-wide:1320px}
.pro-header{background:#0e1e30;color:#fff;border-color:#26374b}.pro-logo-symbol{color:#e0ae6c}.pro-logo-symbol span{color:#fff}.pro-logo-name small{color:#b3bfcf}.pro-header-cta{background:#c59b63;color:#0f1a26!important}.pro-navigation a:hover{color:#dfb97d}.pro-mega{background:#13263b;border-color:#2a4058;color:#c2d0de}.pro-mega>span{color:#d5aa72}.pro-mobile-toggle{color:#fff}
.pro-auto-hero{background:radial-gradient(circle at 68% 50%,#243b54 0%,#112539 34%,#081728 100%);color:#fff}.pro-auto-hero .pro-eyebrow{color:#d5a366}.pro-auto-hero .pro-hero-copy h1 em{color:#d2a16b}.pro-auto-hero .pro-hero-copy p{color:#b9c7d6}.pro-auto-hero .pro-button{color:#0d1b2d!important;background:#d8ad72}.pro-link-light{color:#d8e4ec;font-size:13px;font-weight:750}.pro-auto-hero .pro-hero-art{background:linear-gradient(160deg,#243e59,#0f2033);border:1px solid #ffffff25;box-shadow:0 50px 80px #0005;border-radius:3px;overflow:visible}.pro-abstract-car{position:absolute;inset:0;overflow:hidden;background:radial-gradient(ellipse at 50% 80%,#45648599,transparent 63%)}.pro-abstract-car:before{content:'';position:absolute;left:10%;right:9%;bottom:29%;height:29%;border:3px solid #d3dfefad;border-radius:53% 49% 10% 12%;transform:skewX(-9deg);box-shadow:0 23px 15px #05121f88,0 0 0 3px #182c41 inset}.pro-abstract-car:after{content:'';position:absolute;left:23%;right:23%;bottom:56%;height:17%;border:3px solid #d3dfef72;border-radius:85% 85% 0 0;transform:skewX(-20deg)}.pro-abstract-car>span{position:absolute;top:26px;right:22px;font-size:10px;letter-spacing:.2em;color:#ccd9e5}.auto-wheel{position:absolute;bottom:18%;width:16%;aspect-ratio:1;border-radius:50%;border:11px solid #6f8499;background:#111e2c;box-shadow:0 0 0 4px #111a25}.auto-wheel.a{left:19%}.auto-wheel.b{right:17%}.pro-hero-art>b{position:absolute;bottom:-31px;right:0;font-size:11px;letter-spacing:.2em;color:#bdcde0}.pro-featured{background:#f4f6f8}.pro-featured .pro-card{border:0;box-shadow:0 7px 30px #0f2e4710}.pro-featured .pro-card-cover{background:#d9e2eb}.pro-featured .pro-art{background:linear-gradient(140deg,#dce5ed,#a9baca)}.pro-featured .pro-art-ring{border-color:#eef3f8bb}.pro-statement{background:#12243b;color:#fff}.pro-statement .pro-eyebrow{color:#d3a975}.pro-statement p{color:#aebfd3}.pro-statement h2{font-size:clamp(42px,5vw,70px);max-width:700px}.pro-statement .pro-text-link{color:#dfba83}.pro-brand-section{background:white}.pro-brand-pills a{min-width:150px;text-align:center;background:#f5f7fa}.pro-inner-hero{background:linear-gradient(120deg,#101e31,#273b51);color:#fff}.pro-inner-hero .pro-eyebrow{color:#d0a46b}.pro-inner-hero p,.pro-inner-hero .pro-breadcrumb{color:#c9d1df}.pro-inner-hero .pro-breadcrumb strong{color:#fff}.pro-article-hero{background:#14243a;color:#fff}.pro-article-hero p{color:#bed0df}.pro-footer{background:#071421}.pro-footer-bottom{color:#98aebf}.pro-end-cta{background:#1a324b}.pro-end-cta a{background:#d4a56d;color:#132339!important}.pro-404 h1{color:#b8905c}
/* SiteForge V5 commercial signature */
.pro-auto-hero .pro-hero-art{background-image:url("./visual/hero.svg")!important;background-position:center;background-size:cover;}
.pro-auto-hero{background:linear-gradient(117deg,#081729 0%,#102a43 100%)}.pro-auto-hero .pro-hero-art{border:1px solid #52667a55;border-radius:3px;box-shadow:0 30px 90px #0004;transform:translateX(0)}.pro-auto-hero .pro-hero-art .pro-abstract-car{opacity:0}.pro-hero-bottom{border-top:1px solid #ffffff1c}.pro-brand-pills a{transition:background .2s}.pro-brand-pills a:hover{background:#d6a974;color:#10233a}.pro-card{border-color:#e0e6ee;border-radius:5px}.pro-card-cover{background:linear-gradient(135deg,#edf1f6,#dbe4eb)}.pro-art{background:linear-gradient(130deg,#e0e8ee,#c4d0dc);color:#37536b}.pro-featured{background:#f8fafc}.pro-statement{background:#0d2134;color:white}.pro-statement p{color:#c6d0dd}.pro-statement .pro-text-link{color:#f0b870}.pro-section-head h2{font-weight:800}.pro-footer{background:#08182a;color:#e5e9ed}
@@ -0,0 +1,72 @@
'use strict';(()=>{
const menu=document.querySelector('[data-pro-menu]'); const nav=document.querySelector('#pro-navigation');
if(menu&&nav){menu.addEventListener('click',()=>{const open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));nav.classList.toggle('is-open',open);menu.setAttribute('aria-label',open?'关闭导航':'打开导航');});}
// Drawer navigation. The same markup is used across the four professional themes.
const mobileNav=document.querySelector('#pro-navigation');
let drawerBackdrop=null,drawerClose=null,returnFocus=null;
if(menu&&mobileNav){
const originalParent=mobileNav.parentNode,originalNext=mobileNav.nextSibling;
const heading=document.createElement('div');heading.className='sf-drawer-header';
const title=document.createElement('strong');title.textContent='网站导航';
drawerClose=document.createElement('button');drawerClose.type='button';drawerClose.className='sf-drawer-close';drawerClose.textContent='×';drawerClose.setAttribute('aria-label','关闭网站导航');
heading.append(title,drawerClose);mobileNav.prepend(heading);
drawerBackdrop=document.createElement('div');drawerBackdrop.className='sf-mobile-drawer-backdrop';drawerBackdrop.hidden=false;document.body.append(drawerBackdrop);
function setDrawer(open,focusBack=true){
const isMobile=window.matchMedia('(max-width:1024px)').matches;
open=Boolean(open&&isMobile);
if(open){returnFocus=document.activeElement;document.body.append(mobileNav);}
else if(mobileNav.parentNode!==originalParent){if(originalNext&&originalNext.parentNode===originalParent)originalParent.insertBefore(mobileNav,originalNext);else originalParent.append(mobileNav);}
mobileNav.classList.toggle('is-open',open);drawerBackdrop.classList.toggle('is-open',open);
document.body.classList.toggle('sf-drawer-open',open);
menu.setAttribute('aria-expanded',String(open));menu.setAttribute('aria-label',open?'关闭导航':'打开导航');
mobileNav.setAttribute('aria-hidden',String(!open&&isMobile));
if('inert' in mobileNav)mobileNav.inert=!open&&isMobile;
if(open)drawerClose.focus();
else if(focusBack&&returnFocus instanceof HTMLElement){returnFocus.focus();returnFocus=null;}
}
// Replace the old inline-expansion listener: use capture to stop the old listener.
menu.addEventListener('click',e=>{if(!window.matchMedia('(max-width:1024px)').matches)return;e.stopImmediatePropagation();setDrawer(!mobileNav.classList.contains('is-open'));},true);
drawerClose.addEventListener('click',()=>setDrawer(false));
drawerBackdrop.addEventListener('click',()=>setDrawer(false));
document.querySelectorAll('[data-mobile-open-menu]').forEach(b=>b.addEventListener('click',()=>setDrawer(true)));
mobileNav.addEventListener('click',e=>{if(e.target.closest('a'))setDrawer(false,false)});
document.addEventListener('keydown',e=>{
if(e.key==='Escape'&&mobileNav.classList.contains('is-open')){e.preventDefault();setDrawer(false);}
if(e.key!=='Tab'||!mobileNav.classList.contains('is-open'))return;
const els=[...mobileNav.querySelectorAll('button:not([disabled]),a[href]')].filter(el=>el.getClientRects().length&&!el.closest('[hidden]'));
if(!els.length)return;const first=els[0],last=els[els.length-1];
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
});
window.matchMedia('(max-width:1024px)').addEventListener('change',()=>setDrawer(false,false));
if('inert' in mobileNav&&window.matchMedia('(max-width:1024px)').matches)mobileNav.inert=true;
// Do not strand catalogue links when the old mobile mega-button is hidden.
const megaLinks=document.querySelectorAll('[data-pro-mega] a');
if(megaLinks.length){const wrap=document.createElement('div');wrap.className='sf-drawer-extra';const h=document.createElement('strong');h.textContent='更多分类与系列';wrap.append(h);
[...megaLinks].slice(0,20).forEach(a=>{const clone=a.cloneNode(true);wrap.append(clone);});mobileNav.append(wrap);}
}
const tabbar=document.querySelector('[data-mobile-tabs]');
if(tabbar){tabbar.style.setProperty('--sf-mobile-tab-count',String(tabbar.children.length));}
const megaButton=document.querySelector('[data-pro-catalog]'), mega=document.querySelector('[data-pro-mega]');
if(megaButton&&mega){function setMega(open){mega.hidden=!open;mega.classList.toggle('is-open',open);megaButton.setAttribute('aria-expanded',String(open));}
megaButton.addEventListener('click',()=>setMega(mega.hidden));
document.addEventListener('keydown',e=>{if(e.key==='Escape')setMega(false);});
document.addEventListener('click',e=>{if(!mega.hidden&&!mega.contains(e.target)&&!megaButton.contains(e.target))setMega(false);});
}
document.querySelectorAll('.pro-nav-disclose').forEach(button=>{button.addEventListener('click',()=>{let panel=button.parentElement.querySelector('.pro-nav-children');let visible=button.getAttribute('aria-expanded')==='true';button.setAttribute('aria-expanded',String(!visible));panel.hidden=visible;});});
document.querySelectorAll('.pro-gallery-thumbs').forEach(container=>{const main=container.parentElement.querySelector('.pro-product-media img');const buttons=[...container.querySelectorAll('[data-gallery-image]')];
buttons.forEach(b=>{b.addEventListener('click',()=>{buttons.forEach(x=>x.setAttribute('aria-pressed',String(x===b)));if(main){main.src=b.dataset.galleryImage;main.alt=b.querySelector('img')?.alt||'';}});});
if(!main)return;main.tabIndex=0;main.style.cursor='zoom-in';const open=()=>{const pane=document.createElement('div');pane.className='pro-gallery-lightbox';pane.setAttribute('role','dialog');pane.setAttribute('aria-label','产品大图预览');let img=document.createElement('img');img.src=main.src;img.alt=main.alt;const close=document.createElement('button');close.type='button';close.textContent='关闭 ×';pane.append(img,close);document.body.append(pane);const done=()=>{pane.remove();document.removeEventListener('keydown',esc);main.focus();};const esc=e=>{if(e.key==='Escape')done();};document.addEventListener('keydown',esc);close.addEventListener('click',done);pane.addEventListener('click',e=>{if(e.target===pane)done();});close.focus();};main.addEventListener('click',open);main.addEventListener('keydown',e=>{if(e.key==='Enter')open();});
});
document.querySelectorAll('.pro-filter-scope').forEach(scope=>{
const panel=scope.previousElementSibling;if(!panel||!panel.classList.contains('pro-filter-panel'))return;
const cards=Array.from(scope.querySelectorAll('.pro-card'));
const blank=document.createElement('div');blank.className='pro-filter-empty';blank.hidden=true;blank.textContent='没有找到符合条件的内容,请调整关键词或筛选条件。';scope.append(blank);
const q=panel.querySelector('[data-pro-filter-search]'),b=panel.querySelector('[data-pro-filter-brand]'),c=panel.querySelector('[data-pro-filter-category]'),count=panel.querySelector('[data-pro-filter-count]');
function options(sel,key){const vals=[...new Set(cards.map(x=>x.dataset[key]||'').filter(Boolean))].sort();vals.forEach(x=>sel.add(new Option(x,x)));if(!vals.length)sel.closest('label').hidden=true;}
options(b,'brand');options(c,'category');
function run(){const str=q.value.trim().toLocaleLowerCase();let visible=0;
cards.forEach(el=>{const hit=el.textContent.toLocaleLowerCase().includes(str)&&(!b.value||el.dataset.brand===b.value)&&(!c.value||el.dataset.category===c.value);el.hidden=!hit;visible+=Number(hit);});count.textContent=`共 ${visible} 条`;blank.hidden=visible!==0;}
[q,b,c].forEach(x=>x.addEventListener(x===q?'input':'change',run));run();
});
})();
@@ -0,0 +1 @@
[]
@@ -0,0 +1,31 @@
'use strict';
(()=>{
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const menu=$('.sf-menu-toggle');if(menu){menu.addEventListener('click',()=>{let open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));$('#sf-primary-links')?.classList.toggle('is-open',open);});}
// Filter actual static content cards, no API is needed at runtime.
$$('.sf-collection').forEach(grid=>{
const cards=$$('.sf-item',grid);
if(cards.length<2)return;
const toolbar=document.createElement('div');toolbar.className='sf-filters';
const search=document.createElement('input');search.type='search';search.placeholder='筛选当前列表';search.setAttribute('aria-label','筛选当前列表');
const status=document.createElement('span');status.setAttribute('aria-live','polite');status.style.fontSize='12px';
const brand=document.createElement('select');brand.setAttribute('aria-label','按品牌筛选');
const category=document.createElement('select');category.setAttribute('aria-label','按分类筛选');
function fill(el,field,all){el.append(new Option(all,''));const vals=[...new Set(cards.map(x=>x.dataset[field]||'').filter(Boolean))].sort();vals.forEach(v=>el.append(new Option(v,v)));if(!vals.length)el.hidden=true;}
fill(brand,'brand','全部品牌');fill(category,'category','全部分类');
toolbar.append(search,brand,category,status);grid.parentNode.insertBefore(toolbar,grid);
function apply(){let val=search.value.trim().toLocaleLowerCase();let visible=0;cards.forEach(card=>{let hit=card.textContent.toLocaleLowerCase().includes(val)&&(!brand.value||card.dataset.brand===brand.value)&&(!category.value||card.dataset.category===category.value);card.hidden=!hit;if(hit)visible++;});status.textContent=`共 ${visible} 项`;}
search.addEventListener('input',apply);brand.addEventListener('change',apply);category.addEventListener('change',apply);apply();
});
const input=$('#sf-search'),root=$('#sf-search-results'),count=$('#sf-search-count');
if(input&&root){
let data=[];const params=new URLSearchParams(location.search);input.value=params.get('q')||'';
let debounce=null;
const render=()=>{let q=input.value.trim().toLocaleLowerCase();let found=q?data.filter(d=>`${d.title} ${d.summary} ${d.brand} ${d.type}`.toLocaleLowerCase().includes(q)).slice(0,100):data.slice(0,30);
root.replaceChildren();count.textContent=`${found.length} 条结果${!q?'(显示前30条)':''}`;
found.forEach(d=>{let article=document.createElement('article'),a=document.createElement('a'),small=document.createElement('small'),p=document.createElement('p');article.className='sf-search-result';a.textContent=d.title;a.href=d.url.replace(/^\//,'');small.textContent=d.type;p.textContent=d.summary;article.append(a,small,p);root.append(article);});
};const base=document.querySelector('base')?.href||location.href;
fetch(new URL('search-index.json',base)).then(r=>{if(!r.ok)throw Error('index unavailable');return r.json();}).then(v=>{data=v;render()}).catch(()=>{count.textContent='搜索索引加载失败,请检查站点文件部署路径';});
input.addEventListener('input',()=>{clearTimeout(debounce);debounce=setTimeout(render,100)});
}
})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 740"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#162c42"/><stop offset="1" stop-color="#081a2b"/></linearGradient><linearGradient id="s" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#acc8d2"/><stop offset=".48" stop-color="#516b82"/><stop offset="1" stop-color="#182f46"/></linearGradient><radialGradient id="lit"><stop stop-color="#e8ad68" stop-opacity=".3"/><stop offset="1" stop-color="#e8ad68" stop-opacity="0"/></radialGradient></defs><rect width="1000" height="740" fill="url(#bg)"/><circle cx="600" cy="290" r="420" fill="url(#lit)"/><g fill="none" stroke="#d8e6f0" stroke-opacity=".09"><path d="M0 580h1000M0 612h1000M0 644h1000M0 676h1000"/><path d="M150 740 500 490 860 740M0 740 470 490 1000 740"/></g><g transform="translate(45 80)"><ellipse cx="500" cy="520" rx="425" ry="45" fill="#000" opacity=".4"/><path d="M105 393q59-48 133-65l95-118q29-37 79-42l205-3q52-1 91 45l91 109q70 13 94 45l30 75-18 44H99L74 446Z" fill="url(#s)" stroke="#c4d5dc" stroke-width="5"/><path d="m285 323 98-109q19-20 52-20h168q35 0 66 35l70 93Z" fill="#132b40" stroke="#95b4c4" stroke-width="4"/><path d="M506 198v126" stroke="#a7c1cc" stroke-width="5"/><path d="M104 394q104-26 187-17m424-6q98 0 162 21" stroke="#d4e3e7" stroke-opacity=".7" stroke-width="4"/><path d="M96 419h113M806 413h90" stroke="#fff5d9" stroke-width="11" stroke-linecap="round"/><path d="M316 387h322" stroke="#dce6e9" stroke-opacity=".2" stroke-width="2"/><circle cx="253" cy="478" r="78" fill="#0c1825" stroke="#b6c0c5" stroke-width="9"/><circle cx="253" cy="478" r="39" fill="#667c89" stroke="#d1e0e6" stroke-width="8"/><circle cx="735" cy="478" r="78" fill="#0c1825" stroke="#b6c0c5" stroke-width="9"/><circle cx="735" cy="478" r="39" fill="#667c89" stroke="#d1e0e6" stroke-width="8"/></g><text x="50" y="62" fill="#d7a76d" font-size="14" font-family="Arial" letter-spacing="7">CONCEPT / MOBILITY</text><path d="M52 80h220" stroke="#b98756" opacity=".55"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,24 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base href="./"><title>汽车出口</title><meta name="description" content="专业车辆采购与全球交付"><meta property="og:type" content="website"><meta property="og:title" content="汽车出口"><meta property="og:description" content="专业车辆采购与全球交付"><meta property="og:url" content="https://example.com/"><meta name="theme-color" content="#0e1c2c"><meta name="robots" content="index,follow"><link rel="canonical" href="https://example.com/"><link rel="stylesheet" href="assets/theme.css"><link rel="stylesheet" href="assets/blocks.css"><link rel="stylesheet" href="assets/factory.css"><link rel="stylesheet" href="assets/pro-base.css"><link rel="stylesheet" href="assets/pro.css"><link rel="stylesheet" href="assets/commercial.css"><link rel="stylesheet" href="assets/custom.css"></head><body class="sf-theme-automotive pro-automotive"><a class="pro-skip" href="#main-content">跳转至正文</a>
<div class="pro-utility" hidden><div class="pro-wrap"><span></span><span>专业车辆采购与全球交付</span></div></div>
<header class="sf-site-head pro-header"><div class="pro-wrap pro-head-inner"><a class="pro-logo" href="index.html" aria-label="汽车出口 首页"><span class="pro-logo-symbol">S<span>.</span></span><span class="pro-logo-name">汽车出口<small> AUTOMOTIVE</small></span></a><button class="pro-mobile-toggle" type="button" data-pro-menu aria-controls="pro-navigation" aria-expanded="false" aria-label="打开导航">☰</button><nav id="pro-navigation" class="pro-navigation" aria-label="主导航"><a href="index.html">首页</a><div class="pro-nav-group"><a href="listing.html">产品系列</a><button type="button" class="pro-nav-disclose" aria-expanded="false" aria-label="展开产品系列子菜单">▾</button><div class="pro-nav-children" hidden><a href="/classic/">经典系列</a><a href="/ev/">新能源</a></div></div><a href="/about/">品牌介绍</a><a href="article.html">新闻资讯</a><a href="article.html">联系我们</a><a class="pro-search-link" href="/search/" aria-label="站内搜索">⌕</a></nav><button class="pro-catalog-toggle" type="button" data-pro-catalog aria-controls="pro-mega-content" aria-expanded="false">品牌与系列 ▾</button><a href="article.html" class="pro-header-cta">联系我们 <span>↗</span></a></div><div class="pro-mega pro-wrap" data-pro-mega id="pro-mega-content" hidden><span>探索品牌与系列</span><div><a href="/brands/example/">EXAMPLE ↗</a><a href="/categories/term-36a4ff4ebeb5/">精选产品</a></div></div></header>
<section class="pro-home-hero pro-auto-hero"><div class="pro-wrap pro-home-grid"><div class="pro-hero-copy"><span class="pro-eyebrow">01 / THE WORLD IN MOTION</span><h1>汽车出口<em> MOVE BEYOND.</em></h1><p>专业车辆采购与全球交付</p><div class="pro-hero-actions"><a class="pro-button" href="listing.html">探索车型 <span>↗</span></a><a class="pro-link-light" href="/about/">了解企业 →</a></div></div><div class="pro-hero-art"><div class="pro-abstract-car"><span>THE NEXT CHAPTER</span><div class="auto-wheel a"></div><div class="auto-wheel b"></div></div><b>DRIVE / 01</b></div></div><div class="pro-wrap pro-hero-bottom"><span>DESIGN / TRUST / DELIVERY</span><span>SCROLL TO DISCOVER ↓</span></div></section>
<section class="pro-section pro-featured"><div class="pro-wrap"><div class="pro-section-head"><div><span class="pro-eyebrow">02 / COLLECTION</span><h2>探索精选车型</h2></div><a class="pro-text-link" href="listing.html">浏览全部 <span>↗</span></a></div><div class="pro-grid pro-grid-vehicle">
<a class="pro-card pro-card-vehicle" href="product.html" data-category="精选产品" data-brand="EXAMPLE">
<div class="pro-card-cover">
<div class="pro-art pro-art-vehicle"><span class="pro-art-ring"></span><span class="pro-art-center">标准</span><span class="pro-art-label">VEHICLE</span></div>
</div>
<div class="pro-card-body"><div class="pro-card-meta"><span>EXAMPLE</span><span>01</span></div>
<h3>标准系列产品</h3><p>面向全球客户的精选示范产品</p><span class="pro-card-link">查看详情 <span aria-hidden="true">↗</span></span></div>
</a>
</div></div></section>
<section class="pro-section pro-statement"><div class="pro-wrap pro-statement-layout"><span class="pro-eyebrow">THE COMPANY</span><div><h2>把产品带向更广阔的市场。</h2><p>专业车辆采购与全球交付</p><a class="pro-text-link" href="/about/">认识我们 ↗</a></div></div></section>
<section class="pro-section pro-brand-section"><div class="pro-wrap"><div class="pro-section-head"><div><span class="pro-eyebrow">03 / BRANDS</span><h2>品牌与合作伙伴</h2></div><a class="pro-text-link" href="/brands/">浏览全部 <span>↗</span></a></div><div class="pro-brand-pills"><a href="/brands/example/">EXAMPLE ↗</a></div></div></section>
<main id="main-content" class="sf-canvas pro-custom-content" aria-hidden="true"></main>
<section class="pro-end-cta"><div class="pro-wrap"><div><span class="pro-eyebrow">LET'S CONNECT</span><h2>从这里开始下一步。</h2><p>专业车辆采购与全球交付</p></div><a href="article.html">联系我们 <span>↗</span></a></div></section>
<footer class="sf-site-foot pro-footer"><div class="pro-wrap"><div class="pro-footer-top"><div><span class="pro-eyebrow">DRIVEN BY PURPOSE</span><h2>汽车出口</h2><p>专业车辆采购与全球交付</p></div><div class="pro-footer-links"><ul><li><a href="index.html">首页</a></li><li><a href="listing.html">产品系列</a></li><li><a href="/about/">品牌介绍</a></li><li><a href="article.html">新闻资讯</a></li><li><a href="article.html">联系我们</a></li></ul></div><div><span class="pro-eyebrow">LET'S TALK</span><p>请在后台配置企业联系方式</p></div></div><div class="pro-footer-bottom"><span>© 2026 汽车出口</span><span>SITEFORGE / STATIC SITE</span></div></div></footer>
<nav class="sf-mobile-tabs" style="--sf-mobile-tab-count:4" aria-label="手机底部快捷导航" data-mobile-tabs>
<a class="sf-mobile-tab" href="index.html" aria-current="page"><span class="sf-tab-icon" aria-hidden="true">⌂</span><span class="sf-tab-label">首页</span></a><a class="sf-mobile-tab" href="listing.html"><span class="sf-tab-icon" aria-hidden="true">▤</span><span class="sf-tab-label">产品</span></a><a class="sf-mobile-tab" href="article.html"><span class="sf-tab-icon" aria-hidden="true">✉</span><span class="sf-tab-label">联系我们</span></a><button type="button" class="sf-mobile-tab" data-mobile-open-menu aria-label="菜单"><span class="sf-tab-icon" aria-hidden="true">☰</span><span class="sf-tab-label">菜单</span></button></nav>
<script defer src="assets/site-runtime.js"></script><script defer src="assets/pro.js"></script></body></html>
@@ -0,0 +1,22 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base href="./"><title>全部产品</title><meta name="description" content="分类查看产品与解决方案"><meta property="og:type" content="website"><meta property="og:title" content="全部产品"><meta property="og:description" content="分类查看产品与解决方案"><meta property="og:url" content="https://example.com/products/"><meta name="theme-color" content="#0e1c2c"><meta name="robots" content="index,follow"><link rel="canonical" href="https://example.com/products/"><link rel="stylesheet" href="assets/theme.css"><link rel="stylesheet" href="assets/blocks.css"><link rel="stylesheet" href="assets/factory.css"><link rel="stylesheet" href="assets/pro-base.css"><link rel="stylesheet" href="assets/pro.css"><link rel="stylesheet" href="assets/commercial.css"><link rel="stylesheet" href="assets/custom.css"></head><body class="sf-theme-automotive pro-automotive"><a class="pro-skip" href="#main-content">跳转至正文</a>
<div class="pro-utility" hidden><div class="pro-wrap"><span></span><span>专业车辆采购与全球交付</span></div></div>
<header class="sf-site-head pro-header"><div class="pro-wrap pro-head-inner"><a class="pro-logo" href="index.html" aria-label="汽车出口 首页"><span class="pro-logo-symbol">S<span>.</span></span><span class="pro-logo-name">汽车出口<small> AUTOMOTIVE</small></span></a><button class="pro-mobile-toggle" type="button" data-pro-menu aria-controls="pro-navigation" aria-expanded="false" aria-label="打开导航">☰</button><nav id="pro-navigation" class="pro-navigation" aria-label="主导航"><a href="index.html">首页</a><div class="pro-nav-group"><a href="listing.html">产品系列</a><button type="button" class="pro-nav-disclose" aria-expanded="false" aria-label="展开产品系列子菜单">▾</button><div class="pro-nav-children" hidden><a href="/classic/">经典系列</a><a href="/ev/">新能源</a></div></div><a href="/about/">品牌介绍</a><a href="article.html">新闻资讯</a><a href="article.html">联系我们</a><a class="pro-search-link" href="/search/" aria-label="站内搜索">⌕</a></nav><button class="pro-catalog-toggle" type="button" data-pro-catalog aria-controls="pro-mega-content" aria-expanded="false">品牌与系列 ▾</button><a href="article.html" class="pro-header-cta">联系我们 <span>↗</span></a></div><div class="pro-mega pro-wrap" data-pro-mega id="pro-mega-content" hidden><span>探索品牌与系列</span><div><a href="/brands/example/">EXAMPLE ↗</a><a href="/categories/term-36a4ff4ebeb5/">精选产品</a></div></div></header>
<section class="pro-inner-hero pro-listing-hero"><div class="pro-wrap"><span class="pro-eyebrow">DISCOVER / COLLECTION</span><h1>全部产品</h1><p>分类查看产品与解决方案</p><div class="pro-breadcrumb"><a href="index.html">首页</a><span>/</span><strong>全部产品</strong></div></div></section>
<section class="pro-section pro-list-section"><div class="pro-wrap"><div class="pro-filter-panel"><label><span>搜索</span><input type="search" data-pro-filter-search placeholder="按名称或内容筛选"></label><label><span>品牌</span><select data-pro-filter-brand><option value="">全部品牌</option></select></label><label><span>分类</span><select data-pro-filter-category><option value="">全部分类</option></select></label><span class="pro-filter-count" data-pro-filter-count role="status"></span></div><div class="pro-filter-scope"><div class="pro-grid pro-grid-catalog">
<a class="pro-card pro-card-catalog" href="product.html" data-category="精选产品" data-brand="EXAMPLE">
<div class="pro-card-cover">
<div class="pro-art pro-art-catalog"><span class="pro-art-ring"></span><span class="pro-art-center">标准</span><span class="pro-art-label">CATALOG</span></div>
</div>
<div class="pro-card-body"><div class="pro-card-meta"><span>EXAMPLE</span><span>01</span></div>
<h3>标准系列产品</h3><p>面向全球客户的精选示范产品</p><span class="pro-card-link">查看详情 <span aria-hidden="true">↗</span></span></div>
</a>
</div></div></div></section>
<main id="main-content" class="sf-canvas pro-custom-content" aria-hidden="true"></main>
<section class="pro-end-cta"><div class="pro-wrap"><div><span class="pro-eyebrow">LET'S CONNECT</span><h2>从这里开始下一步。</h2><p>专业车辆采购与全球交付</p></div><a href="article.html">联系我们 <span>↗</span></a></div></section>
<footer class="sf-site-foot pro-footer"><div class="pro-wrap"><div class="pro-footer-top"><div><span class="pro-eyebrow">DRIVEN BY PURPOSE</span><h2>汽车出口</h2><p>专业车辆采购与全球交付</p></div><div class="pro-footer-links"><ul><li><a href="index.html">首页</a></li><li><a href="listing.html">产品系列</a></li><li><a href="/about/">品牌介绍</a></li><li><a href="article.html">新闻资讯</a></li><li><a href="article.html">联系我们</a></li></ul></div><div><span class="pro-eyebrow">LET'S TALK</span><p>请在后台配置企业联系方式</p></div></div><div class="pro-footer-bottom"><span>© 2026 汽车出口</span><span>SITEFORGE / STATIC SITE</span></div></div></footer>
<nav class="sf-mobile-tabs" style="--sf-mobile-tab-count:4" aria-label="手机底部快捷导航" data-mobile-tabs>
<a class="sf-mobile-tab" href="index.html"><span class="sf-tab-icon" aria-hidden="true">⌂</span><span class="sf-tab-label">首页</span></a><a class="sf-mobile-tab" href="listing.html" aria-current="page"><span class="sf-tab-icon" aria-hidden="true">▤</span><span class="sf-tab-label">产品</span></a><a class="sf-mobile-tab" href="article.html"><span class="sf-tab-icon" aria-hidden="true">✉</span><span class="sf-tab-label">联系我们</span></a><button type="button" class="sf-mobile-tab" data-mobile-open-menu aria-label="菜单"><span class="sf-tab-icon" aria-hidden="true">☰</span><span class="sf-tab-label">菜单</span></button></nav>
<script defer src="assets/site-runtime.js"></script><script defer src="assets/pro.js"></script></body></html>
@@ -0,0 +1,17 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base href="./"><title>标准系列产品</title><meta name="description" content="面向全球客户的精选示范产品"><meta property="og:type" content="website"><meta property="og:title" content="标准系列产品"><meta property="og:description" content="面向全球客户的精选示范产品"><meta property="og:url" content="https://example.com/products/demo/"><meta name="theme-color" content="#0e1c2c"><meta name="robots" content="index,follow"><link rel="canonical" href="https://example.com/products/demo/"><link rel="stylesheet" href="assets/theme.css"><link rel="stylesheet" href="assets/blocks.css"><link rel="stylesheet" href="assets/factory.css"><link rel="stylesheet" href="assets/pro-base.css"><link rel="stylesheet" href="assets/pro.css"><link rel="stylesheet" href="assets/commercial.css"><link rel="stylesheet" href="assets/custom.css"></head><body class="sf-theme-automotive pro-automotive"><a class="pro-skip" href="#main-content">跳转至正文</a>
<div class="pro-utility" hidden><div class="pro-wrap"><span></span><span>专业车辆采购与全球交付</span></div></div>
<header class="sf-site-head pro-header"><div class="pro-wrap pro-head-inner"><a class="pro-logo" href="index.html" aria-label="汽车出口 首页"><span class="pro-logo-symbol">S<span>.</span></span><span class="pro-logo-name">汽车出口<small> AUTOMOTIVE</small></span></a><button class="pro-mobile-toggle" type="button" data-pro-menu aria-controls="pro-navigation" aria-expanded="false" aria-label="打开导航">☰</button><nav id="pro-navigation" class="pro-navigation" aria-label="主导航"><a href="index.html">首页</a><div class="pro-nav-group"><a href="listing.html">产品系列</a><button type="button" class="pro-nav-disclose" aria-expanded="false" aria-label="展开产品系列子菜单">▾</button><div class="pro-nav-children" hidden><a href="/classic/">经典系列</a><a href="/ev/">新能源</a></div></div><a href="/about/">品牌介绍</a><a href="article.html">新闻资讯</a><a href="article.html">联系我们</a><a class="pro-search-link" href="/search/" aria-label="站内搜索">⌕</a></nav><button class="pro-catalog-toggle" type="button" data-pro-catalog aria-controls="pro-mega-content" aria-expanded="false">品牌与系列 ▾</button><a href="article.html" class="pro-header-cta">联系我们 <span>↗</span></a></div><div class="pro-mega pro-wrap" data-pro-mega id="pro-mega-content" hidden><span>探索品牌与系列</span><div><a href="/brands/example/">EXAMPLE ↗</a><a href="/categories/term-36a4ff4ebeb5/">精选产品</a></div></div></header>
<section class="pro-section pro-product-layout"><div class="pro-wrap"><div class="pro-breadcrumb"><a href="index.html">首页</a><span>/</span><a href="listing.html">产品中心</a><span>/</span><strong>标准系列产品</strong></div>
<div class="pro-product-hero"><div class="pro-product-gallery"><div class="pro-product-media">
<div class="pro-art pro-art-product"><span class="pro-art-ring"></span><span class="pro-art-center">标准</span><span class="pro-art-label">PRODUCT</span></div>
</div></div><div class="pro-product-summary"><span class="pro-eyebrow">PRODUCT DETAILS / EXAMPLE</span><h1>标准系列产品</h1><p>面向全球客户的精选示范产品</p><p class="pro-product-brand">品牌 · EXAMPLE</p><a class="pro-button" href="article.html">咨询该产品 <span>↗</span></a></div></div>
<div class="pro-detail-layout"><div><span class="pro-eyebrow">OVERVIEW</span><h2>产品介绍</h2><div class="pro-rich"><p>规格、优势与适用场景的示例描述。</p></div></div><div><span class="pro-eyebrow">SPECIFICATIONS</span><h2>规格参数</h2><div class="pro-specs"><div><span>Capacity</span><strong>可定制</strong></div><div><span>Material</span><strong>高品质</strong></div></div></div></div></div></section>
<main id="main-content" class="sf-canvas pro-custom-content" aria-hidden="true"></main>
<section class="pro-end-cta"><div class="pro-wrap"><div><span class="pro-eyebrow">LET'S CONNECT</span><h2>从这里开始下一步。</h2><p>专业车辆采购与全球交付</p></div><a href="article.html">联系我们 <span>↗</span></a></div></section>
<footer class="sf-site-foot pro-footer"><div class="pro-wrap"><div class="pro-footer-top"><div><span class="pro-eyebrow">DRIVEN BY PURPOSE</span><h2>汽车出口</h2><p>专业车辆采购与全球交付</p></div><div class="pro-footer-links"><ul><li><a href="index.html">首页</a></li><li><a href="listing.html">产品系列</a></li><li><a href="/about/">品牌介绍</a></li><li><a href="article.html">新闻资讯</a></li><li><a href="article.html">联系我们</a></li></ul></div><div><span class="pro-eyebrow">LET'S TALK</span><p>请在后台配置企业联系方式</p></div></div><div class="pro-footer-bottom"><span>© 2026 汽车出口</span><span>SITEFORGE / STATIC SITE</span></div></div></footer>
<nav class="sf-mobile-tabs" style="--sf-mobile-tab-count:4" aria-label="手机底部快捷导航" data-mobile-tabs>
<a class="sf-mobile-tab" href="index.html"><span class="sf-tab-icon" aria-hidden="true">⌂</span><span class="sf-tab-label">首页</span></a><a class="sf-mobile-tab" href="listing.html"><span class="sf-tab-icon" aria-hidden="true">▤</span><span class="sf-tab-label">产品</span></a><a class="sf-mobile-tab" href="article.html"><span class="sf-tab-icon" aria-hidden="true">✉</span><span class="sf-tab-label">联系我们</span></a><button type="button" class="sf-mobile-tab" data-mobile-open-menu aria-label="菜单"><span class="sf-tab-icon" aria-hidden="true">☰</span><span class="sf-tab-label">菜单</span></button></nav>
<script defer src="assets/site-runtime.js"></script><script defer src="assets/pro.js"></script></body></html>
@@ -0,0 +1,13 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base href="./"><title>企业动态与行业资讯</title><meta name="description" content="了解最新项目与服务进展"><meta property="og:type" content="article"><meta property="og:title" content="企业动态与行业资讯"><meta property="og:description" content="了解最新项目与服务进展"><meta property="og:url" content="https://example.com/news/demo/"><meta name="theme-color" content="#f5f6fa"><meta name="robots" content="index,follow"><link rel="canonical" href="https://example.com/news/demo/"><link rel="stylesheet" href="assets/theme.css"><link rel="stylesheet" href="assets/blocks.css"><link rel="stylesheet" href="assets/factory.css"><link rel="stylesheet" href="assets/pro-base.css"><link rel="stylesheet" href="assets/pro.css"><link rel="stylesheet" href="assets/commercial.css"><link rel="stylesheet" href="assets/custom.css"></head><body class="sf-theme-corporate pro-corporate"><a class="pro-skip" href="#main-content">跳转至正文</a>
<div class="pro-utility" hidden><div class="pro-wrap"><span></span><span>连接更可靠的全球业务</span></div></div>
<header class="sf-site-head pro-header"><div class="pro-wrap pro-head-inner"><a class="pro-logo" href="index.html" aria-label="企业服务 首页"><span class="pro-logo-symbol">✳</span><span class="pro-logo-name">企业服务</span></a><button class="pro-mobile-toggle" type="button" data-pro-menu aria-controls="pro-navigation" aria-expanded="false" aria-label="打开导航">☰</button><nav id="pro-navigation" class="pro-navigation" aria-label="主导航"><a href="index.html">首页</a><div class="pro-nav-group"><a href="listing.html">产品系列</a><button type="button" class="pro-nav-disclose" aria-expanded="false" aria-label="展开产品系列子菜单">▾</button><div class="pro-nav-children" hidden><a href="/classic/">经典系列</a><a href="/ev/">新能源</a></div></div><a href="/about/">品牌介绍</a><a href="article.html">新闻资讯</a><a href="article.html">联系我们</a><a href="/search/">搜索</a><a class="pro-search-link" href="/search/" aria-label="站内搜索">⌕</a></nav><button class="pro-catalog-toggle" type="button" data-pro-catalog aria-controls="pro-mega-content" aria-expanded="false">服务菜单 ▾</button><a href="article.html" class="pro-header-cta">联系我们 <span>↗</span></a></div></header>
<section class="pro-article-hero"><div class="pro-wrap pro-article-width"><span class="pro-eyebrow">LATEST NEWS / INSIGHTS</span><h1>企业动态与行业资讯</h1><p>了解最新项目与服务进展</p></div></section><article class="pro-wrap pro-article-content"><div class="pro-rich"><h2>项目背景</h2><p>这是一段用于检查手机端资讯排版的样例正文。适当的字距、行距和图片宽度能够提升移动阅读体验。</p></div><a class="pro-text-link" href="article.html">← 返回新闻</a></article>
<main id="main-content" class="sf-canvas pro-custom-content" aria-hidden="true"></main>
<section class="pro-end-cta"><div class="pro-wrap"><div><span class="pro-eyebrow">LET'S CONNECT</span><h2>从这里开始下一步。</h2><p>连接更可靠的全球业务</p></div><a href="article.html">联系我们 <span>↗</span></a></div></section>
<footer class="sf-site-foot pro-footer"><div class="pro-wrap"><div class="pro-footer-top"><div><span class="pro-eyebrow">A BETTER WAY FORWARD</span><h2>企业服务</h2><p>连接更可靠的全球业务</p></div><div class="pro-footer-links"><ul><li><a href="index.html">首页</a></li><li><a href="listing.html">产品系列</a></li><li><a href="/about/">品牌介绍</a></li><li><a href="article.html">新闻资讯</a></li><li><a href="article.html">联系我们</a></li></ul></div><div><span class="pro-eyebrow">CONTACT OUR TEAM</span><p>请在后台配置企业联系方式</p></div></div><div class="pro-footer-bottom"><span>© 2026 企业服务</span><span>BUILDING LONG-TERM VALUE</span></div></div></footer>
<nav class="sf-mobile-tabs" style="--sf-mobile-tab-count:4" aria-label="手机底部快捷导航" data-mobile-tabs>
<a class="sf-mobile-tab" href="index.html"><span class="sf-tab-icon" aria-hidden="true">⌂</span><span class="sf-tab-label">首页</span></a><a class="sf-mobile-tab" href="listing.html"><span class="sf-tab-icon" aria-hidden="true">▤</span><span class="sf-tab-label">产品</span></a><a class="sf-mobile-tab" href="article.html"><span class="sf-tab-icon" aria-hidden="true">✉</span><span class="sf-tab-label">联系我们</span></a><button type="button" class="sf-mobile-tab" data-mobile-open-menu aria-label="菜单"><span class="sf-tab-icon" aria-hidden="true">☰</span><span class="sf-tab-label">菜单</span></button></nav>
<script defer src="assets/site-runtime.js"></script><script defer src="assets/pro.js"></script></body></html>
@@ -0,0 +1,29 @@
/* Portable SiteForge blocks, loaded with every theme (all styles scoped to sf-). */
.sf-section{background:var(--sf-bg,transparent);color:var(--sf-fg,inherit);padding:var(--sf-pad,82px) 0;margin:0}.sf-section:nth-of-type(even){background:var(--sf-bg,#f4f6f9)}.sf-container{max-width:1200px;margin:auto;padding:0 32px}.sf-section h1,.sf-section h2,.sf-section h3,.sf-section p{color:inherit;line-height:1.3}.sf-section h1{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}.sf-section h2{font-size:clamp(27px,3.2vw,44px);font-weight:760;letter-spacing:-.037em}.sf-kicker{display:block;font-size:12px;letter-spacing:.17em;text-transform:uppercase;font-weight:740;color:var(--accent,#3569d3);margin-bottom:18px}.sf-hero-grid,.sf-image-text{display:grid;grid-template-columns:1.08fr .92fr;gap:68px;align-items:center}.sf-hero-copy p{font-size:18px;line-height:1.75;max-width:520px;opacity:.75}.sf-hero-img,.sf-image-side{overflow:hidden;border-radius:9px;min-height:380px;background:linear-gradient(130deg,#d5e1ee,#eef3f9);display:grid;place-items:center}.sf-hero-img img,.sf-image-side img{width:100%;height:100%;max-height:580px;object-fit:cover}.sf-hero-abstract{font-size:110px;color:#a7bdd4}.sf-button{display:inline-flex;align-items:center;justify-content:center;gap:28px;background:var(--accent,#315ee2);color:white!important;padding:16px 26px;border-radius:6px;font-size:14px;text-decoration:none;font-weight:720;margin-top:24px}.sf-section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:32px;gap:24px}.sf-section-head h2{margin:0}.sf-section-head>a,.sf-link{color:var(--accent,#3569d3);font-weight:700;text-decoration:none}.sf-prose{font-size:16px;line-height:1.85;max-width:930px}.sf-prose p{line-height:1.85}.sf-features,.sf-collection,.sf-stats{display:grid;gap:20px}.sf-features{grid-template-columns:repeat(3,minmax(0,1fr))}.sf-feature{border:1px solid #e0e6eb;padding:32px 28px;border-radius:8px;background:#fff;color:#253247}.sf-feature>span{font-size:12px;font-weight:800;color:var(--accent,#346add)}.sf-feature h3{font-size:22px;margin:30px 0 8px}.sf-feature p{font-size:14px;line-height:1.7;color:#647181}.sf-stats{grid-template-columns:repeat(4,1fr);padding:40px 0;border-top:1px solid #b7c0cd66;border-bottom:1px solid #b7c0cd66}.sf-stats>div{display:flex;flex-direction:column;gap:8px}.sf-stats strong{font-size:clamp(28px,4vw,53px);letter-spacing:-.04em}.sf-stats span{font-size:12px;opacity:.65;letter-spacing:.04em}.sf-collection{grid-template-columns:repeat(var(--sf-cols,3),minmax(0,1fr))}.sf-item{display:block;text-decoration:none;color:#233044;background:#fff;border:1px solid #e8edf1;border-radius:8px;overflow:hidden;transition:transform .2s,box-shadow .2s}.sf-item:hover{transform:translateY(-4px);box-shadow:0 18px 38px #344d7018}.sf-item-img{height:210px;overflow:hidden;background:#e4eaf0}.sf-item-img img{height:100%;width:100%;object-fit:cover}.sf-placeholder{display:grid;place-items:center;width:100%;height:100%;min-height:190px;background:linear-gradient(130deg,#e2eaf2,#c9d7e6);font-size:72px;color:#8399b2;font-weight:800}.sf-large{min-height:360px}.sf-item-copy{padding:25px}.sf-item-copy small{text-transform:uppercase;letter-spacing:.13em;color:var(--accent,#376edc)}.sf-item-copy h3{font-size:22px;margin:12px 0 7px}.sf-item-copy p{font-size:14px;line-height:1.6;color:#7a8796;min-height:42px}.sf-item-copy span{font-size:12px;color:var(--accent,#376edc);font-weight:800}.sf-image-text>div>p{font-size:17px;line-height:1.8;max-width:520px;opacity:.7}.sf-reverse>div:first-child{order:2}.sf-gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.sf-gallery img{width:100%;height:230px;object-fit:cover;border-radius:6px}.sf-faq{max-width:920px}.sf-faq details{border-bottom:1px solid #dce3eb;padding:22px 0}.sf-faq summary{font-size:17px;cursor:pointer;font-weight:700}.sf-faq p{color:#647181;line-height:1.75}.sf-cta-wrap{display:flex;justify-content:space-between;align-items:center;gap:24px}.sf-cta-wrap h2{margin:0}.sf-cta-wrap p{font-size:16px;opacity:.75}.sf-section a{cursor:pointer}.sf-muted{opacity:.65}
@media(max-width:820px){.sf-container{padding:0 20px}.sf-section{padding:50px 0}.sf-hero-grid,.sf-image-text{grid-template-columns:1fr;gap:26px}.sf-reverse>div:first-child{order:initial}.sf-hero-img,.sf-image-side{min-height:250px}.sf-features,.sf-collection{grid-template-columns:repeat(2,minmax(0,1fr))}.sf-stats{grid-template-columns:repeat(2,1fr)}.sf-cta-wrap{align-items:flex-start;flex-direction:column}}
@media(max-width:540px){.sf-features,.sf-collection,.sf-gallery{grid-template-columns:1fr}.sf-section-head{display:block}.sf-section-head>a{display:block;margin-top:13px}.sf-item-img{height:220px}.sf-hero-grid{gap:22px}.sf-hero-copy p{font-size:16px}.sf-stats{gap:28px}}
.sf-attributes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 32px;border-top:1px solid #e5eaf0}.sf-attribute{display:flex;justify-content:space-between;align-items:center;gap:18px;border-bottom:1px solid #e5eaf0;padding:19px 10px;font-size:14px}.sf-attribute span{color:#748497}.sf-attribute strong{font-weight:720;text-align:right}@media(max-width:640px){.sf-attributes{grid-template-columns:1fr}}
/* SiteForge responsive no-code grid container */
.sf-layout-grid{display:grid;grid-template-columns:var(--sf-layout-cols,1fr 1fr);gap:var(--sf-layout-gap,24px);align-items:stretch}
.sf-layout-column{min-width:0}.sf-layout-column>.sf-section{padding:0;margin:0 0 18px}.sf-layout-column .sf-container{width:100%;padding:0}
.sf-layout-column>.sf-section:last-child{margin-bottom:0}
@media(max-width:820px){.sf-layout-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
@media(max-width:540px){.sf-layout-grid{grid-template-columns:minmax(0,1fr)!important}}
/* V8.6: accessible, script-free commercial design blocks */
.sf-slides{display:flex;gap:18px;overflow-x:auto;scroll-snap-type:x mandatory;padding:6px 0 18px;scrollbar-width:thin}
.sf-slide{flex:0 0 min(78%,720px);min-width:220px;margin:0;scroll-snap-align:start}
.sf-slide img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px}
.sf-slide figcaption{padding:10px 2px;opacity:.7;font-size:14px}
.sf-video{aspect-ratio:16/9;max-width:1000px;background:#ebf0f5;border-radius:10px;overflow:hidden}
.sf-video iframe{width:100%;height:100%;border:0}
.sf-tab{padding:19px 0;border-bottom:1px solid #dde4ec;max-width:960px}
.sf-tab summary{font-size:18px;font-weight:700;cursor:pointer}.sf-tab p{max-width:850px;line-height:1.8}
.sf-prices{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
.sf-price{padding:28px;background:#fff;border:1px solid #dee5ef;border-radius:10px;color:#29394b}
.sf-price strong{display:block;font-size:27px;margin:14px 0}.sf-price li{margin:11px 0}.sf-price ul{padding-left:19px}
.sf-map-address{font-style:normal;font-size:18px;white-space:pre-line;line-height:1.8}
@media (prefers-reduced-motion:reduce){.sf-slides{scroll-behavior:auto}.sf-item{transition:none}}
@media(max-width:540px){.sf-slide{flex-basis:92%}}
@@ -0,0 +1,84 @@
/* SiteForge commercial theme UX layer: accessibility, layout rhythm, media, responsive navigation. */
:root{--sf-shadow:0 20px 55px rgba(17,33,55,.08);--sf-shadow-hover:0 26px 65px rgba(17,33,55,.15)}
html{scroll-padding-top:104px}body{font-feature-settings:"kern";text-rendering:optimizeLegibility}
a,button,input,select,textarea{-webkit-tap-highlight-color:transparent}
a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid var(--pro-accent);outline-offset:4px}
.pro-wrap{width:100%;max-width:min(var(--pro-wide),100%)}.pro-header{isolation:isolate;box-shadow:0 1px 0 rgba(15,35,60,.035)}.pro-head-inner{gap:clamp(15px,2vw,29px)}.pro-logo{min-width:0;flex-shrink:0;gap:12px}.pro-brand-logo{max-width:155px;max-height:46px;width:auto;height:auto;object-fit:contain}.pro-logo-name{overflow-wrap:anywhere}.pro-navigation{gap:clamp(11px,1.6vw,24px)}.pro-header-cta{white-space:nowrap}
.pro-mobile-toggle,.pro-catalog-toggle,.pro-nav-disclose{border:0;background:transparent;color:inherit;line-height:1.2}
.pro-mobile-toggle{min-height:44px;min-width:44px;border:1px solid currentColor;border-radius:6px;font-size:19px}.pro-nav-group{position:relative;display:flex;align-items:center;gap:2px}
.pro-nav-disclose{padding:9px;font-size:13px}.pro-nav-children{position:absolute;top:100%;left:0;min-width:215px;max-width:330px;display:grid;gap:2px;padding:13px;box-shadow:var(--sf-shadow);border-radius:10px;z-index:45;background:var(--pro-background);border:1px solid var(--pro-border);color:var(--pro-text)}
.pro-nav-children[hidden],.pro-mega[hidden]{display:none!important}.pro-nav-children a{display:block;font-size:13px;white-space:normal;padding:10px;border-radius:5px}.pro-nav-children a:hover{background:var(--pro-surface)}
.pro-catalog-toggle{padding:11px 3px;white-space:nowrap;font-size:12px;font-weight:700}.pro-mega{position:absolute;top:100%;left:50%;transform:translateX(-50%);width:min(100%,var(--pro-wide));box-shadow:var(--sf-shadow);z-index:41;display:grid;grid-template-columns:215px 1fr;background:var(--pro-background);color:var(--pro-text);border:1px solid var(--pro-border);border-radius:0 0 15px 15px;padding:29px;align-items:start}.pro-mega>span{font-size:12px;letter-spacing:.06em}.pro-mega>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px 22px;overflow:visible}.pro-mega>div a{white-space:normal;padding:10px 5px;border-bottom:1px solid var(--pro-border)}
.pro-home-hero{overflow:hidden}.pro-home-hero .pro-home-grid{min-height:660px;gap:5%}.pro-home-hero .pro-hero-copy{position:relative;z-index:2}.pro-home-hero .pro-hero-copy h1{font-size:clamp(46px,5.7vw,92px);line-height:1.06;letter-spacing:-.064em;max-width:15ch}.pro-home-hero .pro-hero-copy h1 em{font-style:normal;display:block}.pro-home-hero .pro-hero-copy p{font-size:clamp(15px,1.3vw,19px);line-height:1.75;max-width:44ch}.pro-home-hero .pro-hero-art{min-width:0;min-height:425px;position:relative;isolation:isolate}.pro-home-hero .pro-hero-art>img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0}.pro-hero-art:has(.pro-abstract-car),.pro-hero-art:has(.pro-technical),.pro-hero-art:has(.pro-corporate-shape){background-position:center;background-size:cover;background-repeat:no-repeat}.pro-abstract-car,.pro-technical,.pro-corporate-shape{position:relative;z-index:1;pointer-events:none}
.pro-hero-actions{display:flex;flex-wrap:wrap;align-items:center;gap:15px}.pro-button,.pro-header-cta,.pro-outline-button{min-height:48px;transition:transform .24s ease,box-shadow .24s ease,opacity .24s ease}.pro-button:hover,.pro-header-cta:hover,.pro-outline-button:hover{transform:translateY(-2px);box-shadow:0 10px 22px rgba(0,0,0,.10)}
.pro-section{padding-top:clamp(76px,7vw,112px)!important;padding-bottom:clamp(76px,7vw,112px)!important}.pro-section-head{gap:16px}.pro-section-head h2{font-size:clamp(32px,3.5vw,53px);max-width:22ch}.pro-section-head .pro-text-link{align-self:end}
.pro-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.pro-card{min-width:0;display:flex;flex-direction:column;transition:transform .28s ease,box-shadow .28s ease;border:1px solid var(--pro-border);background:var(--pro-background);overflow:hidden}.pro-card:hover{transform:translateY(-6px);box-shadow:var(--sf-shadow-hover)}.pro-card-cover{aspect-ratio:1.42;overflow:hidden;background:var(--pro-surface);position:relative}.pro-card-cover .pro-photo{width:100%;height:100%;object-fit:cover;transition:transform .45s ease}.pro-card:hover .pro-photo{transform:scale(1.045)}.pro-card-body{display:flex;flex-direction:column;flex:1}.pro-card-body h3{font-size:clamp(19px,1.6vw,24px);line-height:1.26}.pro-card-body p{font-size:14px}.pro-card-link{margin-top:auto;padding-top:14px;font-size:13px;font-weight:700}
.pro-art{min-height:100%;width:100%;position:relative;overflow:hidden;display:grid;place-items:center;isolation:isolate}.pro-art-ring{width:60%;aspect-ratio:1;border:1px solid currentColor;opacity:.13;border-radius:50%;transform:rotate(20deg);box-shadow:0 0 0 23px #ffffff10,0 0 0 57px #ffffff07}.pro-art-center{font-size:clamp(38px,5vw,80px);position:absolute;font-weight:800;letter-spacing:-.065em;opacity:.65}.pro-art-label{position:absolute;bottom:19px;left:22px;font-size:10px;letter-spacing:.22em;opacity:.7}
.pro-breadcrumb{display:flex;gap:11px;flex-wrap:wrap;font-size:12px;align-items:center}.pro-breadcrumb a{text-decoration:underline;text-underline-offset:4px}.pro-product-hero{align-items:start;gap:clamp(28px,5vw,80px)}.pro-product-media{aspect-ratio:1.12;background:var(--pro-surface);overflow:hidden}.pro-product-media>img,.pro-product-media>.pro-art{width:100%;height:100%;object-fit:contain}.pro-product-summary{padding:clamp(12px,3vw,50px) 0}.pro-product-summary h1{font-size:clamp(36px,4.3vw,72px)}.pro-detail-layout{border-top:1px solid var(--pro-border);padding-top:50px}.pro-specs>div{display:grid;grid-template-columns:minmax(100px,40%) 1fr;gap:10px;align-items:baseline;padding:15px 0;border-bottom:1px solid var(--pro-border)}.pro-specs strong{overflow-wrap:anywhere}.pro-gallery-thumbs button{flex-basis:96px;height:76px}.pro-gallery-lightbox{backdrop-filter:blur(6px)}
.pro-filter-panel{display:flex;align-items:end;flex-wrap:wrap;gap:16px;background:var(--pro-surface);border:1px solid var(--pro-border);padding:20px;margin-bottom:25px;border-radius:var(--pro-radius)}.pro-filter-panel label{display:grid;gap:7px;min-width:180px;flex:1}.pro-filter-panel input,.pro-filter-panel select{width:100%;background:var(--pro-background);border:1px solid var(--pro-border);padding:12px 14px;color:var(--pro-text);border-radius:6px}.pro-filter-count{white-space:nowrap;font-size:13px;padding:12px 0}.pro-card[hidden]{display:none!important}.pro-filter-empty{padding:38px;border:1px dashed var(--pro-border);text-align:center;grid-column:1/-1}
.pro-inquiry input,.pro-inquiry textarea{min-height:46px}.pro-inquiry textarea{min-height:150px}.pro-inquiry-wrap{gap:clamp(32px,7vw,110px)}.pro-inquiry .pro-button{border:0;justify-self:start}.pro-footer-links a{padding-block:5px;display:inline-block}
.pro-end-cta{overflow:hidden}.pro-end-cta h2{font-size:clamp(30px,4vw,64px)}
.pro-rich{line-height:1.85}.pro-rich img{border-radius:var(--pro-radius);margin-block:24px}.pro-rich table{width:100%;display:block;overflow:auto;border-collapse:collapse}.pro-rich td,.pro-rich th{border:1px solid var(--pro-border);padding:10px 14px}.pro-rich a{text-decoration:underline;text-underline-offset:3px}
@media(min-width:1025px){.pro-navigation>.pro-nav-group:hover .pro-nav-children:not([hidden]){visibility:visible}}
@media(max-width:1150px){.pro-head-inner{gap:15px}.pro-navigation{gap:12px}.pro-logo-name{font-size:15px}.pro-header-cta{padding:13px 16px}}
@media(max-width:1024px){.pro-head-inner{min-height:72px;flex-wrap:wrap}.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto}.pro-navigation{display:none;width:100%;flex-basis:100%;flex-direction:column;align-items:stretch;gap:0;order:5;margin:0;padding:8px 0 20px;border-top:1px solid var(--pro-border)}.pro-navigation.is-open{display:flex}.pro-navigation>a,.pro-nav-group>a{padding:14px 8px;font-size:15px}.pro-nav-group{display:flex;flex-wrap:wrap;justify-content:space-between}.pro-nav-children{position:static;flex-basis:100%;max-width:none;width:100%;box-shadow:none;border-radius:0;border-left:2px solid var(--pro-accent);margin:0 0 12px 8px}.pro-nav-disclose{min-width:44px;min-height:44px}.pro-catalog-toggle{margin-left:auto}.pro-mega{top:100%;display:block;padding:20px}.pro-mega>div{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:12px}.pro-home-hero .pro-home-grid{min-height:560px}.pro-home-hero .pro-hero-art{min-height:325px}.pro-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:700px){.pro-wrap{padding-left:20px;padding-right:20px}.pro-utility{display:none}.pro-head-inner{min-height:66px}.pro-logo-name{max-width:140px;font-size:14px}.pro-brand-logo{max-width:125px}.pro-header-cta{display:none}.pro-catalog-toggle{font-size:11px;padding-inline:7px;margin-left:0}.pro-home-hero .pro-home-grid{display:flex;flex-direction:column;gap:36px;padding-top:48px;padding-bottom:48px}.pro-home-hero .pro-hero-copy h1{font-size:clamp(43px,10vw,68px)}.pro-home-hero .pro-hero-copy p{font-size:15px}.pro-home-hero .pro-hero-art{width:100%;min-height:290px}.pro-home-hero .pro-hero-bottom{display:none}.pro-section{padding-top:68px!important;padding-bottom:68px!important}.pro-section-head{align-items:flex-start}.pro-grid{grid-template-columns:1fr;gap:18px}.pro-card-cover{aspect-ratio:1.45}.pro-product-hero,.pro-detail-layout,.pro-info-grid,.pro-inquiry-wrap{display:grid;grid-template-columns:1fr;gap:30px}.pro-product-summary{padding:0}.pro-specs>div{grid-template-columns:1fr 1fr}.pro-mega{top:100%;max-height:65vh;overflow-y:auto}.pro-mega>div{grid-template-columns:1fr}.pro-filter-panel{display:grid;grid-template-columns:1fr;gap:12px}.pro-filter-panel label{min-width:0}.pro-footer-top{display:grid;grid-template-columns:1fr;gap:27px}.pro-footer-bottom{gap:12px;flex-wrap:wrap}}
@media(max-width:410px){.pro-logo-name{max-width:115px}.pro-logo-symbol{font-size:28px}.pro-catalog-toggle{font-size:10px}.pro-mobile-toggle{min-width:42px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
/* Accessible static catalogue pagination for large inventories. */
.pro-pages-nav{display:flex;gap:1rem;justify-content:center;align-items:center;margin:2rem 0;flex-wrap:wrap}
.pro-pages-nav a{padding:.7rem 1.1rem;border:1px solid currentColor;border-radius:.4rem;text-decoration:none}
.pro-pages-nav a:focus-visible{outline:3px solid var(--accent,#2563eb);outline-offset:3px}
.pro-pages-nav span{font-size:.875rem}
/* P6 international sites: small, keyboard-operable language switch. */
.sf-language-switch{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;margin:8px 12px;padding:4px;border:1px solid rgba(120,130,145,.22);border-radius:10px;background:rgba(255,255,255,.08)}
.sf-language-switch a{font:inherit;font-size:12px;line-height:1.5;padding:4px 7px;border-radius:6px;color:inherit;text-decoration:none}
.sf-language-switch a[aria-current="page"]{font-weight:700;text-decoration:underline;text-underline-offset:3px}
.sf-language-switch a:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
@media(max-width:640px){.sf-language-switch{margin:6px 0;width:auto}}
/* V8.21.3 — Four-theme unified mobile off-canvas navigation & optional tab bar.
Desktop remains unchanged. Phone / tablet use one accessible drawer. */
.sf-mobile-tabs{display:none!important}
.sf-drawer-header,.sf-mobile-drawer-backdrop,.sf-drawer-extra{display:none}
@media(max-width:1024px){
.pro-head-inner{flex-wrap:nowrap!important;gap:12px!important}
.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto!important;flex:0 0 44px;min-width:44px;min-height:44px;position:relative;z-index:3}
.pro-catalog-toggle{display:none!important}
.pro-navigation,.pro-navigation.is-open{display:flex!important;position:fixed!important;inset:0 0 0 auto!important;width:min(88vw,360px)!important;height:100dvh!important;height:100vh;max-height:100dvh!important;flex:unset!important;flex-direction:column!important;align-items:stretch!important;order:unset!important;margin:0!important;padding:0 0 calc(20px + env(safe-area-inset-bottom,0px))!important;overflow-x:hidden;overflow-y:auto!important;background:var(--pro-background,#fff)!important;color:var(--pro-text,#19283c)!important;border:0!important;border-left:1px solid var(--pro-border,#ddd)!important;box-shadow:-15px 0 45px #0002!important;z-index:1201!important;transform:translateX(105%);visibility:hidden;pointer-events:none;transition:transform .24s ease,visibility .24s ease!important;overscroll-behavior:contain}
.pro-navigation.is-open{transform:translateX(0);visibility:visible;pointer-events:auto}
.sf-drawer-header{display:flex!important;align-items:center;justify-content:space-between;gap:12px;padding:20px 18px;border-bottom:1px solid var(--pro-border,#dde3e9);font-size:17px;font-weight:750;position:sticky;top:0;background:var(--pro-background,#fff);z-index:2}
.sf-drawer-close{min-width:44px;min-height:44px;border:1px solid var(--pro-border,#dde3e9);color:inherit;background:transparent;border-radius:10px;font-size:24px;cursor:pointer}
.sf-mobile-drawer-backdrop{display:block;position:fixed;inset:0;z-index:1200;background:#0b1b31a3;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .24s ease,visibility .24s ease}
.sf-mobile-drawer-backdrop.is-open{opacity:1;visibility:visible;pointer-events:auto}
body.sf-drawer-open{overflow:hidden!important}
.pro-navigation>a,.pro-navigation .pro-nav-group>a,.pro-navigation .sf-drawer-extra>a{display:flex;align-items:center;min-height:50px;padding:13px 20px!important;font-size:15px!important;color:inherit!important;white-space:normal;border-bottom:1px solid var(--pro-border,#dde3e9)!important;text-decoration:none!important}
.pro-navigation .pro-nav-group{display:flex;position:relative;flex-wrap:wrap!important;width:100%;gap:0;align-items:center;justify-content:space-between}
.pro-navigation .pro-nav-group>a{flex:1;min-width:0}
.pro-navigation .pro-nav-disclose{margin-right:12px;min-width:44px;min-height:44px;border-radius:9px;background:var(--pro-surface,#f4f6f8)}
.pro-navigation .pro-nav-children{position:static!important;width:100%!important;max-width:100%!important;box-shadow:none!important;border-left:3px solid var(--pro-accent,#e77730);border-radius:0!important;margin:0 0 8px 14px!important;flex-basis:calc(100% - 14px)!important;background:var(--pro-surface,#f6f8fa)}
.pro-navigation .pro-nav-children a{padding:12px 16px!important;font-size:14px!important;color:inherit!important;min-height:44px}
.sf-drawer-extra{display:block;padding:12px 0 5px}.sf-drawer-extra strong{display:block;font-size:12px;letter-spacing:.06em;padding:12px 20px;opacity:.65}.sf-drawer-extra a{font-size:14px!important}
.sf-drawer-close:focus-visible,.pro-mobile-toggle:focus-visible,.pro-nav-disclose:focus-visible,.sf-mobile-tab:focus-visible{outline:3px solid var(--pro-accent,#ff8200);outline-offset:2px}
}
@media(max-width:767px){
.pro-header-cta{display:none!important}
.pro-head-inner .pro-logo{min-width:0;max-width:calc(100% - 65px)}
.pro-logo-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pro-home-hero .pro-home-grid{min-height:0!important}
.pro-home-hero .pro-hero-art{min-height:230px}
.pro-card,.pro-card-cover,.pro-product-media{max-width:100%;min-width:0}
.pro-rich img,.pro-content img,.sf-canvas img{max-width:100%;height:auto}
.pro-rich table{max-width:100%;overflow:auto}
.pro-grid,.pro-footer-top{min-width:0}
.sf-mobile-tabs{display:grid!important;position:fixed;z-index:1100;bottom:0;left:0;right:0;grid-template-columns:repeat(var(--sf-mobile-tab-count,4),minmax(0,1fr));align-items:stretch;padding:5px 6px calc(5px + env(safe-area-inset-bottom,0px));min-height:63px;background:var(--pro-background,#fff);color:var(--pro-text,#263446);border-top:1px solid var(--pro-border,#dfe4eb);box-shadow:0 -6px 24px #13223914}
.sf-mobile-tab{appearance:none;border:0!important;background:transparent!important;color:inherit!important;text-decoration:none!important;padding:6px 2px;min-height:50px;min-width:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:2px;border-radius:9px;cursor:pointer;font-family:inherit}
.sf-tab-icon{font-size:23px;line-height:1.15;font-weight:500}
.sf-tab-label{font-size:11px;font-weight:600;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sf-mobile-tab[aria-current=page]{color:var(--pro-accent,#e47730)!important;background:var(--pro-surface,#f5f6f8)!important}
body:has(> .sf-mobile-tabs){padding-bottom:calc(76px + env(safe-area-inset-bottom,0px))!important}
}
@media(min-width:768px){.sf-mobile-tabs{display:none!important}}
@media(prefers-reduced-motion:reduce){.pro-navigation,.sf-mobile-drawer-backdrop{transition:none!important}}
@@ -0,0 +1 @@
body .pro-utility[hidden]{display:none!important}@media(max-width:1023px){.sf-canvas .sf-section{padding-block:64px}}@media(max-width:767px){.sf-canvas .sf-section{padding-block:40px}.pro-wrap,.sf-container{padding-left:20px;padding-right:20px}body .pro-utility{display:none!important}}
@@ -0,0 +1,27 @@
/* SiteForge trusted global shell. Site themes remain visually independent. */
:root{--sf-shell:#12243d;--sf-shell-accent:#dcae71;--sf-shell-soft:#edf2f8;--sf-shell-fg:#173049}
html{scroll-behavior:smooth}body{overflow-x:hidden}body>.header,body>.utility,body>.top,body>header,body>footer{display:none!important}
.sf-theme-industrial{--sf-shell:#114e79;--sf-shell-accent:#92d5ed;--sf-shell-soft:#ebf6fa;--sf-shell-fg:#0c4267}
.sf-theme-corporate{--sf-shell:#232d62;--sf-shell-accent:#b8bdff;--sf-shell-soft:#f3f3fa;--sf-shell-fg:#333468}
.sf-theme-minimal{--sf-shell:#1e1e1c;--sf-shell-accent:#ded6c5;--sf-shell-soft:#f6f4ef;--sf-shell-fg:#252520}
.sf-site-head{position:relative;z-index:25;background:var(--sf-shell);color:#fff;font-family:system-ui,-apple-system,sans-serif}
.sf-head-inner{max-width:1240px;margin:0 auto;min-height:78px;padding:0 28px;display:flex;align-items:center;gap:28px}
.sf-logo{display:flex;align-items:center;gap:11px;font-size:18px;text-decoration:none!important;color:#fff!important;white-space:nowrap}
.sf-logo-mark{width:36px;height:36px;display:grid;place-items:center;background:var(--sf-shell-accent);color:var(--sf-shell);font-size:22px;font-weight:900;border-radius:9px}
.sf-primary-links{display:flex;gap:23px;margin-left:auto;align-items:center}
.sf-primary-links a,.sf-mega a{color:#fff!important;text-decoration:none!important;font-size:13px;font-weight:650;white-space:nowrap}
.sf-primary-links a:hover{color:var(--sf-shell-accent)!important}
.sf-head-cta{font-size:13px;font-weight:750;text-decoration:none;background:var(--sf-shell-accent);color:var(--sf-shell)!important;padding:13px 18px;border-radius:4px;white-space:nowrap}
.sf-menu-toggle{display:none;margin-left:auto;color:white;border:1px solid #ffffff55;background:none;border-radius:4px;padding:8px 12px;font-size:19px;cursor:pointer}
.sf-mega{display:flex;gap:18px;max-width:1240px;margin:auto;padding:0 28px 9px;overflow-x:auto}
.sf-mega a{display:block;min-width:120px;border-top:1px solid #ffffff2e;padding:12px 8px}.sf-mega small{display:block;color:#ffffff99;max-width:160px;overflow:hidden;text-overflow:ellipsis;font-size:10px;margin-top:6px}
.sf-site-foot{background:var(--sf-shell);color:white!important;font:14px/1.7 system-ui,-apple-system,sans-serif;padding:72px 28px 26px}
.sf-foot-grid{max-width:1200px;margin:auto;display:grid;grid-template-columns:2fr 1fr 1fr;gap:70px}.sf-foot-grid h2{font-size:25px;color:white;margin:0 0 17px}.sf-foot-grid h4{font-size:13px;color:var(--sf-shell-accent);letter-spacing:.07em;margin:0 0 17px}.sf-foot-grid p{color:#ffffffae;max-width:380px}.sf-foot-grid a{display:block;width:fit-content;color:#fff!important;text-decoration:none;margin-bottom:8px}.sf-foot-lower{border-top:1px solid #ffffff33;max-width:1200px;margin:45px auto 0;padding-top:20px;display:flex;justify-content:space-between;color:#ffffff9e;font-size:12px}
.sf-filters{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin:0 0 30px}.sf-filters input,.sf-filters select{padding:11px 13px;border-radius:6px;border:1px solid #cfd9e4;background:#fff;color:#263a51;max-width:280px}.sf-filter-empty{padding:22px;color:#778598}
.sf-search-page{min-height:76vh;max-width:1060px;padding:90px 24px;margin:auto;font-family:system-ui,sans-serif;color:var(--sf-shell-fg)}.sf-search-page h1{font-size:clamp(35px,5vw,62px);margin:20px 0}.sf-search-page>label{display:block;margin:18px 0 8px}.sf-search-page input{display:block;width:100%;padding:17px;font-size:18px;border:1px solid #b7c6d7;border-radius:8px}.sf-search-page #sf-search-count{margin:22px 0;color:#607589}.sf-search-result{padding:22px 0;border-bottom:1px solid #dae1ea}.sf-search-result a{color:var(--sf-shell-fg);font-size:21px;font-weight:750;text-decoration:none}.sf-search-result small{display:block;color:#7992a7}.sf-search-result p{margin:7px 0;color:#657487}
@media(max-width:1050px){.sf-head-inner{gap:13px}.sf-primary-links{gap:12px}.sf-primary-links a{font-size:12px}.sf-head-cta{padding:10px}}
@media(max-width:850px){.sf-head-inner{min-height:68px}.sf-menu-toggle{display:block}.sf-head-cta{display:none}.sf-primary-links{display:none;position:absolute;top:68px;left:0;right:0;margin:0;background:var(--sf-shell);padding:20px 28px;flex-direction:column;align-items:stretch;box-shadow:0 20px 24px #0002}.sf-primary-links.is-open{display:flex}.sf-mega{display:none}.sf-foot-grid{grid-template-columns:1fr 1fr;gap:35px}}
@media(max-width:560px){.sf-foot-grid{grid-template-columns:1fr}.sf-foot-lower{display:block}.sf-site-foot{padding-top:48px}.sf-head-inner{padding:0 16px}}
.sf-page-intro{padding:80px 28px;background:var(--sf-shell-soft);color:var(--sf-shell-fg);font-family:system-ui,sans-serif}.sf-page-intro>div{max-width:1200px;margin:auto}.sf-page-intro p{font-size:11px;font-weight:850;letter-spacing:.19em;text-transform:uppercase;color:var(--sf-shell-fg);opacity:.58}.sf-page-intro h1{font-size:clamp(38px,5vw,67px);letter-spacing:-.04em;line-height:1.08;margin:13px 0}.sf-page-intro span{font-size:16px;opacity:.72}
.sf-item[hidden]{display:none!important}

Some files were not shown because too many files have changed in this diff Show More