chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec

- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests)
- Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR
  imports keep post/product types instead of collapsing to page
- Docs: trade-pro theme port spec (R3) and implementation plan

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 16:49:25 +08:00
commit 8cea7dee20
354 changed files with 20736 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.venv
.env
private_secrets
private_ca
.env.*
!.env.example
storage
__pycache__
.pytest_cache
*.pyc
*.zip
screenshots
*.log
demo_storage
+38
View File
@@ -0,0 +1,38 @@
# 复制为 .env 后更换密码和密钥;不要提交 .env 到版本库
SITEFORGE_PORT=8787
SITEFORGE_ADMIN_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
SITEFORGE_SESSION_SECRET=REPLACE_WITH_64_RANDOM_CHARACTERS
SITEFORGE_COOKIE_SECURE=false
# 只有启用 compose.public.yaml 时才需要填写,先正确设置 DNS 记录
SITEFORGE_DOMAIN=siteforge.example.com
# Enterprise-only (compose.enterprise.yaml)
SITEFORGE_PG_USER=siteforge
SITEFORGE_PG_PASSWORD=GENERATE_STRONG_URL_SAFE_PASSWORD
SITEFORGE_PG_DB=siteforge
# Must be HTTPS and trusted deployment endpoint; never set to an arbitrary user URL
SITEFORGE_DEPLOY_STAGING_URL=
SITEFORGE_DEPLOY_STAGING_SECRET=
SITEFORGE_DEPLOY_PRODUCTION_URL=
SITEFORGE_DEPLOY_PRODUCTION_SECRET=
# Release agent is deployed as an isolated service on the receiving host
SITEFORGE_DEPLOY_SECRET=
# P1 task-queue capacity and safe outbound HTTP policy
SITEFORGE_MAX_PENDING_JOBS=100
SITEFORGE_MAX_PENDING_JOBS_PER_SITE=5
SITEFORGE_OUTBOUND_PORTS=80,443
# V8.5 restricted MySQL importer. Empty allowlists disable MySQL access.
# Example values, only enable an approved server + an actual SELECT-only user.
SITEFORGE_MYSQL_ALLOWED_HOSTS=
SITEFORGE_MYSQL_ALLOWED_TABLES=
# Optional CA path inside Worker. Mount certificate into Worker when using custom CA.
SITEFORGE_MYSQL_CA_FILE=
# Credential names should be secrets in Worker, not plaintext from the browser.
# SITEFORGE_MYSQL_READ_USER=<use Docker secret instead>
# SITEFORGE_MYSQL_READ_PASSWORD=<use Docker secret instead>
# Preferred remote signing secret reference (instead of plaintext _SECRET)
# SITEFORGE_DEPLOY_STAGING_SECRET_REF=env:SITEFORGE_DEPLOY_STAGING_SECRET
# SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF=file:siteforge_deploy_production
+90
View File
@@ -0,0 +1,90 @@
name: SiteForge quality gates
on:
push:
pull_request:
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
python: ['3.12', '3.13']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- name: Install locked dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-dev.lock
python -m pip check
- name: Validate Python and tests
env:
SITEFORGE_ADMIN_PASSWORD: administrator-strong-password-for-ci
SITEFORGE_SESSION_SECRET: ci-only-session-key-not-for-production-with-entropy
run: |
bash scripts/check.sh
container:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Validate Compose
env:
SITEFORGE_ADMIN_PASSWORD: ci-test-only-not-production
SITEFORGE_SESSION_SECRET: ci-test-only-session-key-not-for-production-123456
SITEFORGE_PG_PASSWORD: ci-postgresql-password
SITEFORGE_DEPLOY_SECRET: ci-release-secret-should-not-be-used-in-production
run: |
docker compose config --quiet
docker compose -f compose.enterprise.yaml config --quiet
docker compose -f compose.release-agent.yaml config --quiet
- name: One-command Compose build and start smoke
env:
SITEFORGE_ADMIN_PASSWORD: ci-test-only-not-production
SITEFORGE_SESSION_SECRET: ci-test-only-session-key-not-for-production-123456
run: |
docker compose up -d --build
for n in $(seq 1 45); do
web=$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q siteforge)" 2>/dev/null || true)
worker=$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q worker)" 2>/dev/null || true)
if [ "$web" = healthy ] && [ "$worker" = healthy ]; then break; fi
sleep 2
done
test "$web" = healthy && test "$worker" = healthy
docker compose exec -T siteforge python -c "import json,urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/health')); assert data['version']=='8.10.0-dev.1' and data.get('commit')"
- name: Stop Compose smoke
if: always()
env:
SITEFORGE_ADMIN_PASSWORD: ci-test-only-not-production
SITEFORGE_SESSION_SECRET: ci-test-only-session-key-not-for-production-123456
run: docker compose down -v --remove-orphans
postgres-smoke:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16.9-alpine
env:
POSTGRES_USER: siteforge
POSTGRES_PASSWORD: postgres-ci-only
POSTGRES_DB: siteforge_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U siteforge -d siteforge_ci"
--health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install pinned dependencies and PG driver
run: pip install -r requirements.lock -r requirements-enterprise.txt
- name: Bootstrap PG and verify transactional queue
env:
SITEFORGE_DATABASE_URL: postgresql://siteforge:postgres-ci-only@127.0.0.1:5432/siteforge_ci
run: python scripts/verify_pg_bootstrap.py
+16
View File
@@ -0,0 +1,16 @@
.venv/
storage/
__pycache__/
.pytest_cache/
*.pyc
*.log
.DS_Store
.env
private_secrets/
private_ca/
.env.*
!.env.example
secrets/
demo_storage/
hesupipe_clean2.xml
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
5552c7a9a9c1
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
SITEFORGE_DATA_DIR=/data
ARG SITEFORGE_GIT_SHA=unknown
ENV SITEFORGE_GIT_SHA=${SITEFORGE_GIT_SHA}
LABEL org.opencontainers.image.revision=${SITEFORGE_GIT_SHA}
WORKDIR /opt/siteforge
COPY requirements.lock ./
RUN pip install --no-cache-dir -r requirements.lock && \
groupadd -g 10001 siteforge && useradd -u 10001 -g 10001 -s /usr/sbin/nologin siteforge && \
mkdir -p /data /published && chown -R siteforge:siteforge /data /published
COPY --chown=siteforge:siteforge app/ ./app/
COPY --chown=siteforge:siteforge themes/ ./themes/
COPY --chown=siteforge:siteforge examples/ ./examples/
USER siteforge
EXPOSE 8787
CMD ["python","-m","uvicorn","app.main:app","--host","0.0.0.0","--port","8787","--proxy-headers","--forwarded-allow-ips","*"]
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1 SITEFORGE_DATA_DIR=/data
ARG SITEFORGE_GIT_SHA=unknown
ENV SITEFORGE_GIT_SHA=${SITEFORGE_GIT_SHA}
LABEL org.opencontainers.image.revision=${SITEFORGE_GIT_SHA}
WORKDIR /opt/siteforge
COPY requirements.lock requirements-enterprise.txt ./
RUN pip install --no-cache-dir -r requirements.lock -r requirements-enterprise.txt && \
groupadd -g 10001 siteforge && useradd -u 10001 -g 10001 -s /usr/sbin/nologin siteforge && \
mkdir -p /data /published /srv/releases && chown -R siteforge:siteforge /data /published /srv/releases
COPY --chown=siteforge:siteforge app/ ./app/
COPY --chown=siteforge:siteforge themes/ ./themes/
USER siteforge
EXPOSE 8787
CMD ["python","-m","uvicorn","app.main:app","--host","0.0.0.0","--port","8787","--proxy-headers","--forwarded-allow-ips","127.0.0.1"]
+283
View File
@@ -0,0 +1,283 @@
# SiteForge Enterprise V8.10.0-dev.1 — 简洁管理后台体验升级
> **开发集成版,尚未通过 P0–P4 全部生产验收门禁。** 完整工程基于 V8.8 升级;P5/多语言和 Art Design Pro 官方工程等未在本轮解决。
## V8.10:管理后台重新设计(本轮重点)
默认 `http://localhost:8787/` 现使用 **简洁工作台**,保留原有业务 API 和数据结构,不迁移数据库。
- **工作台**:网站数量、操作引导、最近网站;不要求初学者理解 Worker/SEO/主题 Schema。
- **我的网站**:一个地方选择所有网站;创建新站的表单只保留必要字段。
- **网站概览**:四步流程(建立网站、准备内容、完善设计、构建预览),动态提示下一步。
- **内容与页面**:页面、产品、新闻统一管理,可快捷修改文字或进入可视化设计器。
- **外观与设计**:行业主题选择与实际页面编辑;草稿/发布隔离仍由原编辑器负责。
- **导入与迁移**:简单文件快速导入;WordPress/认证 API/MySQL 等复杂任务引导进入专业工作台。快速导入是直接写入,复杂迁移必须使用带预演与复核的入口。
- **构建与交付**:使用既有异步 Worker,预览、静态 ZIP、质量报告集中呈现;正式审批和远程发布保留在企业交付中心。
- **网站设置**:站点名称、域名、公司标语、Logo、类型等从设计功能中独立出来。
- **高级工具**:折叠保留原有生产控制台、任务与审批、主题预览以及历史后台 `/classic`,确保不丢功能。
新增页面在 `app/templates/workspace.html`,样式和功能逻辑在 `app/static/workspace.css`、`app/static/workspace.js`。
其他高级页面属于旧界面,后续可逐一沿用本次信息架构迁移;这次未将 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)。
View File
+99
View File
@@ -0,0 +1,99 @@
"""CMS adapter framework: bounded public-site detection and WP REST import.
Private/paid/SSO content is intentionally not accessed by this adapter. Other
platforms should provide a compatible adapter for their authorised APIs.
"""
from dataclasses import dataclass
from urllib.parse import urljoin,urlparse
from bs4 import BeautifulSoup
import httpx
from .core import ensure_public_url, route_path, save_entry
from . import db, safe_http
USER_AGENT='SiteForge/1.0 (+authorized-migration)'
@dataclass(frozen=True)
class SiteAnalysis:
url:str
platform:str
title:str
rest_api:str|None
hints:list[str]
def _get(client,url):
# TCP is pinned to the public IP returned by the security validation,
# while HTTPS still validates against the original hostname.
response=safe_http.fetch(url,max_bytes=4_000_000,timeout=13,redirects=0)
response.raise_for_status()
return response
def analyze(url):
ensure_public_url(url)
with httpx.Client(timeout=httpx.Timeout(13,connect=5),trust_env=False,headers={'User-Agent':USER_AGENT}) as client:
r=_get(client,url)
soup=BeautifulSoup(r.text,'html.parser')
generator=soup.find('meta',attrs={'name':'generator'})
gen=(generator.get('content','') if generator else '').lower()
rest=soup.find('link',rel='https://api.w.org/')
api_url=rest.get('href') if rest else None
platform='WordPress' if 'wordpress' in gen or api_url or 'wp-content/' in r.text[:200000] else ('Drupal' if 'drupal' in gen else 'Joomla' if 'joomla' in gen else 'Unknown / Custom')
hints=[]
if soup.find('meta',attrs={'name':'robots'}):hints.append('检测到 robots meta')
if soup.find('link',rel='canonical'):hints.append('检测到 Canonical')
if soup.find('script',attrs={'type':'application/ld+json'}):hints.append('检测到结构化数据')
if not api_url and platform=='WordPress':api_url=urljoin(url,'/wp-json/')
return SiteAnalysis(url,platform,soup.title.get_text(' ',strip=True)[:200] if soup.title else '',api_url,hints).__dict__
def import_wordpress_rest(site_id,origin,max_items=150,on_conflict='skip'):
"""Import public published posts/pages from a WP REST API, preserving URL paths.
Special Elementor/ACF/Yoast fields require additional adapter support.
"""
if max_items<1 or max_items>1000:raise ValueError('允许导入 1-1000 条公开内容')
if on_conflict not in ('skip','update'):raise ValueError('无效的冲突策略')
root=origin.rstrip('/')+'/'
ensure_public_url(root)
result={'imported':0,'updated':0,'skipped':0,'errors':[]}
with httpx.Client(timeout=httpx.Timeout(18,connect=6),trust_env=False,headers={'User-Agent':USER_AGENT}) as client:
for kind in ('pages','posts'):
batch_size=min(100,max_items)
remaining=max_items-(result['imported']+result['updated']+result['skipped'])
if remaining<=0:break
page=1
while remaining>0 and page<=20:
api=urljoin(root,f'wp-json/wp/v2/{kind}?per_page={batch_size}&page={page}&_embed=1')
try:
response=_get(client,api)
content=response.json()
if not isinstance(content,list):raise ValueError('API 返回内容不是数组')
except safe_http.OutboundError as exc:
if str(exc) in ('Upstream HTTP 400','Upstream HTTP 404'):break
result['errors'].append({'source':kind,'page':page,'error':str(exc)[:150]});break
except (ValueError,httpx.HTTPError) as exc:
result['errors'].append({'source':kind,'page':page,'error':str(exc)[:150]});break
if not content:break
for item in content:
try:
permalink=item.get('link') or ''
parsed=urlparse(permalink)
if not parsed.path:raise ValueError('未返回页面 URL')
if parsed.hostname!=urlparse(root).hostname:raise ValueError('返回了不同域名的链接,需手动映射')
route=route_path(parsed.path)
title=BeautifulSoup(item.get('title',{}).get('rendered',''),'html.parser').get_text(' ',strip=True)
excerpt=BeautifulSoup(item.get('excerpt',{}).get('rendered',''),'html.parser').get_text(' ',strip=True)
seo=item.get('yoast_head_json') or {}
data={'type':'post' if kind=='posts' else 'page', 'route':route,'title':title,
'summary':excerpt,'body':item.get('content',{}).get('rendered',''),
'seo_title':seo.get('title',''),'seo_description':seo.get('description',''),
'canonical':seo.get('canonical',''),'meta':{'wp_id':item.get('id'),'wp_slug':item.get('slug'),'wp_modified':item.get('modified')}}
previous=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route))
if previous and on_conflict=='skip':result['skipped']+=1;continue
save_entry(site_id,data,existing_id=previous['id'] if previous else None,source='wp-rest')
result['updated' if previous else 'imported']+=1
except Exception as exc:
result['errors'].append({'item':item.get('id'),'error':str(exc)[:180]})
remaining-=1
if remaining<=0:break
if len(content)<batch_size:break
page+=1
return result
+126
View File
@@ -0,0 +1,126 @@
"""Declarative mapping from arbitrary tabular source data to SiteForge content.
Never eval mappings, no AI hallucinated fields, preview shows all errors.
"""
import json,re
from . import db
from .core import route_path,slug_of,save_entry
ALIASES={
'title':('title','name','product_name','model_name','名称','标题','产品名称','车型','车型名称','商品名称'),
'route':('route','url','path','permalink','链接','路径','旧站链接','原网址'),
'summary':('summary','description','excerpt','简介','摘要','产品简介'),
'body':('body','content','html','正文','内容','详细介绍'),
'cover':('cover','image','thumbnail','featured_image','图片','封面','主图'),
'brand':('brand','manufacturer','品牌','厂商'),
'category':('category','分类','类目'),
'seo_title':('seo_title','meta_title','SEO标题','seo标题'),
'seo_description':('seo_description','meta_description','SEO描述','seo描述'),
'canonical':('canonical','规范网址'),
'model':('model','model_name','车型型号','型号','规格型号'),
'energy':('energy','energy_type','fuel_type','动力形式','能源类型','燃料类型'),
'range':('range','battery_range','纯电续航','续航','续航里程'),
'year':('year','model_year','年款','年份'),
'sku':('sku','product_sku','产品编号','货号'),
'gallery':('gallery','images','image_gallery','产品图片','图库','详情图片'),
'price':('price','msrp','售价','价格'),
'specs':('specs','attributes','parameters','技术参数','产品参数'),
}
SYSTEM_FIELDS={'title','route','summary','body','cover','seo_title','seo_description','canonical'}
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS mapping_profiles (
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,content_type TEXT NOT NULL, rules_json TEXT NOT NULL,
created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS import_batches (
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
profile_id TEXT, source TEXT NOT NULL, result_json TEXT NOT NULL,
created_at TEXT NOT NULL);
''')
def suggest(columns):
clean=lambda x:re.sub(r'[\s\-_]+','',str(x).casefold())
src={clean(col):col for col in columns}
mapping={}
for target,aliases in ALIASES.items():
for alias in aliases:
if clean(alias) in src:
mapping[target]=src[clean(alias)];break
return mapping
def validate_rules(rules,columns):
if not isinstance(rules,dict) or len(rules)>100:raise ValueError('字段映射规则不合法')
for dest,src in rules.items():
if not re.fullmatch(r'[\w\-]{1,60}',dest,re.UNICODE):raise ValueError('目标字段名称不合法')
if not isinstance(src,str) or src not in columns:raise ValueError(f'来源字段不存在: {src}')
if 'title' not in rules:raise ValueError('必须映射标题字段')
def transform(rows,rules,kind='product',prefix=None):
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()
validate_rules(rules,columns)
seen=set();out=[];errors=[]
for index,row in enumerate(rows):
vals={}
for target,src in rules.items():
raw=row.get(src)
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','')
if not title:
errors.append({'row':index+1,'error':'标题为空'});continue
route=vals.get('route','')
if not route:
route=f'/{prefix or kind}/{slug_of(title)}/'
try:route=route_path(route)
except ValueError as exc:
errors.append({'row':index+1,'error':f'路径非法:{exc}'});continue
if route in seen:
errors.append({'row':index+1,'error':'导入文件内 URL 重复:'+route});continue
seen.add(route)
fields={k:v for k,v in vals.items() if k not in SYSTEM_FIELDS}
if fields.get('gallery'):
try:
values=json.loads(fields['gallery'])
if isinstance(values,list):fields['gallery']=[str(x.get('url','') if isinstance(x,dict) else x) for x in values if x]
except ValueError:
fields['gallery']=[x.strip() for x in fields['gallery'].split(';') if x.strip()][:40]
out.append({'type':kind,'title':title,'route':route,'summary':vals.get('summary',''),
'body':vals.get('body',''),'cover':vals.get('cover',''),
'seo_title':vals.get('seo_title',''),'seo_description':vals.get('seo_description',''),
'canonical':vals.get('canonical',''),'meta':{'fields':fields}})
return out,errors
def dry_run(site_id,rows,rules,kind='product',prefix=None):
if len(rows)>1000:raise ValueError('单批最多1000条内容')
items,errors=transform(rows,rules,kind,prefix)
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]
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 [],
'sample':items[:10], 'items':items}
def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None):
if on_conflict not in ('skip','replace'):raise ValueError('冲突策略只能为 skip 或 replace')
report=dry_run(site_id,rows,rules,kind,prefix)
created=0;updated=0;skipped=0;errors=list(report['errors'])
for idx,item in enumerate(report['items'],1):
try:
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
save_entry(site_id,item,existing_id=existing['id'] if existing else None,source='mapped-import')
if existing:updated+=1
else:created+=1
except Exception as exc:
errors.append({'row':idx,'error':str(exc)[:250]})
summary={'created':created,'updated':updated,'skipped':skipped,'errors':errors,'mapped':report['mapped']}
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()))
return summary
+35
View File
@@ -0,0 +1,35 @@
"""Consistent SQLite + asset backup. Run: docker compose exec siteforge python -m app.backup"""
import os
import sqlite3
import tempfile
import zipfile
from pathlib import Path
from datetime import datetime, timezone
from . import db
def backup():
db.init()
storage=Path(db.DATA).resolve()
path=storage/'backups'
path.mkdir(exist_ok=True,parents=True)
name='siteforge-backup-'+datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')+'.zip'
dest=path/name
# SQLite online backup is consistent even when the DB uses WAL mode.
with tempfile.TemporaryDirectory() as tmp:
snapshot=Path(tmp)/'siteforge.sqlite3'
with sqlite3.connect(db.DB_FILE) as source, sqlite3.connect(snapshot) as target:
source.backup(target)
with zipfile.ZipFile(dest,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=5) as output:
output.write(snapshot,'siteforge.sqlite3')
for subdir in ('media','legacy','builds','archives'):
root=storage/subdir
if not root.is_dir():continue
for file in root.rglob('*'):
if file.is_file() and not file.is_symlink():
output.write(file, file.relative_to(storage).as_posix())
print(f'备份成功: {dest}')
print('注意:备份文件仍在 Docker 数据卷内。请使用 docker compose cp 或其他方式再复制到外部设备。')
return dest
if __name__=='__main__':backup()
+344
View File
@@ -0,0 +1,344 @@
"""Declarative theme/page blueprints, editor schema, and safe content bindings.
No eval and no arbitrary template code can be submitted by visitors.
"""
import copy
import json
import re
from html import escape
from urllib.parse import urlparse
from .core import sanitize, route_path
BLOCKS = {
'hero': {'name':'封面横幅','icon':'✦','fields':['title','subtitle','description','image','button','href','align'], 'defaults':{'title':'让品牌从这里出发','subtitle':'GLOBAL EXPERIENCE','description':'在这里呈现企业最重要的价值。','button':'探索产品','href':'/product/','align':'left'}},
'richtext':{'name':'富文本','icon':'≡','fields':['title','body'],'defaults':{'title':'关于我们','body':'<p>在这里输入图文内容。</p>'}},
'features':{'name':'核心优势','icon':'▦','fields':['title','items'],'defaults':{'title':'我们的优势','items':[{'title':'专业服务','text':'从咨询到交付'},{'title':'品质保障','text':'严格的质量流程'},{'title':'全球网络','text':'连接不同市场'}]}},
'stats':{'name':'数字指标','icon':'#','fields':['title','items'],'defaults':{'title':'我们的实力','items':[{'value':'50,000+','label':'服务案例'},{'value':'70+','label':'市场覆盖'},{'value':'30+','label':'合作伙伴'}]}},
'collection':{'name':'动态内容列表','icon':'▣','fields':['title','subtitle','source','limit','columns','button','href'],'defaults':{'title':'精选产品','subtitle':'为客户提供适合的解决方案','source':'product','limit':6,'columns':3,'button':'查看全部','href':'/product/'}},
'image_text':{'name':'图文组合','icon':'◧','fields':['title','subtitle','description','image','image_side','button','href'],'defaults':{'title':'从采购到交付','subtitle':'OUR APPROACH','description':'展示业务流程和服务理念。','image':'','image_side':'right','button':'了解更多','href':'/about/'}},
'gallery':{'name':'图片画廊','icon':'▧','fields':['title','images','columns'],'defaults':{'title':'精彩瞬间','images':[],'columns':3}},
'faq':{'name':'常见问题','icon':'?','fields':['title','items'],'defaults':{'title':'常见问题','items':[{'question':'如何开始合作?','answer':'您可以通过联系我们页面提交需求。'}]}},
'cta':{'name':'行动号召','icon':'↗','fields':['title','subtitle','button','href'],'defaults':{'title':'开启新的合作','subtitle':'告诉我们您的需求,我们将提供适合的方案。','button':'联系我们','href':'/contact/'}},
'attributes':{'name':'参数信息表','icon':'▤','fields':['title'],'defaults':{'title':'产品参数'}},
'layout':{'name':'多列自由布局','icon':'▥','fields':['columns','gap','tablet_columns','mobile_columns'], 'defaults':{'columns':[{'width':1,'blocks':[]},{'width':1,'blocks':[]}],'gap':24,'tablet_columns':2,'mobile_columns':1}},
'spacer':{'name':'上下留白','icon':'↕','fields':['height'],'defaults':{'height':48}},
'carousel':{'name':'图片轮播','icon':'▧','fields':['title','items'],'defaults':{'title':'精选展示','items':[{'image':'','alt':'展示图片','caption':'图片说明'}]}},
'video':{'name':'视频展示','icon':'▷','fields':['title','url','description'],'defaults':{'title':'企业影片','url':'','description':''}},
'tabs':{'name':'标签内容','icon':'☷','fields':['title','items'],'defaults':{'title':'详细信息','items':[{'label':'特点','text':'在这里介绍产品特点'},{'label':'服务','text':'在这里介绍服务'}]}},
'pricing':{'name':'服务方案','icon':'$','fields':['title','items'],'defaults':{'title':'服务方案','items':[{'name':'标准方案','price':'联系我们','features':['专业咨询','售后服务'],'href':'/contact/'}]}},
'map':{'name':'位置导航','icon':'⌖','fields':['title','address','url'],'defaults':{'title':'欢迎来访','address':'请填写实际办公地址','url':''}},
'form_link':{'name':'询盘表单入口','icon':'✉','fields':['title','description','button','href'],'defaults':{'title':'提交询盘','description':'欢迎联系我们获取报价和资料','button':'联系销售','href':'/contact/'}},
}
# Property contracts shared with the no-code editor. Unknown executable types
# are not supported; all values are still verified by validate_blocks.
BLOCK_PROPERTY_SCHEMA={key:{
'type':'object','additionalProperties':False,
'properties':{field:{'type':('array' if field in ('items','images','columns') else
'integer' if field in ('height','limit','gap','tablet_columns','mobile_columns') else
'string')} for field in spec['fields']},
'supported_devices':['desktop','tablet','mobile'],
'allows_arbitrary_code':False,
} for key,spec in BLOCKS.items()}
BINDABLE = ['site.name','site.tagline','site.logo','page.title','page.summary','page.cover','page.body']
CONTENT_MODELS={
'product': [{'name':'brand','label':'品牌','type':'text'},{'name':'model','label':'型号','type':'text'},{'name':'category','label':'分类','type':'text'},{'name':'specs','label':'技术参数','type':'textarea'}],
'vehicle':[{'name':'brand','label':'品牌','type':'text'},{'name':'energy','label':'能源类型','type':'text'},{'name':'range','label':'续航里程','type':'text'}],
'post':[{'name':'author','label':'作者','type':'text'},{'name':'publish_date','label':'发布日期','type':'date'}],
'case':[{'name':'location','label':'项目地点','type':'text'},{'name':'industry','label':'行业','type':'text'}],
'brand':[{'name':'country','label':'所属地区','type':'text'},{'name':'website','label':'官方网站','type':'text'}]
}
PAGE_TEMPLATES={
'home':{'name':'首页','description':'全站形象与动态内容展示'},
'about':{'name':'关于我们','description':'企业介绍、优势与里程碑'},
'contact':{'name':'联系我们','description':'联系方式与业务咨询'},
'listing':{'name':'产品或文章列表','description':'内容查询、列表与分类展示'},
'product':{'name':'产品详情','description':'产品简介、图片、技术参数'},
'article':{'name':'新闻文章','description':'正文、作者与相关内容'},
'brand':{'name':'品牌详情','description':'品牌介绍、品牌产品'},
'standard':{'name':'标准内容页','description':'自由组合的自定义页面'},
'notfound':{'name':'404','description':'访问异常页面'},
'category':{'name':'分类详情','description':'按具体分类查看内容'},
'case':{'name':'案例详情','description':'项目介绍、项目图片和成果'},
'service':{'name':'服务详情','description':'业务服务介绍'},
'faq':{'name':'常见问题','description':'问题与回答'},
}
def sample_blocks(kind):
presets={
'home':[('hero',{}),('stats',{}),('collection',{}),('features',{}),('cta',{})],
'about':[('image_text',{'title':'认识我们'}),('stats',{}),('features',{})],
'contact':[('richtext',{'title':'联系我们','body':'<p>请填写企业联系方式与地址。</p>'}),('cta',{'title':'欢迎联系'})],
'listing':[('collection',{'title':'所有产品','limit':24})],
'product':[('image_text',{'title':'产品介绍','description':'{{page.summary}}','image':'{{page.cover}}'}),('attributes',{'title':'技术参数'}),('richtext',{'title':'产品详情','body':'{{page.body}}'}),('cta',{})],
'article':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'})],
'brand':[('image_text',{'title':'{{page.title}}','description':'{{page.summary}}','image':'{{page.cover}}'}),('collection',{'title':'品牌产品','source':'product'})],
'standard':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'})],
'notfound':[('cta',{'title':'页面暂时无法访问','href':'/','button':'返回首页'})],
'category':[('collection',{'source':'product','limit':24})],
'case':[('image_text',{'title':'{{page.title}}','image':'{{page.cover}}','description':'{{page.summary}}'}),('richtext',{'title':'项目介绍','body':'{{page.body}}'})],
'service':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'}),('features',{})],
'faq':[('faq',{})]
}
return [new_block(k,overrides) for k,overrides in presets.get(kind,presets['standard'])]
def new_block(kind, overrides=None):
import uuid
if kind not in BLOCKS:raise ValueError('未知区块类型: '+str(kind))
return {'id':uuid.uuid4().hex[:10], 'type':kind, 'visible':True, **copy.deepcopy(BLOCKS[kind]['defaults']), **(overrides or {})}
def validate_blocks(blocks):
"""Validate nested no-code rows; cap depth, blocks and payload size.
Children must always be safe declared block types. No arbitrary HTML or JS.
"""
found=set()
total=[0]
def normalize(rows,depth=0):
if not isinstance(rows,list) or len(rows)>60:raise ValueError('区块必须是数组,最多 60 个')
clean=[]
for block in rows:
if not isinstance(block,dict) or block.get('type') not in BLOCKS:raise ValueError('包含不支持的区块类型')
import uuid
total[0]+=1
if total[0]>120:raise ValueError('页面最多 120 个区块(含列内组件)')
ident=str(block.get('id') or uuid.uuid4().hex[:10]).strip()
if not re.fullmatch('[a-zA-Z0-9_-]{4,50}',ident) or ident in found:raise ValueError('区块 ID 缺失或重复')
found.add(ident)
spec=BLOCKS[block['type']]
allowed=set(spec['fields'])|{'id','type','visible','style'}
normalized={key:val for key,val in block.items() if key in allowed}
normalized['id']=ident
if 'style' in normalized:
style=normalized['style']
if not isinstance(style,dict):raise ValueError('样式必须为对象')
normalized['style']={}
for key,val in style.items():
if key in ('background','text_color'):
if not isinstance(val,str) or not re.fullmatch(r'#[0-9a-fA-F]{6}',val):raise ValueError('颜色必须为六位十六进制')
normalized['style'][key]=val
elif key in ('padding','tablet_padding','mobile_padding'):
if type(val) is not int or not 0<=val<=120:raise ValueError('区块间距必须是0-120整数')
normalized['style'][key]=val
elif key in ('hide_mobile','hide_tablet'):
if type(val) is not bool:raise ValueError('设备显隐必须是布尔值')
normalized['style'][key]=val
if block['type']=='layout':
if depth>=2:raise ValueError('多列容器最多嵌套两层')
cols=block.get('columns')
if not isinstance(cols,list) or not 1<=len(cols)<=4:raise ValueError('一行需要1-4列')
tablet_cols=block.get('tablet_columns',2)
mobile_cols=block.get('mobile_columns',1)
if type(tablet_cols) is not int or not 1<=tablet_cols<=3:raise ValueError('平板最多支持 1-3 列')
if type(mobile_cols) is not int or not 1<=mobile_cols<=2:raise ValueError('手机最多支持 1-2 列')
sanitized=[]
for col in cols:
if not isinstance(col,dict):raise ValueError('列配置无效')
width=col.get('width',1)
if type(width) not in (int,float) or not .5<=width<=4:raise ValueError('列宽必须在0.5-4之间')
responsive={}
for device,limit in [('tablet',tablet_cols),('mobile',mobile_cols)]:
order=col.get(device+'_order',len(sanitized)+1)
span=col.get(device+'_span',1)
hidden=col.get('hide_'+device,False)
if type(order) is not int or not 0<=order<=20:raise ValueError('列顺序必须在0-20之间')
if type(span) is not int or not 1<=span<=limit:raise ValueError('设备列跨度超过当前列数')
if type(hidden) is not bool:raise ValueError('列显隐必须是布尔值')
responsive.update({device+'_order':order,device+'_span':span,'hide_'+device:hidden})
sanitized.append({'width':float(width),'blocks':normalize(col.get('blocks',[]),depth+1),**responsive})
normalized['columns']=sanitized
normalized['gap']=max(0,min(64,int(block.get('gap',24))))
normalized['tablet_columns']=tablet_cols
normalized['mobile_columns']=mobile_cols
if block['type'] in ('carousel','tabs','pricing'):
items=block.get('items',[])
if not isinstance(items,list) or len(items)>16:raise ValueError('轮播、标签和方案最多16条')
if any(not isinstance(x,dict) for x in items):raise ValueError('组件条目必须是对象')
if block['type']=='pricing':
if any(not isinstance(x.get('features',[]),list) or len(x.get('features',[]))>12 for x in items):raise ValueError('每个方案最多12项')
normalized['items']=items
if block['type']=='video' and not isinstance(block.get('url',''),str):raise ValueError('视频链接无效')
if len(json.dumps(normalized,ensure_ascii=False))>100_000:raise ValueError('单一区块数据过大')
clean.append(normalized)
return clean
return normalize(blocks)
def resolve_text(value, site, page):
if not isinstance(value,str):return str(value or '')
def repl(m):
group,name=m.group(1),m.group(2)
if group=='site' and name in ('name','tagline','logo'): return str(site.get(name) or '')
if group=='page' and name in ('title','summary','cover','body'):return str(page.get(name) or '')
return ''
return re.sub(r'\{\{\s*(site|page)\.([a-z_]+)\s*\}\}', repl,value)
def safe_media(raw):
value=(raw or '').strip()
p=urlparse(value)
if p.scheme and p.scheme not in ('http','https'):return ''
if value.startswith('//') or any(c in value for c in ('"','<','>','\n','\r')):return ''
# Uploaded media and static assets must be root-relative: relative URLs
# resolve against nested /products/.../ routes and otherwise return 404.
if value.startswith(('media/','assets/')):value='/'+value
return escape(value,quote=True)
def safe_href(raw):
value=(raw or '').strip()
p=urlparse(value)
if p.scheme and p.scheme not in ('http','https','mailto','tel'):return '/'
if value.startswith('//') or any(c in value for c in ('"','<','>','\n','\r')):return '/'
if not p.scheme:
try:return escape(route_path(value),quote=True)
except ValueError:return '/'
return escape(value,quote=True)
def safe_video_url(value):
"""Only allow HTTPS embed origins with a narrowly whitelisted host."""
from urllib.parse import urlsplit,quote
p=urlsplit(str(value or '').strip())
try:
if p.scheme!='https' or p.username or p.password or p.port not in (None,443):return ''
except ValueError:return ''
host=(p.hostname or '').lower()
path=p.path.strip('/')
import re as _re
if host in ('www.youtube.com','youtube.com','www.youtube-nocookie.com','youtube-nocookie.com'):
m=_re.fullmatch(r'(?:embed/|watch/)?([A-Za-z0-9_-]{11})',path)
if m:return 'https://www.youtube-nocookie.com/embed/'+m.group(1)
from urllib.parse import parse_qs
if path=='watch':
val=parse_qs(p.query).get('v',[''])[0]
if _re.fullmatch('[A-Za-z0-9_-]{11}',val):return 'https://www.youtube-nocookie.com/embed/'+val
if host in ('player.vimeo.com','vimeo.com','www.vimeo.com'):
m=_re.fullmatch(r'(?:video/)?([0-9]{1,14})',path)
if m:return 'https://player.vimeo.com/video/'+m.group(1)
return ''
def safe_map_url(value):
from urllib.parse import urlsplit
p=urlsplit(str(value or '').strip())
try:
if p.scheme!='https' or p.hostname not in ('www.google.com','maps.google.com','www.openstreetmap.org','openstreetmap.org') or p.username or p.password or p.port not in (None,443):return ''
except ValueError:return ''
return escape(p.geturl(),quote=True)
def render_blocks(blocks, site, page, entries):
"""One trusted rendering implementation for public HTML and editor previews."""
parts=[]
for block in validate_blocks(blocks):
if block.get('visible') is False:continue
typ=block['type']; resolve=lambda key,default='':resolve_text(block.get(key,default),site,page)
title=escape(resolve('title')); subtitle=escape(resolve('subtitle'));desc=escape(resolve('description'))
raw_style=block.get('style') or {}; s=[]
for key,var in [('background','--sf-bg'),('text_color','--sf-fg')]:
val=str(raw_style.get(key,'')).strip()
if re.fullmatch(r'#[a-fA-F0-9]{6}',val):s.append(f'{var}:{val}')
if 'padding' in raw_style:
try:pad=max(0,min(120,int(raw_style['padding'])));s.append(f'--sf-pad:{pad}px')
except (ValueError,TypeError):pass
style=' style="'+escape(';'.join(s),quote=True)+'"' if s else ''
start=f'<section class="sf-section sf-{typ}" data-block="{escape(block["id"])}"{style}><div class="sf-container">'
end='</div></section>'
if typ=='hero':
image=safe_media(resolve('image'))
art=f'<div class="sf-hero-img"><img src="{image}" alt="{title}" loading="lazy"></div>' if image else '<div class="sf-hero-img sf-hero-abstract"><span>✧</span></div>'
parts.append(start+'<div class="sf-hero-grid"><div class="sf-hero-copy"><span class="sf-kicker">'+subtitle+'</span><h1>'+title+'</h1><p>'+desc+'</p><a class="sf-button" href="'+safe_href(resolve('href','/product/'))+'">'+escape(resolve('button','探索更多'))+' <span>↗</span></a></div>'+art+'</div>'+end)
elif typ=='richtext':
body=sanitize(resolve('body'))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-prose">{body}</div>'+end)
elif typ=='features':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'<article class="sf-feature"><span>{i+1:02}</span><h3>{escape(str(item.get("title","")))}</h3><p>{escape(str(item.get("text","")))}</p></article>' for i,item in enumerate(items[:12]) if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-features">{cards}</div>'+end)
elif typ=='stats':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'<div><strong>{escape(str(item.get("value","")))}</strong><span>{escape(str(item.get("label","")))}</span></div>' for item in items[:12] if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-stats">{cards}</div>'+end)
elif typ=='collection':
source=str(block.get('source') or 'product');source=source if source in ('product','post','page','service','case','brand','vehicle','other','all') else 'product'
bound=[r for r in entries if r.get('route')!=page.get('route') and (source=='all' or r.get('type')==source)]
if page.get('type')=='brand' and source in ('product','vehicle'):
def brand_of(record):
try:return str((json.loads(record.get('meta_json') or '{}').get('fields') or {}).get('brand','')).casefold().strip()
except (ValueError,TypeError,AttributeError):return ''
bound=[r for r in bound if brand_of(r)==str(page.get('title','')).casefold().strip()]
count=max(1,min(48,int(block.get('limit') or 6)));cols=max(2,min(4,int(block.get('columns') or 3)))
cards=''.join(f'<a class="sf-item" data-category="{escape(str((json.loads(r.get("meta_json") or "{}").get("fields") or {}).get("category", "")),quote=True)}" data-brand="{escape(str((json.loads(r.get("meta_json") or "{}").get("fields") or {}).get("brand", "")),quote=True)}" href="{safe_href(r.get("route") or "/")}"><div class="sf-item-img">'+(f'<img src="{safe_media(r.get("cover"))}" alt="{escape(r.get("title",""),quote=True)}" loading="lazy">' if safe_media(r.get('cover')) else '<div class="sf-placeholder">'+escape(r.get('title','')[:2])+'</div>')+f'</div><div class="sf-item-copy"><small>{escape(r.get("type",""))}</small><h3>{escape(r.get("title",""))}</h3><p>{escape((r.get("summary") or "")[:140])}</p><span>查看详情 →</span></div></a>' for r in bound[:count])
parts.append(start+f'<div class="sf-section-head"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2></div><a href="{safe_href(resolve("href","/product/"))}">{escape(resolve("button","查看全部"))} →</a></div><div class="sf-collection" style="--sf-cols:{cols}">{cards if cards else "<p class=\"sf-muted\">当前暂无相关内容,请导入或添加数据。</p>"}</div>'+end)
elif typ=='image_text':
im=safe_media(resolve('image')); img=f'<img src="{im}" alt="{title}" loading="lazy">' if im else '<div class="sf-placeholder sf-large">✧</div>'
side=' sf-reverse' if block.get('image_side')=='left' else ''
parts.append(start+f'<div class="sf-image-text{side}"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2><p>{desc}</p><a class="sf-link" href="{safe_href(resolve("href","/about/"))}">{escape(resolve("button","了解更多"))} →</a></div><div class="sf-image-side">{img}</div></div>'+end)
elif typ=='attributes':
raw=page.get('meta_json') or '{}'
try: meta=json.loads(raw) if isinstance(raw,str) else raw
except (ValueError,TypeError):meta={}
props=(meta.get('fields') if isinstance(meta.get('fields'),dict) else meta) or {}
specs=(site.get('schemas') or {}).get(page.get('type'),[])
candidates=[(field['name'],field.get('label') or field['name']) for field in specs]
if not candidates:
candidates=[(k,k.replace('_',' ').title()) for k in props.keys() if k not in ('blocks','page_template','wp_metadata','wp_post_id','wp_type','fields') and isinstance(props[k],(str,int,float,bool))]
pairs=''.join(f'<div class="sf-attribute"><span>{escape(str(label))}</span><strong>{escape(str(props[key]))}</strong></div>' for key,label in candidates if props.get(key) not in (None,''))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-attributes">{pairs if pairs else "<p class=\"sf-muted\">参数尚未填写</p>"}</div>'+end)
elif typ=='gallery':
pics=block.get('images') if isinstance(block.get('images'),list) else []
imgs=''.join(f'<img src="{safe_media(str(img.get("url") or ""))}" alt="{escape(str(img.get("alt") or ""),quote=True)}" loading="lazy">' for img in pics[:24] if isinstance(img,dict) and safe_media(str(img.get('url') or '')))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-gallery">{imgs or "<p>可在编辑器中添加图片</p>"}</div>'+end)
elif typ=='faq':
items=block.get('items') if isinstance(block.get('items'),list) else []
body=''.join(f'<details><summary>{escape(str(item.get("question","")))}</summary><p>{escape(str(item.get("answer","")))}</p></details>' for item in items[:20] if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-faq">{body}</div>'+end)
elif typ=='cta':
parts.append(start+f'<div class="sf-cta-wrap"><div><span class="sf-kicker">LET’S CONNECT</span><h2>{title}</h2><p>{subtitle}</p></div><a class="sf-button" href="{safe_href(resolve("href","/contact/"))}">{escape(resolve("button","联系我们"))} ↗</a></div>'+end)
elif typ=='carousel':
items=block.get('items',[])
cards=[]
for item in items[:16]:
url=safe_media(str(item.get('image') or ''))
if not url:continue
alt=escape(str(item.get('alt') or '展示图片'),quote=True)
caption=escape(str(item.get('caption') or ''))
cards.append(f'<figure class="sf-slide"><img src="{url}" alt="{alt}" loading="lazy"><figcaption>{caption}</figcaption></figure>')
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-slides" role="region" aria-label="{title}" tabindex="0">'+''.join(cards)+'</div>'+end)
elif typ=='video':
url=safe_video_url(resolve('url'))
embed=f'<iframe title="{title}" src="{url}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="fullscreen; picture-in-picture" allowfullscreen></iframe>' if url else '<p class="sf-muted">请填写允许的 YouTube/Vimeo HTTPS 视频链接</p>'
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-video">{embed}</div><p>{desc}</p>'+end)
elif typ=='tabs':
items=block.get('items',[])
panels=''.join('<details class="sf-tab"'+(' open' if i==0 else '')+'><summary>'+escape(str(it.get('label','标签')))+'</summary><p>'+escape(str(it.get('text','')))+'</p></details>' for i,it in enumerate(items[:16]))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-tab-list">{panels}</div>'+end)
elif typ=='pricing':
cards=''
for item in block.get('items',[])[:16]:
features=''.join('<li>'+escape(str(feat))+'</li>' for feat in item.get('features',[])[:12])
cards+='<article class="sf-price"><h3>'+escape(str(item.get('name','方案')))+'</h3><strong>'+escape(str(item.get('price','联系报价')))+'</strong><ul>'+features+'</ul><a class="sf-button" href="'+safe_href(str(item.get('href','/contact/')))+'">了解详情</a></article>'
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-prices">{cards}</div>'+end)
elif typ=='map':
link=safe_map_url(resolve('url'))
action=f'<a class="sf-button" href="{link}" rel="noopener noreferrer" target="_blank">打开地图 ↗</a>' if link else ''
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><address class="sf-map-address">{escape(resolve("address"))}</address>{action}'+end)
elif typ=='form_link':
parts.append(start+'<div class="sf-cta-wrap"><div><h2>'+title+'</h2><p>'+desc+'</p></div><a class="sf-button" href="'+safe_href(resolve('href','/contact/'))+'">'+escape(resolve('button','提交询盘'))+' →</a></div>'+end)
elif typ=='layout':
columns=block['columns'];gap=block.get('gap',24)
weights=' '.join(f'{col["width"]}fr' for col in columns)
html_cols=[]
for ci,col in enumerate(columns):
html_cols.append(f'<div class="sf-layout-column" data-col="{ci+1}">'+render_blocks(col['blocks'],site,page,entries)+'</div>')
parts.append(start+f'<div class="sf-layout-grid" data-layout="{escape(block["id"],quote=True)}" style="--sf-layout-cols:{escape(weights,quote=True)};--sf-layout-gap:{gap}px">'+''.join(html_cols)+'</div>'+end)
elif typ=='spacer':
try:height=max(8,min(160,int(block.get('height',48))))
except (ValueError,TypeError):height=48
parts.append(f'<div class="sf-spacer" style="height:{height}px"></div>')
return ''.join(parts)
def theme_blocks(theme,kind,version=None):
from pathlib import Path
from . import theme_store
path=theme_store.resolve(theme,version)/'page-presets.json'
if not path.is_file():return sample_blocks(kind)
data=json.loads(path.read_text(encoding='utf-8'))
# IDs must be unique per created page.
return [new_block(b['type'],{k:v for k,v in b.items() if k not in ('type','id')}) for b in data.get(kind, data['standard'])]
+469
View File
@@ -0,0 +1,469 @@
"""Static site assembly, audit, ZIP export, immutable build versions."""
import json
import re
import shutil
import hashlib
import mimetypes
import zipfile
from pathlib import Path
from urllib.parse import urlparse, urljoin
from html import escape
from xml.sax.saxutils import escape as xml_escape
from bs4 import BeautifulSoup
import httpx
from . import safe_http
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87
from .core import uid, route_path, ensure_public_url, sanitize, THEMES
ROOT=Path(__file__).resolve().parent.parent
THEME_DIR=ROOT/'themes'
ENV=Environment(loader=FileSystemLoader(str(THEME_DIR)),autoescape=select_autoescape(['html','xml']))
TYPE_NAMES={'page':'页面','post':'新闻','product':'产品','service':'服务','case':'案例','brand':'品牌','vehicle':'车型','other':'内容'}
def output_path(dist,route):
path=route_path(route)
if path=='/':return dist/'index.html'
clean=path.lstrip('/')
# Treat explicit .html/.htm as files and other endpoints as directory/index.
if clean.lower().endswith(('.html','.htm')):
out=dist/clean
else:
out=dist/clean/'index.html'
target=out.resolve()
if not target.is_relative_to(dist.resolve()):raise ValueError('非法生成目标目录')
return out
def link(path):
return (path or '/').lstrip('/') or './'
def public_url(site,route):
origin=site['domain'].strip().rstrip('/')
if not origin.startswith(('https://','http://')):return ''
return origin+route
def render_blocks(blocks):
parts=[]
if not isinstance(blocks,list):return ''
for block in blocks[:30]:
if not isinstance(block,dict):continue
typ=str(block.get('type','text'))
title=escape(str(block.get('title','')))
sub=escape(str(block.get('subtitle','')))
body=sanitize(str(block.get('body','')))
if typ=='hero':
parts.append(f'<section class="block-hero"><div class="container"><span class="eyebrow">{sub}</span><h1>{title}</h1><p>{escape(str(block.get("description","")))}</p></div></section>')
elif typ=='features':
features=block.get('items',[])
cards=''.join(f'<div class="feature"><div class="feature-mark">0{i+1}</div><h3>{escape(str(x.get("title","")))}</h3><p>{escape(str(x.get("text","")))}</p></div>' for i,x in enumerate(features[:12]) if isinstance(x,dict))
parts.append(f'<section class="section"><div class="container"><div class="section-heading"><h2>{title}</h2></div><div class="feature-grid">{cards}</div></div></section>')
elif typ=='text':
parts.append(f'<section class="section"><div class="container prose"><h2>{title}</h2>{body}</div></section>')
elif typ=='stats':
vals=''.join(f'<div class="stat"><strong>{escape(str(x.get("value","")))}</strong><span>{escape(str(x.get("label","")))}</span></div>' for x in block.get('items',[])[:8] if isinstance(x,dict))
parts.append(f'<section class="section stats-section"><div class="container stats">{vals}</div></section>')
elif typ=='cta':
target=block.get('href','/contact/')
try:target=link(route_path(target))
except ValueError:target='./'
parts.append(f'<section class="section"><div class="container cta"><h2>{title}</h2><p>{sub}</p><a class="btn" href="{escape(target,quote=True)}">{escape(str(block.get("button","Contact us")))}</a></div></section>')
return ''.join(parts)
def url_fix(link_value,origin_host,known_routes):
"""Convert imported internal absolute URLs to static-site-relative links."""
if not link_value:return link_value
parsed=urlparse(link_value)
if parsed.scheme in ('mailto','tel'):return link_value
if parsed.scheme in ('https','http') and parsed.hostname==origin_host and parsed.path in known_routes:
return link(parsed.path) + (('?' + parsed.query) if parsed.query else '')
if not parsed.scheme and link_value.startswith('/') and parsed.path in known_routes:
return link(parsed.path)
return link_value
def rewrite_internal_links(body,origin_host,known_routes):
soup=BeautifulSoup(body or '', 'html.parser')
for a in soup.find_all('a',href=True):
a['href']=url_fix(a['href'],origin_host,known_routes)
return str(soup)
def copy_media(site_id,dist):
src=db.DATA/'media'/site_id
target=dist/'media'
if src.exists():shutil.copytree(src,target,dirs_exist_ok=True)
legacy=db.DATA/'legacy'/site_id
if legacy.exists():shutil.copytree(legacy,dist,dirs_exist_ok=True)
def normalize_local_images(html):
soup=BeautifulSoup(html or '', 'html.parser')
for tag in soup.find_all('img',src=True):
src=tag['src']
if src.startswith('media/'):
tag['src']='/'+src
# Keep /media/... absolute to the deployed site root, including in nested pages.
return str(soup)
def mirror_images(html,dist,origin_host,client,cache,warnings):
soup=BeautifulSoup(html or '', 'html.parser')
for tag in soup.find_all('img',src=True):
tag['src']=mirror_image_url(tag['src'],dist,origin_host,client,cache,warnings)
return str(soup)
def mirror_image_url(raw,dist,origin_host,client,cache,warnings):
if not raw:return raw
if raw.startswith('/media/'):return raw # already localized, root-relative static asset
parsed=urlparse(raw)
if parsed.scheme not in ('http','https'):
if raw.startswith('/'):
return raw.lstrip('/')
return raw
if raw in cache:return cache[raw]
try:
ensure_public_url(raw)
response=safe_http.fetch(raw,max_bytes=8_000_000,timeout=10,redirects=0)
response.raise_for_status()
mime=response.headers.get('content-type','').split(';')[0].lower()
allowed={'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','image/svg+xml':'.svg','image/avif':'.avif'}
if mime not in allowed:raise ValueError(f'图片类型不支持:{mime}')
buf=response.content
# SVG can have scripts; disallow external SVG for safety.
if mime=='image/svg+xml':raise ValueError('SVG 外部资源需人工审查')
orig=parsed.path.lstrip('/')
# Original path is preserved for same-domain assets with safe extensions.
if parsed.hostname==origin_host and orig and re.fullmatch(r'[\w\-./%]+',orig) and not any(p in ('.','..') for p in orig.split('/')) and Path(orig).suffix.lower() in allowed.values():
rel=orig
else:
rel='media/remote-'+hashlib.sha256(raw.encode()).hexdigest()[:16]+allowed[mime]
dst=dist/rel
if not dst.resolve().is_relative_to(dist.resolve()):raise ValueError('图片路径越界')
dst.parent.mkdir(parents=True,exist_ok=True)
dst.write_bytes(buf)
cache[raw]=rel
return rel
except Exception as exc:
warnings.append(f'外部图片未本地化: {raw[:100]} ({str(exc)[:120]})')
return raw
def emit_nginx(dist,redirects,slashless):
# Include snippets to be inserted inside the real site's `server {}` block.
lines=['# SiteForge 生成:将本文件内容合并到 Nginx server {} 内','index index.html;','error_page 404 /404.html;',
'location / { try_files $uri $uri/ =404; }']
for old,new,code in redirects:
lines.append(f'location = {old} {{ return {int(code)} {new}; }}')
for route in slashless:
lines.append(f'location = {route} {{ try_files {route}/index.html =404; }}')
(dist/'nginx-snippet.conf').write_text('\n'.join(lines)+'\n',encoding='utf-8')
def build(site_id,mirror=False):
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not site:raise ValueError('网站不存在')
if site['theme'] not in theme_store.ids():raise ValueError('无效的主题')
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,))
settings=json.loads(site['settings_json'] or '{}')
site['schemas']={k:v for k,v in blueprints.CONTENT_MODELS.items()}
for row in db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)):
site['schemas'][row['content_type']]=json.loads(row['fields_json'])
site['email']=settings.get('email','')
site['footer_text']=settings.get('footer_text','')
routes={e['route'] for e in records}
issues=[];warnings=[];slashless=[]
if '/' not in routes:warnings.append('缺少 / 首页:已自动生成默认首页')
if not site['domain'].startswith(('http://','https://')):warnings.append('尚未配置完整域名:未生成绝对 canonical')
route_set=set(routes)
reserved={'/sitemap.xml','/robots.txt','/404.html'}
for e in records:
if e['route'] in reserved:issues.append('页面路由与系统文件冲突: '+e['route'])
if e['route']!='/' and not e['route'].endswith('/') and not e['route'].endswith(('.html','.htm')):
slashless.append(e['route'])
if any(r['old_path'] in route_set for r in redirects):issues.append('重定向来源和有效页面冲突')
if issues:raise ValueError('构建失败:'+';'.join(issues))
bid=uid()
dist=db.DATA/'builds'/site_id/bid/'dist'
dist.mkdir(parents=True,exist_ok=True)
copy_media(site_id,dist)
(dist/'assets').mkdir(exist_ok=True)
theme_pin=theme_store.pinned(site_id)
theme_dir=theme_store.resolve(site['theme'],theme_pin['version'] if theme_pin and theme_pin['theme_id']==site['theme'] else None)
shutil.copy2(theme_dir/'theme.css',dist/'assets'/'theme.css')
shutil.copy2(ROOT/'app'/'static'/'blocks.css',dist/'assets'/'blocks.css')
shutil.copy2(ROOT/'app'/'static'/'pro-base.css',dist/'assets'/'pro-base.css')
shutil.copy2(ROOT/'app'/'static'/'commercial.css',dist/'assets'/'commercial.css')
visuals=theme_dir/'visual'
if visuals.is_dir():
shutil.copytree(visuals,dist/'assets'/'visual',dirs_exist_ok=True)
shutil.copy2(ROOT/'app'/'static'/'pro.js',dist/'assets'/'pro.js')
if (theme_dir/'pro.css').exists():
shutil.copy2(theme_dir/'pro.css',dist/'assets'/'pro.css')
else:
(dist/'assets'/'pro.css').write_text('',encoding='utf-8')
accent=site['accent'] if re.fullmatch(r'#[0-9a-fA-F]{6}',site['accent'] or '') else '#2563eb'
tokens=settings.get('design_tokens') or {}
allowed_fonts={'system':'system-ui,-apple-system,BlinkMacSystemFont,sans-serif',
'serif':'Georgia,Times New Roman,serif',
'modern':'Arial,Helvetica,sans-serif'}
font=allowed_fonts.get(tokens.get('font'),'system-ui,-apple-system,sans-serif')
radius=int(max(0,min(30,tokens.get('radius',12))))
space=int(max(32,min(140,tokens.get('section_spacing',72))))
width=int(max(900,min(1600,tokens.get('container_width',1200))))
scale=float(max(.75,min(1.5,tokens.get('heading_scale',1))))
button={'rounded':f'{radius}px','pill':'999px','square':'0px'}.get(tokens.get('button_style'),f'{radius}px')
theme_css=(f':root{{--accent:{accent};--sf-font:{font};--sf-radius:{radius}px;'
f'--sf-space:{space}px;--sf-width:{width}px;--sf-heading-scale:{scale};}}'
f'body{{font-family:var(--sf-font)}} .sf-canvas .sf-section,.sf-canvas .section{{padding-block:var(--sf-space)}}'
f'.container,.sf-container{{max-width:var(--sf-width)}}'
f'.btn,.primary,.request,.button,.nav-btn{{background-color:{accent};border-radius:{button}}}'
f'body .sf-canvas h1{{font-size:clamp(2.5rem,5vw,calc(4.8rem * var(--sf-heading-scale)))}}')
(dist/'assets'/'custom.css').write_text(theme_css+responsive.site_css(records)+site_design_v87.responsive_css(site_design_v87.published(settings)),encoding='utf-8')
# For installed theme packages, Jinja loads only the trusted included template.
professional_template=(theme_dir/'pro.html').exists()
if professional_template and site['theme'] not in theme_pro.THEMES:
shutil.copy2(theme_dir/'theme.css',dist/'assets'/'theme-overrides.css')
template=ENV.get_template(site['theme']+'/site.html') if site['theme'] in THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve() else Environment(loader=FileSystemLoader(str(theme_dir)),autoescape=True).get_template('site.html')
orig=urlparse(settings.get('origin') or site['domain'])
origin_host=orig.hostname
local_records=[dict(e) for e in records]
remote_cache={}
try:
with httpx.Client(timeout=httpx.Timeout(10,connect=4),trust_env=False,headers={'User-Agent':'SiteForgeAssetMirror/0.1'}) as client:
for e in local_records:
e['body']=normalize_local_images(rewrite_internal_links(e['body'],origin_host,route_set))
if mirror:
e['body']=mirror_images(e['body'],dist,origin_host,client,remote_cache,warnings)
if e['cover']:
e['cover']=mirror_image_url(e['cover'],dist,origin_host,client,remote_cache,warnings)
if mirror and site['logo']:
site['logo']=mirror_image_url(site['logo'],dist,origin_host,client,remote_cache,warnings)
except Exception as exc:
warnings.append('资源镜像操作异常: '+str(exc))
# Precompute immutable catalogue indexes once per build. 5,000+ detail pages
# must not repeatedly regroup every product and parse every metadata JSON.
prepared_catalogue=theme_pro.prepare(local_records)
# Derive brand and category landing pages from actual content, not fabricated business data.
# Existing imported URLs always take priority; derived routes never rewrite source records.
derived=[]
product_records=[x for x in local_records if x.get('type') in ('product','vehicle')]
for cat in (x['name'] for x in prepared_catalogue['categories']):
path='/categories/'+theme_pro.slug(cat)+'/'
if path not in route_set:
derived.append({'id':'derived-category','route':path,'title':cat,'category':cat,'type':'page',
'summary':'分类产品与相关信息','body':'','cover':'','meta_json':'{}','seo_title':'','seo_description':'','canonical':'','robots':'index,follow'})
route_set.add(path)
for brand in prepared_catalogue['brands']:
if brand['url'] not in route_set:
derived.append({'id':'derived-brand','route':brand['url'],'title':brand['name'],'type':'brand',
'summary':'相关品牌及产品','body':'','cover':'','meta_json':'{}','seo_title':'','seo_description':'','canonical':'','robots':'index,follow'})
route_set.add(brand['url'])
# Detail pages, including custom-type documents.
totals=0
def render_one(page,mode='detail',listing_items=None):
nonlocal totals
page=page or {'title':site['name'],'route':'/','summary':site['tagline'],'body':'','cover':'','seo_title':'','seo_description':'','robots':'index,follow','canonical':'','type':'page','meta_json':'{}'}
meta=json.loads(page.get('meta_json') or '{}')
if not isinstance(meta,dict):meta={}
defined_blocks=meta.get('blocks',[])
use_blueprint=isinstance(defined_blocks,list) and len(defined_blocks)>0
blocks_html=blueprints.render_blocks(defined_blocks,site,page,local_records) if use_blueprint else ''
# Starter/scaffold preset blocks are layout defaults, not additional content;
# the full theme already renders the corresponding page family. Keep edited
# blocks, even on starter pages, so the editor remains effective.
if professional_template and use_blueprint and meta.get('layout_mode')!='replace' and page.get('source') in ('starter','site-bootstrap','scaffold'):
kind=theme_pro.infer_kind(page,mode)
try:
def signature(rows):
return [{k:v for k,v in row.items() if k!='id'} for row in rows]
# Starter layouts may belong to any previously selected theme.
is_default=any(signature(defined_blocks)==signature(blueprints.theme_blocks(t,kind)) for t in theme_pro.THEMES)
if not is_default and page.get('source')=='site-bootstrap' and kind=='listing':
# Bootstrap customizes collection title/source only; native listing
# already renders that content without the duplicate block.
is_default=all(b.get('type') in ('collection','spacer') for b in defined_blocks)
if is_default:blocks_html=''
except (ValueError,TypeError,KeyError):pass
canon=page.get('canonical') or public_url(site,page['route'])
if professional_template:
# Version-resolved trusted templates; uploaded themes cannot execute arbitrary code.
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
html=theme_pro.render(site['theme'],site,page,local_records,mode,listing_items,blocks_html,prepared=prepared_catalogue)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,page,local_records,mode,listing_items,prepared=prepared_catalogue)
ctx.update(custom_blocks=Markup(blocks_html),has_custom=bool(blocks_html),noindex=(ctx['kind']=='notfound'),theme_overrides=True,designer_replace=meta.get('layout_mode')=='replace')
html=env.get_template('pro.html').render(**ctx)
else:
html=template.render(site=site,page=page,mode=mode,content=Markup(page.get('body') or ''),
blocks=Markup(blocks_html),blueprint_mode=use_blueprint,entries=local_records,listing_items=listing_items or [],
type_names=TYPE_NAMES,nav=settings.get('nav') or [e for e in local_records if e['type']=='page' and e['route'] in ('/','/about/','/about-us/','/contact/','/contact-us/','/services/','/products/')],
link=link, asset=lambda x: x.lstrip('/') if x.startswith('/') else x, canonical=canon, seo_title=page.get('seo_title') or page['title'],
seo_description=page.get('seo_description') or page.get('summary') or site['tagline'])
html=static_site.shell(html,site,page,local_records,settings.get('nav') or [])
# Each page supplies a stable selector to scope responsive rules in
# global custom.css. Reused templates can share block IDs safely.
html=__import__('re').sub(r'<body(?=[\s>])',
'<body data-siteforge-route="'+responsive.route_key(page['route'])+'"',html,count=1,flags=__import__('re').I)
target=output_path(dist,page['route'])
if target.exists():raise ValueError('多个路由写入同一文件: '+page['route'])
target.parent.mkdir(parents=True,exist_ok=True)
target.write_text(html,encoding='utf-8')
totals+=1
# Static pagination: large product collections must not embed 5,000 cards
# in one HTML page. Every item remains reachable, including in the sitemap.
LIST_PAGE_SIZE=100
def render_paginated(page,items):
root_route=page['route'].rstrip('/')+'/'
pages=max(1,(len(items)+LIST_PAGE_SIZE-1)//LIST_PAGE_SIZE)
for number in range(1,pages+1):
current=dict(page)
current['route']=root_route if number==1 else root_route+'page/'+str(number)+'/'
if current['route'] in route_set and current['route']!=root_route:
raise ValueError('自动分页 URL 与原始内容冲突:'+current['route'])
current['_pagination']={'page':number,'pages':pages,
'previous':(root_route if number==2 else root_route+'page/'+str(number-1)+'/') if number>1 else '',
'next':root_route+'page/'+str(number+1)+'/' if number<pages else ''}
if number>1:
current['title']=str(page.get('title',''))+' · 第 '+str(number)+' 页'
current['seo_description']=str(page.get('summary') or page.get('title') or '')+' · 第 '+str(number)+' 页'
render_one(current,'listing',items[(number-1)*LIST_PAGE_SIZE:number*LIST_PAGE_SIZE])
route_set.add(current['route'])
for e in local_records:render_one(e,'home' if e['route']=='/' else 'detail')
category_items={}
for item in product_records:
category=str(theme_pro.fields(item).get('category') or '').strip().casefold()
if category:category_items.setdefault(category,[]).append(item)
for e in derived:
if e.get('category'):render_paginated(e,category_items.get(e['category'].casefold(),[]))
else:render_one(e,'detail')
if '/' not in routes:render_one(None,'home')
for typ in sorted({e['type'] for e in local_records if e['type']!='page'} | {'product'}):
list_route='/'+typ+'/'
if list_route in route_set:continue
itemlist=[e for e in local_records if e['type']==typ]
render_paginated({'type':'page','route':list_route,'title':TYPE_NAMES.get(typ,typ)+'展示',
'summary':'Explore our '+typ,'meta_json':'{}'},itemlist)
# Theme links must never lead to 404 simply because a new site has not
# entered its first products or company profile. These are explicitly
# noindex *empty placeholders*, not fabricated imported content.
generated_noindex=set()
required_sections={
'/products/':('产品展示','listing'),'/brands/':('品牌中心','listing'),
'/news/':('新闻资讯','listing'),'/cases/':('项目案例','listing'),
'/about/':('关于我们','detail'),'/contact/':('联系我们','detail'),
'/services/':('服务介绍','listing'),'/faq/':('常见问题','detail'),
}
for route,(title,mode) in required_sections.items():
if route in route_set:continue
page={'title':title,'route':route,'type':'page',
'summary':'请在后台填写真实企业资料及栏目内容后再开放收录。',
'body':'','cover':'','meta_json':'{}','robots':'noindex,follow'}
if route=='/products/':
# Standard product hub is also paginated. Otherwise thousands of
# vehicles would still be embedded into one generated scaffold page.
render_paginated(page,product_records)
else:
render_one(page,mode)
route_set.add(route)
generated_noindex.add(route)
if generated_noindex:
warnings.append(f'{len(generated_noindex)} 个尚无真实内容的标准栏目已生成 noindex 占位页,发布前请补充。')
notfound={'title':'404 · 页面不存在','route':'/404.html','summary':'','body':'','cover':'','meta_json':'{}','robots':'noindex,follow'}
if professional_template:
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
missing_html=theme_pro.render(site['theme'],site,notfound,local_records,'notfound',prepared=prepared_catalogue)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,notfound,local_records,'notfound',prepared=prepared_catalogue)
ctx.update(custom_blocks=Markup(''),has_custom=False,noindex=True,theme_overrides=True)
missing_html=env.get_template('pro.html').render(**ctx)
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='')
(dist/'404.html').write_text(missing_html,encoding='utf-8')
# A complete static search page is packaged for every theme, not a live API.
static_site.write_assets(dist,local_records)
if '/search/' not in route_set:
search_file=dist/'search'/'index.html'
search_file.parent.mkdir(parents=True,exist_ok=True)
search_entry={'title':'站内搜索','route':'/search/','type':'page','summary':'搜索本站内容','body':'','meta_json':'{}','robots':'noindex,follow'}
if professional_template:
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
search_html=theme_pro.render(site['theme'],site,search_entry,local_records,prepared=prepared_catalogue)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,search_entry,local_records,prepared=prepared_catalogue)
ctx.update(custom_blocks=Markup(''),has_custom=False,noindex=True,theme_overrides=True)
search_html=env.get_template('pro.html').render(**ctx)
else:
search_html=static_site.search_page(site)
search_file.write_text(search_html,encoding='utf-8')
route_set.add('/search/')
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])
if site['domain'].startswith(('https://','http://')):
urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc></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>'
(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')
rlist=[]
for r in redirects:
old,new=route_path(r['old_path']),r['new_path']
if not (new.startswith('/') or new.startswith('https://')):raise ValueError('非法重定向目标')
rlist.append((old,new,int(r['status_code'])))
emit_nginx(dist,rlist,slashless)
(dist/'.htaccess').write_text('Options -MultiViews\nRewriteEngine On\n'+''.join(f'RewriteRule ^{re.escape(old.lstrip("/"))}$ {new} [R={code},L,NE]\n' for old,new,code in rlist),encoding='utf-8')
# Evaluate packaged files; external src references are reported (not silently asserted migrated).
external=[];missing=[];broken_links=[];legacy_links=[];canonical_mismatches=[]
target_host=urlparse(site['domain']).hostname
for e in records:
c=urlparse(e['canonical'] or '')
if c.hostname and target_host and c.hostname != target_host:
canonical_mismatches.append(e['route'])
for f in dist.rglob('*.html'):
soup=BeautifulSoup(f.read_text(encoding='utf-8'),'html.parser')
for a in soup.select('a[href]'):
href=a.get('href','')
p=urlparse(href)
if p.scheme in ('http','https'):
if p.hostname==origin_host and p.path not in route_set and href not in legacy_links:
legacy_links.append(href)
elif href and href not in ('.','./','index.html') and not href.startswith(('#','mailto:','tel:','javascript:')):
candidate=p.path
if candidate and not candidate.startswith('/'):
candidate='/'+candidate
if candidate and candidate not in route_set and candidate not in ('/index.html',) and not (dist/candidate.lstrip('/')).is_file() and candidate not in broken_links:
broken_links.append(candidate)
for img in soup.select('img[src]'):
src=img.get('src','')
if src.startswith(('http://','https://')):
if src not in external:external.append(src)
else:
rel=src.lstrip('/')
if rel and not (dist/rel).is_file() and rel not in missing:missing.append(rel)
if external:warnings.append(f'{len(external)} 个外部图片 URL 尚未打包(可开启镜像重建)')
if broken_links:warnings.append(f'{len(broken_links)} 个内部链接无对应页面或文件')
if legacy_links:warnings.append(f'{len(legacy_links)} 个指向源站但未能映射的链接,需人工核验')
if canonical_mismatches:warnings.append(f'{len(canonical_mismatches)} 个页面 canonical 域名与新站域名不一致')
if missing:warnings.append(f'{len(missing)} 个本地引用图片缺失')
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']),
'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,
'redirects':len(redirects),'mirrored_images':len(remote_cache),
'quality_status':'warning' if warnings else 'passed','blueprint_blocks':sum(len(json.loads(x.get('meta_json') or '{}').get('blocks',[])) for x in local_records)}
# Separate static quality from readiness for commercial hand-off.
from . import commercial_readiness
handoff=commercial_readiness.inspect(dist)
report['commercial_readiness']={'ready_for_review':handoff['ready_for_review'],
'commercially_certified':False,'blockers':len(handoff['blockers']),
'warnings':len(handoff['warnings'])}
(dist/'commercial-readiness.json').write_text(json.dumps(handoff,ensure_ascii=False,indent=2),encoding='utf-8')
(dist/'siteforge-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
archive_dir=db.DATA/'archives';archive_dir.mkdir(parents=True,exist_ok=True)
archive=archive_dir/(site_id+'-'+bid+'.zip')
with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=6) as z:
for file in dist.rglob('*'):
if file.is_file():z.write(file,file.relative_to(dist))
db.insert('INSERT INTO builds (id,site_id,created_at,status,total_pages,report_json,zip_path) VALUES (?,?,?,?,?,?,?)',
(bid,site_id,db.now(),'complete',totals,json.dumps(report,ensure_ascii=False),str(archive)))
return report
+184
View File
@@ -0,0 +1,184 @@
"""Deterministic import adapters for exported CMS JSON.
No PHP execution, no private API credential collection, no guessing missing SEO.
Every unsupported complex field is flagged for manual migration review.
"""
from __future__ import annotations
import json,re,uuid
from html import unescape
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from fastapi import APIRouter,HTTPException,Request,UploadFile,File,Form,Body
from . import enterprise,importers,wordpress_compat,db
router=APIRouter(prefix='/api/migrations',tags=['CMS import'])
PLATFORMS=('wordpress-rest','woocommerce','drupal-jsonapi','shopify')
MAX_BYTES=12_000_000
MAX_ITEMS=1200
# Persist review findings: importer warnings must not vanish after the response.
def setup():
with db.connect() as conn:
conn.executescript("""CREATE TABLE IF NOT EXISTS cms_import_reviews (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
platform TEXT NOT NULL, row_number INTEGER NOT NULL, issue TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending', reviewer TEXT NOT NULL DEFAULT '',
resolution TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, resolved_at TEXT);
CREATE INDEX IF NOT EXISTS idx_cms_reviews_site ON cms_import_reviews(site_id,state);""")
def open_reviews(site_id):
row=db.one('SELECT COUNT(*) AS n FROM cms_import_reviews WHERE site_id=? AND state=?',
(site_id,'pending'))
return int(row['n']) if row else 0
@router.get('/sites/{site_id}/reviews')
def list_reviews(request:Request,site_id:str,state:str='pending'):
enterprise.require(request,site_id,'viewer')
if state not in ('pending','resolved','all'):raise HTTPException(400,'Invalid review state')
if state=='all':
items=db.rows('SELECT * FROM cms_import_reviews WHERE site_id=? ORDER BY created_at DESC,id DESC LIMIT 300',(site_id,))
else:
items=db.rows('SELECT * FROM cms_import_reviews WHERE site_id=? AND state=? ORDER BY created_at DESC,id DESC LIMIT 300',(site_id,state))
return {'items':items,'pending':open_reviews(site_id)}
@router.post('/sites/{site_id}/reviews/{review_id}/resolve')
def resolve_review(request:Request,site_id:str,review_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'reviewer')
explanation=str(payload.get('resolution','')).strip()
if len(explanation)<12 or len(explanation)>2000:
raise HTTPException(400,'Resolution must explain the manual review in 12-2000 characters')
updated=db.execute("UPDATE cms_import_reviews SET state='resolved', reviewer=?, resolution=?, resolved_at=? WHERE site_id=? AND id=? AND state='pending'",
(request.state.actor,explanation,db.now(),site_id,review_id))
if not updated:raise HTTPException(404,'Review finding not found or already resolved')
return {'resolved':True,'pending':open_reviews(site_id)}
def plain(value):
if isinstance(value,dict):value=value.get('rendered',value.get('value',''))
return BeautifulSoup(str(value or ''),'html.parser').get_text(' ',strip=True)
def path(value,fallback):
if isinstance(value,dict):value=value.get('alias') or value.get('uri')
raw=str(value or '').strip()
if raw.startswith(('http://','https://')):raw=urlparse(raw).path
return raw if raw.startswith('/') else fallback
def mapped_wp(rec,kind):
seo=rec.get('yoast_head_json') or rec.get('rank_math_head_json') or {}
meta=rec.get('meta') if isinstance(rec.get('meta'),dict) else {}
acf=rec.get('acf') if isinstance(rec.get('acf'),dict) else {}
widgets=meta.get('_elementor_data')
warnings=[]
body=rec.get('content') or rec.get('description') or ''
if isinstance(body,dict):body=body.get('rendered','')
if isinstance(widgets,str) and widgets.strip():
el=wordpress_compat.elementor_to_html(widgets)
if not body and el['html']:body=el['html']
warnings+=el['warnings']+['Elementor 原始布局需人工核对']
for field,val in acf.items():
if isinstance(val,(dict,list)):
warnings.append('ACF 复杂关系字段需检查:'+field[:80])
gallery=[]
for obj in rec.get('images',[]) if isinstance(rec.get('images'),list) else []:
if isinstance(obj,dict) and obj.get('src'):gallery.append(obj['src'])
if kind=='product':
for item in rec.get('attributes',[]) if isinstance(rec.get('attributes'),list) else []:
if isinstance(item,dict) and item.get('variation'):warnings.append('WooCommerce 变体属性需要单独映射')
if rec.get('variations'):warnings.append('WooCommerce 变体/价格组合需人工校验')
title=plain(rec.get('title') or rec.get('name'))
identifier=rec.get('slug') or rec.get('id') or 'item'
fallback=f'/{kind}/{identifier}/'
route=path(rec.get('link') or rec.get('permalink'),fallback)
return {'type':kind,'title':title,'route':route,'summary':plain(rec.get('excerpt') or rec.get('short_description')),
'body':str(body),'cover':rec.get('featured_image_url','') or (gallery[0] if gallery else ''),
'seo_title':plain(seo.get('title','')),'seo_description':plain(seo.get('description','')),
'canonical':seo.get('canonical',''),
'meta':{'fields':{**{k:v for k,v in acf.items() if not isinstance(v,(dict,list))},
**({'sku':rec.get('sku'),'price':rec.get('price'),'gallery':gallery} if kind=='product' else {})},
'migration_warnings':warnings,'source_id':rec.get('id')}}
def mapped_drupal(rec):
a=rec.get('attributes') if isinstance(rec.get('attributes'),dict) else {}
typ=str(rec.get('type') or '')
kind='post' if 'article' in typ or 'news' in typ else ('product' if 'product' in typ else 'page')
title=plain(a.get('title') or a.get('name'))
raw=a.get('body') or a.get('field_description') or ''
body=raw.get('processed',raw.get('value','')) if isinstance(raw,dict) else str(raw)
u=a.get('path') or a.get('alias')
route=path(u,f'/{kind}/{rec.get("id") or "item"}/')
meta=a.get('metatag') or []
metad={x.get('attributes',{}).get('name'):x.get('attributes',{}).get('content') for x in meta if isinstance(x,dict)} if isinstance(meta,list) else {}
links=rec.get('relationships') or {}
warnings=['Drupal 实体引用关系需映射:'+str(key)[:60] for key,val in links.items() if isinstance(val,dict) and val.get('data')]
return {'type':kind,'title':title,'route':route,'body':body,'seo_title':metad.get('title') or '',
'seo_description':metad.get('description') or '',
'meta':{'migration_warnings':warnings,'source_id':rec.get('id'),'source_type':typ}}
def mapped_shopify(rec):
handle=rec.get('handle') or rec.get('id') or 'product'
images=rec.get('images',[])
gallery=[x.get('src') for x in images if isinstance(x,dict) and x.get('src')]
warnings=[]
variants=rec.get('variants',[])
if len(variants)>1:warnings.append('Shopify 多变体/库存/价格需人工复核')
if rec.get('options'):warnings.append('Shopify options 与变体关系需审核')
return {'type':'product','title':plain(rec.get('title')),'route':f'/products/{handle}/',
'summary':'','body':str(rec.get('body_html') or ''),'cover':gallery[0] if gallery else '',
'seo_title':rec.get('metafields_global_title_tag') or '',
'seo_description':rec.get('metafields_global_description_tag') or '',
'meta':{'fields':{'sku':variants[0].get('sku','') if variants else '',
'price':variants[0].get('price','') if variants else '',
'gallery':gallery,'brand':rec.get('vendor',''),'category':rec.get('product_type','')},
'migration_warnings':warnings,'source_id':rec.get('id')}}
def parse_export(platform,blob):
if platform not in PLATFORMS:raise ValueError('Unsupported CMS adapter')
if len(blob)>MAX_BYTES:raise ValueError('Import archive too large')
source=json.loads(blob.decode('utf-8-sig'))
if platform=='drupal-jsonapi':
records=source.get('data',[]) if isinstance(source,dict) else source
elif platform=='shopify':
records=source.get('products',[]) if isinstance(source,dict) else source
else:
records=source.get('products',source.get('entries',source.get('items',[]))) if isinstance(source,dict) else source
if not isinstance(records,list) or len(records)>MAX_ITEMS:raise ValueError('Expected <=1200 array items')
result=[];errors=[];warnings=[];routes=set()
for index,raw in enumerate(records,1):
try:
if not isinstance(raw,dict):raise ValueError('Item not an object')
if platform=='drupal-jsonapi':entry=mapped_drupal(raw)
elif platform=='shopify':entry=mapped_shopify(raw)
else:entry=mapped_wp(raw,'product' if platform=='woocommerce' else ('page' if raw.get('type')=='page' else 'post'))
if not entry['title']:raise ValueError('Missing title')
from .core import route_path
entry['route']=route_path(entry['route'])
if entry['route'] in routes:raise ValueError('Duplicate route '+entry['route'])
routes.add(entry['route'])
for warning in entry.get('meta',{}).get('migration_warnings',[]):warnings.append({'row':index,'issue':warning})
result.append(entry)
except (ValueError,TypeError,KeyError) as e:errors.append({'row':index,'error':str(e)[:180]})
return {'platform':platform,'input':len(records),'mapped':len(result),'errors':errors,'warnings':warnings[:250],'preview':result[:8],'entries':result}
@router.post('/sites/{site_id}/preview')
async def preview(request:Request,site_id:str,platform:str=Form(...),file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
try:
blob=await file.read(MAX_BYTES+1)
result=parse_export(platform,blob)
except (ValueError,UnicodeError,json.JSONDecodeError) as exc:raise HTTPException(400,str(exc))
return {k:v for k,v in result.items() if k!='entries'}
@router.post('/sites/{site_id}/import')
async def upload(request:Request,site_id:str,platform:str=Form(...),conflict:str=Form('skip'),file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
if conflict not in ('skip','update'):raise HTTPException(400,'Invalid conflict policy')
try:result=parse_export(platform,await file.read(MAX_BYTES+1))
except (ValueError,UnicodeError,json.JSONDecodeError) as exc:raise HTTPException(400,str(exc))
if result['errors']:raise HTTPException(409,'导入数据存在错误;请先预览并修正')
committed=importers.import_records(site_id,result['entries'],platform,on_conflict=conflict)
if result['warnings']:
with db.connect() as c:
for warning in result['warnings']:
c.execute('INSERT INTO cms_import_reviews(id,site_id,platform,row_number,issue,state,created_at) VALUES (?,?,?,?,?,?,?)',
(uuid.uuid4().hex,site_id,platform,warning['row'],warning['issue'],'pending',db.now()))
return {'result':committed,'migration_warnings':result['warnings'],
'manual_review_required':bool(result['warnings']),'pending_review_count':open_reviews(site_id)}
+56
View File
@@ -0,0 +1,56 @@
"""Commercial-delivery readiness report, separate from technical/SEO pass.
It does NOT certify a site's business claims, copyright clearance, legal compliance,
WCAG conformance or any real-world server/SEO outcome. It reports missing items.
"""
from __future__ import annotations
from pathlib import Path
from bs4 import BeautifulSoup
import json
REQUIRED=('index.html','about/index.html','contact/index.html','products/index.html','brands/index.html','news/index.html','cases/index.html','404.html')
DEMO_MARKERS=('example.com','example.test','这是可替换的','虚构示例','演示内容','演示产品','此为 SiteForge','请在后台配置')
def inspect(dist:Path)->dict:
dist=Path(dist)
blockers=[];warnings=[];metrics={}
for f in REQUIRED:
if not (dist/f).is_file():blockers.append({'code':'page_missing','path':f,'message':'缺少标准栏目页面'})
homepage=dist/'index.html'
if homepage.is_file():
soup=BeautifulSoup(homepage.read_text(encoding='utf-8'),'html.parser')
logo=soup.select_one('header img.pro-brand-logo')
if not logo:warnings.append({'code':'brand_logo','message':'尚未替换成企业正式 Logo'})
if not soup.find('meta',attrs={'name':'description'}):blockers.append({'code':'seo_description','message':'首页缺少 SEO 描述标签'})
if len(soup.select('h1'))!=1:blockers.append({'code':'home_h1','message':'首页 H1 数量不符合预期'})
else:soup=None
# Do not build a 5,000-page HTML string and parse every detail document with
# BeautifulSoup just to count two static CSS classes. Streaming makes the
# commercial audit linear in file size, rather than consuming excessive CPU.
metrics['html_pages']=0
metrics['artwork_fallbacks']=0
metrics['pages_with_body']=0
found_demo=False
for path in dist.rglob('*.html'):
html=path.read_text(encoding='utf-8',errors='replace')
metrics['html_pages']+=1
metrics['artwork_fallbacks']+=html.count('class="pro-art ')
metrics['pages_with_body']+=int('pro-rich' in html)
if not found_demo and any(marker in html for marker in DEMO_MARKERS):found_demo=True
if metrics['artwork_fallbacks']:
warnings.append({'code':'placeholder_images','count':metrics['artwork_fallbacks'],'message':'部分页面仍使用示意图,正式发布前请替换为真实且有使用权的图片'})
if found_demo:
warnings.append({'code':'demo_content','message':'检测到演示或默认文案,正式发布前请逐页核对'})
if not (dist/'assets'/'commercial.css').is_file():
warnings.append({'code':'commercial_style','message':'当前构建未包含商业视觉样式(可能是历史主题版本)'})
contact=dist/'contact/index.html'
if contact.is_file():
con=BeautifulSoup(contact.read_text(encoding='utf-8'),'html.parser')
if not con.select_one('form.pro-inquiry') and not con.select_one('a[href^="mailto:"]'):
blockers.append({'code':'contact_method','message':'联系页既无已配置表单,也无有效电子邮箱入口'})
elif not con.select_one('form.pro-inquiry'):
warnings.append({'code':'form_not_configured','message':'尚未配置 HTTPS 询盘接收服务;目前仅提供邮件联系'})
if not any((dist/p).is_file() for p in ('privacy/index.html','privacy-policy/index.html')):
warnings.append({'code':'legal_pages','message':'请根据目标国家/地区法律审查是否需要隐私政策与 Cookie 声明'})
return {'ready_for_review':not blockers,'commercially_certified':False,'blockers':blockers,'warnings':warnings,
'metrics':metrics,'note':'仅为本地结构化交付清单,不等于合法合规、内容准确、无障碍、性能或生产上线认证'}
+351
View File
@@ -0,0 +1,351 @@
"""V8.4 bounded XML and public JSON API connectors.
All resulting content enters the existing staged, auditable V8 import batches.
Public API paging runs in a subprocess under the common Worker lease/timeout.
Authenticated API sources and database connectors are intentionally not enabled.
"""
from __future__ import annotations
import json
import ipaddress
import re
import uuid
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import APIRouter, Body, File, Form, HTTPException, Request, UploadFile
from defusedxml import ElementTree as XML
from defusedxml.common import DefusedXmlException
from . import db, enterprise, jobs, safe_http, v8_workflows, secret_refs
router = APIRouter(prefix='/api/connectors/sites',tags=['V8.4 data connectors'])
MAX_XML_BYTES = 8_000_000
MAX_XML_NODES = 20_000
MAX_XML_DEPTH = 9
MAX_VALUE_LEN = 20_000
MAX_RECORDS = 1000
MAX_JSON_BYTES = 2_000_000
TAG = re.compile(r'^[A-Za-z_][\w.-]{0,70}$')
JSON_PATH = re.compile(r'^[A-Za-z_][\w-]{0,60}(?:\.[A-Za-z_][\w-]{0,60}){0,4}$')
SENSITIVE_KEYS = {'key','api_key','apikey','token','access_token','secret','password','authorization','auth','signature','client_secret'}
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS v84_api_sources (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
url TEXT NOT NULL, items_path TEXT NOT NULL, page_param TEXT NOT NULL,
per_page_param TEXT NOT NULL, per_page INTEGER NOT NULL,
max_pages INTEGER NOT NULL, next_page INTEGER NOT NULL DEFAULT 1,
scanned_pages INTEGER NOT NULL DEFAULT 0, staged_count INTEGER NOT NULL DEFAULT 0,
kind TEXT NOT NULL, mapping_json TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'ready',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, error TEXT NOT NULL DEFAULT '');
CREATE INDEX IF NOT EXISTS idx_v84_sources_site ON v84_api_sources(site_id,created_at);
CREATE TABLE IF NOT EXISTS v84_api_pages (
source_id TEXT NOT NULL REFERENCES v84_api_sources(id) ON DELETE CASCADE,
page INTEGER NOT NULL, batch_id TEXT REFERENCES v8_import_batches(id),
input_count INTEGER NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(source_id,page));
''')
# Preserve V8.4 sources when upgrading an existing database.
existing={r['name'] for r in c.execute('PRAGMA table_info(v84_api_sources)')}
for col,ddl in {
'auth_type':"TEXT NOT NULL DEFAULT 'none'",
'auth_header':"TEXT NOT NULL DEFAULT ''",
'secret_ref':"TEXT NOT NULL DEFAULT ''",
}.items():
if col not in existing:c.execute(f'ALTER TABLE v84_api_sources ADD COLUMN {col} {ddl}')
def _local_name(tag):
return tag.rsplit('}',1)[-1] if isinstance(tag,str) else ''
def parse_xml_records(data:bytes, record_tag:str):
"""Parse a bounded, non-DTD, flat/two-level XML dataset without network access."""
if not isinstance(data,bytes) or not 0 < len(data) <= MAX_XML_BYTES:
raise ValueError('XML 文件大小必须在 1 字节到 8 MB 之间')
if not isinstance(record_tag,str) or not TAG.fullmatch(record_tag):
raise ValueError('记录节点名称不合法')
# defusedxml is the security gate against DTD entity expansion and XXE.
try:root=XML.fromstring(data,forbid_dtd=True)
except (DefusedXmlException, XML.ParseError) as exc:
raise ValueError('XML 包含不允许的实体或格式错误') from exc
stack=[(root,1)];nodes=0;records=[]
while stack:
elem,depth=stack.pop();nodes+=1
if nodes>MAX_XML_NODES or depth>MAX_XML_DEPTH:
raise ValueError('XML 节点数或嵌套层级超限')
if _local_name(elem.tag)==record_tag:
records.append(elem)
if len(records)>MAX_RECORDS:raise ValueError('XML 每次最多导入1000条记录')
for child in reversed(list(elem)):stack.append((child,depth+1))
if not records:raise ValueError('未找到指定的 XML 记录节点')
rows=[]
for record in records:
item={}
for child in record:
field=_local_name(child.tag)
if not TAG.fullmatch(field):raise ValueError('XML 字段名不合法')
if len(child):
for sub in child:
if len(sub):raise ValueError('仅支持扁平或两层结构的记录')
key=field+'.'+_local_name(sub.tag)
if key in item:raise ValueError('XML 记录存在重复字段:'+key)
item[key]=(sub.text or '').strip()[:MAX_VALUE_LEN]
else:
if field in item:raise ValueError('XML 记录存在重复字段:'+field)
item[field]=(child.text or '').strip()[:MAX_VALUE_LEN]
if item:rows.append(item)
if not rows:raise ValueError('XML 记录没有可导入字段')
if len(json.dumps(rows,ensure_ascii=False).encode('utf-8'))>v8_workflows.MAX_UPLOAD:
raise ValueError('XML 解码后数据超过导入限制')
return rows
def _validate_url(url):
if not isinstance(url,str) or len(url)>1600:raise ValueError('API 地址不合法')
p=urlsplit(url)
if p.scheme!='https' or not p.hostname or p.username or p.password or p.fragment:
raise ValueError('API 连接器只接受公开 HTTPS 地址,不能包含账号、口令或片段')
try:port=p.port
except ValueError as exc:raise ValueError('API 端口不合法') from exc
if port not in (None,443):raise ValueError('仅支持默认 HTTPS 端口')
try:
ip=ipaddress.ip_address(p.hostname)
if not ip.is_global:raise ValueError('禁止使用非公网 IP API 地址')
except ValueError as exc:
if str(exc)=='禁止使用非公网 IP API 地址':raise
if any(k.casefold() in SENSITIVE_KEYS or 'token' in k.casefold() or 'secret' in k.casefold()
for k,_ in parse_qsl(p.query,keep_blank_values=True)):
raise ValueError('请勿在 API URL 中传入凭据或令牌')
if len(parse_qsl(p.query,keep_blank_values=True))>30:raise ValueError('URL 查询参数过多')
# DNS->socket pinning and non-public IP protection are enforced on fetch.
return urlunsplit(p)
def create_api_source(site_id,payload):
v8_workflows._require_site(site_id)
if not isinstance(payload,dict):raise ValueError('API 参数必须是对象')
url=_validate_url(payload.get('url'))
kind=str(payload.get('content_type','product'))
if kind not in ('product','vehicle','page','post','brand','case','service','other'):
raise ValueError('内容类型不受支持')
items_path=str(payload.get('items_path','items'))
if not JSON_PATH.fullmatch(items_path):raise ValueError('JSON 路径不合法')
page_param=str(payload.get('page_param','page'))
per_page_param=str(payload.get('per_page_param','per_page'))
if not TAG.fullmatch(page_param) or not TAG.fullmatch(per_page_param) or page_param==per_page_param:
raise ValueError('分页参数不合法')
if any(k in (page_param,per_page_param) for k,_ in parse_qsl(urlsplit(url).query)):
raise ValueError('源 URL 不应携带分页参数')
try:page_size=int(payload.get('page_size',50));max_pages=int(payload.get('max_pages',20))
except (TypeError,ValueError):raise ValueError('分页数量不合法')
if not 1<=page_size<=100 or not 1<=max_pages<=50 or page_size*max_pages>5000:
raise ValueError('上限为每页100条、最多50页、合计5000条')
mapping=payload.get('mapping') or {}
if not isinstance(mapping,dict) or len(mapping)>100 or any(not isinstance(k,str) or not isinstance(v,str) or len(v)>100 for k,v in mapping.items()):
raise ValueError('字段映射规则不合法')
auth_type=str(payload.get('auth_type','none'))
if auth_type not in ('none','bearer','api_key','custom'):
raise ValueError('不支持的 API 认证方式')
secret_ref='';auth_header=''
if auth_type!='none':
secret_ref=secret_refs.validate_reference(payload.get('secret_ref'))
auth_header='Authorization' if auth_type=='bearer' else 'X-API-Key'
if auth_type=='custom':
auth_header=str(payload.get('auth_header',''))
if not re.fullmatch(r'X-[A-Za-z0-9-]{2,64}',auth_header,re.I):
raise ValueError('自定义凭据请求头必须以 X- 开头')
elif payload.get('secret_ref'):
raise ValueError('公开 API 不应保存密钥引用')
source_id=uuid.uuid4().hex
db.insert('''INSERT INTO v84_api_sources(id,site_id,url,items_path,page_param,per_page_param,
per_page,max_pages,kind,mapping_json,auth_type,auth_header,secret_ref,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(source_id,site_id,url,items_path,page_param,per_page_param,page_size,max_pages,kind,
json.dumps(mapping,ensure_ascii=False),auth_type,auth_header,secret_ref,db.now(),db.now()))
return status(site_id,source_id)
def _source(site_id,source_id):
r=db.one('SELECT * FROM v84_api_sources WHERE id=? AND site_id=?',(source_id,site_id))
if not r:raise HTTPException(404,'API 数据源不存在')
return r
def status(site_id,source_id):
s=_source(site_id,source_id)
# Secret references are never returned by management APIs.
return {k:s[k] for k in ('id','site_id','url','items_path','state','next_page',
'scanned_pages','staged_count','max_pages','per_page','kind','error','auth_type','auth_header')} | {
'batches':db.rows('SELECT page,batch_id,input_count FROM v84_api_pages WHERE source_id=? ORDER BY page',(source_id,))}
def _data_items(content,items_path):
try:result=json.loads(content.decode('utf-8-sig'))
except (ValueError,UnicodeError) as exc:raise ValueError('API 响应不是有效 UTF-8 JSON') from exc
if isinstance(result,list):items=result
else:
for part in items_path.split('.'):
if not isinstance(result,dict) or part not in result:raise ValueError('API JSON 路径不存在:'+items_path)
result=result[part]
items=result
if not isinstance(items,list) or len(items)>100 or any(not isinstance(x,dict) for x in items):
raise ValueError('API 当前页必须包含最多100条对象记录')
if any(len(row)>150 for row in items):raise ValueError('API 单条字段数量超过限制')
return items
def page_url(source,page):
p=urlsplit(source['url']);params=parse_qsl(p.query,keep_blank_values=True)
params.extend(((source['page_param'],str(page)),(source['per_page_param'],str(source['per_page']))))
return urlunsplit((p.scheme,p.netloc,p.path,urlencode(params),''))
def scan_page(site_id,source_id):
source=_source(site_id,source_id)
if source['state'] in ('cancelled','completed','limit_reached'):return {'state':source['state'],'next_page':source['next_page']}
if source['next_page']>source['max_pages']:
db.execute("UPDATE v84_api_sources SET state='completed',updated_at=? WHERE id=?",(db.now(),source_id))
return {'state':'completed','next_page':source['next_page']}
page=int(source['next_page'])
# A process crash after stage but before the checkpoint must not stage twice.
existing=db.one('SELECT * FROM v84_api_pages WHERE source_id=? AND page=?',(source_id,page))
origin=f'api:{source_id}:page:{page}'
if not existing:
pending=db.one('SELECT id,input_count FROM v8_import_batches WHERE site_id=? AND origin=?',(site_id,origin))
if pending:
batch_id=pending['id'];count=pending['input_count']
else:
extra={}
if source.get('auth_type','none')!='none':
token=secret_refs.resolve(source['secret_ref'])
extra[source['auth_header']]=('Bearer '+token if source['auth_type']=='bearer' else token)
opts={'headers':{'Accept':'application/json'},'redirects':0,'timeout':20,'max_bytes':MAX_JSON_BYTES}
if extra:opts['credential_headers']=extra
resp=safe_http.fetch(page_url(source,page),**opts)
resp.raise_for_status()
mime=resp.headers.get('content-type','').split(';')[0].strip().lower()
if mime!='application/json' and not mime.endswith('+json'):
raise ValueError('API 响应 Content-Type 必须为 JSON')
rows=_data_items(resp.content,source['items_path'])
count=len(rows);batch_id=None
if rows:
mapping=json.loads(source['mapping_json'] or '{}') or None
keys=[f'{source_id}:{page}:{i}:{str(r.get("id",i))[:60]}' for i,r in enumerate(rows)]
staged=v8_workflows.stage(site_id,rows,mapping,source['kind'],origin,keys)
batch_id=staged['batch_id']
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
# The exclusive one-page checkpoint is idempotent across retries.
c.execute('''INSERT INTO v84_api_pages(source_id,page,batch_id,input_count,created_at)
VALUES(?,?,?,?,?) ON CONFLICT(source_id,page) DO NOTHING''',
(source_id,page,batch_id,count,db.now()))
else:
count=existing['input_count'];batch_id=existing['batch_id']
finished=count<source['per_page'] or page>=source['max_pages']
final_state=('limit_reached' if page>=source['max_pages'] and count>=source['per_page'] else 'completed') if finished else 'ready'
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
cur=c.execute('SELECT next_page,state FROM v84_api_sources WHERE id=? AND site_id=?',(source_id,site_id)).fetchone()
if cur and cur['state']=='cancelled':
return {'state':'cancelled','page':page,'count':count,'batch_id':batch_id,'next_page':page}
if cur and cur['next_page']==page:
c.execute('''UPDATE v84_api_sources SET next_page=?,scanned_pages=scanned_pages+1,
staged_count=staged_count+?,state=?,updated_at=?,error='' WHERE id=?''',
(page+1,count,final_state,db.now(),source_id))
return {'state':final_state,'page':page,'count':count,'batch_id':batch_id,
'next_page':page+1}
def run_scan(site_id,source_id,job_id=None):
source=_source(site_id,source_id)
if source['state']=='cancelled':return status(site_id,source_id)
# Worker retry always resumes from the durable checkpoint.
while True:
one=scan_page(site_id,source_id)
if one['state'] in ('completed','limit_reached'):break
if one['state']=='cancelled':break
return status(site_id,source_id)
def submit_scan(site_id,source_id,actor,key=None):
source=_source(site_id,source_id)
if source['state']=='cancelled':raise HTTPException(409,'数据源已取消')
if key is not None and not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
raise ValueError('幂等键不合法')
payload=json.dumps({'source_id':source_id},sort_keys=True,separators=(',',':'))
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
if key:
prior=c.execute('SELECT id,state,kind,payload_json FROM build_jobs WHERE site_id=? AND idempotency_key=?',(site_id,key)).fetchone()
if prior:
if prior['kind']!='api_scan' or prior['payload_json']!=payload:raise ValueError('幂等键已关联其他任务')
return {'id':prior['id'],'state':prior['state'],'existing':True}
prior=c.execute('''SELECT id,state FROM build_jobs WHERE site_id=? AND kind='api_scan'
AND state IN ('queued','running','retrying') AND payload_json=? LIMIT 1''',(site_id,payload)).fetchone()
if prior:return {'id':prior['id'],'state':prior['state'],'existing':True}
if source['state'] in ('completed','limit_reached'):return {'state':source['state'],'existing':True,'source_id':source_id}
if c.execute("SELECT COUNT(*) FROM build_jobs WHERE state IN ('queued','running','retrying')").fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS',100):
raise jobs.JobCapacityError('全局任务容量已满')
if c.execute("SELECT COUNT(*) FROM build_jobs WHERE site_id=? AND state IN ('queued','running','retrying')",(site_id,)).fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5):
raise jobs.JobCapacityError('当前网站任务容量已满')
jid=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(?,?,?,?,?,?,?,?,?,?,?)''',
(jid,site_id,'api_scan','queued',actor,db.now(),payload,key,3,900,db.now()))
from . import redis_signal
redis_signal.notify()
return {'id':jid,'state':'queued','existing':False}
@router.post('/{site_id}/xml/staging')
async def api_xml(request:Request,site_id:str,file:UploadFile=File(...),record_tag:str=Form('item'),
content_type:str=Form('product'),mapping_json:str=Form('{}')):
enterprise.require(request,site_id,'editor')
blob=await file.read(MAX_XML_BYTES+1)
try:
mapping=json.loads(mapping_json)
rows=parse_xml_records(blob,record_tag)
result=v8_workflows.stage(site_id,rows,mapping or None,content_type,'xml:'+str(file.filename or 'upload.xml')[:100])
return {**result,'columns':list(rows[0]),'record_tag':record_tag}
except (ValueError,TypeError,json.JSONDecodeError) as exc:raise HTTPException(400,str(exc))
@router.post('/{site_id}/api-sources')
def api_create(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return create_api_source(site_id,payload)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/{site_id}/api-sources')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
v8_workflows._require_site(site_id)
return {'items':db.rows('SELECT id,url,state,next_page,staged_count FROM v84_api_sources WHERE site_id=? ORDER BY created_at DESC LIMIT 100',(site_id,))}
@router.get('/{site_id}/api-sources/{source_id}')
def api_status(request:Request,site_id:str,source_id:str):
enterprise.require(request,site_id,'viewer')
return status(site_id,source_id)
@router.post('/{site_id}/api-sources/{source_id}/scan',status_code=202)
def api_scan(request:Request,site_id:str,source_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return submit_scan(site_id,source_id,request.state.actor,payload.get('idempotency_key'))
except (ValueError,jobs.JobCapacityError) as exc:raise HTTPException(409,str(exc))
@router.post('/{site_id}/api-sources/{source_id}/cancel')
def api_cancel(request:Request,site_id:str,source_id:str):
enterprise.require(request,site_id,'editor')
s=_source(site_id,source_id)
if s['state']!='completed':db.execute("UPDATE v84_api_sources SET state='cancelled',updated_at=? WHERE id=?",(db.now(),source_id))
for row in db.rows("SELECT id FROM build_jobs WHERE site_id=? AND kind='api_scan' AND state IN ('queued','running','retrying')",(site_id,)):
job=db.one('SELECT payload_json FROM build_jobs WHERE id=?',(row['id'],))
if job and json.loads(job['payload_json']).get('source_id')==source_id:jobs.cancel(site_id,row['id'])
return status(site_id,source_id)
+325
View File
@@ -0,0 +1,325 @@
"""V8.5 restricted MySQL connector. Only approved tables and selected columns.
Database-side SELECT-only account and TLS are mandatory. Results are staged
through V8 import batches; no connector directly publishes or edits content.
"""
from __future__ import annotations
import json
import os
import re
import ssl
import uuid
from fastapi import APIRouter, Request, Body, HTTPException
from . import db, enterprise, jobs, secret_refs, v8_workflows, redis_signal
router=APIRouter(prefix='/api/connectors/sites',tags=['V8.5 read-only MySQL'])
IDENT=re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
HOST=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$')
ALLOWED_TYPES={'product','vehicle','post','page','brand','case','service','other'}
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS v85_mysql_sources (
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
host TEXT NOT NULL,port INTEGER NOT NULL,db_name TEXT NOT NULL,
table_name TEXT NOT NULL,key_column TEXT NOT NULL,columns_json TEXT NOT NULL,
user_ref TEXT NOT NULL,password_ref TEXT NOT NULL,
mapping_json TEXT NOT NULL,kind TEXT NOT NULL,page_size INTEGER NOT NULL,
max_pages INTEGER NOT NULL,next_page INTEGER NOT NULL DEFAULT 1,
cursor_json TEXT NOT NULL DEFAULT 'null',scanned_pages INTEGER NOT NULL DEFAULT 0,
staged_count INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL DEFAULT 'ready',
created_at TEXT NOT NULL,updated_at TEXT NOT NULL,error TEXT NOT NULL DEFAULT '');
CREATE INDEX IF NOT EXISTS idx_v85_mysql_site ON v85_mysql_sources(site_id,created_at);
CREATE TABLE IF NOT EXISTS v85_mysql_pages (
source_id TEXT NOT NULL REFERENCES v85_mysql_sources(id) ON DELETE CASCADE,
page INTEGER NOT NULL,batch_id TEXT REFERENCES v8_import_batches(id),
input_count INTEGER NOT NULL,last_cursor_json TEXT NOT NULL,
created_at TEXT NOT NULL,PRIMARY KEY(source_id,page));
''')
def _approved_env(name):
return {v.strip().lower() for v in os.environ.get(name,'').split(',') if v.strip()}
def _identifier(value):
if not isinstance(value,str) or not IDENT.fullmatch(value):
raise ValueError('数据库/表/列标识符不合法')
return value
def create_source(site_id,payload):
v8_workflows._require_site(site_id)
if not isinstance(payload,dict):raise ValueError('MySQL 配置必须为对象')
host=str(payload.get('host','')).lower().rstrip('.')
if not HOST.fullmatch(host) or host not in _approved_env('SITEFORGE_MYSQL_ALLOWED_HOSTS'):
raise ValueError('MySQL 主机未获管理员许可')
port=payload.get('port',3306)
if type(port)!=int or not 1<=port<=65535:raise ValueError('数据库端口不合法')
db_name=_identifier(payload.get('database'))
table=_identifier(payload.get('table'))
approved=_approved_env('SITEFORGE_MYSQL_ALLOWED_TABLES')
if f'{db_name}.{table}'.lower() not in approved:
raise ValueError('MySQL 表未被管理员加入白名单')
key=_identifier(payload.get('key_column'))
columns=payload.get('columns')
if not isinstance(columns,list) or not 1<=len(columns)<=60 or any(not isinstance(x,str) for x in columns):
raise ValueError('MySQL 选择列必须为 1-60 个标识符')
columns=[_identifier(x) for x in columns]
if len(columns)!=len(set(columns)) or key not in columns:
raise ValueError('唯一游标列必须属于选择列,且列不得重复')
kind=payload.get('content_type','product')
if kind not in ALLOWED_TYPES:raise ValueError('不支持的内容类型')
size=payload.get('page_size',100);limit=payload.get('max_pages',20)
if type(size)!=int or type(limit)!=int or not 1<=size<=250 or not 1<=limit<=50 or size*limit>5000:
raise ValueError('每次导入最多 5000 条、50 页、单页 250 条')
mapping=payload.get('mapping') or {}
if not isinstance(mapping,dict) or len(mapping)>80 or any(not isinstance(k,str) or not isinstance(v,str) or v not in columns for k,v in mapping.items()):
raise ValueError('字段映射必须引用已经批准的列')
user_ref=secret_refs.validate_reference(payload.get('user_ref'))
password_ref=secret_refs.validate_reference(payload.get('password_ref'))
source_id=uuid.uuid4().hex
db.insert('''INSERT INTO v85_mysql_sources(id,site_id,host,port,db_name,table_name,key_column,
columns_json,user_ref,password_ref,mapping_json,kind,page_size,max_pages,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(source_id,site_id,host,port,db_name,table,key,json.dumps(columns),user_ref,password_ref,
json.dumps(mapping,ensure_ascii=False),kind,size,limit,db.now(),db.now()))
return status(site_id,source_id)
def _one(site_id,source_id):
row=db.one('SELECT * FROM v85_mysql_sources WHERE id=? AND site_id=?',(source_id,site_id))
if row is None:raise HTTPException(404,'MySQL 数据源不存在')
return row
def status(site_id,source_id):
row=_one(site_id,source_id)
# Neither secret reference nor credential is emitted to browsers or logs.
safe={k:row[k] for k in ('id','site_id','host','db_name','table_name','kind','state',
'next_page','scanned_pages','staged_count','max_pages','page_size','error')}
safe['pages']=db.rows('SELECT page,batch_id,input_count FROM v85_mysql_pages WHERE source_id=? ORDER BY page',(source_id,))
return safe
def relationship_preview(site_id,batch_id):
"""Read-only assessment of brand/category references before batch commit."""
row=db.one('SELECT id,state FROM v8_import_batches WHERE id=? AND site_id=?',(batch_id,site_id))
if not row:raise HTTPException(404,'导入批次不存在')
data=db.rows('SELECT route,content_json FROM v8_import_items WHERE batch_id=? ORDER BY row_number LIMIT 1001',(batch_id,))
if len(data)>1000:raise ValueError('批次超过预览上限')
existing_brands={r['title'].strip().casefold() for r in db.rows("SELECT title FROM entries WHERE site_id=? AND type='brand'",(site_id,))}
batch_brands=set();brands={};categories={};routes={};missing=[]
for record in data:
item=json.loads(record['content_json'])
if item.get('type')=='brand':batch_brands.add(str(item.get('title','')).strip().casefold())
for record in data:
item=json.loads(record['content_json']);meta=item.get('meta') or {};fields=meta.get('fields') or {}
brand=str(fields.get('brand') or '').strip();category=str(fields.get('category') or '').strip()
if brand:brands[brand]=brands.get(brand,0)+1
if category:categories[category]=categories.get(category,0)+1
route=item.get('route','');routes[route]=routes.get(route,0)+1
if brand and brand.casefold() not in existing_brands|batch_brands:
missing.append({'route':route,'brand':brand,'reason':'未找到已存在或本批次品牌页面'})
return {'batch_id':batch_id,'state':row['state'],'items_checked':len(data),
'brands':brands,'categories':categories,'unlinked_brand_references':missing[:100],
'unlinked_brand_total':len(missing),
'duplicate_routes':[k for k,v in routes.items() if v>1],
'note':'关系预览只提示需要核对的品牌页面,不自动创建或修改记录。'}
def query_for(source,last_cursor):
"""Identifier allowlisting occurs at source creation; no arbitrary SQL input."""
key=_identifier(source['key_column'])
cols=json.loads(source['columns_json'])
col_sql=', '.join('`'+_identifier(x)+'`' for x in cols)
table='`'+_identifier(source['db_name'])+'`.`'+_identifier(source['table_name'])+'`'
where=(' WHERE `'+key+'` > %s') if last_cursor is not None else ''
sql='SELECT '+col_sql+' FROM '+table+where+' ORDER BY `'+key+'` ASC LIMIT %s'
return sql,((last_cursor,int(source['page_size'])) if last_cursor is not None else (int(source['page_size']),))
def _fetch_page(source,last_cursor):
# Approved host/table must remain approved when the Worker executes a job.
if source['host'] not in _approved_env('SITEFORGE_MYSQL_ALLOWED_HOSTS') or (
source['db_name']+'.'+source['table_name']).lower() not in _approved_env('SITEFORGE_MYSQL_ALLOWED_TABLES'):
raise ValueError('MySQL 主机或表已经失去授权')
try:
import pymysql
except ImportError as exc:
raise RuntimeError('MySQL 驱动缺失,请安装 requirements.lock') from exc
username=secret_refs.resolve(source['user_ref'])
password=secret_refs.resolve(source['password_ref'])
# Always verify TLS server certificate and hostname, including Docker use.
ca=os.environ.get('SITEFORGE_MYSQL_CA_FILE','')
ctx=ssl.create_default_context(cafile=ca or None)
ctx.verify_mode=ssl.CERT_REQUIRED
ctx.check_hostname=True
sql,params=query_for(source,last_cursor)
conn=None
try:
conn=pymysql.connect(host=source['host'],port=int(source['port']),user=username,
password=password,database=source['db_name'],ssl=ctx,ssl_verify_cert=True,
ssl_verify_identity=True,connect_timeout=8,read_timeout=20,write_timeout=8,
autocommit=True,cursorclass=pymysql.cursors.DictCursor)
with conn.cursor() as cur:
cur.execute('START TRANSACTION READ ONLY')
cur.execute(sql,params)
rows=cur.fetchall()
return rows
except Exception as exc:
# Never surface driver exception text because it can contain credentials.
raise ConnectionError('MySQL 安全连接或只读查询失败,请检查 Worker 服务端日志') from exc
finally:
if conn is not None:
try:conn.rollback()
finally:conn.close()
def _safe_data(rows,columns):
if not isinstance(rows,(list,tuple)) or len(rows)>250:raise ValueError('MySQL 每页记录超限')
cleaned=[]
for item in rows:
if not isinstance(item,dict):raise ValueError('MySQL 应返回字典格式记录')
record={}
for col in columns:
value=item.get(col)
if isinstance(value,(dict,list,tuple)):value=json.dumps(value,ensure_ascii=False,default=str)
if isinstance(value,bytes):value=value.decode('utf-8',errors='replace')
if value is not None and len(str(value))>20000:raise ValueError('字段超出 20000 字符限制')
record[col]=None if value is None else str(value)
cleaned.append(record)
return cleaned
def scan_page(site_id,source_id):
src=_one(site_id,source_id)
if src['state'] in ('cancelled','completed','limit_reached'):
return {'state':src['state'],'next_page':src['next_page']}
page=int(src['next_page'])
if page>src['max_pages']:
db.execute("UPDATE v85_mysql_sources SET state='limit_reached' WHERE id=?",(source_id,))
return {'state':'limit_reached'}
existing=db.one('SELECT * FROM v85_mysql_pages WHERE source_id=? AND page=?',(source_id,page))
origin=f'mysql:{source_id}:page:{page}'
if existing:
count=existing['input_count'];end_cursor=json.loads(existing['last_cursor_json']);batch_id=existing['batch_id']
else:
prior=db.one('SELECT id,input_count FROM v8_import_batches WHERE site_id=? AND origin=?',(site_id,origin))
if prior:
# Only records staged for this exact source+page can be recovered.
batch_id=prior['id'];count=prior['input_count']
last=db.one('''SELECT source_key FROM v8_import_items WHERE batch_id=? ORDER BY row_number DESC LIMIT 1''',(batch_id,))
if not last:raise ValueError('导入检查点缺少最后来源主键')
end_cursor=json.loads(last['source_key'])
else:
rows=_fetch_page(src,json.loads(src['cursor_json']))
cols=json.loads(src['columns_json'])
clean=_safe_data(rows,cols)
count=len(clean);batch_id=None;end_cursor=json.loads(src['cursor_json'])
if count:
keys=[record.get(src['key_column']) for record in clean]
if any(x is None or x=='' for x in keys) or len(set(keys))!=len(keys):
raise ValueError('MySQL 游标列存在空值或重复值;请确保数据库具有唯一索引')
if end_cursor is not None and str(keys[0])<=str(end_cursor):
# Database ORDER BY uses its native type. Numeric keys may be
# converted to strings, so compare only for exact equality.
if str(keys[0])==str(end_cursor):raise ValueError('游标未向前推进')
end_cursor=keys[-1]
mapping=json.loads(src['mapping_json']) or None
staged=v8_workflows.stage(site_id,clean,mapping,src['kind'],origin,
[json.dumps(k,ensure_ascii=False) for k in keys])
batch_id=staged['batch_id']
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
c.execute('''INSERT INTO v85_mysql_pages(source_id,page,batch_id,input_count,last_cursor_json,created_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(source_id,page) DO NOTHING''',
(source_id,page,batch_id,count,json.dumps(end_cursor,ensure_ascii=False),db.now()))
final='completed' if count<src['page_size'] else ('limit_reached' if page>=src['max_pages'] else 'ready')
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
current=c.execute('SELECT state,next_page FROM v85_mysql_sources WHERE id=? AND site_id=?',(source_id,site_id)).fetchone()
if current and current['state']=='cancelled':return {'state':'cancelled','page':page}
if current and current['next_page']==page:
c.execute('''UPDATE v85_mysql_sources SET state=?,next_page=?,cursor_json=?,
scanned_pages=scanned_pages+1,staged_count=staged_count+?,updated_at=? WHERE id=?''',
(final,page+1,json.dumps(end_cursor,ensure_ascii=False),count,db.now(),source_id))
return {'page':page,'state':final,'count':count,'next_page':page+1,'batch_id':batch_id}
def run_scan(site_id,source_id,job_id=None):
while True:
item=scan_page(site_id,source_id)
if item['state'] in ('completed','limit_reached','cancelled'):break
return status(site_id,source_id)
def submit_scan(site_id,source_id,actor,key=None):
src=_one(site_id,source_id)
if src['state']=='cancelled':raise ValueError('数据源已取消')
if key is not None and not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
raise ValueError('幂等键不合法')
payload=json.dumps({'source_id':source_id},sort_keys=True,separators=(',',':'))
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
if key:
prior=c.execute('SELECT id,state,kind,payload_json FROM build_jobs WHERE site_id=? AND idempotency_key=?',(site_id,key)).fetchone()
if prior:
if prior['kind']!='mysql_scan' or prior['payload_json']!=payload:raise ValueError('幂等键已用于其他任务')
return {'id':prior['id'],'state':prior['state'],'existing':True}
prior=c.execute("SELECT id,state FROM build_jobs WHERE site_id=? AND kind='mysql_scan' AND payload_json=? AND state IN ('queued','running','retrying')",(site_id,payload)).fetchone()
if prior:return {'id':prior['id'],'state':prior['state'],'existing':True}
if src['state'] in ('completed','limit_reached'):
return {'state':src['state'],'existing':True,'source_id':source_id}
active="state IN ('queued','running','retrying')"
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE {active}').fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS',100):
raise jobs.JobCapacityError('全局任务容量已满')
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE site_id=? AND {active}',(site_id,)).fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5):
raise jobs.JobCapacityError('当前站点任务容量已满')
jid=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(?,?,?,?,?,?,?,?,?,?,?)''',
(jid,site_id,'mysql_scan','queued',actor,db.now(),payload,key,3,900,db.now()))
redis_signal.notify()
return {'id':jid,'state':'queued','existing':False}
@router.post('/{site_id}/mysql-sources',status_code=201)
def api_create(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
try:return create_source(site_id,payload)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/{site_id}/mysql-sources')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
v8_workflows._require_site(site_id)
return {'items':db.rows('SELECT id,host,db_name,table_name,state,staged_count FROM v85_mysql_sources WHERE site_id=? ORDER BY created_at DESC LIMIT 100',(site_id,))}
@router.get('/{site_id}/mysql-sources/{source_id}')
def api_status(request:Request,site_id:str,source_id:str):
enterprise.require(request,site_id,'viewer')
return status(site_id,source_id)
@router.get('/{site_id}/import-batches/{batch_id}/relationships')
def api_relationships(request:Request,site_id:str,batch_id:str):
enterprise.require(request,site_id,'viewer')
return relationship_preview(site_id,batch_id)
@router.post('/{site_id}/mysql-sources/{source_id}/scan',status_code=202)
def api_scan(request:Request,site_id:str,source_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return submit_scan(site_id,source_id,request.state.actor,payload.get('idempotency_key'))
except (ValueError,jobs.JobCapacityError) as exc:raise HTTPException(409,str(exc))
@router.post('/{site_id}/mysql-sources/{source_id}/cancel')
def api_cancel(request:Request,site_id:str,source_id:str):
enterprise.require(request,site_id,'editor')
src=_one(site_id,source_id)
if src['state']!='completed':
db.execute("UPDATE v85_mysql_sources SET state='cancelled',updated_at=? WHERE id=?",(db.now(),source_id))
for j in db.rows("SELECT id,payload_json FROM build_jobs WHERE site_id=? AND kind='mysql_scan' AND state IN ('queued','running','retrying')",(site_id,)):
if json.loads(j['payload_json']).get('source_id')==source_id:jobs.cancel(site_id,j['id'])
return status(site_id,source_id)
+109
View File
@@ -0,0 +1,109 @@
"""Shared site/route/content rules, with content sanitization."""
import ipaddress
import json
import re
import socket
import uuid
from urllib.parse import urlparse, unquote
import bleach
from . import db
TYPES = ('page','product','post','service','case','brand','vehicle','other')
THEMES = ('automotive','industrial','corporate','minimal')
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']}
def uid():
return uuid.uuid4().hex[:12]
def sanitize(html):
return bleach.clean(html or '', tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, protocols=['http','https','mailto'], strip=True)
def route_path(value, default='/'):
s = (value or default).strip()
if s.startswith(('http://','https://')):
s = urlparse(s).path or '/'
# Query strings cannot be represented as ordinary static file routes.
s = s.split('?',1)[0].split('#',1)[0]
if not s.startswith('/'):
s = '/' + s
if '\\' in s or '\x00' in s or any(part in ('.','..') for part in unquote(s).split('/')):
raise ValueError('不允许路径穿越或反斜杠')
if s.startswith(('/api/','/preview/','/__siteforge/')):
raise ValueError('该路径已保留给系统')
if len(s)>500:
raise ValueError('URL 路径过长')
return s
def slug_of(title):
s = re.sub(r'[^\w\-]+','-', title.strip().lower(), flags=re.UNICODE).strip('-')
return s[:70] or uid()
def clean_hex(s):
if not re.fullmatch(r'#[0-9a-fA-F]{6}', s or ''):
raise ValueError('品牌色需要 #RRGGBB 格式')
return s
def ensure_public_url(url):
parsed=urlparse(url)
if parsed.scheme not in ('https','http') or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('仅支持公开的 HTTP/HTTPS URL')
hostname=parsed.hostname.lower()
if hostname in ('localhost','localhost.localdomain') or hostname.endswith(('.local','.internal')):
raise ValueError('禁止访问内网域名')
try:
addrs=socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme=='https' else 80), type=socket.SOCK_STREAM)
for info in addrs:
ip=ipaddress.ip_address(info[4][0])
if not ip.is_global:
raise ValueError('不允许访问内网、回环或保留 IP 地址')
except socket.gaierror:
raise ValueError('无法解析域名')
return url
def save_entry(site_id, data, existing_id=None, source='manual', replace_route=False):
if not db.one('SELECT id FROM sites WHERE id=?',(site_id,)):
raise ValueError('网站不存在')
kind=str(data.get('type') or 'page').lower()
if kind not in TYPES:
kind='other'
title=str(data.get('title') or '').strip()
if not title:
raise ValueError('内容标题不能为空')
route=route_path(data.get('route'), '/' if kind=='page' and title.lower() in ('home','首页') else f'/{kind}/{slug_of(title)}/')
if route=='/' and kind!='page':
raise ValueError('首页必须是普通页面类型')
old=db.one('SELECT * FROM entries WHERE site_id=? AND route=?',(site_id,route))
if old and old['id'] != existing_id:
if replace_route:
existing_id=old['id']
else:
raise ValueError(f'URL 冲突:{route}')
eid=existing_id or uid()
prior=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid)) if existing_id else None
if existing_id and not prior:
raise ValueError('内容不存在')
meta=data.get('meta', {})
if isinstance(meta,str):
try: meta=json.loads(meta)
except json.JSONDecodeError: meta={}
if not isinstance(meta,dict): meta={}
if 'blocks' in meta:
from .blueprints import validate_blocks
meta['blocks']=validate_blocks(meta['blocks'])
vals=(kind,title,route,slug_of(title),str(data.get('summary') or ''),sanitize(str(data.get('body') or '')),
str(data.get('cover') or ''),str(data.get('seo_title') or ''),str(data.get('seo_description') or ''),
str(data.get('canonical') or ''),str(data.get('robots') or 'index,follow'),json.dumps(meta,ensure_ascii=False),
str(data.get('status') or 'published'))
if prior:
db.execute('''UPDATE entries SET type=?,title=?,route=?,slug=?,summary=?,body=?,cover=?,seo_title=?,seo_description=?,canonical=?,robots=?,meta_json=?,status=?,updated_at=? WHERE site_id=? AND id=?''',(*vals,db.now(),site_id,eid))
else:
db.insert('''INSERT INTO entries (type,title,route,slug,summary,body,cover,seo_title,seo_description,canonical,robots,meta_json,status,created_at,updated_at,site_id,id,source)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',(*vals,db.now(),db.now(),site_id,eid,source))
return eid
def entry_dict(e):
v=dict(e)
try:v['meta']=json.loads(v.pop('meta_json'))
except (ValueError,TypeError):v['meta']={}
return v
+101
View File
@@ -0,0 +1,101 @@
"""Local SQLite data store for SiteForge MVP. One database, hard site_id isolation."""
import os
import sqlite3
import json
from pathlib import Path
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parent.parent
DATA = Path(os.getenv('SITEFORGE_DATA_DIR', str(ROOT / 'storage'))).resolve()
DB_FILE = Path(os.getenv('SITEFORGE_DB_FILE',str(DATA / 'siteforge.sqlite3'))).resolve()
DATABASE_URL = os.getenv('SITEFORGE_DATABASE_URL','')
POSTGRES = DATABASE_URL.startswith(('postgresql://','postgres://'))
def now():
return datetime.now(timezone.utc).isoformat(timespec='seconds')
def connect():
if POSTGRES:
from . import pg_compat
return pg_compat.connect(DATABASE_URL)
DATA.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(DB_FILE)
c.row_factory = sqlite3.Row
c.execute('PRAGMA foreign_keys = ON')
c.execute('PRAGMA busy_timeout=5000')
return c
def init():
with connect() as c:
c.executescript('''
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS sites (
id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT NOT NULL DEFAULT '',
tagline TEXT NOT NULL DEFAULT '', theme TEXT NOT NULL DEFAULT 'corporate',
accent TEXT NOT NULL DEFAULT '#2563eb', logo TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'new',
settings_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
type TEXT NOT NULL, title TEXT NOT NULL, route TEXT NOT NULL, slug TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '',
cover TEXT NOT NULL DEFAULT '', seo_title TEXT NOT NULL DEFAULT '',
seo_description TEXT NOT NULL DEFAULT '', canonical TEXT NOT NULL DEFAULT '',
robots TEXT NOT NULL DEFAULT 'index,follow',
meta_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'published',
source TEXT NOT NULL DEFAULT 'manual', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(site_id, route)
);
CREATE INDEX IF NOT EXISTS idx_entries_site ON entries(site_id, type, status);
CREATE TABLE IF NOT EXISTS redirects (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
old_path TEXT NOT NULL, new_path TEXT NOT NULL, status_code INTEGER NOT NULL DEFAULT 301,
UNIQUE(site_id, old_path)
);
CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
created_at TEXT NOT NULL, status TEXT NOT NULL, total_pages INTEGER NOT NULL DEFAULT 0,
report_json TEXT NOT NULL DEFAULT '{}', zip_path TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_builds_site ON builds(site_id, created_at);
CREATE TABLE IF NOT EXISTS presets (
id TEXT PRIMARY KEY,name TEXT NOT NULL,theme TEXT NOT NULL,
settings_json TEXT NOT NULL,home_blocks_json TEXT NOT NULL,created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS content_schemas (
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
content_type TEXT NOT NULL, fields_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY(site_id,content_type)
);
CREATE TABLE IF NOT EXISTS imports (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
created_at TEXT NOT NULL, source TEXT NOT NULL,
result_json TEXT NOT NULL DEFAULT '{}'
);
''')
if not POSTGRES:
from . import schema_registry
schema_registry.ensure(c)
def rows(sql, params=()):
with connect() as c:
return [dict(r) for r in c.execute(sql, params).fetchall()]
def one(sql, params=()):
with connect() as c:
r=c.execute(sql, params).fetchone()
return dict(r) if r else None
def execute(sql, params=()):
with connect() as c:
cur = c.execute(sql, params)
return cur.rowcount
def insert(sql, params=()):
with connect() as c:
c.execute(sql, params)
if __name__ == '__main__':
init()
+127
View File
@@ -0,0 +1,127 @@
"""Minimal isolated, signed release receiver, not a shell/SSH command runner.
Run behind TLS reverse proxy. The receiver has no database credentials and may
only write under SITEFORGE_DEPLOY_ROOT. Never expose without TLS/firewall.
"""
from __future__ import annotations
import hashlib,hmac,io,json,os,re,shutil,tempfile,time,zipfile
from pathlib import Path,PurePosixPath
from fastapi import FastAPI,HTTPException,Request
from fastapi.responses import FileResponse
app=FastAPI(title='SiteForge Release Agent',docs_url=None,redoc_url=None,openapi_url=None)
ROOT=Path(os.environ.get('SITEFORGE_DEPLOY_ROOT','/srv/releases')).resolve()
SITE=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$')
MAX_ZIP=120*1024*1024
MAX_EXPANDED=1024*1024*1024
def signature(secret,site,timestamp,body,method,path):
# Bind each release signature to the exact HTTP action and target path;
# otherwise an inventory signature could authorize a rollback endpoint.
envelope=(f'{timestamp}\n{site}\n{method.upper()}\n{path}\n'
f'{hashlib.sha256(body).hexdigest()}').encode('utf-8')
return hmac.new(secret.encode(),envelope,hashlib.sha256).hexdigest()
def authorize(site,request,body):
secret=os.environ.get('SITEFORGE_DEPLOY_SECRET','')
if len(secret)<32:raise HTTPException(503,'Release agent secret not configured')
stamp=request.headers.get('x-siteforge-timestamp','')
try:
if abs(int(time.time())-int(stamp))>300:raise ValueError
except ValueError:raise HTTPException(401,'Invalid release timestamp')
actual=request.headers.get('x-siteforge-signature','')
if not hmac.compare_digest(signature(secret,site,stamp,body,request.method,request.url.path),actual):raise HTTPException(401,'Invalid release signature')
def validate_archive(raw):
if len(raw)>MAX_ZIP:raise ValueError('Archive too large')
with zipfile.ZipFile(io.BytesIO(raw)) as z:
if len(z.infolist())>20000:raise ValueError('Too many files')
total=0
for info in z.infolist():
name=info.filename
path=PurePosixPath(name)
if name.startswith('/') or '\\' in name or '..' in path.parts or not name or ':' in name:
raise ValueError('Unsafe archive path')
# Unix symlink type is 0o120000.
if ((info.external_attr>>16)&0o170000)==0o120000:raise ValueError('Symlinks forbidden')
total+=info.file_size
if total>MAX_EXPANDED:raise ValueError('Expanded archive too large')
if info.file_size>2000000 and info.compress_size and info.file_size/info.compress_size>200:
raise ValueError('Suspicious compression ratio')
if 'index.html' not in z.namelist():raise ValueError('Missing site index.html')
return True
def install(site,raw):
if not SITE.fullmatch(site):raise ValueError('Bad site ID')
validate_archive(raw)
digest=hashlib.sha256(raw).hexdigest()[:20]
parent=ROOT/site
releases=parent/'releases'
releases.mkdir(parents=True,exist_ok=True)
target=releases/digest
if not target.is_dir():
stage=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent)))
try:
with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage)
if not (stage/'index.html').is_file():raise ValueError('Invalid site')
try:stage.rename(target)
except FileExistsError:pass
finally:
if stage.exists():shutil.rmtree(stage)
pointer=parent/'.current.tmp'
pointer.write_text(digest,encoding='ascii')
os.replace(pointer,parent/'current')
return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(),'published':True}
@app.get('/health')
def health():return {'healthy':True}
@app.post('/deploy/{site}')
async def deploy(site:str,request:Request):
if not SITE.fullmatch(site):raise HTTPException(400,'Invalid site')
if int(request.headers.get('content-length','0') or 0)>MAX_ZIP:raise HTTPException(413)
parts=[];size=0
async for chunk in request.stream():
size+=len(chunk)
if size>MAX_ZIP:raise HTTPException(413)
parts.append(chunk)
data=b''.join(parts)
authorize(site,request,data)
try:return install(site,data)
except (ValueError,zipfile.BadZipFile) as e:raise HTTPException(400,str(e))
@app.get('/sites/{site}/releases')
def versions(site:str,request:Request):
# No public access to release inventory. Require same signed request semantics.
if not SITE.fullmatch(site):raise HTTPException(400)
authorize(site,request,b'')
parent=ROOT/site
return {'current':(parent/'current').read_text().strip() if (parent/'current').exists() else None,
'releases':sorted(p.name for p in (parent/'releases').iterdir() if p.is_dir()) if (parent/'releases').is_dir() else []}
@app.post('/sites/{site}/rollback/{release}')
async def rollback(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'')
parent=ROOT/site
if not (parent/'releases'/release/'index.html').is_file():raise HTTPException(404)
tmp=parent/'.current.tmp';tmp.write_text(release);os.replace(tmp,parent/'current')
return {'site':site,'release':release,'rolled_back':True}
@app.get('/public/{site}/{path:path}')
def serve(site:str,path:str=''):
if not SITE.fullmatch(site):raise HTTPException(404)
parent=ROOT/site
pointer=parent/'current'
if not pointer.is_file():raise HTTPException(404)
version=pointer.read_text().strip()
if not re.fullmatch('[0-9a-f]{20}',version):raise HTTPException(404)
root=(parent/'releases'/version).resolve()
rel=path or 'index.html'
candidate=(root/rel).resolve()
if not candidate.is_relative_to(root):raise HTTPException(404)
if candidate.is_dir() or not candidate.suffix:candidate=candidate/'index.html'
if not candidate.is_file():raise HTTPException(404)
return FileResponse(candidate)
+351
View File
@@ -0,0 +1,351 @@
"""SiteForge team accounts, RBAC, content history, audit, jobs, migration baseline and release governance.
This module is deliberately database-backed and independent of the frontend. It
extends the existing site factory without allowing editors to bypass permissions.
"""
import csv
import hashlib
import hmac
import io
import json
import os
import secrets
import sqlite3
import uuid
from pathlib import Path
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
from fastapi.responses import Response
from pydantic import BaseModel, Field
from . import db, jobs
from .core import route_path
router = APIRouter(prefix='/api/enterprise', tags=['Enterprise'])
ROLES = ('owner','editor','reviewer','viewer')
ACCESS = {'viewer':0, 'reviewer':1, 'editor':2, 'owner':3, 'superadmin':4}
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS team_users (
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
display_name TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS team_memberships (
user_id TEXT NOT NULL REFERENCES team_users(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
role TEXT NOT NULL, PRIMARY KEY(user_id,site_id));
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY, actor TEXT NOT NULL, site_id TEXT, operation TEXT NOT NULL,
path TEXT NOT NULL, status INTEGER NOT NULL, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_events(created_at);
CREATE TABLE IF NOT EXISTS content_revisions (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
actor TEXT NOT NULL, action TEXT NOT NULL, snapshot_json TEXT NOT NULL,
created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_revision_entry ON content_revisions(site_id,entry_id,created_at);
CREATE TABLE IF NOT EXISTS build_jobs (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
kind TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '', result_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT);
CREATE INDEX IF NOT EXISTS idx_build_jobs_state ON build_jobs(state,created_at);
CREATE TABLE IF NOT EXISTS migration_baseline (
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
route TEXT NOT NULL, seo_title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '', canonical TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 200, PRIMARY KEY(site_id,route));
CREATE TABLE IF NOT EXISTS release_history (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
build_id TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL,
created_at TEXT NOT NULL);
''')
jobs.migrate()
def uid(): return uuid.uuid4().hex[:16]
def hash_password(password, salt=None):
if len(password)<12: raise ValueError('密码至少12位')
salt=salt or secrets.token_bytes(16)
digest=hashlib.scrypt(password.encode(),salt=salt,n=2**14,r=8,p=1,dklen=32)
return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}'
def check_password(password, encoded):
try:
algorithm,n,r,p,salt,expected=encoded.split('$')
if algorithm!='scrypt':return False
result=hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=int(n),r=int(r),p=int(p),dklen=32)
return hmac.compare_digest(result,bytes.fromhex(expected))
except (ValueError,TypeError,MemoryError):return False
def actor(request):
return request.state.actor if hasattr(request.state,'actor') else 'system'
def level(request, site_id=None):
user=getattr(request.state,'account_id',None)
if not user: return ACCESS['superadmin'] if getattr(request.state,'is_superadmin',False) else -1
if not site_id:return ACCESS['viewer']
item=db.one('SELECT role FROM team_memberships WHERE site_id=? AND user_id=?',(site_id,user))
return ACCESS.get(item['role'],-1) if item else -1
def require(request, site_id=None, role='viewer'):
if level(request,site_id)<ACCESS[role]:raise HTTPException(403,'没有访问权限')
def user_by_name(username):
return db.one('SELECT * FROM team_users WHERE username=?',(username,))
def account_from_session(session):
if session.get('sub')=='admin':return {'actor':'admin','id':None,'superadmin':True}
if session.get('sub')!='user':return None
account=db.one('SELECT id,username,disabled FROM team_users WHERE id=?',(session.get('user_id'),))
if not account or account['disabled']:return None
return {'actor':account['username'],'id':account['id'],'superadmin':False}
def log_event(actor_name,path,status,method,site_id=None):
try:
db.insert('INSERT INTO audit_events VALUES (?,?,?,?,?,?,?)',(uid(),actor_name,site_id,method,path,status,db.now()))
except (sqlite3.Error, OSError):pass
def save_revision(site_id,entry_id,actor_name,action):
row=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
if row:
db.insert('INSERT INTO content_revisions VALUES (?,?,?,?,?,?,?)',
(uid(),site_id,entry_id,actor_name,action,json.dumps(row,ensure_ascii=False),db.now()))
class UserCreate(BaseModel):
username:str=Field(min_length=3,max_length=70,pattern=r'^[a-zA-Z0-9._-]+$')
display_name:str=Field(min_length=1,max_length=90)
password:str=Field(min_length=12,max_length=200)
class AssignRole(BaseModel):
user_id:str
role:str
@router.get('/team/users')
def list_users(request:Request):
require(request,role='owner')
if not request.state.is_superadmin:raise HTTPException(403)
return db.rows('SELECT id,username,display_name,disabled,created_at FROM team_users ORDER BY created_at DESC')
@router.post('/team/users',status_code=201)
def create_user(request:Request,payload:UserCreate):
if not request.state.is_superadmin:raise HTTPException(403)
try:
person=uid()
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()))
except sqlite3.IntegrityError:raise HTTPException(409,'用户名已存在')
return {'id':person,'username':payload.username,'display_name':payload.display_name}
@router.patch('/team/users/{user_id}/disable')
def disable_user(request:Request,user_id:str,payload:dict=Body(default_factory=dict)):
if not request.state.is_superadmin:raise HTTPException(403)
if not db.one('SELECT id FROM team_users WHERE id=?',(user_id,)):raise HTTPException(404)
db.execute('UPDATE team_users SET disabled=? WHERE id=?',(int(bool(payload.get('disabled',True))),user_id))
return {'ok':True}
@router.put('/sites/{site_id}/members')
def grant_site(request:Request,site_id:str,payload:AssignRole):
require(request,site_id,'owner')
if payload.role not in ROLES:raise HTTPException(400,'不支持该角色')
if not db.one('SELECT id FROM team_users WHERE id=?',(payload.user_id,)):raise HTTPException(404,'用户不存在')
db.insert('''INSERT INTO team_memberships(user_id,site_id,role) VALUES (?,?,?)
ON CONFLICT(user_id,site_id) DO UPDATE SET role=excluded.role''',(payload.user_id,site_id,payload.role))
return {'assigned':True}
@router.get('/sites/{site_id}/members')
def site_members(request:Request,site_id:str):
require(request,site_id,'owner')
return db.rows('''SELECT u.id,u.username,u.display_name,m.role FROM team_memberships m
JOIN team_users u ON u.id=m.user_id WHERE m.site_id=?''',(site_id,))
@router.get('/audit')
def audits(request:Request,site_id:str='',limit:int=100):
if site_id:require(request,site_id,'owner')
elif not request.state.is_superadmin:raise HTTPException(403)
limit=min(max(limit,1),250)
if site_id:return db.rows('SELECT * FROM audit_events WHERE site_id=? ORDER BY created_at DESC,id DESC LIMIT ?',(site_id,limit))
return db.rows('SELECT * FROM audit_events ORDER BY created_at DESC,id DESC LIMIT ?',(limit,))
@router.get('/sites/{site_id}/entries/{entry_id}/revisions')
def revisions(request:Request,site_id:str,entry_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT id,actor,action,created_at FROM content_revisions WHERE site_id=? AND entry_id=? ORDER BY created_at DESC,id DESC LIMIT 100',(site_id,entry_id))
@router.get('/sites/{site_id}/revisions/{revision_id}')
def revision_detail(request:Request,site_id:str,revision_id:str):
require(request,site_id,'viewer')
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
if not row:raise HTTPException(404)
return {'id':revision_id,'entry':json.loads(row['snapshot_json']),'actor':row['actor']}
@router.post('/sites/{site_id}/revisions/{revision_id}/restore')
def revision_restore(request:Request,site_id:str,revision_id:str):
require(request,site_id,'editor')
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
if not row:raise HTTPException(404)
payload=json.loads(row['snapshot_json'])
entry_id=payload['id']
save_revision(site_id,entry_id,actor(request),'pre_restore')
columns=['type','title','route','slug','summary','body','cover','seo_title','seo_description','canonical','robots','meta_json','status']
with db.connect() as c:
c.execute(f'''UPDATE entries SET {','.join(f'{col}=?' for col in columns)},updated_at=? WHERE site_id=? AND id=?''',
[payload[k] for k in columns]+[db.now(),site_id,entry_id])
if c.total_changes==0:raise HTTPException(404,'原页面已删除,请先创建')
return {'restored':True,'entry_id':entry_id}
@router.post('/sites/{site_id}/jobs',status_code=202)
def enqueue(request:Request,site_id:str,payload:dict=Body(default_factory=dict)):
require(request,site_id,'editor')
kind=payload.get('kind','build')
if kind!='build':raise HTTPException(400,'当前任务队列支持构建任务')
try:
return jobs.submit(site_id, {'mirror_images':bool(payload.get('mirror_images',False))},
actor(request),payload.get('idempotency_key'))
except jobs.JobCapacityError as exc:
raise HTTPException(429, str(exc))
except jobs.JobConflict as exc:
raise HTTPException(409,str(exc))
@router.get('/sites/{site_id}/jobs')
def list_jobs(request:Request,site_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT * FROM build_jobs WHERE site_id=? ORDER BY created_at DESC LIMIT 50',(site_id,))
@router.get('/sites/{site_id}/jobs/{job_id}')
def job_detail(request:Request,site_id:str,job_id:str):
require(request,site_id,'viewer')
job=db.one('SELECT * FROM build_jobs WHERE site_id=? AND id=?',(site_id,job_id))
if not job: raise HTTPException(404,'任务不存在')
return job
@router.post('/sites/{site_id}/jobs/{job_id}/cancel')
def job_cancel(request:Request,site_id:str,job_id:str):
require(request,site_id,'editor')
return jobs.cancel(site_id,job_id)
class BaselineItem(BaseModel):
route:str
seo_title:str=''
description:str=''
canonical:str=''
status:int=200
@router.put('/sites/{site_id}/baseline')
def set_baseline(request:Request,site_id:str,payload:list[BaselineItem]):
require(request,site_id,'editor')
if len(payload)>20000:raise HTTPException(413,'URL 基线最多20000条')
routes=set();rows=[]
for item in payload:
route=route_path(item.route)
if route in routes:raise HTTPException(400,'存在重复 URL:'+route)
routes.add(route)
rows.append((site_id,route,item.seo_title,item.description,item.canonical,item.status))
with db.connect() as c:
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)',rows)
return {'saved':len(rows)}
@router.post('/sites/{site_id}/baseline/snapshot')
def baseline_snapshot(request:Request,site_id:str):
require(request,site_id,'editor')
entries=db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,))
with db.connect() as c:
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)',
[(site_id,x['route'],x['seo_title'],x['seo_description'],x['canonical'],200) for x in entries])
return {'captured':len(entries)}
def diff_baseline(site_id):
baseline=db.rows('SELECT * FROM migration_baseline WHERE site_id=?',(site_id,))
entries={x['route']:x for x in db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,))}
redirects={x['old_path']:x['new_path'] for x in db.rows('SELECT old_path,new_path FROM redirects WHERE site_id=?',(site_id,))}
problems=[]
for old in baseline:
new=entries.get(old['route'])
if not new:
problems.append({'route':old['route'],'field':'url','severity':'error','from':'200','to':f'redirect→{redirects[old["route"]]}' if old['route'] in redirects else 'missing'})
continue
for oldcol,newcol in [('seo_title','seo_title'),('description','seo_description'),('canonical','canonical')]:
if old[oldcol] and old[oldcol]!=new[newcol]:
problems.append({'route':old['route'],'field':oldcol,'severity':'warning','from':old[oldcol],'to':new[newcol]})
return {'baseline_count':len(baseline),'current_count':len(entries),'issues':problems,
'errors':sum(x['severity']=='error' for x in problems),'warnings':sum(x['severity']=='warning' for x in problems),
'passed':not any(x['severity']=='error' for x in problems)}
@router.get('/sites/{site_id}/baseline/report')
def baseline_report(request:Request,site_id:str):
require(request,site_id,'viewer')
return diff_baseline(site_id)
@router.get('/sites/{site_id}/baseline/export.csv')
def baseline_export(request:Request,site_id:str):
require(request,site_id,'viewer')
records=db.rows('SELECT route,seo_title,description,canonical,status FROM migration_baseline WHERE site_id=? ORDER BY route',(site_id,))
dest=io.StringIO(); writer=csv.DictWriter(dest,fieldnames=['route','seo_title','description','canonical','status']);writer.writeheader();writer.writerows(records)
return Response('\ufeff'+dest.getvalue(),media_type='text/csv',headers={'Content-Disposition':'attachment; filename=siteforge-seo-baseline.csv'})
@router.get('/sites/{site_id}/releases')
def release_list(request:Request,site_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT * FROM release_history WHERE site_id=? ORDER BY created_at DESC',(site_id,))
@router.post('/sites/{site_id}/releases/{build_id}/rollback')
def release_rollback(request:Request,site_id:str,build_id:str):
require(request,site_id,'owner')
from .main import publish_local
response=publish_local(site_id,build_id)
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(uid(),site_id,build_id,actor(request),'rollback',db.now()))
return response
# Design tokens are site-specific and independent of the reusable theme package.
DESIGN_DEFAULTS = {'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,'container_width':1200,'button_style':'rounded','header_style':'standard'}
@router.get('/sites/{site_id}/design-tokens')
def get_tokens(request:Request,site_id:str):
require(request,site_id,'viewer')
row=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
if not row:raise HTTPException(404)
settings=json.loads(row['settings_json'])
return {**DESIGN_DEFAULTS,**settings.get('design_tokens',{})}
@router.put('/sites/{site_id}/design-tokens')
def save_tokens(request:Request,site_id:str,payload:dict=Body(...)):
from . import site_design_v87
site_design_v87.require_legacy_enabled()
require(request,site_id,'editor')
tokens={**DESIGN_DEFAULTS}
if set(payload)-set(tokens):raise HTTPException(400,'存在不支持的设计变量')
tokens.update(payload)
if tokens['font'] not in ('system','serif','modern'):raise HTTPException(400,'不支持字体')
if tokens['button_style'] not in ('rounded','square','pill'):raise HTTPException(400,'不支持按钮形状')
if tokens['header_style'] not in ('standard','compact'):raise HTTPException(400,'不支持导航布局')
for field,lo,hi in [('heading_scale',0.75,1.5),('radius',0,30),('section_spacing',32,140),('container_width',900,1600)]:
if not isinstance(tokens[field],(float,int)) or not lo<=tokens[field]<=hi:raise HTTPException(400,'设计变量超出范围: '+field)
record=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
if not record:raise HTTPException(404)
settings=json.loads(record['settings_json']);settings['design_tokens']=tokens
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return tokens
@router.get('/sites/{site_id}/builds/{build_id}/quality')
def quality_report(request:Request,site_id:str,build_id:str):
require(request,site_id,'viewer')
if not db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id)):raise HTTPException(404)
from .quality import report
return report(site_id,build_id)
@router.post('/sites/{site_id}/analyze')
def scan_source(request:Request,site_id:str,payload:dict=Body(...)):
require(request,site_id,'editor')
from .adapters import analyze
try:return analyze(str(payload.get('url','')))
except Exception as exc:raise HTTPException(400,str(exc)[:300])
@router.post('/sites/{site_id}/wordpress-rest')
def wordpress_rest(request:Request,site_id:str,payload:dict=Body(...)):
require(request,site_id,'editor')
from .adapters import import_wordpress_rest
try:return import_wordpress_rest(site_id,str(payload.get('origin','')),int(payload.get('max_items',150)),str(payload.get('on_conflict','skip')))
except Exception as exc:raise HTTPException(400,str(exc)[:300])
+334
View File
@@ -0,0 +1,334 @@
"""Factory V2 endpoints: themes, mappings, build comparisons, team release readiness."""
import json,csv,io
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel,Field
from . import db,theme_store,assembly,enterprise,builder
router=APIRouter(prefix='/api/factory',tags=['Website Factory v2'])
def setup():
theme_store.setup();assembly.setup()
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS release_requests (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
build_id TEXT NOT NULL, requested_by TEXT NOT NULL,
reviewed_by TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT 'pending',
review_note TEXT NOT NULL DEFAULT '', requested_at TEXT NOT NULL,reviewed_at TEXT);
CREATE INDEX IF NOT EXISTS idx_release_request_site ON release_requests(site_id,build_id);
''')
@router.get('/themes')
def all_themes(request:Request):
return theme_store.catalog()
@router.post('/themes/install')
async def install_theme(request:Request,file:UploadFile=File(...)):
if not request.state.is_superadmin:raise HTTPException(403,'仅系统管理员可安装主题')
try:return theme_store.install(await file.read(8_000_001))
except (ValueError,UnicodeError,KeyError,json.JSONDecodeError) as exc:raise HTTPException(400,str(exc))
@router.get('/themes/{theme_id}/compatibility')
def theme_compatibility(request:Request,theme_id:str,version:str=''):
return theme_store.compatibility(theme_id,version or None)
@router.get('/themes/{theme_id}/versions')
def versions(request:Request,theme_id:str):
if theme_id in theme_store.BUILTIN:
archive=theme_store.BASE/theme_id/'versions'
historical=[x.name for x in archive.iterdir() if x.is_dir()] if archive.exists() else []
return [{'theme_id':theme_id,'version':v,'builtin':True} for v in [theme_store.latest(theme_id)]+sorted(historical,reverse=True)]
return db.rows('SELECT * FROM theme_versions WHERE theme_id=? ORDER BY installed_at DESC',(theme_id,))
@router.put('/sites/{site_id}/theme')
def choose_theme(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return theme_store.pin(site_id,str(payload.get('theme','')),payload.get('version') or None)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/theme')
def site_theme(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
site=db.one('SELECT theme FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
pin=theme_store.pinned(site_id)
return {'theme':site['theme'],'version':pin['version'] if pin else theme_store.latest(site['theme'])}
@router.get('/sites/{site_id}/relationships')
def preview_relationships(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
from . import relations
return relations.preview(site_id)
@router.post('/sites/{site_id}/relationships/assemble')
def assemble_relationships(request:Request,site_id:str):
enterprise.require(request,site_id,'editor')
from . import relations
try:return relations.commit(site_id)
except ValueError as exc:raise HTTPException(409,str(exc))
@router.get('/sites/{site_id}/mapping-profiles')
def profiles(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT * FROM mapping_profiles WHERE site_id=? ORDER BY updated_at DESC',(site_id,))
@router.post('/sites/{site_id}/mapping-profiles')
def save_profile(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
from .core import uid
name=str(payload.get('name','')).strip()[:100]
if not name:raise HTTPException(400,'请输入映射方案名称')
rules=payload.get('rules',{})
if not isinstance(rules,dict) or 'title' not in rules:raise HTTPException(400,'请设置标题字段映射')
kind=str(payload.get('content_type','product'))
id=uid()
db.insert('INSERT INTO mapping_profiles VALUES (?,?,?,?,?,?,?)',(id,site_id,name,kind,json.dumps(rules,ensure_ascii=False),db.now(),db.now()))
return {'id':id,'name':name,'rules':rules}
class MapRows(BaseModel):
rows:list[dict]=Field(max_length=1000)
rules:dict[str,str]=Field(default_factory=dict)
content_type:str='product'
prefix:str|None=None
on_conflict:str='skip'
dry_run:bool=True
@router.post('/sites/{site_id}/mapping/parse-file')
async def parse_mapping_file(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'viewer')
from .table_loader import parse_table
try:
decoded=parse_table(file.filename or '',await file.read(12_000_001))
return {**decoded,'suggested':assembly.suggest(decoded['columns'])}
except (ValueError,UnicodeDecodeError,json.JSONDecodeError) as exc:
raise HTTPException(400,str(exc))
@router.post('/sites/{site_id}/mapping/suggest')
def suggested_map(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'viewer')
columns=payload.get('columns',[])
if not isinstance(columns,list) or len(columns)>150:raise HTTPException(400,'字段数量过多')
return {'rules':assembly.suggest(columns)}
@router.post('/sites/{site_id}/mapping/run')
def map_rows(request:Request,site_id:str,payload:MapRows):
enterprise.require(request,site_id,'viewer' if payload.dry_run else 'editor')
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)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/migration/readiness')
def readiness(request:Request,site_id:str,build_id:str=''):
enterprise.require(request,site_id,'viewer')
site=db.one('SELECT id,mode FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
base=enterprise.diff_baseline(site_id)
builds=db.rows('SELECT * FROM builds WHERE site_id=? ORDER BY created_at DESC LIMIT 1',(site_id,))
latest=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id)) if build_id else (builds[0] if builds else None)
report=json.loads(latest['report_json']) if latest else None
gates=[]
if not latest:gates.append({'level':'block','message':'还没有完成静态网站构建'})
if site['mode']=='migration' and base['baseline_count']==0:gates.append({'level':'block','message':'迁移项目尚未导入原站 URL 基线'})
if site['mode']=='migration' and base['errors']:gates.append({'level':'block','message':f'仍有 {base["errors"]} 个旧站 URL 没有原路径页面'})
if base['warnings']:gates.append({'level':'review','message':f'{base["warnings"]} 项 SEO 差异待核查'})
if report:
for key in ('missing_local_images','broken_links'):
if report.get(key):gates.append({'level':'block','message':f'{key} 有 {len(report[key])} 项需处理'})
if report.get('external_assets'):gates.append({'level':'review','message':'仍存在未本地化的远程资源'})
if latest and site['mode']=='migration' and base['baseline_count']:
from . import migration_audit
detail=migration_audit.audit(site_id,latest['id'])
if detail['errors']:
gates.append({'level':'block','message':f'构建文件与原站基线存在 {detail["errors"]} 个阻断问题'})
if detail['warnings']:
gates.append({'level':'review','message':f'生成 HTML 存在 {detail["warnings"]} 项源站内容/SEO 差异'})
if detail.get('unreviewed_warnings'):
gates.append({'level':'block','message':f'还有 {detail["unreviewed_warnings"]} 项差异未经审核,暂不允许发布'})
return {'site_id':site_id,'build_id':latest['id'] if latest else None,'baseline':base,
'report':report,'gates':gates,'can_publish':not any(x['level']=='block' for x in gates),
'must_review':any(x['level']=='review' for x in gates)}
@router.get('/sites/{site_id}/assets/inventory')
def inventory(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
records=db.rows('SELECT route,cover,body,meta_json FROM entries WHERE site_id=?',(site_id,))
from bs4 import BeautifulSoup
assets={}
for row in records:
for url in [row['cover']]+[x.get('src') for x in BeautifulSoup(row['body'] or '','html.parser').select('img[src]')]:
if url:assets.setdefault(url,[]).append(row['route'])
legacy=db.DATA/'legacy'/site_id
media=db.DATA/'media'/site_id
existing=[str(p.relative_to(d)) for d in (legacy,media) if d.exists() for p in d.rglob('*') if p.is_file()]
return {'references':len(assets),'files':len(existing),'assets':[{'url':a,'used_by':routes} for a,routes in sorted(assets.items())][:5000]}
@router.post('/sites/{site_id}/bootstrap')
def bootstrap_site(request:Request,site_id:str):
"""Create full navigation/landing-page structure while preserving existing URLs.
Does not fabricate business descriptions or products; placeholder blocks stay
visibly editable and static empty collections clearly identify missing data.
"""
enterprise.require(request,site_id,'editor')
from . import blueprints
from .core import save_entry
site=db.one('SELECT theme,name FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
pages=[('about','关于我们','/about/','企业介绍与经营理念'),
('contact','联系我们','/contact/','业务咨询与联系方式'),
('listing','产品中心','/products/','按分类浏览产品', 'product'),
('listing','品牌中心','/brands/','了解我们的合作品牌','brand'),
('listing','新闻资讯','/news/','企业资讯与行业动态','post'),
('listing','项目案例','/cases/','精选客户项目','case'),
('standard','服务与解决方案','/services/','业务流程与服务能力'),
('standard','常见问题','/faq/','了解合作流程')]
created=[];skipped=[]
for page in pages:
kind,title,route,description=page[:4]
if db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route)):
skipped.append(route);continue
blocks=blueprints.theme_blocks(site['theme'],kind)
if kind=='listing' and len(page)>4:
for b in blocks:
if b['type']=='collection':b['source']=page[4];b['title']=title;b['limit']=36
if route=='/faq/':blocks=[blueprints.new_block('faq')]
save_entry(site_id,{'type':'page','title':title,'route':route,'summary':description,
'meta':{'page_template':kind,'blocks':blocks}},source='site-bootstrap')
created.append(route)
nav=[{'title':v,'route':r} for v,r in [('首页','/'),('产品中心','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于我们','/about/'),('联系我们','/contact/')]]
raw=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
settings=json.loads(raw['settings_json'] or '{}')
if not settings.get('nav'):
settings['nav']=nav
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'created':created,'skipped':skipped,'navigation':settings.get('nav'),
'message':'页面已建立;企业介绍、联系方式、产品等实际业务内容仍需补充。'}
@router.get('/sites/{site_id}/release-requests')
def requests_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT * FROM release_requests WHERE site_id=? ORDER BY requested_at DESC',(site_id,))
@router.post('/sites/{site_id}/release-requests')
def request_release(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
build_id=str(payload.get('build_id',''))
if not db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id)):
raise HTTPException(404,'构建版本不存在')
status=readiness(request,site_id,build_id)
if not status['can_publish']:
raise HTTPException(409,'迁移/质量门禁尚未通过,不允许提交发布审批')
from . import quality
qa=quality.report(site_id,build_id)
if not qa['passed']:raise HTTPException(409,f'构建质量检测包含 {qa["errors"]} 个错误')
from .core import uid
rid=uid();db.insert('INSERT INTO release_requests(id,site_id,build_id,requested_by,requested_at) VALUES (?,?,?,?,?)',
(rid,site_id,build_id,request.state.actor,db.now()))
return {'id':rid,'state':'pending','build_id':build_id,'review_required':True}
@router.post('/sites/{site_id}/release-requests/{request_id}/review')
def review_release(request:Request,site_id:str,request_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'reviewer')
item=db.one('SELECT * FROM release_requests WHERE id=? AND site_id=?',(request_id,site_id))
if not item:raise HTTPException(404)
if item['state']!='pending':raise HTTPException(409,'该请求已处理')
if item['requested_by']==request.state.actor:raise HTTPException(403,'审批人不能审核自己提交的发布申请')
state=payload.get('state')
if state not in ('approved','rejected'):raise HTTPException(400,'必须选择 approved 或 rejected')
note=str(payload.get('note') or '')[:500]
db.execute('UPDATE release_requests SET state=?,reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND site_id=?',
(state,request.state.actor,note,db.now(),request_id,site_id))
return {'id':request_id,'state':state,'reviewed_by':request.state.actor}
@router.get('/themes/{theme_id}/package')
def download_theme_package(request:Request,theme_id:str,version:str=''):
if not request.state.is_superadmin:raise HTTPException(403,'只有系统管理员可以导出主题包')
import io,zipfile
from fastapi.responses import Response
try:folder=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
buffer=io.BytesIO()
with zipfile.ZipFile(buffer,'w',compression=zipfile.ZIP_DEFLATED) as archive:
manifest=json.loads((folder/'manifest.json').read_text(encoding='utf-8'))
manifest['layout_family']=theme_id if theme_id in theme_store.BUILTIN else manifest.get('layout_family','corporate')
archive.writestr('manifest.json',json.dumps(manifest,ensure_ascii=False,indent=2))
for part in ('theme.css','page-presets.json'):
archive.write(folder/part,arcname=part)
for image_name in ('preview.png','preview.jpg','preview.webp'):
if (folder/image_name).exists():archive.write(folder/image_name,arcname=image_name)
return Response(buffer.getvalue(),media_type='application/zip',headers={'Content-Disposition':f'attachment; filename="{theme_id}-{version or theme_store.latest(theme_id)}.zip"'})
@router.get('/themes/{theme_id}/preview.css')
def theme_preview_css(theme_id:str,version:str=''):
from fastapi.responses import Response
try:dir=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
return Response((dir/'theme.css').read_text(encoding='utf-8'),media_type='text/css')
@router.get('/themes/{theme_id}/preview.pro.css')
def theme_preview_pro_css(theme_id:str,version:str=''):
from fastapi.responses import Response
try:folder=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
asset=folder/'pro.css'
return Response(asset.read_text(encoding='utf-8') if asset.exists() else '',media_type='text/css')
@router.get('/themes/{theme_id}/visual/hero.svg')
def theme_preview_hero(theme_id:str,version:str=''):
from fastapi.responses import Response
try:directory=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
art=directory/'visual'/'hero.svg'
if not art.is_file():raise HTTPException(404,'该主题版本没有内置视觉素材')
return Response(art.read_bytes(),media_type='image/svg+xml',headers={'Cache-Control':'public,max-age=300'})
@router.get('/themes/{theme_id}/preview',response_class=HTMLResponse)
def theme_preview(theme_id:str,version:str='',page_type:str='home'):
from jinja2 import Environment,FileSystemLoader,select_autoescape
from markupsafe import Markup
from html import escape
from . import blueprints,static_site
try:directory=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
if page_type not in blueprints.PAGE_TEMPLATES:
raise HTTPException(400, '不支持的预览页面类型')
manifest=json.loads((directory/'manifest.json').read_text(encoding='utf-8'))
site={'id':'preview','theme':theme_id,'name':manifest.get('name','品牌企业'),
'tagline':'Independent design · Integrated content · Global delivery',
'domain':'','email':'hello@example.com','logo':'','accent':'#346aca'}
page={'title':{'home':'创造有影响力的网站','about':'关于我们','contact':'联系我们','listing':'产品中心','product':'旗舰产品 A','article':'最新企业新闻','brand':'合作品牌','standard':'我们的服务','notfound':'404 · 页面不存在','case':'项目案例','service':'专业服务','faq':'常见问题','category':'演示分类'}[page_type],
'route':'/' if page_type=='home' else ('/404.html' if page_type=='notfound' else ('/categories/demo/' if page_type=='category' else '/preview-example/')),'type':{'product':'product','article':'post','brand':'brand','case':'case','service':'service'}.get(page_type,'page'),
'summary':'从完整主题出发,连接产品、品牌和客户。',
'cover':'','body':'','robots':'index,follow','meta_json':json.dumps({'page_template':page_type})}
records=[{'title':t,'type':'product','route':'/product/example-'+str(i)+'/','summary':'展示产品与解决方案',
'cover':'','meta_json':'{}','status':'published'} for i,t in enumerate(('旗舰产品 A','核心产品 B','定制解决方案 C'))]
site['schemas']={}
blocks=blueprints.theme_blocks(theme_id,page_type,version or None)
block_html=blueprints.render_blocks(blocks,site,page,records)
from . import theme_pro
if (directory/'pro.html').exists():
# Online previews and published pages use the identical professional renderer.
if theme_id in theme_pro.THEMES and directory.resolve()==(theme_store.BASE/theme_id).resolve():
html=theme_pro.render(theme_id,site,page,records,'home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),[],'')
else:
env=Environment(loader=FileSystemLoader([str(directory),str(theme_store.BASE)]),autoescape=select_autoescape(['html']))
context=theme_pro.make_context(site,page,records,'home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),[])
context.update(custom_blocks=Markup(''),has_custom=False,noindex=page_type=='notfound',theme_overrides=True)
html=env.get_template('pro.html').render(**context)
html=html.replace('assets/pro-base.css','/ui/pro-base.css').replace('assets/pro.js','/ui/pro.js').replace('assets/commercial.css','/ui/commercial.css')
html=html.replace('assets/pro.css',f'/api/factory/themes/{theme_id}/preview.pro.css?version={version or theme_store.latest(theme_id)}')
html=html.replace('assets/theme-overrides.css',f'/api/factory/themes/{theme_id}/preview.css?version={version or theme_store.latest(theme_id)}')
else:
env=Environment(loader=FileSystemLoader(str(directory)),autoescape=select_autoescape(['html']))
html=env.get_template('site.html').render(site=site,page=page,mode='home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),content=Markup(''),
blocks=Markup(block_html),blueprint_mode=True,entries=records,listing_items=[],
type_names={'product':'产品','page':'页面'},nav=[],link=lambda x:x.lstrip('/') or './',
asset=lambda x:x,canonical='',seo_title='主题预览 · '+manifest.get('name',''),
seo_description='SiteForge 主题模板预览')
html=static_site.shell(html,site,page,records,[])
html=html.replace('assets/theme.css',f'/api/factory/themes/{theme_id}/preview.css?version={version or theme_store.latest(theme_id)}')
html=html.replace('assets/blocks.css','/ui/blocks.css').replace('assets/factory.css','/ui/factory.css')
html=html.replace('assets/custom.css','/ui/factory.css').replace('assets/site-runtime.js','/ui/site-runtime.js')
return HTMLResponse(html)
+228
View File
@@ -0,0 +1,228 @@
"""Data-import adapters for CSV/JSON/XLSX/WordPress WXR/public HTML crawl."""
import csv
import io
import json
import re
import time
from urllib.parse import urlparse, urljoin, urldefrag
from collections import deque
from defusedxml import ElementTree as SafeET
from bs4 import BeautifulSoup
import httpx
from openpyxl import load_workbook
from .core import route_path, save_entry, ensure_public_url, slug_of
from . import db,wordpress_compat,safe_http
KEY_ALIASES={
'title':('title','name','标题','名称','产品名称','post_title'),
'type':('type','post_type','类型','内容类型'),
'route':('route','url','path','permalink','link','路径','链接'),
'summary':('summary','excerpt','description','摘要','简介'),
'body':('body','content','text','details','正文','内容'),
'cover':('cover','image','image_url','featured_image','图片','封面'),
'seo_title':('seo_title','meta_title','SEO标题'),
'seo_description':('seo_description','meta_description','SEO描述'),
'canonical':('canonical',),
'status':('status',),
}
def normalize(rec):
r={str(k).strip().lower():v for k,v in rec.items()}
clean={}
used=set()
for canonical,aliases in KEY_ALIASES.items():
for alias in aliases:
if alias.lower() in r:
clean[canonical]=r[alias.lower()] if r[alias.lower()] is not None else ''
used.add(alias.lower());break
extra={k:v for k,v in r.items() if k not in used}
embedded=r.get('meta') if isinstance(r.get('meta'),dict) else {}
clean['meta']={**embedded,**{k:v for k,v in extra.items() if k not in ('id','meta')}}
if not clean.get('type'):clean['type']='product'
return clean
def import_records(site_id, records, origin='file', on_conflict='skip'):
if on_conflict not in ('skip','update'):
raise ValueError('无效的冲突策略')
result={'imported':0,'updated':0,'skipped':0,'errors':[]}
for index,rec in enumerate(records,1):
try:
data=normalize(rec)
if not data.get('title'):raise ValueError('缺少 title/name')
kind=data.get('type') or 'product'
if not data.get('route'):
data['route']=f'/{kind}/{slug_of(str(data["title"]))}/'
if '?' in str(data['route']) and not str(data['route']).startswith('http'):
raise ValueError('带查询参数的动态 URL 需要专项映射,不能直接转换为静态路由')
route=route_path(data['route'])
existing=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route))
if existing and on_conflict=='skip':
result['skipped']+=1;continue
save_entry(site_id,data,existing_id=existing['id'] if existing else None,source=origin)
result['updated' if existing else 'imported']+=1
except Exception as exc:
if len(result['errors'])<80:
result['errors'].append({'row':index,'message':str(exc)[:250]})
return result
def import_data_file(site_id, filename, content, conflict):
lower=filename.lower()
if lower.endswith('.csv'):
txt=content.decode('utf-8-sig')
records=list(csv.DictReader(io.StringIO(txt)))
elif lower.endswith('.json'):
parsed=json.loads(content.decode('utf-8-sig'))
if isinstance(parsed,dict):parsed=parsed.get('entries',parsed.get('items',[]))
if not isinstance(parsed,list):raise ValueError('JSON 应当为数据数组或包含 entries/items 数组')
records=parsed
elif lower.endswith('.xlsx'):
wb=load_workbook(io.BytesIO(content),read_only=True,data_only=True)
ws=wb.active
values=ws.iter_rows(values_only=True)
headers=[str(x or '').strip() for x in next(values)]
records=(dict(zip(headers,row)) for row in values)
elif lower.endswith('.xml'):
return import_wxr(site_id,content,conflict)
else:
raise ValueError('仅支持 .csv/.json/.xlsx/.xml')
return import_records(site_id,records,filename,conflict)
def import_wxr(site_id,content,conflict='skip'):
root=SafeET.fromstring(content)
ns={'content':'http://purl.org/rss/1.0/modules/content/',
'excerpt':'http://wordpress.org/export/1.2/excerpt/',
'wp':'http://wordpress.org/export/1.2/'}
# Support alternate WXR versions (e.g. 1.1)
raw_content=None; raw_wp=None; raw_excerpt=None
for e in root.iter():
if e.tag.startswith('{'):
uri=e.tag.split('}')[0][1:]
if uri.endswith('/modules/content/'):raw_content=uri
if 'wordpress.org/export/' in uri and uri.endswith('/') :raw_wp=uri
if uri.endswith('/modules/excerpt/'):raw_excerpt=uri
if raw_content:ns['content']=raw_content
if raw_wp:ns['wp']=raw_wp
if raw_excerpt:ns['excerpt']=raw_excerpt
entries=[]
media_index=wordpress_compat.wp_media_map(root)
for it in root.findall('./channel/item'):
typ=(it.findtext('wp:post_type',default='post',namespaces=ns) or 'post')
status=(it.findtext('wp:status',default='publish',namespaces=ns) or 'publish')
if typ in ('attachment','nav_menu_item','revision') or status not in ('publish','private','draft'):
continue
slug=it.findtext('wp:post_name',namespaces=ns) or slug_of(it.findtext('title') or 'page')
title=it.findtext('title') or slug
link=it.findtext('link') or ''
path=urlparse(link).path if link.startswith(('http://','https://')) else ''
if not path: path=f'/{slug}/' if typ=='page' else f'/{typ}/{slug}/'
wpmeta={}
for pm in it.findall('wp:postmeta',ns):
key=pm.findtext('wp:meta_key',namespaces=ns)
if key:wpmeta[key]=pm.findtext('wp:meta_value',default='',namespaces=ns)
body=it.findtext('content:encoded',default='',namespaces=ns)
taxonomy=wordpress_compat.wp_taxonomy(it)
# ACF stores human-readable fields as plain postmeta keys, while
# underscore-prefixed field references point to the ACF field schema.
# Preserve *all* raw metadata, but expose only safe scalar values as
# structured content fields for the visual parameter component.
fields={k:v[:10000] for k,v in wpmeta.items()
if k and not k.startswith('_') and not k.lower().startswith('field_')
and isinstance(v,str) and len(v)<=10000}
for source,dest in (('_sku','sku'),('_regular_price','regular_price'),
('_sale_price','sale_price'),('_price','price'),
('_stock_status','stock_status'),('_weight','weight')):
if wpmeta.get(source):fields[dest]=str(wpmeta[source])[:1000]
fields.update(taxonomy)
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=[media_index[x] for x in gallery_ids if x in media_index]
if gallery:fields['gallery']=gallery
warnings=[]
if wpmeta.get('_elementor_data'):
elementor=wordpress_compat.elementor_to_html(wpmeta['_elementor_data'])
if not body.strip() and elementor['html']:
body=elementor['html'];warnings.append('Elementor 仅转换已支持的文本与图片组件,原始布局需人工检查')
warnings.extend(elementor['warnings'])
warnings.append('检测到 Elementor 页面构建器 JSON;原生布局不能直接转换为 SiteForge 区块,需人工对照检查。')
if any(k.startswith('_product_attributes') for k in wpmeta):
warnings.append('WooCommerce 序列化商品属性待专项解析,原始 metadata 已保留。')
body=body or ''
entries.append({'type':typ if typ in ('page','post','product','service','case','brand','vehicle') else 'other',
'title':title,'route':path,'body':body,'cover':wp_featured,
'summary':it.findtext('excerpt:encoded',default='',namespaces=ns),
'seo_title':wpmeta.get('_yoast_wpseo_title') or wpmeta.get('rank_math_title') or '',
'seo_description':wpmeta.get('_yoast_wpseo_metadesc') or wpmeta.get('rank_math_description') 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',
'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}})
return import_records(site_id,entries,'wordpress-wxr',conflict)
def safe_get(client,url,max_bytes=3_000_000):
"""Fetch within size and redirect limits using pinned public-only connections."""
response=safe_http.fetch(url,max_bytes=max_bytes,timeout=12,redirects=3)
response.raise_for_status()
if 'text/html' not in response.headers.get('content-type','text/html'):
raise ValueError('页面不是 HTML')
return response.content,response.url
def extract_page(html,url):
s=BeautifulSoup(html,'html.parser')
title=s.title.get_text(' ',strip=True) if s.title else ''
description=s.find('meta',attrs={'name':re.compile('^description$',re.I)})
canonical=s.find('link',attrs={'rel':lambda val: bool(val) and ('canonical' in val if isinstance(val,list) else 'canonical' in val)})
robots=s.find('meta',attrs={'name':re.compile('^robots$',re.I)})
h1=s.find('h1')
heading=h1.get_text(' ',strip=True) if h1 else title
main=s.find('main') or s.find('article') or s.body
body=''
if main:
for tag in main.select('script,style,nav,footer,header,aside,form,iframe,svg'):
tag.decompose()
for image in main.find_all('img'):
src=image.get('src') or image.get('data-src')
if src:image['src']=urljoin(url,src)
for a in main.find_all('a',href=True):
if a['href'].startswith(('http','/','.')):a['href']=urljoin(url,a['href'])
body=str(main)
links=[]
for a in s.find_all('a',href=True):
abs_url=urljoin(url,a['href'])
if urlparse(abs_url).scheme in ('http','https'):
links.append(urldefrag(abs_url)[0])
return ({'type':'page' if urlparse(url).path=='/' else 'post',
'title':heading or 'Untitled','route':urlparse(url).path or '/',
'body':body,'summary':description.get('content','') if description else '',
'seo_title':title,'seo_description':description.get('content','') if description else '',
'canonical':canonical.get('href','') if canonical else '',
'robots':robots.get('content','index,follow') if robots else 'index,follow'},links)
def crawl_site(site_id,start,max_pages=25,conflict='skip'):
ensure_public_url(start)
max_pages=max(1,min(int(max_pages),60))
root=urlparse(start)
host=root.hostname
queue=deque([start]);visited=set();pages=[];errors=[]
# Strict same-host traversal, not open web crawling.
with httpx.Client(timeout=httpx.Timeout(12,connect=5),headers={'User-Agent':'SiteForgeLocalMigration/0.1'},trust_env=False) as client:
while queue and len(visited)<max_pages:
url=queue.popleft()
if url in visited:continue
parsed=urlparse(url)
if parsed.hostname!=host:continue
if re.search(r'/(wp-admin|wp-login|cart|checkout|admin)(/|$)',parsed.path,re.I):continue
visited.add(url)
try:
data,actual=safe_get(client,url)
if urlparse(actual).hostname!=host:raise ValueError('跳转至站外地址')
page,links=extract_page(data,actual)
pages.append(page)
for link in links:
if urlparse(link).hostname==host and link not in visited and link not in queue:
if not re.search(r'\.(pdf|zip|jpg|jpeg|png|gif|webp|js|css)(\?|$)',link,re.I):
queue.append(link)
except Exception as exc:
if len(errors)<40:errors.append({'url':url,'error':str(exc)[:180]})
result=import_records(site_id,pages,'crawler',conflict)
result.update({'crawled':len(visited),'discovered':len(pages),'fetch_errors':errors})
return result
+36
View File
@@ -0,0 +1,36 @@
'''Isolated build subprocess; invoked only by the Worker, never an API handler.'''
import json
import sys
import traceback
from pathlib import Path
from . import db, builder
from fastapi import HTTPException
def main():
job_id=sys.argv[1]
row=db.one('SELECT site_id,payload_json,kind FROM build_jobs WHERE id=?',(job_id,))
if not row:raise SystemExit('Job missing')
out=db.DATA/'job_results';out.mkdir(parents=True,exist_ok=True)
result_file=out/(job_id+'.json')
try:
opts=json.loads(row['payload_json'] or '{}')
if row['kind']=='wordpress_scan':
from . import wp_migration_v82
value=wp_migration_v82.run_scan(row['site_id'],opts['session_id'],job_id)
if value['state']=='page_limit':raise TimeoutError('超过单次任务的采集页数限制,请重新提交任务')
elif row['kind']=='api_scan':
from . import connectors_v84
value=connectors_v84.run_scan(row['site_id'],opts['source_id'],job_id)
elif row['kind']=='mysql_scan':
from . import connectors_v85
value=connectors_v85.run_scan(row['site_id'],opts['source_id'],job_id)
elif row['kind']=='build':
value=builder.build(row['site_id'],bool(opts.get('mirror_images',False)))
else:raise ValueError('未知任务类型')
obj={'ok':True,'result':value}
except Exception as exc:
obj={'ok':False,'error':str(exc)[:1200],'retryable':isinstance(exc,(TimeoutError,ConnectionError)) or (isinstance(exc,HTTPException) and exc.status_code in (502,503,504))}
result_file.write_text(json.dumps(obj,ensure_ascii=False),encoding='utf-8')
return 0 if obj['ok'] else 1
if __name__=='__main__':sys.exit(main())
+142
View File
@@ -0,0 +1,142 @@
'''Durable SQLite build queue. A single job is at most running once per site.
This implementation is deliberately single-host. For multi-host HA, replace
SQLite with PostgreSQL and Redis (outbox+leases) before production scaling.
'''
from __future__ import annotations
import hashlib
import json
import sqlite3
import os
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException
from . import db
class JobConflict(ValueError): pass
class JobCapacityError(ValueError): pass
def _limit(name, default, minimum=1, maximum=10000):
try:return max(minimum,min(maximum,int(os.environ.get(name, default))))
except ValueError:return default
def iso_after(seconds: int) -> str:
return (datetime.now(timezone.utc)+timedelta(seconds=seconds)).isoformat(timespec='seconds')
def migrate():
with db.connect() as c:
existing={r['name'] for r in c.execute('PRAGMA table_info(build_jobs)')}
columns={
'payload_json':"TEXT NOT NULL DEFAULT '{}'",
'idempotency_key':"TEXT",
'max_attempts':"INTEGER NOT NULL DEFAULT 3",
'timeout_seconds':"INTEGER NOT NULL DEFAULT 300",
'available_at':"TEXT",
'cancel_requested':"INTEGER NOT NULL DEFAULT 0",
'worker_id':"TEXT",
'lease_until':"TEXT",
'heartbeat_at':"TEXT",
}
for column,definition in columns.items():
if column not in existing:c.execute(f'ALTER TABLE build_jobs ADD COLUMN {column} {definition}')
c.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_build_key ON build_jobs(site_id,idempotency_key) WHERE idempotency_key IS NOT NULL")
c.execute("CREATE INDEX IF NOT EXISTS idx_build_available ON build_jobs(state,available_at,created_at)")
def submit(site_id, payload:dict, actor:str, key:str|None=None) -> dict:
normalized=json.dumps({'mirror_images':bool(payload.get('mirror_images',False))},sort_keys=True,separators=(',',':'))
if key is not None:
if not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
raise JobConflict('幂等键必须为 8–128 位字母、数字或 _-:.')
# No client key: de-duplicate pending jobs with identical payload for a site.
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
if key:
prior=c.execute('SELECT * FROM build_jobs WHERE site_id=? AND idempotency_key=?',(site_id,key)).fetchone()
if prior:
if prior['payload_json']!=normalized:raise JobConflict('同一幂等键不能对应不同构建参数')
return {'id':prior['id'],'state':prior['state'],'existing':True}
else:
prior=c.execute("SELECT id,state FROM build_jobs WHERE site_id=? AND kind='build' AND state IN ('queued','running','retrying') AND payload_json=? ORDER BY created_at LIMIT 1",(site_id,normalized)).fetchone()
if prior:return {'id':prior['id'],'state':prior['state'],'existing':True}
# Capacity gates run inside the same immediate write transaction as
# INSERT so simultaneous callers cannot bypass the limits.
global_cap=_limit('SITEFORGE_MAX_PENDING_JOBS',100)
site_cap=_limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5)
active="state IN ('queued','running','retrying')"
global_count=c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE {active}').fetchone()[0]
site_count=c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE {active} AND site_id=?',(site_id,)).fetchone()[0]
if global_count>=global_cap:raise JobCapacityError('全局待处理任务达到上限')
if site_count>=site_cap:raise JobCapacityError('该网站待处理任务达到上限')
job_id=uuid.uuid4().hex[:16]
max_attempts=max(1,min(6,int(os.getenv('SITEFORGE_BUILD_MAX_ATTEMPTS','3'))))
timeout=max(10,min(3600,int(os.getenv('SITEFORGE_BUILD_TIMEOUT_SECONDS','300'))))
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,'build','queued',actor,db.now(),normalized,key,max_attempts,timeout,db.now()))
from . import redis_signal
redis_signal.notify()
return {'id':job_id,'state':'queued','existing':False}
def cancel(site_id,job_id):
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
row=c.execute('SELECT state FROM build_jobs WHERE site_id=? AND id=?',(site_id,job_id)).fetchone()
if row is None:raise HTTPException(404,'任务不存在')
if row['state'] in ('done','failed','cancelled'):
return {'id':job_id,'state':row['state'],'cancelled':False}
if row['state'] in ('queued','retrying'):
c.execute("UPDATE build_jobs SET state='cancelled',cancel_requested=1,finished_at=? WHERE id=?",(db.now(),job_id))
return {'id':job_id,'state':'cancelled','cancelled':True}
c.execute('UPDATE build_jobs SET cancel_requested=1 WHERE id=?',(job_id,))
return {'id':job_id,'state':'cancelling','cancelled':True}
def recover_expired():
# Claims are recoverable after a worker crashes. Running jobs are never
# blindly duplicated while their lease is live.
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
rows=c.execute("SELECT * FROM build_jobs WHERE state='running' AND (lease_until IS NULL OR lease_until < ?)",(db.now(),)).fetchall()
for row in rows:
if row['cancel_requested']:
state='cancelled'
else:
state='retrying' if row['attempts']<row['max_attempts'] else 'failed'
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']))
return len(rows)
def submit_migration(site_id,session_id,actor,key=None):
# Queue WordPress migration through the shared lease-based Worker.
from .wp_migration_v81 import _one
_one(site_id,session_id)
if key is not None and not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
raise JobConflict('幂等键必须为 8–128 位字母、数字或 _-:.')
payload=json.dumps({'session_id':session_id},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']!='wordpress_scan' or row['payload_json']!=payload:
raise JobConflict('同一幂等键不能对应不同迁移任务')
return {'id':row['id'],'state':row['state'],'existing':True}
row=c.execute("SELECT id,state FROM build_jobs WHERE kind='wordpress_scan' AND site_id=? AND payload_json=? AND state IN ('queued','running','retrying') ORDER BY created_at LIMIT 1",(site_id,payload)).fetchone()
if row:return {'id':row['id'],'state':row['state'],'existing':True}
global_cap=_limit('SITEFORGE_MAX_PENDING_JOBS',100)
site_cap=_limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5)
if c.execute("SELECT COUNT(*) FROM build_jobs WHERE state IN ('queued','running','retrying')").fetchone()[0]>=global_cap:
raise JobCapacityError('全局待处理任务达到上限')
if c.execute("SELECT COUNT(*) FROM build_jobs WHERE site_id=? AND state IN ('queued','running','retrying')",(site_id,)).fetchone()[0]>=site_cap:
raise JobCapacityError('该网站待处理任务达到上限')
job_id=uuid.uuid4().hex[:16]
attempts=max(1,min(6,int(os.getenv('SITEFORGE_WP_SCAN_MAX_ATTEMPTS','3'))))
timeout=max(60,min(3600,int(os.getenv('SITEFORGE_WP_SCAN_TIMEOUT_SECONDS','900'))))
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,'wordpress_scan','queued',actor,
db.now(),payload,key,attempts,timeout,db.now()))
from . import redis_signal
redis_signal.notify()
return {'id':job_id,'state':'queued','existing':False}
+837
View File
@@ -0,0 +1,837 @@
"""SiteForge Docker-first website factory API and Web console."""
import io
from contextlib import asynccontextmanager
import json
import re
import shutil
import uuid
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Body, Request
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, Response, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
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 .version import VERSION, BUILD_SHA
from .request_trace import TraceMiddleware
from .preview_rewrite import rewrite_preview_html
from .core import uid, THEMES, TYPES, clean_hex, save_entry, route_path, entry_dict
ROOT=Path(__file__).resolve().parent.parent
@asynccontextmanager
async def lifespan(app):
security.configure()
db.init()
enterprise.setup()
factory_api.setup()
migration_audit.setup()
reusables.setup()
release_api.setup()
cms_pipeline.setup()
v8_workflows.setup()
v8_templates.setup()
wp_migration_v81.setup()
wp_migration_v82.setup()
wp_migration_v83.setup()
connectors_v84.setup()
connectors_v85.setup()
studio_v86.setup()
site_design_v87.setup()
yield
app=FastAPI(title='SiteForge 网站工厂',description='Docker-first visual website factory',version=VERSION,lifespan=lifespan)
app.add_middleware(TraceMiddleware)
app.mount('/ui',StaticFiles(directory=str(ROOT/'app'/'static')),name='static')
@app.get('/favicon.ico',include_in_schema=False)
def favicon():
return FileResponse(ROOT/'app'/'static'/'favicon.ico',media_type='image/x-icon')
app.include_router(enterprise.router)
app.include_router(factory_api.router)
app.include_router(migration_audit.router)
app.include_router(reusables.router)
app.include_router(release_api.router)
app.include_router(cms_pipeline.router)
app.include_router(v8_workflows.router)
app.include_router(v8_templates.router)
app.include_router(wp_migration_v81.router)
app.include_router(wp_migration_v82.router)
app.include_router(wp_migration_v83.router)
app.include_router(connectors_v84.router)
app.include_router(connectors_v85.router)
app.include_router(studio_v86.router)
app.include_router(site_design_v87.router)
@app.middleware('http')
async def require_login(request:Request, call_next):
path = request.url.path
public = path in ('/login', '/api/auth/login', '/api/health', '/favicon.ico') or path.startswith('/ui/')
if not public:
session = security.read_session(request.cookies.get(security.COOKIE))
identity = enterprise.account_from_session(session) if session else None
if not identity:
if path.startswith('/api/') or path in ('/openapi.json',):
return JSONResponse({'detail':'请先登录 SiteForge'},status_code=401)
return RedirectResponse('/login',status_code=303)
request.state.account_id=identity['id']
request.state.is_superadmin=identity['superadmin']
request.state.actor=identity['actor']
if request.method not in ('GET','HEAD','OPTIONS'):
if not security.verify_csrf(session,request.headers.get('x-siteforge-csrf','')):
return JSONResponse({'detail':'安全验证失败,请刷新页面'},status_code=403)
origin=request.headers.get('origin')
if origin:
from urllib.parse import urlparse
parsed=urlparse(origin)
forwarded=request.headers.get('x-forwarded-host') or request.headers.get('host') or ''
if parsed.netloc.lower()!=forwarded.lower():
return JSONResponse({'detail':'不允许跨站请求'},status_code=403)
if not identity['superadmin']:
import re
segments=re.match(r'^/api/sites/([^/]+)(?:/|$)',path)
enterprise_segments=re.match(r'^/api/(?:enterprise|factory|audit|production|migrations|connectors)/sites/([^/]+)(?:/|$)',path)
asset_segments=re.match(r'^/(?:editor|preview|local-media)/([^/]+)(?:/|$)',path)
site_id=(segments or enterprise_segments or asset_segments)
if site_id:
site_id=site_id.group(1)
role=('viewer' if request.method in ('GET','HEAD') else 'editor')
if path.startswith('/api/factory/sites/') and '/release-requests/' in path and path.endswith('/review'):
role='reviewer'
if any(t in path for t in ('/publish-local','/rollback','/members','/deploy')) or request.method=='DELETE' and segments and segments.group(0).rstrip('/').endswith(site_id):
role='owner'
if path.endswith('/baseline') and request.method=='GET':role='viewer'
try:enterprise.require(request,site_id,role)
except HTTPException:
return JSONResponse({'detail':'没有访问该网站的权限'},status_code=403)
elif path in ('/api/sites','/api/presets') and request.method not in ('GET','HEAD'):
return JSONResponse({'detail':'只有系统管理员可创建站点/全局模板'},status_code=403)
elif path.startswith('/api/enterprise/team/') or path in ('/docs','/openapi.json'):
return JSONResponse({'detail':'没有全局管理权限'},status_code=403)
if request.method in ('PATCH','PUT','DELETE') and path.startswith('/api/sites/') and '/entries/' in path:
pieces=path.strip('/').split('/')
if len(pieces)>=5:
enterprise.save_revision(pieces[2],pieces[4],identity['actor'],'before_'+request.method.lower())
response=await call_next(request)
if not public:
if request.method in ('POST','PUT','PATCH','DELETE'):
site_match=__import__('re').match(r'^/api/(?:(?:enterprise|factory|production|migrations|connectors)/)?sites/([^/]+)',path)
enterprise.log_event(request.state.actor,path,response.status_code,request.method,
site_match.group(1) if site_match else None)
if request.method=='GET' and path=='/api/sites' and not request.state.is_superadmin and response.status_code==200:
# Apply the same site-scope filter at the top-level list boundary.
import json as _json
permitted=enterprise.db.rows('SELECT site_id FROM team_memberships WHERE user_id=?',(request.state.account_id,))
ids={r['site_id'] for r in permitted}
from starlette.responses import StreamingResponse
raw=b''
async for part in response.body_iterator:raw+=part
data=_json.loads(raw)
response=JSONResponse([x for x in data if x['id'] in ids])
response.headers['X-Content-Type-Options']='nosniff'
response.headers['X-Frame-Options']='SAMEORIGIN' if path.startswith('/editor/') else 'DENY'
response.headers['Referrer-Policy']='strict-origin-when-cross-origin'
response.headers['Cache-Control']='no-store' if path.startswith('/api/auth/') else response.headers.get('Cache-Control','no-cache')
return response
@app.get('/production-v8',response_class=HTMLResponse)
def production_v8():
return (ROOT/'app'/'templates'/'production-v8.html').read_text(encoding='utf-8')
@app.get('/theme-gallery',response_class=HTMLResponse)
def theme_gallery():
return (ROOT/'app'/'templates'/'theme-gallery.html').read_text(encoding='utf-8')
@app.get('/operations',response_class=HTMLResponse)
def operations():
return (ROOT/'app'/'templates'/'operations.html').read_text(encoding='utf-8')
@app.get('/factory-studio',response_class=HTMLResponse)
def factory_studio():
return (ROOT/'app'/'templates'/'factory-v2.html').read_text(encoding='utf-8')
@app.get('/login',response_class=HTMLResponse)
def login_page():
return (ROOT/'app'/'templates'/'login.html').read_text(encoding='utf-8')
class LoginIn(BaseModel):
password: str
username: str = "admin"
@app.post('/api/auth/login')
def login(payload:LoginIn, request:Request):
remote = request.client.host if request.client else 'unknown'
security.check_login_rate(remote)
if payload.username=='admin':
authenticated=security.check_password(payload.password)
token=security.sign_session() if authenticated else None
else:
user=enterprise.user_by_name(payload.username)
authenticated=bool(user and not user['disabled'] and enterprise.check_password(payload.password,user['password_hash']))
token=security.sign_user_session(user['id']) if authenticated else None
if not authenticated:
security.note_login_failure(remote)
raise HTTPException(401, '账号或密码不正确')
security.note_login_success(remote)
response = JSONResponse({'ok':True})
response.set_cookie(security.COOKIE, token, max_age=security.MAX_AGE,
httponly=True, secure=security.COOKIE_SECURE, samesite='strict', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
@app.get('/api/auth/me')
def whoami(request:Request):
session=security.read_session(request.cookies.get(security.COOKIE))
return {'authenticated':True,'user':request.state.actor,'superadmin':request.state.is_superadmin,'csrf':security.csrf(session),'version':VERSION}
@app.post('/api/auth/logout')
def logout():
response=JSONResponse({'ok':True})
response.delete_cookie(security.COOKIE,path='/')
return response
class SiteIn(BaseModel):
name:str = Field(min_length=1,max_length=150)
domain:str = ''
tagline:str = ''
theme:str = 'corporate'
accent:str = '#2563eb'
logo:str = ''
mode:str = 'new'
origin:str = ''
preset_id:str = ''
class SitePatch(BaseModel):
name:str|None=None
domain:str|None=None
tagline:str|None=None
theme:str|None=None
accent:str|None=None
logo:str|None=None
mode:str|None=None
origin:str|None=None
class CrawlIn(BaseModel):
url:str
max_pages:int=20
on_conflict:str='skip'
class BuildIn(BaseModel):
mirror_images:bool=False
class RedirectIn(BaseModel):
old_path:str
new_path:str
status_code:int=301
def get_site(site_id):
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404,'找不到网站')
site['settings']=json.loads(site.pop('settings_json'))
return site
def get_entry(site_id,eid):
val=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid))
if not val:raise HTTPException(404,'找不到内容')
return entry_dict(val)
def fail(exc):
if isinstance(exc,HTTPException):raise exc
raise HTTPException(400,detail=str(exc)[:500])
@app.get('/',response_class=HTMLResponse)
def home():
return (ROOT/'app'/'templates'/'workspace.html').read_text(encoding='utf-8')
@app.get('/classic',response_class=HTMLResponse)
def classic_home():
return (ROOT/'app'/'templates'/'admin.html').read_text(encoding='utf-8')
@app.get('/editor/{site_id}/{entry_id}',response_class=HTMLResponse)
def visual_editor(site_id:str,entry_id:str):
get_entry(site_id,entry_id)
return (ROOT/'app'/'templates'/'editor.html').read_text(encoding='utf-8')
@app.get('/api/health')
def health():return {'status':'ok','name':'SiteForge','version':VERSION,'commit':BUILD_SHA}
@app.get('/api/themes')
def themes():
manifests=[]
for theme in THEMES:
meta=json.loads((ROOT/'themes'/theme/'manifest.json').read_text(encoding='utf-8'))
manifests.append(meta)
return manifests
@app.get('/api/editor/blocks')
def editor_blocks():
return {'blocks':blueprints.BLOCKS,'bindings':blueprints.BINDABLE,'page_templates':blueprints.PAGE_TEMPLATES,'property_schemas':blueprints.BLOCK_PROPERTY_SCHEMA}
@app.get('/api/sites')
def list_sites():
arr=db.rows('SELECT * FROM sites ORDER BY updated_at DESC')
for s in arr:
s.pop('settings_json',None)
s['entries_count']=db.one('SELECT count(*) n FROM entries WHERE site_id=?',(s['id'],))['n']
s['build_count']=db.one('SELECT count(*) n FROM builds WHERE site_id=?',(s['id'],))['n']
return arr
@app.post('/api/sites',status_code=201)
def create_site(payload:SiteIn):
if payload.theme not in theme_store.ids():raise HTTPException(400,'没有该模板')
try:clean_hex(payload.accent)
except Exception as exc:fail(exc)
site_id=uid()
settings={'origin':payload.origin,'nav':[],'email':'','footer_text':''}
homeblocks=blueprints.theme_blocks(payload.theme,'home')
if payload.preset_id:
p=db.one('SELECT * FROM presets WHERE id=?',(payload.preset_id,))
if not p:raise HTTPException(404,'模板预设不存在')
payload.theme=p['theme'];settings.update(json.loads(p['settings_json']))
homeblocks=json.loads(p['home_blocks_json'])
with db.connect() as c:
c.execute('''INSERT INTO sites(id,name,domain,tagline,theme,accent,logo,mode,settings_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)''',
(site_id,payload.name,payload.domain,payload.tagline,payload.theme,payload.accent,payload.logo,payload.mode,json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
save_entry(site_id,{'type':'page','title':'首页','route':'/','summary':payload.tagline,
'seo_title':payload.name,'meta':{'blocks':homeblocks}},source='starter')
return get_site(site_id)
@app.get('/api/sites/{site_id}')
def site_detail(site_id:str):
site=get_site(site_id)
site['counts']={r['type']:r['n'] for r in db.rows('SELECT type,count(*) n FROM entries WHERE site_id=? GROUP BY type',(site_id,))}
site['builds']=db.rows('SELECT id,created_at,total_pages,status FROM builds WHERE site_id=? ORDER BY created_at DESC LIMIT 10',(site_id,))
return site
@app.patch('/api/sites/{site_id}')
def patch_site(site_id:str,patch:SitePatch):
old=get_site(site_id)
data=patch.model_dump(exclude_unset=True)
if 'theme' in data and data['theme'] not in theme_store.ids():raise HTTPException(400,'没有该模板')
if 'accent' in data:
try:clean_hex(data['accent'])
except Exception as exc:fail(exc)
settings=old['settings']
if 'origin' in data:settings['origin']=data.pop('origin') or ''
valid=['name','domain','tagline','theme','accent','logo','mode']
fields=[k for k in data if k in valid]
with db.connect() as c:
for k in fields:c.execute(f'UPDATE sites SET {k}=? WHERE id=?',(data[k],site_id))
c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return get_site(site_id)
@app.delete('/api/sites/{site_id}')
def delete_site(site_id:str):
get_site(site_id)
db.execute('DELETE FROM sites WHERE id=?',(site_id,))
for relative in (db.DATA/'media'/site_id,db.DATA/'builds'/site_id):
if relative.exists():shutil.rmtree(relative)
for z in (db.DATA/'archives').glob(site_id+'-*.zip') if (db.DATA/'archives').exists() else []:
z.unlink()
return {'deleted':True}
@app.post('/api/sites/{site_id}/clone',status_code=201)
def clone_site(site_id:str,settings:dict[str,Any]=Body(default_factory=dict)):
original=get_site(site_id)
clone=create_site(SiteIn(name=settings.get('name') or original['name']+' 副本',theme=original['theme'],
domain=settings.get('domain',''),tagline=original['tagline'],accent=original['accent'],logo=original['logo']))
entries=db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
for old in entries:
e=entry_dict(old)
try:save_entry(clone['id'],e,source='clone',replace_route=True)
except Exception:pass
return get_site(clone['id'])
@app.get('/api/sites/{site_id}/entries')
def list_entries(site_id:str,typ:str='',q:str=''):
get_site(site_id)
sql='SELECT * FROM entries WHERE site_id=?';params=[site_id]
if typ:sql+=' AND type=?';params.append(typ)
if q:sql+=' AND (title LIKE ? OR route LIKE ?)';params.extend(['%'+q+'%','%'+q+'%'])
sql+=' ORDER BY CASE WHEN route="/" THEN 0 ELSE 1 END, type, updated_at DESC'
return [entry_dict(x) for x in db.rows(sql,params)]
@app.post('/api/sites/{site_id}/entries',status_code=201)
def create_entry(site_id:str,payload:dict[str,Any]):
try:
eid=save_entry(site_id,payload)
return get_entry(site_id,eid)
except Exception as exc:fail(exc)
@app.patch('/api/sites/{site_id}/entries/{entry_id}')
def update_entry(site_id:str,entry_id:str,payload:dict[str,Any]):
previous=get_entry(site_id,entry_id)
if isinstance(payload.get('meta'),dict) and any(k in payload['meta'] for k in ('blocks','layout_mode','page_template')):
site_design_v87.require_legacy_enabled()
data={**previous,**payload}
try:
save_entry(site_id,data,existing_id=entry_id)
return get_entry(site_id,entry_id)
except Exception as exc:fail(exc)
@app.delete('/api/sites/{site_id}/entries/{entry_id}')
def delete_entry(site_id:str,entry_id:str):
get_entry(site_id,entry_id)
db.execute('DELETE FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
return {'deleted':True}
def infer_page_template(entry):
route=str(entry.get('route','')).lower()
typ=entry.get('type')
if route=='/':return 'home'
if typ in ('product','vehicle'):return 'product'
if typ=='post':return 'article'
if typ=='brand':return 'brand'
if any(k in route for k in ('/about', '/company', '/our-story')):return 'about'
if any(k in route for k in ('/contact', '/inquiry')):return 'contact'
return 'standard'
@app.post('/api/sites/{site_id}/scaffold')
def scaffold_standard_pages(site_id:str):
"""Add commonly needed enterprise pages while never overwriting existing URLs."""
site=get_site(site_id)
defaults=[('about','关于我们','/about/','企业介绍、经营理念与核心服务能力'),
('contact','联系我们','/contact/','联系方式、业务咨询与合作对接'),
('standard','服务与解决方案','/services/','适合不同客户需求的业务与服务')]
created=[];skipped=[]
for kind,title,route,summary in defaults:
if db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route)):
skipped.append(route);continue
meta={'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))}
save_entry(site_id,{'type':'page','title':title,'route':route,'summary':summary,'meta':meta},source='scaffold')
created.append(route)
return {'created':created,'skipped':skipped,'note':'不覆盖已有页面或修改任何原始 URL'}
@app.post('/api/sites/{site_id}/apply-theme')
def apply_theme_templates(site_id:str,payload:dict=Body(default_factory=dict)):
"""Re-assemble existing content with the selected theme without modifying URLs/SEO."""
site=get_site(site_id)
mode=payload.get('mode','missing')
if mode not in ('missing','overwrite'):raise HTTPException(400,'无效的套版模式')
entries=[entry_dict(x) for x in db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))]
changed=0;skipped=0
for entry in entries:
if mode=='missing' and entry['meta'].get('blocks'):
skipped+=1;continue
kind=infer_page_template(entry)
entry['meta']={**entry['meta'],'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))}
save_entry(site_id,entry,existing_id=entry['id'])
changed+=1
return {'updated':changed,'skipped':skipped,'total':len(entries),'theme':site['theme'],'mode':mode,
'note':'只修改页面模板/区块,不改变正文原数据、URL、SEO 和 Canonical'}
@app.get('/api/sites/{site_id}/schemas')
def site_schemas(site_id:str):
get_site(site_id)
rows=db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,))
result={k:v for k,v in blueprints.CONTENT_MODELS.items()}
for row in rows:result[row['content_type']]=json.loads(row['fields_json'])
return result
@app.put('/api/sites/{site_id}/schemas/{kind}')
def update_schema(site_id:str,kind:str,payload:list[dict]=Body(...)):
get_site(site_id)
if kind not in TYPES:raise HTTPException(400,'未知内容类型')
if len(payload)>40:raise HTTPException(400,'最多允许40个自定义字段')
clean=[];names=set()
for field in payload:
name=str(field.get('name') or '')
if not re.fullmatch('[a-zA-Z_][a-zA-Z0-9_]{0,50}',name) or name in names:raise HTTPException(400,'字段名称不合法或重复')
typ=field.get('type','text')
if typ not in ('text','textarea','number','date','url','boolean'):raise HTTPException(400,'不支持该字段类型')
names.add(name);clean.append({'name':name,'label':str(field.get('label') or name)[:80],'type':typ})
with db.connect() as conn:
conn.execute('INSERT INTO content_schemas(site_id,content_type,fields_json) VALUES (?,?,?) ON CONFLICT(site_id,content_type) DO UPDATE SET fields_json=excluded.fields_json',(site_id,kind,json.dumps(clean,ensure_ascii=False)))
return {'content_type':kind,'fields':clean}
@app.get('/api/sites/{site_id}/pages/{entry_id}/blueprint')
def read_blueprint(site_id:str,entry_id:str):
entry=get_entry(site_id,entry_id)
kind=entry['meta'].get('page_template') or ('home' if entry['route']=='/' else 'product' if entry['type'] in ('product','vehicle') else 'article' if entry['type']=='post' else 'standard')
return {'entry_id':entry_id,'page_template':kind,'layout_mode':entry['meta'].get('layout_mode','append'),'blocks':entry['meta'].get('blocks',[]),'entry':entry}
class BlueprintPayload(BaseModel):
blocks:list[dict]
page_template:str='standard'
layout_mode:str='append'
site_design:dict|None=None
@app.put('/api/sites/{site_id}/pages/{entry_id}/blueprint')
def save_blueprint(site_id:str,entry_id:str,payload:BlueprintPayload):
site_design_v87.require_legacy_enabled()
entry=get_entry(site_id,entry_id)
if payload.page_template not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'不支持的页面模板')
try:validated=blueprints.validate_blocks(payload.blocks)
except ValueError as exc:raise HTTPException(400,str(exc))
if payload.layout_mode not in ('append','replace'):raise HTTPException(400,'不支持的页面设计模式')
meta={**entry['meta'],'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated}
save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id)
return {'saved':True,'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated}
@app.post('/api/sites/{site_id}/pages/{entry_id}/blueprint/preset')
def reset_blueprint(site_id:str,entry_id:str,payload:dict=Body(default_factory=dict)):
site_design_v87.require_legacy_enabled()
entry=get_entry(site_id,entry_id)
kind=payload.get('page_template','home' if entry['route']=='/' else 'standard')
if kind not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'未知的页面模板')
site=get_site(site_id)
blocks=blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))
meta={**entry['meta'],'blocks':blocks,'page_template':kind}
save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id)
return {'page_template':kind,'blocks':blocks}
@app.post('/api/sites/{site_id}/pages/{entry_id}/editor-preview',response_class=HTMLResponse)
def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload):
site=get_site(site_id);page=get_entry(site_id,entry_id)
try:blocks=blueprints.validate_blocks(payload.blocks)
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
site['schemas']={k:v for k,v in blueprints.CONTENT_MODELS.items()}
for row in db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)):
site['schemas'][row['content_type']]=json.loads(row['fields_json'])
content=blueprints.render_blocks(blocks,site,page,[entry_dict(x) for x in db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,))])
# The editor now uses the same trusted theme renderer as production builds.
# CSS assets are inlined because srcdoc is sandboxed and has no /assets mount.
from . import theme_pro, theme_store, responsive
import re as _re
from markupsafe import Markup
from jinja2 import Environment,FileSystemLoader,select_autoescape
settings=site.get('settings') or {}
if payload.site_design is not None:
settings={**settings,'site_design':site_design_v87.validate(payload.site_design)}
if payload.site_design is not None:
settings['design_tokens']=settings['site_design']['tokens']
site['settings_json']=json.dumps(settings,ensure_ascii=False)
records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,))
page['meta_json']=json.dumps({**page['meta'],'layout_mode':payload.layout_mode},ensure_ascii=False)
data=theme_store.pinned(site_id)
theme_dir=theme_store.resolve(site['theme'],data['version'] if data else None)
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(ROOT/'themes'/site['theme']).resolve():
html=theme_pro.render(site['theme'],site,page,records,blocks_html=content)
elif (theme_dir/'pro.html').exists():
env=Environment(loader=FileSystemLoader([str(theme_dir),str(ROOT/'themes')]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,page,records)
ctx.update(custom_blocks=Markup(content),has_custom=bool(content),noindex=False,
designer_replace=payload.layout_mode=='replace',theme_overrides=True)
html=env.get_template('pro.html').render(**ctx)
else:
html='<html><head><meta charset="utf-8"></head><body>'+content+'</body></html>'
styles='\n'.join([
(ROOT/'app'/'static'/'pro-base.css').read_text(encoding='utf-8'),
(ROOT/'app'/'static'/'commercial.css').read_text(encoding='utf-8'),
(ROOT/'app'/'static'/'blocks.css').read_text(encoding='utf-8'),
(ROOT/'app'/'static'/'factory.css').read_text(encoding='utf-8'),
(theme_dir/'pro.css').read_text(encoding='utf-8') if (theme_dir/'pro.css').exists() else '',
(theme_dir/'theme.css').read_text(encoding='utf-8'),
responsive.editor_css(blocks),
site_design_v87.responsive_css(site_design_v87.published(settings)),
site_design_v87.token_css(site_design_v87.published(settings)['tokens'])])
html=_re.sub(r'<link\s+rel="stylesheet"[^>]*>', '', html)
html=_re.sub(r'<script\s+defer\s+src="assets/[^"]+"[^>]*></script>', '', html)
html=html.replace('</head>','<style>'+styles+'</style></head>')
bridge = r'''<script>(function(){
const fields={'title':'h1,h2','description':'.sf-hero-copy p,.sf-image-text p'};
document.querySelectorAll('[data-block]').forEach(block=>{
for(const [field,selector] of Object.entries(fields)){
const target=block.querySelector(selector);if(!target)continue;
target.title='双击直接修改文字';target.addEventListener('dblclick',()=>{
target.contentEditable='plaintext-only';target.focus();
target.style.outline='2px dashed #367bdd';
});
target.addEventListener('blur',()=>{
if(!target.isContentEditable)return;
target.removeAttribute('contenteditable');target.style.outline='';
parent.postMessage({source:'siteforge-inline-editor',blockId:block.dataset.block,field:field,value:target.textContent},'*');
});
}
});
})()</script>'''
# The static site's /media/ assets are hosted by the admin at
# /local-media/{site}/. Rewrite only the editor response, never builds.
html=html.replace('src="/media/',f'src="/local-media/{site_id}/')
html=html.replace('poster="/media/',f'poster="/local-media/{site_id}/')
return HTMLResponse(html.replace('</body>',bridge+'</body>'))
@app.put('/api/sites/{site_id}/contact-settings')
def update_contact_settings(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
site=get_site(site_id)
email=str(payload.get('email') or '').strip()[:180]
endpoint=str(payload.get('form_endpoint') or '').strip()
if email and not re.fullmatch(r'[^@\s]+@[^@\s]+\.[^@\s]+',email):
raise HTTPException(400,'联系邮箱格式不正确')
if endpoint and (not endpoint.startswith('https://') or any(c in endpoint for c in ('\"','<','>','\n','\r')) or len(endpoint)>500):
raise HTTPException(400,'表单服务地址必须是有效的 HTTPS URL')
settings={**site['settings'],'email':email,'form_endpoint':endpoint,
'footer_text':str(payload.get('footer_text') or '')[:600]}
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'saved':True,'email':email,'form_endpoint':endpoint,'footer_text':settings['footer_text'],
'note':'外部表单服务必须另外开通、配置隐私与防垃圾验证,SiteForge不保存表单数据'}
@app.put('/api/sites/{site_id}/navigation')
def update_navigation(site_id:str,items:list[dict]=Body(...)):
site_design_v87.require_legacy_enabled()
site=get_site(site_id)
if len(items)>25:raise HTTPException(400,'最多25个导航项目')
nav=[]
for item in items:
label=str(item.get('title') or '').strip()[:60]
path=route_path(item.get('route') or '/')
if not label:raise HTTPException(400,'导航标题不能为空')
nav.append({'title':label,'route':path})
settings={**site['settings'],'nav':nav}
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'nav':nav}
@app.post('/api/sites/{site_id}/import')
async def import_file(site_id:str,file:UploadFile=File(...),on_conflict:str=Form('skip')):
get_site(site_id)
content=await file.read(15_000_001)
if len(content)>15_000_000:raise HTTPException(413,'导入文件不能超过 15MB')
try:result=importers.import_data_file(site_id,file.filename or '',content,on_conflict)
except Exception as exc:fail(exc)
db.insert('INSERT INTO imports(id,site_id,created_at,source,result_json) VALUES (?,?,?,?,?)',
(uid(),site_id,db.now(),file.filename or 'upload',json.dumps(result,ensure_ascii=False)))
return result
@app.post('/api/sites/{site_id}/crawl')
def crawl(site_id:str,payload:CrawlIn):
get_site(site_id)
try:result=importers.crawl_site(site_id,payload.url,payload.max_pages,payload.on_conflict)
except Exception as exc:fail(exc)
db.insert('INSERT INTO imports(id,site_id,created_at,source,result_json) VALUES (?,?,?,?,?)',
(uid(),site_id,db.now(),'crawler: '+payload.url,json.dumps(result,ensure_ascii=False)))
old=get_site(site_id);settings=old['settings'];settings['origin']=payload.url
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings),db.now(),site_id))
return result
@app.get('/api/sites/{site_id}/imports')
def imports(site_id:str):
get_site(site_id)
return db.rows('SELECT * FROM imports WHERE site_id=? ORDER BY created_at DESC LIMIT 25',(site_id,))
@app.post('/api/sites/{site_id}/media')
async def upload_image(site_id:str,file:UploadFile=File(...)):
get_site(site_id)
allowed={'.png','image/png','.jpg','image/jpeg','.jpeg','.webp','image/webp','.gif','image/gif','.avif','image/avif'}
ext=Path(file.filename or '').suffix.lower()
if ext not in ('.png','.jpg','.jpeg','.webp','.gif','.avif'):
raise HTTPException(400,'仅支持 PNG/JPG/WebP/GIF/AVIF 图片')
data=await file.read(8_000_001)
if len(data)>8_000_000:raise HTTPException(413,'图片不能超过 8 MB')
if not (data.startswith(b'\x89PNG') or data.startswith(b'\xff\xd8') or data.startswith(b'GIF8') or data[4:8]==b'ftyp' or (len(data)>12 and data[8:12]==b'WEBP')):
raise HTTPException(400,'不是有效图片格式')
folder=db.DATA/'media'/site_id;folder.mkdir(parents=True,exist_ok=True)
dest=folder/(uid()+ext);dest.write_bytes(data)
return {'url':'media/'+dest.name,'size':len(data)}
@app.get('/api/sites/{site_id}/media')
def media_list(site_id:str):
get_site(site_id)
folder=db.DATA/'media'/site_id
return [{'url':'media/'+p.name,'name':p.name,'bytes':p.stat().st_size} for p in sorted(folder.glob('*')) if p.is_file()] if folder.exists() else []
@app.get('/local-media/{site_id}/{filename}')
def media_view(site_id:str,filename:str):
get_site(site_id)
if not re.fullmatch(r'(?:[0-9a-f]{12}|wp-[0-9a-f]{24})\.(png|jpg|jpeg|webp|gif|avif)',filename):raise HTTPException(404)
path=db.DATA/'media'/site_id/filename
if not path.is_file():raise HTTPException(404)
return FileResponse(path)
@app.post('/api/sites/{site_id}/redirects')
def add_redirect(site_id:str,payload:RedirectIn):
get_site(site_id)
if payload.status_code not in (301,302,307,308):raise HTTPException(400,'无效的 HTTP 重定向状态')
old=route_path(payload.old_path)
if payload.new_path==old:raise HTTPException(400,'无法重定向到自身')
if not (payload.new_path.startswith('/') or payload.new_path.startswith('https://')):raise HTTPException(400,'无效目标')
try:
db.insert('INSERT INTO redirects(id,site_id,old_path,new_path,status_code) VALUES (?,?,?,?,?)',
(uid(),site_id,old,payload.new_path,payload.status_code))
except Exception as exc:fail(exc)
return {'created':True}
@app.get('/api/sites/{site_id}/redirects')
def list_redirects(site_id:str):
get_site(site_id)
return db.rows('SELECT * FROM redirects WHERE site_id=? ORDER BY old_path',(site_id,))
@app.delete('/api/sites/{site_id}/redirects/{redirect_id}')
def remove_redirect(site_id:str,redirect_id:str):
get_site(site_id)
db.execute('DELETE FROM redirects WHERE site_id=? AND id=?',(site_id,redirect_id))
return {'deleted':True}
@app.post('/api/sites/{site_id}/builds', status_code=202)
def new_build(site_id:str,payload:BuildIn,request:Request):
get_site(site_id)
# All builds are submitted to the same durable worker queue. No build()
# calls are allowed from a request handler.
key=request.headers.get('Idempotency-Key')
try:
return jobs.submit(site_id, {'mirror_images':payload.mirror_images}, enterprise.actor(request), key)
except jobs.JobCapacityError as exc:
raise HTTPException(429, str(exc))
except jobs.JobConflict as exc:
raise HTTPException(409, str(exc))
@app.get('/api/sites/{site_id}/builds')
def list_builds(site_id:str):
get_site(site_id)
return db.rows('SELECT id,created_at,status,total_pages,report_json FROM builds WHERE site_id=? ORDER BY created_at DESC',(site_id,))
@app.get('/api/sites/{site_id}/builds/{build_id}/report')
def build_report(site_id:str,build_id:str):
record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not record:raise HTTPException(404)
return json.loads(record['report_json'])
@app.get('/api/sites/{site_id}/builds/{build_id}/download')
def download_build(site_id:str,build_id:str):
record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not record:raise HTTPException(404)
archive=Path(record['zip_path'])
if not archive.is_file():raise HTTPException(404,'构建压缩包丢失')
return FileResponse(archive,filename=f'siteforge-{site_id}-{build_id}.zip',media_type='application/zip')
@app.get('/preview/{site_id}/{build_id}/{path:path}')
def preview_path(site_id:str,build_id:str,path:str):
b=db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not b:raise HTTPException(404)
dist=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
target=dist/(path or 'index.html')
if target.is_dir():target=target/'index.html'
if not target.exists() and not Path(path).suffix:target=dist/path/'index.html'
if not target.resolve().is_relative_to(dist):raise HTTPException(404)
if not target.is_file():raise HTTPException(404)
if target.suffix.lower() in ('.html','.htm'):
html=target.read_text(encoding='utf-8')
return HTMLResponse(rewrite_preview_html(html,site_id,build_id))
return FileResponse(target)
@app.get('/preview/{site_id}/{build_id}')
def preview_root(site_id:str,build_id:str):
return RedirectResponse(f'/preview/{site_id}/{build_id}/')
@app.get('/api/presets')
def get_presets():
return db.rows('SELECT id,name,theme,created_at FROM presets ORDER BY created_at DESC')
@app.post('/api/sites/{site_id}/save-preset')
def save_preset(site_id:str,payload:dict=Body(default_factory=dict)):
s=get_site(site_id)
homepage=db.one('SELECT meta_json FROM entries WHERE site_id=? AND route="/"',(site_id,))
meta=json.loads(homepage['meta_json'] or '{}') if homepage else {}
blocks=meta.get('blocks',[])
pid=uid()
name=str(payload.get('name') or s['name']+' 模板')[:150]
db.insert('INSERT INTO presets(id,name,theme,settings_json,home_blocks_json,created_at) VALUES (?,?,?,?,?,?)',
(pid,name,s['theme'],json.dumps(s['settings'],ensure_ascii=False),json.dumps(blocks,ensure_ascii=False),db.now()))
return {'id':pid,'name':name,'theme':s['theme']}
@app.post('/api/sites/{site_id}/media-archive')
async def import_media_archive(site_id:str,file:UploadFile=File(...)):
"""Upload a WordPress wp-content/uploads ZIP. Reject executable formats and unsafe paths."""
import zipfile
import stat
get_site(site_id)
if not (file.filename or '').lower().endswith('.zip'):
raise HTTPException(400,'请上传 .zip 媒体压缩包')
data=await file.read(80_000_001)
if len(data)>80_000_000:raise HTTPException(413,'压缩包最大 80MB')
count=0;skipped=0;size=0
allowed={'.png','.jpg','.jpeg','.gif','.webp','.avif','.pdf','.woff','.woff2','.ttf','.ico'}
target=db.DATA/'legacy'/site_id
try:
with zipfile.ZipFile(io.BytesIO(data)) as z:
infos=z.infolist()
if len(infos)>4000:raise ValueError('压缩包文件数过多')
for member in infos:
if member.file_size>100*max(member.compress_size,1) and member.file_size>1_000_000:
raise ValueError('拒绝异常高压缩比的媒体文件')
size+=member.file_size
if size>250_000_000:raise ValueError('解压后的数据总量超过 250MB')
for member in infos:
name=member.filename.replace('\\','/').lstrip('/')
parts=name.split('/')
if member.is_dir():continue
if any(p in ('.','..') for p in parts) or ':' in name or name.startswith('~'):
skipped+=1;continue
if stat.S_ISLNK(member.external_attr>>16):skipped+=1;continue
if Path(name).suffix.lower() not in allowed or member.file_size>25_000_000:
skipped+=1;continue
if 'wp-content' in parts:
name='/'.join(parts[parts.index('wp-content'):])
elif 'uploads' in parts:
name='wp-content/'+'/'.join(parts[parts.index('uploads'):])
else:
name='media/imported/'+name
dst=target/name
if not dst.resolve().is_relative_to(target.resolve()):skipped+=1;continue
dst.parent.mkdir(parents=True,exist_ok=True)
with z.open(member) as source, open(dst,'wb') as out:
shutil.copyfileobj(source,out)
count+=1
except Exception as exc:fail(exc)
return {'imported':count,'skipped':skipped,'bytes_uncompressed':size,
'message':'旧媒体将按原路径打包进下次构建;单独的源站页面和 URL 仍需核验'}
@app.post('/api/sites/{site_id}/builds/{build_id}/publish-local')
def publish_local(site_id:str,build_id:str):
"""Opt-in atomic local release; requires SITEFORGE_PUBLISH_ROOT env."""
import os
root=os.environ.get('SITEFORGE_PUBLISH_ROOT','').strip()
if not root:raise HTTPException(403,'本地部署未启用,请设置 SITEFORGE_PUBLISH_ROOT')
get_site(site_id)
build_record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not build_record:raise HTTPException(404,'构建版本不存在')
if build_record['status']!='complete':
raise HTTPException(409,'尚未构建完成,不能发布')
from . import quality
try:
q=quality.report(site_id,build_id)
except (FileNotFoundError,OSError):
raise HTTPException(409,'构建文件缺失,请重新构建')
if not q['passed']:
raise HTTPException(409,'静态网站质量检查存在阻断错误')
site=get_site(site_id)
if site['mode']=='migration' and cms_pipeline.open_reviews(site_id):
raise HTTPException(409,'复杂 CMS 数据仍有未完成的人工复核')
if site['mode']=='migration':
report=enterprise.diff_baseline(site_id)
if report['baseline_count']==0:raise HTTPException(409,'迁移网站尚未导入旧 URL 基线,禁止直接发布')
if report['errors']:raise HTTPException(409,f'旧站 URL 基线检查未通过:缺失 {report["errors"]} 项')
# Four-eyes rule is enabled by default in the V2 Docker configuration.
if __import__('os').environ.get('SITEFORGE_REQUIRE_RELEASE_APPROVAL','false').lower() in ('1','true','yes'):
approval=db.one('SELECT id FROM release_requests WHERE site_id=? AND build_id=? AND state=? ORDER BY reviewed_at DESC LIMIT 1',
(site_id,build_id,'approved'))
if not approval:raise HTTPException(409,'当前构建版本尚未获得独立审批,禁止发布')
# Migration gate is re-evaluated on publish, not only at approval time.
if site['mode']=='migration' and enterprise.diff_baseline(site_id)['errors']:
raise HTTPException(409,'迁移基线再次检测未通过')
src=db.DATA/'builds'/site_id/build_id/'dist'
if not src.is_dir():raise HTTPException(404,'构建文件缺失')
parent=Path(root).resolve()/site_id
releases=parent/'releases';releases.mkdir(parents=True,exist_ok=True)
dest=releases/build_id
if not dest.exists():shutil.copytree(src,dest)
current=parent/'current'
if os.name=='nt':
previous=parent/'previous'
if previous.exists():shutil.rmtree(previous)
if current.exists():current.rename(previous)
shutil.copytree(dest,current)
else:
tmp=parent/('current-tmp-'+uid())
tmp.symlink_to(dest,target_is_directory=True)
os.replace(tmp,current)
enterprise.db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,build_id,'system','publish',db.now()))
return {'published':True,'root':str(current),'release':build_id,'message':'请确认 Nginx/Caddy 的站点根目录指向 current;DNS/CDN 不会被自动修改'}
+245
View File
@@ -0,0 +1,245 @@
"""Offline, reproducible website migration inventory and built-artifact comparison.
No network calls. The source manifest is treated as untrusted input and never as
an instruction. Unsupported dynamic URLs are reported instead of rewritten.
"""
from __future__ import annotations
import csv, hashlib, io, json, re
from pathlib import Path
from urllib.parse import urlsplit
from bs4 import BeautifulSoup
from defusedxml import ElementTree as ET
from fastapi import APIRouter, Body, File, HTTPException, Request, UploadFile
from . import db, enterprise, builder
from .core import route_path
router=APIRouter(prefix='/api/audit',tags=['Migration Audit V4'])
MAX_BASELINE=20_000
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS source_audit_fields(
site_id TEXT NOT NULL, route TEXT NOT NULL,
robots TEXT NOT NULL DEFAULT '', h1 TEXT NOT NULL DEFAULT '',
original_url TEXT NOT NULL DEFAULT '',
PRIMARY KEY(site_id,route),
FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS source_audit_assets(
site_id TEXT NOT NULL, url TEXT NOT NULL,
source_route TEXT NOT NULL DEFAULT '',
PRIMARY KEY(site_id,url,source_route),
FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS source_audit_exceptions(
site_id TEXT NOT NULL, build_id TEXT NOT NULL, issue_id TEXT NOT NULL,
reviewer TEXT NOT NULL, explanation TEXT NOT NULL, decided_at TEXT NOT NULL,
PRIMARY KEY(site_id,build_id,issue_id));
''')
def _route(value:str):
text=str(value or '').strip()
if not text:raise ValueError('缺少页面路径')
u=urlsplit(text)
if u.query:raise ValueError('需要单独处理带查询参数的动态 URL')
if u.scheme and u.scheme not in ('https','http'):raise ValueError('不支持的 URL 协议')
return route_path(u.path or '/')
def _str(row,*keys):
for key in keys:
if row.get(key) is not None and str(row[key]).strip():return str(row[key]).strip()
return ''
def _normalize(row):
original=_str(row,'url','loc','original_url','link','route','path')
route=_route(original)
status=int(row.get('status') or 200)
if status not in (200,301,302,307,308,404,410):raise ValueError('不支持的 HTTP 状态')
return {'route':route,'original_url':original,'seo_title':_str(row,'seo_title','title','meta_title'),
'description':_str(row,'description','seo_description','meta_description'),
'canonical':_str(row,'canonical'),'robots':_str(row,'robots'),
'h1':_str(row,'h1'),'status':status}
def _wp_item(element):
def get(local,default=''):
for ch in element:
if ch.tag.rsplit('}',1)[-1]==local:return (ch.text or default)
return default
def get_content(local):
for ch in element:
if ch.tag.rsplit('}',1)[-1]==local:return ch.text or ''
return ''
meta={}
for child in element:
if child.tag.rsplit('}',1)[-1]=='postmeta':
key=value=''
for part in child:
if part.tag.endswith('meta_key'):key=part.text or ''
elif part.tag.endswith('meta_value'):value=part.text or ''
if key:meta[key]=value
return (get('post_type','post'),get('status','publish'),get('link'),get('title'),get_content('encoded'),meta)
def from_file(filename:str,blob:bytes):
if len(blob)>16_000_000:raise ValueError('源站清单不能超过 16 MB')
name=filename.lower();rows=[];assets=[];warnings=[]
if name.endswith('.csv'):
rows=list(csv.DictReader(io.StringIO(blob.decode('utf-8-sig'))))
elif name.endswith('.json'):
data=json.loads(blob.decode('utf-8-sig'))
rows=data.get('urls',data.get('pages',[])) if isinstance(data,dict) else data
if not isinstance(rows,list):raise ValueError('JSON 应包含 pages/urls 数组')
elif name.endswith('.xml'):
root=ET.fromstring(blob)
root_name=root.tag.rsplit('}',1)[-1]
if root_name=='urlset':
rows=[{'url':next((c.text or '' for c in node if c.tag.rsplit('}',1)[-1]=='loc'),'')} for node in root]
elif root_name=='rss':
for item in root.findall('./channel/item'):
typ,status,url,title,body,meta=_wp_item(item)
if typ=='attachment':
if url:assets.append((url,''))
continue
if status!='publish' or typ in ('revision','nav_menu_item'):continue
if not url:
warnings.append('WXR 某条记录缺少真实链接,无法推断原路径:'+title[:70]);continue
rows.append({'url':url,'title':meta.get('_yoast_wpseo_title') or meta.get('rank_math_title') or title,
'description':meta.get('_yoast_wpseo_metadesc') or meta.get('rank_math_description') or '',
'canonical':meta.get('_yoast_wpseo_canonical') or meta.get('rank_math_canonical_url') or '',
'robots':'noindex,follow' if meta.get('_yoast_wpseo_meta-robots-noindex')=='1' else '',
'h1':title})
soup=BeautifulSoup(body,'html.parser')
for img in soup.select('img[src]'):
assets.append((img.get('src',''),url))
else:raise ValueError('XML 需要 WordPress WXR 或 sitemap.xml')
else:raise ValueError('仅支持 .csv / .json / WordPress WXR.xml / sitemap.xml')
if not 1<=len(rows)<=MAX_BASELINE:raise ValueError('URL 清单需要1至20000条')
out=[];errors=[];seen=set()
for i,row in enumerate(rows,1):
try:
if not isinstance(row,dict):raise ValueError('记录不是对象')
normalized=_normalize(row)
if normalized['route'] in seen:raise ValueError('原路径重复')
seen.add(normalized['route']);out.append(normalized)
except Exception as exc:errors.append({'row':i,'message':str(exc)[:180]})
return {'records':out,'assets':assets,'errors':errors,'warnings':warnings,'total':len(rows)}
def save(site_id,manifest,replace=True):
if manifest['errors']:raise ValueError('URL 清单存在格式错误,请修正后再导入')
with db.connect() as c:
if replace:
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
c.execute('DELETE FROM source_audit_fields WHERE site_id=?',(site_id,))
c.execute('DELETE FROM source_audit_assets WHERE site_id=?',(site_id,))
for row in manifest['records']:
c.execute('''INSERT INTO migration_baseline(site_id,route,seo_title,description,canonical,status)
VALUES(?,?,?,?,?,?) ON CONFLICT(site_id,route) DO UPDATE SET
seo_title=excluded.seo_title,description=excluded.description,canonical=excluded.canonical,status=excluded.status''',
(site_id,row['route'],row['seo_title'],row['description'],row['canonical'],row['status']))
c.execute('''INSERT INTO source_audit_fields(site_id,route,robots,h1,original_url) VALUES(?,?,?,?,?)
ON CONFLICT(site_id,route) DO UPDATE SET robots=excluded.robots,h1=excluded.h1,original_url=excluded.original_url''',
(site_id,row['route'],row['robots'],row['h1'],row['original_url']))
for url,origin in manifest['assets'][:MAX_BASELINE]:
if not url or len(url)>1000:continue
c.execute('INSERT OR IGNORE INTO source_audit_assets VALUES(?,?,?)',(site_id,url,origin[:500]))
return {'imported':len(manifest['records']),'asset_references':len(manifest['assets']),'warnings':manifest['warnings']}
def _tag(soup,name,attrs=None):
node=soup.find(name,attrs=attrs or {})
return node.get('content','').strip() if node and name=='meta' else node.get_text(' ',strip=True) if node else ''
def audit(site_id,build_id):
# Actual generated bytes, not source entry metadata, are the authority.
root=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
if not root.is_dir() or not db.one('SELECT id FROM builds WHERE id=? AND site_id=?',(build_id,site_id)):
raise ValueError('构建版本不存在')
baseline=db.rows('''SELECT b.*,x.robots,x.h1,x.original_url FROM migration_baseline b
LEFT JOIN source_audit_fields x ON b.site_id=x.site_id AND b.route=x.route
WHERE b.site_id=? ORDER BY b.route''',(site_id,))
if not baseline:return {'baseline_count':0,'errors':1,'warnings':0,'passed':False,
'issues':[{'severity':'error','field':'baseline','message':'未导入源站 URL 清单'}]}
redirects={r['old_path']:(r['new_path'],r['status_code']) for r in db.rows('SELECT old_path,new_path,status_code FROM redirects WHERE site_id=?',(site_id,))}
issues=[];matched=0
for old in baseline:
route=old['route'];expect_status=old['status']; target=builder.output_path(root,route)
if expect_status in (301,302,307,308,404,410):
if expect_status in (301,302,307,308):
if route not in redirects:
issues.append({'route':route,'field':'redirect','severity':'error','message':'历史重定向未配置'})
elif redirects[route][1]!=expect_status:
issues.append({'route':route,'field':'redirect_status','severity':'error','message':'重定向状态码与旧站不一致'})
elif target.is_file():
issues.append({'route':route,'field':'http_status','severity':'error','message':'旧站的不存在/已删除页面被错误地生成了内容'})
continue
if not target.is_file():
issues.append({'route':route,'field':'url','severity':'error','message':'旧站正常页面在生成网站中缺失'})
continue
matched+=1
soup=BeautifulSoup(target.read_text(encoding='utf-8',errors='replace'),'html.parser')
title=_tag(soup,'title')
desc_node=soup.find('meta',attrs={'name':re.compile('^description$',re.I)})
description=(desc_node.get('content','') if desc_node else '').strip()
canonical_node=soup.find('link',rel=lambda value:value and 'canonical' in value)
canonical=(canonical_node.get('href','') if canonical_node else '').strip()
robot_node=soup.find('meta',attrs={'name':re.compile('^robots$',re.I)})
robots=(robot_node.get('content','') if robot_node else '').strip().lower()
h1=_tag(soup,'h1')
for source,current,label in [('seo_title',title,'title'),('description',description,'description'),('canonical',canonical,'canonical'),('robots',robots,'robots'),('h1',h1,'h1')]:
if old.get(source) and ' '.join(str(old[source]).split())!=' '.join(str(current).split()):
issues.append({'route':route,'field':label,'severity':'warning','message':'旧新页面字段不一致',
'before':str(old[source])[:160],'after':str(current)[:160]})
other=__import__('app.quality',fromlist=['report']).report(site_id,build_id)
for q in other['issues']:
if q['severity']=='error':issues.append({'route':q['file'],'field':q['type'],'severity':'error','message':str(q.get('target','构建质量问题'))[:180]})
decisions={x['issue_id']:x for x in db.rows('SELECT * FROM source_audit_exceptions WHERE site_id=? AND build_id=?',(site_id,build_id))}
for issue in issues:
token=json.dumps([issue.get(k,'') for k in ('route','field','message','before','after')],ensure_ascii=False,sort_keys=True)
issue['issue_id']=hashlib.sha256(token.encode()).hexdigest()[:24]
approved=decisions.get(issue['issue_id'])
issue['reviewed']=bool(approved) if issue['severity']=='warning' else False
if approved:
issue['reviewer']=approved['reviewer']
issue['review_reason']=approved['explanation']
outstanding=sum(i['severity']=='warning' and not i['reviewed'] for i in issues)
return {'baseline_count':len(baseline),'matched_pages':matched,
'errors':sum(x['severity']=='error' for x in issues),
'warnings':sum(x['severity']=='warning' for x in issues),
'passed':not any(x['severity']=='error' for x in issues),'unreviewed_warnings':outstanding,
'quality':{'files':other['files'],'errors':other['errors'],'warnings':other['warnings']},
'issues':issues[:2000]}
@router.post('/sites/{site_id}/import-baseline')
async def import_baseline(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
data=await file.read(16_000_001)
try:
manifest=from_file(file.filename or '',data)
if manifest['errors']:
return {'imported':0,'errors':manifest['errors'][:200],'total':manifest['total'],'warnings':manifest['warnings']}
return save(site_id,manifest)
except (ValueError,UnicodeDecodeError) as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/builds/{build_id}/report')
def report(request:Request,site_id:str,build_id:str):
enterprise.require(request,site_id,'viewer')
try:return audit(site_id,build_id)
except ValueError as exc:raise HTTPException(404,str(exc))
@router.post('/sites/{site_id}/builds/{build_id}/review-warning')
def approve_warning(request:Request,site_id:str,build_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'reviewer')
issue_id=str(payload.get('issue_id',''))
reason=str(payload.get('reason','')).strip()
if len(reason)<10 or len(reason)>2000:raise HTTPException(400,'请填写至少10个字符的复核原因及处理依据')
evidence=audit(site_id,build_id)
issue=next((x for x in evidence['issues'] if x['issue_id']==issue_id and x['severity']=='warning'),None)
if not issue:raise HTTPException(404,'只能复核此构建版本当前存在的警告')
db.insert('INSERT INTO source_audit_exceptions(site_id,build_id,issue_id,reviewer,explanation,decided_at) VALUES(?,?,?,?,?,?) ON CONFLICT(site_id,build_id,issue_id) DO UPDATE SET reviewer=excluded.reviewer,explanation=excluded.explanation,decided_at=excluded.decided_at',
(site_id,build_id,issue_id,request.state.actor,reason,db.now()))
return {'approved':True,'issue_id':issue_id,'reviewer':request.state.actor}
@router.get('/sites/{site_id}/builds/{build_id}/report.csv')
def report_csv(request:Request,site_id:str,build_id:str):
from fastapi.responses import Response
enterprise.require(request,site_id,'viewer')
result=audit(site_id,build_id)
dest=io.StringIO();writer=csv.DictWriter(dest,fieldnames=['severity','route','field','message','before','after'],extrasaction='ignore')
writer.writeheader();writer.writerows(result['issues'])
return Response('\ufeff'+dest.getvalue(),media_type='text/csv',headers={'Content-Disposition':'attachment; filename="migration-audit.csv"'})
+81
View File
@@ -0,0 +1,81 @@
"""PostgreSQL DB-API bridge for legacy '?' SQL (optional, requires psycopg[binary]).
No automatic conversion of existing SQLite files is attempted. Production migration
requires a tested one-time data migration and reconciliation.
"""
from __future__ import annotations
import re
from contextlib import contextmanager
def placeholders(sql: str) -> str:
"""Translate SQLite positional placeholders outside SQL quoted strings."""
out=[];quote=None;i=0
while i<len(sql):
ch=sql[i]
if quote:
out.append(ch)
if ch==quote:
if i+1<len(sql) and sql[i+1]==quote:
out.append(sql[i+1]);i+=1
else:quote=None
elif ch in ("'",'"'):
quote=ch;out.append(ch)
elif ch=='?':out.append('%s')
else:out.append(ch)
i+=1
return ''.join(out)
def normalize(sql: str) -> str:
sql=sql.strip()
if re.match(r'^INSERT\s+OR\s+IGNORE\s+INTO\b',sql,re.I):
sql=re.sub(r'^INSERT\s+OR\s+IGNORE\s+INTO\b','INSERT INTO',sql,flags=re.I)
sql=sql.rstrip(';')+' ON CONFLICT DO NOTHING'
return placeholders(sql)
class PGConnection:
def __init__(self,dsn):
import psycopg
from psycopg.rows import dict_row
self.connection=psycopg.connect(dsn,row_factory=dict_row,connect_timeout=5)
self.total_changes=0
def execute(self,sql,params=()):
stripped=sql.strip()
if stripped.upper().startswith('PRAGMA TABLE_INFO('):
table=stripped.split('(',1)[1].split(')',1)[0].strip().strip('"\'')
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,))
if stripped.upper()=='BEGIN IMMEDIATE':
# Conservative transaction-wide lock while porting SQLite queue semantics.
# Replace with per-site advisory locks before large multi-worker scale.
return self.connection.execute('SELECT pg_advisory_xact_lock(823947231)')
if stripped.upper().startswith('PRAGMA '):
return _EmptyResult()
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'):
self.total_changes+=cursor.rowcount
return cursor
def executescript(self,script):
# Schema migrations contain only simple DDL and SQLite PRAGMA statements.
for statement in script.split(';'):
if statement.strip():self.execute(statement)
def __enter__(self):return self
def __exit__(self,typ,val,tb):
try:
if typ:self.connection.rollback()
else:self.connection.commit()
finally:self.connection.close()
return False
class _EmptyResult:
rowcount=0
def fetchone(self):return None
def fetchall(self):return []
def connect(dsn):return PGConnection(dsn)
+44
View File
@@ -0,0 +1,44 @@
"""Rewrite root-relative links only in the HTTP preview response.
The published ZIP / immutable build files are never modified. Source element
markup is preserved so that e.g. canonical links and SEO headers are not
rewritten or reformatted by an HTML serializer.
"""
import re
from html.parser import HTMLParser
_ATTR = re.compile(r'''(?P<name>\b(?:href|src|action|poster))(?P<eq>\s*=\s*)(?P<q>["'])(?P<value>.*?)(?P=q)''', re.I | re.S)
class _PreviewHTMLParser(HTMLParser):
def __init__(self, prefix):
super().__init__(convert_charrefs=False)
self.prefix=prefix
self.out=[]
def _start(self):
raw=self.get_starttag_text()
def replace(match):
url=match.group('value')
if url.startswith('/') and not url.startswith('//') and not url.startswith(self.prefix):
url=self.prefix + url.lstrip('/')
return match.group('name')+match.group('eq')+match.group('q')+url+match.group('q')
self.out.append(_ATTR.sub(replace,raw))
def handle_starttag(self,tag,attrs): self._start()
def handle_startendtag(self,tag,attrs): self._start()
def handle_endtag(self,tag): self.out.append(f'</{tag}>')
def handle_data(self,data): self.out.append(data)
def handle_comment(self,data): self.out.append(f'<!--{data}-->')
def handle_decl(self,decl): self.out.append(f'<!{decl}>')
def handle_entityref(self,name): self.out.append(f'&{name};')
def handle_charref(self,name): self.out.append(f'&#{name};')
def handle_pi(self,data): self.out.append(f'<?{data}>')
def unknown_decl(self,data): self.out.append(f'<![{data}]>')
def rewrite_preview_html(document: str, site_id: str, build_id: str) -> str:
prefix=f'/preview/{site_id}/{build_id}/'
parser=_PreviewHTMLParser(prefix)
parser.feed(document)
parser.close()
return ''.join(parser.out)
+96
View File
@@ -0,0 +1,96 @@
"""Offline static HTML quality gate, bounded CPU for multi-thousand-page sites.
No network requests. Use the standard-library streaming HTML parser and a
pre-indexed build manifest instead of repeatedly opening files for every link.
"""
from html.parser import HTMLParser
from pathlib import Path, PurePosixPath
import posixpath
from urllib.parse import urlsplit,unquote
from . import db
class _Scanner(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.title_parts=[];self.in_title=False;self.h1=0;self.description=''
self.targets=[];self.missing_alt=[];self.base_root=False
def handle_starttag(self,tag,attrs):
attrs=dict(attrs)
if tag=='title':self.in_title=True
elif tag=='h1':self.h1+=1
elif tag=='base' and attrs.get('href')=='/':self.base_root=True
elif tag=='meta' and str(attrs.get('name','')).lower()=='description':
self.description=(attrs.get('content') or '').strip()
elif tag=='img' and not attrs.get('alt'):
self.missing_alt.append((attrs.get('src') or '')[:180])
if tag in ('a','img','script','link'):
if tag=='link':
rel=(attrs.get('rel') or '').lower().split()
if 'canonical' in rel or 'alternate' in rel:return
target=attrs.get('href' if tag in ('a','link') else 'src')
if target:self.targets.append(target)
def handle_startendtag(self,tag,attrs):self.handle_starttag(tag,attrs)
def handle_endtag(self,tag):
if tag=='title':self.in_title=False
def handle_data(self,data):
if self.in_title:self.title_parts.append(data)
def _local_path(target,rel,base_root,hostname):
parsed=urlsplit(target)
if parsed.scheme in ('http','https'):
if parsed.hostname!=hostname:return None
path=parsed.path
elif parsed.scheme or target.startswith('//') or target.startswith('#'):
return None
else:path=parsed.path
if not path:return None
raw=unquote(path)
if raw.startswith('/') or base_root:
combined=raw.lstrip('/')
else:
combined=posixpath.join(posixpath.dirname(rel),raw)
if combined=='':return 'index.html'
normalized=posixpath.normpath(combined)
if normalized in ('..','.') or normalized.startswith('../') or '\\' in normalized:
return '!unsafe!'
if path.endswith('/') or not PurePosixPath(normalized).suffix:
normalized=normalized.rstrip('/')+'/index.html'
return normalized
def report(site_id,build_id):
root=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
if not root.is_dir():raise FileNotFoundError('构建版本不存在')
# One manifest scan replaces hundreds of thousands of repeated stat calls.
indexed={p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file()}
html_files=sorted(x for x in indexed if x.endswith('.html'))
site=db.one('SELECT domain FROM sites WHERE id=?',(site_id,)) or {}
hostname=urlsplit(site.get('domain') or '').hostname
issues=[];seen_titles={};seen_descriptions={}
for rel in html_files:
scanner=_Scanner()
scanner.feed((root/rel).read_text(encoding='utf-8',errors='replace'))
scanner.close()
title=' '.join(''.join(scanner.title_parts).split())
if not title:issues.append({'file':rel,'type':'missing_title','severity':'error'})
if scanner.h1!=1:issues.append({'file':rel,'type':'h1_count','severity':'warning','count':scanner.h1})
if title and title in seen_titles and Path(rel).name!='404.html':
issues.append({'file':rel,'type':'duplicate_title','severity':'warning','other':seen_titles[title]})
if title:seen_titles[title]=rel
if not scanner.description:issues.append({'file':rel,'type':'missing_description','severity':'warning'})
if scanner.description and scanner.description in seen_descriptions:
issues.append({'file':rel,'type':'duplicate_description','severity':'warning','other':seen_descriptions[scanner.description]})
if scanner.description:seen_descriptions[scanner.description]=rel
for img in scanner.missing_alt:
issues.append({'file':rel,'type':'missing_image_alt','severity':'warning','src':img})
for target in scanner.targets:
candidate=_local_path(target,rel,scanner.base_root,hostname)
if candidate is not None and candidate not in indexed:
issues.append({'file':rel,'type':'broken_internal_reference','severity':'error','target':target[:220]})
return {'files':len(html_files),'errors':sum(x['severity']=='error' for x in issues),
'warnings':sum(x['severity']=='warning' for x in issues),
'passed':not any(x['severity']=='error' for x in issues),'issues':issues[:500]}
+56
View File
@@ -0,0 +1,56 @@
"""Optional Redis wake-up channel; database remains authoritative job queue.
Redis outages degrade to timed polling and never lose durable jobs. A bounded
list prevents idle systems accumulating unbounded notification entries.
"""
import os,socket,ssl,time
from urllib.parse import urlparse,unquote
URL=os.getenv('SITEFORGE_REDIS_URL','')
def _command(sock,*args):
payload=b'*'+str(len(args)).encode()+b'\r\n'
for arg in args:
val=str(arg).encode()
payload+=b'$'+str(len(val)).encode()+b'\r\n'+val+b'\r\n'
sock.sendall(payload)
buf=sock.makefile('rb')
line=buf.readline()
if not line:raise OSError('Redis disconnected')
if line[:1]==b'-':raise OSError(line[1:].decode(errors='replace'))
if line[:1] in (b':',b'+'):return line[1:].strip()
if line[:1]==b'$':
size=int(line[1:].strip())
return buf.read(size+2) if size>=0 else None
if line[:1]==b'*':
count=int(line[1:].strip())
for _ in range(max(count,0)):
n=int(buf.readline()[1:].strip());buf.read(n+2)
return count
raise OSError('Unexpected Redis response')
def _socket():
u=urlparse(URL)
if u.scheme not in ('redis','rediss'):raise ValueError('Redis URL requires redis/rediss')
s=socket.create_connection((u.hostname,u.port or 6379),timeout=3)
s.settimeout(4)
if u.scheme=='rediss':s=ssl.create_default_context().wrap_socket(s,server_hostname=u.hostname)
if u.password:_command(s,'AUTH',unquote(u.password))
if u.path and u.path not in ('','/','/0'):_command(s,'SELECT',int(u.path.strip('/')))
return s
def notify():
if not URL:return False
try:
with _socket() as s:
_command(s,'LPUSH','siteforge:build:wakeup',1)
_command(s,'LTRIM','siteforge:build:wakeup',0,1023)
return True
except (OSError,ValueError,TypeError):return False
def wait(fallback=1.5):
if not URL:
time.sleep(fallback);return
try:
with _socket() as s:_command(s,'BRPOP','siteforge:build:wakeup',1)
except (OSError,ValueError,TypeError):time.sleep(fallback)
+50
View File
@@ -0,0 +1,50 @@
"""Content assembly: stable, site-scoped brand/category relationships.
Only materializes associations supported by source data. Generated brand shells
are clearly marked and contain no fabricated copy.
"""
from collections import defaultdict
from . import db
from .core import save_entry
from .theme_pro import fields,slug
KINDS=('product','vehicle')
def preview(site_id):
records=db.rows("SELECT * FROM entries WHERE site_id=? AND type IN ('product','vehicle','brand')",(site_id,))
brand_pages={r['title'].casefold():r for r in records if r['type']=='brand'}
routes={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))}
brands=defaultdict(list);cats=defaultdict(list);issues=[]
for row in records:
if row['type'] not in KINDS:continue
attrs=fields(row)
brand=str(attrs.get('brand') or '').strip();category=str(attrs.get('category') or '').strip()
if brand: brands[brand].append(row['route'])
else:issues.append({'route':row['route'],'severity':'review','message':'未填写品牌'})
if category:cats[category].append(row['route'])
else:issues.append({'route':row['route'],'severity':'review','message':'未填写分类'})
if not row['cover']:issues.append({'route':row['route'],'severity':'review','message':'缺少产品封面图'})
candidates=[]
for name,products in brands.items():
if name.casefold() in brand_pages:continue
url=f'/brands/{slug(name)}/'
if url in routes:
issues.append({'route':url,'severity':'block','message':'品牌自动生成路径与已有页面冲突'});continue
candidates.append({'brand':name,'route':url,'products':len(products)})
return {'brands':[{ 'name':k,'items':len(v)} for k,v in brands.items()],
'categories':[{'name':k,'items':len(v)} for k,v in cats.items()],
'brand_pages_missing':candidates,'issues':issues,
'products':sum(len(x) for x in brands.values()),
'note':'仅根据已有产品字段建立关系;缺少真实品牌介绍/图片时不会自动编造。'}
def commit(site_id):
result=preview(site_id)
if any(issue['severity']=='block' for issue in result['issues']):
raise ValueError('存在路径冲突,请先处理后再装配')
created=[]
for brand in result['brand_pages_missing']:
save_entry(site_id,{'type':'brand','title':brand['brand'],'route':brand['route'],
'summary':'','body':'','status':'published','meta':{'fields':{'source':'auto-assembled'},'source_auto_assembled':True}},
source='relations-assembly')
created.append(brand['route'])
return {'created_brand_pages':created,'count':len(created),'preview':result}
+104
View File
@@ -0,0 +1,104 @@
"""Site-level production endpoints: approval+quality gates, signed HTTPS releases."""
import hashlib,json,os,time
from pathlib import Path
from fastapi import APIRouter,HTTPException,Request,Body
from pydantic import BaseModel,Field
import httpx
from . import db,enterprise,quality,secret_refs,safe_http
from .deploy_agent import signature
router=APIRouter(prefix='/api/production',tags=['Production delivery'])
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS remote_releases(
site_id TEXT NOT NULL, build_id TEXT NOT NULL, target TEXT NOT NULL,
release_id TEXT NOT NULL, actor TEXT NOT NULL, deployed_at TEXT NOT NULL,
PRIMARY KEY(site_id,target,release_id),
FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE);''')
class DeployRequest(BaseModel):
build_id:str=Field(min_length=1,max_length=100)
target:str=Field(default='staging',pattern='^(staging|production)$')
def approved(site_id,build_id):
from . import factory_api
row=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not row:
raise HTTPException(404,'Build not found')
# Only the persisted finished build is deployable. Queued/running/unknown
# states must fail closed (the legacy check accidentally allowed them).
if row['status'] != 'complete':
raise HTTPException(409,'Build has not completed successfully')
if not row['zip_path']:
raise HTTPException(409,'Build archive unavailable')
site=db.one('SELECT mode FROM sites WHERE id=?',(site_id,))
if site['mode']=='migration':
diff=enterprise.diff_baseline(site_id)
if not diff['baseline_count'] or diff['errors']:
raise HTTPException(409,'Migration audit incomplete')
from . import cms_pipeline
if cms_pipeline.open_reviews(site_id):
raise HTTPException(409,'Unresolved CMS import review findings')
try:
q=quality.report(site_id,build_id)
except (FileNotFoundError, OSError):
raise HTTPException(409,'Build output missing; rebuild before release')
if not q['passed']:raise HTTPException(409,'Build quality gate failed')
request=db.one('SELECT id FROM release_requests WHERE site_id=? AND build_id=? AND state=? LIMIT 1',(site_id,build_id,'approved'))
if not request:raise HTTPException(409,'Independent release approval required')
return row
@router.post('/sites/{site_id}/deploy')
def push(request:Request,site_id:str,payload:DeployRequest):
enterprise.require(request,site_id,'owner')
record=approved(site_id,payload.build_id)
# Environment-specific endpoint is operator managed, never accepted from user JSON.
prefix='SITEFORGE_DEPLOY_'+payload.target.upper()
url=os.environ.get(prefix+'_URL','').rstrip('/')
secret_ref=os.environ.get(prefix+'_SECRET_REF','')
try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','')
except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable')
if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured')
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,'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):
release_id:str=Field(pattern=r'^[0-9a-f]{20}$')
target:str=Field(default='staging',pattern='^(staging|production)$')
@router.post('/sites/{site_id}/rollback')
def rollback_remote(request:Request,site_id:str,payload:RollbackRequest):
enterprise.require(request,site_id,'owner')
old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?',
(site_id,payload.target,payload.release_id))
if not old:raise HTTPException(404,'仅允许回滚到本站曾经部署过的版本')
prefix='SITEFORGE_DEPLOY_'+payload.target.upper()
url=os.environ.get(prefix+'_URL','').rstrip('/')
secret_ref=os.environ.get(prefix+'_SECRET_REF','')
try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','')
except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable')
if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured')
stamp=str(int(time.time()))
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
+26
View File
@@ -0,0 +1,26 @@
"""Request correlation without logging URLs, cookies, bodies or credentials."""
import json
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
log=logging.getLogger('siteforge.request')
log.setLevel(logging.INFO)
if not log.handlers:log.addHandler(logging.StreamHandler())
class TraceMiddleware(BaseHTTPMiddleware):
async def dispatch(self,request,call_next):
rid=uuid.uuid4().hex[:16]
request.state.request_id=rid
started=time.monotonic()
try:
response=await call_next(request)
except Exception:
log.error(json.dumps({'event':'http_request','id':rid,'method':request.method,
'status':500,'duration_ms':round((time.monotonic()-started)*1000)}))
raise
response.headers['X-Request-ID']=rid
log.info(json.dumps({'event':'http_request','id':rid,'method':request.method,
'status':response.status_code,'duration_ms':round((time.monotonic()-started)*1000)}))
return response
+52
View File
@@ -0,0 +1,52 @@
"""Validated per-device static CSS for pages and nested layout grids.
Uses exact IDs from validate_blocks, never user supplied CSS selectors/expressions.
Desktop structure is preserved; tablet and mobile may have distinct spans/order.
"""
import re,json,hashlib
from .blueprints import validate_blocks
_ID=re.compile(r'[a-zA-Z0-9_-]{4,50}\Z')
def editor_css(blocks,prefix=""):
tablet=[];mobile=[]
def walk(items):
for b in items:
uid=b['id']
if not _ID.fullmatch(uid):continue
s=b.get('style') or {}
tp=s.get('tablet_padding',50);mp=s.get('mobile_padding',28)
tp=max(0,min(120,int(tp)));mp=max(0,min(120,int(mp)))
ident=prefix+f'.sf-section[data-block="{uid}"]'
tablet.append(f'{ident}{{--sf-pad:{tp}px}}')
mobile.append(f'{ident}{{--sf-pad:{mp}px}}')
if s.get('hide_tablet') is True:tablet.append(f'{ident}{{display:none!important}}')
if s.get('hide_mobile') is True:mobile.append(f'{ident}{{display:none!important}}')
if b['type']=='layout':
selector=prefix+f'.sf-layout-grid[data-layout="{uid}"]'
tablet.append(f'{selector}{{grid-template-columns:repeat({b["tablet_columns"]},minmax(0,1fr))!important}}')
mobile.append(f'{selector}{{grid-template-columns:repeat({b["mobile_columns"]},minmax(0,1fr))!important}}')
for ci,col in enumerate(b['columns'],1):
target=selector+f'>.sf-layout-column[data-col="{ci}"]'
for dev,rules in [('tablet',tablet),('mobile',mobile)]:
rules.append(f'{target}{{order:{col[dev+"_order"]};grid-column:span {col[dev+"_span"]}}}')
if col['hide_'+dev]:rules.append(f'{target}{{display:none!important}}')
walk(col['blocks'])
walk(validate_blocks(blocks))
# Breakpoints are intentionally exclusive: phone has tablet rules plus
# phone overrides. Phone controls are appended after tablet controls.
return '@media(max-width:820px){'+''.join(tablet)+'}@media(max-width:540px){'+''.join(mobile)+'}'
def route_key(route):
return hashlib.sha256(str(route).encode()).hexdigest()[:16]
def site_css(entries):
styles=[]
for entry in entries:
try:meta=json.loads(entry.get('meta_json') or '{}')
except (TypeError,ValueError):continue
if isinstance(meta.get('blocks'),list) and meta['blocks']:
styles.append(editor_css(meta['blocks'],prefix=f'body[data-siteforge-route="{route_key(entry.get("route","/"))}"] '))
# Identical generated IDs may occur on different pages. Styles are scoped
# to block IDs and remain deterministic; validate each page independently.
return ''.join(styles)
+53
View File
@@ -0,0 +1,53 @@
"""Site-scoped reusable editor component library with versioned snapshots."""
import json
from fastapi import APIRouter,Body,HTTPException,Request
from . import db,enterprise,blueprints
from .core import uid
router=APIRouter(prefix='/api/sites',tags=['Reusable Components'])
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS reusable_components(
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,version INTEGER NOT NULL DEFAULT 1,block_json TEXT NOT NULL,
updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_reuse_site ON reusable_components(site_id);
''')
@router.get('/{site_id}/components')
def all_components(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return [{**r,'block':json.loads(r.pop('block_json'))} for r in db.rows(
'SELECT * FROM reusable_components WHERE site_id=? ORDER BY updated_at DESC',(site_id,))]
@router.post('/{site_id}/components')
def save_component(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
name=str(payload.get('name','')).strip()[:100]
if not name:raise HTTPException(400,'组件名称不能为空')
try:block=blueprints.validate_blocks([payload.get('block')])[0]
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
cid=uid()
db.insert('INSERT INTO reusable_components VALUES (?,?,?,?,?,?)',
(cid,site_id,name,1,json.dumps(block,ensure_ascii=False),db.now()))
return {'id':cid,'name':name,'version':1,'block':block}
@router.put('/{site_id}/components/{component_id}')
def update_component(request:Request,site_id:str,component_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
current=db.one('SELECT * FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id))
if not current:raise HTTPException(404)
if payload.get('expected_version')!=current['version']:raise HTTPException(409,'组件已被修改,请重新加载')
try:block=blueprints.validate_blocks([payload.get('block')])[0]
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
db.execute('UPDATE reusable_components SET name=?,block_json=?,version=?,updated_at=? WHERE id=? AND site_id=?',
(str(payload.get('name') or current['name'])[:100],json.dumps(block,ensure_ascii=False),current['version']+1,db.now(),component_id,site_id))
return {'id':component_id,'version':current['version']+1}
@router.delete('/{site_id}/components/{component_id}')
def delete_component(request:Request,site_id:str,component_id:str):
enterprise.require(request,site_id,'editor')
if not db.execute('DELETE FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id)):
raise HTTPException(404)
return {'deleted':True}
+135
View File
@@ -0,0 +1,135 @@
"""Bounded outbound HTTP with DNS-to-socket pinning.
Only public HTTP(S) hosts are permitted. A resolved address is pinned to
its socket while TLS verification still uses the original hostname; this
prevents a second, unchecked DNS lookup between URL validation and connect.
No proxy, cookie jar, or automatic redirect handling is used.
"""
from __future__ import annotations
import http.client
import ipaddress
import json
import os
import socket
import ssl
from dataclasses import dataclass
from urllib.parse import urljoin, urlsplit, urlunsplit
class OutboundError(ValueError):
pass
@dataclass(frozen=True)
class Fetched:
url: str
status_code: int
headers: dict
content: bytes
@property
def text(self):
charset='utf-8'
ct=self.headers.get('content-type','')
if 'charset=' in ct:charset=ct.split('charset=',1)[1].split(';',1)[0].strip()
try:return self.content.decode(charset,errors='replace')
except LookupError:return self.content.decode('utf-8',errors='replace')
def json(self):return json.loads(self.content)
@property
def is_redirect(self):return self.status_code in (301,302,303,307,308)
def raise_for_status(self):
if self.status_code>=400:raise OutboundError(f'Upstream HTTP {self.status_code}')
class _PinnedHTTP(http.client.HTTPConnection):
def __init__(self,hostname,ip,port,timeout):
self._fixed_ip=ip
super().__init__(hostname,port=port,timeout=timeout)
def connect(self):
self.sock=socket.create_connection((self._fixed_ip,self.port),self.timeout)
class _PinnedHTTPS(http.client.HTTPSConnection):
def __init__(self,hostname,ip,port,timeout):
self._fixed_ip=ip
super().__init__(hostname,port=port,timeout=timeout,context=ssl.create_default_context())
def connect(self):
plain=socket.create_connection((self._fixed_ip,self.port),self.timeout)
try:self.sock=self._context.wrap_socket(plain,server_hostname=self.host)
except BaseException:
plain.close();raise
def resolve_target(url):
if not isinstance(url,str) or len(url)>2048 or any(ord(ch)<32 for ch in url):
raise OutboundError('Invalid outbound URL')
p=urlsplit(url)
if p.scheme not in ('http','https') or not p.hostname or p.username is not None or p.password is not None:
raise OutboundError('Only public HTTP(S) URLs allowed')
host=p.hostname.lower().rstrip('.')
if host in ('localhost','localhost.localdomain') or host.endswith(('.local','.internal','.localhost')):
raise OutboundError('Local network addresses are forbidden')
try:
literal=ipaddress.ip_address(host)
except ValueError:
literal=None
if literal is not None and not literal.is_global:
raise OutboundError('Private or reserved IP literal forbidden')
try:port=p.port or (443 if p.scheme=='https' else 80)
except ValueError as ex:raise OutboundError('Invalid port') from ex
allowed={int(x) for x in os.getenv('SITEFORGE_OUTBOUND_PORTS','80,443').split(',') if x.strip().isdigit()}
if port not in allowed:raise OutboundError('Outbound port not allowed')
try:
infos=socket.getaddrinfo(host,port,type=socket.SOCK_STREAM)
except (socket.gaierror,UnicodeError,OSError) as ex:raise OutboundError('Host DNS resolution failed') from ex
ips=list(dict.fromkeys(info[4][0] for info in infos))
if not ips or any(not ipaddress.ip_address(ip).is_global for ip in ips):
raise OutboundError('Private, reserved or mixed DNS results forbidden')
return p,host,port,ips[0]
def fetch(url,*,max_bytes=3_000_000,timeout=12,redirects=0,headers=None,method="GET",body=None,credential_headers=None):
"""Fetch bounded response. Redirect hops are re-resolved and revalidated."""
if method not in ("GET","POST"):raise OutboundError("Outbound method forbidden")
if max_bytes<1 or max_bytes>120_000_000:raise OutboundError('Invalid response limit')
if not (1<=timeout<=120):raise OutboundError('Invalid timeout')
if not (0<=redirects<=5):raise OutboundError('Invalid redirect limit')
if credential_headers:
if not isinstance(credential_headers,dict) or redirects!=0 or urlsplit(url).scheme!='https':
raise OutboundError('Credentials require HTTPS with redirects disabled')
for name,value in credential_headers.items():
low=name.lower()
if not (low=='authorization' or (low.startswith('x-') and name.replace('-','').isalnum())):
raise OutboundError('Forbidden credential header')
if not isinstance(value,str) or not value or len(value)>8192 or any(c in value for c in '\r\n'):
raise OutboundError('Invalid credential value')
current=url
for hop in range(redirects+1):
p,hostname,port,ip=resolve_target(current)
conn=(_PinnedHTTPS if p.scheme=='https' else _PinnedHTTP)(hostname,ip,port,timeout)
path=p.path or '/'
if p.query:path+='?'+p.query
try:
h={'User-Agent':'SiteForgeAuthorizedImport/1.0','Accept':'*/*','Connection':'close'}
if headers:
for k,v in headers.items():
if k.lower() not in ('host','connection','cookie','authorization','proxy-authorization'):
h[k]=v
if credential_headers:h.update(credential_headers)
conn.request(method,path,body=body,headers=h)
resp=conn.getresponse()
status=resp.status
hdr={k.lower():v for k,v in resp.getheaders()}
if status in (301,302,303,307,308) and hop<redirects:
loc=hdr.get('location')
if not loc:raise OutboundError('Redirect lacks Location')
next_url=urljoin(current,loc)
resolve_target(next_url) # security gate before any follow-up connection
current=next_url
continue
if status in (301,302,303,307,308):raise OutboundError('Redirect not permitted')
stated=hdr.get('content-length')
if stated and stated.isdecimal() and int(stated)>max_bytes:raise OutboundError('Response exceeds size limit')
raw=resp.read(max_bytes+1)
if len(raw)>max_bytes:raise OutboundError('Response exceeds size limit')
return Fetched(current,status,hdr,raw)
finally:conn.close()
raise OutboundError('Too many redirects')
def post(url,body=b'',*,headers=None,max_bytes=1_000_000,timeout=120):
"""Pinned HTTPS/HTTP POST, with no redirection or proxy."""
return fetch(url,headers=headers,max_bytes=max_bytes,timeout=timeout,redirects=0,method='POST',body=body)
+31
View File
@@ -0,0 +1,31 @@
"""Incremental schema migration register for SQLite single-host installations.
Legacy tables are created by their existing setup functions. This new ledger
tracks newly introduced migrations and verifies their SQL checksums. Do not
mistake this for a completed migration of all legacy PostgreSQL structures.
"""
from __future__ import annotations
import hashlib
from datetime import datetime,timezone
# Each migration has separately executable (single statement) DDL entries.
# Never rewrite an already applied migration: add a new sequence instead.
MIGRATIONS=(
('20261022_001_p1_limits',(
'CREATE TABLE IF NOT EXISTS platform_limit_overrides (name TEXT PRIMARY KEY, value INTEGER NOT NULL CHECK (value > 0), updated_at TEXT NOT NULL)',
)),
)
def ensure(conn):
conn.execute('''CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)''')
for version,statements in MIGRATIONS:
checksum=hashlib.sha256('\n'.join(statements).encode()).hexdigest()
row=conn.execute('SELECT checksum FROM schema_migrations WHERE version=?',(version,)).fetchone()
if row:
if row[0]!=checksum:raise RuntimeError('Schema migration checksum mismatch: '+version)
continue
# The caller owns a single transaction. DDL and ledger entry commit together.
for sql in statements:conn.execute(sql)
conn.execute('INSERT INTO schema_migrations VALUES (?,?,?)',(
version,checksum,datetime.now(timezone.utc).isoformat(timespec='seconds')))
+37
View File
@@ -0,0 +1,37 @@
"""Secret references: no literal secret values in persisted configuration.
Environment references are intended for single-host development. Production
should mount secrets under /run/secrets via Docker/Kubernetes secret volumes.
"""
import os
import re
from pathlib import Path
ROOT=Path('/run/secrets')
class SecretReferenceError(ValueError):pass
def resolve(ref:str)->str:
if not isinstance(ref,str) or len(ref)>160:raise SecretReferenceError('Invalid secret reference')
if ref.startswith('env:'):
key=ref[4:]
if not re.fullmatch(r'SITEFORGE_[A-Z][A-Z0-9_]{1,110}',key):
raise SecretReferenceError('Environment secret name must be SITEFORGE_*')
val=os.environ.get(key,'')
elif ref.startswith('file:'):
name=ref[5:]
if not re.fullmatch(r'[a-zA-Z0-9_.-]{1,90}',name) or name.startswith('.'):
raise SecretReferenceError('Invalid secret filename')
path=(ROOT/name).resolve()
if path.parent!=ROOT or not path.is_file():raise SecretReferenceError('Secret unavailable')
val=path.read_text(encoding='utf-8').strip()
else:
raise SecretReferenceError('Expected env: or file: secret reference')
if not val:raise SecretReferenceError('Secret is empty or unavailable')
return val
def validate_reference(ref:str)->str:
"""Validate a reference without resolving the credential in the Web process."""
if not isinstance(ref,str) or len(ref)>160:raise SecretReferenceError('Invalid secret reference')
if ref.startswith('env:') and re.fullmatch(r'SITEFORGE_[A-Z][A-Z0-9_]{1,110}',ref[4:]):return ref
if ref.startswith('file:') and re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,89}',ref[5:]):return ref
raise SecretReferenceError('Expected env:SITEFORGE_* or file:<name> secret reference')
+90
View File
@@ -0,0 +1,90 @@
"""One-owner authentication for the SiteForge Docker Web edition.
Session cookie is signed and HttpOnly. JSON mutations additionally require a
session-bound CSRF header; this is intentionally a small-team / single-admin
MVP, not a multi-tenant identity provider.
"""
import hashlib
import hmac
import os
import secrets
import time
from collections import defaultdict
from pathlib import Path
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from fastapi import HTTPException
COOKIE = 'siteforge_session'
MAX_AGE = 8 * 3600
SERIALIZER = None
PASSWORD_HASH = b''
CSRF_KEY = b''
COOKIE_SECURE = False
FAILURES = defaultdict(list)
def configure():
global SERIALIZER, PASSWORD_HASH, CSRF_KEY, COOKIE_SECURE
password_file = os.environ.get('SITEFORGE_ADMIN_PASSWORD_FILE', '').strip()
if password_file:
password = Path(password_file).read_text(encoding='utf-8').strip('\r\n')
else:
password = os.environ.get('SITEFORGE_ADMIN_PASSWORD', '')
secret = os.environ.get('SITEFORGE_SESSION_SECRET', '')
weak = {'password', 'admin123456', 'change-me', 'replace-me', 'changeme', '123456789', 'REPLACE_WITH_STRONG_PASSWORD'}
if len(password) < 12 or password in weak:
raise RuntimeError('请设置至少 12 位的 SITEFORGE_ADMIN_PASSWORD,或配置 SITEFORGE_ADMIN_PASSWORD_FILE')
if len(secret) < 32 or secret in weak or secret == 'REPLACE_WITH_64_RANDOM_CHARACTERS':
raise RuntimeError('请配置至少 32 位的随机 SITEFORGE_SESSION_SECRET')
SERIALIZER = URLSafeTimedSerializer(secret_key=secret, salt='siteforge-web-v02')
PASSWORD_HASH = hashlib.sha256(password.encode('utf-8')).digest()
CSRF_KEY = hashlib.sha256(('siteforge-csrf-v02:'+secret).encode()).digest()
COOKIE_SECURE = os.environ.get('SITEFORGE_COOKIE_SECURE', 'false').lower() in ('1', 'true', 'yes')
FAILURES.clear()
def check_password(password):
return hmac.compare_digest(hashlib.sha256(password.encode('utf-8')).digest(), PASSWORD_HASH)
def sign_session():
session_id = secrets.token_urlsafe(32)
return SERIALIZER.dumps({'sub': 'admin', 'sid': session_id, 'v': 2})
def sign_user_session(user_id):
return SERIALIZER.dumps({'sub':'user','user_id':user_id,'sid':secrets.token_urlsafe(32),'v':3})
def read_session(cookie):
if not cookie or SERIALIZER is None:
return None
try:
value = SERIALIZER.loads(cookie, max_age=MAX_AGE)
if (value.get('sub'),value.get('v')) not in (('admin',2),('user',3)):
return None
return value
except (BadSignature, SignatureExpired, ValueError, TypeError):
return None
def csrf(session):
return hmac.new(CSRF_KEY, session['sid'].encode(), hashlib.sha256).hexdigest()
def verify_csrf(session, supplied):
return bool(supplied) and hmac.compare_digest(csrf(session), supplied)
def check_login_rate(remote):
now = time.monotonic()
FAILURES[remote] = [t for t in FAILURES[remote] if now-t < 900]
if len(FAILURES[remote]) >= 8:
raise HTTPException(429, '登录尝试次数过多,请稍后重试')
def note_login_failure(remote):
FAILURES[remote].append(time.monotonic())
def note_login_success(remote):
FAILURES.pop(remote, None)
+234
View File
@@ -0,0 +1,234 @@
"""Whole-site header/footer/navigation/responsive drafts and publications.
Only site-design `publish` changes the source of static builds. A draft is
versioned and fenced against edits to the underlying site settings.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import uuid
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise
from .core import route_path
router=APIRouter(prefix='/api/sites',tags=['V8.7 site design'])
DEFAULTS={
'header':{'announcement':'','show_announcement':False,'show_search':True,
'show_cta':True,'cta_text':'联系我们','cta_href':'/contact/'},
'footer':{'title':'','description':'','email':'','show_nav':True},
'nav':[],
'tokens':{'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,
'container_width':1200,'button_style':'rounded','header_style':'standard'},
'responsive':{'tablet_gap':64,'mobile_gap':40,'mobile_hide_announcement':True,
'mobile_hide_footer_links':False,'mobile_container_padding':20},
}
def require_legacy_enabled():
"""Block historical direct-to-live design writes unless explicitly enabled.
Operators migrating an old automation may temporarily enable this escape
hatch; the V8.7 editor never uses it.
"""
if os.environ.get('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE') != '1':
raise HTTPException(409,'旧版直接修改正式设计的接口已停用;请使用 site-design 或 studio-draft 草稿发布流程')
def setup():
with db.connect() as c:
c.execute('''CREATE TABLE IF NOT EXISTS site_design_drafts (
site_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, base_hash TEXT NOT NULL,
config_json TEXT NOT NULL, updated_at TEXT NOT NULL)''')
c.execute('''CREATE TABLE IF NOT EXISTS site_design_publications (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, actor TEXT NOT NULL,
revision INTEGER NOT NULL, old_config_json TEXT NOT NULL,
new_config_json TEXT NOT NULL, published_at TEXT NOT NULL)''')
def _site(site_id):
row=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not row:raise HTTPException(404,'网站不存在')
return row
def _settings(row):
return json.loads(row['settings_json'] or '{}')
def _hash(settings):
return hashlib.sha256(json.dumps(settings,sort_keys=True,ensure_ascii=False).encode()).hexdigest()
def _url(value):
if not isinstance(value,str) or len(value)>350 or any(x in value for x in ('<','>','"',"'",'\\','\n','\r','\t')):
raise HTTPException(400,'不安全的网址')
if value.startswith('/') and not value.startswith('//'):
try:return route_path(value)
except ValueError:raise HTTPException(400,'不合法的站内链接')
# External links are intentionally not allowed inside site chrome; the
# enterprise's external CTA can be built with a vetted form-link block.
raise HTTPException(400,'全站导航只允许以 / 开头的站内路径')
def _nav(rows,depth=0,counter=None):
if counter is None:counter=[0]
if not isinstance(rows,list) or len(rows)>18:raise HTTPException(400,'导航项过多')
result=[]
for x in rows:
counter[0]+=1
if counter[0]>48 or not isinstance(x,dict):raise HTTPException(400,'导航结构不合法')
if set(x)-{'title','route','children'}:raise HTTPException(400,'导航出现未支持的字段')
title=x.get('title')
if not isinstance(title,str) or not title.strip() or len(title)>48:raise HTTPException(400,'导航名称不合法')
children=x.get('children',[])
if depth==2 and children:raise HTTPException(400,'最多支持三级导航')
result.append({'title':title.strip(),'route':_url(x.get('route','/')),
'children':_nav(children,depth+1,counter) if depth<2 else []})
return result
def validate(raw):
if not isinstance(raw,dict) or set(raw)-set(DEFAULTS):raise HTTPException(400,'未知的全站设计设置')
normalized={}
for group in ('header','footer','responsive'):
val=raw.get(group,{})
if not isinstance(val,dict) or set(val)-set(DEFAULTS[group]):raise HTTPException(400,'无效的全站设置组: '+group)
normalized[group]={**DEFAULTS[group],**val}
header=normalized['header'];footer=normalized['footer'];mobile=normalized['responsive']
for key in ('show_announcement','show_search','show_cta'):
if type(header[key]) is not bool:raise HTTPException(400,'无效的页眉开关')
for key in ('show_nav',):
if type(footer[key]) is not bool:raise HTTPException(400,'无效的页脚开关')
for key in ('mobile_hide_announcement','mobile_hide_footer_links'):
if type(mobile[key]) is not bool:raise HTTPException(400,'无效的移动端开关')
for field,cap in [('announcement',140),('cta_text',40)]:
if not isinstance(header[field],str) or len(header[field])>cap:raise HTTPException(400,'页眉文字超限')
for field,cap in [('title',120),('description',650),('email',150)]:
if not isinstance(footer[field],str) or len(footer[field])>cap:raise HTTPException(400,'页脚文字超限')
header['cta_href']=_url(header['cta_href'])
for field,lo,hi in [('tablet_gap',20,120),('mobile_gap',16,100),('mobile_container_padding',8,48)]:
n=mobile[field]
if type(n) is not int or not lo<=n<=hi:raise HTTPException(400,'设备间距超出范围: '+field)
tokens=raw.get('tokens',{})
if not isinstance(tokens,dict) or set(tokens)-set(DEFAULTS['tokens']):
raise HTTPException(400,'未知设计规范字段')
tokens={**DEFAULTS['tokens'],**tokens}
for field,options in [('font',('system','serif','modern')),
('button_style',('rounded','square','pill')),('header_style',('standard','compact'))]:
if tokens[field] not in options:raise HTTPException(400,'不支持的设计规范: '+field)
for field,lo,hi in [('heading_scale',.75,1.5),('radius',0,30),
('section_spacing',32,140),('container_width',900,1600)]:
v=tokens[field]
if type(v) not in (float,int) or not lo<=v<=hi:raise HTTPException(400,'设计变量超出范围: '+field)
normalized['tokens']=tokens
normalized['nav']=_nav(raw.get('nav',[]))
if len(json.dumps(normalized,ensure_ascii=False))>35_000:raise HTTPException(400,'全站设计过大')
return normalized
def published(row_or_settings):
settings=_settings(row_or_settings) if 'settings_json' in row_or_settings else row_or_settings
merged=dict(settings.get('site_design') or {})
if 'tokens' not in merged:merged['tokens']=settings.get('design_tokens') or {}
if 'nav' not in merged:merged['nav']=settings.get('nav') or []
if 'footer' not in merged:merged['footer']={'description':settings.get('footer_text') or '', 'email':settings.get('email') or ''}
return validate(merged)
def read(site_id):
site=_site(site_id)
config=published(site)
draft=db.one('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,))
return {'config':json.loads(draft['config_json']) if draft else config,
'revision':int(draft['revision']) if draft else 0,
'state':'draft' if draft else 'published',
'conflict':bool(draft and draft['base_hash']!=_hash(_settings(site)))}
def write(site_id,payload):
plan=validate(payload.get('config'))
site=_site(site_id)
with db.connect() as c:
old=c.execute('SELECT revision,base_hash FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
revision=old[0] if old else 0
if type(payload.get('expected_revision')) is not int or payload['expected_revision']!=revision:
raise HTTPException(409,'全站草稿已更新,请重新加载')
if old and old[1]!=_hash(_settings(site)):
raise HTTPException(409,'网站信息已在其他窗口修改')
base=old[1] if old else _hash(_settings(site))
c.execute('''INSERT INTO site_design_drafts VALUES(?,?,?,?,?)
ON CONFLICT(site_id) DO UPDATE SET revision=excluded.revision,
config_json=excluded.config_json, updated_at=excluded.updated_at''',
(site_id,revision+1,base,json.dumps(plan,ensure_ascii=False),db.now()))
return {'saved':True,'state':'draft','revision':revision+1,'build_unchanged':True}
def publish(site_id,expected_revision,actor):
site=_site(site_id)
with db.connect() as c:
draft=c.execute('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
if not draft:raise HTTPException(409,'尚无全站设计草稿')
if type(expected_revision) is not int or expected_revision!=draft['revision']:
raise HTTPException(409,'草稿版本不匹配')
live=c.execute('SELECT settings_json FROM sites WHERE id=?',(site_id,)).fetchone()
settings=json.loads(live['settings_json'])
if draft['base_hash']!=_hash(settings):raise HTTPException(409,'网站信息已变更,请重新审核')
before=published(settings)
config=validate(json.loads(draft['config_json']))
settings['site_design']=config
settings['design_tokens']=config['tokens']
# Keep old nav/settings for non-professional legacy templates too.
settings['nav']=config['nav']
c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',
(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
publication_id=uuid.uuid4().hex
c.execute('INSERT INTO site_design_publications VALUES(?,?,?,?,?,?,?)',
(publication_id,site_id,actor,expected_revision,json.dumps(before,ensure_ascii=False),
json.dumps(config,ensure_ascii=False),db.now()))
c.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,))
return {'published':True,'publication_id':publication_id,'build_required':True}
def responsive_css(config):
r=validate(config)['responsive']
tablet=r['tablet_gap'];mobile=r['mobile_gap'];padding=r['mobile_container_padding']
rules=(f'@media(max-width:1023px){{.sf-canvas .sf-section{{padding-block:{tablet}px}}}}'
f'@media(max-width:767px){{.sf-canvas .sf-section{{padding-block:{mobile}px}}'
f'.pro-wrap,.sf-container{{padding-left:{padding}px;padding-right:{padding}px}}')
if r['mobile_hide_announcement']:rules+='body .pro-utility{display:none!important}'
if r['mobile_hide_footer_links']:rules+='body .pro-footer-links{display:none!important}'
return 'body .pro-utility[hidden]{display:none!important}'+rules+'}'
def token_css(tokens):
safe=validate({'tokens':tokens})['tokens']
fonts={'system':'system-ui,-apple-system,BlinkMacSystemFont,sans-serif',
'serif':'Georgia,Times New Roman,serif',
'modern':'Arial,Helvetica,sans-serif'}
radius=safe['radius'];width=safe['container_width'];space=safe['section_spacing']
scale=safe['heading_scale']
button={'rounded':f'{radius}px','square':'0px','pill':'999px'}[safe['button_style']]
return (f':root{{--sf-font:{fonts[safe["font"]]};--sf-radius:{radius}px;'
f'--sf-space:{space}px;--sf-width:{width}px;--sf-heading-scale:{scale};}}'
f'body{{font-family:var(--sf-font)}}'
f'.sf-canvas .sf-section,.sf-canvas .section{{padding-block:var(--sf-space)}}'
f'.container,.sf-container{{max-width:var(--sf-width)}}'
f'.btn,.primary,.request,.button,.nav-btn{{border-radius:{button}}}')
@router.get('/{site_id}/site-design')
def api_read(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return read(site_id)
@router.put('/{site_id}/site-design')
def api_write(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return write(site_id,payload)
@router.post('/{site_id}/site-design/publish')
def api_publish(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
return publish(site_id,payload.get('expected_revision'),enterprise.actor(request))
@router.delete('/{site_id}/site-design')
def api_discard(request:Request,site_id:str):
enterprise.require(request,site_id,'editor')
_site(site_id)
db.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,))
return {'discarded':True}
@router.get('/{site_id}/site-design/publications')
def api_history(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
_site(site_id)
return db.rows('''SELECT id,site_id,actor,revision,published_at FROM site_design_publications
WHERE site_id=? ORDER BY published_at DESC LIMIT 50''',(site_id,))
File diff suppressed because one or more lines are too long
+104
View File
@@ -0,0 +1,104 @@
'use strict';
const $=(q,el=document)=>el.querySelector(q);
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const ATTR=s=>E(s);
const cfg={automotive:{label:'汽车与出行',desc:'高级质感 · 深色车型展厅',prev:'auto',title:'Move beyond.'},industrial:{label:'工业制造',desc:'专业理性 · 工业产品展示',prev:'ind',title:'Built to last.'},corporate:{label:'企业品牌',desc:'现代明亮 · 集团企业官网',prev:'corp',title:'Ideas become impact.'},minimal:{label:'极简设计',desc:'编辑式排版 · 极简作品展示',prev:'min',title:'Less, but better.'}};
let csrfToken='';
const state={sites:[],siteId:null,site:null,entries:[],builds:[],tab:'overview',presets:[],query:'',typ:''};
const api=async(path,opt={})=>{
if(!['GET','HEAD','OPTIONS'].includes((opt.method||'GET').toUpperCase())) {opt.headers={...(opt.headers||{}),'X-SiteForge-CSRF':csrfToken}}
const res=await fetch(path,opt);if(res.status===401){location.assign('/login');throw new Error('登录已过期')}let data;try{data=await res.json()}catch{data={detail:'请求失败,响应不是 JSON'}}
if(!res.ok)throw new Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail||data));return data;
};
const json=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const notice=(s,bad=false)=>{const t=$('#toast');t.innerText=s;t.style.background=bad?'#a03e45':'#243952';t.classList.add('show');clearTimeout(t._timer);t._timer=setTimeout(()=>t.classList.remove('show'),4300)};
const onError=e=>{console.error(e);notice(e.message||String(e),true)};
const simpleDate=s=>s?new Date(s).toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'—';
const b=(text,fn,extra='')=>`<button class="btn light small" ${extra} onclick="${fn}">${text}</button>`;
const tabs=[['overview','工作概览'],['entries','内容管理'],['templates','模板与设计'],['design','可视化页面设计'],['models','内容字段模型'],['import','旧站 / 数据导入'],['seo','SEO / URL'],['builds','构建与导出']];
function topTitle(name){$('#topTitle').textContent=name}
function hero(title,desc,buttons='',eyebrow='SITEFORGE / WEBSITE FACTORY'){return `<div class="page-head"><div><span class="eyebrow">${E(eyebrow)}</span><h1>${E(title)}</h1><p>${E(desc)}</p></div><div class="top-actions">${buttons}</div></div>`}
function themeCard(key,action){let t=cfg[key];return `<div class="template-card"><div class="template-art preview-${t.prev}${key==='minimal'?' min':''}"><span>THEME / ${E(key.toUpperCase())}</span><strong>${E(t.title)}</strong></div><div class="detail"><div><h3>${E(t.label)}</h3><p>${E(t.desc)}</p></div>${action}</div></div>`}
const App={
async init(){try{const me=await api('/api/auth/me');csrfToken=me.csrf;state.sites=await api('/api/sites');state.presets=await api('/api/presets');App.renderSiteNav();App.openDashboard()}catch(e){onError(e)}},
async logout(){try{await api('/api/auth/logout',{method:'POST'});location.assign('/login')}catch(e){onError(e)}},
async reload(){state.sites=await api('/api/sites');App.renderSiteNav();if(state.siteId){state.site=await api('/api/sites/'+state.siteId);state.entries=await api('/api/sites/'+state.siteId+'/entries');state.builds=await api('/api/sites/'+state.siteId+'/builds')}},
renderSiteNav(){$('#siteNav').innerHTML=state.sites.map(s=>`<button class="${s.id===state.siteId?'active':''}" onclick="App.openSite('${s.id}')"><span class="site-dot">${E((s.name||'S')[0])}</span><span title="${ATTR(s.name)}">${E(s.name)}</span></button>`).join('')||'<div style="padding:10px 12px;font-size:11px;color:#7185a2">暂无网站项目</div>'},
openDashboard(){state.siteId=null;topTitle('网站总览');App.renderSiteNav();document.querySelectorAll('.side-link').forEach(x=>x.classList.remove('selected'));$('#nav-dashboard').classList.add('selected');
let n=state.sites.length, counts=state.sites.reduce((x,s)=>x+s.entries_count,0),builds=state.sites.reduce((x,s)=>x+s.build_count,0);
$('#view').innerHTML=hero('网站生产工作台','从零创建、迁移旧站、导入内容、更换模板,统一生产独立静态网站。',`<button class="btn primary" onclick="App.openNewSite()">+ 创建网站</button>`,'WELCOME TO YOUR FACTORY')+
`<div class="metrics-grid"><div class="metric"><small>网站总数</small><strong>${n}</strong><span>独立站点项目</span></div><div class="metric"><small>内容条目</small><strong>${counts}</strong><span>页面、产品、文章等</span></div><div class="metric"><small>构建版本</small><strong>${builds}</strong><span>可预览与下载</span></div><div class="metric"><small>可用主题</small><strong>4</strong><span>支持每站独立风格</span></div></div>`+
`<div class="page-head" style="margin-top:35px"><div><h1 style="font-size:21px">我的网站</h1><p>选择任意站点,继续编辑、导入数据或构建版本</p></div></div><div class="cards">`+
state.sites.map(s=>`<article class="site-card" onclick="App.openSite('${s.id}')"><div class="card-preview preview-${cfg[s.theme]?.prev||'corp'}"><span class="card-tag">${E(cfg[s.theme]?.label||'企业站')}</span><div class="brandmark">${E(s.name)}</div></div><div class="site-card-body"><h3>${E(s.name)}</h3><p>${E(s.domain||'待绑定域名')} · ${E(s.tagline||'尚未填写站点简介')}</p><div class="site-card-meta"><span>${s.entries_count} 条内容</span><span>${s.build_count} 次构建 →</span></div></div></article>`).join('')+`<article class="add-card" onclick="App.openNewSite()"><div class="add-circle">+</div><h3>创建新网站</h3><p>旧站迁移 / 新建站点 / 导入内容</p></article></div>`+
(!state.sites.length?`<div class="panel" style="margin-top:26px"><div class="panel-title"><div><h3>第一次体验 SiteForge?</h3><p>生成演示网站,并立即构建一个可独立下载的纯静态网站。</p></div><button class="btn light" onclick="App.seedDemo()">快速创建演示网站 →</button></div></div>`:'');
},
async openSite(id,tab='overview'){try{state.siteId=id;state.tab=tab;await App.reload();topTitle(state.site.name);App.renderSiteNav();App.renderSite()}catch(e){onError(e)}},
renderSite(){const s=state.site;let head=hero(s.name,s.domain||'独立网站 · 尚未设置域名',`<button class="btn light" onclick="App.openEntry()">+ 添加内容</button><button class="btn primary" onclick="App.build()">↗ 构建网站</button>`,'WEBSITE PROJECT / '+s.theme.toUpperCase());
let navigation=`<div class="tabs">${tabs.map(([id,name])=>`<button class="tab ${state.tab===id?'current':''}" onclick="App.changeTab('${id}')">${name}</button>`).join('')}</div>`;
$('#view').innerHTML=head+`<div class="metrics-grid"><div class="metric"><small>页面 / 内容</small><strong>${state.entries.length}</strong><span>统一内容数据中心</span></div><div class="metric"><small>主题</small><strong style="font-size:17px;padding:9px 0">${E(cfg[s.theme]?.label)}</strong><span>可随时切换</span></div><div class="metric"><small>构建次数</small><strong>${state.builds.length}</strong><span>独立构建版本</span></div><div class="metric"><small>SEO 模式</small><strong style="font-size:17px;padding:9px 0">${s.mode==='migration'?'原址保留':'灵活新建'}</strong><span>路由独立管理</span></div></div>`+navigation+`<div id="tab-content"></div>`;App.renderTab();
},
changeTab(tab){state.tab=tab;App.renderSite()},
renderTab(){let html='';switch(state.tab){case'overview':html=App.overview();break;case'entries':html=App.entriesView();break;case'templates':html=App.templateView();break;case'design':html=App.designView();break;case'models':html=App.modelsView();break;case'import':html=App.importView();break;case'seo':html=App.seoView();break;case'builds':html=App.buildView();break;}$('#tab-content').innerHTML=html;if(state.tab==='import')App.loadMedia();if(state.tab==='seo')App.loadRedirects();if(state.tab==='models')App.loadModels()},
overview(){let s=state.site, latest=state.builds[0];return `<div class="panel"><div class="panel-title"><div><h3>网站生产流程</h3><p>每次修改内容、主题或域名后都可以重新构建</p></div></div><div class="steps"><div class="step"><span>STEP 01</span><strong>创建网站</strong><p>选择主题,完成独立站点初始化</p></div><div class="step"><span>STEP 02</span><strong>添加或导入内容</strong><p>手动录入、旧站采集、文件批量导入</p></div><div class="step"><span>STEP 03</span><strong>主题与 SEO</strong><p>自定义视觉样式、路由及页面元信息</p></div><div class="step"><span>STEP 04</span><strong>构建并导出</strong><p>生成静态 HTML 文件,预览并下载 ZIP</p></div></div></div><div class="split"><div class="panel"><div class="panel-title"><div><h3>网站基本资料</h3><p>设置域名、品牌和原网站来源</p></div></div>${App.settingsForm()}</div><div class="panel"><div class="panel-title"><div><h3>最近构建版本</h3><p>构建文件与管理后台彼此独立</p></div></div>${latest?`<div class="pill green">静态构建完成</div><h2 style="margin:16px 0">${latest.total_pages} 个 HTML 页面</h2><p style="color:#8292a8">${simpleDate(latest.created_at)}</p><div class="panel-footer" style="justify-content:start"><a class="btn light" target="_blank" href="/preview/${s.id}/${latest.id}/">查看预览 ↗</a><a class="btn primary" href="/api/sites/${s.id}/builds/${latest.id}/download">下载 ZIP</a></div>`:`<div class="empty"><div>⌁</div><h3>还没有构建版本</h3><p>保存内容并点击「构建网站」,即可生成首个网站。</p><button class="btn primary" onclick="App.build()">开始第一次构建</button></div>`}</div></div>`},
settingsForm(){let s=state.site;return `<form id="settingsForm" onsubmit="App.saveSettings(event)"><div class="setting-grid"><label class="full">网站名称<input name="name" value="${ATTR(s.name)}" required></label><label class="full">正式网站地址<input name="domain" value="${ATTR(s.domain)}" placeholder="https://domain.com"></label><label class="full">网站简介 / 标语<textarea name="tagline" rows="2">${E(s.tagline)}</textarea></label><label class="full">源站地址(旧站导入)<input name="origin" value="${ATTR(s.settings.origin||'')}" placeholder="https://oldsite.com"></label><label>品牌色<input name="accent" type="color" value="${ATTR(s.accent)}" style="height:42px;padding:3px"></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 class="full">Logo 图片 URL<input name="logo" value="${ATTR(s.logo||'')}"></label></div><div class="panel-footer"><button class="btn primary" type="submit">保存网站设置</button></div></form>`},
async saveSettings(ev){ev.preventDefault();try{let data=Object.fromEntries(new FormData(ev.target));state.site=await api('/api/sites/'+state.siteId,json('PATCH',data));await App.reload();notice('已保存站点设置');App.renderSite()}catch(e){onError(e)}},
entriesView(){let arr=state.entries.filter(e=>(!state.typ||e.type===state.typ)&&(!state.query||e.title.toLowerCase().includes(state.query.toLowerCase())||e.route.toLowerCase().includes(state.query.toLowerCase())));return `<div class="panel"><div class="panel-title"><div><h3>内容库</h3><p>页面 / 产品 / 文章 / 自定义内容,支持独立 URL 和 SEO</p></div><button class="btn primary small" onclick="App.openEntry()">+ 新建内容</button></div><div class="toolbar"><input id="searchEntry" placeholder="搜索标题或 URL" value="${ATTR(state.query)}" oninput="App.filterContent()"><select id="typeFilter" onchange="App.filterContent()"><option value="">全部类型</option>${['page','product','post','service','case','vehicle','brand','other'].map(t=>`<option value="${t}" ${state.typ===t?'selected':''}>${{page:'页面',product:'产品',post:'文章',service:'服务',case:'案例',vehicle:'车型',brand:'品牌',other:'自定义'}[t]}</option>`).join('')}</select><span class="muted">${arr.length} 条结果</span></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>标题</th><th>类型</th><th>路由</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead><tbody>${arr.map(e=>`<tr><td><strong>${E(e.title)}</strong><div style="font-size:11px;color:#9da5b2;max-width:240px;overflow:hidden;text-overflow:ellipsis">${E(e.summary||'')}</div></td><td>${E(e.type)}</td><td class="mono">${E(e.route)}</td><td><span class="pill ${e.status==='published'?'green':'yellow'}">${e.status==='published'?'已发布':'草稿'}</span></td><td>${simpleDate(e.updated_at)}</td><td><div class="row-actions"><a class="btn light tiny" href="/editor/${state.siteId}/${e.id}" target="_blank">页面设计</a><button class="btn light tiny" onclick="App.openEntry('${e.id}')">编辑内容</button><button class="btn light tiny" onclick="App.deleteEntry('${e.id}')">删除</button></div></td></tr>`).join('')}</tbody></table>${arr.length?'':'<div class="empty"><h3>尚无匹配内容</h3><p>新建内容或使用导入中心批量导入</p></div>'}</div></div>`},
filterContent(){const active=document.activeElement?.id;const caret=active==='searchEntry'?$('#searchEntry').selectionStart:null;state.query=$('#searchEntry')?.value||'';state.typ=$('#typeFilter')?.value||'';$('#tab-content').innerHTML=App.entriesView();if(active==='searchEntry'){const input=$('#searchEntry');input.focus();if(caret!==null)input.setSelectionRange(caret,caret)}},
async updateNavigation(ev){ev.preventDefault();try{const raw=$('#navRoutes').value.split('\n').map(x=>x.trim()).filter(Boolean);const entries=raw.map(line=>{const sep=line.indexOf('|');if(sep<0)throw new Error('每行必须为:导航文字 | /路径/');return {title:line.slice(0,sep).trim(),route:line.slice(sep+1).trim()}});let path='/api/sites/'+state.siteId+'/site-design';let current=await api(path);current.config.nav=entries;await api(path,json('PUT',{config:current.config,expected_revision:current.revision}));notice('导航已保存为全站草稿:'+entries.length+'项,请在设计器中发布全站设计')}catch(e){onError(e)}},
async scaffold(){if(!confirm('创建关于我们、联系我们和服务页面?已有同路径页面不会覆盖。'))return;try{let r=await api('/api/sites/'+state.siteId+'/scaffold',{method:'POST'});await App.reload();App.renderSite();notice('创建 '+r.created.length+' 个页面,跳过 '+r.skipped.length+' 个')}catch(e){onError(e)}},
modelsView(){return `<div class="panel"><div class="panel-title"><div><h3>内容字段模型</h3><p>自定义产品、车型、品牌、文章等数据字段。字段保存在每个站点自己的配置内,不影响其他站点。</p></div></div><div class="toolbar"><label>内容类型 <select id="modelKind" onchange="App.renderModelFields()">${['product','vehicle','post','brand','case','service','other'].map(x=>`<option value="${x}">${x}</option>`).join('')}</select></label><button class="btn primary" onclick="App.addModelField()">+ 添加字段</button></div><div id="modelFields"><p>正在读取字段定义……</p></div><div class="panel-footer"><button class="btn primary" onclick="App.saveModelFields()">保存字段模型</button></div></div>`},
async loadModels(){try{state.models=await api('/api/sites/'+state.siteId+'/schemas');App.renderModelFields()}catch(e){onError(e)}},
renderModelFields(){let el=$('#modelFields');if(!el||!state.models)return;let kind=$('#modelKind').value;let defs=state.models[kind]||[];el.innerHTML=`<table class="data-table"><thead><tr><th>字段键名</th><th>显示名称</th><th>字段类型</th><th></th></tr></thead><tbody id="fieldRows">${defs.map(d=>App.modelRow(d)).join('')}</tbody></table>`},
modelRow(d={}){return `<tr class="field-row"><td><input class="field-key" value="${ATTR(d.name||'')}" placeholder="例如:wheelbase" style="width:100%"></td><td><input class="field-label" value="${ATTR(d.label||'')}" placeholder="字段名称" style="width:100%"></td><td><select class="field-type">${['text','textarea','number','date','url','boolean'].map(t=>`<option value="${t}" ${d.type===t?'selected':''}>${t}</option>`).join('')}</select></td><td><button class="btn light tiny" onclick="this.closest('tr').remove()">删除</button></td></tr>`},
addModelField(){if(!$('#fieldRows'))return;$('#fieldRows').insertAdjacentHTML('beforeend',App.modelRow())},
async saveModelFields(){try{const fields=[...document.querySelectorAll('.field-row')].map(tr=>({name:tr.querySelector('.field-key').value.trim(),label:tr.querySelector('.field-label').value.trim(),type:tr.querySelector('.field-type').value}));let kind=$('#modelKind').value;let result=await api('/api/sites/'+state.siteId+'/schemas/'+kind,json('PUT',fields));state.models[kind]=result.fields;notice('已保存 '+kind+' 字段模型')}catch(e){onError(e)}},
designView(){const s=state.site;return `<div class="panel"><div class="panel-title"><div><h3>Visual Studio · 页面可视化工作台</h3><p>拖动区块顺序、修改属性、自动绑定内容,并在同一个界面预览 PC 和移动端。</p></div></div><div class="cards">${state.entries.filter(e=>e.type==='page'||e.type==='product'||e.type==='post').slice(0,36).map(e=>`<div class="site-card" style="cursor:default;padding:20px"><div class="pill">${E(e.type)}</div><h3>${E(e.title)}</h3><p style="color:#8999a9">${E(e.route)}</p><a class="btn primary small" href="/editor/${s.id}/${e.id}" target="_blank">打开可视化编辑器 ↗</a></div>`).join('')}</div><p style="margin-top:22px;color:#7f90a2">页面编辑后点击构建,系统生成对应的独立静态页面,不会丢失原有 URL 和 SEO。 <button class="btn light small" style="margin-left:15px" onclick="App.scaffold()">一键补齐企业常用页面</button></p></div><div class="panel"><div class="panel-title"><div><h3>顶部导航配置</h3><p>每行:标题 | /原始路径/,不填写则按网站已有栏目自动生成导航。</p></div></div><form onsubmit="App.updateNavigation(event)"><textarea id="navRoutes" style="width:100%;min-height:115px;border:1px solid #dce3ed;padding:15px;border-radius:10px">${E((state.site.settings.nav||[]).map(n=>n.title+' | '+n.route).join('\n'))}</textarea><div class="panel-footer"><button class="btn primary" type="submit">保存导航结构</button></div></form></div>`},
async applyTemplates(mode){if(!confirm(mode==='overwrite'?'将用当前主题的默认布局覆盖所有页面区块,内容和SEO不变,确定继续?':'将给尚未设置布局的页面自动套用当前主题,确定继续?'))return;try{let r=await api('/api/sites/'+state.siteId+'/apply-theme',json('POST',{mode}));await App.reload();notice('已装配 '+r.updated+' 个页面,跳过 '+r.skipped+' 个');App.renderSite()}catch(e){onError(e)}},
templateView(){let s=state.site;return `<div class="panel"><div class="panel-title"><div><h3>主题样式库</h3><p>切换后内容、原有 URL 和 SEO 数据不变,重新构建立即产生全新视觉版本</p></div></div><div class="template-grid">${Object.keys(cfg).map(t=>themeCard(t,`<button class="btn ${s.theme===t?'light':'primary'} small" ${s.theme===t?'disabled':''} onclick="App.setTheme('${t}')">${s.theme===t?'✓ 正在使用':'使用主题'}</button>`)).join('')}</div></div><div class="panel"><div class="panel-title"><div><h3>模板工厂</h3><p>保存当前主题与首页区块配置,以后创建其他网站时可以直接套用。</p></div></div><div class="toolbar"><input id="presetName" placeholder="例如:HESU 汽车企业基础模板" style="max-width:400px"><button class="btn primary" onclick="App.savePreset()">保存为新模板</button><button class="btn light" onclick="App.cloneSite()">克隆整个站点</button></div><div class="toolbar" style="margin:16px 0"><button class="btn primary" onclick="App.applyTemplates('missing')">一键把已有内容装入新主题(保留已设计的页面)</button><button class="btn light" onclick="App.applyTemplates('overwrite')">全部应用当前主题预设布局</button></div><div class="tip">当前版本支持页面区块拖拽排序、PC / 手机预览、动态内容绑定。完全自由定位的像素级编辑仍不支持。</div></div>`},
importView(){return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>批量导入文件</h3><p>支持 WordPress XML、CSV、Excel XLSX、JSON</p></div></div><form id="importForm" onsubmit="App.importFile(event)"><label class="field">选择内容数据文件<input type="file" name="file" required accept=".xml,.csv,.json,.xlsx"></label><label class="field" style="margin-top:18px">遇到相同 URL 时<select name="on_conflict"><option value="skip">跳过原有页面(安全默认)</option><option value="update">覆盖当前内容(请先备份)</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">开始批量导入</button></div></form><div class="tip" style="margin-top:18px">建议 CSV 字段:type, title, route, summary, body, cover, seo_title, seo_description。</div></div><div class="panel"><div class="panel-title"><div><h3>旧站公开页面采集</h3><p>支持 WordPress、普通 HTML 与其他 CMS 的公开内容</p></div></div><form id="crawlForm" onsubmit="App.crawl(event)"><label class="field">旧网站首页 URL<input name="url" type="url" required value="${ATTR(state.site.settings.origin||'')}" placeholder="https://oldsite.com/"></label><label class="field" style="margin-top:18px">最大抓取页面数<select name="max_pages"><option value="10">10 页(快速验证)</option><option value="20" selected>20 页</option><option value="50">50 页</option></select></label><label class="field" style="margin-top:18px">冲突策略<select name="on_conflict"><option value="skip">保留本地内容</option><option value="update">用采集内容更新</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">扫描并导入公开页面</button></div></form><div class="tip warning" style="margin-top:18px">公开爬取无法保证包含私有字段、插件数据或复杂动态页面。推荐尽量使用原站完整数据库/导出文件。</div></div></div><div class="panel"><div class="panel-title"><div><h3>素材中心</h3><p>上传图片,生成本地媒体路径,内容页可直接引用。</p></div><label class="btn light small">上传图片<input type="file" accept="image/*" hidden onchange="App.uploadMedia(this.files[0])"></label></div><div class="tip">上传文件 URL 将返回类似 media/abc123.jpg,静态站构建时自动携带。</div><div class="toolbar" style="margin-top:18px"><label class="btn light small">批量导入 WordPress 媒体 ZIP<input type="file" accept=".zip" hidden onchange="App.uploadMediaArchive(this.files[0])"></label></div><div id="mediaGrid" style="padding-top:12px"></div></div>`},
seoView(){return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>SEO 保留策略</h3><p>不因改版随意改变页面 URL</p></div></div><div class="feature-option"><div class="feature-icon">↗</div><b>页面 URL Registry</b><p>所有现有 URL 由内容库的 route 精确定义;冲突直接禁止保存。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">◇</div><b>SEO Metadata</b><p>编辑内容可以设置 Title、Description、Canonical 和 Robots。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">▤</div><b>Sitemap + 服务器规则</b><p>构建时生成 sitemap.xml、robots.txt、Nginx snippet 和 .htaccess。</p></div></div><div class="panel"><div class="panel-title"><div><h3>301 / 302 重定向管理</h3><p>原 URL 不能保留时,人工审批后配置对应跳转。</p></div></div><form id="redirectForm" onsubmit="App.createRedirect(event)"><label class="field">原 URL Path<input name="old_path" required placeholder="/old-product/"/></label><label class="field" style="margin-top:14px">目标 URL Path<input name="new_path" required placeholder="/new-product/"/></label><label class="field" style="margin-top:14px">HTTP 状态码<select name="status_code"><option value="301">301 永久</option><option value="302">302 临时</option><option value="307">307 临时保留方法</option><option value="308">308 永久保留方法</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">添加重定向</button></div></form><div id="redirectList"></div></div></div>`},
buildView(){return `<div class="panel"><div class="panel-title"><div><h3>静态构建中心</h3><p>将当前内容、模板和 SEO 路由组合成完全独立的 HTML 文件。</p></div></div><div class="split"><div><div class="feature-option"><div class="feature-icon">▦</div><b>静态 HTML / CSS / JS</b><p>无需 PHP、WordPress 或中心 CMS,就能独立部署访问。</p></div></div><div><div class="feature-option"><div class="feature-icon">▰</div><b>可回溯构建版本</b><p>每次构建产生独立 ZIP 和版本报告,不会覆盖旧版本。</p></div></div></div><div class="toolbar" style="margin-top:22px"><label style="display:flex;align-items:center;gap:9px"><input id="mirrorChoice" type="checkbox" style="width:18px;height:18px">打包外部图片(较慢,外部资源可能下载失败)</label></div><div class="panel-footer"><button class="btn primary" onclick="App.build()">↗ 生成新的静态网站版本</button></div></div><div class="panel"><div class="panel-title"><div><h3>历史构建</h3><p>选择版本查看预览、下载 ZIP、检查 SEO/媒体报告</p></div></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>版本 ID</th><th>构建时间</th><th>页面数量</th><th>报告</th><th>操作</th></tr></thead><tbody>${state.builds.map(x=>{let r=JSON.parse(x.report_json||'{}');return `<tr><td class="mono">${x.id}</td><td>${simpleDate(x.created_at)}</td><td>${x.total_pages}</td><td><span class="pill ${r.quality_status==='warning'?'yellow':'green'}">${r.quality_status==='warning'?'有告警':'已通过基础检查'}</span></td><td><div class="row-actions"><a class="btn light tiny" target="_blank" href="/preview/${state.siteId}/${x.id}/">预览 ↗</a><a class="btn light tiny" href="/api/sites/${state.siteId}/builds/${x.id}/download">ZIP ↓</a><button class="btn light tiny" onclick="App.showReport('${x.id}')">报告</button><button class="btn light tiny" onclick="App.publishLocal('${x.id}')" title="发布到 Docker 持久化发布目录">发布</button></div></td></tr>`}).join('')}</tbody></table>${state.builds.length?'':'<div class="empty"><div>⌘</div><h3>尚无构建记录</h3><p>完成内容编辑后,点击上方按钮生成版本。</p></div>'}</div><div id="reportBox"></div></div>`},
async openTemplates(){state.siteId=null;topTitle('模板资源库');App.renderSiteNav();document.querySelectorAll('.side-link').forEach(x=>x.classList.remove('selected'));$('#nav-template').classList.add('selected');$('#view').innerHTML=hero('模板与主题资源库','从四套行业视觉风格开始,逐步沉淀企业自己的模板库。',`<button class="btn primary" onclick="App.openNewSite()">使用模板建站 →</button>`)+`<div class="template-grid">${Object.keys(cfg).map(t=>themeCard(t,`<button class="btn primary small" onclick="App.openNewSite('${t}')">基于模板创建</button>`)).join('')}</div><div class="panel" style="margin-top:20px"><div class="panel-title"><div><h3>已保存的模板预设</h3><p>由已有网站保存,复用主题及首页区块结构。</p></div></div>${state.presets.length?state.presets.map(p=>`<div class="feature-option" style="margin-bottom:9px"><b>${E(p.name)}</b><p>${E(cfg[p.theme]?.label||p.theme)} · ${simpleDate(p.created_at)}</p><button class="btn light tiny" style="margin-top:11px" onclick="App.openNewSite('${p.theme}','${p.id}')">使用此模板建站</button></div>`).join(''):'<div class="empty"><h3>暂未保存企业自定义模板</h3><p>从任意网站的「模板与设计」中保存为模板。</p></div>'}</div>`},
openNewSite(theme='',preset=''){const f=$('#siteForm');f.reset();if(theme)f.elements.theme.value=theme;$('#presetSelector').innerHTML='<option value="">不使用自定义模板</option>'+state.presets.map(p=>`<option value="${p.id}">${E(p.name)} · ${E(p.theme)}</option>`).join('');if(preset)f.elements.preset_id.value=preset;$('#siteDialog').showModal()},
closeDialog(id){$('#'+id).close()},
async createSite(ev){ev.preventDefault();try{let values=Object.fromEntries(new FormData(ev.target));let site=await api('/api/sites',json('POST',values));App.closeDialog('siteDialog');notice('网站已创建,可以开始添加内容');await App.openSite(site.id)}catch(e){onError(e)}},
openEntry(id=''){let f=$('#entryForm');f.reset();let e=state.entries.find(x=>x.id===id);f.elements.entry_id.value=id;$('#entryHeading').textContent=id?'编辑内容':'新建内容';if(e){for(let key of ['title','route','summary','body','cover','seo_title','seo_description','canonical','type','status','robots'])if(f.elements[key])f.elements[key].value=e[key]??'';f.elements.meta.value=JSON.stringify(e.meta||{},null,2)}else{f.elements.meta.value='{}';f.elements.type.value='page';f.elements.status.value='published'}$('#entryDialog').showModal();App.loadEntryFields()},
async loadEntryFields(){try{state.models=await api('/api/sites/'+state.siteId+'/schemas');App.renderEntryFields()}catch(e){onError(e)}},
renderEntryFields(){let el=$('#dynamicContentFields');if(!el)return;let kind=$('#entryType').value;let list=(state.models||{})[kind]||[];let parsed={};try{parsed=JSON.parse($('#entryMeta').value||'{}')}catch{};const values=parsed.fields||parsed;el.innerHTML=list.length?`<div class="panel-title"><div><h3>业务字段 · ${E(kind)}</h3><p>来自当前网站的字段模型,保存时同步写入内容数据。</p></div></div><div class="setting-grid">${list.map(f=>`<label>${E(f.label||f.name)}<input class="sf-data-field" data-key="${ATTR(f.name)}" type="${['number','date','url'].includes(f.type)?f.type:'text'}" value="${ATTR(values[f.name]||'')}"></label>`).join('')}</div>`:''},
addBlock(type){const el=$('#entryMeta');let parsed={};try{parsed=JSON.parse(el.value||'{}')}catch{return notice('当前 JSON 格式不合法,先修复后再添加区块',true)}if(!Array.isArray(parsed.blocks))parsed.blocks=[];let obj=({hero:{type:'hero',title:'核心主标题',subtitle:'OUR VISION',description:'可编辑的首页主视觉描述'},features:{type:'features',title:'核心优势',items:[{title:'专业实力',text:'填写优势简介'},{title:'可靠交付',text:'填写优势简介'},{title:'服务网络',text:'填写优势简介'}]},stats:{type:'stats',items:[{value:'30+',label:'合作品牌'},{value:'70+',label:'服务市场'}]},cta:{type:'cta',title:'期待与您合作',subtitle:'联系我们,了解更多服务',button:'联系我们',href:'/contact/'}})[type];parsed.blocks.push(obj);el.value=JSON.stringify(parsed,null,2);notice('已添加区块,请编辑 JSON 中的标题与内容')},
async saveEntry(ev){ev.preventDefault();try{let v=Object.fromEntries(new FormData(ev.target)),id=v.entry_id;delete v.entry_id;v.meta=JSON.parse(v.meta||'{}');const fieldInputs=[...document.querySelectorAll('#dynamicContentFields .sf-data-field')];if(fieldInputs.length){let fields={...(v.meta.fields||{})};for(const input of fieldInputs)fields[input.dataset.key]=input.value;v.meta.fields=fields}if(!id&&!v.route)delete v.route;let endpoint='/api/sites/'+state.siteId+'/entries'+(id?'/'+id:'');await api(endpoint,json(id?'PATCH':'POST',v));App.closeDialog('entryDialog');await App.openSite(state.siteId,'entries');notice(id?'已更新内容':'已创建内容')}catch(e){onError(e)}},
async deleteEntry(id){if(!confirm('确定删除此内容吗?删除之后重新构建才会影响线上静态网站。'))return;try{await api('/api/sites/'+state.siteId+'/entries/'+id,{method:'DELETE'});await App.openSite(state.siteId,'entries');notice('内容已删除')}catch(e){onError(e)}},
async setTheme(theme){if(!confirm('切换到「'+cfg[theme].label+'」?内容和 URL 保持不变,需重新构建才能更新站点。'))return;try{await api('/api/sites/'+state.siteId,json('PATCH',{theme}));await App.openSite(state.siteId,'templates');notice('主题已更换,请重新构建预览')}catch(e){onError(e)}},
async savePreset(){let name=$('#presetName')?.value?.trim();if(!name)return notice('请输入新模板名称',true);try{let result=await api('/api/sites/'+state.siteId+'/save-preset',json('POST',{name}));state.presets=await api('/api/presets');notice('已保存模板:'+result.name)}catch(e){onError(e)}},
async cloneSite(){let name=prompt('新站名称:',state.site.name+' 副本');if(!name)return;try{let newsite=await api('/api/sites/'+state.siteId+'/clone',json('POST',{name}));notice('已克隆全部内容到新网站');await App.openSite(newsite.id)}catch(e){onError(e)}},
async importFile(ev){ev.preventDefault();let btn=$('button[type=submit]',ev.target);btn.disabled=true;btn.innerText='导入中...';try{let form=new FormData(ev.target);let result=await api('/api/sites/'+state.siteId+'/import',{method:'POST',body:form});await App.openSite(state.siteId,'import');notice(`导入完成:新增 ${result.imported},更新 ${result.updated},跳过 ${result.skipped},错误 ${result.errors?.length||0}`);if(result.errors?.length)alert(JSON.stringify(result.errors.slice(0,15),null,2))}catch(e){onError(e)}finally{btn.disabled=false;btn.innerText='开始批量导入'}},
async crawl(ev){ev.preventDefault();if(!confirm('确认对该公开网站进行限定范围采集?请仅采集你拥有或获授权迁移的站点。'))return;let btn=$('button[type=submit]',ev.target);btn.disabled=true;btn.innerText='正在采集...';try{const input=Object.fromEntries(new FormData(ev.target));input.max_pages=+input.max_pages;const r=await api('/api/sites/'+state.siteId+'/crawl',json('POST',input));await App.openSite(state.siteId,'import');notice(`采集完成:发现 ${r.discovered} 页,新增 ${r.imported} 页,页面获取失败 ${r.fetch_errors?.length||0}`)}catch(e){onError(e)}finally{btn.disabled=false;btn.innerText='扫描并导入公开页面'}},
async uploadMediaArchive(file){if(!file)return;try{let form=new FormData();form.append('file',file);notice('正在解压媒体文件...');let r=await api('/api/sites/'+state.siteId+'/media-archive',{method:'POST',body:form});notice(`媒体导入:${r.imported} 文件,跳过 ${r.skipped} 文件`)}catch(e){onError(e)}},
async uploadMedia(file){if(!file)return;try{let fd=new FormData();fd.append('file',file);let r=await api('/api/sites/'+state.siteId+'/media',{method:'POST',body:fd});notice('图片已上传:'+r.url);App.loadMedia()}catch(e){onError(e)}},
async uploadCover(file){if(!file)return;try{let fd=new FormData();fd.append('file',file);let r=await api('/api/sites/'+state.siteId+'/media',{method:'POST',body:fd});$('#coverInput').value=r.url;notice('封面上传成功')}catch(e){onError(e)}},
async loadMedia(){if(state.tab!=='import')return;try{let data=await api('/api/sites/'+state.siteId+'/media');$('#mediaGrid').innerHTML=data.map(x=>`<div style="display:inline-flex;flex-direction:column;gap:4px;border:1px solid #ddd;padding:9px;margin:5px"><img style="width:95px;height:70px;object-fit:contain" src="/local-media/${state.siteId}/${E(x.name)}"><button class="btn light tiny" onclick="navigator.clipboard.writeText('${x.url}');notice('已复制图片地址')">复制路径</button></div>`).join('')||'<p class="muted">暂无本地素材</p>'}catch(e){onError(e)}},
async createRedirect(ev){ev.preventDefault();let value=Object.fromEntries(new FormData(ev.target));value.status_code=+value.status_code;try{await api('/api/sites/'+state.siteId+'/redirects',json('POST',value));ev.target.reset();notice('已创建重定向规则');App.loadRedirects()}catch(e){onError(e)}},
async loadRedirects(){if(state.tab!=='seo')return;try{let data=await api('/api/sites/'+state.siteId+'/redirects');$('#redirectList').innerHTML=data.length?'<h4 style="margin-top:24px">已有重定向</h4>'+data.map(x=>`<p class="mono" style="display:flex;justify-content:space-between;gap:12px">${E(x.old_path)} ${x.status_code} → ${E(x.new_path)} <button class="btn light tiny" onclick="App.deleteRedirect('${x.id}')">删除</button></p>`).join(''):'<p style="margin-top:18px;color:#9aa5b2;font-size:12px">当前没有重定向规则。</p>'}catch(e){onError(e)}},
async deleteRedirect(id){if(!confirm('确认删除这条规则?'))return;try{await api('/api/sites/'+state.siteId+'/redirects/'+id,{method:'DELETE'});App.loadRedirects()}catch(e){onError(e)}},
async publishLocal(id){if(!confirm('将此版本发布到 Docker 持久化目录?请确认配置好独立站点服务器。'))return;try{const x=await api('/api/sites/'+state.siteId+'/builds/'+id+'/publish-local',{method:'POST'});notice('已发布:'+x.release+'(发布文件位于 Docker 数据卷)')}catch(e){onError(e)}},
async build(){let s=state.siteId;if(!s)return;let mirror=$('#mirrorChoice')?.checked||false;
try{let job=await api('/api/sites/'+s+'/builds',{...json('POST',{mirror_images:mirror}),headers:{'Content-Type':'application/json','Idempotency-Key':crypto.randomUUID()}});
notice('已进入后台构建队列:'+job.id+',关闭浏览器也可继续执行');
for(let i=0;i<240;i++){await new Promise(resolve=>setTimeout(resolve,1500));
let status=await api('/api/enterprise/sites/'+s+'/jobs/'+job.id);
if(status.state==='done'){let result=JSON.parse(status.result_json||'{}');await App.openSite(s,'builds');notice('构建完成:'+(result.pages||0)+' 个页面');if(result.version)App.showReport(result.version);return}
if(status.state==='failed'||status.state==='cancelled'){notice('任务'+status.state+': '+(status.error||''),true);return}
}notice('构建仍在后台运行,请在任务中心查看状态');
}catch(e){onError(e)}},
async showReport(id){try{const r=await api('/api/sites/'+state.siteId+'/builds/'+id+'/report');let box=$('#reportBox');if(!box){alert(JSON.stringify(r,null,2));return}box.innerHTML=`<div class="panel" style="margin-top:20px;background:#fafdff"><div class="panel-title"><div><h3>质量报告:${E(id)}</h3><p>本报告只覆盖当前实现的基础结构与资源检查,不代表 SEO 排名保证。</p></div></div><div class="metrics-grid"><div class="metric"><small>静态 HTML 数</small><strong>${r.pages}</strong></div><div class="metric"><small>重定向数</small><strong>${r.redirects}</strong></div><div class="metric"><small>已镜像图片</small><strong>${r.mirrored_images}</strong></div><div class="metric"><small>质量告警</small><strong>${r.warnings.length}</strong></div></div>${r.warnings.length?`<div class="tip warning">${r.warnings.map(x=>`• ${E(x)}`).join('<br>')}</div>`:'<div class="tip">✓ 当前规则下未发现告警</div>'}<h4>详细报告</h4><pre class="pre">${E(JSON.stringify(r,null,2))}</pre></div>`;box.scrollIntoView({behavior:'smooth',block:'nearest'})}catch(e){onError(e)}},
async seedDemo(){try{const s=await api('/api/sites',json('POST',{name:'HESU Global Motors · 演示站',tagline:'全球汽车采购、出口与交付服务',theme:'automotive',domain:'https://demo.example.com',mode:'new'}));let base='/api/sites/'+s.id+'/entries';for(let v of [
{type:'page',title:'关于我们',route:'/about/',summary:'专注全球汽车出口与本地交付',body:'<p>这里是示例公司介绍。请在内容管理中替换为你的真实资料。</p>',seo_title:'About us'},
{type:'product',title:'ROX 01',route:'/product/rox-01/',summary:'豪华全地形 SUV · 示例数据,可在后台编辑',body:'<h2>产品概览</h2><p>此条目为演示内容,不代表实际库存或车型参数。</p>'},
{type:'product',title:'BYD ATTO 3',route:'/product/byd-atto-3/',summary:'纯电 SUV · 示例车型信息',body:'<p>可以从 WordPress、Excel 或 JSON 重新导入真正的车型数据。</p>'},
{type:'post',title:'网站动态示例',route:'/news/first-news/',summary:'一个独立的新网站如何快速发布',body:'<p>编辑内容、切换主题、点击构建,即可导出静态网站。</p>'}
])await api(base,json('POST',v));await App.openSite(s.id);await App.build();notice('演示网站已创建并成功构建,可直接预览或下载 ZIP')}catch(e){onError(e)}},
};
window.App=App;
$('#siteForm').addEventListener('submit',App.createSite);
$('#entryForm').addEventListener('submit',App.saveEntry);
window.addEventListener('DOMContentLoaded',()=>App.init());
+29
View File
@@ -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%}}
+33
View File
@@ -0,0 +1,33 @@
/* 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}
File diff suppressed because one or more lines are too long
+155
View File
@@ -0,0 +1,155 @@
'use strict';
const $=id=>document.querySelector(id);
const html=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pathParts=location.pathname.split('/').filter(Boolean);
const siteId=pathParts[1],initialPage=pathParts[2];
let csrf='',site=null,pages=[],schema=null,reusables=[],pageId=initialPage,blocks=[],kind='standard',layoutMode='append',selected=-1,dirty=false,timer=null;
const api=async(p,opt={})=>{if(!['GET','HEAD','OPTIONS'].includes((opt.method||'GET').toUpperCase()))opt.headers={...(opt.headers||{}),'X-SiteForge-CSRF':csrf};let r=await fetch(p,opt);if(r.status===401){location.assign('/login');throw Error('会话已过期')}let t=r.headers.get('content-type')||'';let body=t.includes('json')?await r.json():await r.text();if(!r.ok)throw Error(body.detail||body);return body};
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 activePage=()=>pages.find(e=>e.id===pageId);
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 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()}
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 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 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)}},
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();},
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()},
select(i){selected=i;Studio.draw()},
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()},
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()},
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')},
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()},
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},
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()},
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>`;
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>`;
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>`;
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==='body'||field==='description')out+=`<textarea onchange="Studio.fieldQuiet('${field}',this.value)">${html(value)}</textarea>`;
else if(['limit','columns','height'].includes(field))out+=`<input type="number" min="1" max="120" value="${html(value)}" onchange="Studio.fieldQuiet('${field}',Number(this.value))">`;
else out+=`<input type="text" value="${html(value)}" oninput="Studio.fieldQuiet('${field}',this.value)">`;
if(['title','subtitle','description','image','body'].includes(field))out+='<small class="prop-tip">可输入 {{site.name}}、{{page.title}} 等变量。</small>';
out+='</div>';
}
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>`;
b.columns.forEach((column,ci)=>{
out+=`<div class="layout-editor-col" ondragover="event.preventDefault()" ondrop="Studio.dropChild(event,${ci})"><div class="layout-editor-title"><strong>第${ci+1}列</strong><label>宽度 <input type="number" min="0.5" max="4" step="0.5" value="${html(column.width)}" onchange="Studio.setColumnWidth(${ci},this.value)"></label><button onclick="Studio.removeColumn(${ci})">删除列</button></div>`;
out+=`<div class="prop"><label>平板排序 <input type="number" min="0" max="20" value="${html(column.tablet_order??(ci+1))}" onchange="Studio.setColumnDevice(${ci},'tablet_order',Number(this.value))"></label><label>手机排序 <input type="number" min="0" max="20" value="${html(column.mobile_order??(ci+1))}" onchange="Studio.setColumnDevice(${ci},'mobile_order',Number(this.value))"></label><label>平板跨列 <input type="number" min="1" max="${html(b.tablet_columns||2)}" value="${html(column.tablet_span||1)}" onchange="Studio.setColumnDevice(${ci},'tablet_span',Number(this.value))"></label><label>手机跨列 <input type="number" min="1" max="${html(b.mobile_columns||1)}" value="${html(column.mobile_span||1)}" onchange="Studio.setColumnDevice(${ci},'mobile_span',Number(this.value))"></label><label><input type="checkbox" ${column.hide_tablet?'checked':''} onchange="Studio.setColumnDevice(${ci},'hide_tablet',this.checked)"> 平板隐藏此列</label><label><input type="checkbox" ${column.hide_mobile?'checked':''} onchange="Studio.setColumnDevice(${ci},'hide_mobile',this.checked)"> 手机隐藏此列</label></div>`;
column.blocks.forEach((child,j)=>{out+=`<div class="layout-child" draggable="true" ondragstart="Studio.dragChild(event,${ci},${j})"><strong>${html(schema.blocks[child.type]?.name||child.type)}</strong><button onclick="Studio.removeChild(${ci},${j})" title="删除">×</button>`;
for(const key of (schema.blocks[child.type]?.fields||[]).filter(x=>['title','subtitle','description','image','body','href','button'].includes(x))){out+=`<label>${html(key)}<input value="${html(child[key]??'')}" onchange="Studio.childField(${ci},${j},'${key}',this.value)"></label>`}
if(b.columns.length>1){out+=`<select onchange="Studio.moveChild(${ci},${j},Number(this.value))"><option value="">移动到其他列</option>${b.columns.map((c,ki)=>ki===ci?'':`<option value="${ki}">第${ki+1}列</option>`).join('')}</select>`}
out+='</div>';
});
out+=`<div class="layout-child-toolbar">${['richtext','image_text','collection','cta','features'].map(t=>`<button onclick="Studio.addChild(${ci},'${t}')">+${html(schema.blocks[t].name)}</button>`).join('')}</div></div>`;
});
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 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;
},
undo(){restoreSnapshot(snapshotCursor-1)},
redo(){restoreSnapshot(snapshotCursor+1)},
modified(){recordSnapshot();dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),500)},
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)}},
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)},
changeTemplateKind(t){kind=t;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 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 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)}},
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()},
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()},
async showSiteDesign(){try{
const result=await api(`/api/sites/${siteId}/site-design`);
siteDesign=result.config;siteDesignRevision=result.revision;siteDesignPreview=structuredClone(siteDesign);
const form=$('#siteDesignForm'),h=siteDesign.header,f=siteDesign.footer,r=siteDesign.responsive;
for(const [name,value] of Object.entries(h)){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(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}}
form.elements.namedItem('nav').value=JSON.stringify(siteDesign.nav,null,2);
$('#siteDesignStatus').textContent=result.state==='draft'?`待发布的全站草稿 v${siteDesignRevision}${result.conflict?' · 网站已被其他人修改':''}`:'当前为已发布设计';
$('#siteDesignModal').showModal();
}catch(e){toast('全站设计读取失败:'+e.message,true)}},
collectSiteDesign(){const form=$('#siteDesignForm');if(!siteDesign)throw Error('请先打开全站设计');
const v=(n)=>form.elements.namedItem(n).value;
const yes=(n)=>form.elements.namedItem(n).checked;
let nav;try{nav=JSON.parse(v('nav'))}catch(e){throw Error('导航必须是合法 JSON:'+e.message)}
if(!Array.isArray(nav))throw Error('导航必须是数组');
return {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')},
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')),
mobile_container_padding:Number(v('mobile_container_padding')),mobile_hide_announcement:yes('mobile_hide_announcement'),mobile_hide_footer_links:yes('mobile_hide_footer_links')}};
},
async previewSiteDesign(){try{siteDesignPreview=Studio.collectSiteDesign();await Studio.refreshPreview();
$('#siteDesignStatus').textContent='编辑器正在预览未发布的全站设置;静态网站保持不变';toast('已在编辑器预览全站设计')
}catch(e){toast(e.message,true)}},
async saveSiteDesign(){try{const config=Studio.collectSiteDesign();let saved=await api(`/api/sites/${siteId}/site-design`,req('PUT',{config,expected_revision:siteDesignRevision}));
siteDesignRevision=saved.revision;siteDesign=config;siteDesignPreview=structuredClone(config);
$('#siteDesignStatus').textContent=`草稿 v${siteDesignRevision} 已保存,正式构建保持不变`;toast('全站设计草稿保存成功');return true;
}catch(e){toast('全站草稿保存失败:'+e.message,true);return false}},
async publishSiteDesign(){if(!confirm('发布全站设计会影响该站所有页面的下一次静态构建。确定继续?'))return;
if(!await Studio.saveSiteDesign())return;
try{await api(`/api/sites/${siteId}/site-design/publish`,req('POST',{expected_revision:siteDesignRevision}));
siteDesignRevision=0;$('#siteDesignStatus').textContent='全站设计已发布,请重新构建网站';
$('#siteDesignModal').close();await Studio.refreshPreview();toast('全站设计已发布;现有上线网站仍不改变');
}catch(e){toast('发布失败:'+e.message,true)}},
async discardSiteDesign(){if(!confirm('确定删除尚未发布的全站草稿?'))return;
try{await api(`/api/sites/${siteId}/site-design`,{method:'DELETE'});siteDesignPreview=null;
await Studio.showSiteDesign();await Studio.refreshPreview();toast('全站草稿已放弃')
}catch(e){toast(e.message,true)}},
async showTokens(){try{let current=await api(`/api/sites/${siteId}/site-design`);siteDesign=current.config;siteDesignRevision=current.revision;const tokens=siteDesign.tokens;const modal=$('#designModal');for(const [k,v] of Object.entries(tokens)){const field=modal.querySelector(`[name="${k}"]`);if(field)field.value=v}modal.showModal()}catch(e){toast(e.message,true)}},
async saveTokens(){const form=$('#designForm'),tokens={};for(const input of form.querySelectorAll('[name]'))tokens[input.name]=input.type==='number'?Number(input.value):input.value;try{let r=await api(`/api/sites/${siteId}/site-design`);siteDesign=r.config;siteDesignRevision=r.revision;siteDesign.tokens=tokens;let saved=await api(`/api/sites/${siteId}/site-design`,req('PUT',{config:siteDesign,expected_revision:siteDesignRevision}));siteDesignRevision=saved.revision;siteDesignPreview=structuredClone(siteDesign);$('#designModal').close();Studio.refreshPreview();toast('设计规范已保存为全站草稿;请在“全站页眉与页脚”中发布')}catch(e){toast(e.message,true)}},
setDeviceColumns(device,value){const b=blocks[selected];if(!b||b.type!=='layout')return;const max=device==='tablet'?3:2;const next=Math.min(max,Math.max(1,Number(value)||1));b[device+'_columns']=next;b.columns.forEach(c=>c[device+'_span']=Math.min(next,c[device+'_span']||1));Studio.modified();Studio.inspector()},
setColumnDevice(i,key,value){const b=blocks[selected];if(!b||b.type!=='layout')return;const col=b.columns[i];if(!col)return;col[key]=value;Studio.modified();Studio.inspector()},
addColumn(){const b=blocks[selected];if(!b||b.type!=='layout'||b.columns.length>=4)return;b.columns.push({width:1,blocks:[]});Studio.modified();Studio.inspector()},
removeColumn(i){const b=blocks[selected];if(!b||b.type!=='layout'||b.columns.length<=1)return;if(b.columns[i].blocks.length&&!confirm('这一列含有组件,确认删除?'))return;b.columns.splice(i,1);Studio.modified();Studio.inspector()},
setColumnWidth(i,value){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].width=Math.max(.5,Math.min(4,Number(value)||1));Studio.modified()},
addChild(i,type){const b=blocks[selected];if(!b||b.type!=='layout')return;const item=structuredClone(schema.blocks[type].defaults);item.type=type;item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);item.visible=true;b.columns[i].blocks.push(item);Studio.modified();Studio.inspector()},
removeChild(i,j){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks.splice(j,1);Studio.modified();Studio.inspector()},
childField(i,j,key,value){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks[j][key]=value;Studio.modified()},
moveChild(ci,ri,target){const b=blocks[selected];if(!b||b.type!=='layout'||target<0||target>=b.columns.length)return;let [component]=b.columns[ci].blocks.splice(ri,1);b.columns[target].blocks.push(component);Studio.modified();Studio.inspector()},
async chooseMedia(){try{const data=await api(`/api/sites/${siteId}/media`);const grid=$('#siteMediaGrid');grid.replaceChildren();if(!data.length){grid.textContent='暂无上传图片。可先在当前组件中上传图片。'}for(const item of data){const button=document.createElement('button');button.type='button';button.style.cssText='border:1px solid #e2e8f0;border-radius:9px;background:white;padding:7px;cursor:pointer';const img=document.createElement('img');img.alt=item.name;img.loading='lazy';img.style.cssText='width:100%;height:105px;object-fit:contain';img.src=`/local-media/${encodeURIComponent(siteId)}/${encodeURIComponent(item.name)}`;const label=document.createElement('small');label.textContent=item.name;label.style.cssText='display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';button.append(img,label);button.addEventListener('click',()=>Studio.applyMedia(item.url));grid.append(button)}$('#siteMediaModal').showModal()}catch(e){toast(e.message,true)}},
applyMedia(url){const b=blocks[selected];if(!b)return;if(b.type==='gallery'){b.images=[...(b.images||[]),{url,alt:''}]}else if(b.type==='carousel'){b.items=[...(b.items||[]),{image:url,alt:'',caption:''}]}else{b.image=url}Studio.modified();Studio.draw();$('#siteMediaModal').close();toast('已应用素材到草稿')},
async uploadImage(file){if(!file)return;try{const form=new FormData();form.append('file',file);let r=await api(`/api/sites/${siteId}/media`,{method:'POST',body:form});Studio.field('image',r.url);toast('图片已上传,保存页面后参与构建')}catch(e){toast(e.message,true)}},
async buildSite(){if(dirty||draftRevision||siteDesignRevision)return toast('有尚未发布的页面或全站草稿,请先发布,再构建网站',true);try{
toast('已提交后台构建任务');const task=await api(`/api/sites/${siteId}/builds`,req('POST',{mirror_images:false}));
for(let i=0;i<240;i++){
await new Promise(resolve=>setTimeout(resolve,1500));
const status=await api(`/api/enterprise/sites/${siteId}/jobs/${task.id}`);
if(status.state==='done'){const result=JSON.parse(status.result_json||'{}');toast('构建完成:'+result.pages+' 个页面');if(result.version)window.open(`/preview/${siteId}/${result.version}/`,'_blank','noopener');return}
if(['failed','cancelled'].includes(status.state)){toast('构建'+status.state+':'+(status.error||''),true);return}
}toast('后台任务仍在运行,请稍后查看构建中心');
}catch(e){toast('构建失败:'+e.message,true)}},
};
window.Studio=Studio;
window.addEventListener('DOMContentLoaded',()=>Studio.init());
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
window.addEventListener('message',event=>{
const frame=document.querySelector('#sitePreview');
if(!frame||event.source!==frame.contentWindow)return;
const data=event.data||{};
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 found=findChild(blocks),b=found?.block;
if(!b||!['title','description'].includes(data.field)||typeof data.value!=='string')return;
b[data.field]=data.value.slice(0,500);
selected=blocks.indexOf(found.parent);Studio.modified();Studio.draw();
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()}});
+59
View File
@@ -0,0 +1,59 @@
'use strict';
// Enterprise pages extend the existing reliable Docker Web console without requiring a CDN.
tabs.push(['design-system','设计规范'],['migration-qa','迁移验收'],['operations','任务 / 版本'],['permissions','成员 / 审计']);
const sfEnterpriseApi=async (path,method='GET',data)=>api(path,method==='GET'?{}:json(method,data));
const sfRoot='/api/enterprise';
const _oldRenderTab=App.renderTab.bind(App);
App.renderTab=function(){
if(!['design-system','migration-qa','operations','permissions'].includes(state.tab))return _oldRenderTab();
const el=document.getElementById('tab-content');
el.innerHTML='<div class="panel"><p class="muted">正在读取站点工作数据...</p></div>';
if(state.tab==='design-system')EnterpriseUI.design().catch(onError);
if(state.tab==='migration-qa')EnterpriseUI.migration().catch(onError);
if(state.tab==='operations')EnterpriseUI.operations().catch(onError);
if(state.tab==='permissions')EnterpriseUI.permissions().catch(onError);
};
const EnterpriseUI={
section(name,caption='',actions=''){return `<div class="panel-title"><div><h3>${E(name)}</h3><p>${E(caption)}</p></div>${actions}</div>`},
async design(){
const design=await sfEnterpriseApi(`/api/sites/${state.siteId}/site-design`);const x=design.config.tokens;
const fields=[['font','全站字体','select',['system','serif','modern']],['heading_scale','标题比例','number'],['radius','圆角半径(px)','number'],['section_spacing','板块上下间距(px)','number'],['container_width','内容最大宽度(px)','number'],['button_style','按钮形状','select',['rounded','pill','square']],['header_style','导航密度','select',['standard','compact']]];
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('全站设计规范','同一个主题的不同网站可拥有完全不同的字体、间距、圆角和排版节奏。')}<form id="tokensForm" onsubmit="EnterpriseUI.saveDesign(event)"><div class="setting-grid">${fields.map(([k,name,type,options])=>`<label>${E(name)}${type==='select'?`<select name="${k}">${options.map(o=>`<option ${o===x[k]?'selected':''} value="${o}">${o}</option>`).join('')}</select>`:`<input type="number" name="${k}" step="${k==='heading_scale'?'0.05':'1'}" value="${ATTR(x[k])}">`}</label>`).join('')}</div><div class="panel-footer"><button class="btn primary" type="submit">保存设计规范</button></div></form></div>`;
},
async saveDesign(event){event.preventDefault();try{
const raw=Object.fromEntries(new FormData(event.target));
for(const k of ['heading_scale','radius','section_spacing','container_width'])raw[k]=Number(raw[k]);
const path=`/api/sites/${state.siteId}/site-design`;const design=await sfEnterpriseApi(path);design.config.tokens=raw;await sfEnterpriseApi(path,'PUT',{config:design.config,expected_revision:design.revision});
notice('设计规范已保存为草稿;进入可视化编辑器的全站设计面板发布后才能用于构建');
}catch(err){onError(err)}},
async migration(){
const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/baseline/report`);
const badge=r.errors?`<span class="pill">${r.errors} 项 URL 错误</span>`:'<span class="pill green">无 URL 缺失</span>';
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('旧站 URL / SEO 基线验收','迁移模式发布前必须建立旧站基线,杜绝静默漏页。',`<button class="btn light small" onclick="EnterpriseUI.snapshot()">从当前内容建立基线</button>`)}<div class="metrics-grid"><div class="metric"><small>旧 URL 清单</small><strong>${r.baseline_count}</strong></div><div class="metric"><small>当前页面</small><strong>${r.current_count}</strong></div><div class="metric"><small>URL 缺失</small><strong>${r.errors}</strong></div><div class="metric"><small>SEO 差异</small><strong>${r.warnings}</strong></div></div><div class="panel-footer" style="justify-content:space-between">${badge}<a class="btn light" href="${sfRoot}/sites/${state.siteId}/baseline/export.csv">下载旧站 URL 清单</a></div><p class="muted" style="margin-top:15px">注意:最可靠的做法是在导入旧站后、换模板和调整内容前建立基线。也可调用 API 批量导入旧站已知 URL 清单。</p><div style="max-height:420px;overflow:auto">${r.issues.map(x=>`<div class="tip ${x.severity==='error'?'warning':''}"><strong>${E(x.route)} · ${E(x.field)}</strong><div style="overflow-wrap:anywhere">${E(x.from)} → ${E(x.to)}</div></div>`).join('')||'<div class="tip">暂无 URL 或 SEO 差异。</div>'}</div></div>`;
},
async snapshot(){if(!confirm('这会用当前站点的 URL 与 SEO 覆盖旧基线。推荐在旧站导入完成后、进行改版前操作。是否继续?'))return;
try{const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/baseline/snapshot`,'POST',{});notice(`已记录 ${r.captured} 个原始页面`);await this.migration()}catch(err){onError(err)}},
async operations(){
const jobs=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs`);
const releases=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/releases`);
const builds=state.builds;
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('异步构建任务','后台 Worker 负责执行耗时构建,关闭浏览器也不影响任务。',`<button class="btn primary small" onclick="EnterpriseUI.queueBuild()">+ 提交异步构建</button>`)}<table class="sf-table" style="width:100%"><thead><tr><th>任务 ID</th><th>状态</th><th>尝试次数</th><th>创建时间</th><th>操作</th></tr></thead><tbody>${jobs.map(j=>`<tr><td><code>${E(j.id)}</code></td><td>${E(j.state)}${j.cancel_requested?' (取消中)':''}</td><td>${j.attempts}/${j.max_attempts}</td><td>${simpleDate(j.created_at)}</td><td>${['queued','running','retrying'].includes(j.state)?`<button class="btn light tiny" onclick="EnterpriseUI.cancelJob('${j.id}')">取消</button>`:''}</td></tr>`).join('')||'<tr><td colspan="5">暂无任务</td></tr>'}</tbody></table></div><div class="panel" style="margin-top:22px">${this.section('构建版本与质量检测','检查 HTML / 站内链接 / 图片 ALT / SEO,再决定是否发布。')}<div class="setting-grid">${builds.slice(0,15).map(b=>`<div class="tip"><strong>${E(b.id)} · ${b.total_pages} 页</strong><p>${simpleDate(b.created_at)}</p><button class="btn light small" onclick="EnterpriseUI.quality('${b.id}')">质量检测</button> <button class="btn light small" onclick="EnterpriseUI.rollback('${b.id}')">发布 / 回滚至此版本</button></div>`).join('')||'<p>暂无构建,请先提交任务。</p>'}</div><div id="sf-quality-result"></div></div><div class="panel" style="margin-top:22px">${this.section('本地发布记录','发布记录保留全部构建版本,方便核对和恢复。')}${releases.slice(0,30).map(r=>`<p>${E(r.action)} · <code>${E(r.build_id)}</code> · ${simpleDate(r.created_at)}</p>`).join('')||'<p>暂无发布历史</p>'}</div>`;
},
async queueBuild(){try{const j=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs`,'POST',{kind:'build'});notice('构建任务已提交 '+j.id);await this.operations()}catch(err){onError(err)}},
async cancelJob(id){if(!confirm('确认取消任务 '+id+'?'))return;try{await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs/${id}/cancel`,'POST',{});notice('已发送取消请求');await this.operations()}catch(err){onError(err)}},
async quality(id){try{const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/builds/${id}/quality`);const el=document.getElementById('sf-quality-result');if(el)el.innerHTML=`<div class="tip ${r.errors?'warning':''}"><h4>检查结果 · ${r.files} 页 · ${r.errors} 错误 · ${r.warnings} 警告</h4>${r.issues.slice(0,60).map(i=>`<p><strong>${E(i.file)}</strong> ${E(i.type)} ${E(i.target||'')}</p>`).join('')||'通过基础静态质量检查'}</div>`}catch(err){onError(err)}},
async rollback(id){if(!confirm('确认将此构建版本设为当前本地发布版本?迁移站发布前必须通过 URL 基线检查。'))return;
try{const result=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/releases/${id}/rollback`,'POST',{});notice(result.published?'发布成功':'发布失败');await this.operations()}catch(err){onError(err)}},
async permissions(){
const logs=await sfEnterpriseApi(`${sfRoot}/audit?site_id=${state.siteId}&limit=50`);
let members=[];try{members=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/members`)}catch{}
let users=[];try{users=await sfEnterpriseApi(`${sfRoot}/team/users`)}catch{}
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('网站团队与角色','Owner 可以管理成员,Editor 可以修改内容,Reviewer 和 Viewer 为只读角色。')}<div class="setting-grid">${members.map(m=>`<div class="tip"><strong>${E(m.display_name||m.username)}</strong><p>${E(m.username)} · ${E(m.role)}</p></div>`).join('')||'<p>尚未添加团队成员。系统管理员拥有全部权限。</p>'}</div>${users.length?`<form onsubmit="EnterpriseUI.assign(event)" class="setting-grid"><label>已有账号<select name="user_id">${users.map(u=>`<option value="${u.id}">${E(u.username)}</option>`).join('')}</select></label><label>角色<select name="role"><option>owner</option><option>editor</option><option>reviewer</option><option>viewer</option></select></label><div><button class="btn primary" type="submit">授予站点访问权限</button></div></form>`:''}</div><div class="panel" style="margin-top:22px">${this.section('操作审计日志','记录修改 API 的操作者、时间、路径与响应状态。')}<div style="max-height:450px;overflow:auto">${logs.map(l=>`<div class="tip"><strong>${E(l.actor)} · ${E(l.operation)} · ${l.status}</strong><p style="overflow-wrap:anywhere">${E(l.path)} · ${simpleDate(l.created_at)}</p></div>`).join('')||'<p>暂无操作日志</p>'}</div></div>`;
},
async assign(event){event.preventDefault();try{await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/members`,'PUT',Object.fromEntries(new FormData(event.target)));notice('权限已更新');await this.permissions()}catch(err){onError(err)}},
async team(){try{const users=await sfEnterpriseApi(`${sfRoot}/team/users`);state.siteId=null;topTitle('团队账号');
document.getElementById('view').innerHTML=hero('团队与权限','创建协作者,再为每个网站分配独立角色。')+`<div class="split"><div class="panel">${this.section('新增团队账号','至少12位密码,使用 scrypt 加盐哈希存储。')}<form onsubmit="EnterpriseUI.addUser(event)" class="setting-grid"><label>账号<input required name="username" placeholder="editor_01"></label><label>显示名称<input required name="display_name" placeholder="运营编辑"></label><label class="full">临时密码<input required type="password" name="password" minlength="12" autocomplete="new-password"></label><div><button class="btn primary">创建账号</button></div></form></div><div class="panel">${this.section('现有成员','站点权限可在各个网站的“成员 / 审计”中配置。')}${users.map(u=>`<div class="tip"><strong>${E(u.username)}</strong><p>${E(u.display_name)} · ${u.disabled?'停用':'启用'}</p></div>`).join('')||'<p>尚无协作者。</p>'}</div></div>`;
}catch(err){onError(err)}},
async addUser(event){event.preventDefault();try{await sfEnterpriseApi(`${sfRoot}/team/users`,'POST',Object.fromEntries(new FormData(event.target)));notice('团队账号创建成功');await this.team()}catch(err){onError(err)}}
};
window.EnterpriseUI=EnterpriseUI;
File diff suppressed because one or more lines are too long
+75
View File
@@ -0,0 +1,75 @@
'use strict';
const $=s=>document.querySelector(s), H=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state={sites:[],themes:[],site:null,tab:'overview',csrf:'',importRows:[],fields:[],mapping:{},kind:'product'};
const toast=(s,error=false)=>{let el=$('#alert');el.textContent=s;el.className='on';el.style.background=error?'#ffe6e6':'#e3f7ed';el.style.color=error?'#b13445':'#176e50';setTimeout(()=>el.className='',5500)};
async function api(url,method='GET',data=null){const opt={method,credentials:'same-origin',headers:{}};if(!['GET','HEAD'].includes(method))opt.headers['X-SiteForge-CSRF']=state.csrf;
if(data instanceof FormData)opt.body=data;else if(data!==null){opt.headers['Content-Type']='application/json';opt.body=JSON.stringify(data)}
const r=await fetch(url,opt);if(r.status===401){location.href='/login';throw Error('请登录')};let body=(r.headers.get('content-type')||'').includes('json')?await r.json():await r.text();if(!r.ok)throw Error(body.detail||body);return body;}
const siteUrl=path=>`/api/sites/${state.site.id}${path}`;
const factoryUrl=path=>`/api/factory/sites/${state.site.id}${path}`;
function selected(){if(!state.site){$('#panel').innerHTML='<div class="section"><h2>创建第一个网站</h2><p>选择主题后即可开始添加新内容,或导入旧网站的数据。</p><button class="primary" onclick="openCreate()">创建网站</button></div>';return false}return true;}
function setTab(tab){state.tab=tab;$$('#navtabs button').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));render().catch(e=>toast(e.message,true))}
function $$(s){return [...document.querySelectorAll(s)]}
function setHeading(h,desc){$('#heading').textContent=h;$('#description').textContent=desc;$('#currentTab').textContent=h;}
async function init(){try{state.csrf=(await api('/api/auth/me')).csrf;await refresh();document.querySelectorAll('#navtabs button').forEach(btn=>btn.addEventListener('click',()=>setTab(btn.dataset.tab)));
$('#sitePicker').onchange=()=>{state.site=state.sites.find(s=>s.id===$('#sitePicker').value);render().catch(e=>toast(e.message,true))};$('#reload').onclick=refresh;$('#newSite').onclick=openCreate;$('#closeCreate').onclick=()=>$('#createDialog').close();$('#createForm').onsubmit=create;
$('#buildQuick').onclick=()=>buildNow();await render()}catch(e){toast('初始化失败:'+e.message,true)}}
async function refresh(){state.sites=await api('/api/sites');state.themes=await api('/api/factory/themes');let old=state.site?.id;state.site=state.sites.find(x=>x.id===old)||state.sites[0]||null;
$('#sitePicker').innerHTML=state.sites.map(s=>`<option value="${H(s.id)}">${H(s.name)}</option>`).join('');if(state.site)$('#sitePicker').value=state.site.id;
$('#newTheme').innerHTML=state.themes.map(t=>`<option value="${H(t.id||t.theme_id)}">${H(t.name)}</option>`).join('');}
function openCreate(){$('#createDialog').showModal()}
async function create(e){e.preventDefault();try{let d=Object.fromEntries(new FormData(e.target));let result=await api('/api/sites','POST',d);state.site=result;await api(`/api/factory/sites/${result.id}/bootstrap`,'POST',{});$('#createDialog').close();toast('新网站已创建并生成基础页面结构');await refresh();await render()}catch(err){toast(err.message,true)}}
async function render(){$('#openEditor').href=state.site?`/editor/${state.site.id}`:'#';if(!selected())return;
const maps={overview:['网站总览','查看生产进度、页面数量和建站方式'],themes:['主题与版本','安装主题、切换主题、固定版本以及批量套版'],content:['内容与自动装配','导入 CSV/JSON、自动识别字段、预览映射并建立新内容'],migration:['迁移与 SEO 验收','核对旧站 URL、媒体资源和 SEO 差异'],build:['构建与发布','生成独立 HTML 站点、检测问题、下载与版本管理']};setHeading(...maps[state.tab]);
const fn={overview:overview,themes:themes,content:content,migration:migration,build:buildPanel}[state.tab];$('#panel').innerHTML='<div class="loading">加载中……</div>';await fn();}
async function overview(){let [entries,builds,source]=await Promise.all([api(siteUrl('/entries')),api(siteUrl('/builds')),api(factoryUrl('/theme'))]);let s=state.site;
$('#panel').innerHTML=`<div class="cards"><div class="card"><span class="overline">当前站点</span><strong>${H(s.name)}</strong><small>${H(s.mode==='migration'?'旧站迁移':'新站搭建')} · ${H(s.theme)}</small></div><div class="card"><span class="overline">内容总数</span><strong>${entries.length}</strong><small>页面/产品/品牌/文章</small></div><div class="card"><span class="overline">构建版本</span><strong>${builds.length}</strong><small>静态网站历史记录</small></div><div class="card"><span class="overline">主题版本</span><strong>${H(source.version)}</strong><small>已固定版本可追踪</small></div></div>
<div class="section"><h2>网站工厂生产流程</h2><div class="steps"><div class="step">01<b>网站结构</b><small>首页、栏目页和导航</small></div><div class="step">02<b>内容自动装配</b><small>导入新数据或旧内容</small></div><div class="step">03<b>设计编辑</b><small>切换主题、可视化编辑</small></div><div class="step">04<b>静态站交付</b><small>构建、验收、发布</small></div></div></div>
<div class="columns"><div class="section"><div class="toolbar"><h2>网站页面</h2><button class="outline" onclick="bootstrap()">补齐标准页面</button></div><div class="scroll"><table><thead><tr><th>页面/内容</th><th>类型</th><th>URL</th><th>操作</th></tr></thead><tbody>${entries.slice(0,25).map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.type)}</td><td>${H(x.route)}</td><td><a href="/editor/${H(s.id)}/${H(x.id)}">设计页面 ↗</a></td></tr>`).join('')}</tbody></table></div></div><div class="section"><h2>联系信息与表单服务</h2><p>邮箱会出现在主题的联系页与页脚。外部表单地址为空时,不显示假提交按钮。</p><label class="field">联系邮箱<input id="configEmail" type="email" value="${H(s.settings?.email||'')}"></label><label class="field">HTTPS 表单接收地址(可选)<input id="configForm" value="${H(s.settings?.form_endpoint||'')}"></label><label class="field">页脚说明<input id="configFooter" value="${H(s.settings?.footer_text||'')}"></label><button class="outline" onclick="saveContactSettings()">保存网站联系配置</button></div><div class="section"><h2>常用功能</h2><div class="stack"><button class="outline" onclick="setTab('themes')">切换完整主题 →</button><button class="outline" onclick="setTab('content')">导入内容 / 字段映射 →</button><button class="outline" onclick="setTab('migration')">检查 SEO 和旧站路径 →</button><button class="primary" onclick="buildNow()">生成网站 HTML →</button></div><p>当没有真实产品时,主题不会虚构业务数据;可先完善基础页面,再逐步导入。</p></div></div>`;}
async function bootstrap(){try{let r=await api(factoryUrl('/bootstrap'),'POST',{});toast(`新增 ${r.created.length} 个基础页面;不会覆盖原 URL`);await render()}catch(e){toast(e.message,true)}}
async function themes(){let cur=await api(factoryUrl('/theme'));const schemes={automotive:['#172840','#cfab79','MOVE BEYOND'],industrial:['#dcebf4','#14517d','BUILD FURTHER'],corporate:['#e7e9fb','#535bb4','CREATE IMPACT'],minimal:['#e9e5dc','#3d3b35','LESS IS MORE']};
$('#panel').innerHTML=`<div class="section"><div class="toolbar"><h2>可安装、可版本化的主题包</h2><span class="badge">当前主题:${H(cur.theme)} · v${H(cur.version)}</span></div><div class="gridthemes">${state.themes.map(t=>{let id=t.id||t.theme_id,p=schemes[id]||['#e9eff8','#246c9e','YOUR BRAND'];return `<article class="theme-card"><div class="theme-visual" style="background:${p[0]};color:${p[1]}"><div class="mocknav"></div><div class="mocktitle">${H(p[2])}</div><div class="mockbtn"></div></div><div class="theme-info"><h3>${H(t.name)} <small class="muted">v${H(t.version)}</small></h3><p>${H(t.description)}</p><div class="actions"><button class="outline" onclick="chooseTheme('${H(id)}','${H(t.version)}')">${id===cur.theme?'重新应用':'应用主题'}</button><button class="outline" onclick="showVersions('${H(id)}')">版本管理</button><button class="outline" onclick="checkTheme('${H(id)}','${H(t.version)}')">兼容检查</button><a class="outline" target="_blank" href="/theme-gallery">全部页面预览</a><a class="outline" target="_blank" href="/api/factory/themes/${H(id)}/preview?version=${H(t.version)}">首页预览</a></div></div></article>`}).join('')}</div></div>
<div class="columns"><div class="section"><h2>安装新主题 ZIP</h2><p>主题包由 manifest.json、theme.css、page-presets.json 组成。只能安装受控的页面组件和 CSS,不执行上传的 JavaScript 或模板逻辑。</p><label class="field">上传 ZIP 文件<input type="file" id="themePackage" accept=".zip"></label><button class="primary" onclick="uploadTheme()">安装并登记版本</button></div><div class="section"><h2>批量重装配现有内容</h2><p>会按页面类型给已有内容应用当前主题;保留现有 URL、SEO 和正文。选择「只补缺失」可避免覆盖已手工设计的区块。</p><button class="outline" onclick="applyTheme('missing')">仅装配缺失页面</button> <button class="outline" onclick="applyTheme('overwrite')">覆盖全部布局(谨慎)</button><div id="themeVersions" class="muted" style="margin-top:18px"></div></div></div>`;}
async function chooseTheme(id,version){if(!confirm(`为当前网站选择主题 ${id} v${version}?将保留网站内容。`))return;try{await api(factoryUrl('/theme'),'PUT',{theme:id,version});await refresh();toast('主题切换完成。需要重新构建才能更新正式网站');await render()}catch(e){toast(e.message,true)}}
async function showVersions(id){let list=await api(`/api/factory/themes/${id}/versions`);$('#themeVersions').textContent='已安装版本:'+list.map(x=>x.version).join(' / ')}
async function checkTheme(id,version){try{const r=await api(`/api/factory/themes/${id}/compatibility?version=${encodeURIComponent(version)}`);$('#themeVersions').textContent=`${r.valid?'✓ 主题结构可用':'✗ 存在阻断'} · ${r.checked_page_types||0} 类页面 · ${[...(r.errors||[]),...(r.warnings||[])].join(';')||'无结构警告'}。正式交付前仍需实际浏览器验收。`;}catch(e){toast(e.message,true)}}
async function uploadTheme(){let f=$('#themePackage').files?.[0];if(!f)return toast('请选择主题 ZIP',true);let data=new FormData();data.append('file',f);try{let r=await api('/api/factory/themes/install','POST',data);toast(`已安装:${r.name} v${r.version}`);await refresh();await render()}catch(e){toast(e.message,true)}}
async function applyTheme(mode){if(mode==='overwrite'&&!confirm('覆盖已有页面设计(内容和 SEO 不变),继续吗?'))return;try{let r=await api(siteUrl('/apply-theme'),'POST',{mode});toast(`套版完成:更新 ${r.updated} 个页面`)}catch(e){toast(e.message,true)}}
async function content(){let entries=await api(siteUrl('/entries'));$('#panel').innerHTML=`<div class="columns"><div class="section"><h2>新内容 / 旧内容导入装配</h2><p>上传 Excel、JSON 数组或 CSV;系统自动建议字段对应关系,例如「车型名称 → 标题」「品牌 → 产品品牌」。映射会经过预览与冲突检查。</p><label class="field">文件<input id="dataFile" type="file" accept=".csv,.json,.xlsx"></label><label class="field">导入内容类型<select id="mapKind"><option value="product">产品</option><option value="vehicle">车型</option><option value="brand">品牌</option><option value="post">新闻</option><option value="case">案例</option><option value="service">服务</option><option value="page">页面</option></select></label><button class="primary" onclick="readData()">读取文件并识别字段</button><div id="mappingUI"></div><div class="toolbar"><button class="outline" onclick="previewRelations()">预览产品-品牌关系</button><button class="outline" onclick="assembleRelations()">生成缺失的品牌页面</button></div><div id="relationReport"></div></div><div class="section"><h2>本站内容统计</h2>${['page','product','vehicle','brand','post','case','service'].map(t=>`<div class="row"><strong>${H(({page:'页面',product:'产品',vehicle:'车型',brand:'品牌',post:'新闻',case:'案例',service:'服务'})[t])}</strong><span class="badge">${entries.filter(e=>e.type===t).length}</span></div>`).join('')}<p>其他导入方式(WordPress XML/REST、Excel、媒体 ZIP)可以在原版站点管理工作台使用,使用相同的数据存储。</p><a class="outline" href="/">打开完整内容管理 →</a></div></div>`;}
function parseCSV(text){let rows=[],row=[],cell='',quoted=false;for(let i=0;i<text.length;i++){let ch=text[i];if(ch==='"'){if(quoted&&text[i+1]==='"'){cell+='"';i++}else quoted=!quoted}else if(ch===','&&!quoted){row.push(cell);cell=''}else if((ch==='\n'||ch==='\r')&&!quoted){if(ch==='\r'&&text[i+1]==='\n')i++;row.push(cell);if(row.some(x=>x.trim()))rows.push(row);row=[];cell=''}else cell+=ch;}row.push(cell);if(row.some(x=>x.trim()))rows.push(row);let cols=rows.shift()?.map(s=>s.trim())||[];return rows.map(r=>Object.fromEntries(cols.map((c,i)=>[c,r[i]||''])))}
async function readData(){let f=$('#dataFile').files?.[0];if(!f)return toast('请选择 Excel、CSV 或 JSON 文件',true);
try{let data=new FormData();data.append('file',f);const parsed=await api(factoryUrl('/mapping/parse-file'),'POST',data);state.importRows=parsed.rows;state.fields=parsed.columns;state.mapping=parsed.suggested;drawMapping();}
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)}}
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:'价格'};
$('#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>`;}
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}}
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)}}
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)}}
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>`;}
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 approveAuditWarning(bid,issue){const reason=prompt('填写核查原因及处理依据(至少10字,审核后对本构建生效):','');if(!reason)return;try{await api(`/api/audit/sites/${state.site.id}/builds/${bid}/review-warning`,'POST',{issue_id:issue,reason});toast('已记录审查决定');await render()}catch(e){toast(e.message,true)}}
async function uploadBaseline(){const file=$('#baselineFile')?.files?.[0];if(!file)return toast('请选择原站清单文件',true);const data=new FormData();data.append('file',file);
try{let r=await api(`/api/audit/sites/${state.site.id}/import-baseline`,'POST',data);if(r.errors?.length){$('#baselineImportResult').textContent=JSON.stringify(r.errors.slice(0,15));toast('源站清单存在错误,未写入基线',true);return}toast(`已导入 ${r.imported} 个旧站 URL`);await render()}catch(e){toast(e.message,true)}}
async function openLatestAudit(){try{let builds=await api(siteUrl('/builds'));if(!builds.length)return toast('请先构建网站',true);window.open(`/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report`,'_blank','noopener')}catch(e){toast(e.message,true)}}
async function snapshot(){if(!confirm('仅适用于尚未迁移的测试网站。真实迁移应导入旧站原始 URL 清单!继续吗?'))return;try{let r=await api(`/api/enterprise/sites/${state.site.id}/baseline/snapshot`,'POST',{});toast('已建立 '+r.captured+' 条当前内容基线');await render()}catch(e){toast(e.message,true)}}
async function buildPanel(){let [builds,ready,releaseRequests]=await Promise.all([api(siteUrl('/builds')),api(factoryUrl('/migration/readiness')),api(factoryUrl('/release-requests'))]);$('#panel').innerHTML=`<div class="columns"><div class="section"><h2>静态构建版本</h2><div class="toolbar"><button class="primary" onclick="buildNow()">+ 构建新版本</button><span class="badge ${ready.can_publish?'':'danger'}">${ready.can_publish?'可进入发布审查':'发布前有阻断项'}</span></div><div class="scroll"><table><thead><tr><th>版本</th><th>页面数</th><th>生成时间</th><th>交付</th></tr></thead><tbody>${builds.slice(0,15).map(b=>`<tr><td>${H(b.id)}</td><td>${b.total_pages}</td><td>${H(b.created_at)}</td><td><a target="_blank" href="/preview/${state.site.id}/${b.id}/">预览</a> · <a href="/api/sites/${state.site.id}/builds/${b.id}/download">下载</a> · <button class="outline" onclick="requestRelease('${b.id}')">提交审批</button> · <button class="outline" onclick="publishRelease('${b.id}')">发布</button></td></tr>`).join('')}</tbody></table></div></div><div class="section"><h2>交付说明</h2><p>静态网站包含 HTML、CSS、JavaScript、独立搜索索引、站点地图和 Nginx 重定向示例配置。</p><p>通过实际发布前,需要校验 SEO 基线、静态资源和功能;当前本地发布仅适用于事先配置的发布目录,公网 DNS/CDN 连接器仍需另行开发。</p><div class="stack">${ready.gates.map(g=>`<div class="row"><span class="badge ${g.level==='block'?'danger':'warn'}">${H(g.level)}</span><p>${H(g.message)}</p></div>`).join('')}</div><h2 style="margin-top:24px">发布审批记录</h2>${releaseRequests.slice(0,8).map(r=>`<div class="row"><div><strong>${H(r.build_id)}</strong><small>${H(r.requested_by)} · ${H(r.state)}</small></div><div>${r.state==='pending'?`<button class="outline" onclick="reviewRelease('${r.id}','approved')">通过</button> <button class="outline" onclick="reviewRelease('${r.id}','rejected')">驳回</button>`:''}</div></div>`).join('')||'<p>暂无发布审批记录。</p>'}</div></div>`;}
async function requestRelease(build){try{let r=await api(factoryUrl('/release-requests'),'POST',{build_id:build});toast('审批申请已提交:'+r.id);await render()}catch(e){toast(e.message,true)}}
async function reviewRelease(id,status){try{let note=prompt('请输入审批说明(可留空)','')||'';await api(factoryUrl('/release-requests/'+id+'/review'),'POST',{state:status,note});toast('审批状态已更新');await render()}catch(e){toast(e.message,true)}}
async function publishRelease(build){if(!confirm('确认将此版本发布到预先配置的本地持久化站点目录?'))return;try{let r=await api(siteUrl('/builds/'+build+'/publish-local'),'POST',{});toast('已发布:'+r.release);await render()}catch(e){toast(e.message,true)}}
async function buildNow(){if(!state.site)return;try{
toast('构建已提交后台队列……');const sid=state.site.id;
const job=await api(siteUrl('/builds'),'POST',{mirror_images:false});
toast('任务 '+job.id+' 排队中;关闭窗口不会中断');
for(let i=0;i<240;i++){
await new Promise(resolve=>setTimeout(resolve,1500));
const status=await api(`/api/enterprise/sites/${sid}/jobs/${job.id}`);
if(status.state==='done'){const r=JSON.parse(status.result_json||'{}');toast(`构建成功,${r.pages} 个页面`);await render();if(r.version)window.open(`/preview/${sid}/${r.version}/`,'_blank','noopener');return}
if(['failed','cancelled'].includes(status.state)){toast('构建'+status.state+':'+(status.error||''),true);return}
}toast('任务仍在后台运行,请到任务中心查看');
}catch(e){toast(e.message,true)}}
window.addEventListener('DOMContentLoaded',init);
window.requestRelease=requestRelease;window.reviewRelease=reviewRelease;window.publishRelease=publishRelease;window.bootstrap=bootstrap;window.chooseTheme=chooseTheme;window.checkTheme=checkTheme;window.showVersions=showVersions;window.uploadTheme=uploadTheme;window.applyTheme=applyTheme;window.readData=readData;window.uploadBaseline=uploadBaseline;window.approveAuditWarning=approveAuditWarning;window.saveContactSettings=saveContactSettings;window.openLatestAudit=openLatestAudit;window.previewRelations=previewRelations;window.assembleRelations=assembleRelations;window.mappingPreview=mappingPreview;window.mappingCommit=mappingCommit;window.snapshot=snapshot;window.buildNow=buildNow;window.setTab=setTab;window.openCreate=openCreate;
+27
View File
@@ -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}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+1
View File
@@ -0,0 +1 @@
:root{color-scheme:light;--ink:#142237;--muted:#68778b;--stroke:#e4e8f0;--blue:#3263c9;--bg:#f5f7fb}*{box-sizing:border-box}body{margin:0;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;color:var(--ink);background:var(--bg)}a{color:inherit;text-decoration:none}.layout{min-height:100vh;display:grid;grid-template-columns:240px minmax(0,1fr)}aside{background:#15233b;color:#dbe7fb;padding:24px 15px;display:flex;flex-direction:column;gap:7px}aside>a:not(.wordmark){padding:13px 14px;border-radius:9px;font-size:13px}aside>a.active,aside>a:not(.wordmark):hover{background:#2a4569}.wordmark{display:flex;gap:11px;align-items:center;padding:8px 12px 30px}.wordmark b{display:grid;place-items:center;width:39px;height:39px;border-radius:10px;background:#3b6ee8;font-size:22px}.wordmark strong{display:block;font-size:18px}.wordmark small{font-size:10px;letter-spacing:1px;color:#93a9c8}.eyebrow{font-size:10px;letter-spacing:2px;color:#9eafd0;padding:0 14px}.sidefoot{margin-top:auto;border-top:1px solid #344761;padding:20px 12px;font-size:12px;color:#8ca0bf}main{padding:32px;max-width:1640px;width:100%;margin:0 auto}header{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:25px}header h1{font-size:30px;letter-spacing:-.7px;margin:3px 0 4px}header p,.muted{color:var(--muted);margin:0}.crumb{color:var(--blue);font-size:12px;font-weight:700}.right{display:flex;align-items:end;gap:9px}.right label{font-size:12px;color:var(--muted)}.right select{display:block;min-width:230px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.metrics>div,section{border:1px solid var(--stroke);border-radius:14px;background:#fff;box-shadow:0 4px 18px #172c5008}.metrics>div{padding:19px 21px}.metrics span{display:block;color:var(--muted);font-size:12px}.metrics strong{font-size:28px;margin-top:6px;display:block}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin-bottom:18px}section{padding:24px}.section-head{display:flex;justify-content:space-between;align-items:baseline;gap:10px;border-bottom:1px solid var(--stroke);padding-bottom:14px;margin-bottom:17px}h2{font-size:17px;margin:0}.section-head small{color:#8494a9}h3{font-size:14px;margin:24px 0 9px}input,select,button{font:inherit}input[type=file],select{width:100%;border:1px solid #dce3ed;border-radius:8px;background:#fff;padding:10px;color:var(--ink);margin-top:5px;min-height:41px}label{display:block;font-size:12px;color:#53637a}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.actions{display:flex;gap:9px;margin:17px 0;flex-wrap:wrap}button,.link{border:0;background:var(--blue);color:#fff;border-radius:8px;padding:10px 15px;font-weight:600;cursor:pointer;display:inline-block}.secondary,button.secondary{color:#253952;background:#edf2fa;border:1px solid #dce6f3}button:disabled{opacity:.5;cursor:not-allowed}button:hover:not(:disabled){filter:brightness(.95)}.report,.job-list{border:1px solid #e9edf4;background:#f9fbfe;border-radius:9px;padding:13px;color:#51627b;font-size:12px;overflow-wrap:anywhere;min-height:60px}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 0;border-bottom:1px solid #e5ebf4}.row:last-child{border-bottom:0}.row b{display:block;color:var(--ink)}.row small{color:#8493a8}.row button{font-size:11px;padding:6px 8px}.flag{display:inline-block;border-radius:6px;padding:2px 6px;background:#e1ebfe;color:#2b5cac}.flag.warn{background:#fff2da;color:#936006}#notice{min-height:0;font-weight:600;color:#1f6947;padding:0 0 12px}footer{color:#8c9bae;font-size:12px;padding:16px 0}@media(max-width:1120px){.two{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:760px){.layout{display:block}aside{padding:10px;flex-direction:row;flex-wrap:wrap}.wordmark{padding:5px}.eyebrow,.sidefoot{display:none}main{padding:18px}header{align-items:stretch;flex-direction:column}.right,.fields{grid-template-columns:1fr}.fields{display:grid}.metrics{gap:9px}section{padding:18px}}
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const $=s=>document.querySelector(s), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let csrf='',sites=[],siteId='',jobs=[],builds=[],releases=[],lastPreview=null;
const notify=(message,error=false)=>{$('#notice').textContent=message;$('#notice').style.color=error?'#ac3d45':'#256448'};
async function request(url,{method='GET',body=null,upload=false}={}){
const headers={};if(method!=='GET')headers['X-SiteForge-CSRF']=csrf;
if(body&&!upload)headers['Content-Type']='application/json';
const res=await fetch(url,{method,credentials:'same-origin',headers,body:body?(upload?body:JSON.stringify(body)):undefined});
if(res.status===401){location.assign('/login');throw Error('登录已过期')}
const result=await res.json();if(!res.ok)throw Error(result.detail||'请求失败');return result;
}
const post=(url,body)=>request(url,{method:'POST',body});
function formatJobs(list){return list.length?list.map(j=>`<div class="row"><div><b>${esc(j.kind||'build')} <span class="flag">${esc(j.state)}</span></b><small>${esc(j.id)} · 尝试 ${esc(j.attempts||0)} 次</small></div>${['queued','running','retrying'].includes(j.state)?`<button class="secondary" onclick="cancelJob('${esc(j.id)}')">取消</button>`:''}</div>`).join(''):'当前没有任务';}
async function chooseSite(){siteId=$('#site').value;lastPreview=null;$('#commitImport').disabled=true;await reload()}
async function init(){try{csrf=(await request('/api/auth/me')).csrf;sites=await request('/api/sites');$('#site').innerHTML=sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');siteId=sites[0]?.id||'';$('#site').onchange=chooseSite;$('#reload').onclick=reload;$('#previewImport').onclick=()=>importJSON(false);$('#commitImport').onclick=()=>importJSON(true);$('#build').onclick=buildSite;$('#deploy').onclick=deploySite;$('#refreshRelease').onclick=reload;await reload()}catch(e){notify(e.message,true)}}
async function reload(){if(!siteId)return;try{
const [list,jobsData,buildData,approvals,history,reviewData]=await Promise.all([
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`)]);
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(''):'当前没有构建';
$('#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(''):'暂无审批记录';
$('#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(''):'暂无部署记录';
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)}}
async function importJSON(commit){const file=$('#file').files?.[0];if(!file)return notify('请选择 JSON 文件',true);if(!siteId)return;const platform=$('#platform').value;
if(commit&&!lastPreview)return;const data=new FormData();data.append('platform',platform);data.append('file',file);if(commit)data.append('conflict','skip');
try{const result=await request(`/api/migrations/sites/${siteId}/${commit?'import':'preview'}`,{method:'POST',body:data,upload:true});if(commit){notify(`导入完成:${result.result.imported} 条,人工复核 ${result.migration_warnings.length} 项`);lastPreview=null;$('#commitImport').disabled=true;await reload()}else{lastPreview=result;$('#commitImport').disabled=result.errors.length>0;$('#report').innerHTML=`<b>输入 ${result.input} 条 · 可映射 ${result.mapped} 条 · 错误 ${result.errors.length} 条 · 复核项 ${result.warnings.length} 条</b><div>${result.errors.slice(0,12).map(x=>`<p>第${x.row}条:${esc(x.error)}</p>`).join('')}${result.warnings.slice(0,12).map(x=>`<p>待复核 第${x.row}条:${esc(x.issue)}</p>`).join('')}</div>`}}
catch(e){notify(e.message,true)}}
async function resolveReview(id){
const answer=prompt('请详细填写对照旧站完成检查/修复的说明(至少12个字符)。未经核查不要批准。');
if(answer===null)return;
try{await post(`/api/migrations/sites/${siteId}/reviews/${id}/resolve`,{resolution:answer});notify('复核记录已保存');await reload()}catch(e){notify(e.message,true)}
}
async function buildSite(){try{const task=await post(`/api/sites/${siteId}/builds`,{mirror_images:false});notify(`任务 ${task.id} 已进入 Worker 队列`);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 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)}}
window.resolveReview=resolveReview;window.cancelJob=cancelJob;window.requestApproval=requestApproval;window.approve=approve;
window.addEventListener('DOMContentLoaded',init);
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
'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?'关闭导航':'打开导航');});}
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();
});
})();
+31
View File
@@ -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
+118
View File
@@ -0,0 +1,118 @@
'use strict';
/* SiteForge simplified workspace: native APIs, no parallel business backend. */
const $=(s,root=document)=>root.querySelector(s);
const esc=x=>String(x??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
const themeName={automotive:'汽车与出行',industrial:'工业与制造',corporate:'企业品牌',minimal:'极简展示'};
const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',brand:'品牌',case:'案例',service:'服务'};
const fmtDate=t=>{if(!t)return '—';const d=new Date(t);return Number.isNaN(d.getTime())?String(t).slice(0,16):d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})};
const state={sites:[],site:null,entries:[],builds:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null};
const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',settings:'网站设置'};
async function api(url,method='GET',data=null,extraHeaders={}){
const headers={...extraHeaders};if(method!=='GET'&&method!=='HEAD')headers['X-SiteForge-CSRF']=state.csrf;
let body=data;if(data!=null && !(data instanceof FormData)){headers['Content-Type']='application/json';body=JSON.stringify(data)}
const res=await fetch(url,{method,headers,body,credentials:'same-origin'});
if(res.status===401){location.href='/login';throw Error('登录已过期,请重新登录')}
let value;try{value=await res.json()}catch{throw Error('服务器响应异常(HTTP '+res.status+')')}
if(!res.ok)throw Error(typeof value.detail==='string'?value.detail:JSON.stringify(value.detail||value));return value;
}
function toast(msg,bad=false){const el=$('#toast');el.textContent=msg;el.classList.toggle('bad',bad);el.classList.add('show');clearTimeout(el.timer);el.timer=setTimeout(()=>el.classList.remove('show'),4000)}
function notifyError(error){console.error(error);toast(error?.message||String(error),true)}
function htmlHeading(title,desc,actions=''){return `<div class="page-heading"><div><p class="overline">SITEFORGE / ${esc(headings[state.view]||'WORKSPACE')}</p><h1>${esc(title)}</h1><p>${esc(desc)}</p></div><div class="inline-actions">${actions}</div></div>`}
function actionButton(text,action=''){return `<button type="button" class="btn btn-primary" data-action="${action}">${esc(text)}</button>`}
function stat(title,num,icon){return `<div class="stat"><span class="glyph">${icon}</span><small>${title}</small><strong class="value">${esc(num)}</strong></div>`}
function emptyState(title,description,button=''){return `<div class="empty"><div class="empty-symbol">◇</div><h3>${esc(title)}</h3><p>${esc(description)}</p>${button}</div>`}
function nav(button){return `data-action="navigate" data-view="${button}"`}
function siteCard(s){return `<button class="site-card" data-action="select-site" data-id="${esc(s.id)}"><span class="site-icon ${esc(s.theme)}">${esc((s.name||'S').slice(0,1))}</span><span><h3>${esc(s.name)}</h3><p>${esc(s.domain||'尚未设置正式域名')}</p><span class="site-badges"><span>${esc(themeName[s.theme]||'企业网站')}</span><span>·</span><span>${Number(s.entries_count)||0} 条内容</span><span>·</span><span>${Number(s.build_count)||0} 次构建</span></span></span><span class="arrow">›</span></button>`}
function overviewShortcut(view,icon,title,desc){return `<button class="quick-card" ${nav(view)}><span class="quick-ico">${icon}</span><span><h3>${title}</h3><p>${desc}</p><span class="quick-more">前往操作 →</span></span></button>`}
function home(){const sites=state.sites;const total=sites.reduce((n,s)=>n+(+s.entries_count||0),0);const builds=sites.reduce((n,s)=>n+(+s.build_count||0),0);
return htmlHeading('欢迎回来','管理和制作企业网站,从这里开始。',actionButton('+ 创建新网站','new-site'))+
`<div class="stats">${stat('网站项目',sites.length,'▤')}${stat('内容总数',total,'▧')}${stat('构建版本',builds,'↗')}${stat('可用主题',4,'◈')}</div>`+
`<div class="section-heading"><div><h2>您想先做什么?</h2><p>按使用目的进入对应页面,不需要了解技术术语。</p></div></div><div class="quick-grid">`+
overviewShortcut('sites','+','创建或管理网站','从零搭建网站,或者接着制作已有网站。')+
overviewShortcut('import','⇩','迁移旧站 / 导入数据','导入 WordPress 或 Excel 产品资料。')+
overviewShortcut('design','◈','更换网站设计','选择行业主题,编辑网页与全站外观。')+
`</div><div class="section-heading"><div><h2>最近的网站</h2><p>点击网站继续制作。</p></div><button class="btn-link" ${nav('sites')}>查看全部网站 →</button></div>`+
(sites.length?`<div class="site-list">${sites.slice(0,4).map(siteCard).join('')}</div>`:emptyState('还没有网站项目','创建一个网站,选择主题后即可开始添加页面、产品和新闻。',actionButton('+ 创建第一个网站','new-site')));
}
function sites(){return htmlHeading('我的网站','统一管理所有网站项目,每个网站的数据、主题和构建记录彼此独立。',actionButton('+ 创建网站','new-site'))+
(state.sites.length?`<div class="site-list">${state.sites.map(siteCard).join('')}</div>`:emptyState('暂时没有网站','选择行业主题,开始制作您的第一个网站。',actionButton('创建网站','new-site')));
}
function currentSite(){return !!state.site}
function noSite(){return htmlHeading('请先选择一个网站','选择现有网站或创建新网站,才能继续进行内容编辑、迁移和构建。')+emptyState('尚未选择网站','点击右上角选择网站,或直接创建一个新的企业网站。',actionButton('+ 创建网站','new-site'))}
function latestDone(){return state.builds.find(b=>['done','complete','success','completed'].includes(b.status))||null}
function overview(){const s=state.site;if(!s)return noSite();const count=state.entries.length;const build=latestDone();const hasContent=count>1;const hasDomain=!!s.domain;const steps=[
{title:'建立网站',desc:'选择行业主题并创建项目',done:true,view:'settings'},
{title:'准备内容',desc:'添加页面、产品或导入旧数据',done:hasContent,view:'content'},
{title:'完善设计',desc:'选用主题,调整图文和全站风格',done:false,view:'design'},
{title:'构建并预览',desc:'生成静态网站,检查效果与链接',done:!!build,view:'build'}];
const next=steps.find(x=>!x.done)||steps[3];
return htmlHeading(s.name,'这是您的网站工作台。按照下面的顺序操作,完成后就能预览网站。',actionButton('↗ 构建网站','build-now'))+
`<div class="task-note"><div><strong>下一步建议:${next.title}</strong><p>${next.desc}。${!hasDomain?'网站域名也可以稍后再设置。':''}</p></div><button class="btn btn-primary" ${nav(next.view)}>现在去做 →</button></div>`+
`<div class="stats">${stat('网站内容',count,'▧')}${stat('已构建版本',state.builds.length,'↗')}${stat('当前主题',themeName[s.theme]||'企业品牌','◈')}${stat('域名状态',hasDomain?'已填写':'待设置','◎')}</div>`+
`<div class="section-heading"><div><h2>四步完成网站制作</h2><p>从左到右进行;每一步都可以随时返回修改。</p></div></div>`+
`<div class="wizard-grid">${steps.map((step,i)=>`<div class="wizard-step ${step.done?'done':next===step?'next':''}"><span class="wizard-index">${step.done?'✓':i+1}</span><h3>${step.title}</h3><p>${step.desc}</p><button ${nav(step.view)}>打开 →</button></div>`).join('')}</div>`+
`<div class="section-heading"><div><h2>常用操作</h2><p>日常更新网站通常只需要使用以下三个入口。</p></div></div>`+
`<div class="quick-grid">${overviewShortcut('content','▧','添加和编辑内容','管理网站页面、产品、新闻、案例。')}${overviewShortcut('design','◈','修改网站样式','更换主题、编辑页面和移动端样式。')}${overviewShortcut('build','↗','查看和下载网站','提交构建,预览并下载静态网站。')}</div>`;
}
function content(){if(!currentSite())return noSite();let arr=state.entries.filter(e=>(!state.typ||e.type===state.typ)&&(!state.search||`${e.title} ${e.route}`.toLowerCase().includes(state.search.toLowerCase())));
return htmlHeading('内容与页面','所有网页、产品、车型和新闻都在这里维护,保存后重新构建即可更新网站。',actionButton('+ 添加内容','new-entry'))+
`<div class="panel"><div class="panel-head"><div><h2>内容列表</h2><p>共 ${state.entries.length} 条内容。点击“设计页面”可进行可视化排版。</p></div></div><div class="toolbar"><input id="contentSearch" aria-label="搜索内容" placeholder="搜索标题或页面路径…" value="${esc(state.search)}"><select id="contentType" aria-label="按类型筛选"><option value="">全部类型</option>${Object.entries(typeName).map(([key,value])=>`<option value="${key}" ${state.typ===key?'selected':''}>${value}</option>`).join('')}</select></div>`+
(arr.length?`<div class="table-wrap"><table class="data-table"><thead><tr><th>名称</th><th>内容类型</th><th>页面地址</th><th>状态</th><th>操作</th></tr></thead><tbody>${arr.slice(0,200).map(e=>`<tr><td><strong>${esc(e.title)}</strong></td><td>${esc(typeName[e.type]||e.type)}</td><td class="muted">${esc(e.route)}</td><td><span class="pill ${e.status==='draft'?'warn':'success'}">${e.status==='draft'?'草稿':'已保存'}</span></td><td class="action-cell"><button class="btn btn-tiny" data-action="edit-entry" data-id="${esc(e.id)}">修改内容</button> <a class="btn btn-tiny" href="/editor/${encodeURIComponent(state.site.id)}/${encodeURIComponent(e.id)}">设计页面 ↗</a></td></tr>`).join('')}</tbody></table></div>`:emptyState('没有找到内容','可以清除筛选条件,或者添加新内容。',actionButton('+ 添加内容','new-entry')))+
`${arr.length>200?'<p class="muted">当前显示前 200 条,请使用搜索缩小范围。</p>':''}</div>`;
}
function design(){if(!currentSite())return noSite();const s=state.site;const cards=Object.entries(themeName).map(([id,name])=>`<div class="theme-card"><div class="theme-preview ${id}">${name}</div><div class="theme-desc"><h3>${name} ${s.theme===id?'<span class="pill success">当前使用</span>':''}</h3><p>${({automotive:'适合汽车出口、车型及品牌展示',industrial:'适合制造业、产品与技术参数',corporate:'适合集团公司、服务和案例',minimal:'适合作品展示和品牌形象'})[id]}</p><button class="btn btn-tiny ${s.theme===id?'btn-muted':'btn-primary'}" ${s.theme===id?'disabled':''} data-action="set-theme" data-theme="${id}">${s.theme===id?'正在使用':'使用此主题'}</button></div></div>`).join('');
return htmlHeading('外观与设计','先选一套网站风格,再打开可视化编辑器修改图文、导航和手机端布局。')+
`<div class="section-heading"><div><h2>第一步 · 选择行业主题</h2><p>更换主题不会删除您已经录入的业务内容和 URL。</p></div><a href="/theme-gallery" class="btn btn-tiny">查看完整主题预览 ↗</a></div><div class="choice-cards">${cards}</div>`+
`<div class="section-heading"><div><h2>第二步 · 设计网页</h2><p>挑选要修改的页面;可在设计器中编辑导航、页眉、页脚及各设备布局。</p></div></div><div class="panel"><div class="panel-head"><div><h2>选择要设计的页面</h2><p>修改先保存为草稿,正式发布设计后才会参与下一次静态构建。</p></div></div><div class="grid-2 grid-equal">${state.entries.filter(e=>e.type==='page').slice(0,8).map(e=>`<a class="site-card" href="/editor/${encodeURIComponent(s.id)}/${encodeURIComponent(e.id)}"><span class="site-icon">▤</span><span><h3>${esc(e.title)}</h3><p>${esc(e.route)}</p></span><span class="arrow">›</span></a>`).join('')||'<div class="muted">尚无页面,请先添加内容。</div>'}</div></div><div class="help-hint" style="margin-top:19px"><strong>提示</strong>:网站设置用于公司信息和域名;可视化编辑器用于调整页面外观。两者不会混在同一表单里。</div>`;
}
function imports(){if(!currentSite())return noSite();return htmlHeading('导入与迁移','根据您已有的资料选择操作方式:Excel 产品、WordPress 网站,或者外部 API / 数据库。')+
`<div class="quick-grid">${overviewShortcut('import','▤','导入 Excel / CSV','批量增加产品、车型和新闻数据。')}<a class="quick-card" href="/production-v8"><span class="quick-ico">⇄</span><span><h3>迁移 WordPress 旧站</h3><p>迁移会话、文章采集、图片与 SEO 检查。</p><span class="quick-more">打开迁移工作台 ↗</span></span></a><a class="quick-card" href="/production-v8"><span class="quick-ico">⌘</span><span><h3>API / XML / MySQL</h3><p>连接外部业务系统,进行字段映射和分批导入。</p><span class="quick-more">进入高级导入 ↗</span></span></a></div>`+
`<div class="section-heading"><div><h2>快速导入文件</h2><p>适合简单的 Excel、CSV、JSON、WordPress XML 文件。</p></div></div><div class="panel"><h2>上传数据文件</h2><p class="panel-desc">已有网站会保留;重复页面路径默认跳过。复杂迁移请使用上方的专业迁移工作台先预检。</p><form id="importForm"><div class="import-box"><label for="importFile"><strong>选择要导入的数据文件</strong></label><input type="file" id="importFile" name="file" accept=".csv,.xlsx,.xls,.json,.xml" required><p class="muted">支持 CSV、Excel、JSON、WordPress XML;单文件最多 15 MB。</p></div><div class="form-actions"><button class="btn btn-primary" type="submit">确认导入数据</button><a href="/production-v8" class="btn">需要预演与批次回滚?</a></div></form><div id="importSummary" class="message" style="margin-top:15px" hidden></div></div>`+
`<div class="help-hint" style="margin-top:17px"><strong>迁移旧网站请注意:</strong>如果需要保留旧 URL、原有 SEO、复杂插件字段和全部图片,请优先使用 WordPress 迁移工作台,而不是仅上传一个内容文件。</div>`;
}
function builds(){if(!currentSite())return noSite();const latest=latestDone();return htmlHeading('构建与交付','将当前网站数据生成为独立的 HTML 文件。先预览和检查,再决定是否发布。',actionButton('↗ 开始构建','build-now'))+
`<div class="task-note"><div><strong>${latest?'上次构建已完成,可以继续预览':'您还没有完成网站构建'}</strong><p>内容或设计有更新时,需要重新构建才能生成新版本。</p></div><a class="btn" href="/operations">构建任务与发布审批 ↗</a></div>`+
`<div class="panel"><div class="panel-head"><div><h2>构建版本</h2><p>下载的 ZIP 是独立静态网站,可交给运维部署。</p></div></div><div id="jobNotice" class="message" ${state.job?'':'hidden'}>${esc(state.job||'')}</div>`+
(state.builds.length?state.builds.slice(0,20).map(b=>`<div class="build-row"><span class="quick-ico">${['done','completed','success','complete'].includes(b.status)?'✓':'◷'}</span><div class="build-meta"><h3>${esc(fmtDate(b.created_at))} · ${esc(b.total_pages||'—')} 个页面</h3><p>版本 ${esc(b.id.slice(0,12))} · 状态 ${esc(b.status||'未知')}</p></div><div class="inline-actions"><a class="btn btn-tiny" href="/preview/${encodeURIComponent(state.site.id)}/${encodeURIComponent(b.id)}/" target="_blank" rel="noopener">预览 ↗</a><button class="btn btn-tiny" data-action="report" data-id="${esc(b.id)}">质量报告</button><a class="btn btn-tiny btn-primary" href="/api/sites/${encodeURIComponent(state.site.id)}/builds/${encodeURIComponent(b.id)}/download">下载 ZIP</a></div></div>`).join(''):emptyState('尚无构建版本','网站内容和设计准备好后,点击“开始构建”生成独立网站文件。',actionButton('开始构建','build-now')))+`<div id="reportContent"></div></div>`;
}
function 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>`;
}
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')});
area.innerHTML=({home,sites,overview,content,design,import:imports,build:builds,settings}[selected]||home)();
}
async function loadSites(){state.sites=await api('/api/sites');const picker=$('#sitePicker'),prev=state.site?.id||'';picker.innerHTML='<option value="">选择网站…</option>'+state.sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');picker.value=state.sites.some(s=>s.id===prev)?prev:''}
async function selectSite(id,go='overview'){if(!id){state.site=null;state.entries=[];state.builds=[];state.view='sites';$('#sitePicker').value='';rerender();return}try{const [site,entries,builds]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`)]);state.site=site;state.entries=entries;state.builds=builds;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();window.scrollTo({top:0,behavior:'instant'})}catch(e){notifyError(e)}}
function navigate(to){if(!state.site&&!['home','sites'].includes(to)){state.view='sites';rerender();toast('请先选择网站项目');return}state.view=to;rerender();closeMobile();window.scrollTo({top:0,behavior:'instant'})}
function openModal(name){const node=$('#'+name);node.showModal();node.querySelector('input:not([type=hidden]),select')?.focus()}
function closeModal(name){$('#'+name)?.close()}
function openEntry(id){const form=$('#entryForm');form.reset();form.elements.entry_id.value='';$('#entryModalTitle').textContent=id?'修改内容':'添加内容';if(id){const e=state.entries.find(x=>x.id===id);if(!e)return toast('这条内容不存在',true);for(const field of ['title','route','summary','body','seo_title','seo_description','type','status']){if(form.elements[field])form.elements[field].value=e[field]||''}form.elements.entry_id.value=id}openModal('entryModal')}
async function saveEntry(ev){ev.preventDefault();if(!state.site)return;const el=ev.currentTarget;const data=Object.fromEntries(new FormData(el));const id=data.entry_id;delete data.entry_id;
if(!data.route?.trim())delete data.route;
try{await api(`/api/sites/${state.site.id}/entries${id?'/'+encodeURIComponent(id):''}`,id?'PATCH':'POST',data);closeModal('entryModal');toast(id?'内容已更新':'内容已添加');await selectSite(state.site.id,'content')}catch(e){notifyError(e)}}
async function saveSite(ev){ev.preventDefault();try{const f=ev.currentTarget;const data=Object.fromEntries(new FormData(f));const s=await api('/api/sites','POST',data);closeModal('siteModal');toast('网站创建成功,现在可以添加内容');await loadSites();await selectSite(s.id,'overview')}catch(e){notifyError(e)}}
async function setTheme(theme){if(!state.site||theme===state.site.theme)return;if(!confirm(`切换到「${themeName[theme]}」?网站内容及已有 URL 将保持不变,外观会在下次构建后更新。`))return;try{await api(`/api/sites/${state.site.id}`,'PATCH',{theme});toast('主题已切换,重新构建后生效');await selectSite(state.site.id,'design')}catch(e){notifyError(e)}}
async function saveSettings(ev){ev.preventDefault();try{await api(`/api/sites/${state.site.id}`,'PATCH',Object.fromEntries(new FormData(ev.currentTarget)));toast('网站设置已保存');await loadSites();await selectSite(state.site.id,'settings')}catch(e){notifyError(e)}}
async function importFile(ev){ev.preventDefault();try{if(!state.site)return;const form=new FormData(ev.currentTarget);if(!form.get('file')?.name)throw Error('请选择文件');if(!confirm('将文件内容直接写入当前网站,重复路径默认跳过。需要预演和批次回滚请先使用高级导入。确认继续?'))return;const result=await api(`/api/sites/${state.site.id}/import`,'POST',form);const box=$('#importSummary');box.hidden=false;box.textContent=`导入完成:新增 ${result.imported||0} 条,更新 ${result.updated||0} 条,跳过 ${result.skipped||0} 条。错误 ${result.errors?.length||0} 条。`;toast('导入已完成');await refreshSiteKeepView()}catch(e){notifyError(e)}}
async function refreshSiteKeepView(){if(!state.site)return;const [site,entries,builds]=await Promise.all([api('/api/sites/'+state.site.id),api('/api/sites/'+state.site.id+'/entries'),api('/api/sites/'+state.site.id+'/builds')]);state.site=site;state.entries=entries;state.builds=builds}
async function buildNow(){if(!state.site)return toast('请先选择网站',true);const id=state.site.id;try{navigate('build');state.job='正在提交网站构建任务…';rerender();const job=await api(`/api/sites/${id}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()});state.job=`网站已进入构建队列(任务 ${job.id}),关闭页面也可以继续运行。`;rerender();for(let i=0;i<120;i++){await new Promise(resolve=>setTimeout(resolve,1600));if(state.site?.id!==id||state.view!=='build')break;const status=await api(`/api/enterprise/sites/${id}/jobs/${job.id}`);if(['done','failed','cancelled'].includes(status.state)){state.job=status.state==='done'?'网站构建已完成,可以预览和下载。':`构建${status.state==='cancelled'?'已取消':'失败'}:${status.error||'请在任务中心查看详情'}`;await refreshSiteKeepView();rerender();toast(state.job,status.state!=='done');break}if(i%5===0){state.job=`Worker 正在构建(${status.state||'等待中'})…`;$('#jobNotice')?.replaceChildren(document.createTextNode(state.job))}}}catch(e){notifyError(e)}}
async function showReport(buildId){try{const r=await api(`/api/sites/${state.site.id}/builds/${buildId}/report`);const target=$('#reportContent');if(!target)return;target.innerHTML=`<div class="panel" style="margin-top:16px"><h3>质量检查结果</h3><p class="muted">本报告检查静态内容与资源,不保证搜索引擎排名。</p><div class="stats">${stat('HTML 页面',r.pages||0,'▤')}${stat('重定向',r.redirects||0,'⇄')}${stat('已本地化图片',r.mirrored_images||0,'▧')}${stat('提示数量',(r.warnings||[]).length,'!')}</div>${r.warnings?.length?`<div class="message">${r.warnings.slice(0,20).map(x=>esc(x)).join('\n')}</div>`:'<span class="pill success">当前检查没有警告</span>'}</div>`;target.scrollIntoView({behavior:'smooth',block:'nearest'})}catch(e){notifyError(e)}}
function closeMobile(){$('#sideMenu').classList.remove('open');$('#mobileBackdrop').classList.remove('open');$('#menuToggle').setAttribute('aria-expanded','false')}
async function init(){try{const me=await api('/api/auth/me');state.csrf=me.csrf;await loadSites();const health=await api('/api/health');$('#appVersion').textContent='版本 '+health.version;
// Opening the home screen intentionally does not auto-select a site.
navigate('home')}catch(e){notifyError(e);$('#mainContent').innerHTML=emptyState('后台加载失败',e.message,'<button class="btn btn-primary" onclick="location.reload()">重新加载</button>')}}
// Delegated UI interactions keep navigation and form actions centrally auditable.
document.addEventListener('click',async ev=>{const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='new-site')openModal('siteModal');if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='report')await showReport(control.dataset.id)}catch(e){notifyError(e)}});
document.addEventListener('click',ev=>{const btn=ev.target.closest('[data-close]');if(btn)closeModal(btn.dataset.close)});
$('#siteForm').addEventListener('submit',saveSite);$('#entryForm').addEventListener('submit',saveEntry);
document.addEventListener('submit',ev=>{if(ev.target.id==='settingsForm')saveSettings(ev);if(ev.target.id==='importForm')importFile(ev)});
document.addEventListener('input',ev=>{if(ev.target.id==='contentSearch'){state.search=ev.target.value;const cursor=ev.target.selectionStart;const type=state.typ;rerender();const el=$('#contentSearch');el.focus();el.setSelectionRange(cursor,cursor);state.typ=type}});
document.addEventListener('change',ev=>{if(ev.target.id==='contentType'){state.typ=ev.target.value;rerender()}});
$('#sitePicker').addEventListener('change',ev=>selectSite(ev.target.value));
$('#newSiteTop').addEventListener('click',()=>openModal('siteModal'));
$('#logoutBtn').addEventListener('click',async()=>{try{await api('/api/auth/logout','POST');location.href='/login'}catch(e){notifyError(e)}});
$('#menuToggle').addEventListener('click',()=>{const open=$('#sideMenu').classList.toggle('open');$('#mobileBackdrop').classList.toggle('open',open);$('#menuToggle').setAttribute('aria-expanded',String(open))});
$('#mobileBackdrop').addEventListener('click',closeMobile);
document.addEventListener('keydown',ev=>{if(ev.key==='Escape')closeMobile()});
document.addEventListener('DOMContentLoaded',init);
+56
View File
@@ -0,0 +1,56 @@
"""Trusted shared site shell for the four visual theme designs and installed themes.
Client-side search/filter works without a server after static export.
"""
from html import escape as e
import json
from pathlib import Path
from . import db
ASSETS=Path(__file__).resolve().parent/'static'
def shell(html,site,page,records,nav):
theme=site.get('theme','corporate')
label=lambda key: e(str(key or ''),quote=True)
links=[]
if not nav:
nav=[{'title':'首页','route':'/'},{'title':'产品中心','route':'/product/'},{'title':'品牌','route':'/brand/'},
{'title':'新闻资讯','route':'/post/'},{'title':'成功案例','route':'/case/'},{'title':'关于我们','route':'/about/'},{'title':'联系我们','route':'/contact/'}]
seen=set()
for n in nav[:25]:
route=n.get('route','/')
if route in seen:continue
seen.add(route)
links.append(f'<a href="{label(route.lstrip("/") or "./")}">{label(n.get("title"))}</a>')
# Themes can vary greatly in typography, but share an accessible responsive site shell.
name=label(site['name']); tag=label(site.get('tagline',''))
panels=''.join(f'<a href="{label(x["route"].lstrip("/") or "./")}"><span>{label(x["title"])}</span><small>{label(x["summary"][:86])}</small></a>' for x in records if x.get('type') in ('brand','service') and x.get('status')=='published')[:12000]
header=f'''<div class="sf-site-head" id="sf-site-head"><div class="sf-head-inner"><a class="sf-logo" href="./"><span class="sf-logo-mark">S</span><strong>{name}</strong></a>
<button type="button" class="sf-menu-toggle" aria-controls="sf-primary-links" aria-expanded="false" aria-label="展开网站菜单">☰</button>
<nav class="sf-primary-links" id="sf-primary-links" aria-label="主导航">{''.join(links)}<a href="search/" aria-label="站内搜索">⌕ 搜索</a></nav>
<a class="sf-head-cta" href="contact/">合作咨询 ↗</a></div>{('<div class="sf-mega">'+panels+'</div>') if panels else ''}</div>'''
foot=f'''<div class="sf-site-foot"><div class="sf-foot-grid"><div><h2>{name}</h2><p>{tag}</p></div><div><h4>快速导航</h4>{''.join(links[:6])}</div><div><h4>联系我们</h4><p>{label(site.get('email') or '请在管理后台填写联系邮箱')}</p><a href="contact/">提交咨询 →</a></div></div><div class="sf-foot-lower"><span>© {name}</span><span>GLOBAL SOURCING · GLOBAL DELIVERY</span></div></div>'''
# Every generated landing/detail page needs exactly one visible page heading.
# Blueprints for information pages start with h2 blocks by default.
if page.get('route') != '/' and '<main class="sf-canvas">' in html and '<h1' not in html:
intro=f'<section class="sf-page-intro"><div><p>EXPLORE / {label(page.get("type","PAGE")).upper()}</p><h1>{label(page.get("title", ""))}</h1><span>{label(page.get("summary", ""))}</span></div></section>'
html=html.replace('<main class="sf-canvas">',intro+'<main class="sf-canvas">',1)
html=html.replace('<body>','<body class="sf-theme-'+label(theme)+'">'+header,1)
html=html.replace('</body>',foot+'<script defer src="assets/site-runtime.js"></script></body>',1)
html=html.replace('</head>','<link rel="stylesheet" href="assets/factory.css"></head>',1)
return html
def write_assets(dist,records):
from shutil import copy2
copy2(ASSETS/'factory.css',dist/'assets'/'factory.css')
copy2(ASSETS/'site-runtime.js',dist/'assets'/'site-runtime.js')
search=[{'title':r['title'],'url':r['route'],'summary':(r.get('summary') or '')[:360],
'type':r['type'], 'brand':str((json.loads(r.get('meta_json') or '{}').get('fields') or {}).get('brand',''))}
for r in records if r.get('route') and r['route']!='/']
(dist/'search-index.json').write_text(json.dumps(search,ensure_ascii=False),encoding='utf-8')
def search_page(site):
from html import escape
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="/"><title>站内搜索 - {escape(site['name'])}</title><meta name="robots" content="noindex,follow"><link rel="stylesheet" href="assets/theme.css"><link rel="stylesheet" href="assets/factory.css"></head>
<body class="sf-theme-{escape(site['theme'])}"><main class="sf-search-page"><a href="./">← 返回首页</a><h1>搜索网站内容</h1><label for="sf-search">关键词</label><input id="sf-search" type="search" autofocus placeholder="搜索产品、新闻、品牌、案例……"><div id="sf-search-count" aria-live="polite"></div><div id="sf-search-results"></div></main><script defer src="assets/site-runtime.js"></script></body></html>'''
+146
View File
@@ -0,0 +1,146 @@
"""Isolated page-design drafts with optimistic published-content protection.
Existing /blueprint endpoints remain as legacy API compatibility. The V8.6
studio uses only /studio-draft endpoints; a build never consumes these drafts.
"""
from __future__ import annotations
import hashlib
import json
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, blueprints, enterprise
from .core import save_entry
router = APIRouter(prefix='/api/sites', tags=['V8.6 draft studio'])
def setup():
with db.connect() as c:
c.execute('''CREATE TABLE IF NOT EXISTS studio_drafts (
site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
revision INTEGER NOT NULL, base_hash TEXT NOT NULL,
blueprint_json TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (site_id,entry_id))''')
c.execute('''CREATE TABLE IF NOT EXISTS studio_publications (
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,
blueprint_hash TEXT NOT NULL)''')
def _entry(site_id, entry_id):
row=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
if not row:raise HTTPException(404,'页面不存在')
return row
def _hash(row):
fields=('meta_json','title','route','status','body','summary','updated_at')
return hashlib.sha256(json.dumps([row.get(x) for x in fields],ensure_ascii=False).encode()).hexdigest()
def _validate(payload):
if not isinstance(payload,dict):raise HTTPException(400,'蓝图应为 JSON 对象')
try:blocks=blueprints.validate_blocks(payload.get('blocks',[]))
except (ValueError,TypeError) as e:raise HTTPException(400,str(e))
kind=payload.get('page_template','standard')
mode=payload.get('layout_mode','append')
if kind not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'未知页面类型')
if mode not in ('append','replace'):raise HTTPException(400,'未知排版模式')
return {'blocks':blocks,'page_template':kind,'layout_mode':mode}
def read(site_id,entry_id):
row=_entry(site_id,entry_id)
draft=db.one('SELECT * FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
if draft:
return {**json.loads(draft['blueprint_json']),'revision':draft['revision'],
'conflict':draft['base_hash']!=_hash(row),'state':'draft', 'entry':{'id':row['id'],'title':row['title'],'route':row['route']}}
meta=json.loads(row['meta_json'] or '{}')
return {'blocks':meta.get('blocks',[]),'page_template':meta.get('page_template','home' if row['route']=='/' else 'standard'),
'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):
row=_entry(site_id,entry_id)
plan=_validate(payload)
# A draft is detached from the public entry. Only the final publish
# operation can modify entries, after checking its base fingerprint.
with db.connect() as c:
old=c.execute('SELECT revision,base_hash FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id)).fetchone()
current=int(old[0]) if old else 0
expected=payload.get('expected_revision',0)
if type(expected) is not int or expected!=current:raise HTTPException(409,'草稿已由另一操作修改,请重新加载')
if old and old[1]!=_hash(row):raise HTTPException(409,'原页面发生变化,不能继续覆盖,请复核')
next_rev=current+1
base=old[1] if old else _hash(row)
c.execute('''INSERT INTO studio_drafts VALUES (?,?,?,?,?,?)
ON CONFLICT(site_id,entry_id) DO UPDATE SET revision=excluded.revision,
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()))
return {'saved':True,'state':'draft','revision':next_rev,'published_unchanged':True}
def publish(site_id,entry_id,expected_revision,actor):
row=_entry(site_id,entry_id)
draft=db.one('SELECT * FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
if not draft:raise HTTPException(409,'没有待发布的设计草稿')
if expected_revision!=draft['revision']:raise HTTPException(409,'草稿版本已经改变')
if draft['base_hash']!=_hash(row):raise HTTPException(409,'原页面已变更,请先处理冲突')
plan=_validate(json.loads(draft['blueprint_json']))
meta=json.loads(row['meta_json'] or '{}')
meta.update(plan)
from .core import entry_dict
current=entry_dict(row)
# Atomic update + publication journal + removal of draft.
import uuid
with db.connect() as c:
fresh=c.execute('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id)).fetchone()
if not fresh or _hash(dict(fresh))!=draft['base_hash']:
raise HTTPException(409,'原页面已在其他窗口更新,请重新加载')
c.execute('UPDATE entries SET meta_json=?,updated_at=? WHERE site_id=? AND id=?',
(json.dumps(meta,ensure_ascii=False),db.now(),site_id,entry_id))
pid=uuid.uuid4().hex
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()))
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'],
'build_required':True,'message':'页面设计已发布到内容库;需要重新构建静态站点才会上线'}
@router.get('/{site_id}/pages/{entry_id}/studio-draft')
def api_read(request:Request,site_id:str,entry_id:str):
enterprise.require(request,site_id,'viewer')
return read(site_id,entry_id)
@router.put('/{site_id}/pages/{entry_id}/studio-draft')
def api_write(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return write(site_id,entry_id,payload)
@router.post('/{site_id}/pages/{entry_id}/studio-draft/publish')
def api_publish(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
rev=payload.get('expected_revision')
if type(rev) is not int:raise HTTPException(400,'需要草稿版本号')
return publish(site_id,entry_id,rev,enterprise.actor(request))
@router.delete('/{site_id}/pages/{entry_id}/studio-draft')
def api_discard(request:Request,site_id:str,entry_id:str):
enterprise.require(request,site_id,'editor')
_entry(site_id,entry_id)
db.execute('DELETE FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
return {'discarded':True}
@router.get('/{site_id}/pages/{entry_id}/studio-draft/preset')
def api_draft_preset(request:Request,site_id:str,entry_id:str,page_template:str='standard'):
from . import theme_store
enterprise.require(request,site_id,'viewer')
_entry(site_id,entry_id)
if page_template not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'无效模板类型')
site=db.one('SELECT theme FROM sites WHERE id=?',(site_id,))
pinned=theme_store.pinned(site_id)
return {'blocks':blueprints.theme_blocks(site['theme'],page_template,(pinned or {}).get('version')),
'page_template':page_template,'note':'仅生成草稿预设,不改变已发布内容'}
+38
View File
@@ -0,0 +1,38 @@
"""Decode Excel/CSV/JSON source files for a no-side-effect mapping preview."""
import csv,io,json
from openpyxl import load_workbook
MAX_ROWS=1000
def parse_table(name,data):
if len(data)>12_000_000:raise ValueError('文件超过12 MB')
lower=name.lower()
if lower.endswith('.csv'):
rows=list(csv.DictReader(io.StringIO(data.decode('utf-8-sig'))))
elif lower.endswith('.json'):
rows=json.loads(data.decode('utf-8-sig'))
if isinstance(rows,dict):rows=rows.get('rows',rows.get('entries',rows.get('items',[])))
elif lower.endswith('.xlsx'):
wb=load_workbook(io.BytesIO(data),read_only=True,data_only=True)
try:
ws=wb.active;iterator=ws.iter_rows(values_only=True)
header=next(iterator,None)
if header is None:raise ValueError('Excel 工作表为空')
titles=[str(x or '').strip() for x in header]
if not all(titles) or len(set(titles))!=len(titles):raise ValueError('Excel 列标题不能为空或重复')
rows=[]
for i,values in enumerate(iterator):
if i>=MAX_ROWS:raise ValueError('一次最多导入1000条记录')
if any(x is not None and str(x).strip() for x in values):
rows.append(dict(zip(titles,values)))
finally:wb.close()
else:raise ValueError('支持 CSV、JSON 或 XLSX 文件')
if not isinstance(rows,list) or not 1<=len(rows)<=MAX_ROWS or any(not isinstance(r,dict) for r in rows):
raise ValueError('内容必须包含1-1000条对象记录')
keys=list(dict.fromkeys(str(k) for r in rows for k in r))
if not keys or len(keys)>150:raise ValueError('字段数量不合法')
# Date/datetime -> string to avoid JSON encoder serialization exceptions.
normalized=[]
for row in rows:
normalized.append({str(k):v.isoformat() if hasattr(v,'isoformat') else v for k,v in row.items()})
return {'filename':name,'columns':keys,'count':len(rows),'rows':normalized}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 智能网站工厂</title><link rel="stylesheet" href="/ui/admin.css"><script defer src="/ui/admin.js"></script><script defer src="/ui/enterprise-ui.js"></script></head><body>
<div class="workspace">
<aside class="sidebar"><div class="identity"><div class="logo">S<span>✳</span></div><div><strong>SiteForge</strong><small>WEBSITE FACTORY</small></div></div>
<div class="side-caption">工作空间</div><a class="side-link" href="/factory-studio" style="text-decoration:none;display:block">◈ &nbsp;网站生产中心 →</a><a class="side-link" href="/operations" style="text-decoration:none;display:block">⇄ &nbsp;迁移与企业发布中心 →</a><button class="side-link selected" id="nav-dashboard" onclick="App.openDashboard()"><span>▦</span> 网站项目</button><button class="side-link" onclick="App.openNewSite()"><span>⊕</span> 创建新网站</button><button class="side-link" onclick="EnterpriseUI.team()"><span>♙</span> 团队账号</button><button class="side-link" id="nav-template" onclick="App.openTemplates()"><span>▤</span> 模板资源库</button>
<div class="side-caption between">我的网站 <button title="新建网站" onclick="App.openNewSite()">+</button></div><div id="siteNav" class="site-nav"></div>
<div class="sidebar-bottom"><div class="status-dot"></div><div><strong>旧版管理界面 · 高级功能</strong><small>站点独立静态发布</small></div></div>
</aside>
<div class="shell"><header class="topbar"><div class="breadcrumb"><span>工作空间</span><span class="crumb-sep">/</span><strong id="topTitle">网站总览</strong></div><div class="top-actions"><span class="env-label">● ENTERPRISE EDITION</span><button class="btn light small" onclick="App.openTemplates()">浏览模板</button><button class="btn light small" onclick="App.logout()">退出登录</button><button class="btn primary small" onclick="App.openNewSite()">+ 创建网站</button></div></header>
<main class="main" id="view"><div class="loading">正在初始化 SiteForge...</div></main>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<dialog id="siteDialog" class="dialog"><form id="siteForm" method="dialog"><div class="dialog-head"><div><span class="eyebrow">CREATE WEBSITE</span><h2>创建全新网站</h2><p>选择模板并创建站点,后续可继续导入旧内容或添加新数据。</p></div><button type="button" class="icon-btn" onclick="App.closeDialog('siteDialog')">×</button></div><div class="form-grid"><label>网站名称 <input name="name" required maxlength="150" placeholder="例如:HESU Global"/></label><label>新网站域名 <input name="domain" placeholder="https://example.com(可稍后设置)"/></label><label class="full">网站简介 / 标语 <input name="tagline" placeholder="全球采购 · 本地服务 · 高效交付"/></label><label>建站模式 <select name="mode"><option value="new">新站搭建</option><option value="migration">旧站改版 / 迁移</option><option value="hybrid">混合建站</option></select></label><label>主题风格 <select name="theme" id="siteTheme"><option value="automotive">汽车与出行</option><option value="industrial">工业制造</option><option value="corporate" selected>企业品牌</option><option value="minimal">极简设计</option></select></label><label class="full">基于已保存模板 <select name="preset_id" id="presetSelector"><option value="">不使用自定义模板</option></select></label></div><div class="dialog-actions"><button class="btn light" type="button" onclick="App.closeDialog('siteDialog')">取消</button><button class="btn primary" type="submit">创建并进入工作台 →</button></div></form></dialog>
<dialog id="entryDialog" class="dialog wide"><form id="entryForm" method="dialog"><div class="dialog-head"><div><span class="eyebrow">CONTENT EDITOR</span><h2 id="entryHeading">新增页面 / 内容</h2><p>所有字段均属于当前网站,修改后重新构建即可更新静态文件。</p></div><button type="button" class="icon-btn" onclick="App.closeDialog('entryDialog')">×</button></div><input type="hidden" name="entry_id"><div class="form-grid"><label>内容类型 <select name="type" id="entryType" onchange="App.renderEntryFields()"><option value="page">页面 Page</option><option value="product">产品 Product</option><option value="post">文章 Post</option><option value="service">服务 Service</option><option value="case">案例 Case</option><option value="vehicle">车型 Vehicle</option><option value="brand">品牌 Brand</option><option value="other">自定义内容</option></select></label><label>发布状态 <select name="status"><option value="published">已发布</option><option value="draft">草稿</option></select></label><label class="full">标题 <input name="title" required placeholder="页面或产品名称"/></label><label class="full">URL 路径(不会自动更改既有 URL) <input name="route" placeholder="/product/example/"/><small>建议:/products/example/,若为迁移站必须填写原有路径</small></label><label class="full">摘要 <textarea name="summary" rows="2" placeholder="列表描述、首页标语等"></textarea></label><label class="full">内容 HTML <textarea name="body" rows="6" placeholder="<p>正文内容</p>"></textarea></label><label class="full">封面图片 URL <div class="inline-input"><input id="coverInput" name="cover" placeholder="media/xxx.jpg 或 https://..."/><button type="button" class="btn light small" onclick="document.querySelector('#coverFile').click()">上传图片</button><input id="coverFile" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" hidden onchange="App.uploadCover(this.files[0])"></div></label><label>SEO Title <input name="seo_title" placeholder="留空则使用页面标题"/></label><label>Canonical <input name="canonical" placeholder="默认根据域名与路径生成"/></label><label class="full">Meta Description <textarea name="seo_description" rows="2"></textarea></label><label>Robots <select name="robots"><option value="index,follow">index,follow</option><option value="noindex,follow">noindex,follow</option><option value="noindex,nofollow">noindex,nofollow</option></select></label><label>其他结构化字段 JSON <textarea name="meta" id="entryMeta" rows="4" placeholder='{"blocks": []}'></textarea></label><div id="dynamicContentFields" class="full"></div><div class="full editor-tools"><strong>首页区块快捷添加</strong><button type="button" class="btn light tiny" onclick="App.addBlock('hero')">+ Hero</button><button type="button" class="btn light tiny" onclick="App.addBlock('features')">+ 优势</button><button type="button" class="btn light tiny" onclick="App.addBlock('stats')">+ 数字统计</button><button type="button" class="btn light tiny" onclick="App.addBlock('cta')">+ CTA</button><small>基础区块使用 JSON 配置;进入「可视化页面设计」可进行拖动排序和属性调整。</small></div></div><div class="dialog-actions"><button type="button" class="btn light" onclick="App.closeDialog('entryDialog')">取消</button><button type="submit" class="btn primary">保存内容</button></div></form></dialog>
<a href="/production-v8" style="position:fixed;right:18px;bottom:20px;z-index:9900;background:#1658a6;color:white;padding:13px 18px;border-radius:30px;box-shadow:0 5px 24px #0003;text-decoration:none;font-size:13px">V8 网站生产控制台 →</a></body></html>
+71
View File
@@ -0,0 +1,71 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge Studio · 可视化建站</title><link rel="stylesheet" href="/ui/editor.css"><script defer src="/ui/editor.js"></script></head>
<body>
<div class="studio">
<header class="studio-top">
<div class="brand"><a href="/" title="返回网站管理">◈</a><div><b>SiteForge</b><small>VISUAL STUDIO</small></div></div>
<span class="divide"></span><div class="file-name"><span id="siteName">加载中</span><small> / </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>
<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>
</header>
<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>
<div class="field"><label>页面</label><select id="pageSelect" onchange="Studio.choosePage(this.value)"></select></div>
<div class="field"><label>页面模板</label><select id="pageTemplate" onchange="Studio.changeTemplateKind(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="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>
<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="sidebar-foot">页面由区块驱动,所见预览使用服务端与正式构建相同的数据绑定引擎。</div>
</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>
<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="inspector" id="inspector"><div class="no-selection">在左侧选择一个区块,在这里编辑它的文字、图片、布局及数据来源。</div></div>
<div class="right-foot"><div>动态数据绑定</div><p>支持 <code>{{site.name}}</code>、<code>{{page.title}}</code> 等变量;“动态内容列表”按类型自动查询当前网站的数据。</p></div>
</aside>
</div>
<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">
<h2>全站设计 · 页眉、导航、页脚与响应式</h2>
<p style="color:#64748b">先保存独立草稿;预览只显示在编辑器内。发布后,所有页面的下次静态构建才会使用新设计。</p>
<div id="siteDesignStatus" role="status" style="font-weight:600;margin-bottom:12px">读取中...</div>
<form id="siteDesignForm" method="dialog" style="display:grid;gap:12px">
<fieldset style="display:grid;gap:9px;border:1px solid #e2e8f0;border-radius:8px"><legend>页眉与导航</legend>
<label><input type="checkbox" name="show_announcement"> 显示顶部提示栏</label>
<label>提示栏文字 <input name="announcement" maxlength="140" style="width:100%"></label>
<label><input type="checkbox" name="show_search"> 显示站内搜索</label>
<label><input type="checkbox" name="show_cta"> 显示联系按钮</label>
<label>按钮文字 <input name="cta_text" maxlength="40"></label>
<label>按钮路径 <input name="cta_href" placeholder="/contact/"></label>
<label>导航结构(JSON,每项含 title、route、可选 children)
<textarea name="nav" rows="6" style="display:block;width:100%;font-family:monospace" spellcheck="false"></textarea>
</label>
</fieldset>
<fieldset style="display:grid;gap:9px;border:1px solid #e2e8f0;border-radius:8px"><legend>全站页脚</legend>
<label>企业名称或页脚标题 <input name="footer_title" maxlength="120" style="width:100%"></label>
<label>页脚简介 <textarea name="footer_description" rows="2" maxlength="650" style="display:block;width:100%"></textarea></label>
<label>联系邮箱 <input name="footer_email" type="email" maxlength="150"></label>
<label><input name="footer_show_nav" type="checkbox"> 页脚显示导航链接</label>
</fieldset>
<fieldset style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;border:1px solid #e2e8f0;border-radius:8px"><legend>不同设备布局</legend>
<label>平板板块间距 <input name="tablet_gap" type="number" min="20" max="120"></label>
<label>手机板块间距 <input name="mobile_gap" type="number" min="16" max="100"></label>
<label>手机内容边距 <input name="mobile_container_padding" type="number" min="8" max="48"></label>
<label><input name="mobile_hide_announcement" type="checkbox"> 手机隐藏提示栏</label>
<label><input name="mobile_hide_footer_links" type="checkbox"> 手机隐藏页脚导航</label>
</fieldset>
<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.discardSiteDesign()">放弃全站草稿</button>
<button class="ui-btn ghost" type="button" onclick="Studio.saveSiteDesign()">保存全站草稿</button>
<button class="ui-btn primary" type="button" onclick="Studio.publishSiteDesign()">发布全站设计</button>
<button class="ui-btn ghost" value="cancel">关闭</button>
</div>
</form>
</dialog>
<dialog id="siteMediaModal" style="border:1px solid #dce5f1;border-radius:14px;padding:24px;width:min(720px,94vw);max-height:85vh;overflow:auto">
<h2>本站图片素材库</h2><p style="font-size:13px;color:#66758a">选择图片后更新当前组件;保存草稿、发布设计后才会参与静态构建。</p>
<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>
</dialog>
<div id="toast" class="toast"></div>
</div></body></html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 高级建站中心</title><link rel="stylesheet" href="/ui/factory-v2.css"><script defer src="/ui/factory-v2.js"></script></head>
<body><div class="shell">
<aside class="sidebar"><a class="identity" href="/factory-studio"><span class="mark">SF</span><span><strong>SiteForge</strong><small>高级建站工具</small></span></a>
<div class="side-title">生产中心 / WORKSPACE</div>
<nav id="navtabs"><button data-tab="overview" class="active">◫ &nbsp; 网站总览</button><button data-tab="themes">▧ &nbsp; 主题与版本</button><button data-tab="content">▤ &nbsp; 内容与数据装配</button><button data-tab="migration">⇄ &nbsp; 迁移与 SEO 验收</button><button data-tab="build">◈ &nbsp; 构建与发布</button></nav>
<div class="side-bottom"><a href="/operations">企业发布中心</a> · <a href="/theme-gallery">↗ 完整主题预览中心</a> · <a href="/">返回网站工作台</a><small>Docker · Static Sites · Multi Tenant</small></div></aside>
<div class="work"><header class="top"><div class="top-crumb">SiteForge / <span id="currentTab">网站总览</span></div><div class="top-controls"><label>当前网站 <select id="sitePicker" aria-label="当前网站"></select></label><button class="outline" id="reload">刷新</button><button class="primary" id="newSite">+ 新建网站</button></div></header>
<div class="content"><div id="alert" role="status"></div><div class="heading"><div><div class="eyebrow">MANAGE · ASSEMBLE · DEPLOY</div><h1 id="heading">网站生产工作台</h1><p id="description">将新内容或旧网站数据与主题组合,构建独立可部署的网站。</p></div><div class="head-actions"><a class="outline" id="openEditor" href="#">可视化编辑器 ↗</a><button class="primary" id="buildQuick">生成静态网站</button></div></div>
<section id="panel" aria-live="polite"></section></div>
</div></div>
<dialog id="createDialog"><form id="createForm" method="dialog"><h2>创建网站</h2><p>不需要旧网站,也能直接选择主题、添加内容、构建整套官网。</p><label>网站名称<input name="name" required placeholder="例如 Hesu Global"></label><label>域名<input name="domain" placeholder="https://example.com"></label><label>站点模式<select name="mode"><option value="new">全新网站</option><option value="migration">旧站迁移改版</option><option value="hybrid">混合建站</option></select></label><label>主题<select name="theme" id="newTheme"></select></label><div class="form-actions"><button type="button" class="outline" id="closeCreate">取消</button><button class="primary" type="submit">创建并初始化网站</button></div></form></dialog>
</body></html>
+5
View File
@@ -0,0 +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>
: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>
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}});
</script></body></html>
+10
View File
@@ -0,0 +1,10 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 企业运行中心</title><link rel="stylesheet" href="/ui/operations.css"><script defer src="/ui/operations.js"></script></head><body>
<div class="layout"><aside><a class="wordmark" href="/"><b>◆</b><span><strong>SiteForge</strong><small>高级运行工具</small></span></a><p class="eyebrow">网站生产工厂</p><a href="/">▦ 返回网站工作台</a><a href="/theme-gallery">▤ 主题预览</a><a class="active" href="/operations">⇄ 迁移与交付中心</a><div class="sidefoot">Web + Worker<br>PostgreSQL / Redis 可选部署</div></aside>
<main><header><div><span class="crumb">SiteForge / 企业运行中心</span><h1>迁移、构建与发布</h1><p>同一个界面核对迁移数据,执行静态构建、审批、发布与回滚。</p></div><div class="right"><label>网站项目<select id="site"></select></label><button id="reload" class="secondary">刷新</button></div></header>
<div id="notice" role="status"></div><div class="metrics"><div><span>站点构建</span><strong id="statBuild">—</strong></div><div><span>待处理任务</span><strong id="statJobs">—</strong></div><div><span>审批请求</span><strong id="statApprovals">—</strong></div><div><span>迁移模式</span><strong id="statMode">—</strong></div></div>
<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>
<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>
<footer>企业运行与迁移中心 · 第三方 CMS 复杂插件、PostgreSQL/Redis 和公网部署应分别完成真实环境验收。</footer>
</main></div></body></html>
+161
View File
@@ -0,0 +1,161 @@
<!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,"Microsoft YaHei",system-ui,sans-serif;color:#202a39;background:#f4f7fb}*{box-sizing:border-box}body{margin:0}header{display:flex;align-items:center;justify-content:space-between;background:#101d31;color:white;padding:18px 28px;gap:16px}header h1{font-size:18px;margin:0}header small{color:#a4b9d4}main{max-width:1320px;margin:24px auto;padding:0 20px}.pill{border-radius:20px;background:#eaf2ff;color:#164a8d;font-size:12px;padding:5px 10px}.muted{color:#6d7685;font-size:13px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.nav{display:flex;gap:10px}.nav a{color:#d8e8f9;font-size:13px;text-decoration:none}.kpi{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0}.kpi article,.panel{background:white;border:1px solid #e1e8f1;border-radius:12px;padding:20px;box-shadow:0 2px 12px #10203806}.kpi strong{display:block;font-size:25px;margin-top:8px}.layout{display:grid;grid-template-columns:1fr 1fr;gap:18px}.panel h2{font-size:17px;margin:0 0 6px}.panel h3{font-size:14px;margin:16px 0 8px}label{display:block;font-size:12px;color:#526075;font-weight:600;margin-top:13px;margin-bottom:5px}input,select,textarea{width:100%;padding:10px 11px;font:inherit;font-size:13px;border:1px solid #cbd6e6;border-radius:7px;background:white}textarea{min-height:115px;resize:vertical}button{cursor:pointer;border:1px solid #c5d2e4;border-radius:8px;background:white;color:#213249;font-weight:600;font-size:13px;padding:10px 14px}button:hover{border-color:#2274bd}button.primary{background:#1658a6;color:#fff;border-color:#1658a6}button.danger{color:#b22d2d}button:disabled{opacity:.45;cursor:not-allowed}hr{border:0;border-top:1px solid #e8edf5;margin:20px 0}.message{white-space:pre-wrap;font-size:12px;line-height:1.55;overflow:auto;max-height:270px;background:#f5f8fc;padding:12px;border-radius:8px}.results{max-height:270px;overflow:auto}.results>div{padding:10px;border-bottom:1px solid #eef1f5;font-size:13px;display:flex;gap:10px;justify-content:space-between}.foot{margin:20px 0;color:#6f7b8b;font-size:12px}.ok{color:#176d45}.err{color:#c0392b}@media(max-width:850px){.layout,.kpi{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column}main{margin:12px auto}}
[hidden]{display:none!important}.tool-switch{display:flex;flex-wrap:wrap;gap:9px;background:#fff;padding:12px;margin:18px 0 0;border-radius:12px;border:1px solid #e1e8f1}.tool-btn{background:#f6f8fc;color:#50627c;border-color:transparent;padding:11px 14px}.tool-btn.active{background:#e8f0ff;color:#174ea7;border:1px solid #a5c5f5}.tool-help{font-size:12px;color:#64748b;margin:12px 5px 16px}.advanced-layout{grid-template-columns:minmax(0,1fr)}.advanced-layout>.panel{width:100%}.nav a:first-child{border:1px solid #7793bd;padding:9px 11px;border-radius:7px;background:#fff1}@media(max-width:720px){.tool-switch{gap:5px}.tool-btn{padding:9px 11px}}
</style></head><body>
<header><div><h1>SiteForge · 网站生产控制台 <span style="font-size:11px;color:#acd5ff">V8.10 · 高级工具</span></h1><small>整站模板 · 数据批次导入 · 可审计回滚 · 静态构建</small></div><div class="nav"><a href="/">返回网站工作台</a><a href="/factory-studio">页面编辑器</a><a href="/theme-gallery">主题中心</a><a href="/operations">任务中心</a></div></header>
<main>
<div class="panel"><div class="row" style="justify-content:space-between"><div><h2>选择要操作的网站</h2><div class="muted">所有批次、模板和构建记录均按网站隔离;新网站可先在网站工作台创建。</div></div><select id="site" style="max-width:410px" aria-label="选择网站"></select></div></div>
<section class="kpi"><article><span class="muted">当前网站</span><strong id="siteName">—</strong></article><article><span class="muted">内容记录</span><strong id="entryCount">—</strong></article><article><span class="muted">导入批次</span><strong id="batchCount">—</strong></article></section>
<div class="tool-switch" role="tablist" aria-label="高级工具分类">
<button type="button" class="tool-btn active" data-tool="batch" role="tab" aria-selected="true">① 批次导入</button>
<button type="button" class="tool-btn" data-tool="templates" role="tab" aria-selected="false">② 整站模板</button>
<button type="button" class="tool-btn" data-tool="wp" role="tab" aria-selected="false">③ WordPress 迁移</button>
<button type="button" class="tool-btn" data-tool="api" role="tab" aria-selected="false">④ XML / API</button>
<button type="button" class="tool-btn" data-tool="mysql" role="tab" aria-selected="false">⑤ MySQL 数据源</button>
</div><p class="tool-help" id="toolHelp" role="status">先核对字段与 URL 冲突,再将数据正式写入网站。</p>
<section class="layout advanced-layout">
<div class="panel" id="tool-batch"><h2>批次数据导入</h2><div class="muted">先预览字段与 URL 冲突,确认后再提交;支持按批次回滚且保护导入后的人工作业。</div>
<label for="source">数据格式</label><select id="source"><option value="json">JSON 行数组</option><option value="wxr">WordPress WXR XML 文件</option><option value="table">Excel / CSV / JSON 文件</option></select>
<label for="kind">内容类型</label><select id="kind"><option value="product">产品</option><option value="vehicle">车型</option><option value="page">普通页面</option><option value="post">新闻文章</option><option value="case">案例</option><option value="brand">品牌</option><option value="service">服务</option></select>
<div id="jsonInputs"><label for="records">JSON 数据</label><textarea id="records" spellcheck="false">[{"车型名称":"Example Vehicle A","品牌":"Example Brand","动力形式":"EV"}]</textarea>
<div class="muted">第一行字段自动建议映射。中文字段可对应标题、品牌、分类、参数等。</div></div>
<div id="tableInputs" style="display:none"><label for="tableFile">上传 XLSX / CSV / JSON</label><input type="file" id="tableFile" accept=".xlsx,.csv,.json"></div><div id="wxrInputs" style="display:none"><label for="wxrFile">上传 WordPress 全量 WXR XML</label><input type="file" id="wxrFile" accept=".xml"></div>
<div class="row" style="margin-top:16px"><button class="primary" id="stage">1. 校验并暂存</button><button id="refreshBatch">刷新批次</button></div><pre class="message" id="importResult">请选择网站后导入。</pre>
<h3>已暂存/已导入批次</h3><div class="results" id="batches"></div>
</div>
<div class="panel" id="tool-templates" hidden><h2>整站模板生产线</h2><div class="muted">保存当前网站的页面区块、导航与设计令牌。模板不复制产品、文章、域名、Logo、SEO 或用户密钥。</div>
<label for="snapshotName">新模板名称</label><input id="snapshotName" value="企业官网整站模板"><button id="saveTemplate" style="margin-top:10px" class="primary">保存当前网站的模板快照</button>
<hr><label for="sourceSite">模板来源网站</label><select id="sourceSite"></select><label for="templateId">模板版本</label><select id="templateId"></select>
<div class="row" style="margin-top:14px"><button id="preflight">2. 兼容性预检</button><button id="apply" class="primary">3. 暂存整站模板</button><button id="publishTemplate" class="primary">4. Owner 发布模板</button></div>
<pre class="message" id="templateResult">先保存模板,再在另一网站上预检并应用。若存在同路径页面,需显式授权替换布局。</pre>
<hr><h3>静态网站生产</h3><div class="muted">使用现有独立 Worker 构建队列,提交后到任务中心检查超时、重试和取消。</div>
<div class="row" style="margin-top:12px"><button class="primary" id="build">提交静态构建任务</button><a href="/operations">查看任务进度 →</a></div>
<pre class="message" id="buildResult">尚未提交</pre>
</div>
</section>
<section class="panel" id="tool-wp" style="margin-top:18px" aria-labelledby="wpTitle" hidden>
<h2 id="wpTitle">WordPress 全站迁移工作台</h2>
<div class="muted">适用于你有权迁移的公开 WordPress 网站。每次安全读取一页(最多 50 条),保留进度,所有内容先暂存,确认后才生成原站 URL/SEO 基线。旧站复杂插件与页面效果仍须单独复核。</div>
<div class="row" style="align-items:flex-end;margin-top:12px">
<div style="flex:2;min-width:250px"><label for="wpOrigin">原 WordPress 网站地址</label><input id="wpOrigin" placeholder="https://old.example.com/"></div>
<div style="flex:1;min-width:140px"><label for="wpLimit">最多采集记录</label><input id="wpLimit" type="number" min="1" max="20000" value="2000"></div>
<button id="wpCreate" class="primary">新建迁移会话</button>
</div>
<div class="row" style="margin-top:10px">
<select aria-label="WordPress 迁移会话" id="wpSession" style="min-width:240px;max-width:430px"></select>
<button id="wpNext">继续采集一页</button><button id="wpWorker" class="primary">提交 Worker 后台采集</button><button id="wpSitemap">发现 Sitemap</button><button id="wpMedia">本地化图片(20 张)</button><select id="wpCatalogType" aria-label="同步 WordPress 目录"><option value="categories">分类</option><option value="tags">标签</option><option value="media">媒体库</option></select><button id="wpCatalogSync">同步目录下一页</button><button id="wpCatalogStatus">目录进度</button><button id="wpAuto">连续采集</button><button id="wpStop">停止连续采集</button>
<button id="wpFinalize" class="primary">确认并导入全部暂存</button>
<button id="wpCancel" class="danger">取消迁移</button>
<button id="wpReload">刷新状态</button><button id="wpAudit">对比最新构建的 URL/SEO</button>
</div>
<pre id="wpResult" class="message">选择站点并新建迁移会话。每页请求独立保存检查点;采集完成后可明确审核 URL、SEO、缺失内容。</pre>
</section>
<section class="panel" id="tool-api" style="margin-top:18px" aria-labelledby="p3Title" hidden>
<h2 id="p3Title">P3 · XML / HTTP API 数据连接器</h2>
<div class="muted">XML 仅支持扁平或两层记录,拒绝外部实体;HTTP API 支持公开或密钥引用认证的 HTTPS JSON。全部数据先进入暂存批次,需要单独审核并提交;API 采集在 Worker 后台执行。</div>
<div class="layout" style="margin-top:12px">
<div>
<h3>XML 导入</h3>
<label for="xmlFile">XML 文件(最大 8 MB)</label><input id="xmlFile" type="file" accept=".xml,text/xml,application/xml">
<label for="xmlRecord">记录节点标签(如 product / item)</label><input id="xmlRecord" value="item">
<label for="xmlMapping">字段映射 JSON(可为空自动识别)</label><textarea id="xmlMapping" spellcheck="false" rows="3">{}</textarea>
<button class="primary" id="xmlStage">校验并暂存 XML</button>
</div>
<div>
<h3>JSON API(公开或认证)</h3>
<label for="apiUrl">HTTPS 数据接口</label><input id="apiUrl" placeholder="https://api.example.com/v1/products">
<label for="apiAuth">认证方式</label><select id="apiAuth"><option value="none">公开 API</option><option value="bearer">Bearer Token</option><option value="api_key">X-API-Key</option><option value="custom">自定义 X- 请求头</option></select>
<label for="apiSecretRef">密钥引用(仅 Worker 读取,不填写密钥本身)</label><input id="apiSecretRef" placeholder="file:partner_api_token 或 env:SITEFORGE_API_TOKEN">
<label for="apiAuthHeader">自定义请求头(仅 custom)</label><input id="apiAuthHeader" value="X-Partner-Key">
<label for="apiItems">列表 JSON 路径</label><input id="apiItems" value="data.items">
<div class="row"><div style="flex:1"><label for="apiPageSize">每页数量</label><input id="apiPageSize" type="number" value="50" min="1" max="100"></div><div style="flex:1"><label for="apiMaxPages">最多页数</label><input id="apiMaxPages" type="number" value="10" min="1" max="50"></div></div>
<label for="apiMapping">字段映射 JSON(可为空自动识别)</label><textarea id="apiMapping" spellcheck="false" rows="3">{}</textarea>
<div class="row"><button class="primary" id="apiCreate">创建 API 数据源</button><button id="apiReload">刷新采集记录</button></div>
<label for="apiSource">已保存的数据源</label><select id="apiSource" aria-label="API 数据源"></select>
<div class="row"><button class="primary" id="apiRun">提交 Worker 采集</button><button id="apiStatus">查看采集与批次</button><button class="danger" id="apiCancel">取消采集</button></div>
</div>
</div>
<pre class="message" id="p3Result">创建数据源、提交 Worker 后到“任务中心”查看执行;采集完后到上方“已暂存批次”确认导入。</pre>
</section>
<section class="panel" id="tool-mysql" style="margin-top:18px" aria-labelledby="mysqlTitle" hidden>
<h2 id="mysqlTitle">P3 · 只读 MySQL 连接器</h2>
<div class="muted">需要管理员预先在服务器环境配置允许的主机和数据库表,并建立数据库侧仅 SELECT 的账号。强制 TLS 证书校验,拒绝任意 SQL;数据分批暂存,不自动发布。</div>
<div class="layout">
<div><label for="myHost">管理员批准的 MySQL 主机</label><input id="myHost" placeholder="db.internal.example">
<label for="myDatabase">数据库名</label><input id="myDatabase" placeholder="catalog">
<label for="myTable">表或视图</label><input id="myTable" placeholder="products_public">
<label for="myKey">有唯一索引的游标列</label><input id="myKey" value="id">
<label for="myColumns">允许读取的列(英文逗号分隔)</label><input id="myColumns" placeholder="id,name,brand,category">
</div>
<div><label for="myUserRef">MySQL 只读账号密钥引用</label><input id="myUserRef" placeholder="file:mysql_reader_user">
<label for="myPassRef">MySQL 密码密钥引用</label><input id="myPassRef" placeholder="file:mysql_reader_password">
<label for="myMapping">字段映射 JSON</label><textarea id="myMapping" rows="2">{"title":"name","brand":"brand"}</textarea>
<div class="row"><div style="flex:1"><label for="myPageSize">单页</label><input id="myPageSize" type="number" value="100" min="1" max="250"></div><div style="flex:1"><label for="myMaxPages">最多页数</label><input id="myMaxPages" type="number" value="10" min="1" max="50"></div></div>
<div class="row"><button class="primary" id="myCreate">创建 MySQL 数据源</button><button id="myReload">刷新</button></div>
<label for="mySource">已保存的数据源</label><select id="mySource" aria-label="MySQL 数据源"></select>
<div class="row"><button class="primary" id="myRun">提交后台采集</button><button id="myStatus">检查批次</button><button class="danger" id="myCancel">取消</button></div>
</div>
</div>
<pre class="message" id="myResult">配置源数据后交由 Worker 执行;使用最小权限账号、TLS CA 证书和服务器侧白名单。</pre>
</section>
<p class="foot">提示:V8 当前是分阶段开发版本。远程正式发布、Art Design Pro 官方工程编译、复杂 WordPress 组件无损迁移仍需专项验收。</p>
</main><script>
'use strict';const $=id=>document.getElementById(id);let sites=[],csrf='',templates=[];
const toolNames=['batch','templates','wp','api','mysql'];
const toolHelp={batch:'先核对字段与 URL 冲突,再将数据正式写入网站。',templates:'保存整站布局 → 预检 → 暂存 → Owner 确认应用。',wp:'逐页采集旧站、同步图片与分类;确认导入后进行 SEO 验收。',api:'先建立 XML 或 HTTPS API 数据源,执行后在“批次导入”中审核。',mysql:'此处面向管理员:只读账号、TLS 和数据源白名单必须事先配置。'};
function changeTool(key){if(!toolNames.includes(key))return;for(const k of toolNames){const el=$('tool-'+k);el.hidden=k!==key;}for(const btn of document.querySelectorAll('[data-tool]')){let yes=btn.dataset.tool===key;btn.classList.toggle('active',yes);btn.setAttribute('aria-selected',yes?'true':'false');btn.tabIndex=yes?0:-1;}$('toolHelp').textContent=toolHelp[key]||'';window.scrollTo({top:0,behavior:'instant'})}
document.querySelectorAll('[data-tool]').forEach(btn=>btn.addEventListener('click',()=>changeTool(btn.dataset.tool)));
async function api(path,method='GET',body){const opts={method,credentials:'same-origin',headers:{'Accept':'application/json'}};if(method!=='GET')opts.headers['X-SiteForge-CSRF']=csrf;if(body instanceof FormData)opts.body=body;else if(body!==undefined){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}const r=await fetch(path,opts);let data;try{data=await r.json()}catch{data={detail:'服务器返回非 JSON'}}if(!r.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail||data));return data}
function show(id,obj){$(id).textContent=typeof obj==='string'?obj:JSON.stringify(obj,null,2)}
function current(){return $('site').value}function opt(value,text){const el=document.createElement('option');el.value=value;el.textContent=text;return el}
async function load(){try{const me=await api('/api/auth/me');csrf=me.csrf;sites=await api('/api/sites');$('site').replaceChildren(...sites.map(s=>opt(s.id,s.name)));$('sourceSite').replaceChildren(...sites.map(s=>opt(s.id,s.name)));await refresh();}catch(e){alert('需要先登录或加载失败:'+e.message);location.href='/login'}}
async function refresh(){const s=sites.find(x=>x.id===current());if(!s)return;$('siteName').textContent=s.name;$('entryCount').textContent=s.entries_count;await loadBatches();await loadTemplates();await loadWpSessions();await loadApiSources();await loadMySources()}
async function loadBatches(){try{const data=await api(`/api/factory/sites/${current()}/staging`);$('batchCount').textContent=data.items.length;const box=$('batches');box.replaceChildren();for(const b of data.items){const div=document.createElement('div');const title=document.createElement('span');title.textContent=`${b.origin} · ${b.state} · ${b.input_count} 条`;div.append(title);const inspect=document.createElement('button');inspect.textContent='关系检查';inspect.onclick=async()=>{try{show('importResult',await api(`/api/connectors/sites/${current()}/import-batches/${b.id}/relationships`))}catch(e){show('importResult','关系检查失败:'+e.message)}};div.append(inspect);if(b.state==='staged'){const c=document.createElement('button');c.textContent='提交';c.onclick=()=>processBatch(b.id,'commit');div.append(c)}if(['committed','partial'].includes(b.state)){const c=document.createElement('button');c.textContent='回滚';c.className='danger';c.onclick=()=>processBatch(b.id,'rollback');div.append(c)}box.append(div)}}catch(e){show('importResult',e.message)}}
async function processBatch(id,action){let payload={};if(action==='commit'){const c=confirm('确认提交本批数据?遇到已存在的路径,默认跳过,不覆盖。');if(!c)return;payload={on_conflict:'skip'}}else if(!confirm('确认回滚本批导入?导入后被人工修改的内容将被保护,不能强制覆盖。'))return;try{show('importResult',await api(`/api/factory/sites/${current()}/staging/${id}/${action}`,'POST',payload));await loadBatches()}catch(e){show('importResult','错误:'+e.message)}}
$('source').onchange=()=>{$('jsonInputs').style.display=$('source').value==='json'?'block':'none';$('wxrInputs').style.display=$('source').value==='wxr'?'block':'none';$('tableInputs').style.display=$('source').value==='table'?'block':'none'};
$('site').onchange=refresh;$('sourceSite').onchange=loadTemplates;
$('stage').onclick=async()=>{try{let data;if($('source').value==='table'){const form=new FormData();if(!$('tableFile').files[0])throw Error('请选择 Excel/CSV/JSON 文件');form.append('file',$('tableFile').files[0]);form.append('content_type',$('kind').value);data=await api(`/api/factory/sites/${current()}/staging/file`,'POST',form)}else if($('source').value==='wxr'){const form=new FormData();if(!$('wxrFile').files[0])throw Error('请选择 WXR XML');form.append('file',$('wxrFile').files[0]);data=await api(`/api/factory/sites/${current()}/staging/wxr`,'POST',form)}else{const rows=JSON.parse($('records').value);data=await api(`/api/factory/sites/${current()}/staging`,'POST',{rows,content_type:$('kind').value})}show('importResult',data);await loadBatches()}catch(e){show('importResult','错误:'+e.message)}};
$('refreshBatch').onclick=loadBatches;
$('saveTemplate').onclick=async()=>{try{show('templateResult',await api(`/api/factory/sites/${current()}/whole-site-templates`,'POST',{name:$('snapshotName').value}));await loadTemplates()}catch(e){show('templateResult','错误:'+e.message)}};
async function loadTemplates(){const source=$('sourceSite').value;if(!source)return;try{templates=await api(`/api/factory/sites/${source}/whole-site-templates`);$('templateId').replaceChildren(...templates.map(t=>opt(t.id,`${t.name} · ${t.version}`)))}catch(e){show('templateResult',e.message)}}
async function applyTemplate(confirmApply){try{const payload={source_site_id:$('sourceSite').value,template_id:$('templateId').value,confirm:confirmApply,overwrite_layouts:confirmApply};if(!payload.template_id)throw Error('请先保存或选择整站模板');const document=await api(`/api/factory/sites/${payload.source_site_id}/whole-site-templates/${payload.template_id}`);const report=await api(`/api/factory/sites/${current()}/whole-site-templates/preflight`,'POST',{template:document});if(!report.compatible||!confirmApply){show('templateResult',report);return}if(!confirm('兼容性通过。应用模板将替换同路径页面布局,但保留内容及 SEO。确认?'))return;const draft=await api(`/api/factory/sites/${current()}/whole-site-templates/staged/current`);payload.expected_revision=draft.revision||0;show('templateResult',await api(`/api/factory/sites/${current()}/whole-site-templates/apply`,'POST',payload))}catch(e){show('templateResult','错误:'+e.message)}}
$('preflight').onclick=()=>applyTemplate(false);$('apply').onclick=()=>applyTemplate(true);$('publishTemplate').onclick=async()=>{try{const s=await api(`/api/factory/sites/${current()}/whole-site-templates/staged/current`);if(s.state!=='draft')throw Error('没有待发布的整站模板');if(s.conflict)throw Error('网站已经变化,请重新预检并暂存');if(!confirm('将由 Owner 正式应用整站布局,并保留原网站业务内容与 SEO。继续?'))return;show('templateResult',await api(`/api/factory/sites/${current()}/whole-site-templates/staged/publish`,'POST',{expected_revision:s.revision}))}catch(e){show('templateResult','错误:'+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 wpAction(action){const sid=$('wpSession').value;if(!sid)throw Error('请先创建迁移会话');return api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/${action}`,'POST',{})}
$('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;
$('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}};
$('wpStop').onclick=()=>{wpAutoRunning=false};
$('wpWorker').onclick=async()=>{try{const sid=$('wpSession').value;show('wpResult',await api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/scan`,'POST',{}));await loadWpSessions()}catch(e){show('wpResult','提交失败:'+e.message)}};
$('wpSitemap').onclick=async()=>{try{const sid=$('wpSession').value;show('wpResult',await api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/sitemap`,'POST',{}));}catch(e){show('wpResult','Sitemap 失败:'+e.message)}};
$('wpMedia').onclick=async()=>{try{const sid=$('wpSession').value;show('wpResult',await api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/media/localize`,'POST',{limit:20}));}catch(e){show('wpResult','媒体迁移失败:'+e.message)}};
$('wpCatalogSync').onclick=async()=>{try{const sid=$('wpSession').value;const resource=$('wpCatalogType').value;show('wpResult',await api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/catalog/${resource}/next`,'POST',{}));}catch(e){show('wpResult','目录同步失败:'+e.message)}};
$('wpCatalogStatus').onclick=async()=>{try{show('wpResult',await api(`/api/migrations/sites/${current()}/wordpress/sessions/${$('wpSession').value}/catalog`));}catch(e){show('wpResult','读取目录失败:'+e.message)}};
$('wpNext').onclick=async()=>{try{show('wpResult',await wpAction('advance'));await loadWpSessions()}catch(e){show('wpResult','错误:'+e.message)}};
$('wpFinalize').onclick=async()=>{if(!confirm('采集完成后,确认将全部暂存内容正式导入并登记原 URL/SEO 基线?'))return;try{show('wpResult',await wpAction('finalize'));await loadWpSessions();await loadBatches()}catch(e){show('wpResult','错误:'+e.message)}};
$('wpCancel').onclick=async()=>{if(!confirm('取消此迁移会话?已经暂存的批次不会自动删除。'))return;try{show('wpResult',await wpAction('cancel'));await loadWpSessions()}catch(e){show('wpResult','错误:'+e.message)}};
$('wpReload').onclick=loadWpSessions;
$('wpAudit').onclick=async()=>{try{const builds=await api(`/api/sites/${current()}/builds`);const latest=builds.find(b=>b.status==='complete'||b.status==='done'||b.status==='success'||b.status==='completed');if(!latest)throw Error('尚未找到已完成的静态构建版本');const result=await api(`/api/audit/sites/${current()}/builds/${latest.id}/report`);show('wpResult',{build_id:latest.id,...result})}catch(e){show('wpResult','SEO 验收错误:'+e.message)}};
$('wpSession').onchange=loadWpSessions;
async function loadApiSources(){if(!current())return;try{const res=await api(`/api/connectors/sites/${current()}/api-sources`);const el=$('apiSource'),was=el.value;el.replaceChildren(...res.items.map(s=>opt(s.id,`${s.url} · ${s.state} · ${s.staged_count} 条`)));if(res.items.some(x=>x.id===was))el.value=was;}catch(e){show('p3Result','查询 API 数据源失败:'+e.message)}}
$('xmlStage').onclick=async()=>{try{if(!$('xmlFile').files[0])throw Error('请选择 XML 文件');const form=new FormData();form.append('file',$('xmlFile').files[0]);form.append('record_tag',$('xmlRecord').value);form.append('content_type',$('kind').value);form.append('mapping_json',$('xmlMapping').value);show('p3Result',await api(`/api/connectors/sites/${current()}/xml/staging`,'POST',form));await loadBatches()}catch(e){show('p3Result','XML 导入失败:'+e.message)}};
$('apiCreate').onclick=async()=>{try{const result=await api(`/api/connectors/sites/${current()}/api-sources`,'POST',{url:$('apiUrl').value,items_path:$('apiItems').value,content_type:$('kind').value,page_size:Number($('apiPageSize').value),max_pages:Number($('apiMaxPages').value),mapping:JSON.parse($('apiMapping').value||'{}'),auth_type:$('apiAuth').value,secret_ref:$('apiAuth').value==='none'?'':$('apiSecretRef').value,auth_header:$('apiAuthHeader').value});await loadApiSources();$('apiSource').value=result.id;show('p3Result',result)}catch(e){show('p3Result','创建失败:'+e.message)}};
$('apiReload').onclick=loadApiSources;
$('apiRun').onclick=async()=>{try{if(!$('apiSource').value)throw Error('请先选择数据源');show('p3Result',await api(`/api/connectors/sites/${current()}/api-sources/${$('apiSource').value}/scan`,'POST',{}));await loadApiSources()}catch(e){show('p3Result','提交失败:'+e.message)}};
$('apiStatus').onclick=async()=>{try{show('p3Result',await api(`/api/connectors/sites/${current()}/api-sources/${$('apiSource').value}`));await loadBatches()}catch(e){show('p3Result','查询失败:'+e.message)}};
$('apiCancel').onclick=async()=>{if(!confirm('取消当前 API 数据源的后台采集?已暂存的批次不自动删除。'))return;try{show('p3Result',await api(`/api/connectors/sites/${current()}/api-sources/${$('apiSource').value}/cancel`,'POST',{}));await loadApiSources()}catch(e){show('p3Result','取消失败:'+e.message)}};
async function loadMySources(){if(!current())return;try{const data=await api(`/api/connectors/sites/${current()}/mysql-sources`);const el=$('mySource'),was=el.value;el.replaceChildren(...data.items.map(s=>opt(s.id,`${s.db_name}.${s.table_name} · ${s.state} · ${s.staged_count} 条`)));if(data.items.some(s=>s.id===was))el.value=was}catch(e){show('myResult','读取 MySQL 数据源失败:'+e.message)}}
$('myCreate').onclick=async()=>{try{const result=await api(`/api/connectors/sites/${current()}/mysql-sources`,'POST',{host:$('myHost').value,database:$('myDatabase').value,table:$('myTable').value,key_column:$('myKey').value,columns:$('myColumns').value.split(',').map(s=>s.trim()).filter(Boolean),user_ref:$('myUserRef').value,password_ref:$('myPassRef').value,page_size:Number($('myPageSize').value),max_pages:Number($('myMaxPages').value),content_type:$('kind').value,mapping:JSON.parse($('myMapping').value||'{}')});await loadMySources();$('mySource').value=result.id;show('myResult',result)}catch(e){show('myResult','创建失败:'+e.message)}};
$('myReload').onclick=loadMySources;
$('myRun').onclick=async()=>{try{show('myResult',await api(`/api/connectors/sites/${current()}/mysql-sources/${$('mySource').value}/scan`,'POST',{}))}catch(e){show('myResult','提交失败:'+e.message)}};
$('myStatus').onclick=async()=>{try{show('myResult',await api(`/api/connectors/sites/${current()}/mysql-sources/${$('mySource').value}`));await loadBatches()}catch(e){show('myResult','查询失败:'+e.message)}};
$('myCancel').onclick=async()=>{if(!confirm('取消 MySQL 后台采集?已暂存的批次保留。'))return;try{show('myResult',await api(`/api/connectors/sites/${current()}/mysql-sources/${$('mySource').value}/cancel`,'POST',{}));await loadMySources()}catch(e){show('myResult','取消失败:'+e.message)}};
load();
</script></body></html>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 完整主题预览中心</title>
<style>*{box-sizing:border-box}body{margin:0;background:#eef1f5;font:14px/1.6 system-ui,'Microsoft YaHei',sans-serif;color:#172c42}header{display:flex;align-items:center;justify-content:space-between;padding:14px 26px;background:#fff;border-bottom:1px solid #dce3eb;gap:24px;flex-wrap:wrap}header strong{font-size:17px}header strong span{background:#15253e;color:#fff;border-radius:6px;padding:6px 8px;margin-right:8px}header a{color:#255b99;text-decoration:none}.controls{display:flex;align-items:end;flex-wrap:wrap;gap:12px;padding:18px 26px;border-bottom:1px solid #dce3eb;background:white}.controls label{font-weight:700;color:#5c7083;font-size:11px;display:grid;gap:6px}.controls select{border:1px solid #ccd5df;background:#fff;color:#192d41;padding:10px 14px;border-radius:7px;min-width:132px;font-size:13px}.devices{display:flex;gap:4px;margin-left:auto;background:#f0f3f8;border-radius:8px;padding:4px}.devices button{border:0;background:none;padding:9px 14px;border-radius:6px;cursor:pointer;color:#52697e}.devices button.active{background:#1e446c;color:white}.stage{padding:22px 18px 80px;display:flex;justify-content:center;overflow:auto}.frame{width:100%;height:calc(100vh - 210px);min-height:520px;background:white;box-shadow:0 16px 50px #12304720;border-radius:10px;overflow:hidden;transition:width .25s}.frame iframe{border:0;width:100%;height:100%;background:white}.help{text-align:center;color:#6d8296;font-size:12px;margin:-55px auto 28px}.error{color:#c14f45;display:none}@media(max-width:700px){header,.controls{padding:12px}header strong{font-size:14px}.devices{margin-left:0}.frame{min-height:450px}}</style></head>
<body><header><strong><span>SF</span> SiteForge · 主题预览中心</strong><a href="/factory-studio">返回网站工厂 →</a></header>
<section class="controls"><label>主题<select id="theme"><option value="automotive">Automotive · 汽车与出行</option><option value="industrial">Industrial · 工业制造</option><option value="corporate">Corporate · 企业品牌</option><option value="minimal">Minimal · 极简设计</option></select></label>
<label>页面类型<select id="page"><option value="home">首页</option><option value="about">关于我们</option><option value="listing">产品 / 分类列表</option><option value="category">分类详情</option><option value="product">产品详情</option><option value="brand">品牌详情</option><option value="article">新闻详情</option><option value="case">案例详情</option><option value="service">服务详情</option><option value="contact">联系我们</option><option value="faq">常见问题</option><option value="standard">标准页面</option><option value="notfound">404</option></select></label>
<label>主题版本<select id="version"><option value="">最新</option></select></label><div class="devices"><button data-size="100%" class="active">PC</button><button data-size="820px">平板</button><button data-size="390px">手机</button></div><span class="error" id="error">预览失败,请确认已登录。</span></section>
<div class="stage"><div class="frame" id="frame"><iframe id="preview" title="主题真实页面预览" loading="eager"></iframe></div></div>
<div class="help">预览使用真实页面模板与演示数据。实际网站构建后将使用你的品牌信息和内容。</div>
<script>
const theme=document.querySelector('#theme'),page=document.querySelector('#page'),version=document.querySelector('#version'),frame=document.querySelector('#frame'),iframe=document.querySelector('#preview');
function show(){iframe.src=`/api/factory/themes/${encodeURIComponent(theme.value)}/preview?page_type=${encodeURIComponent(page.value)}&version=${encodeURIComponent(version.value)}`;}
async function loadVersions(){const response=await fetch(`/api/factory/themes/${encodeURIComponent(theme.value)}/versions`,{credentials:'same-origin'});if(!response.ok)throw Error('无法读取主题版本');const arr=await response.json();version.replaceChildren();arr.forEach(x=>{const op=new Option('v'+x.version,x.version);version.append(op);});show();}
theme.addEventListener('change',()=>loadVersions().catch(()=>document.querySelector('#error').style.display='inline'));
[page,version].forEach(x=>x.addEventListener('change',show));document.querySelectorAll('.devices button').forEach(x=>x.addEventListener('click',()=>{document.querySelectorAll('.devices button').forEach(y=>y.classList.remove('active'));x.classList.add('active');frame.style.width=x.dataset.size;}));loadVersions().catch(()=>document.querySelector('#error').style.display='inline');
</script></body></html>
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SiteForge · 网站工作台</title><meta name="description" content="统一管理网站、内容、设计、迁移和构建。">
<link rel="icon" href="/favicon.ico"><link rel="stylesheet" href="/ui/workspace.css"><script defer src="/ui/workspace.js"></script></head>
<body>
<div class="app-shell">
<aside class="nav-side" id="sideMenu">
<a href="/" class="brand" aria-label="SiteForge 工作台"><span class="brand-icon">S</span><span><strong>SiteForge</strong><small>网站生产工厂</small></span></a>
<div class="nav-group"><div class="nav-label">工作空间</div>
<button data-view="home" class="nav-item active"><span class="nav-ico">▦</span><span>工作台</span></button>
<button data-view="sites" class="nav-item"><span class="nav-ico">▤</span><span>我的网站</span></button>
</div>
<div class="nav-group" id="websiteGroup"><div class="nav-label">当前网站</div><div id="sideSiteName" class="site-mini">请先选择网站</div>
<button data-view="overview" class="nav-item"><span class="nav-ico">◎</span><span>网站概览</span></button>
<button data-view="content" 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="build" 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>
<details class="advanced"><summary><span class="nav-ico">⌘</span>高级工具 <span class="chevron">⌄</span></summary>
<a href="/production-v8" class="nav-sub">数据导入与模板管理</a>
<a href="/operations" class="nav-sub">迁移复核与发布审批</a>
<a href="/theme-gallery" class="nav-sub">完整主题预览</a>
<a href="/factory-studio" class="nav-sub">高级网站设计</a>
<a href="/classic" class="nav-sub">旧版管理功能</a>
</details>
<div class="side-foot"><span class="side-indicator"></span><span><strong>系统运行中</strong><small id="appVersion">读取版本…</small></span></div>
</aside>
<div class="content-shell">
<header class="topbar">
<div class="top-left"><button class="hamburger" id="menuToggle" aria-label="展开菜单" aria-expanded="false">☰</button><div class="breadcrumbs"><span>工作空间</span><span class="crumb-divider">/</span><strong id="breadcrumbTitle">工作台</strong></div></div>
<div class="top-right"><label class="site-picker-wrap"><span>正在管理</span><select id="sitePicker" aria-label="选择当前网站"><option value="">请选择网站</option></select></label><button id="newSiteTop" class="btn btn-primary">+ 创建网站</button><button class="logout" id="logoutBtn" title="退出登录" aria-label="退出登录">↪</button></div>
</header>
<main class="main-content" id="mainContent" tabindex="-1"><div class="skeleton">正在读取工作空间…</div></main>
<footer class="app-footer"><span>SiteForge · 让网站制作有清晰的下一步</span><span>正式发布前请完成内容与 SEO 检查</span></footer>
</div>
</div>
<div class="mobile-backdrop" id="mobileBackdrop"></div><div id="toast" class="toast" role="status" aria-live="polite"></div>
<dialog id="siteModal" class="modal"><form id="siteForm" class="modal-inner"><div class="modal-top"><div><p class="overline">CREATE A WEBSITE</p><h2>创建新网站</h2><p class="subtle">只需填写基本信息,其他内容都可以稍后完善。</p></div><button type="button" data-close="siteModal" class="x-btn" aria-label="关闭">×</button></div>
<div class="form-fields"><label class="full">网站名称 <input name="name" required maxlength="150" placeholder="例如:HESU Auto 全球官网"></label><label class="full">这次要做什么? <select name="mode"><option value="new">搭建一个全新网站</option><option value="migration">把旧网站迁移过来</option><option value="hybrid">旧内容 + 新内容混合建站</option></select></label><label class="full">选择行业风格 <select name="theme"><option value="automotive">汽车与出行</option><option value="industrial">工业与制造</option><option value="corporate" selected>企业品牌</option><option value="minimal">极简展示</option></select></label><label class="full">网站域名 <span class="optional">选填</span><input name="domain" placeholder="https://example.com"></label><label class="full">一句话介绍 <span class="optional">选填</span><input name="tagline" placeholder="例如:全球汽车出口与服务"></label></div><div class="modal-actions"><button type="button" data-close="siteModal" class="btn btn-muted">取消</button><button type="submit" class="btn btn-primary">创建并开始 →</button></div></form></dialog>
<dialog id="entryModal" class="modal"><form id="entryForm" class="modal-inner"><div class="modal-top"><div><p class="overline">SITE CONTENT</p><h2 id="entryModalTitle">添加内容</h2><p class="subtle">按业务填写信息即可。详细布局可以进入可视化编辑器修改。</p></div><button type="button" data-close="entryModal" class="x-btn" aria-label="关闭">×</button></div><input type="hidden" name="entry_id"><div class="form-fields"><label>内容类型<select name="type"><option value="page">网站页面</option><option value="product">产品</option><option value="vehicle">车型</option><option value="post">新闻文章</option><option value="brand">品牌</option><option value="case">案例</option><option value="service">服务项目</option></select></label><label>状态<select name="status"><option value="published">可参与网站构建</option><option value="draft">内容草稿</option></select></label><label class="full">标题<input name="title" required maxlength="250" placeholder="输入页面或产品标题"></label><label class="full">页面地址 <span class="optional">新内容可留空</span><input name="route" placeholder="/products/example/"></label><label class="full">简短介绍<textarea name="summary" rows="2" placeholder="显示在列表和摘要中的介绍"></textarea></label><label class="full">正文内容 <span class="optional">可选</span><textarea name="body" rows="5" placeholder="填写业务正文,复杂排版请使用可视化设计器"></textarea></label><details class="form-expand full"><summary>SEO 设置(进阶)</summary><div class="form-fields"><label class="full">SEO 标题<input name="seo_title" placeholder="默认使用内容标题"></label><label class="full">Meta 描述<textarea name="seo_description" rows="2"></textarea></label></div></details></div><div class="modal-actions"><button type="button" data-close="entryModal" class="btn btn-muted">取消</button><button type="submit" class="btn btn-primary">保存内容</button></div></form></dialog>
</body></html>
+190
View File
@@ -0,0 +1,190 @@
"""Full, version-aware page-family theme renderer.
All content is escaped by Jinja's HTML autoescaping. Templates packaged with the
application are trusted; uploaded declarative themes cannot supply executable Jinja.
"""
from __future__ import annotations
import hashlib
import datetime
import json
import re
from html import escape
from pathlib import Path
from urllib.parse import quote
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from .core import sanitize, route_path
THEMES=('automotive','industrial','corporate','minimal')
ROOT=Path(__file__).resolve().parent.parent
LOADER=Environment(loader=FileSystemLoader(str(ROOT/'themes')),autoescape=select_autoescape(['html','xml']))
TITLES={'product':'产品中心','vehicle':'车型中心','post':'新闻资讯','case':'项目案例','brand':'品牌中心','service':'服务与解决方案'}
def meta(item):
try:
raw=item.get('meta_json') or '{}'
val=json.loads(raw) if isinstance(raw,str) else raw
return val if isinstance(val,dict) else {}
except (TypeError,ValueError):return {}
def fields(item):
v=meta(item).get('fields') or {}
return v if isinstance(v,dict) else {}
def text(item,key,limit=500):
return str(item.get(key) or '')[:limit]
def slug(value):
# Conservative URL generation for newly derived pages; never rewrite imported URLs.
raw=str(value).strip().casefold()
ascii_slug=re.sub(r'[^a-z0-9-]+','-',raw).strip('-')[:54]
return ascii_slug or ('term-'+hashlib.sha256(raw.encode()).hexdigest()[:12])
def all_groups(records):
return {typ:[e for e in records if e.get('type')==typ] for typ in TITLES}
def categories(records):
return sorted({str(fields(e).get('category') or '').strip() for e in records if e.get('type') in ('product','vehicle') and str(fields(e).get('category') or '').strip()},key=str.casefold)
def brands(records):
found={}
for entry in records:
if entry.get('type')=='brand':found[entry['title'].casefold()]={'name':entry['title'],'url':entry['route']}
for entry in records:
if entry.get('type') not in ('product','vehicle'):continue
name=str(fields(entry).get('brand') or '').strip()
if name and name.casefold() not in found:found[name.casefold()]={'name':name,'url':'/brands/'+slug(name)+'/'}
return sorted(found.values(),key=lambda e:e['name'].casefold())
def infer_kind(page,mode='detail'):
path=page.get('route') or '/'
typ=page.get('type') or 'page'
explicit=str(meta(page).get('page_template') or '').strip().lower()
if mode=='home' or path=='/':return 'home'
if mode=='notfound':return 'notfound'
if path=='/search/':return 'search'
if path.startswith('/categories/'):return 'category'
if mode=='listing' or explicit=='listing' or path in ('/products/','/brands/','/news/','/cases/','/product/','/post/','/brand/','/case/','/service/','/vehicle/'):
return 'listing'
if typ in ('product','vehicle'):return 'product'
if typ=='post':return 'article'
if typ=='brand':return 'brand'
if typ=='case':return 'case'
if typ=='service':return 'service'
if explicit in ('home','about','contact','listing','product','article','brand','standard','notfound','category','case','service','faq'):return explicit
if path.startswith('/about'):return 'about'
if path.startswith('/contact'):return 'contact'
if path.startswith('/faq'):return 'faq'
if path.startswith('/services'):return 'service'
return 'standard'
def safe_href(route):
if not isinstance(route,str):return '/'
if route.startswith(('mailto:','tel:','https://','http://')):return route
try:return route_path(route)
except ValueError:return '/'
def safe_image(value):
if not value or not isinstance(value,str):return ''
value=value.strip()
if value.startswith('//') or any(c in value for c in ('<','>','"','\n','\r')):return ''
if value.startswith(('https://','http://')):return value
if value.startswith('/') or not value.startswith(('data:','javascript:')):return '/'+value.lstrip('/')
return ''
def media_gallery(page):
"""Accept JSON gallery arrays or explicit semicolon/newline separated URLs."""
attrs=fields(page)
raw=attrs.get('gallery') or attrs.get('images') or meta(page).get('gallery') or []
if isinstance(raw,str):
try: raw=json.loads(raw)
except (ValueError,TypeError):raw=re.split(r'[;\n]+',raw)
if not isinstance(raw,list):raw=[]
results=[]
for item in [page.get('cover')]+raw:
uri=item.get('url','') if isinstance(item,dict) else item
safe=safe_image(uri)
if safe and safe not in results:results.append(safe)
if len(results)>=20:break
return results
def select_listing(page,kind,groups,all_records,requested):
if kind=='category':
category=text(page,'category') or text(page,'title')
return [e for e in groups['product']+groups['vehicle'] if str(fields(e).get('category') or '').casefold()==category.casefold()]
path=(page.get('route') or '').lower()
title=text(page,'title').lower()
template=meta(page)
source=next((b.get('source') for b in template.get('blocks',[]) if isinstance(b,dict) and b.get('type')=='collection' and b.get('source')),'')
category= 'post' if ('news' in path or '新闻' in title) else ('brand' if ('brand' in path or '品牌' in title) else ('case' if ('case' in path or '案例' in title) else ('service' if ('service' in path or '服务' in title) else 'product')))
category=source if source in groups else category
if requested and kind=='listing' and not source and path.startswith(('/product/','/post/','/brand/','/case/','/service/')):
category={'/product/':'product','/post/':'post','/brand/':'brand','/case/':'case','/service/':'service/'} .get(path,category)
if category=='product':return groups['product']+groups['vehicle']
return groups.get(category,[])
def prepare(records):
# Grouping a 5,000-page catalogue for every detail page is quadratic.
# The per-build object is scoped to one site; no cross-request global cache.
grouped=all_groups(records)
return {'groups':grouped,
'brands':brands(records),
'categories':[{'name':x,'url':'/categories/'+slug(x)+'/'} for x in categories(records)]}
def make_context(site,page,records,mode='detail',listing_items=None,prepared=None):
pg=dict(page or {})
kind=infer_kind(pg,mode)
prepared=prepared or prepare(records)
groups=prepared['groups']
# Detail templates never render the complete listing; avoid copying thousands
# of records into an unused array for every detail page.
listing=select_listing(pg,kind,groups,records,listing_items) if kind in ('listing','category','search','home') else []
if listing_items is not None and mode=='listing':
listing=list(listing_items)
detail_fields=fields(pg)
spec=[{'label':str(k).replace('_',' ').capitalize(),'value':str(v)} for k,v in detail_fields.items() if isinstance(v,(str,int,float)) and v not in ('',None) and k not in ('brand','category','model','description','image','cover','url')]
ownbrand=str(detail_fields.get('brand') or pg.get('title') or '').strip()
related=[x for x in groups['product']+groups['vehicle'] if str(fields(x).get('brand') or '').casefold()==ownbrand.casefold()] if kind=='brand' else []
settings=json.loads(site.get('settings_json') or '{}') if isinstance(site.get('settings_json'),str) else site.get('settings_json',{}) or {}
nav=settings.get('nav') or [
{'title':'首页','route':'/'},{'title':'产品','route':'/products/'},
{'title':'品牌','route':'/brands/'},{'title':'新闻','route':'/news/'},
{'title':'案例','route':'/cases/'},{'title':'关于','route':'/about/'},
{'title':'联系','route':'/contact/'}]
def nav_item(row,depth=0):
output={'title':str(row.get('title',''))[:50],'route':safe_href(row.get('route') or '/')}
children=row.get('children',[]) if depth<2 else []
output['children']=[nav_item(c,depth+1) for c in children[:20] if isinstance(c,dict)] if isinstance(children,list) else []
return output
nav=[nav_item(x) for x in nav if isinstance(x,dict)][:20]
assets={'categories':prepared['categories'],'brands':prepared['brands']}
title=(pg.get('title') or site.get('name') or '')
canonical=(pg.get('canonical') or (site.get('domain','').rstrip('/')+pg.get('route','/') if site.get('domain','').startswith(('http://','https://')) else ''))
from . import site_design_v87
chrome=site_design_v87.published(settings)
if chrome['nav']:nav=chrome['nav']
form_endpoint=settings.get('form_endpoint') or ''
form_endpoint=form_endpoint if isinstance(form_endpoint,str) and form_endpoint.startswith('https://') and not any(x in form_endpoint for x in ('<','>','\"','\n')) else ''
return {'year':datetime.datetime.now(datetime.timezone.utc).year,'theme_color':{'automotive':'#0e1c2c','industrial':'#113f59','corporate':'#f5f6fa','minimal':'#f8f7f3'}.get(site.get('theme'),'#ffffff'),'gallery':media_gallery(pg),'form_endpoint':form_endpoint,'site':site,'page':pg,'kind':kind,'records':records,'groups':groups,'listing':listing,
'showcase':groups['product'][:6]+groups['vehicle'][:max(0,6-len(groups['product'][:6]))],
'features':groups['service'][:3], 'stories':groups['post'][:3], 'cases':groups['case'][:3],
'brands':assets['brands'],'categories':assets['categories'], 'nav':nav,
'chrome':chrome,'chrome_is_custom':bool(settings.get('site_design')),
'footer_nav':nav if chrome['footer']['show_nav'] else [],'specs':spec,
'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 '',
'canonical':canonical,'safe_href':safe_href,'safe_image':safe_image,
'fields_of':fields,'labels':TITLES,'footer_email':chrome['footer']['email'] or settings.get('email') or site.get('email') or '',
'footer_text':chrome['footer']['description'] or settings.get('footer_text') or site.get('tagline') or '',
'pagination':pg.get('_pagination'), 'route':pg.get('route','/'),
'pretty_type':TITLES.get(pg.get('type'),'内容'),
'empty':len(listing)==0,
}
def render(theme,site,page,records,mode='detail',listing_items=None,blocks_html='',prepared=None):
if theme not in THEMES:raise ValueError('不是内置专业主题')
ctx=make_context(site,page,records,mode,listing_items,prepared=prepared)
ctx.update(custom_blocks=Markup(blocks_html or ''),has_custom=bool(blocks_html),noindex=(ctx['kind']=='notfound'),designer_replace=meta(page).get('layout_mode')=='replace')
return LOADER.get_template(theme+'/pro.html').render(**ctx)
+159
View File
@@ -0,0 +1,159 @@
"""Versioned *declarative* theme packages. Never execute user uploaded templates.
Uploads can customize styles and built-in page blueprint presets. The trusted
Jinja template is copied from the built-in base. Arbitrary HTML/Jinja/JS in
uploaded archives is deliberately not supported.
"""
from __future__ import annotations
import io, json, re, zipfile, shutil
from pathlib import Path
from . import db
BASE=Path(__file__).resolve().parent.parent/'themes'
THEME_ROOT=db.DATA/'theme_packages'
BUILTIN=('automotive','industrial','corporate','minimal')
THEME_ID=re.compile(r'^[a-z][a-z0-9-]{2,39}$')
VERSION=re.compile(r'^\d+\.\d+\.\d+$')
class ThemeError(ValueError):pass
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS theme_versions (
theme_id TEXT NOT NULL, version TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', installed_at TEXT NOT NULL,
PRIMARY KEY(theme_id,version));
CREATE TABLE IF NOT EXISTS site_theme_pins (
site_id TEXT PRIMARY KEY REFERENCES sites(id) ON DELETE CASCADE,
theme_id TEXT NOT NULL, version TEXT NOT NULL);
''')
def catalog():
builtin=[]
for tid in BUILTIN:
obj=json.loads((BASE/tid/'manifest.json').read_text(encoding='utf-8'))
builtin.append({**obj,'builtin':True,'installed':True})
custom=[dict(r,builtin=False,installed=True) for r in db.rows('SELECT * FROM theme_versions ORDER BY installed_at DESC')]
return builtin+custom
def ids():
return set(BUILTIN)|{r['theme_id'] for r in db.rows('SELECT DISTINCT theme_id FROM theme_versions')}
def latest(tid):
if tid in BUILTIN:
return json.loads((BASE/tid/'manifest.json').read_text(encoding='utf-8')).get('version','1.0.0')
items=db.rows('SELECT version FROM theme_versions WHERE theme_id=?',(tid,))
if not items:raise ThemeError('主题不存在')
return sorted((r['version'] for r in items),key=lambda s:tuple(map(int,s.split('.'))))[-1]
def resolve(tid,version=None):
if tid in BUILTIN:
current=latest(tid)
if not version or version==current:return BASE/tid
if not VERSION.fullmatch(version):raise ThemeError('非法主题版本')
previous=(BASE/tid/'versions'/version).resolve()
if not previous.is_relative_to((BASE/tid/'versions').resolve()) or not previous.is_dir():
raise ThemeError('内置主题的历史版本不存在')
return previous
ver=version or latest(tid)
if not db.one('SELECT theme_id FROM theme_versions WHERE theme_id=? AND version=?',(tid,ver)):
raise ThemeError('主题版本不存在')
result=(THEME_ROOT/tid/ver).resolve()
if not result.is_relative_to(THEME_ROOT.resolve()) or not result.is_dir():raise ThemeError('主题目录丢失')
return result
def pin(site_id,tid,ver=None):
target=resolve(tid,ver)
ver=ver or latest(tid)
with db.connect() as c:
c.execute('INSERT INTO site_theme_pins VALUES (?,?,?) ON CONFLICT(site_id) DO UPDATE SET theme_id=excluded.theme_id,version=excluded.version',(site_id,tid,ver))
c.execute('UPDATE sites SET theme=?,updated_at=? WHERE id=?',(tid,db.now(),site_id))
return {'theme':tid,'version':ver,'path':str(target)}
def pinned(site_id):
return db.one('SELECT theme_id,version FROM site_theme_pins WHERE site_id=?',(site_id,))
def compatibility(theme_id,version=None):
"""Theme build/renderer compatibility, not a subjective visual quality score."""
from . import blueprints
try:folder=resolve(theme_id,version)
except ThemeError as exc:return {'valid':False,'errors':[str(exc)],'warnings':[]}
issues=[];warnings=[]
for resource in ('manifest.json','theme.css','page-presets.json','pro.html','pro.css'):
if not (folder/resource).is_file():issues.append('缺少 '+resource)
if not issues:
try:manifest=json.loads((folder/'manifest.json').read_text(encoding='utf-8'))
except (ValueError,UnicodeError) as exc:issues.append('主题清单解析失败:'+str(exc));manifest={}
if manifest.get('id')!=theme_id:issues.append('清单 ID 与文件夹不匹配')
try:presets=json.loads((folder/'page-presets.json').read_text(encoding='utf-8'))
except (ValueError,UnicodeError):presets={};issues.append('页面预设 JSON 不合法')
missing=[kind for kind in blueprints.PAGE_TEMPLATES if kind not in presets]
if missing:warnings.append('缺少可编辑页面预设:'+','.join(missing))
layout_family=manifest.get('layout_family',theme_id)
family=layout_family if layout_family in BUILTIN else 'corporate'
page_root=(folder/'pages') if (folder/'pages').exists() else BASE/family/'pages'
for kind in ('home','about','contact','listing','product','brand','article','case','service','faq','category','search','standard','notfound'):
if not (page_root/(kind+'.html')).is_file():issues.append('缺少实际页面布局:'+kind)
return {'valid':not issues,'theme':theme_id,'version':version or (latest(theme_id) if not issues else version),
'checked_page_types':14,'errors':issues,'warnings':warnings,
'note':'技术兼容性检查不会代替企业内容准确性和真实设备视觉验收'}
def install(blob:bytes):
if len(blob)>8_000_000:raise ThemeError('主题包最大 8 MB')
try:archive=zipfile.ZipFile(io.BytesIO(blob))
except zipfile.BadZipFile:raise ThemeError('不是 ZIP 压缩包')
names={n.filename for n in archive.infolist()}
permitted={'manifest.json','theme.css','page-presets.json','preview.png','preview.jpg','preview.webp'}
if not {'manifest.json','theme.css','page-presets.json'}.issubset(names):raise ThemeError('缺少主题清单、样式或页面预设')
if len(names)>6 or any(name not in permitted for name in names):raise ThemeError('主题包只能包含受支持的声明式文件,不允许模板脚本')
for f in archive.infolist():
if f.file_size>1_500_000 or f.is_dir():raise ThemeError('主题资源体积或结构不符合要求')
manifest=json.loads(archive.read('manifest.json'))
tid=manifest.get('id',''); ver=manifest.get('version','')
if not THEME_ID.fullmatch(tid) or not VERSION.fullmatch(ver) or tid in BUILTIN:raise ThemeError('主题 ID 或 SemVer 不合法,内置主题不能覆盖')
if db.one('SELECT theme_id FROM theme_versions WHERE theme_id=? AND version=?',(tid,ver)):
raise ThemeError('同版本主题已存在,请升级版本号')
if str(manifest.get('layout_family') or 'corporate') not in BUILTIN:
raise ThemeError('layout_family 只能选择已审查的基础布局')
title=str(manifest.get('name',''))[:100]; description=str(manifest.get('description',''))[:400]
if not title:raise ThemeError('主题名称不能为空')
presets=json.loads(archive.read('page-presets.json'))
if not isinstance(presets,dict):raise ThemeError('页面模板预设必须为对象')
from . import blueprints
for kind,blocks in presets.items():
if kind not in blueprints.PAGE_TEMPLATES:raise ThemeError('未知的页面模板:'+kind)
if not isinstance(blocks,list):raise ThemeError('页面预设内容必须是区块数组')
for b in blocks:
if not isinstance(b,dict):raise ThemeError('页面预设区块格式不正确')
blueprints.validate_blocks([blueprints.new_block(b['type'],b) for b in blocks])
css=archive.read('theme.css').decode('utf-8')
# Style packages are declarative: no network requests or executable CSS extensions.
if len(css)>180000 or re.search(r'@import\b|url\s*\(|expression\s*\(|behavior\s*:|</style|<script',css,re.I):
raise ThemeError('主题样式不允许引用外部地址或脚本行为')
temp=THEME_ROOT/tid/(ver+'-upload')
path=THEME_ROOT/tid/ver
if path.exists():raise ThemeError('主题版本文件已存在')
temp.mkdir(parents=True,exist_ok=False)
try:
(temp/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False),encoding='utf-8')
(temp/'theme.css').write_text(css,encoding='utf-8')
(temp/'page-presets.json').write_text(json.dumps(presets,ensure_ascii=False),encoding='utf-8')
# This Jinja template is shipped and reviewed with SiteForge itself.
family=str(manifest.get('layout_family') or 'corporate')
if family not in BUILTIN:raise ThemeError('不支持的专业主题版式')
shutil.copy2(BASE/family/'site.html',temp/'site.html')
shutil.copy2(BASE/family/'pro.html',temp/'pro.html')
shutil.copy2(BASE/family/'pro.css',temp/'pro.css')
# Custom declarative themes inherit the trusted family's local visual assets.
if (BASE/family/'visual').is_dir():
shutil.copytree(BASE/family/'visual',temp/'visual')
for n in ('preview.png','preview.jpg','preview.webp'):
if n in names:(temp/n).write_bytes(archive.read(n))
temp.rename(path)
db.insert('INSERT INTO theme_versions VALUES (?,?,?,?,?)',(tid,ver,title,description,db.now()))
except Exception:
shutil.rmtree(temp,ignore_errors=True)
if path.exists() and not db.one('SELECT theme_id FROM theme_versions WHERE theme_id=? AND version=?',(tid,ver)):
shutil.rmtree(path,ignore_errors=True)
raise
return {'id':tid,'version':ver,'name':title,'page_templates':list(presets)}
+303
View File
@@ -0,0 +1,303 @@
"""V8 declarative whole-site template snapshot and safe application.
A template contains page structure, not source-site content records, product data,
media bytes, domain, secrets or SEO values.
"""
from __future__ import annotations
import copy,json,uuid,hashlib
from fastapi import APIRouter,Body,HTTPException,Request
from . import db,enterprise,blueprints,theme_store
from .core import save_entry
router=APIRouter(prefix='/api/factory/sites',tags=['V8 whole-site templates'])
DESIGN_FIELDS=('columns','gap','align','image_side','limit','source','height','visible','width','background',
'padding','padding_mobile','padding_tablet','margin','color','columns_mobile','tablet_columns','mobile_columns')
PAGE_LIMIT=35
def _base_block(block):
kind=block['type']
fresh=blueprints.new_block(kind)
for key in DESIGN_FIELDS:
if key in block and key!='columns':fresh[key]=copy.deepcopy(block[key])
if kind=='layout':
columns=[]
for column in block.get('columns',[]):
if not isinstance(column,dict):raise ValueError('Invalid layout column')
columns.append({'width':column.get('width',1),**{k:column[k] for k in ('tablet_order','mobile_order','tablet_span','mobile_span','hide_tablet','hide_mobile') if k in column},'blocks':[_base_block(x) for x in column.get('blocks',[])]})
fresh['columns']=columns
return fresh
def export_template(site_id,name):
site=db.one('SELECT id,theme,accent,settings_json FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404,'网站不存在')
entries=db.rows("SELECT route,title,meta_json FROM entries WHERE site_id=? AND type='page' ORDER BY route LIMIT 36",(site_id,))
pages=[]
for entry in entries:
raw=json.loads(entry['meta_json'] or '{}')
blocks=raw.get('blocks')
if blocks is None:continue
shape=blueprints.validate_blocks(blocks)
pages.append({'route':entry['route'],'page_type':raw.get('page_template') or ('home' if entry['route']=='/' else 'standard'),
'blocks':[_base_block(b) for b in shape]})
if len(pages)>PAGE_LIMIT:raise ValueError('模板最多35个页面')
settings=json.loads(site['settings_json'] or '{}')
layout={'format':'siteforge-blueprint-v1','name':str(name)[:100],'theme':site['theme'],
'tokens':{'accent':site['accent']},'theme_version':(theme_store.pinned(site_id) or {}).get('version') or theme_store.latest(site['theme']),'pages':pages,
'navigation':[{'title':str(x.get('title',''))[:60],'route':str(x.get('route',''))[:250]} for x in settings.get('nav',[]) if isinstance(x,dict)]}
return validate_template(layout)
def validate_template(doc):
if not isinstance(doc,dict) or doc.get('format')!='siteforge-blueprint-v1':raise ValueError('模板格式不支持')
if doc.get('theme') not in theme_store.ids():raise ValueError('主题未安装或不可用')
from .core import route_path,clean_hex
tokens=doc.get('tokens',{})
if not isinstance(tokens,dict):raise ValueError('设计令牌不合法')
accent=clean_hex(tokens.get('accent','#2563eb'))
pages=doc.get('pages',[])
if not isinstance(pages,list) or not (1<=len(pages)<=PAGE_LIMIT):raise ValueError('页面数量必须为1到35')
routes=set();out=[]
for p in pages:
if not isinstance(p,dict):raise ValueError('页面配置不合法')
route=route_path(p.get('route',''))
if route in routes:raise ValueError('模板包含重复 URL')
routes.add(route)
kind=p.get('page_type','standard')
if kind not in blueprints.PAGE_TEMPLATES:raise ValueError('未知页面类型')
blocks=blueprints.validate_blocks(p.get('blocks',[]))
out.append({'route':route,'page_type':kind,'blocks':blocks})
navigation=doc.get('navigation',[])
if not isinstance(navigation,list) or len(navigation)>30:raise ValueError('导航不能超过30项')
links=[]
for nav in navigation:
if not isinstance(nav,dict):raise ValueError('导航项不合法')
link=route_path(nav.get('route',''))
links.append({'title':str(nav.get('title',''))[:60],'route':link})
return {'format':'siteforge-blueprint-v1','name':str(doc.get('name','Website template'))[:100],
'theme':doc['theme'],'theme_version':str(doc.get('theme_version',''))[:30],'tokens':{'accent':accent},'navigation':links,'pages':out}
def save_snapshot(site_id,name):
doc=export_template(site_id,name)
id=uuid.uuid4().hex
db.insert('INSERT INTO v8_site_templates VALUES (?,?,?,?,?,?)',
(id,site_id,doc['name'],'1.0.0',json.dumps(doc,ensure_ascii=False),db.now()))
return {'id':id,'name':doc['name'],'pages':len(doc['pages']),'format':doc['format']}
def preflight(target_site_id,doc):
"""Check applicability without changing the destination site or its content."""
normalized=validate_template(doc)
site=db.one('SELECT id,theme FROM sites WHERE id=?',(target_site_id,))
if not site:raise HTTPException(404,'网站不存在')
theme=normalized['theme']
verdict=theme_store.compatibility(theme, str(doc.get('theme_version') or '') or None)
errors=list(verdict['errors']);warnings=list(verdict['warnings'])
required=sorted({b['type'] for p in normalized['pages'] for b in blueprints.validate_blocks(p['blocks'])})
source_version=doc.get('theme_version')
if source_version:
try:theme_store.resolve(theme,str(source_version))
except (ValueError,FileNotFoundError) as e:errors.append('模板要求的主题版本不可用:'+str(e))
missing=[t for t in required if t not in blueprints.BLOCKS]
if missing:errors.append('区块组件不存在:'+','.join(missing))
installed=theme_store.ids()
if site['theme']!=theme:warnings.append('将切换现有主题:'+site['theme']+' → '+theme)
current={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(target_site_id,))}
conflicts=[p['route'] for p in normalized['pages'] if p['route'] in current]
if conflicts:warnings.append('将更新已有页面的布局,但不复制业务正文和 SEO:'+', '.join(conflicts[:10]))
return {'compatible':not errors,'errors':errors,'warnings':warnings,'theme':theme,
'page_count':len(normalized['pages']),'required_blocks':required,
'existing_routes':conflicts,'create_routes':[p['route'] for p in normalized['pages'] if p['route'] not in current],
'content_preserved':True,'no_changes_applied':True}
def preview_application(target_site_id,doc):
doc=validate_template(doc)
if not db.one('SELECT id FROM sites WHERE id=?',(target_site_id,)):raise HTTPException(404,'网站不存在')
existing={x['route'] for x in db.rows('SELECT route FROM entries WHERE site_id=?',(target_site_id,))}
return {'theme':doc['theme'],'new':[x['route'] for x in doc['pages'] if x['route'] not in existing],
'existing':[x['route'] for x in doc['pages'] if x['route'] in existing],
'pages':len(doc['pages']),'content_preserved':True,'requires_confirmation':True}
def setup():
with db.connect() as c:
c.execute('''CREATE TABLE IF NOT EXISTS v8_template_application_drafts (
site_id TEXT PRIMARY KEY REFERENCES sites(id) ON DELETE CASCADE,
revision INTEGER NOT NULL, base_hash TEXT NOT NULL,
template_json TEXT NOT NULL, overwrite_pages INTEGER NOT NULL,
created_at TEXT NOT NULL)''')
def _source_fingerprint(site_id,c=None):
"""Hash current page designs + site chrome before applying a multi-page plan."""
def read(conn):
site=conn.execute('SELECT theme,accent,settings_json,updated_at FROM sites WHERE id=?',(site_id,)).fetchone()
if not site:raise HTTPException(404,'网站不存在')
rows=conn.execute('SELECT route,meta_json,updated_at FROM entries WHERE site_id=? ORDER BY route',(site_id,)).fetchall()
return hashlib.sha256(json.dumps({'site':dict(site),'pages':[dict(x) for x in rows]},sort_keys=True,ensure_ascii=False).encode()).hexdigest()
if c is not None:return read(c)
with db.connect() as conn:return read(conn)
def stage_template(site_id,doc,overwrite_pages=False,expected_revision=0):
"""Persist a reviewable whole-site plan without touching published pages."""
setup()
plan=validate_template(doc)
report=preflight(site_id,plan)
if not report['compatible']:raise HTTPException(422,{'errors':report['errors']})
if report['existing_routes'] and not overwrite_pages:
raise HTTPException(409,'已有同路径页面,需明确允许替换布局')
if type(expected_revision) is not int or expected_revision<0:raise HTTPException(400,'草稿版本无效')
with db.connect() as c:
if not db.POSTGRES:c.execute('BEGIN IMMEDIATE')
old=c.execute('SELECT revision FROM v8_template_application_drafts WHERE site_id=?',(site_id,)).fetchone()
revision=old['revision'] if old else 0
if expected_revision!=revision:raise HTTPException(409,'模板草稿已在其他窗口修改')
fingerprint=_source_fingerprint(site_id,c)
c.execute('''INSERT INTO v8_template_application_drafts
(site_id,revision,base_hash,template_json,overwrite_pages,created_at)
VALUES(?,?,?,?,?,?)
ON CONFLICT(site_id) DO UPDATE SET revision=excluded.revision,base_hash=excluded.base_hash,
template_json=excluded.template_json,overwrite_pages=excluded.overwrite_pages,
created_at=excluded.created_at''',
(site_id,revision+1,fingerprint,json.dumps(plan,ensure_ascii=False),int(overwrite_pages),db.now()))
return {'staged':True,'revision':revision+1,'page_count':len(plan['pages']),
'content_preserved':True,'published_unchanged':True,'requires_owner_approval':True}
def read_stage(site_id):
setup()
draft=db.one('SELECT * FROM v8_template_application_drafts WHERE site_id=?',(site_id,))
if not draft:return {'state':'none','revision':0}
doc=json.loads(draft['template_json'])
return {'state':'draft','revision':draft['revision'],'theme':doc['theme'],
'page_count':len(doc['pages']), 'conflict':_source_fingerprint(site_id)!=draft['base_hash']}
def publish_stage(site_id,revision):
setup()
draft=db.one('SELECT * FROM v8_template_application_drafts WHERE site_id=?',(site_id,))
if not draft:raise HTTPException(409,'没有待应用的整站模板草稿')
if type(revision) is not int or revision!=draft['revision']:raise HTTPException(409,'模板草稿版本不匹配')
return apply_template(site_id,json.loads(draft['template_json']),bool(draft['overwrite_pages']),
expected_hash=draft['base_hash'],staged_revision=revision)
def apply_template(target_site_id,doc,overwrite_pages=False,expected_hash=None,staged_revision=None):
'''Apply a confirmed template as one SQLite/PostgreSQL transaction.
This administrative layout operation is blocked when designers have pending
drafts. A failed insert/update does not leave half the site changed.
Business content, robots, SEO, media and published build artifacts survive.
'''
from . import studio_v86,site_design_v87
from .core import slug_of
studio_v86.setup();site_design_v87.setup()
doc=validate_template(doc)
suitability=preflight(target_site_id,doc)
if not suitability['compatible']:raise HTTPException(422,{'errors':suitability['errors']})
dry=preview_application(target_site_id,doc)
if dry['existing'] and not overwrite_pages:
raise HTTPException(409,{'message':'现有页面需要显式确认才能应用布局','routes':dry['existing']})
with db.connect() as c:
if not db.POSTGRES:c.execute('BEGIN IMMEDIATE')
site=c.execute('SELECT * FROM sites WHERE id=?',(target_site_id,)).fetchone()
if not site:raise HTTPException(404,'网站不存在')
if expected_hash is not None and _source_fingerprint(target_site_id,c)!=expected_hash:
raise HTTPException(409,'模板预检后网站已变化;请重新暂存并审核')
if staged_revision is not None:
record=c.execute('SELECT revision,base_hash FROM v8_template_application_drafts WHERE site_id=?',(target_site_id,)).fetchone()
if not record or record['revision']!=staged_revision or record['base_hash']!=expected_hash:
raise HTTPException(409,'当前模板草稿已被更新')
if c.execute('SELECT 1 FROM studio_drafts WHERE site_id=? LIMIT 1',(target_site_id,)).fetchone():
raise HTTPException(409,'存在尚未发布的页面设计草稿;请先发布或放弃草稿')
if c.execute('SELECT 1 FROM site_design_drafts WHERE site_id=?',(target_site_id,)).fetchone():
raise HTTPException(409,'存在尚未发布的全站草稿;请先发布或放弃草稿')
created=0;updated=0
for page in doc['pages']:
row=c.execute('SELECT id,meta_json FROM entries WHERE site_id=? AND route=?',
(target_site_id,page['route'])).fetchone()
if row:
meta=json.loads(row['meta_json'] or '{}')
meta.update(blocks=page['blocks'],page_template=page['page_type'])
c.execute('UPDATE entries SET meta_json=?,updated_at=? WHERE site_id=? AND id=?',
(json.dumps(meta,ensure_ascii=False),db.now(),target_site_id,row['id']))
updated+=1
else:
route=page['route']
label='首页' if route=='/' else route.strip('/').replace('-',' ').title()
c.execute('''INSERT INTO entries
(id,site_id,type,title,route,slug,summary,body,cover,seo_title,
seo_description,canonical,robots,meta_json,status,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(uuid.uuid4().hex,target_site_id,'page',label,route,slug_of(label),'','','','','',
'','noindex,follow',json.dumps({'blocks':page['blocks'],'page_template':page['page_type']},ensure_ascii=False),
'draft','v8-template',db.now(),db.now()))
created+=1
settings=json.loads(site['settings_json'] or '{}')
settings['nav']=doc['navigation']
c.execute('UPDATE sites SET theme=?,accent=?,settings_json=?,updated_at=? WHERE id=?',
(doc['theme'],doc['tokens']['accent'],json.dumps(settings,ensure_ascii=False),db.now(),target_site_id))
if staged_revision is not None:
c.execute('DELETE FROM v8_template_application_drafts WHERE site_id=? AND revision=?',(target_site_id,staged_revision))
return {'site_id':target_site_id,'created':created,'updated_layouts':updated,
'theme':doc['theme'],'content_preserved':True,'transactional':True}
@router.post('/{site_id}/whole-site-templates')
def api_save(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return save_snapshot(site_id,payload.get('name','New whole-site template'))
@router.get('/{site_id}/whole-site-templates')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT id,name,version,created_at FROM v8_site_templates WHERE site_id=? ORDER BY created_at DESC',(site_id,))
@router.get('/{site_id}/whole-site-templates/{template_id}')
def api_show(request:Request,site_id:str,template_id:str):
enterprise.require(request,site_id,'viewer')
row=db.one('SELECT blueprint_json FROM v8_site_templates WHERE id=? AND site_id=?',(template_id,site_id))
if not row:raise HTTPException(404,'模板不存在')
return json.loads(row['blueprint_json'])
@router.post('/{site_id}/whole-site-templates/preflight')
def api_preflight(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'viewer')
try:return preflight(site_id,payload.get('template'))
except ValueError as e:raise HTTPException(400,str(e))
@router.post('/{site_id}/whole-site-templates/apply')
def api_apply(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
template=payload.get('template')
if template is None:
source=payload.get('source_site_id');snapshot=payload.get('template_id')
# Read requires membership in the source site, not only the destination.
enterprise.require(request,source,'viewer')
row=db.one('SELECT blueprint_json FROM v8_site_templates WHERE site_id=? AND id=?',(source,snapshot))
if not row:raise HTTPException(404,'源模板不存在')
template=json.loads(row['blueprint_json'])
try:
if not payload.get('confirm',False):return preview_application(site_id,template)
if payload.get('legacy_direct_apply'):
from .site_design_v87 import require_legacy_enabled
require_legacy_enabled()
enterprise.require(request,site_id,'owner')
return apply_template(site_id,template,bool(payload.get('overwrite_layouts',False)))
return stage_template(site_id,template,bool(payload.get('overwrite_layouts',False)),payload.get('expected_revision',0))
except ValueError as e:raise HTTPException(400,str(e))
@router.get('/{site_id}/whole-site-templates/staged/current')
def api_stage_detail(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return read_stage(site_id)
@router.post('/{site_id}/whole-site-templates/staged/publish')
def api_publish_staged(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
return publish_stage(site_id,payload.get('expected_revision'))
@router.delete('/{site_id}/whole-site-templates/staged/current')
def api_discard_staged(request:Request,site_id:str):
enterprise.require(request,site_id,'editor')
setup()
db.execute('DELETE FROM v8_template_application_drafts WHERE site_id=?',(site_id,))
return {'discarded':True}
+280
View File
@@ -0,0 +1,280 @@
"""V8 portable content import pipeline: deterministic preview, batch audit and guarded rollback.
This is a single-instance pipeline suitable for controlled pilots. It never runs
untrusted scripts, and keeps original import and business data separate.
"""
from __future__ import annotations
import hashlib
import io
import json
import re
import uuid
import csv
from urllib.parse import urlsplit
from fastapi import APIRouter, Body, HTTPException, Request, File, UploadFile, Form
from . import db, enterprise, assembly
from .core import save_entry, route_path
router = APIRouter(prefix='/api/factory/sites', tags=['V8 content workflow'])
MAX_ITEMS = 1000
MAX_UPLOAD = 12_000_000
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS v8_import_batches(
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
origin TEXT NOT NULL, content_type TEXT NOT NULL, state TEXT NOT NULL,
mapping_json TEXT NOT NULL, warnings_json TEXT NOT NULL DEFAULT '[]',
input_count INTEGER NOT NULL, created_at TEXT NOT NULL, committed_at TEXT,
rolled_back_at TEXT);
CREATE TABLE IF NOT EXISTS v8_import_items(
id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES v8_import_batches(id) ON DELETE CASCADE,
source_key TEXT NOT NULL, row_number INTEGER NOT NULL,
route TEXT NOT NULL, content_json TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'staged',
entry_id TEXT, before_json TEXT, after_fingerprint TEXT, error TEXT,
UNIQUE(batch_id, source_key));
CREATE INDEX IF NOT EXISTS v8_batches_by_site ON v8_import_batches(site_id,created_at);
CREATE TABLE IF NOT EXISTS v8_site_templates(
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL, version TEXT NOT NULL, blueprint_json TEXT NOT NULL,
created_at TEXT NOT NULL);
''')
def _row_hash(row):
if row is None:return ''
data={k:row[k] for k in ('id','site_id','type','title','route','slug','summary','body','cover',
'seo_title','seo_description','canonical','robots','meta_json','status','source','created_at','updated_at')}
return hashlib.sha256(json.dumps(data,ensure_ascii=False,sort_keys=True).encode()).hexdigest()
def _require_site(site_id):
if not db.one('SELECT id FROM sites WHERE id=?',(site_id,)):
raise HTTPException(404,'网站不存在')
def _mapped_input(rows, mapping, kind):
if not isinstance(rows,list) or not rows or len(rows)>MAX_ITEMS:
raise ValueError('每批需要 1-1000 条内容')
if any(not isinstance(x,dict) for x in rows):raise ValueError('数据行必须是对象')
if kind not in ('product','vehicle','page','post','case','brand','service','other','mixed'):
raise ValueError('内容类型不受支持')
mapping = mapping or assembly.suggest(list(rows[0].keys()))
if not isinstance(mapping,dict):raise ValueError('映射规则不合法')
mapped,errors=assembly.transform(rows,mapping,'page' if kind=='mixed' else kind)
if kind=='mixed':
kinds={'post':'post','page':'page','product':'product','brand':'brand','vehicle':'vehicle',
'case':'case','service':'service'}
for i,item in enumerate(mapped):
src=str(rows[i].get('类型') or rows[i].get('分类') or 'page').lower()
item['type']=kinds.get(src,'other')
if errors:raise ValueError('数据预检失败:'+json.dumps(errors[:8],ensure_ascii=False))
return mapped,mapping
def stage(site_id, rows, mapping=None, kind='product', origin='manual-import', source_keys=None):
_require_site(site_id)
mapped,mapping=_mapped_input(rows,mapping,kind)
if len(json.dumps(mapped,ensure_ascii=False).encode('utf-8'))>MAX_UPLOAD:
raise ValueError('映射后的批次超过12MB上限,请拆分导入')
if source_keys is not None and len(source_keys)!=len(rows):raise ValueError('源记录主键数量不一致')
existing_routes={x['route'] for x in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))}
conflicts=[x['route'] for x in mapped if x['route'] in existing_routes]
batch_id=uuid.uuid4().hex
now=db.now()
keys=[]
for i,item in enumerate(mapped):
source_key=str(source_keys[i] if source_keys else item['route'])
if not source_key or len(source_key)>250:raise ValueError('来源主键缺失或过长')
keys.append(source_key)
if len(set(keys))!=len(keys):raise ValueError('导入文件的来源主键重复')
with db.connect() as c:
c.execute('''INSERT INTO v8_import_batches(id,site_id,origin,content_type,state,mapping_json,
input_count,created_at) VALUES(?,?,?,?,?,?,?,?)''',
(batch_id,site_id,origin[:160],kind,'staged',json.dumps(mapping,ensure_ascii=False),len(mapped),now))
for i,item in enumerate(mapped):
c.execute('''INSERT INTO v8_import_items(id,batch_id,source_key,row_number,route,content_json)
VALUES(?,?,?,?,?,?)''',(uuid.uuid4().hex,batch_id,keys[i],i+1,item['route'],
json.dumps(item,ensure_ascii=False)))
return {'batch_id':batch_id,'state':'staged','mapped':len(mapped),'conflicts':conflicts,
'sample':mapped[:5],'mapping':mapping,'requires_conflict_choice':bool(conflicts)}
def list_batches(site_id):
_require_site(site_id)
return db.rows('SELECT id,origin,content_type,state,input_count,created_at,committed_at,rolled_back_at FROM v8_import_batches WHERE site_id=? ORDER BY created_at DESC LIMIT 100',(site_id,))
def batch_detail(site_id,batch_id):
result=db.one('SELECT * FROM v8_import_batches WHERE site_id=? AND id=?',(site_id,batch_id))
if not result:raise HTTPException(404,'导入批次不存在')
result['items']=db.rows('SELECT id,source_key,row_number,route,state,entry_id,error FROM v8_import_items WHERE batch_id=? ORDER BY row_number LIMIT 1100',(batch_id,))
return result
def commit(site_id,batch_id,on_conflict='skip'):
if on_conflict not in ('skip','replace'):raise ValueError('冲突策略只能为 skip 或 replace')
batch=db.one('SELECT * FROM v8_import_batches WHERE site_id=? AND id=?',(site_id,batch_id))
if not batch:raise HTTPException(404,'导入批次不存在')
if batch['state']=='committed':return {'batch_id':batch_id,'state':'committed','existing':True}
if batch['state']!='staged':raise HTTPException(409,'批次已经开始处理,不能重复执行')
locked=db.execute("UPDATE v8_import_batches SET state='processing' WHERE id=? AND state='staged'",(batch_id,))
if not locked:raise HTTPException(409,'批次正在由其他请求处理')
items=db.rows('SELECT * FROM v8_import_items WHERE batch_id=? ORDER BY row_number',(batch_id,))
created=updated=skipped=0
failed=[]
for item in items:
original=db.one('SELECT * FROM entries WHERE site_id=? AND route=?',(site_id,item['route']))
if original and on_conflict=='skip':
db.execute("UPDATE v8_import_items SET state='skipped', entry_id=? WHERE id=?",(original['id'],item['id']))
skipped+=1
continue
try:
payload=json.loads(item['content_json'])
meta=payload.get('meta') or {}
meta.setdefault('provenance',{})
meta['provenance'].update({'import_batch':batch_id,'source_key':item['source_key'],'origin':batch['origin']})
payload['meta']=meta
before=json.dumps(original,ensure_ascii=False) if original else None
eid=save_entry(site_id,payload,existing_id=original['id'] if original else None,source='v8-import')
after=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid))
db.execute('''UPDATE v8_import_items SET state='applied',entry_id=?,before_json=?,after_fingerprint=? WHERE id=?''',
(eid,before,_row_hash(after),item['id']))
if original:updated+=1
else:created+=1
except Exception as exc:
msg=str(exc)[:250]
db.execute("UPDATE v8_import_items SET state='error',error=? WHERE id=?",(msg,item['id']))
failed.append({'row':item['row_number'],'message':msg})
db.execute('UPDATE v8_import_batches SET state=?,committed_at=? WHERE id=?',
('partial' if failed else 'committed',db.now(),batch_id))
# Keep unsupported Elementor/WooCommerce details in the existing release gate.
if batch['origin']=='wordpress-wxr':
for finding in json.loads(batch.get('warnings_json') or '[]')[:200]:
db.insert('''INSERT INTO cms_import_reviews(id,site_id,platform,row_number,issue,created_at)
VALUES(?,?,?,?,?,?)''',(uuid.uuid4().hex,site_id,'wordpress-wxr',int(finding.get('row',0)),
('v8:'+batch_id+': '+str(finding.get('issue','Requires review')))[:600],db.now()))
return {'batch_id':batch_id,'state':'partial' if failed else 'committed',
'created':created,'updated':updated,'skipped':skipped,'errors':failed}
def rollback(site_id,batch_id):
# Hold one write transaction across the conflict check and all reversions.
# A manual edit racing with rollback must not be accidentally overwritten.
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
batch=c.execute('SELECT * FROM v8_import_batches WHERE site_id=? AND id=?',(site_id,batch_id)).fetchone()
if not batch:raise HTTPException(404,'导入批次不存在')
if batch['state']=='rolled_back':return {'batch_id':batch_id,'state':'rolled_back','existing':True}
if batch['state'] not in ('committed','partial'):raise HTTPException(409,'批次还没有完成提交')
applied=c.execute("SELECT * FROM v8_import_items WHERE batch_id=? AND state='applied' ORDER BY row_number DESC",(batch_id,)).fetchall()
conflicts=[]
for item in applied:
current=c.execute('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,item['entry_id'])).fetchone()
if _row_hash(dict(current) if current else None)!=item['after_fingerprint']:
conflicts.append(item['route'])
if conflicts:raise HTTPException(409,{'message':'内容在导入后被修改,拒绝覆盖人工编辑','routes':conflicts})
cols=('type','title','route','slug','summary','body','cover','seo_title','seo_description',
'canonical','robots','meta_json','status','source','created_at','updated_at')
for item in applied:
if not item['before_json']:
c.execute('DELETE FROM entries WHERE site_id=? AND id=?',(site_id,item['entry_id']))
else:
before=json.loads(item['before_json'])
assignments=','.join(k+'=?' for k in cols)
c.execute(f'UPDATE entries SET {assignments} WHERE site_id=? AND id=?',
(*[before[k] for k in cols],site_id,item['entry_id']))
c.execute("UPDATE v8_import_items SET state='rolled_back' WHERE id=?",(item['id'],))
c.execute("UPDATE v8_import_batches SET state='rolled_back',rolled_back_at=? WHERE id=?",(db.now(),batch_id))
if batch['origin']=='wordpress-wxr':
c.execute("UPDATE cms_import_reviews SET state='resolved',reviewer='system',resolution='Source import batch rolled back',resolved_at=? WHERE site_id=? AND platform='wordpress-wxr' AND state='pending' AND issue LIKE ?",
(db.now(),site_id,'v8:'+batch_id+': %'))
return {'batch_id':batch_id,'state':'rolled_back','restored':len(applied)}
def parse_wxr(content):
"""Extract conservative WordPress records without writing into the site.
Unsupported builder configuration stays in source metadata for review.
"""
from defusedxml import ElementTree as ET
if len(content)>MAX_UPLOAD:raise ValueError('XML 文件超过限制')
root=ET.fromstring(content)
if root.tag!='rss':raise ValueError('这不是 WordPress WXR RSS 导出')
rows=[];ids=[];warnings=[]
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),'')
raw_typ=get('post_type') or 'post'
status=get('status') or 'publish'
if raw_typ in ('attachment','revision','nav_menu_item') or status not in ('publish','draft','private'):continue
if len(rows)>=MAX_ITEMS:raise ValueError('单批 WXR 最多1000条,请拆分文件')
wid=get('post_id')
title=get('title').strip() or get('post_name').strip()
if not title:continue
url=get('link')
slug=get('post_name') or re.sub(r'[^a-z0-9-]','-',title.lower())[:65] or uuid.uuid4().hex[:8]
route=urlsplit(url).path if url.startswith(('http://','https://')) else ''
if not route:route=f'/{slug}/' if raw_typ=='page' else f'/{raw_typ}/{slug}/'
metas={}
for pm in it:
if not pm.tag.endswith('}postmeta'):continue
k=v=''
for n in pm:
if n.tag.endswith('}meta_key'):k=n.text or ''
if n.tag.endswith('}meta_value'):v=n.text or ''
if k:metas[k]=v[:10000]
if any(k in metas for k in ('_elementor_data','_product_attributes')):
warnings.append({'row':len(rows)+1,'issue':'含复杂 Elementor/WooCommerce 组件,导入后需人工核对'})
rows.append({'标题':title,'路径':route,'正文':get('encoded'),
'SEO标题':metas.get('_yoast_wpseo_title') or metas.get('rank_math_title',''),
'SEO描述':metas.get('_yoast_wpseo_metadesc') or metas.get('rank_math_description',''),
'分类':raw_typ})
ids.append(f'wordpress:{wid}' if wid else f'wordpress:path:{route}')
return rows,ids,warnings
@router.post('/{site_id}/staging')
def api_stage(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return stage(site_id,payload.get('rows'),payload.get('mapping'),payload.get('content_type','product'),payload.get('origin','manual-import'))
except ValueError as e:raise HTTPException(400,str(e))
@router.post('/{site_id}/staging/file')
async def api_table_file(request:Request,site_id:str,file:UploadFile=File(...),
content_type:str=Form('product')):
enterprise.require(request,site_id,'editor')
from .table_loader import parse_table
blob=await file.read(MAX_UPLOAD+1)
try:
parsed=parse_table(file.filename or '',blob)
result=stage(site_id,parsed['rows'],kind=content_type,origin='table:'+parsed['filename'][:120])
result['columns']=parsed['columns']
return result
except (ValueError,UnicodeError,json.JSONDecodeError) as e:
raise HTTPException(400,str(e))
@router.post('/{site_id}/staging/wxr')
async def api_wxr(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
if not (file.filename or '').lower().endswith('.xml'):raise HTTPException(400,'仅支持 .xml 导出')
blob=await file.read(MAX_UPLOAD+1)
try:
rows,keys,warnings=parse_wxr(blob)
if not rows:raise ValueError('没有可导入的 WordPress 页面或文章')
result=stage(site_id,rows,{'title':'标题','route':'路径','body':'正文',
'seo_title':'SEO标题','seo_description':'SEO描述'},'mixed','wordpress-wxr',keys)
db.execute('UPDATE v8_import_batches SET warnings_json=? WHERE id=?',
(json.dumps(warnings,ensure_ascii=False),result['batch_id']))
return {**result,'warnings':warnings[:30]}
except ValueError as e:raise HTTPException(400,str(e))
@router.get('/{site_id}/staging')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return {'items':list_batches(site_id)}
@router.get('/{site_id}/staging/{batch_id}')
def api_detail(request:Request,site_id:str,batch_id:str):
enterprise.require(request,site_id,'viewer')
return batch_detail(site_id,batch_id)
@router.post('/{site_id}/staging/{batch_id}/commit')
def api_commit(request:Request,site_id:str,batch_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return commit(site_id,batch_id,payload.get('on_conflict','skip'))
except ValueError as e:raise HTTPException(400,str(e))
@router.post('/{site_id}/staging/{batch_id}/rollback')
def api_rollback(request:Request,site_id:str,batch_id:str):
enterprise.require(request,site_id,'owner')
return rollback(site_id,batch_id)
+5
View File
@@ -0,0 +1,5 @@
"""Single source of truth for the SiteForge platform release identity."""
import os
VERSION = "8.10.0-dev.1"
PLATFORM_NAME = "SiteForge Factory"
BUILD_SHA = os.environ.get("SITEFORGE_GIT_SHA", "unknown").strip()[:40] or "unknown"
+83
View File
@@ -0,0 +1,83 @@
"""Conservative WordPress WXR adapter helpers.
Never execute PHP, Elementor widgets, shortcodes or serialized metadata.
Only known text/image widgets are converted to sanitized HTML with warnings
for unknown proprietary elements; original metadata is always retained.
"""
import json, re
from html import escape
from .core import sanitize
WIDGETS={'heading','text-editor','image','button','icon-box','image-box','divider','spacer','video','html'}
def elementor_to_html(raw):
if not isinstance(raw,str) or not raw.strip():return {'html':'','warnings':[],'widgets':0}
try:data=json.loads(raw)
except (ValueError,TypeError):return {'html':'','warnings':['Elementor JSON 解析失败:保留原始字段,需人工检查'],'widgets':0}
if not isinstance(data,list):return {'html':'','warnings':['Elementor 数据根节点不是数组,需人工检查'],'widgets':0}
parts=[];warnings=[];count=[0]
def walk(elements,depth=0):
if depth>12: warnings.append('Elementor 嵌套超过安全限制');return
if not isinstance(elements,list):return
for item in elements[:300]:
if not isinstance(item,dict):continue
typ=item.get('widgetType') or ''
settings=item.get('settings') or {}
if not isinstance(settings,dict):settings={}
if typ:
count[0]+=1
if typ not in WIDGETS:
if len(warnings)<60:warnings.append('未转换的 Elementor 组件:'+str(typ)[:65])
elif typ=='heading':
size=settings.get('header_size','h2');size=size if size in ('h1','h2','h3','h4','h5','h6') else 'h2'
parts.append(f'<{size}>{escape(str(settings.get("title") or ""))}</{size}>')
elif typ=='text-editor':
parts.append(sanitize(str(settings.get('editor') or '')))
elif typ in ('image','image-box'):
image=settings.get('image') or {}
url=image.get('url','') if isinstance(image,dict) else ''
if isinstance(url,str) and url.startswith(('https://','http://','/')):
parts.append(f'<p><img src="{escape(url,quote=True)}" alt="{escape(str(settings.get("title") or ""),quote=True)}"></p>')
if typ=='image-box' and settings.get('title_text'):
parts.append('<h3>'+escape(str(settings['title_text']))+'</h3>')
if typ=='image-box' and settings.get('description_text'):
parts.append('<p>'+escape(str(settings['description_text']))+'</p>')
elif typ=='button':
href=settings.get('link') or {}
dest=href.get('url','') if isinstance(href,dict) else ''
if isinstance(dest,str) and dest.startswith(('https://','http://','/')):
parts.append(f'<a href="{escape(dest,quote=True)}">{escape(str(settings.get("text") or "更多信息"))}</a>')
elif typ=='html':
parts.append(sanitize(str(settings.get('html') or '')))
elif typ in ('video','divider','spacer','icon-box'):
if len(warnings)<60:warnings.append(f'{typ} 布局/嵌入需人工复核')
walk(item.get('elements') or [],depth+1)
walk(data)
return {'html':'\n'.join(parts),'warnings':warnings,'widgets':count[0]}
def wp_taxonomy(item):
data={}
for node in item:
if node.tag.rsplit('}',1)[-1]!='category':continue
domain=node.get('domain','')
name=(node.text or '').strip()
if not name:continue
if domain in ('category','product_cat','vehicle_cat'):data['category']=name
elif domain in ('product_brand','brand','pa_brand'):data['brand']=name
elif domain=='product_type':data['product_type']=name
elif domain.startswith('pa_'):data[domain[3:]]=name
return data
def wp_media_map(root):
"""WXR attachment ID -> original URL, useful for featured and gallery images."""
media={}
for item in root.findall('./channel/item'):
typ=post_id=url=''
for node in item:
local=node.tag.rsplit('}',1)[-1]
if local=='post_type':typ=node.text or ''
elif local=='post_id':post_id=node.text or ''
elif local=='attachment_url':url=node.text or ''
if typ=='attachment' and post_id and url and url.startswith(('http://','https://')):
media[post_id]=url
return media
+126
View File
@@ -0,0 +1,126 @@
'''Sole build executor: lease-based queue, isolated process, cancellation, timeout, retries.'''
import json
import os
import signal
import subprocess
import sys
import time
import uuid
from . import db, enterprise, builder, jobs
running=True
WORKER_ID=uuid.uuid4().hex[:12]
def heartbeat():
"""Container health heartbeat, independent of the HTTP server.
This single-worker probe is deliberately not used as a multi-worker
membership system; multi-worker orchestration needs separate leases.
"""
path=db.DATA/'worker-heartbeat'
path.parent.mkdir(parents=True,exist_ok=True)
temp=path.with_suffix(f'.{WORKER_ID}.tmp')
temp.write_text(WORKER_ID,encoding='ascii')
os.replace(temp,path)
def stop(*_):
global running
running=False
def claim():
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
query='''SELECT id FROM build_jobs AS j WHERE j.state IN ('queued','retrying')
AND (j.available_at IS NULL OR j.available_at <= ?)
AND NOT EXISTS (SELECT 1 FROM build_jobs AS other WHERE other.site_id=j.site_id AND other.state='running')
ORDER BY j.created_at,j.id LIMIT 1'''
if db.POSTGRES:query+=' FOR UPDATE SKIP LOCKED'
item=c.execute(query,(db.now(),)).fetchone()
if not item:return None
c.execute('''UPDATE build_jobs SET state='running',attempts=attempts+1,
started_at=?,worker_id=?,lease_until=?,heartbeat_at=? WHERE id=?''',
(db.now(),WORKER_ID,jobs.iso_after(45),db.now(),item['id']))
return dict(c.execute('SELECT * FROM build_jobs WHERE id=?',(item['id'],)).fetchone())
def complete(job, value=None, error=None, retryable=False, cancelled=False, timedout=False):
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
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:
return
if cancelled or current['cancel_requested']:
state='cancelled'
elif error is None:
state='done'
elif retryable and current['attempts']<current['max_attempts']:
state='retrying'
else:state='failed'
delay=min(60,2**current['attempts']) if state=='retrying' else 0
c.execute('''UPDATE build_jobs SET state=?,result_json=?,error=?,finished_at=?,
available_at=?,worker_id=NULL,lease_until=NULL WHERE id=?''',
(state,json.dumps(value or {},ensure_ascii=False),error or '',
db.now() if state not in ('retrying',) else None,
jobs.iso_after(delay) if delay else db.now(),job['id']))
def execute_subprocess(job):
env=os.environ.copy()
env['SITEFORGE_DATA_DIR']=str(db.DATA)
env['SITEFORGE_DB_FILE']=str(db.DB_FILE)
output=db.DATA/'job_results'/f"{job['id']}.json"
output.parent.mkdir(parents=True,exist_ok=True)
output.unlink(missing_ok=True)
proc=subprocess.Popen([sys.executable,'-m','app.job_runner',job['id']],
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env)
began=time.monotonic()
interval=float(os.getenv('SITEFORGE_WORKER_POLL_SECONDS','0.5'))
while proc.poll() is None:
heartbeat()
row=db.one('SELECT cancel_requested FROM build_jobs WHERE id=?',(job['id'],))
if not running or (row and row['cancel_requested']):
proc.terminate()
try:proc.wait(timeout=3)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
return None,'Task cancelled',False,True
if time.monotonic()-began>job['timeout_seconds']:
proc.terminate()
try:proc.wait(timeout=3)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
return None,'Build timed out',True,False
db.execute('UPDATE build_jobs SET lease_until=?,heartbeat_at=? WHERE id=? AND state=? AND worker_id=?',
(jobs.iso_after(45),db.now(),job['id'],'running',WORKER_ID))
time.sleep(interval)
if output.exists():
try:
data=json.loads(output.read_text(encoding='utf-8'))
if data.get('ok'):return data['result'],None,False,False
return None,data.get('error','Build failed'),bool(data.get('retryable',False)),False
except Exception:return None,'Invalid job result',True,False
return None,f'Build process exited with code {proc.returncode}',True,False
def run_once(executor=None):
job=claim()
if job is None:return None
try:
if executor is None:
value,error,retryable,cancelled=execute_subprocess(job)
else:
# In-process executor is explicitly for deterministic unit tests only.
try:value=executor(job['site_id'],json.loads(job['payload_json']));error=None;retryable=False;cancelled=False
except Exception as exc:value=None;error=str(exc);retryable=isinstance(exc,(TimeoutError,ConnectionError));cancelled=False
complete(job,value,error,retryable,cancelled)
except Exception as exc:
complete(job,error=str(exc),retryable=True)
return job['id']
def main():
db.init();enterprise.setup()
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
while running:
heartbeat()
jobs.recover_expired()
if not run_once():
from . import redis_signal
redis_signal.wait(float(os.getenv('SITEFORGE_JOB_POLL_SECONDS','1.5')))
if __name__=='__main__':main()
+18
View File
@@ -0,0 +1,18 @@
"""Docker worker liveness probe: no localhost Web dependency."""
import os
import time
from pathlib import Path
from . import db
def healthy(path=None, max_age=None):
file=Path(path) if path is not None else db.DATA/'worker-heartbeat'
limit=float(max_age if max_age is not None else os.getenv('SITEFORGE_WORKER_HEALTH_MAX_AGE','45'))
if limit <= 0:return False
try:
age=time.time()-file.stat().st_mtime
return -5 <= age < limit
except OSError:
return False
if __name__=='__main__':
raise SystemExit(0 if healthy() else 1)
+319
View File
@@ -0,0 +1,319 @@
"""Resumable, bounded WordPress REST migration sessions for SiteForge V8.1.
Each advance fetches at most one WP REST page. No unknown remote HTML or JS is
executed, and no imported content is published until an explicit finalization.
The outbound client pins validated public DNS results to the connected socket.
This is deliberately a single-instance pilot workflow, not a background bulk
crawler. Progress survives HTTP failures and server restarts.
"""
from __future__ import annotations
import json
import uuid
from urllib.parse import urlsplit, urlencode
from bs4 import BeautifulSoup
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise, safe_http, migration_audit, v8_workflows
from .core import route_path
router=APIRouter(prefix='/api/migrations/sites',tags=['V8.1 WordPress migration'])
PAGE_SIZE=50
MAX_WARNINGS=500
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS wp_migration_sessions (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
origin TEXT NOT NULL, state TEXT NOT NULL, phase TEXT NOT NULL DEFAULT 'pages',
next_page INTEGER NOT NULL DEFAULT 1, max_items INTEGER NOT NULL,
fetched INTEGER NOT NULL DEFAULT 0, reviewed INTEGER NOT NULL DEFAULT 0,
warnings_json TEXT NOT NULL DEFAULT '[]', last_error TEXT NOT NULL DEFAULT '',
cancelled INTEGER NOT NULL DEFAULT 0, lease_until TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS wp_sessions_by_site ON wp_migration_sessions(site_id,created_at);
CREATE TABLE IF NOT EXISTS wp_migration_pages(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
phase TEXT NOT NULL, page INTEGER NOT NULL, batch_id TEXT,
source_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(session_id,phase,page));
CREATE TABLE IF NOT EXISTS wp_migration_urls(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
route TEXT NOT NULL, original_url TEXT NOT NULL,
seo_title TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
canonical TEXT NOT NULL DEFAULT '', robots TEXT NOT NULL DEFAULT '',
h1 TEXT NOT NULL DEFAULT '', status INTEGER NOT NULL DEFAULT 200,
PRIMARY KEY(session_id,route));
''')
# Keep old migration setup callers forward-compatible with V8.2 schema.
from . import wp_migration_v82
wp_migration_v82.setup()
from . import wp_migration_v83
wp_migration_v83.setup()
def _origin(url):
p=urlsplit(str(url).strip())
if p.scheme not in ('http','https') or not p.hostname or p.username or p.password or p.query or p.fragment:
raise ValueError('请输入公开可访问的 WordPress 站点 HTTP(S) 地址(不要带账号、参数或片段)')
if len(url)>500 or (p.path and ('..' in p.path.split('/') or '\\' in p.path)):
raise ValueError('原网站地址不合法')
if p.port not in (None,80,443):raise ValueError('仅允许标准 HTTP/HTTPS 端口')
return f'{p.scheme}://{p.netloc}{p.path.rstrip("/")}/'
def _one(site_id,session_id):
s=db.one('SELECT * FROM wp_migration_sessions WHERE site_id=? AND id=?',(site_id,session_id))
if s is None:raise HTTPException(404,'WordPress 迁移会话不存在')
return s
def create(site_id,origin,max_items=2000):
v8_workflows._require_site(site_id)
origin=_origin(origin)
if not 1<=int(max_items)<=20_000:raise ValueError('迁移上限须为 1–20,000 条')
sid=uuid.uuid4().hex
db.insert('''INSERT INTO wp_migration_sessions(id,site_id,origin,state,phase,max_items,created_at,updated_at)
VALUES(?,?,?,'created','pages',?,?,?)''',(sid,site_id,origin,int(max_items),db.now(),db.now()))
return details(site_id,sid)
def details(site_id,session_id):
s=_one(site_id,session_id)
s['warnings']=json.loads(s.pop('warnings_json') or '[]')
s['batches']=db.rows('''SELECT p.phase,p.page,p.source_count,b.state AS batch_state,p.batch_id
FROM wp_migration_pages p LEFT JOIN v8_import_batches b ON b.id=p.batch_id
WHERE p.session_id=? ORDER BY CASE p.phase WHEN 'pages' THEN 0 ELSE 1 END,p.page''',(session_id,))
s['baseline_count']=db.one('SELECT COUNT(*) n FROM wp_migration_urls WHERE session_id=?',(session_id,))['n']
return s
def list_sessions(site_id):
v8_workflows._require_site(site_id)
return db.rows('''SELECT id,origin,state,phase,next_page,max_items,fetched,reviewed,created_at,updated_at,last_error
FROM wp_migration_sessions WHERE site_id=? ORDER BY created_at DESC LIMIT 100''',(site_id,))
def _plain(html):
return BeautifulSoup(html or '','html.parser').get_text(' ',strip=True)[:600]
def _transform(source,kind,origin):
"""Returns a staged row + audit row, or a manual review warning."""
if not isinstance(source,dict):raise ValueError('WordPress 返回了非对象记录')
wp_id=source.get('id')
if not isinstance(wp_id,int) or wp_id<1:raise ValueError('WordPress 记录没有合法数字 ID')
link=source.get('link') or ''
p=urlsplit(link)
base=urlsplit(origin)
if (p.scheme not in ('http','https') or p.hostname!=base.hostname or p.port!=base.port or
p.username or p.password or p.query or p.fragment or not p.path):
raise ValueError(f'记录 #{wp_id} 没有本站的固定 URL(可能使用了 ?p= 参数)')
route=route_path(p.path)
title=_plain((source.get('title') or {}).get('rendered',''))
if not title:raise ValueError(f'记录 #{wp_id} 缺少标题')
content=(source.get('content') or {}).get('rendered','')
excerpt=_plain((source.get('excerpt') or {}).get('rendered',''))
seo=source.get('yoast_head_json') or {}
if not isinstance(seo,dict):seo={}
embedded=source.get('_embedded') or {}
media=embedded.get('wp:featuredmedia') or []
cover=media[0].get('source_url','') if isinstance(media,list) and media and isinstance(media[0],dict) else ''
record={'标题':title,'路径':route,'正文':content,'摘要':excerpt,
'SEO标题':seo.get('title') or '', 'SEO描述':seo.get('description') or '',
'Canonical':seo.get('canonical') or '', '封面':cover,'类型':'page' if kind=='pages' else 'post'}
baseline={'route':route,'url':link,'seo_title':record['SEO标题'],
'description':record['SEO描述'],'canonical':record['Canonical'],
'robots':'noindex,follow' if seo.get('robots',{}).get('index')=='noindex' else '',
'h1':title,'status':200}
return record,baseline,f'wp-rest:{kind}:{wp_id}'
def _fetch(origin,phase,page):
# A fixed page size makes the cursor stable for restart and retries.
query=urlencode({'per_page':PAGE_SIZE,'page':page,'_embed':'1','status':'publish'})
url=origin+'wp-json/wp/v2/'+phase+'?'+query
r=safe_http.fetch(url,max_bytes=8_000_000,timeout=18,redirects=0,headers={'Accept':'application/json'})
if r.status_code==400 and page>1:return [] # WP returns 400 on page > available total
r.raise_for_status()
value=r.json()
if not isinstance(value,list):raise ValueError('WordPress REST 返回的不是记录数组')
if len(value)>PAGE_SIZE:raise ValueError('WordPress REST 单页数据超过限制')
return value
def advance(site_id,session_id):
s=_one(site_id,session_id)
if s['state']=='ready':return details(site_id,session_id)
if s['state'] in ('completed','completed_with_warnings','cancelled','limit_reached'):
raise HTTPException(409,'迁移会话已结束或达到采集上限')
# Lease makes concurrent browser clicks / retries safe. Expired claims can resume.
from .jobs import iso_after
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
item=c.execute('SELECT * FROM wp_migration_sessions WHERE id=? AND site_id=?',(session_id,site_id)).fetchone()
if item['cancelled']:raise HTTPException(409,'迁移会话已取消')
if item['state']=='running' and item['lease_until'] and item['lease_until']>db.now():
raise HTTPException(409,'迁移采集正在执行,请勿重复提交')
c.execute("UPDATE wp_migration_sessions SET state='running',lease_until=?,last_error='',updated_at=? WHERE id=?",
(iso_after(60),db.now(),session_id))
phase=s['phase'];page=s['next_page']
try:
result=_fetch(s['origin'],phase,page)
if db.one('SELECT cancelled FROM wp_migration_sessions WHERE id=?',(session_id,))['cancelled']:
raise HTTPException(409,'迁移已取消')
existing=db.one('SELECT * FROM wp_migration_pages WHERE session_id=? AND phase=? AND page=?',(session_id,phase,page))
candidates=[];audit=[];keys=[];warnings=[]
if not existing:
seen=set()
for source in result:
try:
row,baseline,key=_transform(source,phase,s['origin'])
if row['路径'] in seen:raise ValueError('同一页中源 URL 重复')
if db.one('SELECT route FROM wp_migration_urls WHERE session_id=? AND route=?',(session_id,row['路径'])):
raise ValueError('原站多个内容共用路径 '+row['路径']+';已保留第一次采集的记录')
seen.add(row['路径']);candidates.append(row);audit.append(baseline);keys.append(key)
if not row['SEO标题'] or not row['SEO描述']:
warnings.append({'phase':phase,'page':page,'issue':'REST 没有提供完整 SEO 元数据:'+row['路径']+';需要与原网页 HTML 对比'})
if row['封面']:
warnings.append({'phase':phase,'page':page,'issue':'外部特色图片仍需本地化:'+row['路径']})
if any(tag.get('src','').startswith(('http://','https://','//')) for tag in BeautifulSoup(row['正文'],'html.parser').find_all('img',src=True)):
warnings.append({'phase':phase,'page':page,'issue':'正文中存在远端图片,需要本地化:'+row['路径']})
if source.get('acf') or 'elementor' in str(source.get('content',{})).lower():
warnings.append({'phase':phase,'page':page,'issue':'包含 ACF/页面构建器信息,必须人工复核布局:'+row['路径']})
except (ValueError,TypeError,AttributeError) as e:
warnings.append({'phase':phase,'page':page,'issue':str(e)[:230]})
if s['fetched']+len(result)>s['max_items']:
# Do not commit a partial last page and falsely claim an exhaustive scan.
db.execute("UPDATE wp_migration_sessions SET state='limit_reached',lease_until=NULL,last_error=?,updated_at=? WHERE id=?",
('达到本会话最大采集上限;请建立更大上限的新迁移会话',db.now(),session_id))
return details(site_id,session_id)
origin_key=f'wp-session:{session_id}:{phase}:{page}'
batch=db.one('SELECT id FROM v8_import_batches WHERE site_id=? AND origin=?',(site_id,origin_key))
if candidates and not batch:
staged=v8_workflows.stage(site_id,candidates,
{'title':'标题','route':'路径','body':'正文','summary':'摘要',
'seo_title':'SEO标题','seo_description':'SEO描述','canonical':'Canonical','cover':'封面'},
'mixed',origin_key,keys)
batch_id=staged['batch_id']
else:batch_id=batch['id'] if batch else None
# Keep bounded WordPress source IDs/ACF refs with the staged content.
from . import wp_migration_v83
if batch_id and not existing:
wp_migration_v83.staged_relations(site_id,session_id,result,phase,batch_id)
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
for item in audit:
c.execute('''INSERT OR IGNORE INTO wp_migration_urls(session_id,route,original_url,seo_title,description,canonical,robots,h1,status)
VALUES(?,?,?,?,?,?,?,?,?)''',(session_id,item['route'],item['url'],item['seo_title'],
item['description'],item['canonical'],item['robots'],item['h1'],200))
c.execute('''INSERT OR IGNORE INTO wp_migration_pages(session_id,phase,page,batch_id,source_count)
VALUES(?,?,?,?,?)''',(session_id,phase,page,batch_id,len(result)))
if batch_id and not existing:
wp_migration_v83.refresh_staged_relations(site_id,session_id)
else:warnings=[]
previous_warnings=json.loads(s['warnings_json'] or '[]')
next_phase=phase;next_page=page+1;state='paused'
if len(result)<PAGE_SIZE:
if phase=='pages':next_phase='posts';next_page=1
else:state='ready'
if s['fetched']+len(result)>=s['max_items'] and state!='ready':state='limit_reached'
db.execute('''UPDATE wp_migration_sessions SET state=?,phase=?,next_page=?,fetched=fetched+?,
warnings_json=?,reviewed=reviewed+?,lease_until=NULL,updated_at=? WHERE id=?''',
(state,next_phase,next_page,len(result) if not existing else 0,
json.dumps((previous_warnings+warnings)[:MAX_WARNINGS],ensure_ascii=False),len(warnings),db.now(),session_id))
return details(site_id,session_id)
except Exception as exc:
# Do not overwrite a concurrent cancel. Preserve cursor; retries are idempotent.
if db.one('SELECT cancelled FROM wp_migration_sessions WHERE id=?',(session_id,))['cancelled']:
raise HTTPException(409,'迁移会话已取消') from exc
db.execute("UPDATE wp_migration_sessions SET state='paused',lease_until=NULL,last_error=?,updated_at=? WHERE id=?",
(str(exc)[:500],db.now(),session_id))
if isinstance(exc,HTTPException):raise
raise HTTPException(502,'WordPress 采集失败,可重试当前页:'+str(exc)[:200]) from exc
def finalize(site_id,session_id):
s=_one(site_id,session_id)
if s['state'] in ('completed','completed_with_warnings'):
return details(site_id,session_id)
if s['state']!='ready':raise HTTPException(409,'必须先完成 WordPress 页面与文章的全部采集')
pages=db.rows('SELECT * FROM wp_migration_pages WHERE session_id=? ORDER BY CASE phase WHEN ? THEN 0 ELSE 1 END,page',(session_id,'pages'))
if not pages:raise HTTPException(409,'尚未读取任何页面')
results=[]
for p in pages:
if not p['batch_id']:continue
outcome=v8_workflows.commit(site_id,p['batch_id'],'skip')
results.append(outcome)
if outcome['state']!='committed':
raise HTTPException(409,'部分内容导入失败,请处理批次后重试')
# Source taxonomy archive routes enter the SEO baseline, never silently generated.
from . import wp_migration_v83
wp_migration_v83.register_archive_baseline(site_id,session_id)
source=db.rows('SELECT * FROM wp_migration_urls WHERE session_id=?',(session_id,))
if not source:raise HTTPException(409,'没有可以迁移的有效原站 URL')
manifest={'records':[{'route':r['route'],'original_url':r['original_url'],
'seo_title':r['seo_title'],'description':r['description'],
'canonical':r['canonical'],'robots':r['robots'],'h1':r['h1'],'status':200} for r in source],
'assets':[],'errors':[],'warnings':[]}
migration_audit.save(site_id,manifest,replace=False)
# Compare discovered Sitemap URLs and unresolved media before release.
from . import wp_migration_v82
extra_findings=wp_migration_v82._findings(site_id,session_id)
from . import wp_migration_v83
extra_findings += wp_migration_v83.findings(site_id,session_id)
# Findings are reviewable in the existing enterprise gate. Do not silently
# treat a 'completed_with_warnings' migration as releasable.
if s['reviewed'] or extra_findings:
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
for index, finding in enumerate(json.loads(s['warnings_json'] or '[]')+[{'issue':x} for x in extra_findings]):
issue=('wp-rest:'+session_id+': '+str(finding.get('issue','需要人工核对')))[:600]
if c.execute('SELECT id FROM cms_import_reviews WHERE site_id=? AND platform=? AND issue=?',
(site_id,'wordpress-rest',issue)).fetchone():continue
c.execute('''INSERT INTO cms_import_reviews(id,site_id,platform,row_number,issue,state,created_at)
VALUES(?,?,?,?,?,?,?)''',(uuid.uuid4().hex,site_id,'wordpress-rest',index+1,issue,'pending',db.now()))
final_state='completed_with_warnings' if s['reviewed'] or extra_findings else 'completed'
db.execute('UPDATE wp_migration_sessions SET state=?,updated_at=? WHERE id=?',(final_state,db.now(),session_id))
return {'session':details(site_id,session_id),'batch_results':results,'baseline_saved':len(source),
'requires_review':bool(s['reviewed'] or extra_findings),'sitemap_audit':wp_migration_v82.sitemap_status(site_id,session_id)}
def cancel(site_id,session_id):
s=_one(site_id,session_id)
if s['state'] in ('completed','completed_with_warnings'):raise HTTPException(409,'已完成的迁移不能取消;可使用批次回滚')
db.execute("UPDATE wp_migration_sessions SET state='cancelled',cancelled=1,lease_until=NULL,updated_at=? WHERE id=?",
(db.now(),session_id))
return details(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions')
def api_create(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return create(site_id,payload.get('origin',''),payload.get('max_items',2000))
except (ValueError,TypeError) as ex:raise HTTPException(400,str(ex))
@router.get('/{site_id}/wordpress/sessions')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return {'items':list_sessions(site_id)}
@router.get('/{site_id}/wordpress/sessions/{session_id}')
def api_detail(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'viewer')
return details(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions/{session_id}/advance')
def api_advance(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'editor')
return advance(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions/{session_id}/finalize')
def api_finalize(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'editor')
return finalize(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions/{session_id}/cancel')
def api_cancel(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'editor')
return cancel(site_id,session_id)
+323
View File
@@ -0,0 +1,323 @@
"""SiteForge V8.2: bounded WordPress sitemap discovery, safe media localization
and durable worker-backed REST scanning. All flows require explicit authorization
and remain subject to the existing SiteForge release-review gates.
"""
from __future__ import annotations
import hashlib
import json
import uuid
import zlib
import time
from urllib.parse import urljoin, urlsplit
from bs4 import BeautifulSoup
from defusedxml import ElementTree
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise, jobs, safe_http, wp_migration_v81 as wp
from .core import route_path
router=APIRouter(prefix='/api/migrations/sites',tags=['V8.2 WordPress migration'])
MAX_SITEMAP_URLS=20_000
MAX_SITEMAP_FILES=60
MAX_MEDIA_BYTES=8_000_000
ALLOWED={'image/png':'.png','image/jpeg':'.jpg','image/webp':'.webp','image/gif':'.gif','image/avif':'.avif'}
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS wp_migration_sitemap(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
route TEXT NOT NULL, original_url TEXT NOT NULL,
PRIMARY KEY(session_id,route));
CREATE TABLE IF NOT EXISTS wp_migration_media(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
original_url TEXT NOT NULL, local_url TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL, bytes INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL,
PRIMARY KEY(session_id,original_url));
''')
def _site_url(origin,url):
"""Same host and scoped WordPress base directory (no external sitemap)."""
p=urlsplit(url)
root=urlsplit(origin)
if (p.scheme!=root.scheme or p.username or p.password or p.query or p.fragment
or p.hostname != root.hostname or p.port != root.port):
raise ValueError('仅允许同一 WordPress 站点的无查询参数 Sitemap 地址')
prefix=root.path.rstrip('/')+'/'
if not p.path.startswith(prefix):raise ValueError('Sitemap 超出了来源站点的路径范围')
return url
def _xml_bytes(response):
raw=response.content
if len(raw)>2_000_000:raise ValueError('Sitemap 文件大于 2MB')
if raw[:2]==b'\x1f\x8b':
z=zlib.decompressobj(16+zlib.MAX_WBITS)
raw=z.decompress(raw,3_000_001)
if len(raw)>3_000_000 or z.unconsumed_tail or not z.eof:
raise ValueError('Sitemap 压缩包解压后超出限制')
if len(raw)>3_000_000:raise ValueError('Sitemap 解压后超出限制')
if b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
raise ValueError('Sitemap 禁止 DTD/实体声明')
return raw
def discover(site_id,session_id,sitemap_url=None):
s=wp._one(site_id,session_id)
if s['state'] in ('cancelled','completed','completed_with_warnings','running'):
raise HTTPException(409,'当前迁移状态不能修改 Sitemap 清单')
base=s['origin']
start=_site_url(base,sitemap_url or urljoin(base,'wp-sitemap.xml'))
queue=[start];processed=set();urls={}
while queue:
if len(processed)>=MAX_SITEMAP_FILES:raise ValueError('Sitemap 文件数超过上限')
target=queue.pop(0)
if target in processed:continue
_site_url(base,target)
processed.add(target)
response=safe_http.fetch(target,max_bytes=2_000_000,timeout=15,redirects=0,
headers={'Accept':'application/xml,text/xml'})
response.raise_for_status()
root=ElementTree.fromstring(_xml_bytes(response))
root_name=root.tag.split('}')[-1]
if root_name not in ('urlset','sitemapindex'):
raise ValueError('不是有效的 XML Sitemap 或 Sitemap Index')
for node in root:
if node.tag.split('}')[-1] not in ('sitemap','url'):continue
raw=next((v.text.strip() for v in node if v.tag.split('}')[-1]=='loc' and v.text),None)
if not raw:continue
if root_name=='sitemapindex':
queue.append(_site_url(base,raw))
if len(queue)+len(processed)>MAX_SITEMAP_FILES:raise ValueError('Sitemap Index 数量超过上限')
else:
# Different domains and out-of-scope paths cannot enter our baseline.
_site_url(base,raw)
urlp=urlsplit(raw)
normalized=route_path(urlp.path)
if normalized in urls and urls[normalized]!=raw:raise ValueError('Sitemap 中存在冲突 URL: '+normalized)
urls[normalized]=raw
if len(urls)>MAX_SITEMAP_URLS:raise ValueError('Sitemap URL 数量超过上限')
if not urls:raise ValueError('Sitemap 没有找到任何合法页面 URL')
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
c.execute('DELETE FROM wp_migration_sitemap WHERE session_id=?',(session_id,))
c.executemany('INSERT INTO wp_migration_sitemap(session_id,route,original_url) VALUES(?,?,?)',
((session_id,r,u) for r,u in urls.items()))
return sitemap_status(site_id,session_id)
def sitemap_status(site_id,session_id):
wp._one(site_id,session_id)
total=db.one('SELECT COUNT(*) n FROM wp_migration_sitemap WHERE session_id=?',(session_id,))['n']
missing=db.rows('''SELECT m.route,m.original_url FROM wp_migration_sitemap m
LEFT JOIN wp_migration_urls u ON u.session_id=m.session_id AND u.route=m.route
WHERE m.session_id=? AND u.route IS NULL ORDER BY m.route LIMIT 100''',(session_id,))
missing_count=db.one('''SELECT COUNT(*) n FROM wp_migration_sitemap m LEFT JOIN wp_migration_urls u
ON u.session_id=m.session_id AND u.route=m.route
WHERE m.session_id=? AND u.route IS NULL''',(session_id,))['n']
return {'discovered':total,'missing_count':missing_count,'missing_sample':missing}
def _image_valid(mime,raw):
if mime=='image/jpeg':return raw.startswith(b'\xff\xd8\xff')
if mime=='image/png':return raw.startswith(b'\x89PNG\r\n\x1a\n')
if mime=='image/gif':return raw.startswith((b'GIF87a',b'GIF89a'))
if mime=='image/webp':return len(raw)>12 and raw[:4]==b'RIFF' and raw[8:12]==b'WEBP'
if mime=='image/avif':return len(raw)>12 and raw[4:8]==b'ftyp' and b'avif' in raw[8:32]
return False
def _download_image(origin,url,site_id,session_id):
p=urlsplit(url);base=urlsplit(origin)
# Explicitly don't follow offsite CDNs or credential-bearing URLs automatically.
if p.scheme not in ('http','https') or p.hostname!=base.hostname or p.port!=base.port or p.username or p.password:
raise ValueError('外部 CDN 或跨域资源需要单独授权')
r=safe_http.fetch(url,max_bytes=MAX_MEDIA_BYTES,timeout=15,redirects=0)
r.raise_for_status()
mime=r.headers.get('content-type','').split(';',1)[0].lower().strip()
if mime not in ALLOWED or not _image_valid(mime,r.content):raise ValueError('不支持或无效的图片类型')
# Store only simple flat images; never use remote paths as local filenames.
name='wp-'+hashlib.sha256(url.encode()).hexdigest()[:24]+ALLOWED[mime]
target=db.DATA/'media'/site_id/name
target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists():
temp=target.with_suffix(target.suffix+'.'+uuid.uuid4().hex[:8]+'.tmp')
try:temp.write_bytes(r.content);temp.replace(target)
finally:temp.unlink(missing_ok=True)
return '/media/'+name,len(r.content)
def _srcset_urls(value):
"""Extract standard WordPress srcset URL candidates, preserving width/density hints."""
if not isinstance(value,str):return []
result=[]
for candidate in value.split(','):
fields=candidate.strip().split()
if fields and fields[0].startswith(('http://','https://','//')):
result.append(fields[0])
return result
def _media_urls(content):
"""Collect all remote media references, including responsive and lazy images."""
urls=[content.get('cover') or '']
soup=BeautifulSoup(content.get('body') or '','html.parser')
for tag in soup.find_all(['img','source','video']):
for key in ('src','data-src','poster'):
if tag.get(key):urls.append(tag[key])
for key in ('srcset','data-srcset'):
urls.extend(_srcset_urls(tag.get(key)))
return list(dict.fromkeys(u for u in urls if isinstance(u,str)
and u.startswith(('https://','http://','//'))))
def _rewrite_srcset(value,converted):
candidates=[];changed=False
for part in str(value).split(','):
fields=part.strip().split()
if not fields:continue
url=fields[0]
if url in converted:
fields[0]=converted[url];changed=True
candidates.append(' '.join(fields))
return ', '.join(candidates),changed
def _rewrite_media_html(body,converted):
soup=BeautifulSoup(body,'html.parser');changed=False
for tag in soup.find_all(['img','source','video']):
for key in ('src','data-src','poster'):
if tag.get(key) in converted:
tag[key]=converted[tag[key]];changed=True
for key in ('srcset','data-srcset'):
if not tag.get(key):continue
new,updated=_rewrite_srcset(tag[key],converted)
if updated:
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:
fields=new.strip().split()
if fields and fields[0]==tag.get('src'):
tag.attrs.pop(key,None)
return str(soup) if changed else body,changed
def localize_media(site_id,session_id,limit=20):
s=wp._one(site_id,session_id)
if not 1<=int(limit)<=40:raise ValueError('单次处理媒体数量须为 1–40')
if s['state'] in ('running','cancelled','completed','completed_with_warnings'):
raise HTTPException(409,'请先暂停采集,且必须在确认导入前执行媒体本地化')
batches=db.rows('''SELECT b.id FROM wp_migration_pages p JOIN v8_import_batches b ON b.id=p.batch_id
WHERE p.session_id=? AND b.site_id=? AND b.state='staged' ORDER BY p.phase,p.page''',(session_id,site_id))
items=[]
for b in batches:
items.extend(db.rows('SELECT id,content_json FROM v8_import_items WHERE batch_id=? ORDER BY row_number',(b['id'],)))
unique=list(dict.fromkeys(url for item in items
for url in _media_urls(json.loads(item['content_json']))))
if len(unique)>4000:raise ValueError('媒体引用数量超过单次迁移安全上限 4000')
failures=[];done=0
for url in unique:
existing=db.one('SELECT * FROM wp_migration_media WHERE session_id=? AND original_url=?',(session_id,url))
if existing and existing['state']=='localized':continue
if done>=int(limit):break
done+=1
try:
local,size=_download_image(s['origin'],url,site_id,session_id)
state='localized';error=''
except Exception as exc:
local='';size=0;state='review_required';error=str(exc)[:240]
failures.append({'url':url,'reason':error})
db.execute('''INSERT INTO wp_migration_media(session_id,original_url,local_url,state,bytes,error,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(session_id,original_url) DO UPDATE SET
local_url=excluded.local_url,state=excluded.state,bytes=excluded.bytes,
error=excluded.error,updated_at=excluded.updated_at''',
(session_id,url,local,state,size,error,db.now()))
# Rewrite staged content only, never an already-committed or hand-edited entry.
converted={r['original_url']:r['local_url'] for r in db.rows('SELECT * FROM wp_migration_media WHERE session_id=? AND state=?',(session_id,'localized'))}
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
for item in items:
obj=json.loads(item['content_json']);modified=False
if obj.get('cover') in converted:obj['cover']=converted[obj['cover']];modified=True
if obj.get('body'):
updated,replaced=_rewrite_media_html(obj['body'],converted)
if replaced:obj['body']=updated;modified=True
if modified:c.execute('UPDATE v8_import_items SET content_json=? WHERE id=?',(json.dumps(obj,ensure_ascii=False),item['id']))
return {'attempted':done,'localized':len(converted),'failed':failures,'total_candidates':len(unique),
'remaining':len([x for x in unique if x not in converted]),'requires_review':bool(failures)}
def _findings(site_id,session_id):
# Sitemap-only URLs must block claims of complete migration.
sitemap=sitemap_status(site_id,session_id)
issues=[f'站点地图中未由 REST 采集的页面:{r["route"]}' for r in sitemap['missing_sample']]
if sitemap['missing_count']>len(sitemap['missing_sample']):
issues.append(f'其他未采集 Sitemap URL 数量:{sitemap["missing_count"]-len(sitemap["missing_sample"])}')
for r in db.rows('SELECT original_url,error FROM wp_migration_media WHERE session_id=? AND state!=?',
(session_id,'localized')):
issues.append('媒体未本地化:'+r['original_url'][:170]+';'+r['error'][:90])
for b in db.rows('''SELECT p.batch_id FROM wp_migration_pages p JOIN v8_import_batches b ON b.id=p.batch_id
WHERE p.session_id=? AND b.site_id=?''',(session_id,site_id)):
if not b['batch_id']:continue
for record in db.rows('SELECT route,content_json FROM v8_import_items WHERE batch_id=?',(b['batch_id'],)):
content=json.loads(record['content_json'])
urls=_media_urls(content)
if urls:
issues.append('正文或封面仍引用外部图片:'+record['route'])
return list(dict.fromkeys(issues))[:500]
def submit_scan(site_id,session_id,actor,key=None):
s=wp._one(site_id,session_id)
if s['state'] in ('ready','cancelled','completed','completed_with_warnings','limit_reached'):
raise HTTPException(409,'迁移采集已经完成或被取消')
return jobs.submit_migration(site_id,session_id,actor,key)
def run_scan(site_id,session_id,job_id,max_pages=500):
"""Run by isolated Worker subprocess only; do not auto-commit staged data."""
count=0
while count<max_pages:
job=db.one('SELECT cancel_requested FROM build_jobs WHERE id=? AND site_id=?',(job_id,site_id))
if job is None or job['cancel_requested']:return {'state':'cancelled','pages_processed':count}
s=wp._one(site_id,session_id)
if s['state']=='running' and s['lease_until'] and s['lease_until']>db.now():
# Previous worker may have died while fetching; wait for its per-page
# lease instead of burning all task retries during the 60-second lease.
waited=0
while waited<70 and wp._one(site_id,session_id)['lease_until'] and wp._one(site_id,session_id)['lease_until']>db.now():
row=db.one('SELECT cancel_requested FROM build_jobs WHERE id=?',(job_id,))
if not row or row['cancel_requested']:return {'state':'cancelled','pages_processed':count}
time.sleep(1);waited+=1
if waited>=70:raise TimeoutError('迁移页锁未释放,等待后再重试')
if s['state'] in ('ready','cancelled','limit_reached','completed','completed_with_warnings'):
return {'state':s['state'],'pages_processed':count,'fetched':s['fetched']}
wp.advance(site_id,session_id)
count+=1
return {'state':'page_limit','pages_processed':count}
@router.post('/{site_id}/wordpress/sessions/{session_id}/sitemap')
def api_sitemap(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return discover(site_id,session_id,payload.get('url'))
except (ValueError,safe_http.OutboundError,ElementTree.ParseError) as exc:raise HTTPException(400,str(exc)) from exc
@router.get('/{site_id}/wordpress/sessions/{session_id}/sitemap')
def api_sitemap_status(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'viewer')
return sitemap_status(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions/{session_id}/media/localize')
def api_media(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return localize_media(site_id,session_id,int(payload.get('limit',20)))
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc)) from exc
@router.post('/{site_id}/wordpress/sessions/{session_id}/scan')
def api_scan(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return submit_scan(site_id,session_id,request.state.actor,payload.get('idempotency_key'))
except (jobs.JobConflict,jobs.JobCapacityError) as exc:raise HTTPException(409,str(exc)) from exc
+282
View File
@@ -0,0 +1,282 @@
"""V8.3: checkpointed public WordPress category/tag/media catalog and staged references.
Catalogs are *metadata only*, not a full fidelity migration of WordPress custom
fields, image files, taxonomy archive templates or WooCommerce variations.
All remote traffic uses the pinned safe_http client. Nothing here publishes a site.
"""
from __future__ import annotations
import json
import re
from urllib.parse import urlencode, urlsplit
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise, safe_http, wp_migration_v81 as wp
router = APIRouter(prefix='/api/migrations/sites', tags=['V8.3 WordPress catalog'])
RESOURCE = {'categories': 'category', 'tags': 'tag', 'media': 'media'}
PAGE_SIZE = 50
MAX_ROWS = 5000
MAX_STORED_META = 16000
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS wp_catalog_cursors(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
resource TEXT NOT NULL, next_page INTEGER NOT NULL DEFAULT 1,
fetched INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL,
PRIMARY KEY(session_id,resource));
CREATE TABLE IF NOT EXISTS wp_catalog_items(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
resource TEXT NOT NULL, wp_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '', slug TEXT NOT NULL DEFAULT '',
parent_id INTEGER NOT NULL DEFAULT 0, source_url TEXT NOT NULL DEFAULT '',
meta_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY(session_id,resource,wp_id));
CREATE INDEX IF NOT EXISTS wp_catalog_session_resource
ON wp_catalog_items(session_id,resource);
CREATE TABLE IF NOT EXISTS wp_catalog_refs(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
source_key TEXT NOT NULL, resource TEXT NOT NULL,
target_id INTEGER NOT NULL,
PRIMARY KEY(session_id,source_key,resource,target_id));
CREATE INDEX IF NOT EXISTS wp_catalog_refs_by_target
ON wp_catalog_refs(session_id,resource,target_id);
''')
def _safe_id_list(values):
if not isinstance(values, list):return []
return list(dict.fromkeys(v for v in values[:100] if type(v) is int and 0 < v <= 2_000_000_000))
def _source_meta(source, phase):
"""Bounded public-source snapshot kept separate from SiteForge business copy."""
meta = {'platform':'wordpress','wp_id':source['id'],'source_type':phase,
'category_ids':_safe_id_list(source.get('categories')),
'tag_ids':_safe_id_list(source.get('tags')),
'featured_media_id':source.get('featured_media') if type(source.get('featured_media')) is int else 0}
acf=source.get('acf')
if isinstance(acf,dict):
packed=json.dumps(acf,ensure_ascii=False)
meta['acf_keys']=[str(k)[:100] for k in list(acf)[:80]]
if len(packed.encode('utf-8'))<=MAX_STORED_META:
meta['acf_snapshot']=acf
else:meta['acf_truncated']=True
meta['requires_builder_review']=bool(source.get('elementor_data') or source.get('_elementor_data'))
return meta
def _record(resource, obj):
if not isinstance(obj,dict) or type(obj.get('id')) is not int or obj['id']<1:
raise ValueError('WordPress 分类/标签/媒体记录缺少有效 ID')
raw_url=obj.get('source_url','') if resource=='media' else obj.get('link','')
meta={}
if resource=='media':
meta={'mime_type':str(obj.get('mime_type') or '')[:120],
'alt_text':str(obj.get('alt_text') or '')[:500],
'caption':str((obj.get('caption') or {}).get('rendered',''))[:1000] if isinstance(obj.get('caption'),dict) else '',
'media_details':{'width':(obj.get('media_details') or {}).get('width'),
'height':(obj.get('media_details') or {}).get('height')}}
title=obj.get('title')
name=obj.get('name') or (title.get('rendered','') if isinstance(title,dict) else '')
return (int(obj['id']),str(name or '')[:350],
str(obj.get('slug') or '')[:200],int(obj.get('parent') or 0) if resource=='categories' else 0,
str(raw_url or '')[:1000],json.dumps(meta,ensure_ascii=False))
def _fetch(origin,resource,page):
url=origin+'wp-json/wp/v2/'+resource+'?'+urlencode({'per_page':PAGE_SIZE,'page':page})
resp=safe_http.fetch(url,max_bytes=4_000_000,timeout=18,redirects=0,headers={'Accept':'application/json'})
if resp.status_code==400 and page>1:
# End of pagination only for REST invalid-page-number, not arbitrary 400s.
try: code=resp.json().get('code')
except (ValueError,AttributeError):code=None
if code=='rest_post_invalid_page_number':return []
resp.raise_for_status()
rows=resp.json()
if not isinstance(rows,list) or len(rows)>PAGE_SIZE:raise ValueError('REST 分类目录返回数据不符合单页限制')
return rows
def sync_page(site_id,session_id,resource):
s=wp._one(site_id,session_id)
if resource not in RESOURCE:raise ValueError('不支持的 WordPress 资源目录')
if s['state'] in ('running','cancelled','completed','completed_with_warnings'):
raise HTTPException(409,'当前会话状态不允许采集分类目录')
cursor=db.one('SELECT * FROM wp_catalog_cursors WHERE session_id=? AND resource=?',(session_id,resource))
if cursor and cursor['done']:return catalog_status(site_id,session_id)
page=cursor['next_page'] if cursor else 1
rows=_fetch(s['origin'],resource,page)
parsed=[_record(resource,obj) for obj in rows]
if (cursor['fetched'] if cursor else 0)+len(rows)>MAX_ROWS:
raise ValueError('单个分类目录最多采集 5000 条;请分批处理')
# Atomic cursor progression: a failure never skips an uncommitted page.
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
actual=c.execute('SELECT next_page,done FROM wp_catalog_cursors WHERE session_id=? AND resource=?',
(session_id,resource)).fetchone()
if (actual and (actual['next_page']!=page or actual['done'])) or (not actual and page!=1):
# Another operator already committed the same page. Never count it twice.
return catalog_status(site_id,session_id)
for wp_id,name,slug,parent,source,meta in parsed:
c.execute('''INSERT INTO wp_catalog_items(session_id,resource,wp_id,name,slug,parent_id,source_url,meta_json)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(session_id,resource,wp_id) DO UPDATE SET
name=excluded.name,slug=excluded.slug,parent_id=excluded.parent_id,
source_url=excluded.source_url,meta_json=excluded.meta_json''',
(session_id,resource,wp_id,name,slug,parent,source,meta))
c.execute('''INSERT INTO wp_catalog_cursors(session_id,resource,next_page,fetched,done,updated_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(session_id,resource) DO UPDATE SET
next_page=excluded.next_page,fetched=excluded.fetched,done=excluded.done,updated_at=excluded.updated_at''',
(session_id,resource,page+1,(cursor['fetched'] if cursor else 0)+len(rows),int(len(rows)<PAGE_SIZE),db.now()))
refresh_staged_relations(site_id,session_id)
return catalog_status(site_id,session_id)
def catalog_status(site_id,session_id):
wp._one(site_id,session_id)
states={r['resource']:{'next_page':r['next_page'],'fetched':r['fetched'],'done':bool(r['done'])}
for r in db.rows('SELECT * FROM wp_catalog_cursors WHERE session_id=?',(session_id,))}
for resource in RESOURCE:
states.setdefault(resource,{'next_page':1,'fetched':0,'done':False,'not_started':True})
return {'session_id':session_id,'resources':states,
'term_counts':{r:db.one('SELECT COUNT(*) n FROM wp_catalog_items WHERE session_id=? AND resource=?',
(session_id,r))['n'] for r in RESOURCE}}
def staged_relations(site_id,session_id,source_rows,phase,batch_id):
"""Add WP references to staged items only; never overwrite committed edits."""
if not batch_id:return
sources={f'wp-rest:{phase}:{s["id"]}':s for s in source_rows if isinstance(s,dict) and type(s.get('id')) is int}
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
items=c.execute('SELECT * FROM v8_import_items WHERE batch_id=? AND state=?',(batch_id,'staged')).fetchall()
for item in items:
src=sources.get(item['source_key'])
if not src:continue
content=json.loads(item['content_json']);meta=content.get('meta') or {}
meta['wp_source']=_source_meta(src,phase)
content['meta']=meta
for resource,ids in (('categories',meta['wp_source']['category_ids']),('tags',meta['wp_source']['tag_ids']),
('media',[meta['wp_source']['featured_media_id']] if meta['wp_source']['featured_media_id'] else [])):
for target_id in ids:
c.execute('INSERT OR IGNORE INTO wp_catalog_refs(session_id,source_key,resource,target_id) VALUES(?,?,?,?)',
(session_id,item['source_key'],resource,target_id))
c.execute('UPDATE v8_import_items SET content_json=? WHERE id=?',
(json.dumps(content,ensure_ascii=False),item['id']))
refresh_staged_relations(site_id,session_id)
def refresh_staged_relations(site_id,session_id):
wp._one(site_id,session_id)
terms={kind:{r['wp_id']:r for r in db.rows('SELECT * FROM wp_catalog_items WHERE session_id=? AND resource=?',
(session_id,kind))} for kind in ('categories','tags','media')}
batches=db.rows('''SELECT DISTINCT p.batch_id FROM wp_migration_pages p
JOIN v8_import_batches b ON b.id=p.batch_id WHERE p.session_id=? AND b.site_id=? AND b.state='staged' ''',
(session_id,site_id))
changed=0
for batch in batches:
if not batch['batch_id']:continue
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
for row in c.execute('SELECT * FROM v8_import_items WHERE batch_id=? AND state=?',(batch['batch_id'],'staged')).fetchall():
content=json.loads(row['content_json']);meta=content.get('meta') or {}
wp_source=meta.get('wp_source') or {}
if not wp_source:continue
fields=meta.setdefault('fields',{})
for kind,key in (('categories','category'),('tags','tags')):
names=[terms[kind][i]['name'] for i in wp_source.get('category_ids' if kind=='categories' else 'tag_ids',[])
if i in terms[kind] and terms[kind][i]['name']]
if names:
fields[key]=names[0] if key=='category' else ', '.join(names[:30])
wp_source[kind+'_resolved']=names
mid=wp_source.get('featured_media_id')
if mid and mid in terms['media']:
wp_source['featured_media_original']=terms['media'][mid]['source_url']
text=json.dumps(content,ensure_ascii=False)
if text!=row['content_json']:
c.execute('UPDATE v8_import_items SET content_json=? WHERE id=?',(text,row['id']))
changed+=1
return {'updated':changed}
def register_archive_baseline(site_id,session_id):
"""Add taxonomy archive URLs to the source audit, without fabricating pages."""
from .core import route_path
session=wp._one(site_id,session_id)
base=urlsplit(session['origin'])
added=0
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
items=c.execute("SELECT resource,source_url FROM wp_catalog_items WHERE session_id=? AND resource IN ('categories','tags')",(session_id,)).fetchall()
for term in items:
url=term['source_url']
if not url:continue
p=urlsplit(url)
if (p.scheme!=base.scheme or p.hostname!=base.hostname or p.port!=base.port or p.username or p.password
or p.query or p.fragment or not p.path.startswith(base.path)):
continue
try:route=route_path(p.path)
except ValueError:continue
if c.execute('SELECT route FROM wp_migration_urls WHERE session_id=? AND route=?',(session_id,route)).fetchone():
continue
c.execute('INSERT INTO wp_migration_urls(session_id,route,original_url,status) VALUES(?,?,?,200)',
(session_id,route,url))
added+=1
return {'taxonomy_archives_added':added}
def findings(site_id,session_id):
"""All discovered references must be either matched or explicitly reviewed."""
wp._one(site_id,session_id)
seen=[]
terms={kind:{r['wp_id'] for r in db.rows('SELECT wp_id FROM wp_catalog_items WHERE session_id=? AND resource=?',
(session_id,kind))} for kind in RESOURCE}
batches=db.rows('SELECT batch_id FROM wp_migration_pages WHERE session_id=? AND batch_id IS NOT NULL',(session_id,))
for b in batches:
for r in db.rows('SELECT route,content_json FROM v8_import_items WHERE batch_id=?',(b['batch_id'],)):
obj=json.loads(r['content_json']);source=(obj.get('meta') or {}).get('wp_source') or {}
for kind,key in (('categories','category_ids'),('tags','tag_ids')):
for n in source.get(key,[]):
if n not in terms[kind]:seen.append(f'缺少 WordPress {kind} #{n},内容 {r["route"]}')
mid=source.get('featured_media_id')
if mid and mid not in terms['media']:
seen.append(f'特色图片媒体记录 #{mid} 未核对,内容 {r["route"]}')
if source.get('acf_keys'):
seen.append(f'ACF 自定义字段需要人工核对 {r["route"]}: '+', '.join(source['acf_keys'][:8]))
if source.get('acf_truncated'):
seen.append(f'ACF 内容过大未保留完整快照 {r["route"]}')
if source.get('requires_builder_review'):
seen.append(f'页面构建器布局需要人工复核 {r["route"]}')
# Broken parent references are explicit review issues.
base=urlsplit(wp._one(site_id,session_id)['origin'])
for item in db.rows('SELECT resource,wp_id,name,parent_id,source_url FROM wp_catalog_items WHERE session_id=?',(session_id,)):
if item['resource']=='categories' and item['parent_id'] and item['parent_id'] not in terms['categories']:
seen.append(f'分类 #{item["wp_id"]} 的上级分类 #{item["parent_id"]} 未采集')
if item['resource'] in ('categories','tags') and not item['name']:
seen.append(f'WordPress {item["resource"]} #{item["wp_id"]} 缺少名称')
if item['resource'] in ('categories','tags') and item['source_url']:
parsed=urlsplit(item['source_url'])
if parsed.scheme!=base.scheme or parsed.hostname!=base.hostname or parsed.port!=base.port:
seen.append(f'WordPress 分类/标签归档 URL 与源站不同源:{item["source_url"][:150]}')
return list(dict.fromkeys(seen))[:500]
@router.get('/{site_id}/wordpress/sessions/{session_id}/catalog')
def api_catalog_status(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'viewer')
return catalog_status(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions/{session_id}/catalog/{resource}/next')
def api_catalog_next(request:Request,site_id:str,session_id:str,resource:str):
enterprise.require(request,site_id,'editor')
try:return sync_page(site_id,session_id,resource)
except (ValueError,safe_http.OutboundError) as exc:raise HTTPException(400,str(exc)) from exc
@router.post('/{site_id}/wordpress/sessions/{session_id}/catalog/refresh')
def api_catalog_refresh(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
return refresh_staged_relations(site_id,session_id)
+113
View File
@@ -0,0 +1,113 @@
name: siteforge-v8-10-enterprise
services:
siteforge:
build:
context: .
dockerfile: Dockerfile.enterprise
args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.10.0-dev.1
init: true
restart: unless-stopped
ports:
- '127.0.0.1:${SITEFORGE_PORT:-8787}:8787'
environment:
SITEFORGE_DATA_DIR: /data
SITEFORGE_MAX_PENDING_JOBS: ${SITEFORGE_MAX_PENDING_JOBS:-100}
SITEFORGE_MAX_PENDING_JOBS_PER_SITE: ${SITEFORGE_MAX_PENDING_JOBS_PER_SITE:-5}
SITEFORGE_OUTBOUND_PORTS: ${SITEFORGE_OUTBOUND_PORTS:-80,443}
SITEFORGE_MYSQL_ALLOWED_HOSTS: ${SITEFORGE_MYSQL_ALLOWED_HOSTS:-}
SITEFORGE_MYSQL_ALLOWED_TABLES: ${SITEFORGE_MYSQL_ALLOWED_TABLES:-}
SITEFORGE_DATABASE_URL: postgresql://${SITEFORGE_PG_USER:-siteforge}:${SITEFORGE_PG_PASSWORD:?Set strong PG password}@postgres:5432/${SITEFORGE_PG_DB:-siteforge}
SITEFORGE_REDIS_URL: redis://redis:6379/0
SITEFORGE_ADMIN_PASSWORD: ${SITEFORGE_ADMIN_PASSWORD:?Set admin password}
SITEFORGE_SESSION_SECRET: ${SITEFORGE_SESSION_SECRET:?Set session secret}
SITEFORGE_COOKIE_SECURE: ${SITEFORGE_COOKIE_SECURE:-false}
SITEFORGE_PUBLISH_ROOT: /published
SITEFORGE_REQUIRE_RELEASE_APPROVAL: 'true'
SITEFORGE_DEPLOY_STAGING_URL: ${SITEFORGE_DEPLOY_STAGING_URL:-}
SITEFORGE_DEPLOY_STAGING_SECRET: ${SITEFORGE_DEPLOY_STAGING_SECRET:-}
SITEFORGE_DEPLOY_STAGING_SECRET_REF: ${SITEFORGE_DEPLOY_STAGING_SECRET_REF:-}
SITEFORGE_DEPLOY_PRODUCTION_URL: ${SITEFORGE_DEPLOY_PRODUCTION_URL:-}
SITEFORGE_DEPLOY_PRODUCTION_SECRET: ${SITEFORGE_DEPLOY_PRODUCTION_SECRET:-}
SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF: ${SITEFORGE_DEPLOY_PRODUCTION_SECRET_REF:-}
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
volumes:
- siteforge_data:/data
- siteforge_published:/published
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
healthcheck:
test: ['CMD','python','-c',"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health',timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
worker:
image: siteforge/web:8.10.0-dev.1
command: ['python','-m','app.worker']
init: true
restart: unless-stopped
environment:
SITEFORGE_DATA_DIR: /data
SITEFORGE_MAX_PENDING_JOBS: ${SITEFORGE_MAX_PENDING_JOBS:-100}
SITEFORGE_MAX_PENDING_JOBS_PER_SITE: ${SITEFORGE_MAX_PENDING_JOBS_PER_SITE:-5}
SITEFORGE_OUTBOUND_PORTS: ${SITEFORGE_OUTBOUND_PORTS:-80,443}
SITEFORGE_MYSQL_ALLOWED_HOSTS: ${SITEFORGE_MYSQL_ALLOWED_HOSTS:-}
SITEFORGE_MYSQL_ALLOWED_TABLES: ${SITEFORGE_MYSQL_ALLOWED_TABLES:-}
SITEFORGE_MYSQL_CA_FILE: ${SITEFORGE_MYSQL_CA_FILE:-}
SITEFORGE_MYSQL_READ_USER: ${SITEFORGE_MYSQL_READ_USER:-}
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_REDIS_URL: redis://redis:6379/0
SITEFORGE_BUILD_TIMEOUT_SECONDS: ${SITEFORGE_BUILD_TIMEOUT_SECONDS:-300}
SITEFORGE_BUILD_MAX_ATTEMPTS: ${SITEFORGE_BUILD_MAX_ATTEMPTS:-3}
depends_on:
siteforge: {condition: service_started}
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
volumes:
- siteforge_data:/data
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
healthcheck:
test: ["CMD", "python", "-m", "app.worker_health"]
interval: 20s
timeout: 5s
retries: 3
start_period: 30s
postgres:
image: postgres:16.9-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${SITEFORGE_PG_USER:-siteforge}
POSTGRES_PASSWORD: ${SITEFORGE_PG_PASSWORD:?Set PG password}
POSTGRES_DB: ${SITEFORGE_PG_DB:-siteforge}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL','pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 10s
timeout: 5s
retries: 10
security_opt: ['no-new-privileges:true']
redis:
image: redis:7.4.2-alpine
restart: unless-stopped
command: ['redis-server','--save','', '--appendonly','no', '--maxmemory','64mb','--maxmemory-policy','allkeys-lru']
healthcheck:
test: ['CMD','redis-cli','ping']
interval: 10s
timeout: 3s
retries: 10
security_opt: ['no-new-privileges:true']
volumes:
postgres_data:
siteforge_data:
siteforge_published:
+8
View File
@@ -0,0 +1,8 @@
# Optional overlay: create private_secrets/ and private_ca/ on the Docker host.
# docker compose -f compose.yaml -f compose.mysql-secrets.yaml up -d --build
# These paths must never be committed or copied into the application image.
services:
worker:
volumes:
- ./private_secrets:/run/secrets:ro
- ./private_ca:/etc/siteforge/certs:ro
+25
View File
@@ -0,0 +1,25 @@
# 在正式 DNS / HTTPS 域名配置完成后才启用:
# docker compose -f compose.yaml -f compose.public.yaml up -d --build
services:
siteforge:
environment:
SITEFORGE_COOKIE_SECURE: "true"
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
siteforge:
condition: service_healthy
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
SITEFORGE_DOMAIN: ${SITEFORGE_DOMAIN:?Set SITEFORGE_DOMAIN in .env}
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
+24
View File
@@ -0,0 +1,24 @@
name: siteforge-release-agent
services:
agent:
build:
context: .
dockerfile: Dockerfile.enterprise
args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.10.0-dev.1
command: ['python','-m','uvicorn','app.deploy_agent:app','--host','0.0.0.0','--port','8790']
restart: unless-stopped
environment:
SITEFORGE_DEPLOY_SECRET: ${SITEFORGE_DEPLOY_SECRET:?Set 32+ character release secret}
SITEFORGE_DEPLOY_ROOT: /srv/releases
volumes:
- releases:/srv/releases
ports:
- '127.0.0.1:8790:8790'
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
volumes:
releases:
+83
View File
@@ -0,0 +1,83 @@
name: siteforge-v8-10
services:
siteforge:
build:
context: .
dockerfile: Dockerfile
args:
SITEFORGE_GIT_SHA: ${SITEFORGE_GIT_SHA:-unknown}
image: siteforge/web:8.10.0-dev.1
init: true
restart: unless-stopped
ports:
- "127.0.0.1:${SITEFORGE_PORT:-8787}:8787"
environment:
SITEFORGE_DATA_DIR: /data
SITEFORGE_MAX_PENDING_JOBS: ${SITEFORGE_MAX_PENDING_JOBS:-100}
SITEFORGE_MAX_PENDING_JOBS_PER_SITE: ${SITEFORGE_MAX_PENDING_JOBS_PER_SITE:-5}
SITEFORGE_OUTBOUND_PORTS: ${SITEFORGE_OUTBOUND_PORTS:-80,443}
SITEFORGE_MYSQL_ALLOWED_HOSTS: ${SITEFORGE_MYSQL_ALLOWED_HOSTS:-}
SITEFORGE_MYSQL_ALLOWED_TABLES: ${SITEFORGE_MYSQL_ALLOWED_TABLES:-}
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_COOKIE_SECURE: "${SITEFORGE_COOKIE_SECURE:-false}"
SITEFORGE_PUBLISH_ROOT: /published
SITEFORGE_REQUIRE_RELEASE_APPROVAL: "true"
volumes:
- siteforge_data:/data
- siteforge_published:/published
read_only: true
tmpfs:
- /tmp:size=128m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 128
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
worker:
image: siteforge/web:8.10.0-dev.1
depends_on:
siteforge: {condition: service_started}
init: true
restart: unless-stopped
command: ["python", "-m", "app.worker"]
environment:
SITEFORGE_DATA_DIR: /data
SITEFORGE_MAX_PENDING_JOBS: ${SITEFORGE_MAX_PENDING_JOBS:-100}
SITEFORGE_MAX_PENDING_JOBS_PER_SITE: ${SITEFORGE_MAX_PENDING_JOBS_PER_SITE:-5}
SITEFORGE_OUTBOUND_PORTS: ${SITEFORGE_OUTBOUND_PORTS:-80,443}
SITEFORGE_MYSQL_ALLOWED_HOSTS: ${SITEFORGE_MYSQL_ALLOWED_HOSTS:-}
SITEFORGE_MYSQL_ALLOWED_TABLES: ${SITEFORGE_MYSQL_ALLOWED_TABLES:-}
SITEFORGE_MYSQL_CA_FILE: ${SITEFORGE_MYSQL_CA_FILE:-}
# Development-only env secret references; use compose.mysql-secrets.yaml for production files.
SITEFORGE_MYSQL_READ_USER: ${SITEFORGE_MYSQL_READ_USER:-}
SITEFORGE_MYSQL_READ_PASSWORD: ${SITEFORGE_MYSQL_READ_PASSWORD:-}
SITEFORGE_JOB_POLL_SECONDS: "1.5"
SITEFORGE_BUILD_TIMEOUT_SECONDS: "${SITEFORGE_BUILD_TIMEOUT_SECONDS:-300}"
SITEFORGE_BUILD_MAX_ATTEMPTS: "${SITEFORGE_BUILD_MAX_ATTEMPTS:-3}"
volumes:
- siteforge_data:/data
read_only: true
tmpfs:
- /tmp:size=128m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 128
healthcheck:
test: ["CMD", "python", "-m", "app.worker_health"]
interval: 20s
timeout: 5s
retries: 3
start_period: 30s
volumes:
siteforge_data:
siteforge_published:
+9
View File
@@ -0,0 +1,9 @@
{$SITEFORGE_DOMAIN} {
encode zstd gzip
reverse_proxy siteforge:8787
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
+11
View File
@@ -0,0 +1,11 @@
# 6.0.0 / SiteForge Factory
- 统一 Web API、用户会话响应、健康检查、后台界面和部署名称的软件版本为 `6.0.0`;主题独立版本继续为 `1.1.0`。
- 移除 Web 请求线程中的直接静态构建;所有 UI 构建操作都提交 202 任务。
- 队列增加客户端 Idempotency-Key、活跃任务去重、取消、子进程超时和有限重试;同一网站运行任务互斥。
- Worker 加入租约续期、过期任务恢复、可取消的执行进程、可配置超时和重试次数。
- 锁定直接和传递依赖版本;添加 GitHub Actions Python 测试、JS 语法和 Docker 构建配置。
- 继承 V5 全套主题、内容导入/装配、视觉编辑、迁移审计、审批和静态导出。
- 通过 58 项测试;另用真实 Uvicorn+独立 Worker 进行 HTTP 登录、创建站点、排队构建、预览和 ZIP 下载。
**非本轮完成**:Art Design Pro 官方工程替换、PostgreSQL/Redis 分布式基础设施、深度 CMS 全格式支持、任意服务器发布与生产安全压力测试。
+17
View File
@@ -0,0 +1,17 @@
# SiteForge v0.3 变更
## 从 v0.2 到 v0.3
- 新增 `app/blueprints.py`:受限的页面区块协议、区块输入校验、字段占位符、动态内容数据源和网页 HTML 渲染。
- 四套主题新增 `manifest.json` 与 9 种 `page-presets.json`,支持独立行业主题的页面预设。
- 增加 11 类实际渲染的页面区块及共享响应式 `blocks.css`。
- 新增完整屏可视化编辑器 `/editor/{site_id}/{entry_id}`:区块拖拽排序、属性面板、预览、保存、构建。
- 新增站点内容类型字段模型管理及数据结构存储、导航编辑和静态绑定参数表。
- 新增 `/apply-theme` 批量一键套版,以及 `/scaffold` 默认栏目初始化。
- 旧的 WordPress / CSV / XLSX / JSON / 静态导出和登录机制保留,已有旧站内容不自动覆盖。
- 新增 Art Design Pro Vue3 / Element Plus 独立对接示例(非官方完整前端代码)。
- 编写新增端到端 API 单测,旧用例继续回归。
## 风险与后续重点
开发版暂未实现基于官方 Art Design Pro 工程的实际构建、复杂页面组件多级布局、全站实时预览、复杂插件迁移、分布式任务队列、企业角色权限、公共云多目标部署。Docker 尚须在用户的运行环境验证镜像构建及持久卷。
+57
View File
@@ -0,0 +1,57 @@
# SiteForge V5 商业主题交付说明
> V5 是在 V4 原有后台、内容模型、编辑器与构建流程上迭代的**行业网站主题交付候选版**。完成页面功能及技术回归,不代表现已取得各类网站的商用法律、运营或视觉验收。
## 本轮实际开发
- 四个独立主题 Automotive、Industrial、Corporate、Minimal,继续保留各自 14 类页面模板。
- 页面视觉重新整理:商业版字体排版、节奏、卡片、详情与参数布局、按钮交互、移动端适配;四套不同视觉方向。
- 主题自带本地 SVG 视觉示意素材,可离线浏览;上传企业正式图片或 Logo 后,会优先展示真实素材。内置插画不是实际产品图片,请正式交付前替换。
- 全站 Logo 优先使用网站实际配置。导航多级菜单、品牌与分类目录、产品画廊、筛选、对比页面结构等保持数据驱动。
- 移动端导航与筛选空状态、键盘焦点、减少动态效果偏好等交互改进。
- 站点 OG 标题/摘要与主题色,继承站点已有 Title/SEO/Canonical/Robots,不自行修改迁移来源 URL。
- 建立商业交付报告 `commercial-readiness.json`,提示演示内容、占位图、Logo、询盘渠道及隐私说明等待人工验证事项。报告也在 `siteforge-report.json` 包含摘要。这与静态质量检测分开,**不会虚构已合规或已上线**。
- 新增主题 `1.1.0`。保留 V4 主题 `1.0.0` 的完整模板快照供已锁定旧主题的站点使用;更旧的 `0.3.0` 仍保留。
- 修正后台主题预览的商业 CSS 与本地 SVG 资源加载,使其与构建所用主题文件一致。
## 四套主题的业务定位
| 主题 | 风格与重点 | 常见栏目 |
| --- | --- | --- |
| Automotive | 深蓝与暖金、大幅车辆展示、车型与品牌分区 | 首页/车型/品牌/参数/新闻/案例/联系 |
| Industrial | 浅蓝与工程网格、产品分级、规格表 | 首页/分类/产品/规格/案例/技术文章/联系 |
| Corporate | 现代紫白体系、业务能力与项目展示 | 首页/服务/关于/案例/新闻/联系 |
| Minimal | 中性色编辑式布局、作品陈列 | 首页/作品/服务/简介/联系 |
## 快速使用
1. Docker Desktop 启动后,在项目目录初始化 `.env`,然后运行 `docker compose up -d --build`。
2. `http://localhost:8787/` 登录。`/theme-gallery` 查看主题,`/factory-studio` 创建网站。
3. 在网站项目中初始化完整栏目,添加企业资料、Logo、正式邮箱、产品与文章。
4. 选择主题版本 `1.1.0`、预览、生成静态站并在 ZIP 中查看 `commercial-readiness.json` 与 `siteforge-report.json`。
5. 完成真实设备、内容、图片版权、SEO、表单、法律页面与性能检查后再上线。
## 商业交付前人工验收清单
- [ ] 企业 Logo、品牌颜色、产品主图、图库和版权授权材料均已填入;不再使用演示素材。
- [ ] 首页、关于、产品、品牌、新闻、案例、联系与 404 页面逐页核对;不允许空栏或虚构数据。
- [ ] PC 1440px/1024px、手机 390px/360px 等设备检查,尤其是中文/英文长标题和多级菜单。
- [ ] 真实询盘接收渠道已配置并实际发送验证;如连接第三方表单,需要其隐私和防垃圾邮件机制。
- [ ] Title、Description、Canonical、Robots、Sitemap 和历史 URL 与旧站基线一致(仅迁移项目)。
- [ ] 图片与 PDF、内部链接、搜索、筛选、参数/画廊和键盘操作实际验证。
- [ ] 业务文案和业绩指标经过企业审核,隐私政策/法律页按目标市场要求配置。
- [ ] 部署环境的 HTTPS、缓存、日志、备份、回滚、安全与压力测试已通过。
## 当前不在本轮保证范围
不保证任意 WordPress/Elementor/ACF/WooCommerce 无损迁移;不提供任意代码主题安全安装、内置邮件服务器、完整 Art Design Pro 官方仓库整合、PostgreSQL/Redis、多云一键发布,也未完成正式生产环境合规或多浏览器全面测试。构建通过只是技术成功,`commercially_certified` 固定为 `false`。
## 测试方法
```bash
python -m pytest -q
PYTHONPATH=. SITEFORGE_DEMO_OUT=/tmp/siteforge-demo python scripts/make_theme_demo.py
python scripts/make_offline_preview.py /tmp/siteforge-demo /tmp/siteforge-preview.html
```
静态演示使用虚构内容,包含各主题的本地 `visual/hero.svg`;不依赖第三方图片服务。
+54
View File
@@ -0,0 +1,54 @@
# SiteForge 7.1.0 — 对照《COMPLETION_PLAN(1).md》优化结果
> 对照文件:用户提供《SiteForge 网站工厂 — 完整实现与生产落地计划》v2.0(2026-10-22)。
> 状态:**P0 代码修复及本地测试完成;真实 Docker / 云 CI / 浏览器网络访问 / 线上回滚尚待指定部署环境验收。**
> 注意:该计划评审的是 v5 源码及 v4 运行实例;本项目的实际起点是 **V7.0.0**。不应把计划中的 v5 数字直接改写成实际运行结论。
## 评审中的关键结论
1. **“代码更新 ≠ 生产部署已生效”**:保留生产实例的健康版本和提交标识对账,杜绝仅凭 ZIP 文件判断升级成功。
2. **P0 必须先修**:预览导航根路径 404、Compose 重复构建标签、Worker 错误继承 Web HTTP 探针、favicon、Linux CI 和离线恢复。
3. **安全底座不足仍是最高风险**:任意 URL 的 DNS 解析与重定向、数据导入包的 XXE/压缩炸弹、数据库只读权限、密钥引用策略,必须先在 P1 统一设计而非每个连接器各自实现。
4. **发布闭环尚未生产验收**:不可变构建、审批、原子发布、线上探测、回滚、CDN 控制需正式环境测试。
5. **多语言需要内容级映射**:仅克隆站点不能形成正确的翻译组/hreflang。
## 本次 P0 工作与验证证据
| 文档任务 | 实际修改 | 验证状态 |
|---|---|---|
| P0-1 平台/部署版本一致 | 平台改为 7.1.0;health 返回 `version` 与 `commit`;后台文字、Docker 标签、init 配置对应升级;Dockerfile 加 OCI revision 标签 | 本地 API / UI 代码测试通过;目标服务器镜像摘要仍需部署后对账 |
| P0-2 预览根链接 404 | `app/preview_rewrite.py`:仅在预览 HTTP 响应中改写 href/src/action/poster;绝对外站 URL 不动;静态 ZIP 原封不动 | 新增真实构建/TestClient 回归,检查 `/products/` 预览状态 200 |
| P0-3 Worker 健康检查 | 去除 Dockerfile 的 Web 专属内置探针;Web 仍检查 HTTP;Worker 使用 `/data/worker-heartbeat` 独立探针 | 单元及 Compose 结构测试通过;Docker Engine 未运行 |
| P0-4 favicon | 新增 `/favicon.ico` 真正文件与路由 | HTTP 测试 200 |
| P0-5 同镜像标签重复构建 | Web 唯一进行 build,Worker 引用同一镜像并依赖 Web 容器启动;本地 / 企业 Compose 同步调整 | Compose 结构测试通过;GitHub CI 新增单命令 `up -d --build` 冒烟,尚待 CI 云端运行 |
| P0-6 跨平台 CI | `scripts/check.sh` 与 `scripts/check.ps1`;CI 使用 Bash 检查 Python、所有 JS、pytest;增加 Docker Compose 双服务 healthy 冒烟 | Linux 本地 79 项通过;GitHub Actions 尚待在仓库运行 |
| P0-7 密钥卫生 | 保持 `.env`、`secrets/` 在 Git ignore / Docker ignore;初始化脚本不覆盖已有密钥 | 静态检查完成;真实环境密钥轮换须由管理员执行,不能从交付包代替生产凭据 |
| P0-8 备份恢复 | 新增 SQLite WAL 在线备份 → 独立目录恢复 → 核对站点与媒体的测试 | 本地隔离测试通过;真实 Docker 数据卷离线恢复演练待完成 |
## P0 的真实退出条件(仍需在你自己的 Docker 环境完成)
- 运行 `docker compose up -d --build`,**Web 和 Worker 均显示 healthy**。
- `/api/health` 返回 `7.1.0` 和你提供的 `SITEFORGE_GIT_SHA`(生产环境不应是 `unknown`)。
- 对目标镜像记录 `docker image inspect` 摘要,截图或保存启动日志。
- PC 1440、手机 390/360 登录、导航、构建、预览无失败。
- 在独立测试卷完成备份、恢复和回滚验证,不直接覆盖旧站正式数据。
- 轮换暴露过的管理员密码、会话密钥及发布 Agent 密钥。
## 后续开发顺序(依照原计划)
| 阶段 | 首先要完成的工程 | 完成门槛 |
|---|---|---|
| P1 | 显式 DB schema 迁移表、通用任务/审计、SSR F/DNS/重定向安全客户端、secret ID 引用、配额 | 故障注入与越权、私网请求拒绝测试通过 |
| P2 | WP REST/WXR/Sitemap 断点续跑、幂等、URL/SEO 基线、迁移会话 | 本地 WP 固定夹具 + 授权真实站匿名验收报告 |
| P3 | XML/API/只读 MySQL 连接器、导入暂存与批次回滚 | 5,000 条可恢复导入、失败不重复写入 |
| P4 | Schema 区块、整站主题包、兼容性预检、草稿与设备样式 | 多页面模板安装/回退和视觉回归通过 |
| P5 | SFTP/S3 不可变版本、审批、原子切换、线上探测 | 容器化部署/故障/回滚测试通过 |
| P6 | locale / translation_groups / hreflang | 多语言页面双向 hreflang 与 sitemap 一致 |
| P7 | PostgreSQL + 分布式队列高可用、SSO、监控、备份、SLO | 真实并发与灾备压测和安全审计 |
## 技术边界
- 本次未将当前 PostgreSQL 兼容层表述为正式数据迁移/高可用认证。
- 未声称官方 Art Design Pro 项目已构建:它仍以独立接入代码形式存在。
- 浏览器自动化在当前隔离环境访问本地 HTTP 返回 `ERR_BLOCKED_BY_ADMINISTRATOR`;这属于测试环境访问限制,**不作为页面已通过浏览器测试的证据**。
- Docker Engine 当前未安装,不能宣称 Compose 真正已启动。
+78
View File
@@ -0,0 +1,78 @@
# SiteForge V7 — Web + Docker 部署与正式发布
## 1. 配置
首先下载源码到可信环境,进入项目目录。Windows PowerShell 首次生成 `.env`(不会覆盖已存在密码):
```powershell
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
```
切勿把 `.env`、管理员密码、发布签名密钥或源站数据库导出提交到代码库。数据库密码必须为 URL 安全字符或在连接字符串中正确编码。
## 2. 单机验证
```bash
docker compose up -d --build
docker compose ps
docker compose logs -f siteforge worker
```
浏览器打开 `http://localhost:8787/`,登录后进入 `/factory-studio`(建站)、`/operations`(CMS 导入、人工复核、构建、审批、发布)、`/theme-gallery`(主题预览)。
## 3. PostgreSQL + Redis 的企业测试组合
```bash
docker compose -f compose.enterprise.yaml up -d --build
docker compose -f compose.enterprise.yaml ps
docker compose -f compose.enterprise.yaml logs -f siteforge worker postgres redis
```
这是**新数据库部署**,不要把 V6 的 SQLite 数据卷直接绑定给 V7 的 PostgreSQL 版本。先备份,再制定一次性数据导入和对账计划。`postgres-smoke` CI Job 具有独立临时 PostgreSQL 数据库的基础烟雾测试;当前交付环境无法运行该 Job 或本地 Docker/PostgreSQL。
Redis 当前只作为通知通道,数据库是任务事实来源,Redis 不提供 Exactly-Once 保障。
## 4. 接入 Art Design Pro 官方工程
联网取得官方项目(开源地址:`https://github.com/Daymychen/art-design-pro`),按照官方文档所需 Node.js / pnpm 版本安装:
```bash
python scripts/install_into_art_design_pro.py /path/to/art-design-pro
python scripts/install_into_art_design_pro.py /path/to/art-design-pro --apply
cd /path/to/art-design-pro
pnpm install --frozen-lockfile
pnpm build
```
注册菜单:网站项目、主题库、可视化编辑、CMS 迁移、企业运行中心、审批与发布。需要同域代理 `/api`、`/ui`、`/editor`、`/preview`、`/login` 等到 SiteForge,且保留服务端 Session + CSRF。正式交付前要验证前端角色菜单与服务端 RBAC 一致。
本压缩包**不包含官方 Art Design Pro 整体源代码**;当前 Docker 默认仍运行 SiteForge 内置 Web 后台。
## 5. 单独部署 Release Agent
推荐在目标服务器中部署独立 Agent,只授予一个发布目录的写入权限,并在 HTTPS 反向代理和网络白名单后面运行。
```bash
docker compose -f compose.release-agent.yaml up -d --build
```
目标机器 `.env` 配置 `SITEFORGE_DEPLOY_SECRET`(至少 32 字符),控制端配置 `SITEFORGE_DEPLOY_STAGING_URL` 和 `SITEFORGE_DEPLOY_STAGING_SECRET`,或 PRODUCTION 对应值。
Agent 在容器里使用 `/srv/releases/<site-id>/releases/<hash>/` 保存不可变版本,并通过 `current` 指针切换。外部网站域名应由 Nginx/Caddy 指向该只读版本目录或代理 Agent 的 `/public/<site-id>/...`;需要由运维为每个站点配置域名,当前**不是自动配置 DNS/CDN 的产品**。
发布接口不是公网通用文件上传:必须经过构建完成、迁移与 HTML 质量检查、独立审批,并使用 HTTPS HMAC 请求签名,签名绑定 HTTP 方法和精确路径,存在 5 分钟的时间戳有效窗口。务必开启 TLS、源 IP 限制和审计日志。
## 6. CI 与运行验收
```bash
python -m pip install -r requirements-dev.lock
python -m pytest -q
python scripts/verify_release.py
for file in app/static/*.js; do node --check "$file"; done
```
完整 GitHub Actions:`.github/workflows/ci.yml`。包含 Python 3.12/3.13 测试、静态 JavaScript、Docker 镜像及 PostgreSQL 冒烟配置;但必须在你的仓库中真正跑通流水线,确认依赖、容器构建、数据库行为才可标为通过。
## 7. 升级时的数据保护
不要 `docker compose down -v`。先备份 SQLite/PostgreSQL、主题、媒体、构建产物及发布历史,再在隔离测试环境进行恢复和预演;确认旧网站 URL/SEO 清单无差异后再切换正式流量。不同 Compose project 的同名卷默认并不共享,升级流程不得靠“覆盖 ZIP”代替数据迁移。
+17
View File
@@ -0,0 +1,17 @@
# SiteForge Factory 6.0.0 — 功能交付矩阵
| 模块 | 已经落地的真实功能 | 不能等同于 |
|---|---|---|
| 新建/多站点 | 站点 CRUD、克隆、独立数据、团队角色 | 大规模租户隔离认证审计认证通过 |
| 四套主题 | 14 类页面模板、各主题独立资源、品牌/产品/新闻/案例、菜单、搜索、筛选、图库和主题版本 | 不需人工检查即可直接交付任何行业客户 |
| 可视化编辑 | 组件库、布局列、嵌套区块、直接文字编辑、图片上传、响应式预览与全局样式 | Figma 级自由布局、多用户实时协同 |
| 内容模型 | 产品/文章/品牌、字段规则、Excel/CSV/JSON 预览与映射、导入冲突报告 | 任意复杂数据零人工识别与完全语义映射 |
| WordPress 迁移 | XML/WXR,基本 REST/网页采集,部分 Elementor、WooCommerce 字段,媒体压缩包,源站 URL/SEO 清单 | WooCommerce 变体/订单全量、ACF/Elementor 任意组件像素保真 |
| SEO/验收 | URL 基线、meta/Canonical 规则、sitemap、broken link、质量报告、迁移差异 CSV | 搜索引擎排名不变保证,完整 SEO 审计认证 |
| 后台 Web | FastAPI + 自带 Web 工作台、可视化设计器、团队权限 | 官方 Art Design Pro 已完整替换 |
| Worker | 202 任务、SQLite 持久化队列、超时重试/取消/幂等/租约 | 分布式 HA/Exactly Once 原子发布,完整冻结构建快照 |
| 发布 | 构建版本、ZIP、预览、本地目录发布、审核/回滚记录 | 一键全云部署、DNS/CDN 自动控制、灾备 SLA |
| Docker | Compose Web/Worker、卷、健康检查、可选 Caddy HTTPS | 实际 Docker 容器在本环境启动/云上压测通过 |
| CI | 精确依赖版本锁、GitHub Actions 测试/构建配置 | GitHub Actions 真实运行通过或安全漏洞审计通过 |
**可验证完成标准(后续正式商业验收)**:汽车和工业两个真实站点分别完成零代码套版、内容录入、旧站路径/SEO 核对、PC/平板/手机真实浏览器测试、询盘服务验证、审批与部署/回滚演练,并提供逐项通过记录。
+22
View File
@@ -0,0 +1,22 @@
# V4 功能验收矩阵
| 用户期望 | 本次已实现 | 继续开发/验收 |
|---|---|---|
| 四套完整行业主题 | 原 V3 的四套主题共 56 组独立模板;V4 追加多级导航、产品图库 Lightbox、可配置 HTTPS 表单和主题兼容性检查 | 专业素材、真实表单接收服务、复杂车型筛选逻辑与视觉优化 |
| 可视化组件设计 | 支持编辑器响应式多列布局、列内区块、全页自由布局/追加模式、完整主题预览、可复用组件库 | 自由网格画布、高级排版控制、多人实时协作 |
| 内容智能装配 | Excel/CSV/JSON 解析预览、字段别名、车型/能源/续航/图库/参数绑定、关系报告与品牌空页面装配 | 多值关系、多语言和复杂字段映射、AI 语义匹配需人工确认 |
| WordPress 迁移 | WXR/REST 等原有路径 + V4 特殊字段转换、SEO 清单、静态构建差异和人工复核机制 | SQL 全库深度适配、复杂插件无损解析及真实站验收 |
| 发布前审计 | WXR/sitemap/CSV/JSON 基线;实际 HTML 路由、title、description、canonical、robots、H1 和质量检查;差异 CSV;单条人工接受理由 | 真实 HTTP 状态 / CDN 规则与线上 Google Search Console 指标监控 |
## 源数据验证标准
- 未抓取/导出的路径不能自动假设不存在。
- HTTP 200 的源路径必须有对应 HTML,缺少会阻断发布。
- SEO 源数据缺失时只报告已知差异,不虚构旧站 metadata。
- 所有警告必须审核接受或修复后重新构建;结构阻断项不允许直接接受例外。
- 复杂 Elementor、ACF、WooCommerce 数据保留原始字段,部分安全转换并列出人工核查项。
- 构建产物中不得出现未识别的本地资源/内链错误;外部资源应按实际站点审核是否本地化。
## 运行安全界限
本项目不提供 Docker 容器启动成功的保证。不要将默认测试端口直接暴露公网;请配置 HTTPS、限流、备份、生产安全审计。管理中心与客户静态网站独立部署。
+43
View File
@@ -0,0 +1,43 @@
# SiteForge 6.0.0 — 运维与安全说明
## 运行架构
- 单机 Compose:Web(认证及 API)、Worker(后台构建)、独立持久化数据卷、独立已发布文件卷;可选 Caddy HTTPS。
- 数据:SQLite WAL;一个站点同时间最多一个运行中的构建任务。长期多节点部署要替换存储和任务中心。
- 运行时只需公网暴露经正确配置的 HTTPS 反向代理。请不要把 8787 开放到公网。
## 日常排障
```bash
docker compose ps
docker compose logs --tail=100 siteforge
docker compose logs --tail=100 worker
docker compose exec siteforge python -c 'from app.version import VERSION;print(VERSION)'
```
正常时 `GET /api/health` 返回 `{"status":"ok","name":"SiteForge","version":"6.0.0"}`。Worker 未运行时 API 可以仍然健康,但构建会停留在 queued;因此应同时监测 Worker 容器。
## 数据安全
- 密钥只保存在 `.env` 或平台密钥管理器;生产 HTTPS 将 `SITEFORGE_COOKIE_SECURE=true`。
- 管理员密码至少 12 位,团队账号使用 scrypt 密码哈希;启用站点角色。
- 发布权限、迁移 SEO 阻断和审核不能仅由客户端判断,必须遵从后端校验。
- 不要任意加载第三方 Jinja/JS 主题代码;当前主题导入限制为声明式主题包。
- 旧站采集应只针对已获得授权的站点,防止内部网络 SSRF。
## 备份/恢复
SQLite 正在运行时不要直接只复制一个 `.sqlite3` 文件;应使用 SQLite 在线备份 API,并同步备份 media、theme_packages、archives、builds、legacy 及部署文件。可先停止维护性写入,再执行卷快照。恢复必须在空白测试部署上演练后才能用于正式生产。
## V5 升级
不要运行 `docker compose down -v`,不要覆盖 V5 的数据卷。使用全新 V6 Compose 名称和独立卷,检查账户、旧 URL/SEO、主题固定版本、素材、历史构建、发布根目录,再安排切换。
## Worker 任务处理
- 前端构建请求统一 HTTP 202 排队。
- `SITEFORGE_BUILD_TIMEOUT_SECONDS` 默认 300,可调整 10–3600 秒。
- `SITEFORGE_BUILD_MAX_ATTEMPTS` 默认 3,可调整 1–6。
- 用户取消排队任务立即 `cancelled`;运行中进入 `cancel_requested`,Worker 会终止构建子进程并确认状态。
- 临时任务错误才会指数退避重试;确定性数据错误标记 failed,避免无用重复尝试。
- Worker 领取任务后更新租约;租约过期可能重试任务。对于进程崩溃时未清理的孤儿进程,当前实现尚未保证多机严格一次执行,正式集群版需进程组隔离及分布式租约协议。
+58
View File
@@ -0,0 +1,58 @@
> 本文件含 V8.8 原始评估内容;V8.9 最新实测数据与剩余门禁以 [V8.9 验收报告](P0_P4_V8_9_VERIFICATION.md) 为准。
# SiteForge Enterprise V8.9 — P0–P4 实现与验收矩阵
> 状态:**集成开发版,不是生产验收完成版**。本文件依据《COMPLETION_PLAN(1).md》v2.0 的 P0–P4 验收要求,结合 V8.8 源码与 V8.9 新增的导入、性能、恢复及媒体回归编写。文档历史记录(原源码 v5、运行 v4)不等于本工程当前运行事实。
## 本轮实质交付
1. **按设备调整真正的多列布局**:`layout` 区块现在分别支持平板 1–3 列、手机 1–2 列;每列单独设置顺序、跨列、显隐;子组件支持已有响应式样式。规则校验后构建成静态 CSS。为防整站模板复制后 ID 冲突,按网站页面路径哈希作用域隔离 CSS。
2. **整站套版增加审核态**:后台原“确认应用”改成“暂存 → Owner 发布”;发布时必须验证草稿版本与整站布局指纹;若网站被其他人修改,返回 409,不静默覆盖。
3. **多页套版原子化**:使用同一个数据库事务修改全部页面和网站主题/导航;执行中失败后整体回滚;拒绝覆盖仍存在的页面/全站草稿。新页面默认草稿状态、`noindex,follow`,不伪造 SEO 与客户数据。
4. **路径与媒体修复**:区块 CTA、图片等使用根路径;编辑器预览使用 `/local-media/{site}/{file}`;新增站点媒体库选择与画廊/轮播添加入口。
5. **样式白名单收口**:背景色严格为六位十六进制;间距为 0–120 的整数,显隐为布尔值;防止无效 CSS 或任意表达式进入构建器。
6. **版本、Compose、CI 断言统一**:`8.9.0-dev.1`;修复旧 `siteforge-v8-6` Compose 项目名遗留问题;保留独立 Worker 健康检查与测试脚本。
## 对《COMPLETION_PLAN(1).md》逐项判断
符号:✅ 已有代码及本地测试;◐ 部分实现/缺实机验收;❌ 未完成。
| 阶段 | 需求 | 现状 | 阻断生产交付的剩余工作 |
|---|---|---|---|
| P0 | 版本标识、预览根路径、favicon、Worker 健康 | ✅/◐ | 目标机上镜像摘要、实际浏览器、回滚演练尚未验证 |
| P0 | 一键 Compose 构建/启动、备份恢复、跨平台 CI | ◐ | Docker Engine 实机及 GitHub Actions 真正运行、旧数据库升级演练 |
| P0 | 真实凭据轮换 | ❌ | 运维方需要在自己环境轮换管理员口令、Session、第三方 Secret,不能通过打包源码代替 |
| P1 | 数据库迁移登记、Worker 幂等/超时/取消/重试/容量 | ◐ | SQLite 迁移登记已有;旧表的完整版本迁移、多个 Worker 并发/死信恢复仍不完整 |
| P1 | 安全出网、密钥引用、日志、资源上限 | ◐ | 核心 HTTP SSRF 规则和密钥引用已有;生产密钥轮换、告警、各协议审计仍待实机 |
| P2 | WordPress REST/WXR、Sitemap、检查点、媒体 | ◐ | 公开文章、页、分类、标签、有限媒体已支持;私有内容与任意 Elementor/ACF/WooCommerce 结构不能保证无损 |
| P2 | 全 URL/SEO/媒体基线及统一迁移向导 | ◐ | 有 URL/SEO 比较和复核;需要本地综合夹具、真实授权 WP 站点全流程验收,复杂媒体 CSS/srcset 适配继续开发 |
| P3 | Excel/CSV/JSON/XML/API/只读 MySQL | ◐ | 有暂存、映射、工作任务、关系预览与回滚;真实 MySQL TLS、5000 条数据容量、API 密钥轮换仍待验收 |
| P3 | 安全 XML/ZIP 与批次完整回滚 | ◐ | XXE/压缩限制与受保护回滚已有;更复杂文件与异常恢复需故障注入测试 |
| P4 | 受控声明式区块/主题预设 | ✅ | 有组件白名单、四套主题、模板预检及安全渲染;实际视觉质量需正式素材验收 |
| P4 | 页面/全站草稿与正式构建隔离 | ✅/◐ | 页面草稿/全站草稿已隔离,整站模板现在具备暂存及 Owner 发布;旧批量生成入口仍需全面权限梳理 |
| P4 | PC/平板/手机不同布局 | ◐ | 现在支持区块间距/显隐与布局列数/跨列/排序;不等于三套完全独立可拖拽画布 |
| P4 | 整站模板可复制、回退、兼容检查 | ◐ | 已预检、模板暂存与原子应用;模板版本升级兼容和视觉回归、全站变更历史回退仍不完整 |
| P4 | Chrome 桌面/平板/390px/360px 回归与无障碍 | ❌ | 需真实浏览器交互、键盘流程及系统性视觉回归;当前环境未完成 |
## 不应误报为完成的需求
- 正式 **Art Design Pro 官方工程**整合与编译:未完成。当前仍以现有 Web 后台为主。
- PostgreSQL/Redis 的高可用和多实例:非 P0–P4 生产验收范围,也尚未完成;企业 Compose/兼容层不能代替实测。
- SFTP/S3 正式发布、生产回滚、多语言:P5–P7 尚待后续独立交付。
- 任意 WordPress 插件的像素级还原:不承诺。必须按特定站点/组件制定转换规则和复核结果。
## 已执行的验证
- Python 自动化回归,包含 V8.8 新增的响应式布局、复制模板、草稿不泄漏、两步应用、旧站改动拒绝、事务失败回滚、内页链接及媒体路径等专项测试。
- `scripts/verify_release.py`、JavaScript `node --check`、Python 编译、源码 ZIP 完整性;以上以实际最后一次运行结果为准。
- **未执行**:真实 Docker Engine 启动、GitHub Actions 云端运行、正式浏览器和移动设备视觉测试、授权 WordPress 实站迁移、远程发布故障恢复、真实 MySQL TLS。
## 达到 P0–P4 完全验收的明确条件
需在安装好的测试服务器上完成:`docker compose up -d --build` 一条命令通过;`/api/health` 和镜像版本相符;360/390/1024/1440 像素交互验收;5000 行内容导入与批次中断恢复;WordPress REST/WXR/Sitemap/媒体综合本地夹具及一个授权实站;四主题整站草稿、模板套用、构建前后隔离;密钥轮换、安全审计、数据库备份与恢复。每项有可重复报告,未过项不能标记为生产完成。
## V8.9 补充(以新报告为准)
- 已加入 P0 备份先验证后恢复、P2 WordPress 响应式媒体路径、P3 站点级品牌/分类缓存和 100 条分页、P3 性能夹具、P4 静态质量与模板回归测试。
- 5,000 条同分类测试,技术构建生成 50 个分类分页和 50 个产品中心分页;独立静态 QA 扫描 5,191 HTML,`errors=0`。详细警告与环境限制参见 `P0_P4_V8_9_VERIFICATION.md`。
- **仍不满足 P0–P4 全面上线门禁**:真实 Docker、GitHub CI、各设备浏览器、授权 WordPress 站点、真实 MySQL TLS、密钥轮换/高可用尚未实机验收。
+52
View File
@@ -0,0 +1,52 @@
# SiteForge Enterprise V8.9 — P0–P4 优化及真实验收证据
**版本**:8.9.0-dev.1(2026-10-22)
**状态**:开发与离线集成测试已完成一轮;**不是 P0–P4 全部生产验收通过**。
## 已在当前环境验证的实际成果
| 阶段 | 具体修改 | 已执行验证 | 仍待完成 |
|---|---|---|---|
| P0 | 离线备份恢复先检查所有 ZIP 路径、范围、符号链接、SQLite 完整性;旁路暂存并目录切换;保存恢复前原目录 | 正常恢复、存在旧目录、损坏 DB、非法 ZIP 路径和保留原目录专项测试 | Docker Engine 一键启动、旧版本真实数据升级/回滚、管理员及 Session 密钥轮换、GitHub CI 真正运行 |
| P1 | 延续 V8.8 安全出网、密钥引用、限额、Worker 超时/幂等/取消/租约;本轮未声称做完 P1 所有基础设施 | 所有旧测试继续通过;回归建站、授权和任务接口 | 正式密钥轮换/吊销、完整可观测性及告警、数据库迁移注册表完备性与多 Worker 故障演练 |
| P2 | WordPress 额外识别 `img srcset/data-src/data-srcset`、`picture source srcset`、懒加载与视频 poster;仅安全同源图片自动下载;保留响应式尺寸描述、远端 CDI/跨域资源待复核 | 响应式媒体 URL 发现、重写保真、剩余 CDN 识别、既有迁移用例 | CSS `background-image`、私有附件、Elementor/ACF/WooCommerce 任意复杂结构、经授权真实 WP 站点全量迁移 |
| P3 | 构建器对产品品牌/分类预构建索引;商业就绪与静态 QA 流式扫描;真正用 5,000 条车型分 10 批提交及构建 | 5,000 条记录 10×500 完整提交(14.34s);静态构建产生 5,043 个 HTML(26.01s);优化后的独立质量扫描(6.5s,错误 0) | 多种数据形状压测(如 5000 个同分类页面)、真实 MySQL TLS/外部 API、并发导入及断点故障注入、商业 SEO 数据清洗 |
| P4 | 维持现有手机/平板独立列、草稿和整站模板发布;新增构建器索引缓存防止模板渲染多次遍历全库,静态 QA 防路径穿越 | 原有页面设计、事务、草稿/正式隔离测试持续通过 | 实际 360/390/1024/1440 浏览器截图/键盘操作/视觉回归,任意设备自由画布与全站历史撤销 |
## 5,000 条测试的注意事项
- 测试数据是**本地虚构车型数据**,并未连接真实 MySQL,也没有在 Docker 容器中执行。
- 用 10 批各 500 条执行真正的 `stage → commit`,不是只把测试数据插进数据库。合计 5,000 条记录。
- 静态构建成功生成 5,043 个 HTML 文件。质量扫描 `errors=0`,但原始测试数据有 **5,039 条 SEO/描述等 warning**;这意味着技术上可生成,不代表可以直接商业上线。
- **本轮后续修复**:新增每页 100 条的静态分页,包括同一分类和默认 `/products/` 产品中心。5,000 条同一分类的隔离数据库构建生成了 50 个分类分页、50 个产品中心分页,构建约 29.59 秒;该构建的独立 QA 约 6.67 秒,扫描 5,191 个 HTML,错误为 0,但保留 5,039 条内容/SEO 警告。未经正式内容清洗不得对外上线。
- 可复现的程序:`python scripts/pilot_5000.py --report pilot.json`。默认只执行 5,000 条导入,使用临时数据库,不覆盖现有数据;在独立大内存测试机加 `--build` 执行全部构建/QA,需单独验收耗时、资源峰值与 Worker 超时。
## 新代码位置
- `app/wp_migration_v82.py` — 图片候选、响应式 URL 改写与待复核。
- `app/theme_pro.py`、`app/builder.py` — 站点级商品/品牌/分类预计算。
- `app/commercial_readiness.py`、`app/quality.py` — 线性读取与静态 QA 路径索引。
- `scripts/restore_backup.py` — 离线预检、目录恢复及保留原目录。
- `scripts/pilot_5000.py` — 5,000 条独立验收脚本。
- `tests/test_p0_p4_completion_v89.py` — 回归测试。
## 发布门禁与运维限制
- **尚不能标记 P0–P4 生产验收全部完成**。开发代码没有目标服务器镜像摘要、跨浏览器体验、授权客户 CMS 全量迁移及故障恢复证据,不能代替这些检查。
- 生产恢复必须事先停止 Web/Worker。新恢复脚本仅适用于 SQLite 的单机卷;如果使用 PostgreSQL,需要独立 pg_dump / 迁移/恢复方案。
- 不应提前启用 P5 远程自动发布生产域名;必须先完成 P0–P4 的上线门禁并验证旧网址和 SEO。
## 实际验证步骤
```bash
python -m pytest -q
python scripts/verify_release.py
python -m compileall -q app scripts
bash scripts/check.sh
python scripts/pilot_5000.py --report pilot.json
# 仅在有 Docker Engine 的服务器执行:
docker compose up -d --build
curl -fsS http://127.0.0.1:8787/api/health
```
以上 Docker、真机浏览器及真实外部 WordPress/MySQL 步骤在本次执行环境**未完成**。

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