chore: import SiteForge Factory V5 source (baseline)
导入 SiteForge Factory V5 基线源码: - FastAPI + SQLite + Docker Compose(Web + Worker)架构 - 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照) - 四种建站模式骨架 + 迁移审计 + 发布门禁 - 52 项 pytest 测试 - 补全计划主文档 docs/superpowers/plans/ Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
storage
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
*.pyc
|
||||
*.zip
|
||||
screenshots
|
||||
*.log
|
||||
|
||||
demo_storage
|
||||
@@ -0,0 +1,9 @@
|
||||
# 复制为 .env 后更换密码和密钥;不要提交 .env 到版本库
|
||||
SITEFORGE_PORT=8787
|
||||
SITEFORGE_ADMIN_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
|
||||
SITEFORGE_SESSION_SECRET=REPLACE_WITH_64_RANDOM_CHARACTERS
|
||||
SITEFORGE_COOKIE_SECURE=false
|
||||
SITEFORGE_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# 只有启用 compose.public.yaml 时才需要填写,先正确设置 DNS 记录
|
||||
SITEFORGE_DOMAIN=siteforge.example.com
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
.venv/
|
||||
storage/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets/
|
||||
|
||||
demo_storage/
|
||||
|
||||
# 本地测试产物
|
||||
smoke-*.png
|
||||
.playwright-mcp/
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
SITEFORGE_DATA_DIR=/data
|
||||
WORKDIR /opt/siteforge
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
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
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=3)"
|
||||
CMD ["python","-m","uvicorn","app.main:app","--host","0.0.0.0","--port","8787","--proxy-headers"]
|
||||
@@ -0,0 +1,123 @@
|
||||
# SiteForge Factory V5 — 四套商业视觉主题升级版
|
||||
|
||||
> **独立可部署源代码项目**。V5 基于 V4 基于 V3,重点开发需求中的前四个模块:完整行业主题、可视化编辑、数据自动装配、旧站迁移审计。**这是可测试的功能整合版,不等于已经通过生产级安全、真实 CMS 无损迁移和全设备视觉验收。**
|
||||
|
||||
## V5 本轮核心升级
|
||||
|
||||
四套主题新增商业版视觉体系、内置本地 SVG 示例视觉素材、内容与 Logo 优先展示、响应式细节、交互优化和**独立商业交付检查报告**,主题版本升级为 `1.1.0` 并保留旧版快照。后台 `/theme-gallery` 与正式静态网站共用主题样式资源。详细功能与验收限制请阅读 [docs/COMMERCIAL_THEME_V5.md](docs/COMMERCIAL_THEME_V5.md)。
|
||||
|
||||
> 这是一套可以用于客户项目试点与二次精修的商业主题交付候选版,**不是已经完成真实企业资料、表单和法律/SEO 验收的正式上线成品**。
|
||||
|
||||
## 快速开始(Windows + Docker Desktop)
|
||||
|
||||
1. 安装 Docker Desktop,解压项目,在解压根目录打开 PowerShell。
|
||||
2. 首次初始化(只执行一次,密码显示在终端并自行妥善保存):
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/project" -w /project python:3.12-slim python scripts/init_config.py
|
||||
```
|
||||
|
||||
3. 启动管理网站及后台任务 Worker:
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
4. 浏览器打开 `http://localhost:8787/`,使用初始化后的 `admin` 密码登录。
|
||||
|
||||
进入 **`/factory-studio`** 查看网站工厂、主题装配、迁移审计。编辑器地址从网站管理工作台进入。主题预览 **`/theme-gallery`**。
|
||||
|
||||
V5 的 Compose 项目名为 `siteforge-v5`,默认使用单独的 Docker 数据卷,以免意外覆盖 V3。**如果你已经有 V4 数据,先做备份并设计独立迁移步骤,不要通过更改数据卷名直接强行复用。**
|
||||
|
||||
## 能实际做的四条工作流程
|
||||
|
||||
### A. 无旧站:从零新建网站
|
||||
|
||||
1. 登录,进入 `/factory-studio`,创建网站并选择 Automotive / Industrial / Corporate / Minimal 主题。
|
||||
2. 在网站结构里初始化页面;填写公司介绍、Logo、邮箱、产品、新闻、案例及联系信息。
|
||||
3. 在编辑器中选择全页自由排版模式,添加 Hero、图文、动态内容、**响应式多列容器**等组件;列内继续添加组件,支持跨列移动和复用组件。
|
||||
4. 选择主题版本,预览,构建并下载静态站 ZIP,或进入现有审批发布流程。
|
||||
|
||||
### B. 数据生产:Excel、CSV、JSON 自动装配
|
||||
|
||||
1. 打开 `/factory-studio` 的“内容与自动装配”,上传 XLSX / CSV / JSON。
|
||||
2. 系统建议车型、品牌、分类、动力形式、图库、技术参数、SEO 字段的映射;可以手动调整。
|
||||
3. 先“预览数据和错误”,确认 URL 冲突、空标题与未映射字段,再执行导入。
|
||||
4. 预览“品牌关系”并将缺失的品牌页面创建为空资料页,不伪造企业文案。
|
||||
5. 切换主题重新构建,保留已经导入的 URL / SEO / 品牌和参数数据。
|
||||
|
||||
### C. 旧站迁移:WordPress 与其他来源
|
||||
|
||||
- WordPress WXR/XML:文章和页面、基础 SEO、公开自定义字段;V4/V5 继续提取特色图片、WXR 媒体 URL、品牌/产品分类、部分 WooCommerce 简单商品属性,以及 Elementor 已知标题/正文/图片组件。**未知 Elementor 组件、ACF 复杂字段、变体/订单及短代码均保留原始数据并产生复核提示。**
|
||||
- 其他来源:现有 CSV/JSON/XLSX 导入,以及安全受限的公开 HTML 采集。没有对任意 PHP/MySQL/CMS 数据库格式进行通用自动转换。
|
||||
- 旧站基线:上传原网站的 URL/SEO CSV、JSON、sitemap XML 或 WXR 文件。CSV 列可包含 `url,seo_title,description,canonical,robots,h1,status`。
|
||||
- 系统对 **实际生成的 HTML** 检查缺失 URL、title/description/canonical/robots/H1 差异及静态资源、内部链接。阻断项必须修复,警告必须经具有审核权限的人员填写理由才能通过发布门禁。
|
||||
|
||||
### D. 主题开发与维护
|
||||
|
||||
四套内置主题各有 14 种独立模板、响应式 CSS、菜单、产品参数、产品多图切换/大图预览、搜索筛选、页脚,以及可配置的联系表单入口。主题 ZIP 支持受限声明式 CSS/区块预设的版本安装和兼容性检查。**不允许安装执行任意 Jinja/JS/Python 的 ZIP**,避免任意代码执行。
|
||||
|
||||
联系表单只有在后台设置正式的 HTTPS 外部表单接收地址时才会呈现。SiteForge 默认不会提供虚假的“发送成功”;表单服务的收件、隐私、防垃圾邮件和运维需要自行配置。
|
||||
|
||||
## 技术实现概要
|
||||
|
||||
- FastAPI / SQLite / Docker Compose Web + Worker;这是 V3 继承的运行架构,**尚未更换 PostgreSQL/Redis**。
|
||||
- 专业主题引擎:四套主题的 `pro.html`、`pro.css` 和各 14 种页面模板。
|
||||
- 编辑器:允许 `layout`(1-4 列、最多两层)及列内嵌套安全组件;支持整页替换、全主题预览、PC/平板/手机切换、图片上传、内联文本、按设备隐藏/间距、组件库保存复用。
|
||||
- 内容装配:`app/assembly.py`、`app/table_loader.py`、`app/relations.py`。
|
||||
- WordPress 特殊数据:`app/wordpress_compat.py`,不执行 Elementor/PHP 代码。
|
||||
- 迁移清单/构建差异/人工复核:`app/migration_audit.py`,API `/api/audit/...`。
|
||||
- 多站点权限、内容历史、基础审批、静态生成、ZIP / 本地发布延续 V3。
|
||||
|
||||
## 关键 API
|
||||
|
||||
| API | 功能 |
|
||||
|---|---|
|
||||
| `GET /api/factory/themes/{id}/compatibility` | 检查主题资源及 14 种页面结构 |
|
||||
| `POST /api/factory/sites/{site}/mapping/parse-file` | Excel、CSV、JSON 字段解析,不直接写入数据库 |
|
||||
| `POST /api/factory/sites/{site}/mapping/run` | 字段映射预览/写入 |
|
||||
| `GET /api/factory/sites/{site}/relationships` | 产品与品牌分类关系检查 |
|
||||
| `POST /api/factory/sites/{site}/relationships/assemble` | 创建缺失的空品牌页 |
|
||||
| `GET/POST /api/sites/{site}/components` | 可复用组件库 |
|
||||
| `PUT /api/sites/{site}/pages/{page}/blueprint` | 保存区块和整页设计模式 `layout_mode` |
|
||||
| `PUT /api/sites/{site}/contact-settings` | 联系邮箱和外部 HTTPS 表单配置 |
|
||||
| `POST /api/audit/sites/{site}/import-baseline` | 导入旧站 URL + SEO 清单 |
|
||||
| `GET /api/audit/sites/{site}/builds/{build}/report` | 实际构建文件差异报告 |
|
||||
| `GET /api/audit/sites/{site}/builds/{build}/report.csv` | 下载差异 CSV |
|
||||
| `POST /api/audit/sites/{site}/builds/{build}/review-warning` | 有理由地审核单条警告 |
|
||||
|
||||
全部管理 API 均需要登录;写入 API 需要 CSRF 校验、站点角色权限。为避免不受控的远程页面运行风险,迁移清单和 WXR 采用离线解析。
|
||||
|
||||
## 自检
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
python -m pytest -q
|
||||
python -m compileall -q app
|
||||
node --check app/static/editor.js
|
||||
node --check app/static/factory-v2.js
|
||||
node --check app/static/pro.js
|
||||
```
|
||||
|
||||
Windows 也可以在安装开发依赖后运行 `powershell -ExecutionPolicy Bypass -File scripts/check.ps1`。2026-10-22 使用 Python 3.12 完成回归,当前 **54 项自动化测试全部通过**;四套演示主题的浏览器 E2E 与 Docker 容器仍应在目标部署环境重新验收,不能用源码测试通过代替上线验收。
|
||||
|
||||
运行依赖使用已经回归验证的精确版本。升级 FastAPI、Starlette、Pydantic 或 HTTPX 时应作为独立变更处理,并重新运行完整测试,避免 Docker 重建时悄然跨越框架兼容边界。
|
||||
|
||||
## V5 运行安全配置
|
||||
|
||||
- `SITEFORGE_ALLOWED_HOSTS` 是逗号分隔的后台访问域名白名单;本地 Compose 默认只允许 localhost。公网部署文件会自动加入 `SITEFORGE_DOMAIN`。
|
||||
- 管理员密码与团队密码都使用 scrypt 派生后比较;仍建议通过 Docker Secret 或 `SITEFORGE_ADMIN_PASSWORD_FILE` 提供管理员密码。
|
||||
- Worker 对临时失败的构建默认最多尝试 3 次,并采用短延迟退避;可通过 `SITEFORGE_JOB_MAX_ATTEMPTS` 调整。
|
||||
- 安全响应头允许主题中心进行同源 iframe 预览,但禁止第三方网站嵌入管理后台。
|
||||
|
||||
## 明确未实现的部分
|
||||
|
||||
1. 任意外部 CMS 的自动无损迁移;完整 ACF Repeaters、Elementor 全部 widget、WooCommerce 变体/订单/支付、任意 SQL 备份的自动解释。
|
||||
2. 任意上传完整可执行代码主题;上传仅限安全声明式主题,独立复杂主题需要开发者审核并放入代码仓库。
|
||||
3. 真正任意元素绝对定位、所有断点分别绘制的 Figma 级编辑器;V4 提供可控的列布局与嵌套组件。
|
||||
4. 内置表单邮件/CRM 收件服务;需配置受控外部 HTTPS 服务。
|
||||
5. Art Design Pro **官方完整工程替换**;当前默认还是 SiteForge Web 管理后台。
|
||||
6. PostgreSQL/Redis、多云/CDN 一键发布、正式渗透测试、高可用、监控、真实 Google Search Console 性能保障。
|
||||
|
||||
**建议首次试点:** 使用一个完整 WordPress 导出(WXR + 媒体备份)以及一份实际车型 Excel,分别做旧站迁移和新站创建,对所有链接、内容、SEO 与手机视图逐项验收,再考虑生产上线。
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""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
|
||||
|
||||
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):
|
||||
ensure_public_url(url)
|
||||
r=client.get(url,follow_redirects=False)
|
||||
if r.is_redirect:raise ValueError('重定向目标需由管理员核实后再次扫描')
|
||||
r.raise_for_status()
|
||||
if len(r.content)>4_000_000:raise ValueError('响应内容超过限制')
|
||||
return r
|
||||
|
||||
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 httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code in (400,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
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""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'], 'defaults':{'columns':[{'width':1,'blocks':[]},{'width':1,'blocks':[]}],'gap':24}},
|
||||
'spacer':{'name':'上下留白','icon':'↕','fields':['height'],'defaults':{'height':48}},
|
||||
}
|
||||
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']={k:v for k,v in style.items() if k in ('background','text_color','padding','tablet_padding','mobile_padding','hide_mobile','hide_tablet') and isinstance(v,(str,int,float,bool))}
|
||||
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列')
|
||||
sanitized=[]
|
||||
for col in cols:
|
||||
if not isinstance(col,dict):raise ValueError('列配置无效')
|
||||
width=col.get('width',1)
|
||||
if not isinstance(width,(int,float)) or not .5<=width<=4:raise ValueError('列宽必须在0.5-4之间')
|
||||
sanitized.append({'width':float(width),'blocks':normalize(col.get('blocks',[]),depth+1)})
|
||||
normalized['columns']=sanitized
|
||||
normalized['gap']=max(0,min(64,int(block.get('gap',24))))
|
||||
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 ''
|
||||
return escape(value.lstrip('/') if value.startswith('/') else 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).lstrip('/') or './',quote=True)
|
||||
except ValueError:return '/'
|
||||
return escape(value,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=='layout':
|
||||
columns=block['columns'];gap=block.get('gap',24)
|
||||
weights=' '.join(f'{col["width"]}fr' for col in columns)
|
||||
html_cols=[]
|
||||
for col in columns:
|
||||
html_cols.append('<div class="sf-layout-column">'+render_blocks(col['blocks'],site,page,entries)+'</div>')
|
||||
parts.append(start+f'<div class="sf-layout-grid" 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'])]
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
"""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 jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from markupsafe import Markup
|
||||
from . import db, blueprints, theme_store, static_site, responsive, theme_pro
|
||||
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('/') and not src.startswith('//'):
|
||||
tag['src']=src.lstrip('/')
|
||||
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
|
||||
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)
|
||||
with client.stream('GET',raw,follow_redirects=False) as response:
|
||||
if response.status_code in (301,302,303,307,308):
|
||||
raise ValueError('图片重定向需人工检查')
|
||||
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=bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
buf.extend(chunk)
|
||||
if len(buf)>8_000_000:raise ValueError('图片超过 8MB')
|
||||
# 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),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))
|
||||
# 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 theme_pro.categories(product_records):
|
||||
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 theme_pro.brands(local_records):
|
||||
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)
|
||||
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)
|
||||
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 [])
|
||||
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
|
||||
for e in local_records:render_one(e,'home' if e['route']=='/' else 'detail')
|
||||
for e in derived: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_one({'type':'page','route':list_route,'title':TYPE_NAMES.get(typ,typ)+'展示','summary':'Explore our '+typ,'meta_json':'{}'},'listing',itemlist)
|
||||
route_set.add(list_route)
|
||||
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')
|
||||
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')
|
||||
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)
|
||||
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)
|
||||
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/'}
|
||||
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
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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
|
||||
htmls=list(dist.rglob('*.html'))
|
||||
texts=' '.join(p.read_text(encoding='utf-8',errors='replace') for p in htmls)
|
||||
metrics['html_pages']=len(htmls)
|
||||
metrics['artwork_fallbacks']=sum(x.count('class="pro-art ') for x in [texts])
|
||||
metrics['pages_with_body']=sum(bool(BeautifulSoup(p.read_text(encoding='utf-8'),'html.parser').select_one('.pro-rich')) for p in htmls)
|
||||
if metrics['artwork_fallbacks']:
|
||||
warnings.append({'code':'placeholder_images','count':metrics['artwork_fallbacks'],'message':'部分页面仍使用示意图,正式发布前请替换为真实且有使用权的图片'})
|
||||
if any(marker in texts for marker in DEMO_MARKERS):
|
||||
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':'仅为本地结构化交付清单,不等于合法合规、内容准确、无障碍、性能或生产上线认证'}
|
||||
+109
@@ -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
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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 = DATA / 'siteforge.sqlite3'
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||
|
||||
def connect():
|
||||
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 '{}'
|
||||
);
|
||||
''')
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,338 @@
|
||||
"""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
|
||||
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,
|
||||
retry_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);
|
||||
''')
|
||||
# Lightweight forward migration for V2-V5 databases created before
|
||||
# delayed job retries were introduced.
|
||||
columns={row['name'] for row in c.execute('PRAGMA table_info(build_jobs)').fetchall()}
|
||||
if 'retry_at' not in columns:
|
||||
c.execute('ALTER TABLE build_jobs ADD COLUMN retry_at TEXT')
|
||||
|
||||
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: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,'当前任务队列支持构建任务')
|
||||
row_id=uid()
|
||||
db.insert('INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at) VALUES (?,?,?,?,?,?)',
|
||||
(row_id,site_id,kind,'queued',actor(request),db.now()))
|
||||
return {'id':row_id,'state':'queued'}
|
||||
|
||||
@router.get('/sites/{site_id}/jobs')
|
||||
def 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,))
|
||||
|
||||
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(...)):
|
||||
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])
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Factory V5 endpoints: themes, mappings, build comparisons and 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)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""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
|
||||
|
||||
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):
|
||||
"""Validate each hop to prevent trivial redirect-to-private host SSRF."""
|
||||
for _ in range(4):
|
||||
ensure_public_url(url)
|
||||
with client.stream('GET',url, follow_redirects=False) as response:
|
||||
if response.status_code in (301,302,303,307,308):
|
||||
loc=response.headers.get('location')
|
||||
if not loc: raise ValueError('重定向缺少 Location')
|
||||
url=urljoin(url,loc);continue
|
||||
response.raise_for_status()
|
||||
if 'text/html' not in response.headers.get('content-type','text/html'):
|
||||
raise ValueError('页面不是 HTML')
|
||||
buff=bytearray()
|
||||
for part in response.iter_bytes():
|
||||
buff.extend(part)
|
||||
if len(buff)>max_bytes:raise ValueError('HTML 页面超过 3 MB')
|
||||
return bytes(buff),str(response.url)
|
||||
raise ValueError('页面重定向次数过多')
|
||||
|
||||
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
|
||||
+772
@@ -0,0 +1,772 @@
|
||||
"""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 starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from . import db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables
|
||||
from .meta import APP_NAME, APP_VERSION, API_TITLE, API_DESCRIPTION
|
||||
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()
|
||||
yield
|
||||
|
||||
app=FastAPI(title=API_TITLE,description=API_DESCRIPTION,version=APP_VERSION,lifespan=lifespan)
|
||||
allowed_hosts=[x.strip() for x in __import__('os').environ.get('SITEFORGE_ALLOWED_HOSTS','').split(',') if x.strip()]
|
||||
if allowed_hosts:
|
||||
app.add_middleware(TrustedHostMiddleware,allowed_hosts=allowed_hosts)
|
||||
app.mount('/ui',StaticFiles(directory=str(ROOT/'app'/'static')),name='static')
|
||||
app.include_router(enterprise.router)
|
||||
app.include_router(factory_api.router)
|
||||
app.include_router(migration_audit.router)
|
||||
app.include_router(reusables.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)/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')) 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)/)?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'
|
||||
# The authenticated theme gallery embeds same-origin previews. Block
|
||||
# cross-origin framing without breaking that internal workflow.
|
||||
response.headers['X-Frame-Options']='SAMEORIGIN'
|
||||
response.headers['Referrer-Policy']='strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy']='camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
||||
response.headers['Content-Security-Policy']=(
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; "
|
||||
"form-action 'self' https:"
|
||||
)
|
||||
response.headers['Cache-Control']='no-store' if path.startswith('/api/auth/') else response.headers.get('Cache-Control','no-cache')
|
||||
return response
|
||||
|
||||
|
||||
@app.get('/theme-gallery',response_class=HTMLResponse)
|
||||
def theme_gallery():
|
||||
return (ROOT/'app'/'templates'/'theme-gallery.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':APP_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'/'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':APP_NAME,'version':APP_VERSION}
|
||||
|
||||
@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}
|
||||
|
||||
@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)
|
||||
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'
|
||||
|
||||
@app.put('/api/sites/{site_id}/pages/{entry_id}/blueprint')
|
||||
def save_blueprint(site_id:str,entry_id:str,payload:BlueprintPayload):
|
||||
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)):
|
||||
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 {}
|
||||
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)])
|
||||
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>'''
|
||||
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=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}\.(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')
|
||||
def new_build(site_id:str,payload:BuildIn):
|
||||
get_site(site_id)
|
||||
try:return builder.build(site_id,payload.mirror_images)
|
||||
except Exception as exc:fail(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')
|
||||
html=html.replace('<base href="/">',f'<base href="/preview/{site_id}/{build_id}/">')
|
||||
return HTMLResponse(html)
|
||||
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:
|
||||
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,'构建版本不存在')
|
||||
site=get_site(site_id)
|
||||
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 不会被自动修改'}
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Application identity shared by the API, health checks and UI clients."""
|
||||
|
||||
APP_NAME = "SiteForge"
|
||||
APP_VERSION = "5.0.0"
|
||||
API_TITLE = "SiteForge 网站工厂"
|
||||
API_DESCRIPTION = "Docker-first visual website factory"
|
||||
@@ -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"'})
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Offline static-build quality gate. No external network requests or speculative SEO scores."""
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit,unquote
|
||||
from bs4 import BeautifulSoup
|
||||
from . import db
|
||||
|
||||
def report(site_id, build_id):
|
||||
root=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
|
||||
if not root.is_dir():raise FileNotFoundError('构建版本不存在')
|
||||
files=list(root.rglob('*.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 p in files:
|
||||
rel=p.relative_to(root).as_posix()
|
||||
soup=BeautifulSoup(p.read_text(encoding='utf-8',errors='replace'),'html.parser')
|
||||
title=(soup.title.get_text(' ',strip=True) if soup.title else '')
|
||||
h1=soup.find_all('h1')
|
||||
if not title:issues.append({'file':rel,'type':'missing_title','severity':'error'})
|
||||
if len(h1)!=1:issues.append({'file':rel,'type':'h1_count','severity':'warning','count':len(h1)})
|
||||
if title and title in seen_titles and p.name!='404.html':issues.append({'file':rel,'type':'duplicate_title','severity':'warning','other':seen_titles[title]})
|
||||
seen_titles[title]=rel
|
||||
description=soup.find('meta',attrs={'name':'description'})
|
||||
desc=description.get('content','').strip() if description else ''
|
||||
if not desc:issues.append({'file':rel,'type':'missing_description','severity':'warning'})
|
||||
if desc and desc in seen_descriptions:issues.append({'file':rel,'type':'duplicate_description','severity':'warning','other':seen_descriptions[desc]})
|
||||
seen_descriptions[desc]=rel
|
||||
for img in soup.find_all('img'):
|
||||
if not img.get('alt'):issues.append({'file':rel,'type':'missing_image_alt','severity':'warning','src':img.get('src','')[:180]})
|
||||
for node in soup.find_all(['a','img','script','link']):
|
||||
attr='href' if node.name in ('a','link') else 'src'
|
||||
target=node.get(attr)
|
||||
if not target:continue
|
||||
parsed=urlsplit(target)
|
||||
if parsed.scheme in ('http','https'):
|
||||
if parsed.hostname!=hostname:continue
|
||||
path=parsed.path
|
||||
elif parsed.scheme or target.startswith('//'):continue
|
||||
elif target.startswith('#'):continue
|
||||
else:path=parsed.path
|
||||
if not path:continue
|
||||
if node.name=='link' and node.get('rel') and 'canonical' in node.get('rel'):continue
|
||||
from_root=path.startswith('/')
|
||||
# HTML uses <base href="/">, which makes relative paths site-root relative.
|
||||
base=soup.find('base',href=True)
|
||||
if base and base.get('href')=='/':from_root=True
|
||||
raw=unquote(path.lstrip('/'))
|
||||
if not from_root:raw=(p.relative_to(root).parent/raw).as_posix()
|
||||
test=(root/raw)
|
||||
if target.endswith('/') or not Path(raw).suffix:test=test/'index.html'
|
||||
if not test.resolve().is_relative_to(root) or not test.is_file():
|
||||
if node.name=='link' and ('alternate' in (node.get('rel') or [])):continue
|
||||
issues.append({'file':rel,'type':'broken_internal_reference','severity':'error','target':target[:220]})
|
||||
return {'files':len(files),'errors':sum(i['severity']=='error' for i in issues),
|
||||
'warnings':sum(i['severity']=='warning' for i in issues),
|
||||
'passed':not any(i['severity']=='error' for i in issues),'issues':issues[:500]}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Safe per-device block CSS using validated data-block identifiers."""
|
||||
import re,json
|
||||
|
||||
def editor_css(blocks):
|
||||
tablet=[];mobile=[]
|
||||
for b in blocks:
|
||||
uid=b.get('id','')
|
||||
if not re.fullmatch('[a-zA-Z0-9_-]{4,50}',uid):continue
|
||||
s=b.get('style') or {}
|
||||
try:
|
||||
tp=min(120,max(0,int(s.get('tablet_padding',50))))
|
||||
mp=min(120,max(0,int(s.get('mobile_padding',28))))
|
||||
except (TypeError,ValueError):tp,mp=50,28
|
||||
tablet.append(f'.sf-section[data-block="{uid}"]{{--sf-pad:{tp}px}}')
|
||||
mobile.append(f'.sf-section[data-block="{uid}"]{{--sf-pad:{mp}px}}')
|
||||
if s.get('hide_tablet') is True:tablet.append(f'.sf-section[data-block="{uid}"]{{display:none}}')
|
||||
if s.get('hide_mobile') is True:mobile.append(f'.sf-section[data-block="{uid}"]{{display:none}}')
|
||||
return '@media(max-width:820px){'+''.join(tablet)+'}@media(max-width:540px){'+''.join(mobile)+'}'
|
||||
|
||||
def site_css(entries):
|
||||
items=[]
|
||||
for entry in entries:
|
||||
meta=json.loads(entry.get('meta_json') or '{}')
|
||||
items.extend(meta.get('blocks',[]) if isinstance(meta.get('blocks'),list) else [])
|
||||
return editor_css(items)
|
||||
@@ -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}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
"""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''
|
||||
PASSWORD_SALT = b''
|
||||
CSRF_KEY = b''
|
||||
COOKIE_SECURE = False
|
||||
FAILURES = defaultdict(list)
|
||||
|
||||
|
||||
def configure():
|
||||
global SERIALIZER, PASSWORD_HASH, PASSWORD_SALT, 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')
|
||||
# Keep the deployment secret out of memory after startup and make each
|
||||
# verification deliberately expensive, consistent with team accounts.
|
||||
PASSWORD_SALT = secrets.token_bytes(16)
|
||||
PASSWORD_HASH = hashlib.scrypt(password.encode('utf-8'),salt=PASSWORD_SALT,n=2**14,r=8,p=1,dklen=32)
|
||||
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):
|
||||
try:
|
||||
candidate=hashlib.scrypt(password.encode('utf-8'),salt=PASSWORD_SALT,n=2**14,r=8,p=1,dklen=32)
|
||||
return hmac.compare_digest(candidate,PASSWORD_HASH)
|
||||
except (ValueError,TypeError,MemoryError):
|
||||
return False
|
||||
|
||||
|
||||
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()
|
||||
# Drop expired buckets so spoofed source addresses cannot grow memory
|
||||
# without bound on a long-running process.
|
||||
for key in list(FAILURES):
|
||||
recent=[t for t in FAILURES[key] if now-t < 900]
|
||||
if recent:FAILURES[key]=recent
|
||||
else:FAILURES.pop(key,None)
|
||||
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)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
const $=(q,el=document)=>el.querySelector(q);
|
||||
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 resp=await api('/api/sites/'+state.siteId+'/navigation',json('PUT',entries));state.site=await api('/api/sites/'+state.siteId);notice('导航已保存:'+resp.nav.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">v0.3 已提供页面区块拖拽排序、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;notice('开始构建,请检查完成后的质量报告...');try{let report=await api('/api/sites/'+s+'/builds',json('POST',{mirror_images:mirror}));await App.openSite(s,'builds');notice(`构建成功:${report.pages} 个页面 · ${report.warnings.length} 项警告`);App.showReport(report.version)}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());
|
||||
@@ -0,0 +1,13 @@
|
||||
/* 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}}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* 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}}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
const $=id=>document.querySelector(id);
|
||||
const html=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
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}/blueprint`);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;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>`;
|
||||
b.columns.forEach((column,ci)=>{
|
||||
out+=`<div class="layout-editor-col"><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>`;
|
||||
column.blocks.forEach((child,j)=>{out+=`<div class="layout-child"><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><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;
|
||||
},
|
||||
modified(){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}));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{let result=await api(`/api/sites/${siteId}/pages/${pageId}/blueprint/preset`,req('POST',{page_template:kind}));blocks=result.blocks;selected=0;dirty=false;Studio.draw();Studio.refreshPreview();toast('已加载页面预设并保存')}catch(e){toast(e.message,true)}},
|
||||
async save(){try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/blueprint`,req('PUT',{blocks,page_template:kind,layout_mode:layoutMode}));blocks=result.blocks;dirty=false;$('#saveStatus').textContent='已保存';$('#saveStatus').classList.remove('dirty');toast('页面蓝图已保存,重新构建后会生成正式静态页面')}catch(e){toast('保存失败:'+e.message,true)}},
|
||||
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 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){await Studio.save();if(dirty)return}try{toast('开始构建网站……');let result=await api(`/api/sites/${siteId}/builds`,req('POST',{mirror_images:false}));toast('构建完成:'+result.pages+' 个页面');window.open(`/preview/${siteId}/${result.version}/`,'_blank','noopener')}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('已修改文字,点击保存页面写入数据库');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'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 x=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/design-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]);
|
||||
await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/design-tokens`,'PUT',raw);
|
||||
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></tr></thead><tbody>${jobs.map(j=>`<tr><td><code>${E(j.id)}</code></td><td>${E(j.state)}</td><td>${j.attempts}</td><td>${simpleDate(j.created_at)}</td></tr>`).join('')||'<tr><td colspan="4">暂无任务</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 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
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
const $=s=>document.querySelector(s), H=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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('构建中……');let r=await api(siteUrl('/builds'),'POST',{mirror_images:false});toast(`构建成功,${r.pages} 个页面`);await render();window.open(`/preview/${state.site.id}/${r.version}/`,'_blank','noopener')}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;
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SiteForge trusted global shell. Site themes remain visually independent. */
|
||||
:root{--sf-shell:#12243d;--sf-shell-accent:#dcae71;--sf-shell-soft:#edf2f8;--sf-shell-fg:#173049}
|
||||
html{scroll-behavior:smooth}body{overflow-x:hidden}body>.header,body>.utility,body>.top,body>header,body>footer{display:none!important}
|
||||
.sf-theme-industrial{--sf-shell:#114e79;--sf-shell-accent:#92d5ed;--sf-shell-soft:#ebf6fa;--sf-shell-fg:#0c4267}
|
||||
.sf-theme-corporate{--sf-shell:#232d62;--sf-shell-accent:#b8bdff;--sf-shell-soft:#f3f3fa;--sf-shell-fg:#333468}
|
||||
.sf-theme-minimal{--sf-shell:#1e1e1c;--sf-shell-accent:#ded6c5;--sf-shell-soft:#f6f4ef;--sf-shell-fg:#252520}
|
||||
.sf-site-head{position:relative;z-index:25;background:var(--sf-shell);color:#fff;font-family:system-ui,-apple-system,sans-serif}
|
||||
.sf-head-inner{max-width:1240px;margin:0 auto;min-height:78px;padding:0 28px;display:flex;align-items:center;gap:28px}
|
||||
.sf-logo{display:flex;align-items:center;gap:11px;font-size:18px;text-decoration:none!important;color:#fff!important;white-space:nowrap}
|
||||
.sf-logo-mark{width:36px;height:36px;display:grid;place-items:center;background:var(--sf-shell-accent);color:var(--sf-shell);font-size:22px;font-weight:900;border-radius:9px}
|
||||
.sf-primary-links{display:flex;gap:23px;margin-left:auto;align-items:center}
|
||||
.sf-primary-links a,.sf-mega a{color:#fff!important;text-decoration:none!important;font-size:13px;font-weight:650;white-space:nowrap}
|
||||
.sf-primary-links a:hover{color:var(--sf-shell-accent)!important}
|
||||
.sf-head-cta{font-size:13px;font-weight:750;text-decoration:none;background:var(--sf-shell-accent);color:var(--sf-shell)!important;padding:13px 18px;border-radius:4px;white-space:nowrap}
|
||||
.sf-menu-toggle{display:none;margin-left:auto;color:white;border:1px solid #ffffff55;background:none;border-radius:4px;padding:8px 12px;font-size:19px;cursor:pointer}
|
||||
.sf-mega{display:flex;gap:18px;max-width:1240px;margin:auto;padding:0 28px 9px;overflow-x:auto}
|
||||
.sf-mega a{display:block;min-width:120px;border-top:1px solid #ffffff2e;padding:12px 8px}.sf-mega small{display:block;color:#ffffff99;max-width:160px;overflow:hidden;text-overflow:ellipsis;font-size:10px;margin-top:6px}
|
||||
.sf-site-foot{background:var(--sf-shell);color:white!important;font:14px/1.7 system-ui,-apple-system,sans-serif;padding:72px 28px 26px}
|
||||
.sf-foot-grid{max-width:1200px;margin:auto;display:grid;grid-template-columns:2fr 1fr 1fr;gap:70px}.sf-foot-grid h2{font-size:25px;color:white;margin:0 0 17px}.sf-foot-grid h4{font-size:13px;color:var(--sf-shell-accent);letter-spacing:.07em;margin:0 0 17px}.sf-foot-grid p{color:#ffffffae;max-width:380px}.sf-foot-grid a{display:block;width:fit-content;color:#fff!important;text-decoration:none;margin-bottom:8px}.sf-foot-lower{border-top:1px solid #ffffff33;max-width:1200px;margin:45px auto 0;padding-top:20px;display:flex;justify-content:space-between;color:#ffffff9e;font-size:12px}
|
||||
.sf-filters{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin:0 0 30px}.sf-filters input,.sf-filters select{padding:11px 13px;border-radius:6px;border:1px solid #cfd9e4;background:#fff;color:#263a51;max-width:280px}.sf-filter-empty{padding:22px;color:#778598}
|
||||
.sf-search-page{min-height:76vh;max-width:1060px;padding:90px 24px;margin:auto;font-family:system-ui,sans-serif;color:var(--sf-shell-fg)}.sf-search-page h1{font-size:clamp(35px,5vw,62px);margin:20px 0}.sf-search-page>label{display:block;margin:18px 0 8px}.sf-search-page input{display:block;width:100%;padding:17px;font-size:18px;border:1px solid #b7c6d7;border-radius:8px}.sf-search-page #sf-search-count{margin:22px 0;color:#607589}.sf-search-result{padding:22px 0;border-bottom:1px solid #dae1ea}.sf-search-result a{color:var(--sf-shell-fg);font-size:21px;font-weight:750;text-decoration:none}.sf-search-result small{display:block;color:#7992a7}.sf-search-result p{margin:7px 0;color:#657487}
|
||||
@media(max-width:1050px){.sf-head-inner{gap:13px}.sf-primary-links{gap:12px}.sf-primary-links a{font-size:12px}.sf-head-cta{padding:10px}}
|
||||
@media(max-width:850px){.sf-head-inner{min-height:68px}.sf-menu-toggle{display:block}.sf-head-cta{display:none}.sf-primary-links{display:none;position:absolute;top:68px;left:0;right:0;margin:0;background:var(--sf-shell);padding:20px 28px;flex-direction:column;align-items:stretch;box-shadow:0 20px 24px #0002}.sf-primary-links.is-open{display:flex}.sf-mega{display:none}.sf-foot-grid{grid-template-columns:1fr 1fr;gap:35px}}
|
||||
@media(max-width:560px){.sf-foot-grid{grid-template-columns:1fr}.sf-foot-lower{display:block}.sf-site-foot{padding-top:48px}.sf-head-inner{padding:0 16px}}
|
||||
|
||||
.sf-page-intro{padding:80px 28px;background:var(--sf-shell-soft);color:var(--sf-shell-fg);font-family:system-ui,sans-serif}.sf-page-intro>div{max-width:1200px;margin:auto}.sf-page-intro p{font-size:11px;font-weight:850;letter-spacing:.19em;text-transform:uppercase;color:var(--sf-shell-fg);opacity:.58}.sf-page-intro h1{font-size:clamp(38px,5vw,67px);letter-spacing:-.04em;line-height:1.08;margin:13px 0}.sf-page-intro span{font-size:16px;opacity:.72}
|
||||
.sf-item[hidden]{display:none!important}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,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();
|
||||
});
|
||||
})();
|
||||
@@ -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)});
|
||||
}
|
||||
})();
|
||||
@@ -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>'''
|
||||
@@ -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}
|
||||
@@ -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">◈ 网站工厂 V5 →</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>SiteForge Enterprise v1.0</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>
|
||||
</body></html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!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>
|
||||
<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 primary" onclick="Studio.save()">保存页面 <span>↗</span></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>
|
||||
<div id="toast" class="toast"></div>
|
||||
</div></body></html>
|
||||
@@ -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 3 · 网站生产中心</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>WEBSITE FACTORY V5</small></span></a>
|
||||
<div class="side-title">生产中心 / WORKSPACE</div>
|
||||
<nav id="navtabs"><button data-tab="overview" class="active">◫ 网站总览</button><button data-tab="themes">▧ 主题与版本</button><button data-tab="content">▤ 内容与数据装配</button><button data-tab="migration">⇄ 迁移与 SEO 验收</button><button data-tab="build">◈ 构建与发布</button></nav>
|
||||
<div class="side-bottom"><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>
|
||||
@@ -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 · Enterprise Edition v1.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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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 make_context(site,page,records,mode='detail',listing_items=None):
|
||||
pg=dict(page or {})
|
||||
kind=infer_kind(pg,mode)
|
||||
groups=all_groups(records)
|
||||
listing=select_listing(pg,kind,groups,records,listing_items)
|
||||
if listing_items and mode=='listing' and not (pg.get('route') or '').startswith('/categories/'):
|
||||
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':[{'name':c,'url':'/categories/'+slug(c)+'/'} for c in categories(records)],
|
||||
'brands':brands(records)}
|
||||
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 ''))
|
||||
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, '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':settings.get('email') or site.get('email') or '',
|
||||
'footer_text':settings.get('footer_text') or site.get('tagline') or '',
|
||||
'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=''):
|
||||
if theme not in THEMES:raise ValueError('不是内置专业主题')
|
||||
ctx=make_context(site,page,records,mode,listing_items)
|
||||
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)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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, time
|
||||
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))
|
||||
# Windows virus scanners and indexers can briefly hold a freshly
|
||||
# written directory. Retry the atomic rename, then fall back to a
|
||||
# checked copy while the database record still has not been created.
|
||||
for attempt in range(5):
|
||||
try:
|
||||
temp.rename(path)
|
||||
break
|
||||
except PermissionError:
|
||||
if attempt==4:
|
||||
shutil.copytree(temp,path)
|
||||
shutil.rmtree(temp)
|
||||
break
|
||||
time.sleep(0.05*(attempt+1))
|
||||
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)}
|
||||
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Dedicated Docker worker: database-backed queue, serialized claim, retry + restart recovery."""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from . import db, enterprise, builder
|
||||
|
||||
running=True
|
||||
|
||||
def stop(*_):
|
||||
global running
|
||||
running=False
|
||||
|
||||
def claim():
|
||||
with db.connect() as c:
|
||||
c.execute('BEGIN IMMEDIATE')
|
||||
job=c.execute("""SELECT id FROM build_jobs
|
||||
WHERE state='queued' AND (retry_at IS NULL OR retry_at<=?)
|
||||
ORDER BY created_at,id LIMIT 1""",(db.now(),)).fetchone()
|
||||
if not job:return None
|
||||
c.execute("UPDATE build_jobs SET state='running',attempts=attempts+1,started_at=?,retry_at=NULL WHERE id=?",(db.now(),job['id']))
|
||||
return dict(c.execute('SELECT * FROM build_jobs WHERE id=?',(job['id'],)).fetchone())
|
||||
|
||||
def run_once():
|
||||
job=claim()
|
||||
if job is None:return None
|
||||
try:
|
||||
if job['kind']=='build': result=builder.build(job['site_id'],False)
|
||||
else:raise ValueError('Unsupported job')
|
||||
db.execute("UPDATE build_jobs SET state='done',result_json=?,finished_at=? WHERE id=?",
|
||||
(json.dumps(result,ensure_ascii=False),db.now(),job['id']))
|
||||
except Exception as exc:
|
||||
maximum=max(1,int(os.getenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')))
|
||||
if job['attempts']<maximum:
|
||||
delay=min(300,5*(2**max(job['attempts']-1,0)))
|
||||
retry_at=(datetime.now(timezone.utc)+timedelta(seconds=delay)).isoformat(timespec='seconds')
|
||||
db.execute("""UPDATE build_jobs SET state='queued',error=?,started_at=NULL,
|
||||
finished_at=NULL,retry_at=? WHERE id=?""",
|
||||
(str(exc)[:1200],retry_at,job['id']))
|
||||
else:
|
||||
db.execute("UPDATE build_jobs SET state='failed',error=?,finished_at=?,retry_at=NULL WHERE id=?",
|
||||
(str(exc)[:1200],db.now(),job['id']))
|
||||
return job['id']
|
||||
|
||||
def main():
|
||||
db.init();enterprise.setup()
|
||||
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
|
||||
# A crash before the build record is committed is safe to retry: build IDs
|
||||
# are generated inside builder.build and output directories are immutable.
|
||||
maximum=max(1,int(os.getenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')))
|
||||
db.execute("""UPDATE build_jobs SET state='queued',error='Worker interrupted; retry scheduled',
|
||||
started_at=NULL,finished_at=NULL,retry_at=?
|
||||
WHERE state='running' AND attempts<?""",(db.now(),maximum))
|
||||
db.execute("""UPDATE build_jobs SET state='failed',error='Worker interrupted; retry limit reached',
|
||||
finished_at=?,retry_at=NULL WHERE state='running'""",(db.now(),))
|
||||
while running:
|
||||
if not run_once():time.sleep(float(os.getenv('SITEFORGE_JOB_POLL_SECONDS','1.5')))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,26 @@
|
||||
# 在正式 DNS / HTTPS 域名配置完成后才启用:
|
||||
# docker compose -f compose.yaml -f compose.public.yaml up -d --build
|
||||
services:
|
||||
siteforge:
|
||||
environment:
|
||||
SITEFORGE_COOKIE_SECURE: "true"
|
||||
SITEFORGE_ALLOWED_HOSTS: "${SITEFORGE_DOMAIN},localhost,127.0.0.1"
|
||||
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:
|
||||
@@ -0,0 +1,62 @@
|
||||
name: siteforge-v5
|
||||
services:
|
||||
siteforge:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: siteforge/web:5.0
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${SITEFORGE_PORT:-8787}:8787"
|
||||
environment:
|
||||
SITEFORGE_DATA_DIR: /data
|
||||
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_ALLOWED_HOSTS: "${SITEFORGE_ALLOWED_HOSTS:-localhost,127.0.0.1,testserver}"
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: siteforge/web:5.0
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
command: ["python", "-m", "app.worker"]
|
||||
environment:
|
||||
SITEFORGE_DATA_DIR: /data
|
||||
SITEFORGE_JOB_POLL_SECONDS: "1.5"
|
||||
SITEFORGE_JOB_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
|
||||
|
||||
volumes:
|
||||
siteforge_data:
|
||||
siteforge_published:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 尚须在用户的运行环境验证镜像构建及持久卷。
|
||||
@@ -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`;不依赖第三方图片服务。
|
||||
@@ -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、限流、备份、生产安全审计。管理中心与客户静态网站独立部署。
|
||||
@@ -0,0 +1,17 @@
|
||||
# SiteForge 企业正式生产化清单
|
||||
|
||||
本项目实现了单机 Docker Web 形式的网站生产核心功能。要达到大型企业多团队多环境长期运行标准,还应完成如下工程工作(不属于本次已验证能力):
|
||||
|
||||
1. **数据库和队列**:从当前 SQLite WAL 单实例升级到 PostgreSQL(Alembic 迁移、多租户行级访问控制、连接池)和 Redis/RabbitMQ + Worker;构建任务幂等、重试退避、死信队列、任务超时和容量控制。
|
||||
2. **账号与认证**:接入 OIDC / SSO、MFA、账号生命周期、细粒度角色审批、管理员紧急停用和会话撤销。当前 Cookie 签名在有效期内仍需账户禁用验证。
|
||||
3. **审计不可抵赖**:当前审计日志与主库共存,应写入追加型独立日志、配置审计保留策略和异地日志汇聚。
|
||||
4. **主题市场与沙箱**:严格的主题 Manifest 版本约束、组件兼容性、预览沙箱、第三方主题供应链签名、模板回滚和主题资产扫描。
|
||||
5. **高级可视化编辑**:页面级断点独立控制、样式变量冲突检测、组件属性 Schema、草稿分支、多人冲突锁、预览审批,避免用户自定义不受控的 HTML/JS。
|
||||
6. **大型 CMS 迁移**:WordPress 数据库/ACF/Elementor/WooCommerce 扩展适配器、Drupal、Joomla、Shopify/Headless CMS 权限 API、分页重试和导入幂等;开放页面爬虫不可替代私有源数据。
|
||||
7. **静态 QA**:旧站抓取 URL 完整性(包含状态码/Canonical/Hreflang/HTTP 缓存/结构化数据)、手机/桌面视觉差异、在线域名上线后的真实 301/404 响应检查,Search Console 监测。
|
||||
8. **安全**:运行时镜像与 Python 包漏洞扫描、供应链锁文件、SSRF DNS pinning / 网络 egress ACL、文件解析沙箱、病毒扫描、限流共享 Redis、CSP/安全响应头、DNS/CDN 变更审批。
|
||||
9. **发布管道**:Nginx/Caddy/对象存储/CDN 多提供商部署器、同步版本清单、预部署审核、流量切换、回滚、不同环境变量密钥管理。
|
||||
10. **运维 SLO**:Prometheus/OpenTelemetry、集中日志、异常报警、备份异地与还原演练、容量规划、真实 Docker/Kubernetes 压测及 RPO/RTO 目标。
|
||||
11. **Art Design Pro 官方集成**:取得有权使用的 Art Design Pro 版本源码,按对应版本路由/权限系统接入并在 Node 构建环境完整测试、验证其许可证要求。
|
||||
|
||||
建议实施顺序:正式试点环境 → 真实 WP 站迁移基线验收 → PostgreSQL/队列 → Art 前端正式整合 → 安全审计 → 大型网站灰度发布。
|
||||
@@ -0,0 +1,29 @@
|
||||
# SiteForge V2 六大优先级 · 开发交付与验收状态
|
||||
|
||||
最后核对:2026-10-21。本文件用“已实现/基础实现/未实现/未验证”分别记录,不把开发代码视为已通过生产验收。
|
||||
|
||||
| 用户要求 | 当前交付的可用能力 | 尚需继续的功能 | 状态 |
|
||||
|---|---|---|---|
|
||||
| ① 完整主题 | 四套独立设计、9 类页面预设、导航/响应式页脚、静态搜索和筛选、上传声明式主题 ZIP、版本管理、预览、站点固定版本 | 更完善的商业设计资产、更多独立布局组件、高级导航交互、行业专题详情定制 | 基础实现,视觉未商业验收 |
|
||||
| ② 可视化编辑 | 区块拖拽排序/增删/复制、图片上传、预览双击文字编辑、PC/平板/手机布局差异、设计 Token、构建实时生效 | 任意布局嵌套/多层栅格、任意元素拖放、实时协同、完整撤销/重做、复杂动画 | 结构化区块编辑已实现 |
|
||||
| ③ 内容智能装配 | 产品/车型/品牌映射、字段别名识别、规则保存、CSV/JSON 映射预演、冲突报告、实际导入、内容关联列表和详情 | 多层关联复杂自动识别、AI 语义识别、全量 Excel/数据库字段映射后台工作流和数据质量分析 | 规则式自动装配已实现 |
|
||||
| ④ 完整旧站迁移 | WP WXR/REST、公开爬取、CSV/XLSX/JSON、ACF/Woo 部分字段、原 URL 基线、SEO 差异/404/资源质量检查、发布门禁 | Elementor 深度重构、完整 Woo 业务数据、非 WP 专有 CMS/数据库深度迁移、真实网站逐页验收 | 基础迁移,**不保证无损** |
|
||||
| ⑤ 正式 Art Design Pro | Vue3 业务页面与官方工程离线接入脚本;完整可用原生 Web 控制台 | 官方完整工程及依赖集成、前端生产构建、路由与登录权限同构、官方 UI E2E | **尚未正式完成** |
|
||||
| ⑥ 企业级发布运维 | 登录 RBAC、操作审计、异步 Worker、双人审批、本地发布、版本回滚、Docker Compose 数据卷 | PostgreSQL/Redis、多实例高可用、远程发布/HTTPS/CDN、监控告警、压测/渗透/容灾演练 | 基础交付,**未完成生产化** |
|
||||
|
||||
## 本次可以跑通的闭环
|
||||
|
||||
- 从空白新建站点 → 初始化标准栏目 → 添加/导入内容 → 选择主题 → 设计区块 → 构建 → 预览 → 导出静态站。
|
||||
- 旧站内容按字段导入 → URL 和 SEO 基线 → 更换主题 → 检查是否丢页 → 审批 → 本地发布。
|
||||
- 一个账号仅能管理授权网站;不同账号申请与审批。新增版本后原内容保持可追溯。
|
||||
|
||||
## 生产发布前的强制验收
|
||||
|
||||
- [ ] 真实 Docker 环境完成 Compose 构建与 Web/Worker 启停验证。
|
||||
- [ ] Art Design Pro 官方代码整合并通过 `pnpm build`、浏览器验收(若坚持用它作为唯一后台)。
|
||||
- [ ] 数据卷、数据库升级方案、异地备份和恢复演练。
|
||||
- [ ] 真实旧站全量 URL/SEO/资源清单与新站差异报告无未处理阻断项。
|
||||
- [ ] 登录/权限/CSRF/SSRF/上传文件安全检查和渗透测试。
|
||||
- [ ] CDN/HTTPS/多域名发布、回滚、容量和性能验收。
|
||||
|
||||
**结论:这是可继续扩展的 V2 开发集成版本,不等于六项要求全部生产级完成。**
|
||||
@@ -0,0 +1,905 @@
|
||||
# SiteForge 智能网站生产工厂
|
||||
|
||||
> **完整产品需求与技术架构方案(PRD + Architecture + Implementation Plan)**
|
||||
> 版本:V2.0(对 V1.0“通用多站点迁移与静态建站平台”的定位升级)
|
||||
> 日期:2026-10-21
|
||||
> 状态:产品与研发规划版
|
||||
> 适用:产品经理、设计师、前后端研发、测试、运维、Claude 等编程助手
|
||||
|
||||
---
|
||||
|
||||
## 0. 核心定义与产品定位
|
||||
|
||||
**SiteForge 不只是旧网站迁移工具,而是“网站工厂(Website Factory)”:以模板、数据、页面结构和站点配置为原材料,经过装配、构建、验证和发布,持续生产完全独立的网站。**
|
||||
|
||||
网站可以:
|
||||
|
||||
1. **旧站改造**:从 WordPress、其他 CMS、自研 PHP 或普通网站导入既有内容,选用全新主题,保留旧站 SEO 和 URL,重建设计不同的静态网站。
|
||||
2. **模板建站**:没有旧站,选择行业模板,填入企业资料、Logo、产品、文章和媒体文件,直接形成新站。
|
||||
3. **数据建站**:从 Excel/CSV/JSON/XML、API、数据库或图片资料批量导入新内容,自动建立内容类型和页面,匹配模板生成网站。
|
||||
4. **自由建站**:从空白项目开始创建导航、页面、区块、数据集合、主题和页面规则;最终保存为可复用的新模板。
|
||||
5. **混合建站**:上述方式可自由组合,例如“旧站文章 + 新 Excel 产品 + 新模板 + 自定义页面”。
|
||||
|
||||
统一原则:**内容和视觉彻底分离,迁移和新建使用同一个内容模型、页面装配器、静态构建器、质量检测及发布系统。**
|
||||
|
||||
### 0.1 产品成功标准
|
||||
|
||||
- 非迁移用户也能完整创建、编辑、构建并发布一个新网站。
|
||||
- 旧站迁移不是复制旧视觉,而是按“内容映射 → 新主题 → 全新网站”装配。
|
||||
- 同一份数据可以切换不同主题、重复构建;主题切换不得无故删除内容。
|
||||
- 一份设计可以保存为模板;模板升级不能未经确认覆盖站点定制内容。
|
||||
- 多网站独立运行、互不影响;输出静态文件后生产站不依赖中心平台在线。
|
||||
- 迁移站保留需要保留的 URL、SEO、链接和媒体访问行为;全新站可自由制定 URL/SEO 策略。
|
||||
- 导出物有真实 HTML/CSS/JS/媒体、必要的路由/重定向/服务器配置,而非只提供开发框架项目。
|
||||
|
||||
### 0.2 非目标与边界
|
||||
|
||||
- 不是承诺对任何公开 URL 一键获得不可见的后台、私有数据或完整业务代码。
|
||||
- 不是将所有交互功能硬转为静态 HTML;账号、支付、订单、实时库存、复杂搜索等需要独立 API/服务。
|
||||
- 不是初期一次性建成可对任意复杂页面精准拖拽设计的通用 Webflow 类编辑器。
|
||||
- 不保证迁移后搜索排名零波动;系统负责风险降低、可追溯验证和监测。
|
||||
|
||||
---
|
||||
|
||||
## 1. 四大建站入口与混合生产工作流
|
||||
|
||||
| 模式 | 起始条件 | 用户主要步骤 | 输出 |
|
||||
|---|---|---|---|
|
||||
| A 旧站重建 | 有旧网站 | 识别旧站 → 导入内容/SEO/URL → 选主题 → 映射 → 预览 → 构建 → 发布 | 新设计的独立网站 |
|
||||
| B 模板新建 | 无旧站 | 选行业模板 → 填公司信息 → 导入或新增内容 → 配置栏目 → 预览 → 发布 | 全新网站 |
|
||||
| C 数据驱动 | 有 Excel/API/数据库 | 导入数据 → 字段映射 → 生成内容集合 → 选模板 → 自动建立列表/详情页 → 发布 | 数据驱动批量页面网站 |
|
||||
| D 空白搭建 | 需要自主设计 | 创建站点 → 可视化组装页面 → 配置设计变量/数据 → 预览 → 发布 → 沉淀为模板 | 定制网站与可复用模板 |
|
||||
|
||||
### 1.1 “继续生产”能力
|
||||
|
||||
网站创建完后可继续:新增文章和产品、批量导入新数据、替换 Banner、维护多语言、增删栏目、修改 SEO、调整主题样式、升级组件、局部/全站重建、回滚到旧版本。**迁移任务完成不等于站点项目结束。**
|
||||
|
||||
### 1.2 三个关键产品动作
|
||||
|
||||
- **Create Site**:从空白、模板或克隆既有站点结构新建。
|
||||
- **Import Content**:随时将旧站、文件、API 等数据导入现有站点,映射、去重、合并和校验。
|
||||
- **Build & Publish**:将某一明确的数据版本 + 主题版本 + 站点配置打包成可追溯静态发布版本。
|
||||
|
||||
不能把“导入网站”与“创建网站”绑定成一对一:一个站点允许多个数据源,一个数据源也可按授权复用到多个站点。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构:三条输入管线、一个生产流水线
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Inputs[网站生产输入]
|
||||
I1[旧站导入:WP/CMS/PHP/HTML]
|
||||
I2[新内容导入:Excel/CSV/JSON/API/媒体]
|
||||
I3[从零创建:内容编辑器/页面编辑器]
|
||||
end
|
||||
subgraph Core[SiteForge 网站工厂控制中心]
|
||||
C1[通用内容中心 / 媒体中心]
|
||||
C2[模板市场 / 主题设计系统]
|
||||
C3[页面编辑器 / 页面装配规则]
|
||||
C4[网站蓝图 Site Blueprint]
|
||||
C5[URL / SEO / 多语言规则]
|
||||
C6[任务引擎 / 版本 / 权限]
|
||||
end
|
||||
subgraph Pipeline[网站生产流水线]
|
||||
P1[数据校验与字段映射]
|
||||
P2[站点装配与路由解析]
|
||||
P3[静态构建 HTML/CSS/JS]
|
||||
P4[预览 / SEO / 功能 / 链接检查]
|
||||
P5[发布 / ZIP 导出 / 回滚]
|
||||
end
|
||||
I1 --> P1
|
||||
I2 --> P1
|
||||
I3 --> C1
|
||||
P1 --> C1
|
||||
C1 --> C4
|
||||
C2 --> C4
|
||||
C3 --> C4
|
||||
C5 --> C4
|
||||
C6 --> C4
|
||||
C4 --> P2 --> P3 --> P4 --> P5
|
||||
P5 --> S[独立运行的网站 A / B / C ...]
|
||||
```
|
||||
|
||||
### 2.1 控制面与生产面分离
|
||||
|
||||
控制面:SiteForge 后台、API、数据/媒体资产、模板、采集、构建任务、发布/审核。生产面:各网站独立的静态文件/CDN/Nginx 与可选独立业务 API。**线上网站运行不要求 SiteForge API 或数据库常驻可用。**
|
||||
|
||||
### 2.2 “网站”并不是“主题”
|
||||
|
||||
一个网站由以下因素组合:
|
||||
|
||||
```
|
||||
Website Release =
|
||||
Site Configuration
|
||||
+ Site Blueprint (页面/栏目/菜单/区块/路由规则)
|
||||
+ Theme & Component Version
|
||||
+ Content Snapshot
|
||||
+ Asset Snapshot
|
||||
+ SEO & Redirect Rules
|
||||
+ Environment & Build Settings
|
||||
```
|
||||
|
||||
这是保证可重复构建、主题更换和版本回滚的核心公式。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键领域模型(必须从第一天设计好)
|
||||
|
||||
### 3.1 Site(站点)
|
||||
|
||||
唯一站点 ID、站点类型、语言/地区、域名、环境、数据源、主题引用、权限、发布策略。
|
||||
|
||||
### 3.2 Site Blueprint(网站蓝图)
|
||||
|
||||
蓝图描述**网站结构而不是内容本身**:
|
||||
|
||||
- 页面树:主页、关于我们、联系方式、服务、产品、新闻、品牌、活动等。
|
||||
- 导航树:顶部主导航、Mega Menu、页脚、移动导航。
|
||||
- 页面类型:普通静态页、内容集合列表、集合详情、分类聚合、专题页。
|
||||
- 路由规则:固定页 slug、列表分页、产品 URL 格式、语言前缀。
|
||||
- SEO 规则:title 模板、canonical、sitemap/noindex、结构化数据类型。
|
||||
- 区块编排:Banner、产品网格、优势、表单、统计、FAQ、CTA 等。
|
||||
- 设计变量:品牌色、字体、布局宽度、间距、圆角、响应式断点。
|
||||
- 模板绑定:哪个页面类型使用哪套布局及组件。
|
||||
- 功能扩展:搜索、询盘、表单、在线聊天、外部 API 等。
|
||||
|
||||
站点蓝图可以从模板初始化,也可以由编辑器独立创建;保存之后属于站点自身,主题升级不得悄悄覆盖人工更改。
|
||||
|
||||
### 3.3 Content Type(内容类型)与 Entry(内容条目)
|
||||
|
||||
内容中心不能硬编码只支持“文章”和“产品”,应支持动态类型:
|
||||
|
||||
- 默认类型:Page、Post、Product、Category、Brand、Service、Case、FAQ、Team、Download。
|
||||
- 自定义内容类型:如汽车的 Vehicle、工业管材的 Pipe、房地产的 Property。
|
||||
- 字段定义:短文本、富文本、数字、布尔、日期、枚举、关系、数组、媒体、SEO 字段、多语言字段、引用。
|
||||
- 校验规则:是否必填、唯一性、格式、单位、可搜索/筛选、跨字段规则。
|
||||
- 数据状态:draft / review / published / archived;版本和来源可追踪。
|
||||
|
||||
### 3.4 Theme(主题)/ Template(页面模板)/ Component(组件)
|
||||
|
||||
- **主题 Theme**:完整设计风格及组件集合、页面模板、响应式规则、设计 Token。
|
||||
- **页面模板 Page Template**:例如首页、产品列表、产品详情、文章详情、专题落地页。
|
||||
- **组件 Component**:Hero、图片网格、产品卡、参数表、图文交错、导航栏、页脚等。
|
||||
- **主题实例 Theme Instance**:某站点在主题基础上的参数覆盖与自定义区块配置。
|
||||
|
||||
### 3.5 Route Registry(路由注册表)
|
||||
|
||||
一切最终发布 URL 的唯一事实源:`site_id + method + normalized_path + locale + route_behavior`;必须记录来源及冲突处理,不能仅由标题推断路径。
|
||||
|
||||
---
|
||||
|
||||
## 4. 模板工厂:不仅选模板,还要生产模板
|
||||
|
||||
### 4.1 模板分类
|
||||
|
||||
- 行业模板:汽车出口、工业品、制造业、外贸 B2B、企业介绍、服务类、资讯类。
|
||||
- 页面模板:首页、列表、详情、关于、联系、新闻、案例、FAQ、落地专题等。
|
||||
- 区块库:Hero、统计、产品参数、网格、时间线、客户评价、团队、展会等。
|
||||
- 布局方案:顶部导航、侧边导航、全宽、多列、自适应等。
|
||||
|
||||
### 4.2 模板选择体验
|
||||
|
||||
每个主题展示 PC/移动端预览、适合行业、支持的内容类型、内置页面数量、菜单形态和可编辑范围。选择主题后运行 `compatibilityCheck`,提示当前内容是否满足主题必需字段,并提供字段映射或替代组件选项。
|
||||
|
||||
### 4.3 模板来源
|
||||
|
||||
1. 平台预置主题。
|
||||
2. 开发人员以标准主题 SDK 编写并注册主题。
|
||||
3. 在设计器中制作页面,保存为模板或区块。
|
||||
4. 从现有网站**复制结构/设计配置**作为新项目起点,不自动复制私有数据。
|
||||
|
||||
### 4.4 防止主题与数据耦合
|
||||
|
||||
主题只能根据语义接口取数据,例如 `products.featured`、`company.name`、`posts.latest`。不能把某个具体产品、域名或 WordPress ID 硬编码进主题组件。
|
||||
|
||||
### 4.5 模板升级
|
||||
|
||||
主题使用不可变版本号。站点自行选择是否升级;升级前进行数据兼容性检测和区块差异预览;不支持自动合并的站点私有修改应手动解决。
|
||||
|
||||
---
|
||||
|
||||
## 5. 可视化建站编辑器(分级实现)
|
||||
|
||||
### 5.1 编辑器 V1:区块式编辑
|
||||
|
||||
支持:增删页面、排列标准区块、替换组件样式、设定图片/文案/数据引用、编辑导航与页脚、调整设计 Token、切换 PC/平板/手机预览、草稿/审核/发布。
|
||||
|
||||
### 5.2 编辑器 V2:结构化可视化编排
|
||||
|
||||
支持:嵌套容器、列布局、响应式属性、条件显示、组件插槽、集合绑定、重复列表、页面模板编辑和全站设计系统管理。
|
||||
|
||||
### 5.3 编辑器 V3(可选):高级自由设计
|
||||
|
||||
高级拖拽、交互动效、定制脚本、组件市场等;需严格控制任意 HTML/JS 注入与主题兼容问题。
|
||||
|
||||
**不建议 V1 开发任意像素拖拽引擎。** 先用区块式编辑器建立高复用度和稳定构建体验;高级主题仍允许开发者编写独立代码。
|
||||
|
||||
### 5.4 主题自由度分级
|
||||
|
||||
- L1 配置型:换色、Logo、素材和文案即可上线。
|
||||
- L2 区块型:调整首页和内页的组件排列及内容映射。
|
||||
- L3 高级定制型:独立开发主题,完全自定义 HTML、样式、交互和布局。
|
||||
|
||||
这避免为了统一拖拽而让不同网站“长得一样”。
|
||||
|
||||
---
|
||||
|
||||
## 6. 内容工厂:手动、批量、API 与旧站多源输入
|
||||
|
||||
### 6.1 数据输入管道
|
||||
|
||||
| 输入渠道 | 典型数据 | 数据处理 |
|
||||
|---|---|---|
|
||||
| 后台手动新增 | 文章、产品、服务、页面 | 表单编辑和字段校验 |
|
||||
| Excel / CSV | 车型、价格、参数、批量产品 | 预览字段、列映射、类型推断、增量更新 |
|
||||
| JSON / XML | 第三方结构化数据 | schema 映射、字段转换、关系解析 |
|
||||
| API / Webhook | ERP、CRM、内部商品系统 | 授权连接、定时/手动同步、冲突策略 |
|
||||
| SQL / 备份 | 原数据库内容 | 隔离解析、适配映射、数据脱敏 |
|
||||
| WordPress XML/API | 文章、产品、分类、meta | WordPress 插件与字段适配器 |
|
||||
| 通用网站抓取 | 公开页面、SEO、图片、菜单 | 页面发现、JS 渲染、去噪和人工确认 |
|
||||
| 媒体 ZIP / 对象存储 | 图片、PDF、视频 | 去重、批量命名和资源引用映射 |
|
||||
|
||||
### 6.2 通用导入工作台
|
||||
|
||||
选择数据源 → 预览 → 识别内容类型 → 字段映射 → 数据清洗 → 去重/冲突策略 → 导入模拟运行(dry-run) → 确认入库 → 验证关联与资源 → 保留导入快照。
|
||||
|
||||
支持“仅新增”“按唯一键更新”“新增+更新”“人工审核冲突”;**默认不得直接覆盖人工编辑过的字段**。保留来源字段值、最新导入值、人工覆盖值及优先级。
|
||||
|
||||
### 6.3 内容与页面的区别
|
||||
|
||||
- 录入 1000 个车型,不意味着手工建 1000 个页面。
|
||||
- 车型都是 `Vehicle` 内容条目;系统采用一个 `vehicle-detail` 页面模板批量生成 1000 个对应 URL。
|
||||
- 新增车型后自动生成新详情页并更新分类列表、筛选数据、站点地图与内部链接。
|
||||
|
||||
### 6.4 媒体中心
|
||||
|
||||
- 原图存储,派生多尺寸格式;保留 alt、文件名、来源 URL 和使用关系。
|
||||
- 旧站媒体需维护原路径映射,必要时保留原 URL 的资源响应。
|
||||
- 批量上传、图库、资源去重、视频外链、PDF 和下载附件。
|
||||
- 资源删除时检查使用关系;不能造成已上线页面引用失效。
|
||||
|
||||
---
|
||||
|
||||
## 7. 网站装配引擎(全产品最核心的技术)
|
||||
|
||||
### 7.1 输入
|
||||
|
||||
`site_blueprint.json + theme@version + content_snapshot + asset_manifest + route_registry + SEO_policy`
|
||||
|
||||
### 7.2 核心过程
|
||||
|
||||
1. 验证主题与内容类型是否兼容。
|
||||
2. 解析页面树与导航,构造页面上下文。
|
||||
3. 读取数据绑定配置,生成列表和详情集合。
|
||||
4. 根据 URL/语言规则生成页面路径,检测重复与冲突。
|
||||
5. 按页面模板渲染静态 HTML,并生成 CSS/JS。
|
||||
6. 生成 sitemap、robots、结构化数据和必要的重定向配置。
|
||||
7. 执行页面链接、SEO、媒体、性能及功能验证。
|
||||
8. 形成不可变构建产物、版本元数据和差异报告。
|
||||
|
||||
### 7.3 装配示例:汽车站
|
||||
|
||||
- 公司信息绑定 `company.*`。
|
||||
- 首页精选车型绑定 `vehicles where featured=true`。
|
||||
- 品牌列表绑定 `brands`,每个品牌生成 `/brands/{slug}/`。
|
||||
- 车型详情绑定 `vehicles`,生成 `/models/{slug}/` 或从旧站注册表获取原有 URL。
|
||||
- 新闻内容绑定 `posts`,自动生成新闻列表和详情。
|
||||
- PC 和移动端页面由同一语义内容驱动,但主题可采用不同响应式布局。
|
||||
|
||||
### 7.4 蓝图 JSON 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"siteKey": "export-auto-demo",
|
||||
"mode": "migration-redesign",
|
||||
"theme": {"id": "automotive-premium", "version": "1.4.0"},
|
||||
"locale": "en",
|
||||
"designTokens": {"primaryColor": "#1765B2", "contentWidth": "1280px"},
|
||||
"pages": [
|
||||
{"kind": "static", "path": "/", "template": "home"},
|
||||
{"kind": "collection", "path": "/products/", "template": "product-list", "contentType": "vehicle"},
|
||||
{"kind": "entry", "template": "product-detail", "contentType": "vehicle", "routeSource": "registry"}
|
||||
],
|
||||
"navigation": {"header": ["/", "/products/", "/about-us/", "/contact/"]},
|
||||
"seoPolicy": "preserve-imported"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 主题数据绑定示例
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "home",
|
||||
"slots": {
|
||||
"hero": {"type": "Hero", "bindings": {"title": "site.homeHero.title", "image": "site.homeHero.image"}},
|
||||
"featured": {"type": "VehicleGrid", "source": {"contentType": "vehicle", "filter": {"featured": true}, "limit": 8}},
|
||||
"news": {"type": "LatestPosts", "source": {"contentType": "post", "limit": 3}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
规则应由受控 schema/查询 DSL 解释,**禁止把任意用户输入作为数据库查询或 JS/SQL 执行**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 旧站迁移子系统(Website Factory 的一个入口)
|
||||
|
||||
### 8.1 兼容范围与优先顺序
|
||||
|
||||
P0:WordPress(REST API + WXR XML + SQL + `wp-content`)、HTML 静态站、普通 PHP 公开站。P1:Drupal、Joomla、自定义 CMS、Vue/React 渲染站。P2:电商、会员、复杂业务站专用适配器。
|
||||
|
||||
### 8.2 自动扫描
|
||||
|
||||
抓取 robots、sitemap、内部链接、菜单、页面、分页、媒体和 head SEO;识别 CMS、页面类型、动态表单、语言、状态码和 canonical;得到带来源、置信度和异常标记的扫描清单。
|
||||
|
||||
### 8.3 迁移独有工作流
|
||||
|
||||
`Old Site → Snapshot → Extract → Normalize → Map To Theme → Route/SEO Preserve → Preview/QA → Cutover`
|
||||
|
||||
1. 建立旧站不可变快照,记录内容、URL、响应状态、SEO、媒体。
|
||||
2. 能取得权限时优先组合 API/备份/网页数据,不能取得权限时抓取公开内容并标记未知项。
|
||||
3. 将旧站类型映射到统一类型,例如 WooCommerce product → Product / ACF car → Vehicle。
|
||||
4. 让用户选择新的视觉主题,映射旧数据字段到新页面组件。
|
||||
5. 保留旧 URL 注册表、历史 301、canonical 和 SEO 元数据;异常需明示审核。
|
||||
6. 禁止新主题自动重写旧路由。新增加的内容可按新建路由规则生成。
|
||||
7. 预览视觉不同的新站并以旧站数据基线自动验收。
|
||||
8. 发布时注意 DNS、CDN、缓存、预览 noindex、必要 API 以及回滚。
|
||||
9. 上线后追踪 404、搜索抓取和流量变化;上线前后还可进行增量同步直至冻结旧站写入。
|
||||
|
||||
### 8.4 关键事实
|
||||
|
||||
公开页面采集无法保证后台未展示字段完整;网站登录、支付、购物车及实时功能不能纯静态复制;要在扫描报告中明确“可自动”“需授权”“需开发扩展”“不迁移”四种处置结果。
|
||||
|
||||
---
|
||||
|
||||
## 9. 新站创建子系统(与迁移同等优先级)
|
||||
|
||||
### 9.1 模板新建流程
|
||||
|
||||
1. 输入网站名称、主语言、品牌信息、目标行业、域名(可延后绑定)。
|
||||
2. 选择模板并预览 PC/移动端。
|
||||
3. 从模板生成站点蓝图、页面树、导航和内容类型定义。
|
||||
4. 进入引导式公司资料表:名称、Logo、联系方式、社媒、地址、企业简介。
|
||||
5. 手工或批量添加产品、文章、品牌、图片;主题根据字段绑定渲染页面。
|
||||
6. 修改页面区块、主色、字体和导航,设置联系表单目标。
|
||||
7. 自动生成页面、站点地图与 SEO 默认值;检查内容完整性。
|
||||
8. 预览、发布、绑定域名;未来继续从后台更新。
|
||||
|
||||
### 9.2 空白新建流程
|
||||
|
||||
1. 创建站点与内容类型。
|
||||
2. 配置导航/页脚和布局框架。
|
||||
3. 通过区块编辑器建立页面和模板。
|
||||
4. 建立动态集合页及详情路由规则。
|
||||
5. 绑定内容和媒体,预览并发布。
|
||||
6. 经审核将该站的可复用结构导出为主题/站点模板。
|
||||
|
||||
### 9.3 导入内容建站流程
|
||||
|
||||
1. 上传 `vehicles.xlsx`,识别标题、品牌、图片、价格、参数等。
|
||||
2. 创建或选择 `Vehicle` 内容类型,建立字段映射、唯一键和数据校验。
|
||||
3. 选择适合的产品站主题;若字段不足,提示补齐,而不是凭空生成。
|
||||
4. 由车型集合创建列表、详情、品牌聚合页,生成对应 URL。
|
||||
5. 配置通用企业资料、SEO、导航,发布成完整新站。
|
||||
|
||||
### 9.4 主题替换机制
|
||||
|
||||
切换主题时预检查必需字段、页面类型、交互能力、导航结构及 URL 兼容性。允许在临时预览环境试装,保留旧主题和站点蓝图版本;确认后切换,不删除原内容和媒体。
|
||||
|
||||
---
|
||||
|
||||
## 10. URL 与 SEO:区分“迁移”和“新建”两种政策
|
||||
|
||||
### 10.1 迁移站(Preserve Mode)
|
||||
|
||||
- 以实际旧站最终有效访问行为为基线,保留用户可见有效 URL、状态码和所需重定向链。
|
||||
- Title、Description、Canonical、Robots、Hreflang、JSON-LD、Open Graph、图片 alt、内链和 sitemap 做差异比对。
|
||||
- 旧 URL 到新构建产物一对一登记,包含中文路径、大小写、尾部斜杠、`.html`、分页、文件、查询参数规则。
|
||||
- 404 处理必须区分原本无效和新迁移导致;如果页面已批准下线,应设置合适的 301/410 行为。
|
||||
- 允许设计变化,但不能因使用新模板悄悄改变原 SEO/URL。
|
||||
|
||||
### 10.2 新站(Generate Mode)
|
||||
|
||||
- 按站点定义 slug 策略和 URL 规则;允许人工指定覆盖。
|
||||
- 使用 SEO 默认模板,例如 `{product.name} | {site.name}`,并提示检查重复标题/空描述。
|
||||
- 自动生成 robots、sitemap、Open Graph、必要的 JSON-LD 和 canonical。
|
||||
- 多语言模式需完整管理 locale URL、翻译关联和 hreflang。
|
||||
- 预览域名必须鉴权并禁止搜索索引。
|
||||
|
||||
### 10.3 混合模式
|
||||
|
||||
来源于旧站的内容使用 Preserve URL,新增内容使用 Generate URL;注册表统一检查冲突。**不能简单设置一个整站 `preserve=true` 就忽略新内容路径生成。**
|
||||
|
||||
### 10.4 质量门禁
|
||||
|
||||
迁移站关键 URL 和 SEO 的未批准差异必须阻止发布;新站的门禁主要是 URL 冲突、重复规范地址、无效内链、关键必填字段缺失和资源失效。
|
||||
|
||||
---
|
||||
|
||||
## 11. 管理后台信息架构(建议菜单)
|
||||
|
||||
```
|
||||
SiteForge
|
||||
├── 工作台 Dashboard
|
||||
│ ├── 网站数量 / 最近发布 / 失败任务 / 风险
|
||||
│ └── 快速操作:新建网站 / 导入内容 / 创建模板
|
||||
├── 网站工厂
|
||||
│ ├── 所有网站
|
||||
│ ├── 创建网站向导(空白 / 模板 / 旧站 / 数据)
|
||||
│ ├── 网站蓝图 / 页面树 / 菜单 / 域名
|
||||
│ └── 预览 / 历史版本 / 克隆结构
|
||||
├── 内容中心
|
||||
│ ├── 内容类型设计器
|
||||
│ ├── 文章 / 产品 / 自定义集合
|
||||
│ ├── 公司资料 / 全局数据
|
||||
│ ├── 数据导入与同步
|
||||
│ └── 媒体资源库
|
||||
├── 设计中心
|
||||
│ ├── 模板库 / 主题库
|
||||
│ ├── 页面模板 / 区块库
|
||||
│ ├── 页面编辑器
|
||||
│ ├── 全站设计 Token
|
||||
│ └── 自定义主题注册与版本
|
||||
├── 迁移中心
|
||||
│ ├── 网站技术扫描
|
||||
│ ├── WordPress / CMS 导入
|
||||
│ ├── 字段映射 / 旧站快照
|
||||
│ ├── URL / SEO 基线
|
||||
│ └── 迁移差异报告
|
||||
├── 构建与发布
|
||||
│ ├── 构建任务 / 预览
|
||||
│ ├── 自动验收与报告
|
||||
│ ├── 发布审批 / 目标服务器
|
||||
│ ├── ZIP 导出 / 历史版本
|
||||
│ └── 回滚 / CDN 缓存刷新
|
||||
└── 系统设置
|
||||
├── 用户 / 团队 / 角色
|
||||
├── API 密钥 / 数据源凭证
|
||||
├── 适配器 / 扩展服务
|
||||
├── 审计日志 / 告警
|
||||
└── 存储 / 域名 / 环境
|
||||
```
|
||||
|
||||
### 11.1 新建网站首页建议
|
||||
|
||||
四个同级入口卡片:**旧站改造、使用模板新建、导入数据建站、空白建站**。不要把“迁移”置于唯一入口,也不要把“选择模板”藏在迁移流程中。
|
||||
|
||||
### 11.2 网站内部工作台
|
||||
|
||||
概览 → 内容 → 页面 → 设计 → SEO/路由 → 预览 → 发布 → 设置。便于独立运营每个网站,不要求管理员理解数据仓库和构建细节。
|
||||
|
||||
---
|
||||
|
||||
## 12. 推荐技术选型与研发边界
|
||||
|
||||
| 部分 | 推荐 | 备注 |
|
||||
|---|---|---|
|
||||
| 后台界面 | Vue 3 + TypeScript + Element Plus / Art Design Pro | 多站点、内容编辑、区块页面编辑器 |
|
||||
| 核心 API | Python FastAPI + Pydantic + SQLAlchemy | 复用 Python 采集、数据处理和内部工程经验 |
|
||||
| 异步任务 | Celery + Redis(或 Dramatiq) | 采集、媒体、导入、同步、质量任务 |
|
||||
| 数据库 | PostgreSQL | JSONB 扩展字段、关系表和审计日志 |
|
||||
| 采集服务 | Python httpx/Scrapy + Playwright | SSR/动态页面扫描与插件适配 |
|
||||
| 模板及静态生成 | Astro + TypeScript/Node Worker | 主题组件、静态页面编译、增量/全量构建 |
|
||||
| 资产存储 | S3 兼容对象存储/OSS + 本地缓存 | 原图、附件、站点版本和静态包 |
|
||||
| 部署 | Nginx / 对象存储 + CDN / SSH 或 Git 工作流 | 独立站交付和可回滚 |
|
||||
| 任务与日志 | PostgreSQL + Redis + 集中日志 | 可追踪的导入和发布作业 |
|
||||
| 运行 | Docker Compose 首选 | 后期再按规模拆分服务 |
|
||||
|
||||
**为什么不强制全部使用一种语言**:数据采集/规范化适合 Python;静态主题的现代前端生态适合 TypeScript/Astro。通过版本化 JSON 契约连接两个 Worker,避免牺牲网站的设计自由度。若团队对 NestJS 更熟,也可替换 API 层,不影响领域模型和构建协议。
|
||||
|
||||
### 12.1 部署架构
|
||||
|
||||
- API、PostgreSQL、Redis、导入 Worker、构建 Worker、管理前端、对象存储等分别通过容器管理。
|
||||
- 每个网站生成独立不可变 `release_id` 的产物并可单独导出、上传和回滚。
|
||||
- 为不同域名、地区和站点提供独立构建与部署配置。
|
||||
- 预览站点与生产站点隔离,预览授权、禁止索引。
|
||||
|
||||
---
|
||||
|
||||
## 13. 数据库详细方案
|
||||
|
||||
### 13.1 数据库分域
|
||||
|
||||
**组织与权限**:`organizations`、`users`、`memberships`、`roles`、`permissions`。
|
||||
|
||||
**网站与蓝图**:`sites`、`site_environments`、`site_blueprints`、`site_settings`、`page_nodes`、`navigation_menus`、`navigation_items`、`site_domains`。
|
||||
|
||||
**内容模型**:`content_types`、`field_definitions`、`content_entries`、`entry_versions`、`entry_relations`、`content_localizations`、`global_entries`。
|
||||
|
||||
**媒体**:`media_assets`、`media_variants`、`media_references`、`media_source_urls`。
|
||||
|
||||
**模板与组件**:`themes`、`theme_versions`、`page_templates`、`component_definitions`、`component_instances`、`design_tokens`、`site_theme_bindings`。
|
||||
|
||||
**数据输入与适配器**:`data_sources`、`adapter_definitions`、`import_jobs`、`import_items`、`field_mappings`、`sync_policies`、`source_snapshots`、`source_identity_map`。
|
||||
|
||||
**URL 与 SEO**:`routes`、`redirect_rules`、`seo_records`、`seo_snapshots`、`link_checks`、`route_diffs`。
|
||||
|
||||
**构建发布**:`build_jobs`、`build_artifacts`、`releases`、`deployments`、`quality_reports`、`audit_events`。
|
||||
|
||||
### 13.2 SQL 示意
|
||||
|
||||
```sql
|
||||
CREATE TABLE sites (
|
||||
id UUID PRIMARY KEY,
|
||||
organization_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
site_key TEXT NOT NULL,
|
||||
creation_mode TEXT NOT NULL CHECK (creation_mode IN
|
||||
('blank','template','migration','data_import','clone_structure')),
|
||||
default_locale TEXT NOT NULL DEFAULT 'en',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (organization_id, site_key)
|
||||
);
|
||||
|
||||
CREATE TABLE content_types (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES sites(id),
|
||||
type_key TEXT NOT NULL,
|
||||
schema_json JSONB NOT NULL,
|
||||
UNIQUE (site_id, type_key)
|
||||
);
|
||||
|
||||
CREATE TABLE content_entries (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES sites(id),
|
||||
content_type_id UUID NOT NULL REFERENCES content_types(id),
|
||||
source_identity TEXT,
|
||||
slug TEXT,
|
||||
locale TEXT NOT NULL DEFAULT 'en',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
data_json JSONB NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE site_blueprints (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES sites(id),
|
||||
revision INTEGER NOT NULL,
|
||||
blueprint_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (site_id, revision)
|
||||
);
|
||||
|
||||
CREATE TABLE routes (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES sites(id),
|
||||
locale TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
behavior TEXT NOT NULL, -- render | redirect | gone | proxy
|
||||
target_entry_id UUID NULL REFERENCES content_entries(id),
|
||||
target_url TEXT NULL,
|
||||
source_kind TEXT NOT NULL, -- imported | generated | manual
|
||||
UNIQUE (site_id, locale, path)
|
||||
);
|
||||
|
||||
CREATE TABLE build_jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES sites(id),
|
||||
blueprint_revision INTEGER NOT NULL,
|
||||
theme_version TEXT NOT NULL,
|
||||
input_manifest JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
数据库 DDL 只是结构示意;正式实现应补全租户隔离策略、外键/索引、关系类型、软删、版本表、迁移脚本和审计字段。`source_identity_map` 不应在跨数据源之间简单依赖 slug 去重。
|
||||
|
||||
### 13.3 数据治理约束
|
||||
|
||||
- 以 `organization_id + site_id` 强制作用域隔离,查询接口必须实现授权校验。
|
||||
- 导入前保存 raw source;标准化后保留原来源与变更来源。
|
||||
- 使用内容版本快照支持回滚和差异查看。
|
||||
- 记录手动字段覆盖,不允许重导入无声覆盖。
|
||||
- 涉及多语言、复杂关系、复用媒体时,不用一张任意 JSONB 表取代所有关系模型。
|
||||
|
||||
---
|
||||
|
||||
## 14. 核心 API 契约(示意)
|
||||
|
||||
```text
|
||||
POST /api/sites # 建立网站;body.creationMode
|
||||
GET /api/sites # 站点清单
|
||||
GET /api/sites/{siteId}
|
||||
POST /api/sites/{siteId}/clone-structure
|
||||
GET /api/themes # 主题库
|
||||
GET /api/themes/{themeId}/versions
|
||||
POST /api/sites/{siteId}/theme/preview # 试装主题/兼容性检查
|
||||
POST /api/sites/{siteId}/theme/activate
|
||||
GET /api/sites/{siteId}/blueprint
|
||||
PUT /api/sites/{siteId}/blueprint # 乐观锁 revision
|
||||
GET /api/sites/{siteId}/content-types
|
||||
POST /api/sites/{siteId}/content-types
|
||||
GET /api/sites/{siteId}/entries
|
||||
POST /api/sites/{siteId}/entries
|
||||
PATCH /api/sites/{siteId}/entries/{entryId}
|
||||
POST /api/sites/{siteId}/imports # 文件/API/CMS/旧站导入
|
||||
POST /api/imports/{jobId}/dry-run
|
||||
POST /api/imports/{jobId}/commit
|
||||
GET /api/imports/{jobId}/conflicts
|
||||
GET /api/sites/{siteId}/routes
|
||||
GET /api/sites/{siteId}/seo-audit
|
||||
POST /api/sites/{siteId}/builds
|
||||
GET /api/builds/{buildId}
|
||||
POST /api/sites/{siteId}/releases/{releaseId}/publish
|
||||
POST /api/sites/{siteId}/deployments/{deployId}/rollback
|
||||
GET /api/sites/{siteId}/quality-reports
|
||||
```
|
||||
|
||||
### 14.1 任务状态机
|
||||
|
||||
`created → queued → running → needs_review | failed | completed`;构建验收后再从 `completed` 经 `approved → published`,允许取消、重试、失败恢复,但每个状态转换须有审计记录。
|
||||
|
||||
### 14.2 构建契约
|
||||
|
||||
Node/Astro 构建 Worker 只接受经过签名/授权访问的不可变输入 manifest,输出静态包路径、构建报告、内容 hash、资源清单和验收结果;不要将数据库直接暴露给静态构建主题代码。
|
||||
|
||||
---
|
||||
|
||||
## 15. 质量验收体系(按建站模式执行)
|
||||
|
||||
### 15.1 通用验收
|
||||
|
||||
- 所有必需页面可访问、无冲突路由、没有意外 404/5xx。
|
||||
- 内容、分类、关联、图片和附件链接完整;过滤 XSS 等不可信脚本。
|
||||
- PC / 移动端核心页面无溢出、错位和严重布局问题。
|
||||
- sitemap、robots、canonical 与发布目标域名一致。
|
||||
- 站点预览不被搜索引擎收录,正式站按配置开放索引。
|
||||
- 表单、询盘、导航、搜索等启用功能经过实际测试。
|
||||
- 发布包可在隔离环境部署并独立访问;能回滚。
|
||||
|
||||
### 15.2 迁移专用验收
|
||||
|
||||
- 旧站已发现的有效 URL 都有一致访问或批准例外;关键路径的未批准变化阻断发布。
|
||||
- SEO 标题、描述、canonical、robots、hreflang 等做旧/新对照。
|
||||
- 产品/文章/页面数与旧站基线差异有解释;不可获取内容明确列出。
|
||||
- 资源与重定向规则逐项校验;上线后持续关注抓取和错误。
|
||||
|
||||
### 15.3 新站专用验收
|
||||
|
||||
- 必填企业信息、Logo、联系路径和站点语言完整。
|
||||
- 集合列表与详情页数量正确,新增内容可以重新生成网页。
|
||||
- SEO 自动生成规则没有导致大量重复标题或无意义内容。
|
||||
- 所选模板适用内容字段,缺失信息不能通过臆造补全。
|
||||
|
||||
### 15.4 验收报告分级
|
||||
|
||||
`BLOCKER`(必须解决)/ `WARNING`(可人工批准)/ `INFO`。不要把“页面加载成功”视作“内容迁移完整”。
|
||||
|
||||
---
|
||||
|
||||
## 16. 构建、部署、静态导出与持续更新
|
||||
|
||||
### 16.1 全量与局部构建
|
||||
|
||||
V1 可先全量构建保证正确性;V2 根据内容变化计算影响依赖:更新车型 → 对应详情、分类、品牌列表、首页精选和 sitemap 等重建。依赖图要可追踪,以免漏刷引用页。
|
||||
|
||||
### 16.2 交付格式
|
||||
|
||||
```
|
||||
release-2026-10-21-a1b2/
|
||||
├── public/
|
||||
│ ├── index.html
|
||||
│ ├── about-us/index.html
|
||||
│ ├── products/index.html
|
||||
│ ├── products/example/index.html
|
||||
│ ├── assets/...
|
||||
│ ├── sitemap.xml
|
||||
│ └── robots.txt
|
||||
├── server/
|
||||
│ ├── nginx.conf.example
|
||||
│ └── redirects.json
|
||||
├── manifest.json
|
||||
├── quality-report.json
|
||||
└── deployment-notes.md
|
||||
```
|
||||
|
||||
旧站存在 `.html`、尾斜杠与目录结构差异时,按照路由注册表选择适当静态目录或 Web 服务器规则;**输出文件路径本身不代表 HTTP 访问语义自动等同**,必须经过实际服务器验收。
|
||||
|
||||
### 16.3 部署策略
|
||||
|
||||
- 预览 / staging / production 三级环境,可选择简化为 preview + production。
|
||||
- 产物不可变、版本化;原子切换部署;媒体缓存和 HTML 缓存采用不同策略。
|
||||
- 支持 ZIP 下载、SSH/SFTP、Git CI、对象存储 + CDN;不同站点独立域名和缓存。
|
||||
- 发布操作与回滚操作留存操作人、时间、版本和差异。
|
||||
|
||||
### 16.4 外部服务扩展
|
||||
|
||||
表单、询盘、搜索、评论、登录等由独立功能适配器负责;主题仅通过公开的组件/客户端 SDK 调用,避免各站重复实现且不把平台后台变成生产站依赖。
|
||||
|
||||
---
|
||||
|
||||
## 17. 安全、权限、成本与可维护性
|
||||
|
||||
- **采集 SSRF**:阻断内部 IP、云元数据地址、敏感主机及重定向跳转;抓取速率限制、URL 白名单、robots 与站点使用权限审查。
|
||||
- **文件安全**:ZIP 解压路径穿越、恶意 HTML/JS/PHP、上传扩展名、MIME、体积限制与资源扫描。
|
||||
- **数据隔离**:租户、站点、内容、媒体、API 凭证严格隔离,导出权限与发布权限分离。
|
||||
- **主题安全**:主题依赖锁定、审核第三方组件、禁止把未审核用户表达式转换为运行代码。
|
||||
- **SEO 安全**:导入外站数据时不能盲目继承失效域名 canonical/跳转目标;必须判别并显示冲突。
|
||||
- **构建安全**:构建 Worker 沙箱、资源/CPU/时间限制、审计命令、脱离机密生产凭证。
|
||||
- **版本安全**:导入快照、内容版本、主题版本、发布版本皆可追溯与恢复。
|
||||
- **数据备份**:数据库备份、媒体/源快照、构建物及密钥轮换政策。
|
||||
|
||||
---
|
||||
|
||||
## 18. 开发路线图:从最小“网站工厂”开始
|
||||
|
||||
### 阶段 0 — 架构与样本验证(建议 2–3 周)
|
||||
|
||||
成果:Site Blueprint schema、内容类型/字段协议、主题 manifest、路由引擎规则、导入/构建 JSON 契约、站点项目 UI 原型;准备 3 个试点样本(WordPress 迁移、新站模板、新数据批量导入)。
|
||||
|
||||
### 阶段 1 — MVP 网站工厂(建议 6–10 周)
|
||||
|
||||
**必须同时具备“新建”和“迁移”两条路径。**
|
||||
|
||||
- 站点项目管理、主题目录(至少两套不同设计)、站点蓝图。
|
||||
- 内容模型(页面/文章/产品/分类、可扩展字段)、媒体上传与基础编辑。
|
||||
- 模板新建、CSV/Excel 导入、WordPress 核心内容导入。
|
||||
- 页面装配/静态构建、基本 URL/SEO 模式、预览与 ZIP 导出。
|
||||
- 自动验收、单站版本管理。
|
||||
|
||||
验收:同一系统能生产一个无旧站的新网站和一个 WP 迁移重建网站;两站视觉结构不同且可独立访问。
|
||||
|
||||
### 阶段 2 — 可规模化多站点运营(建议 6–10 周)
|
||||
|
||||
- 通用网站爬虫与插件适配器、字段映射工作台。
|
||||
- 主题兼容性检测、区块式编辑器、页面/菜单可视化设置。
|
||||
- 增量导入、媒体处理、版本化部署与一键回滚。
|
||||
- URL/SEO 完整旧新站差异报告、批量质量门禁。
|
||||
- 多站点、多权限、多环境和站点克隆结构。
|
||||
|
||||
验收:至少一个非 WordPress 站可迁移,模板与数据独立升级可运行。
|
||||
|
||||
### 阶段 3 — 完整模板工厂与自动化(按实际业务决定)
|
||||
|
||||
- 可视化页面模板编辑、组合式区块库。
|
||||
- 自定义主题 SDK 和内部主题市场、模板版本升级迁移。
|
||||
- API 数据同步、任务计划、依赖图增量构建。
|
||||
- AI 内容分类、字段匹配和页面布局建议(人工审核)。
|
||||
- 更多 CMS / ERP 适配器和复杂组件。
|
||||
|
||||
> 时间均为粗略研发规划区间,不是工期承诺;应基于团队人数、模板复杂度、实际旧站数据和试点验收调整。
|
||||
|
||||
---
|
||||
|
||||
## 19. 研发团队协作与目录建议
|
||||
|
||||
```text
|
||||
siteforge/
|
||||
├── apps/
|
||||
│ ├── admin-web/ # Vue 管理后台
|
||||
│ └── api/ # FastAPI 主服务
|
||||
├── workers/
|
||||
│ ├── imports/ # 导入与数据清洗
|
||||
│ ├── crawler/ # 旧站抓取和 CMS 适配
|
||||
│ ├── media/ # 媒体处理
|
||||
│ ├── builder/ # Astro 构建服务
|
||||
│ └── qa/ # URL/SEO/功能验收
|
||||
├── packages/
|
||||
│ ├── blueprint-schema/ # 站点蓝图协议
|
||||
│ ├── content-schema/ # 内容与字段协议
|
||||
│ ├── adapter-sdk/ # 数据源接入接口
|
||||
│ ├── theme-sdk/ # 主题数据绑定/组件接口
|
||||
│ ├── route-engine/ # 路由规则与映射
|
||||
│ └── build-contract/ # 输入/输出 manifest
|
||||
├── themes/
|
||||
│ ├── corporate-clean/
|
||||
│ ├── automotive-premium/
|
||||
│ └── industrial-b2b/
|
||||
├── migrations/ # 数据库演进
|
||||
├── docs/ # PRD、ADR、API、模板规范
|
||||
├── tests/ # 完整端到端样本
|
||||
└── infra/ # Docker、Nginx、CDN、CI
|
||||
```
|
||||
|
||||
### 19.1 设计/数据/研发协作界面
|
||||
|
||||
- 设计师交付主题原型与组件规范。
|
||||
- 研发把组件封装成可配置主题,声明需要哪些数据字段。
|
||||
- 内容人员录入内容和映射字段,不需修改代码。
|
||||
- 项目负责人检查预览并发布。
|
||||
- 可选开发人员继续以 SDK 开发特殊功能,保持独立品牌差异。
|
||||
|
||||
### 19.2 文档最小集合
|
||||
|
||||
PRD、站点蓝图 JSON Schema、Theme Manifest、组件数据绑定规范、适配器 SDK、数据模型 ERD、API OpenAPI、任务状态机、部署/回滚手册、验收测试用例。
|
||||
|
||||
---
|
||||
|
||||
## 20. 首批试点与详细验收用例
|
||||
|
||||
### 试点 A:旧 WordPress 汽车网站 → 新汽车主题
|
||||
|
||||
提供 WordPress XML + SQL/媒体(若可用)+ 网站 URL;输出文章/车型/品牌/分类和 SEO;选择全新汽车主题;旧站现存核心 URL 不改变;首页/品牌/车型详情样式与旧站不同;确认可离线发布。
|
||||
|
||||
### 试点 B:新建工业品网站(无任何旧站)
|
||||
|
||||
选择工业主题 → 添加公司 Logo/简介/联系方式 → 批量上传管材 Excel 和图片 → 自动生成产品分类与详情 → 配置联系页面 → 预览 → 以纯静态文件发布。
|
||||
|
||||
### 试点 C:已有网站 + 新数据混合
|
||||
|
||||
在 A 项目后续导入新一批产品 CSV:仅新增/更新相应产品,不覆盖旧文章与人工编辑;新增页面按新规则生成路由;原迁移 URL 不改变;系统更新受影响页面与 sitemap。
|
||||
|
||||
### 试点 D:空白页面 → 保存为模板 → 复用到第二个网站
|
||||
|
||||
从空白建立官网页面树和首页组件结构,绑定内容字段,保存为可复用模板;新项目使用该模板和另一套公司资料生成不同网站,不复制试点的私有内容。
|
||||
|
||||
**上述四个试点是网站工厂成功与否的核心验收,不允许用“只完成 WP 迁移”代替。**
|
||||
|
||||
---
|
||||
|
||||
## 21. 风险与关键决策
|
||||
|
||||
| 风险/问题 | 对策 |
|
||||
|---|---|
|
||||
| 模板不适配导入数据 | 主题声明字段和页面能力,预检查并引导补齐字段 |
|
||||
| “一键搬家”遗漏旧数据 | 多源交叉提取、数据覆盖率报告、人工审核和异常列表 |
|
||||
| 新主题导致旧 URL 改变 | URL 注册表独立于主题、迁移模式发布门禁 |
|
||||
| 模板升级覆盖独立设计 | 主题版本锁定、兼容检测、差异预览、独立站点覆盖层 |
|
||||
| 内容修改无法及时更新静态站 | 事件触发构建、预览与人工或自动发布策略 |
|
||||
| 大站生成耗时过长 | 静态构建分批、缓存、增量依赖、构建 Worker 并发 |
|
||||
| 复杂业务功能无法静态化 | 独立服务/API 适配器、功能清单和迁移边界确认 |
|
||||
| AI 生成错误产品参数或公司事实 | 不凭空补全、标记来源和置信度、人工审阅 |
|
||||
| 运营长期绑定平台无法退出 | 保证完整静态包、资源与服务器规则可导出 |
|
||||
|
||||
### 21.1 推荐冻结的架构决策
|
||||
|
||||
1. Website Factory 为总产品;Migration Center 仅是子模块。
|
||||
2. 模板和内容分离;Site Blueprint 负责装配。
|
||||
3. 四种同级建站入口;导入在网站创建后随时可用。
|
||||
4. “旧站 SEO 保真”与“新站 SEO 生成”是两种独立策略。
|
||||
5. 线上输出优先真正静态文件;额外动态能力作为可选扩展。
|
||||
6. 站点、内容、媒体、主题、蓝图、构建物独立版本化。
|
||||
7. 第一阶段不追求任意像素自由拖拽,保留独立主题开发能力。
|
||||
|
||||
---
|
||||
|
||||
## 22. 可立即拆解的第一轮开发任务
|
||||
|
||||
### Sprint A:基础项目与协议
|
||||
|
||||
- 建立仓库、开发环境、CI、PostgreSQL、Redis、身份与网站项目模型。
|
||||
- 完成 Site Blueprint / Theme Manifest / Content Type Schema 草案及验证器。
|
||||
- 创建模板库 UI、四入口建站首页和站点工作台初版。
|
||||
|
||||
### Sprint B:内容 + 模板 + 构建闭环
|
||||
|
||||
- 手动创建产品、文章、基本页面和媒体。
|
||||
- 实现两种设计风格不同的主题,完成数据绑定和列表/详情页生成。
|
||||
- 输入规范 manifest,Astro 全量构建、预览站点和 ZIP 导出。
|
||||
|
||||
### Sprint C:数据导入 + WordPress 迁移
|
||||
|
||||
- CSV/Excel 字段映射、dry-run、幂等更新、异常行报告。
|
||||
- WordPress WXR/API 导入和图片资源迁移。
|
||||
- 旧站 URL/SEO 基线及 Preserve Mode。
|
||||
|
||||
### Sprint D:上线级质量闭环
|
||||
|
||||
- URL/SEO/媒体/内链验收、构建版本、发布审批和回滚。
|
||||
- 四项端到端试点验收;填写已知限制和改进清单。
|
||||
|
||||
---
|
||||
|
||||
## 23. 最终交付物清单
|
||||
|
||||
- SiteForge 管理后台(含四种建站入口)。
|
||||
- 通用内容中心、动态内容类型、媒体库、导入工作台。
|
||||
- 模板/主题/组件中心、站点蓝图、区块式页面配置。
|
||||
- WordPress 适配器、通用爬虫、Excel/CSV/JSON 导入能力。
|
||||
- 静态装配与构建引擎,包含 ZIP 导出与独立服务器部署。
|
||||
- 旧站路由与 SEO 比对、新站 SEO 自动生成、自动验收和质量报告。
|
||||
- 多网站环境/发布/回滚/权限/审计能力。
|
||||
- 至少三套不同业务风格的参考主题与必要主题 SDK 文档。
|
||||
- 四个试点通过报告、自动化测试、开发部署手册和数据恢复演练。
|
||||
|
||||
---
|
||||
|
||||
## 24. 一句话总结
|
||||
|
||||
**SiteForge 是“有内容就能造网站、没有内容也能从零造网站、有旧网站就能搬内容换设计、造完还能持续管理和再次生成”的通用网站工厂。**
|
||||
|
||||
旧站迁移、内容管理、主题市场、页面设计、SEO、静态构建与发布,都是围绕“生产网站”这一核心能力服务的工序,而不是独立产品的拼接。
|
||||
@@ -0,0 +1,133 @@
|
||||
# SiteForge 网站工厂补全 — 主计划(Master Plan)
|
||||
|
||||
> **For agentic workers:** 本文档是路线图级主计划。每个 Phase 会各自展开为独立的任务级实施计划(TDD、bite-sized steps),按 superpowers:writing-plans 流程单独编写和评审后再执行。
|
||||
|
||||
**Goal:** 把现有 SiteForge V5(四种建站模式骨架已完成 ~70%)补全为需求定义的"智能网站生产工厂":四种模式名实相符、可组合、可随时修改、可一键交付。
|
||||
|
||||
**Architecture:** 全部在现有 FastAPI + SQLite + 声明式主题/区块架构上增量演进,不推倒重来。每个 Phase 独立交付可测试的软件,完成后系统始终可用。
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / SQLite(Phase 5 前不换)/ Jinja2 / 原生 JS / Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 前提与决策点(实施前需用户确认)
|
||||
|
||||
| # | 决策 | 推荐 | 影响 |
|
||||
|---|---|---|---|
|
||||
| D1 | 多语言方案 | **站点级复制 + hreflang 互链**(一个语言一个 site,跨站关联),不改 entries 表结构 | 单站多语言混存需重构 route 唯一约束和全部主题模板,代价高且当前无此需求证据 |
|
||||
| D2 | 数据库连接器首发 | **MySQL 只读**(WordPress 老站生态主流) | 决定 Phase 2 连接器实现顺序 |
|
||||
| D3 | PostgreSQL 迁移时机 | **推迟到 Phase 5**(试点验证后再做) | 提前做会拖慢所有功能迭代 |
|
||||
| D4 | Phase 顺序 | 1→2→3→4→5 | Phase 1 最快见效(纯编排已有 API) |
|
||||
|
||||
## 工程前提(阻塞项,最先做)
|
||||
|
||||
- [ ] **P0-1: `git init`** —— 当前项目不是 git 仓库,任何实施前必须先有版本控制(.gitignore 已存在,需补 `storage/`、`.env`、`*.png` 截图)
|
||||
- [ ] **P0-2: 修复实测发现的 3 个缺陷**:
|
||||
- compose.yaml 双服务同标签构建冲突:`docker compose up -d --build` 失败(已验证规避法:先 `docker compose build siteforge`)。修法:worker 服务加 `build: false` 或 image 复用策略
|
||||
- worker 容器 unhealthy:worker 服务加 `healthcheck: disable: true`(继承镜像 HEALTHCHECK 探测 8787 所致)
|
||||
- favicon.ico 404:补一个内置 favicon 路由
|
||||
- [ ] **P0-3: CI 自检脚本**(`scripts/ci.sh`:pytest + compileall + node --check,与 README 自检命令一致)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 模式 A 补全:迁移向导("输入旧站地址"一站式)
|
||||
|
||||
**解决缺口:** 模式 A 现在要手动 5 步;需求是输入地址后系统自动编排。无表结构变更,纯编排层,最快见效。
|
||||
|
||||
**Files:**
|
||||
- Create: `app/migration_wizard.py`(向导状态机 + 编排逻辑)
|
||||
- Create: `app/static/wizard.js`(前端步骤面板,挂在 factory-v2)
|
||||
- Modify: `app/factory_api.py`(挂载向导路由)
|
||||
- Modify: `app/db.py`(wizard_state 表)
|
||||
- Test: `tests/test_migration_wizard.py`
|
||||
|
||||
**功能拆解:**
|
||||
1. `POST /api/factory/sites/{site}/wizard/start` `{url}` → 调 `adapters.analyze()` 识别平台 → 返回推荐路径(WP REST / WXR 上传 / 爬虫 / 基线上传)
|
||||
2. 向导状态机(pending → analyzed → importing → baseline → media → done),每步可重试、可跳过,状态落库
|
||||
3. WP REST 全量分页导入(现有 1000 条上限 → 10000,走 worker 队列异步 + 进度查询)
|
||||
4. 媒体自动发现:导入完成后自动生成 assets inventory(现有 API),向导里展示"待本地化图片清单"并提供构建时镜像开关
|
||||
5. sitemap.xml 自动拉取生成基线(当前基线只能手动上传文件;加一个"从 URL 拉取 sitemap"入口,受 ensure_public_url 约束)
|
||||
6. 爬虫容量 60 → 200 页,带 robots.txt 检查
|
||||
|
||||
**验收标准:** 输入一个真实 WordPress 站地址,向导引导完成 内容导入→基线→媒体清单→构建→审计报告 全流程,中途退出可恢复状态。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — 模式 C 补全:数据源连接器 + 自动匹配
|
||||
|
||||
**解决缺口:** XML 不支持、数据库/API 连接完全没有、"自动识别类型/自动匹配模板"没有。
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/table_loader.py`(+XML 行解析)
|
||||
- Create: `app/connectors.py`(MySQL/PG 只读连接器框架 + 凭据加密存储)
|
||||
- Create: `app/content_sense.py`(列名/值特征打分 → 内容类型 + 行业主题推荐)
|
||||
- Modify: `app/importers.py`、`app/factory_api.py`(连接器端点、批量导入走 worker)
|
||||
- Modify: `app/worker.py`(新 job kind: `import`)
|
||||
- Test: `tests/test_connectors.py`、`tests/test_content_sense.py`
|
||||
|
||||
**功能拆解:**
|
||||
1. 通用 XML 数据导入(任意扁平/两层 XML → 行记录,走现有 mapping 管道)
|
||||
2. MySQL 只读连接器:连接配置(主机/库/账号)用 Fernet 加密存库;只允许 `SELECT`,白名单表,单表 ≤10000 行,超时与行数硬上限;预览表结构 → 选表选列 → 进 mapping 管道(复用 dry-run/commit)
|
||||
3. PostgreSQL 连接器(同框架第二实现)
|
||||
4. `content_sense`:对解析后的 columns+样本行打分(品牌/价格/续航→vehicle;作者/日期→post;案例地点→case),输出推荐 content_type 与推荐主题(automotive/industrial/corporate/minimal 关键词匹配),前端"智能推荐"面板一键采纳
|
||||
5. 大批量:mapping commit 改为分批(500/批)入 worker 队列,前端进度条;上限 1000 → 50000
|
||||
|
||||
**验收标准:** 上传一份 5000 行车型 Excel → 系统推荐 vehicle 类型 + automotive 主题 → 异步导入完成 → 构建出含分类/品牌聚合页的完整站点。连一个测试 MySQL 库选表导入同样走通。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — 模式 D 补全:编辑器区块扩充 + 整站模板
|
||||
|
||||
**解决缺口:** 区块仅 12 种;"保存为新模板"只有首页级。
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/blueprints.py`(新区块类型:carousel / video(白名单 embed) / tabs / pricing / map-static / form-link)
|
||||
- Modify: `app/static/editor.js` + `app/static/blocks.css`(新区块编辑与渲染)
|
||||
- Create: `app/site_templates.py`(整站模板存取:多页面 blocks + 导航 + 设置 + 主题 + 设计令牌)
|
||||
- Modify: `app/db.py`(site_templates 表)、`app/factory_api.py`(导出/应用端点)
|
||||
- Test: `tests/test_site_templates.py`、tests/test_factory.py 扩展
|
||||
|
||||
**功能拆解:**
|
||||
1. 6 个新区块(全部声明式、白名单校验,video 仅允许 https YouTube/Vimeo embed URL,map 用静态图片不引第三方 JS——延续"不执行用户代码"红线)
|
||||
2. 整站模板:`POST /api/factory/sites/{site}/export-template` 把整站设计打包 JSON;建站时可选整站模板初始化(区别于现有首页级 preset)
|
||||
3. 模板兼容性检查(复用 theme compatibility 思路:区块类型是否被当前版本支持)
|
||||
4. (可选,需确认)草稿模式:entry status=draft 不进构建 —— 改动面大,默认不做
|
||||
|
||||
**验收标准:** 从零设计一个完整站点(多页+导航+设计令牌)→ 一键存为整站模板 → 新建站点套用该模板 → 构建结果与原站视觉一致。新 taxonomy 区块在编辑器和构建产物中渲染正确。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — 横向刚需:多语言 + 发布连接器
|
||||
|
||||
**解决缺口:** 出口外贸场景的多语言(D1 方案);重新发布的"一键上线"。
|
||||
|
||||
**Files:**
|
||||
- Create: `app/multilang.py`(站点语言属性 + hreflang 互链 + 一键克隆翻译站)
|
||||
- Create: `app/publish_targets.py`(SFTP 与 S3 兼容目标:凭据加密、连通性测试、发布/回滚记录)
|
||||
- Modify: `app/db.py`(sites.locale、site_relations、publish_targets、publish_history 表)
|
||||
- Modify: `app/builder.py`(sitemap 输出 hreflang alternates)
|
||||
- Modify: `app/static/factory-v2.js`(语言管理面板、发布目标面板)
|
||||
- Test: `tests/test_multilang.py`、`tests/test_publish_targets.py`
|
||||
|
||||
**功能拆解:**
|
||||
1. 站点 locale 字段 + 语言变体关系组;克隆为翻译站(复制结构、内容置为待翻译占位,不机器翻译、不伪造)
|
||||
2. 构建时各语言站 sitemap 互相输出 hreflang;语言切换器区块(白名单)
|
||||
3. SFTP 发布目标:host/账号/路径,密码加密存储;"发布到此目标"= 上传当前 release 目录(原子:先传 `.new` 再 rename),发布历史落库,失败回滚提示
|
||||
4. S3 兼容目标(endpoint/key/secret/bucket/prefix),同样走审批门禁后才可发布
|
||||
5. 发布动作复用现有 release_requests 审批流(未审批的构建禁止推送到外部目标)
|
||||
|
||||
**验收标准:** 一个中文站克隆出英文变体站,两站 sitemap 含正确 hreflang;任一构建版本经审批后一键 SFTP 推送到测试服务器并可访问。
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — 生产化加固(引用 docs/PRODUCTION_ROADMAP.md,单独立项)
|
||||
|
||||
PostgreSQL/Redis、SSO/MFA、审计外置、E2E 视觉回归、供应链与渗透测试、监控 SLO。**不在本主计划展开**,功能补全(Phase 1-4)验证业务闭环后再启动,避免过早优化。
|
||||
|
||||
---
|
||||
|
||||
## 执行方式
|
||||
|
||||
- 每个 Phase 开始前:按 superpowers:writing-plans 写任务级详细计划(含 TDD 步骤与完整代码)→ 评审 → 按 subagent-driven 或 inline 方式执行
|
||||
- 每个 Phase 结束:`scripts/ci.sh` 全绿 + Docker 实测启动 + 浏览器验收(复用本次会话已验证的冒烟流程)
|
||||
- 顺序建议:P0(0.5 天)→ Phase 1(最快见效)→ Phase 2 → Phase 3 → Phase 4;Phase 2/3 可并行(不同子系统)
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"type": "page",
|
||||
"title": "About Us",
|
||||
"route": "/about-us/",
|
||||
"summary": "Learn more about our global business.",
|
||||
"body": "<h2>Who we are</h2><p>Replace this example content with your real company introduction.</p>",
|
||||
"seo_title": "About Us",
|
||||
"seo_description": "Our company introduction."
|
||||
},
|
||||
{
|
||||
"type": "service",
|
||||
"title": "Global Shipping",
|
||||
"route": "/services/global-shipping/",
|
||||
"summary": "Integrated shipping and delivery coordination.",
|
||||
"body": "<p>Describe your real shipping services here.</p>"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
type,title,route,summary,seo_title,seo_description,cover
|
||||
product,Electric SUV Model A,/vehicles/model-a/,Modern electric SUV,Model A | Electric SUV,Discover Model A,
|
||||
product,Pickup Truck Model B,/vehicles/model-b/,Practical pickup for global markets,Model B | Pickup,Explore Model B,
|
||||
product,Premium SUV Model C,/vehicles/model-c/,Comfortable and versatile premium SUV,Model C | SUV,Explore Model C,
|
||||
|
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
# Art Design Pro 官方工程对接(可执行安装器)
|
||||
|
||||
SiteForge V2 随包提供三个 Vue 3 + Element Plus 业务页面:`WebsiteFactory.vue`、`ThemeCenter.vue`、`EnterpriseCenter.vue`。
|
||||
|
||||
> **重要:** 由于本次代码执行环境无法访问 GitHub 与 npm,无法直接打包或编译 Art Design Pro 官方完整仓库。以下对接脚本已实现源码复制和路由注册逻辑,但尚未针对官方实际项目执行 `pnpm build`。默认可运行的 `/factory-studio` Web 管理台不依赖这些文件。
|
||||
|
||||
## 在联网设备对接
|
||||
|
||||
1. 依官方文档克隆 `https://github.com/Daymychen/art-design-pro`,使用符合要求的 Node 版本和 pnpm。
|
||||
2. 在 SiteForge 根目录执行:
|
||||
|
||||
```bash
|
||||
python scripts/install_into_art_design_pro.py /path/to/art-design-pro
|
||||
python scripts/install_into_art_design_pro.py /path/to/art-design-pro --apply
|
||||
```
|
||||
|
||||
3. 使用同域反向代理:
|
||||
|
||||
- Art 前端 `/` 为网站工厂管理 UI
|
||||
- `/api` → SiteForge FastAPI
|
||||
- `/editor`、`/preview`、`/ui`、`/local-media`、`/login`、`/factory-studio` → SiteForge FastAPI
|
||||
|
||||
4. 在 Art 工程目录执行 `pnpm install && pnpm build`,访问 `/siteforge/factory`、`/siteforge/themes`、`/siteforge/enterprise`。
|
||||
5. SiteForge API 仍使用自己的服务端 Session、CSRF、站点级 RBAC;前端的隐藏菜单不构成授权。`/api/auth/me` 必须在同一 Cookie 域名可访问。正式发布需统一登录体验,并补齐 Art 当前版本的路由/权限约定。
|
||||
|
||||
安装脚本不会覆盖已有配置而不备份:原 `asyncRoutes.ts` 会先保存 `.ts.siteforge-backup`,同名 Vue 文件另存 `.vue.siteforge-backup`。重复执行不会重复插入路由。
|
||||
|
||||
官方路由结构和本项目的适配规则以 [Art Design Pro 官方路由文档](https://www.artd.pro/docs/zh/guide/essentials/route.html) 为准。
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="sf-enterprise">
|
||||
<div class="intro"><div><small>SITEFORGE / ENTERPRISE CONTROL</small><h1>网站交付与质量控制</h1><p>角色权限 · 原网址与 SEO 基线 · 异步构建 · 版本质量</p></div><el-button type="primary" @click="reload">刷新数据</el-button></div>
|
||||
<el-card shadow="never" class="panel"><el-form inline><el-form-item label="选择站点"><el-select v-model="siteId" filterable placeholder="选择要管理的网站" style="width:330px" @change="reloadSite"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" /></el-select></el-form-item></el-form></el-card>
|
||||
<el-tabs v-if="siteId" v-model="tab" type="border-card" class="main-tabs">
|
||||
<el-tab-pane label="迁移验收" name="qa"><el-alert type="info" show-icon :closable="false" title="首先导入原始 URL 基线,然后才能确认改版后没有漏页。"/><el-row :gutter="18" class="summary"><el-col v-for="[title,value] in summaries" :key="title" :span="6"><el-statistic :title="title" :value="value" /></el-col></el-row><el-space wrap><el-button @click="snapshot">从当前内容建立基线</el-button><el-button @click="loadQA">重新检查</el-button><el-button @click="downloadBaseline">导出 CSV</el-button></el-space><el-table :data="qa?.issues || []" stripe style="margin-top:20px"><el-table-column prop="route" label="原路径" min-width="250"/><el-table-column prop="field" label="对比字段" width="150"/><el-table-column prop="severity" label="严重程度" width="120"/><el-table-column prop="to" label="当前内容" min-width="260" show-overflow-tooltip/></el-table></el-tab-pane>
|
||||
<el-tab-pane label="任务队列" name="jobs"><el-button type="primary" @click="enqueue">提交后台构建</el-button><el-button @click="loadJobs">刷新任务</el-button><el-table :data="jobs" stripe style="margin-top:20px"><el-table-column prop="id" label="任务 ID" min-width="180"/><el-table-column prop="kind" label="类型"/><el-table-column prop="state" label="状态"/><el-table-column prop="attempts" label="尝试次数"/><el-table-column prop="created_at" label="提交时间" min-width="200"/></el-table></el-tab-pane>
|
||||
<el-tab-pane label="站点成员" name="members"><el-alert type="info" :closable="false" title="Owner 管理成员和发布,Editor 编辑网站,Reviewer/Viewer 只读。"/><el-table :data="members" style="margin-top:20px"><el-table-column prop="username" label="用户"/><el-table-column prop="display_name" label="显示名称"/><el-table-column prop="role" label="权限角色"/></el-table></el-tab-pane>
|
||||
<el-tab-pane label="审计记录" name="audit"><el-table :data="logs" stripe><el-table-column prop="actor" label="操作者"/><el-table-column prop="operation" label="操作"/><el-table-column prop="path" label="API 路径" min-width="350" show-overflow-tooltip/><el-table-column prop="status" label="响应码"/><el-table-column prop="created_at" label="操作时间" min-width="195"/></el-table></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
interface Site {id:string;name:string}
|
||||
interface QA {baseline_count:number;current_count:number;errors:number;warnings:number;issues:Array<{route:string;field:string;severity:string;to:string}>}
|
||||
const sites=ref<Site[]>([]),siteId=ref(''),csrf=ref(''),tab=ref('qa'),qa=ref<QA|null>(null),jobs=ref<any[]>([]),members=ref<any[]>([]),logs=ref<any[]>([])
|
||||
const summaries=computed<Array<[string,number]>>(()=>[['旧站 URL',qa.value?.baseline_count||0],['新站 URL',qa.value?.current_count||0],['URL 缺失',qa.value?.errors||0],['SEO 差异',qa.value?.warnings||0]])
|
||||
async function request<T>(url:string,method='GET',body?:unknown):Promise<T>{const r=await fetch(url,{method,credentials:'same-origin',headers:{'Content-Type':'application/json',...(method!=='GET'?{'X-SiteForge-CSRF':csrf.value}:{})},body:body===undefined?undefined:JSON.stringify(body)});const json=await r.json();if(!r.ok)throw new Error(json.detail||'请求失败');return json as T}
|
||||
async function safe(action:()=>Promise<void>){try{await action()}catch(error:any){ElMessage.error(error.message||'操作失败')}}
|
||||
async function reload(){await safe(async()=>{sites.value=await request<Site[]>('/api/sites');if(!siteId.value&&sites.value.length)siteId.value=sites.value[0].id;await reloadSite()})}
|
||||
async function loadQA(){qa.value=await request<QA>(`/api/enterprise/sites/${siteId.value}/baseline/report`)}
|
||||
async function loadJobs(){jobs.value=await request<any[]>(`/api/enterprise/sites/${siteId.value}/jobs`)}
|
||||
async function reloadSite(){if(!siteId.value)return;await safe(async()=>{await Promise.all([loadQA(),loadJobs(),(async()=>{try{members.value=await request<any[]>(`/api/enterprise/sites/${siteId.value}/members`)}catch{members.value=[]}})(),(async()=>{try{logs.value=await request<any[]>(`/api/enterprise/audit?site_id=${siteId.value}`)}catch{logs.value=[]}})()])})}
|
||||
async function snapshot(){await safe(async()=>{await ElMessageBox.confirm('当前站点内容会覆盖之前的旧 URL 基线。推荐在迁移导入后、内容改版前操作。','确认基线');await request(`/api/enterprise/sites/${siteId.value}/baseline/snapshot`,'POST',{});ElMessage.success('URL 基线已保存');await loadQA()})}
|
||||
async function enqueue(){await safe(async()=>{await request(`/api/enterprise/sites/${siteId.value}/jobs`,'POST',{kind:'build'});ElMessage.success('任务已进入后台队列');await loadJobs()})}
|
||||
function downloadBaseline(){window.open(`/api/enterprise/sites/${siteId.value}/baseline/export.csv`,'_blank','noopener')}
|
||||
onMounted(()=>safe(async()=>{csrf.value=(await request<{csrf:string}>('/api/auth/me')).csrf;await reload()}))
|
||||
</script>
|
||||
<style scoped>
|
||||
.sf-enterprise{padding:8px 5px 30px}.intro{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.intro small{color:#6386b0;letter-spacing:2px;font-weight:800}.intro h1{font-size:28px;margin:7px 0}.intro p{color:#8994a4;margin:0}.panel{margin-bottom:18px}.main-tabs{border-radius:12px}.summary{margin:20px 0 24px}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<el-row justify="space-between" align="middle" style="margin-bottom:22px">
|
||||
<div><h1>网站主题中心</h1><p>版本固定、主题安装与内容重新装配</p></div>
|
||||
<el-upload :auto-upload="false" :limit="1" :show-file-list="false" accept=".zip" :on-change="install"><el-button type="primary">安装主题 ZIP</el-button></el-upload>
|
||||
</el-row>
|
||||
<el-card shadow="never" style="margin-bottom:20px"><el-form inline><el-form-item label="当前网站"><el-select v-model="siteId" filterable style="width:310px" @change="loadPinned"><el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id"/></el-select></el-form-item></el-form></el-card>
|
||||
<el-row :gutter="18"><el-col v-for="theme in themes" :key="theme.id||theme.theme_id" :xs="24" :sm="12" :lg="8" style="margin-bottom:20px"><el-card shadow="never"><div class="theme-cover" :class="theme.id||theme.theme_id"><span>WEBSITE<br>DESIGN<br>SYSTEM</span></div><h3>{{theme.name}}</h3><p class="secondary">{{theme.description}}</p><el-space><el-tag>v{{theme.version}}</el-tag><el-tag v-if="pinned.theme===(theme.id||theme.theme_id)" type="success">当前使用</el-tag></el-space><div class="buttons"><el-button type="primary" @click="apply(theme)">应用版本</el-button><el-button @click="versions(theme)">版本列表</el-button></div></el-card></el-col></el-row>
|
||||
<el-card shadow="never"><h3>主题自动装配</h3><p class="secondary">自动为旧内容应用当前主题预设,保留历史 URL、SEO、正文内容。</p><el-button @click="assemble('missing')">仅补齐缺失页面</el-button><el-button type="warning" plain @click="assemble('overwrite')">覆盖全部页面布局</el-button></el-card>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref,onMounted} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import type {UploadFile} from 'element-plus'
|
||||
interface Site{id:string;name:string}
|
||||
interface Theme{id?:string;theme_id?:string;name:string;description:string;version:string}
|
||||
const sites=ref<Site[]>([]),themes=ref<Theme[]>([]),siteId=ref(''),pinned=ref({theme:'',version:''}),csrf=ref('')
|
||||
async function request(url:string,method='GET',payload?:unknown,isFile=false){
|
||||
const headers=new Headers();if(method!=='GET')headers.set('X-SiteForge-CSRF',csrf.value);if(payload&&!isFile)headers.set('Content-Type','application/json')
|
||||
const r=await fetch(url,{method,headers,credentials:'same-origin',body:payload?(isFile?payload as FormData:JSON.stringify(payload)):undefined})
|
||||
const v=await r.json();if(!r.ok)throw new Error(v.detail||'请求失败');return v
|
||||
}
|
||||
async function load(){try{sites.value=await request('/api/sites');themes.value=await request('/api/factory/themes');if(!siteId.value)siteId.value=sites.value[0]?.id||'';await loadPinned()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function loadPinned(){if(siteId.value)pinned.value=await request(`/api/factory/sites/${siteId.value}/theme`)}
|
||||
async function apply(theme:Theme){if(!siteId.value)return;try{const id=theme.id||theme.theme_id;await request(`/api/factory/sites/${siteId.value}/theme`,'PUT',{theme:id,version:theme.version});ElMessage.success('已绑定主题版本');await loadPinned()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function versions(theme:Theme){try{let list=await request(`/api/factory/themes/${theme.id||theme.theme_id}/versions`);await ElMessageBox.alert(list.map((x:any)=>`${x.version} (${x.installed_at||'内置'})`).join('\n'),'主题版本',{confirmButtonText:'关闭'})}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function install(file:UploadFile){try{const form=new FormData();if(!file.raw)throw Error('未选择 ZIP');form.append('file',file.raw);let r=await request('/api/factory/themes/install','POST',form,true);ElMessage.success(`已安装 ${r.name} v${r.version}`);await load()}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function assemble(mode:string){if(!siteId.value)return;try{if(mode==='overwrite')await ElMessageBox.confirm('将覆盖所有已有页面蓝图,确定继续?','风险确认');let r=await request(`/api/sites/${siteId.value}/apply-theme`,'POST',{mode});ElMessage.success(`已装配 ${r.updated} 个页面`)}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
onMounted(async()=>{try{csrf.value=(await request('/api/auth/me')).csrf;await load()}catch(e:any){ElMessage.error(e.message)}})
|
||||
</script>
|
||||
<style scoped>
|
||||
h1{margin:0 0 6px;font-size:26px}p.secondary,.page-content>p{font-size:13px;color:var(--el-text-color-secondary);line-height:1.7}.theme-cover{height:174px;border-radius:7px;background:#e6eafa;padding:25px;margin-bottom:16px;color:#3c4591}.theme-cover span{font-size:25px;line-height:1;letter-spacing:-1px;font-weight:900}.theme-cover.automotive{background:#172940;color:#e4c69a}.theme-cover.industrial{background:#e3edf6;color:#1c6292}.theme-cover.minimal{background:#eeeae3;color:#3b3b36}.buttons{margin-top:20px}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="sf-art-page">
|
||||
<div class="sf-art-intro">
|
||||
<div><div class="sf-overline">SITEFORGE / WEBSITE FACTORY</div><h1>网站生产工厂</h1><p>多站点管理 · 旧站迁移 · 主题装配 · 可视化页面设计 · 静态发布</p></div>
|
||||
<el-button type="primary" size="large" @click="createVisible=true"><el-icon><Plus /></el-icon> 创建新网站</el-button>
|
||||
</div>
|
||||
<el-row :gutter="18" class="metric-line">
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">网站数量</div><strong>{{ sites.length }}</strong></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">站点内容</div><strong>{{ sites.reduce((sum, s)=>sum+s.entries_count,0) }}</strong></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">构建版本</div><strong>{{ sites.reduce((sum, s)=>sum+s.build_count,0) }}</strong></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never"><div class="metric-label">行业主题</div><strong>{{ themes.length }}</strong></el-card></el-col>
|
||||
</el-row>
|
||||
<el-card shadow="never" class="listing-card">
|
||||
<template #header><div class="table-head"><div><h3>我的网站</h3><p>可选择站点继续编辑、批量套版或进行静态构建</p></div><el-input v-model="filter" clearable placeholder="搜索网站名称" style="width:240px" /></div></template>
|
||||
<el-table :data="visibleSites" v-loading="loading" size="large" stripe>
|
||||
<el-table-column label="网站项目" min-width="220"><template #default="{ row }"><div class="site-name"><span class="site-mark">{{ row.name[0] }}</span><div><b>{{ row.name }}</b><small>{{ row.domain || '尚未绑定域名' }}</small></div></div></template></el-table-column>
|
||||
<el-table-column label="主题" width="135"><template #default="{ row }"><el-tag effect="plain">{{ themeTitle(row.theme) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="entries_count" label="内容" width="85" />
|
||||
<el-table-column prop="build_count" label="版本" width="85" />
|
||||
<el-table-column prop="updated_at" label="更新日期" min-width="180" />
|
||||
<el-table-column label="操作" width="350" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="editSite(row)">管理站点</el-button><el-button link @click="openStudio(row)">设计首页</el-button><el-button link @click="applyPreset(row)">内容套版</el-button><el-button link type="success" @click="build(row)">构建</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-dialog v-model="createVisible" title="创建网站项目" width="540px" destroy-on-close>
|
||||
<el-form :model="draft" label-position="top">
|
||||
<el-form-item label="网站名称"><el-input v-model="draft.name" placeholder="例如:HESU Global Auto" /></el-form-item>
|
||||
<el-form-item label="网站地址"><el-input v-model="draft.domain" placeholder="https://example.com" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="draft.tagline" placeholder="品牌服务介绍" /></el-form-item>
|
||||
<el-form-item label="行业主题"><el-select v-model="draft.theme" style="width:100%"><el-option v-for="t in themes" :key="t.id" :label="t.name" :value="t.id" /></el-select></el-form-item>
|
||||
<el-form-item label="建站模式"><el-radio-group v-model="draft.mode"><el-radio value="new">新建网站</el-radio><el-radio value="migration">旧站迁移</el-radio><el-radio value="hybrid">混合建站</el-radio></el-radio-group></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="createVisible=false">取消</el-button><el-button type="primary" :loading="loading" @click="create">创建网站</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="siteVisible" :title="active?.name || '网站管理'" width="680px">
|
||||
<el-tabs v-model="siteTab"><el-tab-pane label="基本信息" name="info"><p>主题:{{ themeTitle(active?.theme) }} · {{ active?.domain }}</p><p>{{ active?.tagline }}</p></el-tab-pane><el-tab-pane label="内容管理" name="content"><el-button @click="nativeConsole(active)">打开内容编辑工作台</el-button><p class="hint">支持页面、产品、文章、SEO及 WordPress/CSV/XLSX 数据导入。</p></el-tab-pane><el-tab-pane label="主题与编辑" name="design"><el-button type="primary" @click="openStudio(active)">进入 SiteForge Visual Studio</el-button></el-tab-pane><el-tab-pane label="静态发布" name="build"><el-button type="success" @click="build(active)">生成静态 HTML</el-button><p class="hint">构建后可以在 SiteForge Web 控制台预览、下载 ZIP 或发布到持久化目录。</p></el-tab-pane></el-tabs>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import {Plus} from '@element-plus/icons-vue'
|
||||
interface Site {id:string;name:string;domain:string;tagline:string;theme:string;entries_count:number;build_count:number;updated_at:string}
|
||||
interface Theme {id:string;name:string}
|
||||
const sites=ref<Site[]>([]),themes=ref<Theme[]>([]),filter=ref(''),loading=ref(false),createVisible=ref(false),siteVisible=ref(false)
|
||||
const active=ref<Site|null>(null),siteTab=ref('info'),csrf=ref('')
|
||||
const draft=ref({name:'',domain:'',tagline:'',theme:'corporate',mode:'new'})
|
||||
const visibleSites=computed(()=>sites.value.filter(s=>s.name.toLowerCase().includes(filter.value.trim().toLowerCase())))
|
||||
const themeTitle=(key?:string)=>themes.value.find(t=>t.id===key)?.name||key||'—'
|
||||
async function api<T>(path:string,init:RequestInit={}){
|
||||
const method=(init.method||'GET').toUpperCase(),headers=new Headers(init.headers)
|
||||
if(!['GET','HEAD'].includes(method))headers.set('X-SiteForge-CSRF',csrf.value)
|
||||
const res=await fetch(path,{...init,headers,credentials:'same-origin'})
|
||||
if(res.status===401)throw Error('请先通过 SiteForge 登录')
|
||||
const json=await res.json()
|
||||
if(!res.ok)throw Error(json.detail||'请求失败')
|
||||
return json as T
|
||||
}
|
||||
const post=(data:unknown):RequestInit=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})
|
||||
async function reload(){loading.value=true;try{const [list,ts]=await Promise.all([api<Site[]>('/api/sites'),api<Theme[]>('/api/themes')]);sites.value=list;themes.value=ts}catch(e:any){ElMessage.error(e.message)}finally{loading.value=false}}
|
||||
onMounted(async()=>{try{csrf.value=(await api<{csrf:string}>('/api/auth/me')).csrf;await reload()}catch(e:any){ElMessage.error(e.message)}})
|
||||
async function create(){if(!draft.value.name.trim())return ElMessage.warning('请输入网站名称');loading.value=true;try{await api('/api/sites',post(draft.value));createVisible.value=false;await reload();ElMessage.success('网站创建成功')}catch(e:any){ElMessage.error(e.message)}finally{loading.value=false}}
|
||||
function editSite(s:Site){active.value=s;siteTab.value='info';siteVisible.value=true}
|
||||
function nativeConsole(s:Site|null){if(s)window.open('/','_blank','noopener')}
|
||||
async function openStudio(s:Site|null){if(!s)return;try{const entries=await api<Array<{id:string;route:string}>>(`/api/sites/${s.id}/entries`);const home=entries.find(x=>x.route==='/')||entries[0];if(!home)return ElMessage.warning('网站暂无可编辑的页面');window.open(`/editor/${s.id}/${home.id}`,'_blank','noopener')}catch(e:any){ElMessage.error(e.message)}}
|
||||
async function applyPreset(s:Site){try{await ElMessageBox.confirm('自动给没有布局的页面装配新主题,原 URL、正文和 SEO 不变。继续吗?','批量内容套版');const r=await api<{updated:number}>(`/api/sites/${s.id}/apply-theme`,post({mode:'missing'}));ElMessage.success(`已完成 ${r.updated} 页布局装配`)}catch(e:any){if(e!=='cancel')ElMessage.error(e.message)}}
|
||||
async function build(s:Site|null){if(!s)return;try{const r=await api<{version:string;pages:number}>(`/api/sites/${s.id}/builds`,post({mirror_images:false}));ElMessage.success(`构建成功:${r.pages} 个静态页面`);window.open(`/preview/${s.id}/${r.version}/`,'_blank','noopener');await reload()}catch(e:any){ElMessage.error(e.message)}}
|
||||
</script>
|
||||
<style scoped>
|
||||
.sf-art-page{padding:4px 4px 30px}.sf-art-intro{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:27px}.sf-art-intro h1{font-size:30px;color:var(--el-text-color-primary);margin:5px 0 7px}.sf-art-intro p,.table-head p{color:var(--el-text-color-secondary);font-size:13px;margin:0}.sf-overline{font-size:10px;letter-spacing:2px;font-weight:800;color:#5884d4}.metric-line{margin-bottom:22px}.metric-label{font-size:12px;color:var(--el-text-color-secondary);margin-bottom:10px}.metric-line strong{font-size:32px;line-height:1.2;display:block}.table-head{display:flex;justify-content:space-between;align-items:center}.table-head h3{margin:0 0 5px}.site-name{display:flex;align-items:center;gap:11px}.site-mark{background:#eaf1ff;color:#4b73ca;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;font-size:16px;font-weight:800}.site-name b{display:block;font-size:13px}.site-name small{display:block;font-size:11px;color:#98a4b6;margin-top:3px}.hint{font-size:12px;color:#8997a8;line-height:1.8}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.2
|
||||
httpx2==2.16.0
|
||||
@@ -0,0 +1,14 @@
|
||||
# Runtime versions are pinned so Docker rebuilds do not silently select a new
|
||||
# framework generation. Update these deliberately and run scripts/check.ps1.
|
||||
fastapi==0.141.1
|
||||
starlette==1.4.1
|
||||
pydantic==2.13.4
|
||||
uvicorn[standard]==0.52.1
|
||||
jinja2==3.1.6
|
||||
python-multipart==0.0.32
|
||||
httpx==0.28.1
|
||||
beautifulsoup4==4.15.0
|
||||
bleach==6.4.0
|
||||
defusedxml==0.7.1
|
||||
openpyxl==3.1.5
|
||||
itsdangerous==2.2.0
|
||||
@@ -0,0 +1,25 @@
|
||||
param(
|
||||
[string]$Python = "python"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
& $Python -m pip check
|
||||
if ($LASTEXITCODE -ne 0) { throw "Python dependency check failed" }
|
||||
|
||||
& $Python -m compileall -q app
|
||||
if ($LASTEXITCODE -ne 0) { throw "Python compilation failed" }
|
||||
|
||||
& $Python -m pytest -q --basetemp=.pytest_cache/check-tmp
|
||||
if ($LASTEXITCODE -ne 0) { throw "Pytest failed" }
|
||||
|
||||
if (Get-Command node -ErrorAction SilentlyContinue) {
|
||||
foreach ($file in @("app/static/admin.js", "app/static/editor.js", "app/static/factory-v2.js", "app/static/pro.js")) {
|
||||
& node --check $file
|
||||
if ($LASTEXITCODE -ne 0) { throw "JavaScript syntax check failed: $file" }
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Node.js is unavailable; JavaScript syntax checks were skipped."
|
||||
}
|
||||
|
||||
Write-Host "SiteForge V5 checks passed."
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Generate a local .env using only Docker's Python image (no host Python needed)."""
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
root=Path(__file__).resolve().parent.parent
|
||||
config=root/'.env'
|
||||
if config.exists():
|
||||
print('已存在 .env:为了防止覆盖现有密码和密钥,本次不做修改。')
|
||||
raise SystemExit(0)
|
||||
password=secrets.token_urlsafe(24)
|
||||
secret=secrets.token_urlsafe(48)
|
||||
config.write_text(f'''# SiteForge Web Docker v0.3 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\n''', encoding='utf-8')
|
||||
try:os.chmod(config,0o600)
|
||||
except OSError:pass
|
||||
print('SiteForge 已生成 .env 配置。')
|
||||
print('首次登录管理员密码:'+password)
|
||||
print('请将密码保存在安全的位置,忘记后可编辑 .env 的密码并重启容器。')
|
||||
print('本机启动命令:docker compose up -d --build')
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Offline installer for SiteForge components into a user-provided official Art Design Pro checkout.
|
||||
|
||||
Run with --dry-run first. It does NOT download, modify auth schemes, or
|
||||
pretend to have compiled the official repository in this sandbox.
|
||||
"""
|
||||
import argparse,shutil,re
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE=Path(__file__).resolve().parents[1]/'integrations'/'art-design-pro'/'src'/'views'/'siteforge'
|
||||
MARKER='// SITEFORGE_FACTORY_V2_REGISTERED'
|
||||
ROUTES='''\n // SITEFORGE_FACTORY_V2_REGISTERED\n {\n path: '/siteforge', name: 'SiteForge', component: '/index/index',\n meta: {title:'网站工厂',icon:'ri:layout-grid-line'},\n children: [\n {path:'factory',name:'SiteForgeFactory',component:'/siteforge/WebsiteFactory',meta:{title:'网站项目'}},\n {path:'themes',name:'SiteForgeThemes',component:'/siteforge/ThemeCenter',meta:{title:'主题与版本'}},\n {path:'enterprise',name:'SiteForgeEnterprise',component:'/siteforge/EnterpriseCenter',meta:{title:'迁移与交付'}},\n ]\n },\n'''
|
||||
|
||||
def install(root:Path,dry_run:bool):
|
||||
base=root/'src'/'router'/'routes'/'asyncRoutes.ts'
|
||||
target=root/'src'/'views'/'siteforge'
|
||||
if not base.is_file() or not (root/'package.json').is_file():raise ValueError('不是受支持的 Art Design Pro 源码目录')
|
||||
original=base.read_text(encoding='utf-8')
|
||||
pat=r'(export\s+const\s+asyncRoutes\s*:[^=]*=\s*\[)'
|
||||
if MARKER not in original:
|
||||
modified,count=re.subn(pat,lambda m:m.group(1)+ROUTES,original,count=1)
|
||||
if count!=1:raise ValueError('无法定位 asyncRoutes 定义;该官方版本的路由结构可能不同,请人工集成')
|
||||
else:modified=original
|
||||
print(f'官方工程: {root}\n文件目标: {target}\n待复制页面: {len(list(SOURCE.glob("*.vue")))}\n路由需要更新: {original!=modified}')
|
||||
if dry_run:return
|
||||
target.mkdir(parents=True,exist_ok=True)
|
||||
for src in SOURCE.glob('*.vue'):
|
||||
dest=target/src.name
|
||||
if dest.exists():shutil.copy2(dest,dest.with_suffix('.vue.siteforge-backup'))
|
||||
shutil.copy2(src,dest)
|
||||
if modified!=original:
|
||||
shutil.copy2(base,base.with_suffix('.ts.siteforge-backup'))
|
||||
base.write_text(modified,encoding='utf-8')
|
||||
print('业务页面与菜单已写入;下一步请配置 /api、/editor、/preview 代理并执行 pnpm build 完成验收。')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('official_repo',type=Path)
|
||||
parser.add_argument('--apply',action='store_true',help='执行源代码变更;不传则只检查')
|
||||
args=parser.parse_args()
|
||||
install(args.official_repo.resolve(),dry_run=not args.apply)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Self-contained HTML preview from the actual four built static sites.
|
||||
It inlines production CSS; navigation/build/SEO remains in the accompanying ZIP.
|
||||
"""
|
||||
import json,re,base64
|
||||
from pathlib import Path
|
||||
root=Path(__import__('sys').argv[1]).resolve() if len(__import__('sys').argv)>1 else Path(__file__).resolve().parent.parent/'demo_sites'
|
||||
out=Path(__import__('sys').argv[2]).resolve() if len(__import__('sys').argv)>2 else Path(__file__).resolve().parent.parent/'theme-preview.html'
|
||||
styles=['theme.css','blocks.css','factory.css','pro-base.css','pro.css','commercial.css','custom.css']
|
||||
data={}
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
base=root/theme
|
||||
pages={'home':'index.html','products':'products/index.html','detail':'products/1/index.html','news':'news/index.html','case':'cases/1/index.html','about':'about/index.html','contact':'contact/index.html'}
|
||||
data[theme]={}
|
||||
for name,file in pages.items():
|
||||
src=(base/file).read_text(encoding='utf-8')
|
||||
for css in styles:
|
||||
text=(base/'assets'/css).read_text(encoding='utf-8')
|
||||
visual=base/'assets'/'visual'/'hero.svg'
|
||||
if visual.is_file():
|
||||
text=text.replace('./visual/hero.svg','data:image/svg+xml;base64,'+base64.b64encode(visual.read_bytes()).decode())
|
||||
src=src.replace(f'<link rel="stylesheet" href="assets/{css}">','<style>'+text+'</style>')
|
||||
src=src.replace('<base href="/">','<base href="about:srcdoc">')
|
||||
src=re.sub(r'<script\s+defer\s+src="assets/[^\"]+"></script>','',src)
|
||||
data[theme][name]=src
|
||||
payload=json.dumps(data,ensure_ascii=False).replace('</','<\\/')
|
||||
html='''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V5 · 四套商业视觉主题 · 真实构建预览</title><style>body{margin:0;background:#edf1f5;font:14px system-ui,-apple-system,'Microsoft YaHei',sans-serif;color:#1d2d41}header{background:#fff;border-bottom:1px solid #dfe4ea;padding:18px 24px;display:flex;gap:14px;align-items:center;justify-content:space-between}header strong{font-size:18px}header span{color:#72869b;font-size:12px}main{max-width:1550px;margin:auto;padding:20px 22px}.tools{display:flex;flex-wrap:wrap;gap:12px;align-items:end;margin-bottom:18px}.tools label{display:grid;gap:6px;font-size:11px;font-weight:bold;color:#6a7c8c}.tools select{padding:10px 13px;border:1px solid #d0dae2;border-radius:7px;background:#fff;min-width:135px;font-size:13px;color:#1b2d40}.devices{margin-left:auto;display:flex;background:#fff;border:1px solid #d0dae2;border-radius:7px;padding:3px}.devices button{cursor:pointer;border:0;background:none;color:#63788c;padding:9px 15px}.devices button.active{background:#183b60;color:#fff;border-radius:5px}.stage{display:flex;justify-content:center}.frame{width:100%;height:760px;background:white;box-shadow:0 18px 40px #253c4b25;transition:width .2s;border:1px solid #d5dee5;overflow:hidden;border-radius:8px}.frame iframe{width:100%;height:100%;border:0}p.note{color:#738697;line-height:1.7;font-size:12px;text-align:center;margin-top:15px}@media(max-width:680px){.devices{margin-left:0}header{display:block}header span{display:block;margin-top:8px}main{padding:12px}}</style></head>
|
||||
<body><header><strong>SiteForge V5 · 专业主题效果预览</strong><span>直接打开本文件 · 不需要 Docker · 展示真实构建的 HTML/CSS</span></header><main><div class="tools"><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="products">产品列表</option><option value="detail">产品详情</option><option value="news">新闻资讯</option><option value="case">案例详情</option><option value="about">关于我们</option><option value="contact">联系我们</option></select></label><div class="devices"><button class="active" data-width="100%">PC</button><button data-width="820px">平板</button><button data-width="390px">手机</button></div></div><div class="stage"><div class="frame" id="frame"><iframe id="preview" title="实际静态网站预览" sandbox=""></iframe></div></div><p class="note">演示包含虚构企业与产品数据。此单文件用于查看实际生成页面的视觉布局;完整网站的导航、搜索和筛选请在 Docker 或静态 Web 服务器部署演示站后体验。</p></main>
|
||||
<script id="data" type="application/json">__PAYLOAD__</script><script>const data=JSON.parse(document.getElementById('data').textContent),theme=document.getElementById('theme'),page=document.getElementById('page'),iframe=document.getElementById('preview');function render(){iframe.srcdoc=data[theme.value][page.value]}theme.onchange=render;page.onchange=render;document.querySelectorAll('[data-width]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-width]').forEach(b=>b.classList.remove('active'));button.classList.add('active');document.getElementById('frame').style.width=button.dataset.width;});render();</script></body></html>'''.replace('__PAYLOAD__',payload)
|
||||
out.write_text(html,encoding='utf-8')
|
||||
print(str(out),out.stat().st_size,'bytes')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Build four fully static demonstration sites from the real V3 builder.
|
||||
All sample names and content here are clearly fictitious demonstration data.
|
||||
"""
|
||||
import json, os, shutil
|
||||
from pathlib import Path
|
||||
BASE=Path(__file__).resolve().parent.parent
|
||||
DATA=Path(os.environ.get('SITEFORGE_DATA_DIR',str(BASE/'demo_storage')))
|
||||
os.environ['SITEFORGE_DATA_DIR']=str(DATA)
|
||||
from app import db,theme_store,builder,quality
|
||||
from app.core import uid,save_entry
|
||||
from app.factory_api import bootstrap_site
|
||||
|
||||
def put(sid,typ,title,route,summary='',body='',category='',brand='',extra=None):
|
||||
fields={'category':category,'brand':brand}
|
||||
if extra:fields.update(extra)
|
||||
return save_entry(sid,{'type':typ,'title':title,'route':route,'summary':summary,'body':body,
|
||||
'meta':{'fields':fields}},source='demo')
|
||||
|
||||
def build_demo():
|
||||
db.init();theme_store.setup()
|
||||
demos={
|
||||
'automotive':('NOVA MOBILITY','探索更广阔的出行可能','汽车与出行演示主题','Cars'),
|
||||
'industrial':('ARC INDUSTRIES','面向现代工程的产品与服务','工业制造演示主题','Materials'),
|
||||
'corporate':('NORTHLINE GROUP','以专业的服务连接企业与未来','企业品牌演示主题','Services'),
|
||||
'minimal':('MONO STUDIO','少而精,让每一项作品更清晰','极简设计演示主题','Objects'),
|
||||
}
|
||||
out=Path(os.environ.get('SITEFORGE_DEMO_OUT',str(BASE/'demo_sites')));out.mkdir(parents=True,exist_ok=True)
|
||||
results={}
|
||||
for theme,(name,tagline,about,term) in demos.items():
|
||||
sid=uid()
|
||||
settings={'nav':[{'title':x,'route':r} for x,r in [('首页','/'),('产品','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于','/about/'),('联系','/contact/')]],
|
||||
'email':'hello@example.com','footer_text':'此为 SiteForge 主题演示网站。公司、产品及内容均为虚构示例。'}
|
||||
db.insert('INSERT INTO sites VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(sid,name,'https://'+theme+'.example.test',tagline,theme,'#315f92','', 'new',json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
|
||||
theme_store.pin(sid,theme,'1.1.0')
|
||||
put(sid,'page','首页','/',tagline)
|
||||
for i,(route,title,description) in enumerate([
|
||||
('/about/','关于我们','企业介绍、发展理念、合作能力与团队故事。'),
|
||||
('/contact/','联系我们','欢迎通过邮件与我们的演示团队取得联系。'),
|
||||
('/products/','产品中心','查看系列、类别及产品资料。'),
|
||||
('/brands/','品牌中心','探索与产品相关的品牌。'),
|
||||
('/news/','新闻资讯','了解最新消息与团队动态。'),
|
||||
('/cases/','项目案例','精选项目展示。'),
|
||||
('/services/','服务与解决方案','从咨询到交付的全链路服务。'),
|
||||
('/faq/','常见问题','更多问题可以联系我们。')]):
|
||||
put(sid,'page',title,route,description,'<p>本页为演示内容。您可以在网站工厂中直接修改和替换。</p>')
|
||||
for i,(prod,cat,brand) in enumerate([
|
||||
(f'{term} Alpha','旗舰系列','NOVA'),(f'{term} Beta','旗舰系列','NOVA'),
|
||||
(f'{term} Gamma','畅销系列','ARC'),(f'{term} Delta','畅销系列','ARC'),
|
||||
(f'{term} Epsilon','定制系列','MONO'),(f'{term} Zeta','定制系列','MONO'),
|
||||
(f'{term} Eta','专业系列','NOVA'),(f'{term} Theta','专业系列','ARC')]):
|
||||
put(sid,'product',prod,f'/products/{i+1}/',f'{prod} — 针对多样应用的演示产品。',
|
||||
'<h2>产品说明</h2><p>此处可以展示产品特点、应用场景和可用规格。</p>',cat,brand,
|
||||
{'model':'MX-'+str(200+i),'material':'Example / Demo','version':'2026','energy':'EV' if theme=='automotive' else 'Standard'})
|
||||
for name_b in ('NOVA','ARC','MONO'):
|
||||
put(sid,'brand',name_b,'/brands/'+name_b.lower()+'/',f'{name_b} 品牌产品与应用。')
|
||||
for i in range(3):
|
||||
put(sid,'post',f'企业动态 · {i+1:02d}',f'/news/{i+1}/','在这里发布新闻资讯与品牌动态。',
|
||||
'<p>这是可替换的新闻正文内容,可包含图片、表格和链接。</p>',extra={'publish_date':f'2026-10-{11+i:02}'})
|
||||
put(sid,'case',f'精选项目 · {i+1:02d}',f'/cases/{i+1}/','展示项目背景、过程与成果。',
|
||||
'<p>本页仅为项目结构示例,并非实际完成的工程项目。</p>',extra={'location':'Demo location','industry':'Demo industry'})
|
||||
put(sid,'service','供应与交付方案','/services/solution/','灵活、可配置的供应交付服务。')
|
||||
r=builder.build(sid)
|
||||
src=db.DATA/'builds'/sid/r['version']/'dist'
|
||||
dst=out/theme
|
||||
if dst.exists():shutil.rmtree(dst)
|
||||
shutil.copytree(src,dst)
|
||||
results[theme]={'page_count':r['pages'],'warnings':r['warnings'],'quality':quality.report(sid,r['version'])}
|
||||
(out/'BUILD_REPORT.json').write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print(json.dumps({k:{'pages':v['page_count'],'warnings':len(v['warnings']),'quality_errors':v['quality']['errors']} for k,v in results.items()},ensure_ascii=False,indent=2))
|
||||
if __name__=='__main__':build_demo()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Offline restore of SiteForge backups. STOP API and worker containers first!
|
||||
|
||||
Usage: python scripts/restore_backup.py /path/to/backup.zip --confirmed-offline
|
||||
The application must be stopped to prevent WAL writes during restore.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
def restore(path, target, confirmed=False):
|
||||
if not confirmed:raise RuntimeError('停止 API 与 worker 后,使用 --confirmed-offline 才能恢复')
|
||||
target=Path(target).resolve();archive=Path(path).resolve()
|
||||
if not archive.exists():raise FileNotFoundError(archive)
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
infos=z.infolist()
|
||||
if len(infos)>250000:raise ValueError('备份文件数量过大')
|
||||
if sum(i.file_size for i in infos)>30_000_000_000:raise ValueError('解压数据总量过大')
|
||||
for item in infos:
|
||||
p=Path(item.filename)
|
||||
if p.is_absolute() or any(x in ('..','.') for x in p.parts) or not p.parts:
|
||||
raise ValueError('备份包含不安全路径')
|
||||
if stat.S_ISLNK(item.external_attr>>16):raise ValueError('不允许符号链接')
|
||||
if 'siteforge.sqlite3' not in z.namelist():raise ValueError('缺少数据库文件')
|
||||
with tempfile.TemporaryDirectory(dir=target.parent) as tmp:
|
||||
z.extractall(tmp)
|
||||
restored=Path(tmp)/'siteforge.sqlite3'
|
||||
with sqlite3.connect(restored) as db:
|
||||
if db.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
|
||||
raise ValueError('备份数据库完整性校验失败')
|
||||
target.mkdir(parents=True,exist_ok=True)
|
||||
# Keep a copy of the old DB outside the restored data volume.
|
||||
old_db=target/'siteforge.sqlite3'
|
||||
if old_db.exists():
|
||||
dest=target.parent/'siteforge-pre-restore.sqlite3'
|
||||
shutil.copy2(old_db,dest)
|
||||
for special in ('siteforge.sqlite3-wal','siteforge.sqlite3-shm'):
|
||||
(target/special).unlink(missing_ok=True)
|
||||
for src in Path(tmp).rglob('*'):
|
||||
if src.is_file():
|
||||
dest=target/src.relative_to(tmp)
|
||||
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||
shutil.copy2(src,dest)
|
||||
print('恢复完成。请检查文件权限后再启动 SiteForge。')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('archive')
|
||||
parser.add_argument('--data',default=os.getenv('SITEFORGE_DATA_DIR','/data'))
|
||||
parser.add_argument('--confirmed-offline',action='store_true')
|
||||
args=parser.parse_args()
|
||||
restore(args.archive,args.data,args.confirmed_offline)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""One-time developer script: break V3 full themes into independently maintainable Jinja page templates."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
base=Path(__file__).resolve().parent.parent/'themes'
|
||||
types={'home':['home'],'search':['search'],'listing':['listing','category'],'product':['product'],
|
||||
'brand':['brand'],'article':['article'],'case':['case'],'info':['about','service','contact','faq','standard'],'notfound':['notfound']}
|
||||
markers=[("{% if kind=='home' %}",'home'),("{% elif kind=='search' %}",'search'),("{% elif kind in ['listing','category'] %}",'listing'),("{% elif kind=='product' %}",'product'),("{% elif kind=='brand' %}",'brand'),("{% elif kind=='article' %}",'article'),("{% elif kind=='case' %}",'case'),("{% elif kind in ('about','service','contact','faq','standard') %}",'info'),("{% elif kind=='notfound' %}",'notfound')]
|
||||
for name in ('automotive','industrial','corporate','minimal'):
|
||||
p=base/name/'pro.html'
|
||||
t=p.read_text(encoding='utf-8')
|
||||
beginning=t.index(markers[0][0]); ending=t.index('{% endif %}\n{% if has_custom',beginning)
|
||||
common=t[:beginning]
|
||||
remainder=t[ending+len('{% endif %}'):] # preserve custom blocks and footer
|
||||
dir=base/name/'pages';dir.mkdir(parents=True,exist_ok=True)
|
||||
pieces={}
|
||||
for i,(marker,key) in enumerate(markers):
|
||||
start=t.index(marker,beginning)+len(marker)
|
||||
finish=t.index(markers[i+1][0],start) if i+1<len(markers) else ending
|
||||
pieces[key]=t[start:finish].strip()+'\n'
|
||||
for key,kinds in types.items():
|
||||
for kind in kinds:
|
||||
(dir/(kind+'.html')).write_text(pieces[key],encoding='utf-8')
|
||||
p.write_text(common+"{% include '"+name+"/pages/' ~ kind ~ '.html' %}\n"+remainder,encoding='utf-8')
|
||||
print(name,len(list(dir.glob('*.html'))),'page family files')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Acceptance tests for V5 visual packages and honest commercial readiness."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from app import theme_store,db,builder,commercial_readiness
|
||||
from test_factory_v2 import factory,site
|
||||
|
||||
def test_all_four_new_versions_have_complete_offline_artwork(factory):
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
versions=factory.get(f'/api/factory/themes/{theme}/versions').json()
|
||||
names={row['version'] for row in versions}
|
||||
assert {'1.1.0','1.0.0','0.3.0'} <= names
|
||||
folder=theme_store.resolve(theme,'1.1.0')
|
||||
assert (folder/'visual/hero.svg').read_text(encoding='utf-8').startswith('<svg ')
|
||||
assert (folder/'pages/product.html').is_file()
|
||||
assert theme_store.compatibility(theme,'1.1.0')['valid']
|
||||
|
||||
def test_site_build_contains_commercial_css_artwork_and_report(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'industrial','version':'1.1.0'}).status_code==200
|
||||
result=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert result.status_code==200,result.text
|
||||
bid=result.json()['version']
|
||||
url=f'/preview/{sid}/{bid}'
|
||||
home=factory.get(url+'/').text
|
||||
soup=BeautifulSoup(home,'html.parser')
|
||||
assert soup.select_one('link[href="assets/commercial.css"]')
|
||||
assert len(soup.select('h1'))==1
|
||||
assert soup.select_one('meta[property="og:title"]')
|
||||
assert factory.get(url+'/assets/commercial.css').status_code==200
|
||||
svg=factory.get(url+'/assets/visual/hero.svg')
|
||||
assert svg.status_code==200 and '<svg' in svg.text
|
||||
report=factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
|
||||
assert not report['errors']
|
||||
assert report['commercial_readiness']['commercially_certified'] is False
|
||||
handoff=factory.get(url+'/commercial-readiness.json')
|
||||
assert handoff.status_code==200
|
||||
obj=handoff.json();assert obj['commercially_certified'] is False
|
||||
assert any(w['code']=='brand_logo' for w in obj['warnings'])
|
||||
|
||||
def test_theme_rollback_keeps_previous_version(factory):
|
||||
sid=site(factory)
|
||||
for version in ('1.0.0','1.1.0'):
|
||||
selected=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':version})
|
||||
assert selected.status_code==200,selected.text
|
||||
result=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert result.status_code==200,result.text
|
||||
html=factory.get(f'/preview/{sid}/{result.json()["version"]}/').text
|
||||
assert ('assets/commercial.css' in html)==(version=='1.1.0')
|
||||
|
||||
def test_readiness_detects_missing_required_pages_and_contact(tmp_path):
|
||||
root=tmp_path
|
||||
(root/'index.html').write_text('<html><head><title>Test</title></head><body><h1>Test</h1></body></html>')
|
||||
report=commercial_readiness.inspect(root)
|
||||
assert not report['ready_for_review']
|
||||
assert report['commercially_certified'] is False
|
||||
assert any(x['code']=='page_missing' for x in report['blockers'])
|
||||
|
||||
def test_online_theme_preview_loads_same_css_and_hero(factory):
|
||||
preview=factory.get('/api/factory/themes/automotive/preview',params={'version':'1.1.0','page_type':'home'})
|
||||
assert preview.status_code==200
|
||||
assert '/ui/commercial.css' in preview.text
|
||||
sheet=factory.get('/ui/commercial.css')
|
||||
assert sheet.status_code==200 and 'pro-filter-panel' in sheet.text
|
||||
art=factory.get('/api/factory/themes/automotive/visual/hero.svg',params={'version':'1.1.0'})
|
||||
assert art.status_code==200 and 'image/svg+xml' in art.headers['content-type']
|
||||
assert factory.get('/api/factory/themes/automotive/visual/hero.svg',params={'version':'1.0.0'}).status_code==404
|
||||
|
||||
def test_custom_declarative_theme_inherits_trusted_visuals(factory):
|
||||
import io,zipfile
|
||||
from test_factory_v2 import zip_theme
|
||||
blob=io.BytesIO(zip_theme('art-compatible','2.0.0'))
|
||||
response=factory.post('/api/factory/themes/install',files={'file':('theme.zip',blob.getvalue(),'application/zip')})
|
||||
assert response.status_code==200,response.text
|
||||
folder=theme_store.resolve('art-compatible','2.0.0')
|
||||
assert (folder/'visual/hero.svg').is_file()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Regression tests for V4: theme layouts, migration audit, assembly, WXR.
|
||||
No external HTTP/DNS or Docker engine required.
|
||||
"""
|
||||
import io,json,zipfile
|
||||
from pathlib import Path
|
||||
from html import escape
|
||||
from openpyxl import Workbook
|
||||
from app import db,blueprints,theme_store,migration_audit,wordpress_compat
|
||||
from test_factory_v2 import factory,site
|
||||
|
||||
|
||||
def test_nested_grid_validates_and_builds(factory):
|
||||
sid=site(factory)
|
||||
home=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
layout=blueprints.new_block('layout',{'columns':[
|
||||
{'width':2,'blocks':[blueprints.new_block('richtext',{'title':'品牌故事','body':'<p>我们的企业介绍</p>'})]},
|
||||
{'width':1,'blocks':[blueprints.new_block('cta',{'title':'欢迎合作','href':'/contact/'})]}]})
|
||||
result=factory.put(f'/api/sites/{sid}/pages/{home["id"]}/blueprint',json={'blocks':[layout],
|
||||
'page_template':'home','layout_mode':'replace'})
|
||||
assert result.status_code==200,result.text
|
||||
assert result.json()['layout_mode']=='replace'
|
||||
preview=factory.post(f'/api/sites/{sid}/pages/{home["id"]}/editor-preview',json={'blocks':[layout],'page_template':'home','layout_mode':'replace'})
|
||||
assert preview.status_code==200,preview.text
|
||||
assert '品牌故事' in preview.text and 'sf-layout-grid' in preview.text and 'pro-header' in preview.text
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
published=factory.get(f'/preview/{sid}/{version}/').text
|
||||
assert '品牌故事' in published and 'sf-layout-grid' in published and 'pro-header' in published
|
||||
css=factory.get(f'/preview/{sid}/{version}/assets/blocks.css').text
|
||||
assert '@media(max-width:540px)' in css
|
||||
|
||||
|
||||
def test_layout_rejects_duplicate_and_nested_excess(factory):
|
||||
a=blueprints.new_block('richtext');b=blueprints.new_block('richtext');b['id']=a['id']
|
||||
bad=blueprints.new_block('layout',{'columns':[{'width':1,'blocks':[a,b]}]})
|
||||
try:blueprints.validate_blocks([bad]);assert False,'duplicate should be rejected'
|
||||
except ValueError:pass
|
||||
nested=blueprints.new_block('layout');child=blueprints.new_block('layout');grand=blueprints.new_block('layout')
|
||||
child['columns'][0]['blocks']=[grand];nested['columns'][0]['blocks']=[child]
|
||||
try:blueprints.validate_blocks([nested]);assert False,'deep nesting should be rejected'
|
||||
except ValueError:pass
|
||||
|
||||
|
||||
def test_wordpress_elementor_media_taxonomy_and_woo_gallery(factory):
|
||||
sid=site(factory)
|
||||
widgets=json.dumps([{'widgetType':'heading','settings':{'title':'核心价值','header_size':'h2'}},
|
||||
{'widgetType':'image','settings':{'image':{'url':'https://img.example/media.jpg'}}},
|
||||
{'widgetType':'unknown_plugin_widget','settings':{'value':'secret'}}],ensure_ascii=False)
|
||||
wxr='''<?xml version="1.0"?><rss xmlns:wp="http://wordpress.org/export/1.2/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel>
|
||||
<item><title>图片</title><wp:post_type>attachment</wp:post_type><wp:post_id>12</wp:post_id><wp:attachment_url>https://img.example/cover.jpg</wp:attachment_url></item>
|
||||
<item><title>产品A</title><link>https://wp.example/product/test-a/</link><wp:post_type>product</wp:post_type><wp:status>publish</wp:status><content:encoded></content:encoded>
|
||||
<category domain="product_cat">运动型多用途车</category>
|
||||
<wp:postmeta><wp:meta_key>_thumbnail_id</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_product_image_gallery</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_elementor_data</wp:meta_key><wp:meta_value><![CDATA['''+widgets+''']]></wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_price</wp:meta_key><wp:meta_value>1200</wp:meta_value></wp:postmeta>
|
||||
</item></channel></rss>'''
|
||||
response=factory.post(f'/api/sites/{sid}/import',files={'file':('export.xml',wxr.encode(),'application/xml')})
|
||||
assert response.status_code==200,response.text
|
||||
item=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['title']=='产品A')
|
||||
assert item['cover']=='https://img.example/cover.jpg'
|
||||
assert item['meta']['fields']['category']=='运动型多用途车'
|
||||
assert item['meta']['fields']['price']=='1200'
|
||||
assert '核心价值' in item['body']
|
||||
assert item['meta']['migration_warnings']
|
||||
assert item['meta']['fields']['gallery']==['https://img.example/cover.jpg']
|
||||
|
||||
|
||||
def test_excel_mapping_relations_preview_and_repeat(factory):
|
||||
sid=site(factory)
|
||||
w=Workbook();ws=w.active;ws.append(['车型名称','品牌','分类','动力形式','图库','原网址'])
|
||||
ws.append(['SUV Alpha','Hesu','SUV','PHEV','https://img.example/1.jpg;https://img.example/2.jpg','/cars/alpha.html'])
|
||||
b=io.BytesIO();w.save(b)
|
||||
r=factory.post(f'/api/factory/sites/{sid}/mapping/parse-file',files={'file':('cars.xlsx',b.getvalue())})
|
||||
assert r.status_code==200,r.text
|
||||
parsed=r.json();assert parsed['count']==1
|
||||
assert parsed['suggested']['title']=='车型名称'
|
||||
report=factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':parsed['rows'],
|
||||
'rules':{**parsed['suggested'],'gallery':'图库'},'content_type':'vehicle','dry_run':True}).json()
|
||||
assert report['mapped']==1 and report['sample'][0]['route']=='/cars/alpha.html'
|
||||
committed=factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':parsed['rows'],
|
||||
'rules':{**parsed['suggested'],'gallery':'图库'},'content_type':'vehicle','dry_run':False})
|
||||
assert committed.status_code==200
|
||||
x=factory.get(f'/api/factory/sites/{sid}/relationships').json()
|
||||
assert any(i['brand']=='Hesu' for i in x['brand_pages_missing'])
|
||||
y=factory.post(f'/api/factory/sites/{sid}/relationships/assemble').json()
|
||||
assert y['count']==1
|
||||
assert factory.post(f'/api/factory/sites/{sid}/relationships/assemble').json()['count']==0
|
||||
entries=factory.get(f'/api/sites/{sid}/entries').json()
|
||||
assert any(e['type']=='brand' and e['title']=='Hesu' for e in entries)
|
||||
assert next(e for e in entries if e['title']=='SUV Alpha')['meta']['fields']['gallery']==['https://img.example/1.jpg','https://img.example/2.jpg']
|
||||
for theme in ('industrial','corporate','minimal'):
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':theme}).status_code==200
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
assert 'SUV Alpha' in factory.get(f'/preview/{sid}/{version}/cars/alpha.html').text
|
||||
|
||||
|
||||
def test_migration_manifest_and_built_qa_blocks_missing(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
baseline='url,seo_title,description,canonical,status\nhttps://old.example/,Title from old,Old description,,200\nhttps://old.example/legacy-only.html,Missing,,,200\n'
|
||||
r=factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('urls.csv',baseline.encode())})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['imported']==2
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
report=factory.get(f'/api/audit/sites/{sid}/builds/{version}/report').json()
|
||||
assert not report['passed'] and report['errors']>=1
|
||||
assert any(i['route']=='/legacy-only.html' and i['field']=='url' for i in report['issues'])
|
||||
assert any(i['route']=='/' and i['severity']=='warning' for i in report['issues'])
|
||||
readiness=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={version}').json()
|
||||
assert not readiness['can_publish']
|
||||
csv=factory.get(f'/api/audit/sites/{sid}/builds/{version}/report.csv')
|
||||
assert csv.status_code==200 and 'legacy-only.html' in csv.text
|
||||
|
||||
|
||||
def test_sitemap_import_and_invalid_baseline_atomic(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
xml='''<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://old.example/about/</loc></url></urlset>'''
|
||||
assert factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('sitemap.xml',xml.encode())}).json()['imported']==1
|
||||
bad='url\nhttps://old.example/about/\nhttps://old.example/about/?page=5\n'
|
||||
response=factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('bad.csv',bad.encode())})
|
||||
assert response.status_code==200 and response.json()['errors']
|
||||
baseline=factory.get(f'/api/enterprise/sites/{sid}/baseline/report').json()
|
||||
assert baseline['baseline_count']==1
|
||||
|
||||
|
||||
def test_theme_gallery_and_compatibility(factory):
|
||||
sid=site(factory)
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
r=factory.get(f'/api/factory/themes/{theme}/compatibility')
|
||||
assert r.status_code==200 and r.json()['valid'],r.text
|
||||
product=factory.post(f'/api/sites/{sid}/entries',json={'title':'Unique Product','type':'product','route':'/product/unique/',
|
||||
'cover':'https://imgs.example/cover.jpg','meta':{'fields':{'gallery':['https://imgs.example/cover.jpg','https://imgs.example/second.jpg']}}})
|
||||
assert product.status_code in (200,201),product.text
|
||||
ver=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
html=factory.get(f'/preview/{sid}/{ver}/product/unique/').text
|
||||
assert 'pro-gallery-thumbs' in html and 'data-gallery-image' in html
|
||||
assert 'pro.js' in html
|
||||
|
||||
|
||||
def test_css_style_breakout_rejected(factory):
|
||||
z=io.BytesIO()
|
||||
with zipfile.ZipFile(z,'w') as f:
|
||||
data={'id':'test-css-escape','version':'1.0.0','name':'Safe Test','layout_family':'corporate'}
|
||||
f.writestr('manifest.json',json.dumps(data));f.writestr('page-presets.json',json.dumps({'home':[]}))
|
||||
f.writestr('theme.css','body{color:red}</style><script>alert(1)</script>')
|
||||
result=factory.post('/api/factory/themes/install',files={'file':('unsafe.zip',z.getvalue())})
|
||||
assert result.status_code==400
|
||||
|
||||
def test_migration_warning_requires_explicit_review(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
source='url,seo_title\nhttps://old.example/,Old SEO Title\n'
|
||||
factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('old.csv',source.encode())})
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
current=factory.get(f'/api/audit/sites/{sid}/builds/{build}/report').json()
|
||||
warnings=[i for i in current['issues'] if i['severity']=='warning' and i['field']=='title']
|
||||
assert warnings and current['unreviewed_warnings']>=1
|
||||
readiness=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={build}').json()
|
||||
assert not readiness['can_publish']
|
||||
assert any('未经审核' in x['message'] for x in readiness['gates'])
|
||||
denied=factory.post(f'/api/audit/sites/{sid}/builds/{build}/review-warning',json={
|
||||
'issue_id':warnings[0]['issue_id'],'reason':'短'});
|
||||
assert denied.status_code==400
|
||||
accepted=factory.post(f'/api/audit/sites/{sid}/builds/{build}/review-warning',json={
|
||||
'issue_id':warnings[0]['issue_id'],'reason':'企业管理人员已经核查该 SEO 标题调整,并批准本构建中的改动。'})
|
||||
assert accepted.status_code==200,accepted.text
|
||||
after=factory.get(f'/api/audit/sites/{sid}/builds/{build}/report').json()
|
||||
assert after['unreviewed_warnings']==0
|
||||
next_gate=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={build}').json()
|
||||
assert not any('未经审核' in x['message'] for x in next_gate['gates'])
|
||||
# Unrelated broken links may still correctly block publication.
|
||||
|
||||
|
||||
def test_site_scoped_reusable_block_library(factory):
|
||||
first=site(factory); second=site(factory)
|
||||
block=blueprints.new_block('layout',{'columns':[{'width':1,'blocks':[blueprints.new_block('richtext',{'title':'跨页面共用模块'})]}]})
|
||||
created=factory.post(f'/api/sites/{first}/components',json={'name':'品牌单列布局','block':block})
|
||||
assert created.status_code==200,created.text
|
||||
cid=created.json()['id']
|
||||
rows=factory.get(f'/api/sites/{first}/components').json()
|
||||
assert len(rows)==1 and rows[0]['block']['columns'][0]['blocks'][0]['title']=='跨页面共用模块'
|
||||
assert not factory.get(f'/api/sites/{second}/components').json()
|
||||
assert factory.put(f'/api/sites/{first}/components/{cid}',json={'name':'新版', 'expected_version':0,'block':block}).status_code==409
|
||||
assert factory.put(f'/api/sites/{first}/components/{cid}',json={'name':'新版', 'expected_version':1,'block':block}).json()['version']==2
|
||||
assert factory.delete(f'/api/sites/{first}/components/{cid}').json()['deleted']
|
||||
|
||||
|
||||
def test_contact_form_requires_configured_https_endpoint(factory):
|
||||
sid=site(factory)
|
||||
assert factory.put(f'/api/sites/{sid}/contact-settings',json={'email':'hello@example.org', 'form_endpoint':'http://unsecure.example/forms'}).status_code==400
|
||||
config=factory.put(f'/api/sites/{sid}/contact-settings',json={
|
||||
'email':'hello@example.org','form_endpoint':'https://forms.example.org/receive','footer_text':'Official inquiry channel'})
|
||||
assert config.status_code==200,config.text
|
||||
factory.post(f'/api/factory/sites/{sid}/bootstrap')
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
contact=factory.get(f'/preview/{sid}/{build}/contact/').text
|
||||
assert '<form' in contact and 'forms.example.org/receive' in contact
|
||||
assert 'Official inquiry channel' in contact
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import os
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
from app import db, enterprise, worker
|
||||
from app.main import app
|
||||
from app.worker import run_once
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(db,'DATA',tmp_path)
|
||||
monkeypatch.setattr(db,'DB_FILE',tmp_path/'app.sqlite3')
|
||||
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','administrator-strong-password-2026')
|
||||
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','secure-random-test-key-with-long-length-1234')
|
||||
monkeypatch.setenv('SITEFORGE_PUBLISH_ROOT',str(tmp_path/'published'))
|
||||
with TestClient(app) as c:
|
||||
assert c.post('/api/auth/login',json={'username':'admin','password':'administrator-strong-password-2026'}).status_code==200
|
||||
csrf=c.get('/api/auth/me').json()['csrf']
|
||||
c.headers.update({'X-SiteForge-CSRF':csrf})
|
||||
yield c
|
||||
|
||||
def site(c,name):
|
||||
r=c.post('/api/sites',json={'name':name,'theme':'corporate','mode':'migration','domain':'https://example.org'})
|
||||
assert r.status_code==201,r.text
|
||||
return r.json()['id']
|
||||
|
||||
def test_team_rbac_isolation(root):
|
||||
one=site(root,'Company A')
|
||||
two=site(root,'Company B')
|
||||
response=root.post('/api/enterprise/team/users',json={'username':'editor1','password':'long-editor-password-2026','display_name':'Editor'})
|
||||
assert response.status_code==201,response.text
|
||||
uid=response.json()['id']
|
||||
r=root.put(f'/api/enterprise/sites/{one}/members',json={'user_id':uid,'role':'editor'})
|
||||
assert r.status_code==200,r.text
|
||||
root.post('/api/auth/logout')
|
||||
assert root.post('/api/auth/login',json={'username':'editor1','password':'long-editor-password-2026'}).status_code==200
|
||||
root.headers.update({'X-SiteForge-CSRF':root.get('/api/auth/me').json()['csrf']})
|
||||
visible=root.get('/api/sites').json()
|
||||
assert [x['id'] for x in visible]==[one]
|
||||
assert root.get(f'/api/sites/{two}').status_code==403
|
||||
assert root.post(f'/api/sites/{two}/entries',json={'title':'Blocked'}).status_code==403
|
||||
assert root.post('/api/sites',json={'name':'Blocked'}).status_code==403
|
||||
assert root.post(f'/api/sites/{one}/entries',json={'title':'Allowed','type':'page','route':'/allowed/'}).status_code==201
|
||||
assert root.post(f'/api/sites/{one}/builds/invalid/publish-local').status_code==403
|
||||
assert root.get('/api/enterprise/team/users').status_code==403
|
||||
assert root.get(f'/api/enterprise/sites/{one}/baseline/report').status_code==200
|
||||
|
||||
|
||||
def test_baseline_and_release_gate(root):
|
||||
sid=site(root,'Legacy')
|
||||
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Original','route':'/legacy/a.html','seo_title':'Old Title'})
|
||||
assert x.status_code==201
|
||||
assert root.post(f'/api/enterprise/sites/{sid}/baseline/snapshot').json()['captured']==2
|
||||
e=x.json()['id']
|
||||
assert root.delete(f'/api/sites/{sid}/entries/{e}').status_code==200
|
||||
report=root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()
|
||||
assert report['errors']==1
|
||||
build=root.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert build.status_code==200,build.text
|
||||
bid=build.json()['version']
|
||||
publish=root.post(f'/api/sites/{sid}/builds/{bid}/publish-local')
|
||||
assert publish.status_code==409
|
||||
assert root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Replaced','route':'/legacy/a.html','seo_title':'Old Title'}).status_code==201
|
||||
assert root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()['errors']==0
|
||||
rebuilt=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
publish=root.post(f'/api/sites/{sid}/builds/{rebuilt}/publish-local')
|
||||
assert publish.status_code==200,publish.text
|
||||
qa=root.get(f'/api/enterprise/sites/{sid}/builds/{rebuilt}/quality')
|
||||
assert qa.status_code==200,qa.text
|
||||
assert qa.json()['files']>=2
|
||||
history=root.get(f'/api/enterprise/sites/{sid}/releases').json()
|
||||
assert history and history[0]['build_id']==rebuilt
|
||||
rollback=root.post(f'/api/enterprise/sites/{sid}/releases/{rebuilt}/rollback')
|
||||
assert rollback.status_code==200,rollback.text
|
||||
|
||||
|
||||
def test_revision_restore_and_design_tokens(root):
|
||||
sid=site(root,'Design')
|
||||
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Draft v1','route':'/old-page/'})
|
||||
entry=x.json()['id']
|
||||
assert root.patch(f'/api/sites/{sid}/entries/{entry}',json={'title':'Draft v2'}).status_code==200
|
||||
revs=root.get(f'/api/enterprise/sites/{sid}/entries/{entry}/revisions').json()
|
||||
assert len(revs)>=1
|
||||
old=root.get(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}').json()
|
||||
assert old['entry']['title']=='Draft v1'
|
||||
assert root.post(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}/restore').status_code==200
|
||||
refreshed=root.get(f'/api/sites/{sid}/entries').json()
|
||||
assert any(i['title']=='Draft v1' for i in refreshed)
|
||||
invalid=root.put(f'/api/enterprise/sites/{sid}/design-tokens',json={'font':'system','radius':999})
|
||||
assert invalid.status_code==400
|
||||
payload={'font':'serif','heading_scale':1.2,'radius':18,'section_spacing':80,'container_width':1360,'button_style':'pill','header_style':'compact'}
|
||||
assert root.put(f'/api/enterprise/sites/{sid}/design-tokens',json=payload).status_code==200
|
||||
assert root.get(f'/api/enterprise/sites/{sid}/design-tokens').json()==payload
|
||||
build=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
import pathlib
|
||||
css=(db.DATA/'builds'/sid/build/'dist'/'assets'/'custom.css').read_text(encoding='utf-8')
|
||||
assert 'Georgia' in css and '--sf-width:1360px' in css
|
||||
|
||||
|
||||
def test_background_worker_and_audit(root):
|
||||
sid=site(root,'Jobs')
|
||||
res=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'})
|
||||
assert res.status_code==202,res.text
|
||||
assert run_once()==res.json()['id']
|
||||
jobs=root.get(f'/api/enterprise/sites/{sid}/jobs').json()
|
||||
assert jobs[0]['state']=='done'
|
||||
audits=root.get('/api/enterprise/audit').json()
|
||||
assert any(x['operation']=='POST' for x in audits)
|
||||
assert root.get('/ui/enterprise-ui.js').status_code==200
|
||||
|
||||
|
||||
def test_background_worker_retries_transient_failure(root,monkeypatch):
|
||||
sid=site(root,'Retry Jobs')
|
||||
job=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'}).json()['id']
|
||||
monkeypatch.setenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')
|
||||
monkeypatch.setattr(worker.builder,'build',lambda *_: (_ for _ in ()).throw(RuntimeError('temporary failure')))
|
||||
assert worker.run_once()==job
|
||||
queued=db.one('SELECT state,attempts,error,retry_at FROM build_jobs WHERE id=?',(job,))
|
||||
assert queued['state']=='queued' and queued['attempts']==1
|
||||
assert queued['retry_at'] and 'temporary failure' in queued['error']
|
||||
|
||||
def test_viewer_cannot_modify_or_see_other_site(root):
|
||||
own=site(root,'Site One'); foreign=site(root,'Site Two')
|
||||
user=root.post('/api/enterprise/team/users',json={'username':'viewer22','display_name':'Viewer','password':'long-viewer-password-2026'}).json()['id']
|
||||
assert root.put(f'/api/enterprise/sites/{own}/members',json={'user_id':user,'role':'viewer'}).status_code==200
|
||||
root.post('/api/auth/logout')
|
||||
root.post('/api/auth/login',json={'username':'viewer22','password':'long-viewer-password-2026'})
|
||||
root.headers['X-SiteForge-CSRF']=root.get('/api/auth/me').json()['csrf']
|
||||
assert root.get(f'/api/sites/{own}/entries').status_code==200
|
||||
assert root.get(f'/preview/{foreign}/any/').status_code==403
|
||||
assert root.post(f'/api/sites/{own}/builds',json={}).status_code==403
|
||||
assert root.put(f'/api/enterprise/sites/{own}/design-tokens',json={}).status_code==403
|
||||
assert root.get(f'/api/enterprise/sites/{own}/jobs').status_code==200
|
||||
assert root.get(f'/api/enterprise/sites/{foreign}/jobs').status_code==403
|
||||
@@ -0,0 +1,269 @@
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.main import app
|
||||
from app.core import route_path,ensure_public_url
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(db,'DATA',tmp_path)
|
||||
monkeypatch.setattr(db,'DB_FILE',tmp_path/'test.sqlite3')
|
||||
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-supervisor-password-2026')
|
||||
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','large-random-key-for-test-only-2026-the-test-environment')
|
||||
with TestClient(app) as c:
|
||||
assert c.get('/api/sites').status_code == 401
|
||||
assert c.post('/api/auth/login',json={'password':'strong-supervisor-password-2026'}).status_code == 200
|
||||
auth=c.get('/api/auth/me').json()
|
||||
c.headers.update({'X-SiteForge-CSRF': auth['csrf']})
|
||||
yield c
|
||||
|
||||
def create(c,theme='automotive'):
|
||||
r=c.post('/api/sites',json={'name':'Example Test','theme':theme,'domain':'https://example.test','mode':'migration'})
|
||||
assert r.status_code==201,r.text
|
||||
return r.json()['id']
|
||||
|
||||
def test_site_and_theme_switch_preserve_content(client):
|
||||
a=create(client)
|
||||
b=create(client,'industrial')
|
||||
payload={'type':'product','title':'Product A','route':'/products/legacy.html','seo_title':'Original SEO Title','seo_description':'Original SEO Description','body':'<p>Hello <strong>world</strong></p>'}
|
||||
x=client.post(f'/api/sites/{a}/entries',json=payload)
|
||||
assert x.status_code==201,x.text
|
||||
assert len(client.get(f'/api/sites/{b}/entries').json())==1
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
assert client.patch(f'/api/sites/{a}',json={'theme':theme}).status_code==200
|
||||
info=client.post(f'/api/sites/{a}/builds',json={'mirror_images':False})
|
||||
assert info.status_code==200,info.text
|
||||
bid=info.json()['version']
|
||||
resp=client.get(f'/preview/{a}/{bid}/products/legacy.html')
|
||||
assert resp.status_code==200,resp.text[:150]
|
||||
assert '<title>Original SEO Title</title>' in resp.text
|
||||
assert '<link rel="canonical" href="https://example.test/products/legacy.html">' in resp.text
|
||||
assert 'Original SEO Description' in resp.text
|
||||
assert 'Hello' in resp.text
|
||||
dl=client.get(f'/api/sites/{a}/builds/{bid}/download')
|
||||
with zipfile.ZipFile(io.BytesIO(dl.content)) as z:
|
||||
assert 'products/legacy.html' in z.namelist()
|
||||
assert 'assets/theme.css' in z.namelist()
|
||||
assert 'sitemap.xml' in z.namelist()
|
||||
assert len(client.get(f'/api/sites/{a}/entries').json())==2
|
||||
|
||||
def test_home_blueprint_and_static_links(client):
|
||||
sid=create(client)
|
||||
pages=client.get(f'/api/sites/{sid}/entries').json()
|
||||
home=pages[0]
|
||||
assert home['route']=='/'
|
||||
meta={'blocks':[{'type':'features','title':'优势清单','items':[{'title':'服务能力','text':'广泛覆盖'}]},
|
||||
{'type':'cta','title':'马上合作','href':'/contact/'}]}
|
||||
assert client.patch(f'/api/sites/{sid}/entries/{home["id"]}',json={'meta':meta}).status_code==200
|
||||
r=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
bid=r.json()['version']
|
||||
txt=client.get(f'/preview/{sid}/{bid}/').text
|
||||
assert '优势清单' in txt and '马上合作' in txt
|
||||
|
||||
def test_duplicate_routes_and_unsafe_html(client):
|
||||
sid=create(client)
|
||||
x=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'X','route':'/test/','body':'<script>alert(1)</script><a href="javascript:alert(1)">x</a>'})
|
||||
assert x.status_code==201
|
||||
y=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Y','route':'/test/'})
|
||||
assert y.status_code==400
|
||||
z=client.get(f'/api/sites/{sid}/entries').json()
|
||||
it=next(x for x in z if x['route']=='/test/')
|
||||
assert '<script>' not in it['body']
|
||||
assert 'href="javascript:' not in it['body']
|
||||
assert client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'unsafe','route':'/x/../y/'}).status_code==400
|
||||
|
||||
def test_data_import_and_presets(client):
|
||||
sid=create(client)
|
||||
csv_data=b'type,title,route,seo_title\nproduct,Vehicle 1,/cars/vehicle-1/,Vehicle One SEO\nproduct,Vehicle 2,/cars/vehicle-2/,Vehicle Two SEO\n'
|
||||
response=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
|
||||
assert response.status_code==200,response.text
|
||||
assert response.json()['imported']==2
|
||||
r2=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
|
||||
assert r2.json()['skipped']==2
|
||||
save=client.post(f'/api/sites/{sid}/save-preset',json={'name':'My Master Theme'})
|
||||
assert save.status_code==200
|
||||
reuse=client.post('/api/sites',json={'name':'New from Preset','preset_id':save.json()['id']})
|
||||
assert reuse.status_code==201
|
||||
assert reuse.json()['theme']=='automotive'
|
||||
|
||||
def test_wxr_legacy_urls_meta(client):
|
||||
sid=create(client)
|
||||
wxr=b'''<?xml version="1.0" encoding="utf-8"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wp="http://wordpress.org/export/1.2/" version="2.0"><channel><item>
|
||||
<title>News</title><link>https://old.example.org/news/article-01.html</link>
|
||||
<wp:post_type>post</wp:post_type><wp:status>publish</wp:status><wp:post_name>article-01</wp:post_name>
|
||||
<content:encoded><![CDATA[<h2>Article</h2><p>WordPress body</p>]]></content:encoded>
|
||||
<wp:postmeta><wp:meta_key>_yoast_wpseo_title</wp:meta_key><wp:meta_value>Exact SEO Title</wp:meta_value></wp:postmeta>
|
||||
</item></channel></rss>'''
|
||||
response=client.post(f'/api/sites/{sid}/import',files={'file':('wordpress.xml',wxr,'application/xml')},data={'on_conflict':'skip'})
|
||||
assert response.status_code==200,response.text
|
||||
arr=client.get(f'/api/sites/{sid}/entries').json()
|
||||
match=next(p for p in arr if p['title']=='News')
|
||||
assert match['route']=='/news/article-01.html'
|
||||
assert match['seo_title']=='Exact SEO Title'
|
||||
assert 'WordPress body' in match['body']
|
||||
|
||||
def test_media_archive(client):
|
||||
sid=create(client)
|
||||
buffer=io.BytesIO()
|
||||
with zipfile.ZipFile(buffer,'w') as zipf:
|
||||
zipf.writestr('wp-content/uploads/2024/01/product.jpg',b'\xff\xd8\xff\xd9')
|
||||
zipf.writestr('../bad.php',b'evil')
|
||||
buffer.seek(0)
|
||||
r=client.post(f'/api/sites/{sid}/media-archive',files={'file':('uploads.zip',buffer.getvalue(),'application/zip')})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['imported']==1
|
||||
built=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert built.status_code==200
|
||||
z=client.get(f'/api/sites/{sid}/builds/{built.json()["version"]}/download')
|
||||
with zipfile.ZipFile(io.BytesIO(z.content)) as archive:
|
||||
assert 'wp-content/uploads/2024/01/product.jpg' in archive.namelist()
|
||||
assert not any('bad.php' in s for s in archive.namelist())
|
||||
|
||||
def test_redirect_and_ssrf_reject(client):
|
||||
sid=create(client)
|
||||
assert client.post(f'/api/sites/{sid}/redirects',json={'old_path':'/old.html','new_path':'/new/','status_code':301}).status_code==200
|
||||
r=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert r.status_code==200
|
||||
assert len(client.get(f'/api/sites/{sid}/redirects').json())==1
|
||||
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'http://127.0.0.1:8787/'})
|
||||
assert bad.status_code==400
|
||||
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'file:///etc/passwd'})
|
||||
assert bad.status_code==400
|
||||
|
||||
|
||||
def test_login_and_csrf_enforcement(client):
|
||||
session=client.cookies.get('siteforge_session')
|
||||
assert session
|
||||
me=client.get('/api/auth/me')
|
||||
assert me.json()['version']=='5.0.0'
|
||||
assert me.headers['x-frame-options']=='SAMEORIGIN'
|
||||
assert "frame-ancestors 'self'" in me.headers['content-security-policy']
|
||||
assert 'camera=()' in me.headers['permissions-policy']
|
||||
original=client.headers.pop('X-SiteForge-CSRF')
|
||||
assert client.post('/api/sites',json={'name':'Blocked'}).status_code == 403
|
||||
client.headers['X-SiteForge-CSRF']=original
|
||||
assert client.get('/api/sites').status_code == 200
|
||||
assert client.post('/api/auth/logout').status_code == 200
|
||||
assert client.get('/api/sites').status_code == 401
|
||||
assert client.get('/').status_code in (200, 303) # TestClient follows /login
|
||||
assert '管理员密码' in client.get('/login').text
|
||||
|
||||
|
||||
def test_health_uses_current_application_version(client):
|
||||
client.cookies.clear()
|
||||
assert client.get('/api/health').json()=={'status':'ok','name':'SiteForge','version':'5.0.0'}
|
||||
|
||||
def test_build_download_requires_login(client):
|
||||
sid=create(client)
|
||||
result=client.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
bid=result['version']
|
||||
ok=client.get(f'/api/sites/{sid}/builds/{bid}/download')
|
||||
assert ok.status_code==200
|
||||
client.cookies.clear()
|
||||
assert client.get(f'/api/sites/{sid}/builds/{bid}/download').status_code==401
|
||||
assert '/login' in client.get(f'/preview/{sid}/{bid}/').url.path
|
||||
|
||||
def test_visual_blocks_auto_bind_save_build_and_site_isolation(client):
|
||||
sid=create(client,'corporate')
|
||||
other=create(client,'minimal')
|
||||
prod=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'CUSTOM SUV 01','route':'/autos/custom-01/','summary':'Quality example SUV'}).json()
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
blocks=[
|
||||
{'id':'hero-block-1','type':'hero','title':'{{site.name}}','subtitle':'WELCOME','description':'{{site.tagline}}','button':'汽车列表','href':'/product/'},
|
||||
{'id':'products-1','type':'collection','title':'车型推荐','source':'product','limit':6,'columns':3},
|
||||
{'id':'stats-block-1','type':'stats','title':'统计数据','items':[{'value':'3,000+','label':'全球交付'}]}
|
||||
]
|
||||
save=client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
|
||||
assert save.status_code==200,save.text
|
||||
preview=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert preview.status_code==200
|
||||
assert 'CUSTOM SUV 01' in preview.text and '车型推荐' in preview.text
|
||||
assert 'AUTOS' not in preview.text
|
||||
resp=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert resp.status_code==200,resp.text
|
||||
assert resp.json()['blueprint_blocks']>=3
|
||||
html=client.get(f'/preview/{sid}/{resp.json()["version"]}/').text
|
||||
assert 'CUSTOM SUV 01' in html and '车型推荐' in html
|
||||
assert 'assets/blocks.css' in html
|
||||
assert client.get(f'/api/sites/{other}/pages/{homepage["id"]}/blueprint').status_code==404
|
||||
assert client.get(f'/editor/{sid}/{homepage["id"]}').status_code==200
|
||||
|
||||
|
||||
def test_schema_navigation_and_preset(client):
|
||||
sid=create(client)
|
||||
fields=[{'name':'model_code','label':'产品编号','type':'text'},{'name':'horsepower','label':'马力','type':'number'}]
|
||||
r=client.put(f'/api/sites/{sid}/schemas/product',json=fields)
|
||||
assert r.status_code==200,r.text
|
||||
assert client.get(f'/api/sites/{sid}/schemas').json()['product']==fields
|
||||
assert client.put(f'/api/sites/{sid}/schemas/product',json=[{'name':'../not_safe','type':'text'}]).status_code==400
|
||||
nav=[{'title':'首页','route':'/'},{'title':'车型','route':'/product/'}]
|
||||
assert client.put(f'/api/sites/{sid}/navigation',json=nav).status_code==200
|
||||
assert client.get(f'/api/sites/{sid}').json()['settings']['nav']==nav
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
r=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint/preset',json={'page_template':'about'})
|
||||
assert r.status_code==200
|
||||
assert len(r.json()['blocks'])==3
|
||||
assert client.get('/api/themes').json()[0]['page_templates']
|
||||
|
||||
|
||||
def test_blocks_validation_escaping_and_disabled(client):
|
||||
sid=create(client)
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
blocks=[{'id':'dangerous-x','type':'hero','title':'<img src=x onerror=alert(1)>','image':'javascript:alert(1)','href':'javascript:alert(1)'},
|
||||
{'id':'hidden-one','type':'richtext','title':'should be hidden','body':'<script>alert(1)</script>','visible':False}]
|
||||
res=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert res.status_code==200,res.text
|
||||
assert 'onerror=alert' in res.text # text escaped, never a literal img tag
|
||||
assert '<img src=x onerror' in res.text
|
||||
assert 'href="/"' in res.text or 'href="./"' in res.text
|
||||
assert 'should be hidden' not in res.text
|
||||
assert client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':[{'id':'bad-id','type':'script','title':'Bad'}],'page_template':'home'}).status_code==400
|
||||
|
||||
def test_bulk_theme_assembly_preserves_legacy_url_and_seo(client):
|
||||
sid=create(client,'industrial')
|
||||
resp=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'HDPE Pipe','route':'/old-product/hdpe-pipe.html','body':'<h2>Legacy Technical Details</h2>','seo_title':'HDPE pipe SEO forever','seo_description':'Exact old metadata'})
|
||||
assert resp.status_code==201
|
||||
entry=resp.json()
|
||||
assert not entry['meta'].get('blocks')
|
||||
done=client.post(f'/api/sites/{sid}/apply-theme',json={'mode':'missing'})
|
||||
assert done.status_code==200,done.text
|
||||
assert done.json()['updated']>=1
|
||||
updated=client.get(f'/api/sites/{sid}/entries').json()
|
||||
final=next(x for x in updated if x['id']==entry['id'])
|
||||
assert final['route']=='/old-product/hdpe-pipe.html'
|
||||
assert final['seo_title']=='HDPE pipe SEO forever'
|
||||
assert len(final['meta']['blocks'])>=2
|
||||
built=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert built.status_code==200,built.text
|
||||
html=client.get(f'/preview/{sid}/{built.json()["version"]}/old-product/hdpe-pipe.html').text
|
||||
assert 'Legacy Technical Details' in html
|
||||
assert '<title>HDPE pipe SEO forever</title>' in html
|
||||
|
||||
def test_scaffold_standard_pages_safe_idempotent(client):
|
||||
sid=create(client)
|
||||
first=client.post(f'/api/sites/{sid}/scaffold')
|
||||
assert first.status_code==200
|
||||
assert '/about/' in first.json()['created']
|
||||
second=client.post(f'/api/sites/{sid}/scaffold')
|
||||
assert second.status_code==200 and not second.json()['created']
|
||||
lst=client.get(f'/api/sites/{sid}/entries').json()
|
||||
assert len([x for x in lst if x['route']=='/about/'])==1
|
||||
build=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert build.status_code==200,build.text
|
||||
|
||||
def test_attributes_bind_imported_and_typed_fields(client):
|
||||
sid=create(client,'automotive')
|
||||
product=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'BYD SUV','route':'/product/byd-suv/','meta':{'fields':{'brand':'BYD','model':'SUV 1','specs':'Electric'}}})
|
||||
assert product.status_code==201
|
||||
eid=product.json()['id']
|
||||
blocks=[{'id':'attributes-1','type':'attributes','title':'Technical Parameters'}]
|
||||
save=client.put(f'/api/sites/{sid}/pages/{eid}/blueprint',json={'blocks':blocks,'page_template':'product'})
|
||||
assert save.status_code==200,save.text
|
||||
b=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert b.status_code==200,b.text
|
||||
page=client.get(f'/preview/{sid}/{b.json()["version"]}/product/byd-suv/').text
|
||||
assert 'Technical Parameters' in page and '>BYD<' in page and 'SUV 1' in page
|
||||
@@ -0,0 +1,160 @@
|
||||
import io,json,zipfile
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db,theme_store
|
||||
from app.main import app
|
||||
|
||||
@pytest.fixture
|
||||
def factory(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(db,'DATA',tmp_path)
|
||||
monkeypatch.setattr(db,'DB_FILE',tmp_path/'site.sqlite3')
|
||||
monkeypatch.setattr(theme_store,'THEME_ROOT',tmp_path/'theme_packages')
|
||||
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-admin-2026-secret')
|
||||
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','longer-test-session-secret-2026-not-for-production')
|
||||
with TestClient(app) as c:
|
||||
r=c.post('/api/auth/login',json={'username':'admin','password':'strong-admin-2026-secret'})
|
||||
assert r.status_code==200,r.text
|
||||
c.headers.update({'X-SiteForge-CSRF':c.get('/api/auth/me').json()['csrf']})
|
||||
yield c
|
||||
|
||||
def site(c,mode='new'):
|
||||
r=c.post('/api/sites',json={'name':'Factory Sandbox','mode':mode,'theme':'automotive','domain':'https://test.example'})
|
||||
assert r.status_code==201,r.text
|
||||
return r.json()['id']
|
||||
|
||||
def zip_theme(id='brand-x',version='1.0.0',bad=False):
|
||||
from pathlib import Path
|
||||
root=Path(__file__).resolve().parent.parent/'themes'/'corporate'
|
||||
arr=io.BytesIO()
|
||||
with zipfile.ZipFile(arr,'w') as z:
|
||||
data=json.loads((root/'manifest.json').read_text(encoding='utf-8'));data.update({'id':id,'version':version,'name':'V2 Test Brand'})
|
||||
z.writestr('manifest.json',json.dumps(data))
|
||||
z.writestr('theme.css','body{background:#f5f5f5}')
|
||||
z.writestr('page-presets.json',(root/'page-presets.json').read_bytes())
|
||||
if bad:z.writestr('../hack.py','print(1)')
|
||||
return arr.getvalue()
|
||||
|
||||
def test_install_theme_and_pinned_build(factory):
|
||||
sid=site(factory)
|
||||
r=factory.post('/api/factory/themes/install',files={'file':('theme.zip',zip_theme(),'application/zip')})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['id']=='brand-x'
|
||||
assert 'brand-x' in [x.get('id',x.get('theme_id')) for x in factory.get('/api/factory/themes').json()]
|
||||
update=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'brand-x','version':'1.0.0'})
|
||||
assert update.status_code==200,update.text
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert b.status_code==200,b.text
|
||||
info=factory.get(f'/api/sites/{sid}/builds/{b.json()["version"]}/report').json()
|
||||
assert info['theme']=='brand-x' and info['theme_version']=='1.0.0'
|
||||
file=factory.get(f'/preview/{sid}/{b.json()["version"]}/search/').text
|
||||
assert '站内搜索' in file
|
||||
assert factory.get(f'/api/factory/sites/{sid}/theme').json()['version']=='1.0.0'
|
||||
|
||||
def test_theme_rejects_zip_traversal(factory):
|
||||
r=factory.post('/api/factory/themes/install',files={'file':('evil.zip',zip_theme(bad=True),'application/zip')})
|
||||
assert r.status_code==400
|
||||
|
||||
def test_complete_bootstrap_repeated_safe(factory):
|
||||
sid=site(factory)
|
||||
r=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
|
||||
assert len(r['created'])>=7 and '/about/' in r['created']
|
||||
r2=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
|
||||
assert not r2['created']
|
||||
records=factory.get(f'/api/sites/{sid}/entries').json()
|
||||
assert all(r['route'] for r in records)
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
html=factory.get(f'/preview/{sid}/{build["version"]}/').text
|
||||
assert 'sf-site-head' in html and 'factory.css' in html
|
||||
assert factory.get(f'/preview/{sid}/{build["version"]}/assets/site-runtime.js').status_code==200
|
||||
|
||||
def test_mapping_preview_commit_and_cross_site(factory):
|
||||
a=site(factory);b=site(factory)
|
||||
rows=[{'车型名称':'A SUV','品牌':'Alpha','原网址':'/legacy/suv-a.html','续航':'400km'},
|
||||
{'车型名称':'B SUV','品牌':'Beta','原网址':'/legacy/suv-b.html','续航':'500km'}]
|
||||
rules={'title':'车型名称','brand':'品牌','route':'原网址','range':'续航'}
|
||||
mapping=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':True})
|
||||
assert mapping.status_code==200,mapping.text
|
||||
assert mapping.json()['mapped']==2
|
||||
assert factory.get(f'/api/sites/{b}/entries').json()[0]['route']=='/'
|
||||
saved=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':False})
|
||||
assert saved.status_code==200,saved.text
|
||||
assert saved.json()['created']==2
|
||||
imported=factory.get(f'/api/sites/{a}/entries').json()
|
||||
suv=next(r for r in imported if r['title']=='A SUV')
|
||||
assert suv['route']=='/legacy/suv-a.html' and suv['meta']['fields']['brand']=='Alpha'
|
||||
assert all(r['title']!='A SUV' for r in factory.get(f'/api/sites/{b}/entries').json())
|
||||
again=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':False})
|
||||
assert again.json()['skipped']==2
|
||||
|
||||
def test_migration_readiness_wont_greenwash(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
r=factory.get(f'/api/factory/sites/{sid}/migration/readiness').json()
|
||||
assert not r['can_publish']
|
||||
assert any('基线' in x['message'] for x in r['gates'])
|
||||
assert any('构建' in x['message'] for x in r['gates'])
|
||||
assert factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':[{'name':''}],'rules':{'title':'name'},'dry_run':True}).json()['errors']
|
||||
|
||||
def test_editor_supports_responsive_settings(factory):
|
||||
sid=site(factory)
|
||||
homepage=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
blocks=homepage['meta']['blocks'];blocks[0]['style']={'mobile_padding':19,'tablet_padding':34,'hide_mobile':True}
|
||||
r=factory.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
|
||||
assert r.status_code==200,r.text
|
||||
preview=factory.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert preview.status_code==200
|
||||
assert 'siteforge-inline-editor' in preview.text and '--sf-pad:19px' in preview.text
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
css=factory.get(f'/preview/{sid}/{build["version"]}/assets/custom.css').text
|
||||
assert '--sf-pad:19px' in css and 'display:none' in css
|
||||
|
||||
def test_release_requires_separate_approval(factory,monkeypatch):
|
||||
sid=site(factory)
|
||||
factory.post(f'/api/factory/sites/{sid}/bootstrap')
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
bid=build['version']
|
||||
monkeypatch.setenv('SITEFORGE_REQUIRE_RELEASE_APPROVAL','true')
|
||||
monkeypatch.setenv('SITEFORGE_PUBLISH_ROOT',str(db.DATA/'published'))
|
||||
fail=factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local')
|
||||
assert fail.status_code==409,fail.text
|
||||
req=factory.post(f'/api/factory/sites/{sid}/release-requests',json={'build_id':bid})
|
||||
# If QA reports a real error we must not silently approve the release.
|
||||
if req.status_code==409:
|
||||
from app import quality
|
||||
issues=quality.report(sid,bid)
|
||||
assert issues['errors']>0,issues
|
||||
return
|
||||
assert req.status_code==200,req.text
|
||||
rid=req.json()['id']
|
||||
same_user=factory.post(f'/api/factory/sites/{sid}/release-requests/{rid}/review',json={'state':'approved'})
|
||||
assert same_user.status_code==403
|
||||
user=factory.post('/api/enterprise/team/users',json={'username':'review_v2','password':'very-long-review-password-2026','display_name':'Reviewer'}).json()
|
||||
factory.put(f'/api/enterprise/sites/{sid}/members',json={'user_id':user['id'],'role':'reviewer'})
|
||||
factory.post('/api/auth/logout')
|
||||
assert factory.post('/api/auth/login',json={'username':'review_v2','password':'very-long-review-password-2026'}).status_code==200
|
||||
factory.headers.update({'X-SiteForge-CSRF':factory.get('/api/auth/me').json()['csrf']})
|
||||
approved=factory.post(f'/api/factory/sites/{sid}/release-requests/{rid}/review',json={'state':'approved'})
|
||||
assert approved.status_code==200,approved.text
|
||||
# Reviewer cannot run owner's publish action.
|
||||
assert factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local').status_code==403
|
||||
factory.post('/api/auth/logout')
|
||||
factory.post('/api/auth/login',json={'password':'strong-admin-2026-secret'})
|
||||
factory.headers.update({'X-SiteForge-CSRF':factory.get('/api/auth/me').json()['csrf']})
|
||||
assert factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local').status_code==200
|
||||
|
||||
def test_theme_previews_are_rendered_by_trusted_templates(factory):
|
||||
for name in ('automotive','industrial','corporate','minimal'):
|
||||
result=factory.get(f'/api/factory/themes/{name}/preview')
|
||||
assert result.status_code==200,result.text[:500]
|
||||
assert 'sf-site-head' in result.text
|
||||
assert 'assets/theme.css' not in result.text
|
||||
assert 'sf-canvas' in result.text
|
||||
assert factory.get(f'/api/factory/themes/{name}/preview.css').status_code==200
|
||||
|
||||
def test_theme_page_type_previews(factory):
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
for page_type in ('home','about','product','article','brand','listing','contact','standard','notfound'):
|
||||
p=factory.get(f'/api/factory/themes/{theme}/preview',params={'page_type':page_type})
|
||||
assert p.status_code==200,(theme,page_type,p.text[:300])
|
||||
assert 'text/html' in p.headers['content-type']
|
||||
assert factory.get('/api/factory/themes/automotive/preview?page_type=wrong').status_code==400
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Professional theme system integration tests use the real FastAPI and static builder."""
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from test_factory_v2 import factory, site, zip_theme
|
||||
|
||||
THEMES=('automotive','industrial','corporate','minimal')
|
||||
|
||||
def test_four_complete_theme_families_have_distinct_markup(factory):
|
||||
main=[]
|
||||
for theme in THEMES:
|
||||
for page_type in ('home','about','contact','listing','product','article','brand','standard','notfound'):
|
||||
r=factory.get(f'/api/factory/themes/{theme}/preview',params={'page_type':page_type})
|
||||
assert r.status_code==200,(theme,page_type,r.text[:400])
|
||||
soup=BeautifulSoup(r.text,'html.parser')
|
||||
assert soup.find('header',class_='pro-header')
|
||||
assert soup.find('footer',class_='pro-footer')
|
||||
assert len(soup.find_all('h1'))==1,(theme,page_type,len(soup.find_all('h1')))
|
||||
assert 'pro-base.css' in r.text and 'pro.js' in r.text
|
||||
if page_type=='product':assert soup.select_one('.pro-product-layout .pro-specs')
|
||||
if page_type=='listing':assert soup.select_one('[data-pro-filter-search]')
|
||||
if page_type=='article':assert soup.select_one('.pro-article-hero')
|
||||
main.append(factory.get(f'/api/factory/themes/{theme}/preview?page_type=home').text)
|
||||
assert len(set(re.findall(r'class="pro-home-hero [^"]+"',s)[0] for s in main))==4
|
||||
|
||||
|
||||
def test_theme_build_derived_category_brand_search_and_seo(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
r=factory.post(f'/api/sites/{sid}/entries',json={
|
||||
'type':'product','title':'Electric A','route':'/old/product-a.html',
|
||||
'summary':'Long-range sample','seo_title':'Keep Me SEO','seo_description':'Original description',
|
||||
'meta':{'fields':{'brand':'Quanta Motors','category':'SUV','range':'500 km'}}})
|
||||
assert r.status_code==201,r.text
|
||||
for theme in THEMES:
|
||||
selected=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':theme,'version':'1.0.0'})
|
||||
assert selected.status_code==200,selected.text
|
||||
result=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert result.status_code==200,result.text
|
||||
bid=result.json()['version']
|
||||
for url in ['/','/about/','/contact/','/products/','/brands/','/news/','/cases/','/categories/suv/','/brands/quanta-motors/','/search/','/old/product-a.html']:
|
||||
page=factory.get(f'/preview/{sid}/{bid}{url}')
|
||||
assert page.status_code==200,(theme,url,page.text[:200])
|
||||
soup=BeautifulSoup(page.text,'html.parser')
|
||||
assert len(soup.find_all('h1'))==1,(theme,url)
|
||||
assert soup.select_one('.pro-header') and soup.select_one('.pro-footer')
|
||||
product=factory.get(f'/preview/{sid}/{bid}/old/product-a.html').text
|
||||
assert '<title>Keep Me SEO</title>' in product
|
||||
assert 'range' in product.lower() and '500 km' in product
|
||||
assert 'https://test.example/old/product-a.html' in product
|
||||
listing=factory.get(f'/preview/{sid}/{bid}/categories/suv/').text
|
||||
assert 'Electric A' in listing
|
||||
theme_css=factory.get(f'/preview/{sid}/{bid}/assets/pro.css')
|
||||
assert theme_css.status_code==200 and len(theme_css.content)>2000
|
||||
report=factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
|
||||
assert not report['errors'] and not report['broken_links'] and not report['missing_local_images']
|
||||
|
||||
|
||||
def test_builtin_version_pin_is_real(factory):
|
||||
sid=site(factory)
|
||||
versions=factory.get('/api/factory/themes/automotive/versions').json()
|
||||
assert {v['version'] for v in versions}=={'1.1.0','1.0.0','0.3.0'}
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'0.3.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
old=factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
assert 'pro-auto-hero' not in old
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'1.0.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
new=factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
assert 'pro-auto-hero' in new
|
||||
|
||||
|
||||
def test_custom_theme_package_uses_trusted_layout_and_css(factory):
|
||||
sid=site(factory)
|
||||
raw=io.BytesIO(zip_theme('custom-v3','2.0.0'))
|
||||
data=io.BytesIO()
|
||||
with zipfile.ZipFile(raw) as old,zipfile.ZipFile(data,'w') as new:
|
||||
for fn in old.namelist():
|
||||
content=old.read(fn)
|
||||
if fn=='manifest.json':
|
||||
obj=json.loads(content);obj['layout_family']='industrial'
|
||||
content=json.dumps(obj).encode()
|
||||
if fn=='theme.css':content=b':root{--pro-accent:#dd5500} .pro-card{border-width:2px}'
|
||||
new.writestr(fn,content)
|
||||
install=factory.post('/api/factory/themes/install',files={'file':('theme.zip',data.getvalue(),'application/zip')})
|
||||
assert install.status_code==200,install.text
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'custom-v3','version':'2.0.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
assert 'pro-industrial-hero' in factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
css=factory.get(f'/preview/{sid}/{b["version"]}/assets/theme-overrides.css').text
|
||||
assert '--pro-accent:#dd5500' in css
|
||||
pkg=factory.get('/api/factory/themes/custom-v3/package?version=2.0.0')
|
||||
assert pkg.status_code==200
|
||||
with zipfile.ZipFile(io.BytesIO(pkg.content)) as z:
|
||||
assert {'manifest.json','theme.css','page-presets.json'}<=set(z.namelist())
|
||||
|
||||
|
||||
def test_static_responsive_and_search_facilities(factory):
|
||||
sid=site(factory)
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
bid=b['version']
|
||||
css=factory.get(f'/preview/{sid}/{bid}/assets/pro-base.css').text
|
||||
js=factory.get(f'/preview/{sid}/{bid}/assets/pro.js').text
|
||||
assert '@media(max-width:560px)' in css
|
||||
assert 'aria-expanded' in js and 'data-pro-filter-brand' in js
|
||||
assert 'search-index.json' in factory.get(f'/preview/{sid}/{bid}/assets/site-runtime.js').text
|
||||
assert 'noindex' in factory.get(f'/preview/{sid}/{bid}/search/').text
|
||||
|
||||
|
||||
def test_all_fifty_six_page_templates_and_live_gallery(factory):
|
||||
base=Path(__file__).resolve().parent.parent/'themes'
|
||||
expected={'home','about','contact','listing','category','product','brand','article','case','service','faq','standard','search','notfound'}
|
||||
for theme in THEMES:
|
||||
assert {p.stem for p in (base/theme/'pages').glob('*.html')}==expected
|
||||
for page_type in ('case','service','faq','category'):
|
||||
r=factory.get(f'/api/factory/themes/{theme}/preview?page_type={page_type}')
|
||||
assert r.status_code==200,(theme,page_type,r.text[:300])
|
||||
page=factory.get('/theme-gallery')
|
||||
assert page.status_code==200 and 'iframe' in page.text and 'page_type' in page.text
|
||||
@@ -0,0 +1,16 @@
|
||||
{% macro cover(item, kind='product') %}
|
||||
{% if item.cover %}<img class="pro-photo" src="{{ safe_image(item.cover) }}" alt="{{ item.title }}" loading="lazy">
|
||||
{% else %}<div class="pro-art pro-art-{{ kind }}"><span class="pro-art-ring"></span><span class="pro-art-center">{{ (item.title or 'SF')[:2] }}</span><span class="pro-art-label">{{ kind|upper }}</span></div>{% endif %}
|
||||
{% endmacro %}
|
||||
{% macro card(item, kind='product', position=0) %}
|
||||
<a class="pro-card pro-card-{{ kind }}" href="{{ safe_href(item.route) }}" data-category="{{ fields_of(item).get('category','') }}" data-brand="{{ fields_of(item).get('brand','') }}">
|
||||
<div class="pro-card-cover">{{ cover(item,kind) }}</div>
|
||||
<div class="pro-card-body"><div class="pro-card-meta"><span>{{ fields_of(item).get('brand') or fields_of(item).get('category') or labels.get(item.type,'PROJECT') }}</span><span>{{ '%02d'|format(position+1) }}</span></div>
|
||||
<h3>{{ item.title }}</h3><p>{{ (item.summary or '')[:128] }}</p><span class="pro-card-link">查看详情 <span aria-hidden="true">↗</span></span></div>
|
||||
</a>
|
||||
{% endmacro %}
|
||||
{% macro grid(items,kind='product') %}<div class="pro-grid pro-grid-{{ kind }}">{% for item in items %}{{ card(item,kind,loop.index0) }}{% else %}<div class="pro-empty">该栏目还没有发布内容。可在网站工厂的内容中心添加内容并重新构建。</div>{% endfor %}</div>{% endmacro %}
|
||||
{% macro section_title(eyebrow,heading,href='') %}<div class="pro-section-head"><div><span class="pro-eyebrow">{{ eyebrow }}</span><h2>{{ heading }}</h2></div>{% if href %}<a class="pro-text-link" href="{{ href }}">浏览全部 <span>↗</span></a>{% endif %}</div>{% endmacro %}
|
||||
{% macro filters() %}<div class="pro-filter-panel"><label><span>搜索</span><input type="search" data-pro-filter-search placeholder="按名称或内容筛选"></label><label><span>品牌</span><select data-pro-filter-brand><option value="">全部品牌</option></select></label><label><span>分类</span><select data-pro-filter-category><option value="">全部分类</option></select></label><span class="pro-filter-count" data-pro-filter-count role="status"></span></div>{% endmacro %}
|
||||
{% macro footer_links(nav) %}<ul>{% for item in nav[:7] %}<li><a href="{{item.route}}">{{item.title}}</a></li>{% endfor %}</ul>{% endmacro %}
|
||||
{% macro body_copy(content,page) %}{% if content %}<div class="pro-rich">{{ content }}</div>{% elif page.summary %}<p class="pro-lead">{{ page.summary }}</p>{% else %}<p class="pro-muted">可在管理后台完善本页的内容。</p>{% endif %}{% endmacro %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"id": "automotive",
|
||||
"name": "汽车与出行",
|
||||
"description": "商业主题体验升级:独立视觉素材、响应式排版、产品列表筛选、多级导航、可访问性与交付检查(需真实内容验收)",
|
||||
"version": "1.1.0",
|
||||
"style": "luxury",
|
||||
"entry": "pro.html",
|
||||
"styles": [
|
||||
"theme.css",
|
||||
"blocks.css"
|
||||
],
|
||||
"page_templates": [
|
||||
"home",
|
||||
"about",
|
||||
"contact",
|
||||
"listing",
|
||||
"category",
|
||||
"product",
|
||||
"article",
|
||||
"brand",
|
||||
"case",
|
||||
"service",
|
||||
"faq",
|
||||
"standard",
|
||||
"notfound"
|
||||
],
|
||||
"blocks": [
|
||||
"hero",
|
||||
"richtext",
|
||||
"features",
|
||||
"stats",
|
||||
"collection",
|
||||
"image_text",
|
||||
"gallery",
|
||||
"faq",
|
||||
"cta",
|
||||
"attributes",
|
||||
"spacer"
|
||||
],
|
||||
"responsive": true,
|
||||
"auto_binding": true,
|
||||
"professional_layout": true,
|
||||
"commercial_ui": true,
|
||||
"commercial_features": [
|
||||
"responsive-design",
|
||||
"product-detail",
|
||||
"brand-collections",
|
||||
"gallery",
|
||||
"content-facets",
|
||||
"mega-navigation",
|
||||
"contact-integration",
|
||||
"readiness-audit"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"home": [
|
||||
{
|
||||
"id": "ff2755fabe",
|
||||
"type": "hero",
|
||||
"visible": true,
|
||||
"title": "探索全球汽车新可能",
|
||||
"subtitle": "THE WORLD OF MOBILITY",
|
||||
"description": "{{site.tagline}}",
|
||||
"button": "浏览车型",
|
||||
"href": "/product/",
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"id": "f997b5975c",
|
||||
"type": "collection",
|
||||
"visible": true,
|
||||
"title": "精选车型",
|
||||
"subtitle": "EXPLORE OUR COLLECTION",
|
||||
"source": "product",
|
||||
"limit": 6,
|
||||
"columns": 3,
|
||||
"button": "查看全部",
|
||||
"href": "/product/"
|
||||
},
|
||||
{
|
||||
"id": "b79e9bdeee",
|
||||
"type": "stats",
|
||||
"visible": true,
|
||||
"title": "值得信赖的服务",
|
||||
"items": [
|
||||
{
|
||||
"value": "50,000+",
|
||||
"label": "服务案例"
|
||||
},
|
||||
{
|
||||
"value": "70+",
|
||||
"label": "市场覆盖"
|
||||
},
|
||||
{
|
||||
"value": "30+",
|
||||
"label": "合作伙伴"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fd3475d4a9",
|
||||
"type": "image_text",
|
||||
"visible": true,
|
||||
"title": "专业采购与全球交付",
|
||||
"subtitle": "GLOBAL DELIVERY",
|
||||
"description": "为海外市场提供适合的汽车采购与服务解决方案。",
|
||||
"image": "",
|
||||
"image_side": "right",
|
||||
"button": "了解更多",
|
||||
"href": "/about/"
|
||||
},
|
||||
{
|
||||
"id": "5f159c1114",
|
||||
"type": "cta",
|
||||
"visible": true,
|
||||
"title": "开启下一次合作",
|
||||
"subtitle": "告诉我们您的需求,我们将提供适合的方案。",
|
||||
"button": "联系我们",
|
||||
"href": "/contact/"
|
||||
}
|
||||
],
|
||||
"about": [
|
||||
{
|
||||
"id": "3b57a5c80a",
|
||||
"type": "image_text",
|
||||
"visible": true,
|
||||
"title": "认识我们",
|
||||
"subtitle": "OUR APPROACH",
|
||||
"description": "展示业务流程和服务理念。",
|
||||
"image": "",
|
||||
"image_side": "right",
|
||||
"button": "了解更多",
|
||||
"href": "/about/"
|
||||
},
|
||||
{
|
||||
"id": "c7aaebcbc8",
|
||||
"type": "stats",
|
||||
"visible": true,
|
||||
"title": "我们的实力",
|
||||
"items": [
|
||||
{
|
||||
"value": "50,000+",
|
||||
"label": "服务案例"
|
||||
},
|
||||
{
|
||||
"value": "70+",
|
||||
"label": "市场覆盖"
|
||||
},
|
||||
{
|
||||
"value": "30+",
|
||||
"label": "合作伙伴"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "f0d35431bc",
|
||||
"type": "features",
|
||||
"visible": true,
|
||||
"title": "我们的优势",
|
||||
"items": [
|
||||
{
|
||||
"title": "专业服务",
|
||||
"text": "从咨询到交付"
|
||||
},
|
||||
{
|
||||
"title": "品质保障",
|
||||
"text": "严格的质量流程"
|
||||
},
|
||||
{
|
||||
"title": "全球网络",
|
||||
"text": "连接不同市场"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"contact": [
|
||||
{
|
||||
"id": "a21100b5fd",
|
||||
"type": "richtext",
|
||||
"visible": true,
|
||||
"title": "联系我们",
|
||||
"body": "<p>请填写企业联系方式与地址。</p>"
|
||||
},
|
||||
{
|
||||
"id": "360304f1f1",
|
||||
"type": "cta",
|
||||
"visible": true,
|
||||
"title": "欢迎联系",
|
||||
"subtitle": "告诉我们您的需求,我们将提供适合的方案。",
|
||||
"button": "联系我们",
|
||||
"href": "/contact/"
|
||||
}
|
||||
],
|
||||
"listing": [
|
||||
{
|
||||
"id": "e8c990ef8b",
|
||||
"type": "collection",
|
||||
"visible": true,
|
||||
"title": "所有在售车型",
|
||||
"subtitle": "为客户提供适合的解决方案",
|
||||
"source": "product",
|
||||
"limit": 24,
|
||||
"columns": 3,
|
||||
"button": "查看全部",
|
||||
"href": "/product/"
|
||||
}
|
||||
],
|
||||
"product": [
|
||||
{
|
||||
"id": "7dfe1376b0",
|
||||
"type": "image_text",
|
||||
"visible": true,
|
||||
"title": "产品介绍",
|
||||
"subtitle": "OUR APPROACH",
|
||||
"description": "{{page.summary}}",
|
||||
"image": "{{page.cover}}",
|
||||
"image_side": "right",
|
||||
"button": "了解更多",
|
||||
"href": "/about/"
|
||||
},
|
||||
{
|
||||
"id": "c3a67474f4",
|
||||
"type": "attributes",
|
||||
"visible": true,
|
||||
"title": "产品规格与参数"
|
||||
},
|
||||
{
|
||||
"id": "29b39f194a",
|
||||
"type": "richtext",
|
||||
"visible": true,
|
||||
"title": "产品详情",
|
||||
"body": "{{page.body}}"
|
||||
},
|
||||
{
|
||||
"id": "70d24b5293",
|
||||
"type": "cta",
|
||||
"visible": true,
|
||||
"title": "开启新的合作",
|
||||
"subtitle": "告诉我们您的需求,我们将提供适合的方案。",
|
||||
"button": "联系我们",
|
||||
"href": "/contact/"
|
||||
}
|
||||
],
|
||||
"article": [
|
||||
{
|
||||
"id": "f283231732",
|
||||
"type": "richtext",
|
||||
"visible": true,
|
||||
"title": "{{page.title}}",
|
||||
"body": "{{page.body}}"
|
||||
}
|
||||
],
|
||||
"brand": [
|
||||
{
|
||||
"id": "e08f6ad68f",
|
||||
"type": "image_text",
|
||||
"visible": true,
|
||||
"title": "{{page.title}}",
|
||||
"subtitle": "OUR APPROACH",
|
||||
"description": "{{page.summary}}",
|
||||
"image": "{{page.cover}}",
|
||||
"image_side": "right",
|
||||
"button": "了解更多",
|
||||
"href": "/about/"
|
||||
},
|
||||
{
|
||||
"id": "0f3689e9f0",
|
||||
"type": "collection",
|
||||
"visible": true,
|
||||
"title": "品牌产品",
|
||||
"subtitle": "为客户提供适合的解决方案",
|
||||
"source": "product",
|
||||
"limit": 6,
|
||||
"columns": 3,
|
||||
"button": "查看全部",
|
||||
"href": "/product/"
|
||||
}
|
||||
],
|
||||
"standard": [
|
||||
{
|
||||
"id": "d47d90fe91",
|
||||
"type": "richtext",
|
||||
"visible": true,
|
||||
"title": "{{page.title}}",
|
||||
"body": "{{page.body}}"
|
||||
}
|
||||
],
|
||||
"notfound": [
|
||||
{
|
||||
"id": "8b39fc46a4",
|
||||
"type": "cta",
|
||||
"visible": true,
|
||||
"title": "页面暂时无法访问",
|
||||
"subtitle": "告诉我们您的需求,我们将提供适合的方案。",
|
||||
"button": "返回首页",
|
||||
"href": "/"
|
||||
}
|
||||
],
|
||||
"case": [
|
||||
{
|
||||
"type": "image_text",
|
||||
"title": "{{page.title}}",
|
||||
"image": "{{page.cover}}",
|
||||
"description": "{{page.summary}}"
|
||||
},
|
||||
{
|
||||
"type": "richtext",
|
||||
"title": "项目概述",
|
||||
"body": "{{page.body}}"
|
||||
}
|
||||
],
|
||||
"service": [
|
||||
{
|
||||
"type": "richtext",
|
||||
"title": "{{page.title}}",
|
||||
"body": "{{page.body}}"
|
||||
},
|
||||
{
|
||||
"type": "cta",
|
||||
"title": "了解更多",
|
||||
"href": "/contact/"
|
||||
}
|
||||
],
|
||||
"faq": [
|
||||
{
|
||||
"type": "faq",
|
||||
"title": "常见问题",
|
||||
"items": []
|
||||
}
|
||||
],
|
||||
"category": [
|
||||
{
|
||||
"type": "collection",
|
||||
"title": "分类产品",
|
||||
"source": "product",
|
||||
"limit": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<section class="pro-inner-hero pro-info-hero"><div class="pro-wrap"><span class="pro-eyebrow">{{ 'GET IN TOUCH' if kind=='contact' else ('OUR STORY' if kind=='about' else 'EXPLORE') }}</span><h1>{{page.title}}</h1><p>{{page.summary}}</p></div></section>
|
||||
<section class="pro-section pro-info-section"><div class="pro-wrap pro-info-grid"><div><span class="pro-eyebrow">{{'CONNECT / CONTACT' if kind=='contact' else 'ABOUT / DISCOVER'}}</span><h2>{{'让我们开始沟通。' if kind=='contact' else page.title}}</h2>{% if kind=='contact' and footer_email %}<a class="pro-contact-email" href="mailto:{{footer_email}}">{{footer_email}} ↗</a>{% elif kind=='contact' %}<p class="pro-muted">请先在网站管理后台填写正式联系邮箱。</p>{% endif %}</div><div class="pro-info-copy">{{body_copy(content,page)}}{% if page.cover %}<img class="pro-info-photo" src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% endif %}</div></div></section>
|
||||
@@ -0,0 +1 @@
|
||||
<section class="pro-article-hero"><div class="pro-wrap pro-article-width"><span class="pro-eyebrow">LATEST NEWS / INSIGHTS</span><h1>{{page.title}}</h1><p>{{page.summary}}</p>{% if fields.get('publish_date') %}<time>{{fields.get('publish_date')}}</time>{% endif %}</div></section><article class="pro-wrap pro-article-content">{% if page.cover %}<img class="pro-article-cover" src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% endif %}{{body_copy(content,page)}}<a class="pro-text-link" href="/news/">← 返回新闻</a></article>
|
||||
@@ -0,0 +1 @@
|
||||
<section class="pro-inner-hero pro-brand-hero"><div class="pro-wrap pro-brand-hero-grid"><div><span class="pro-eyebrow">BRAND PROFILE / PARTNERS</span><h1>{{page.title}}</h1><p>{{page.summary}}</p></div><div class="pro-brand-media">{{cover(page,'brand')}}</div></div></section><section class="pro-section"><div class="pro-wrap">{{body_copy(content,page)}}{% if related %}{{section_title('PRODUCT LINE','相关产品','/products/')}}{{grid(related,'product')}}{% endif %}</div></section>
|
||||
@@ -0,0 +1 @@
|
||||
<section class="pro-inner-hero"><div class="pro-wrap"><span class="pro-eyebrow">SELECTED WORK / PROJECT</span><h1>{{page.title}}</h1><p>{{page.summary}}</p></div></section><section class="pro-section"><div class="pro-wrap pro-case-detail">{% if page.cover %}<img src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% endif %}<div class="pro-case-meta">{% if fields.get('location') %}<span>项目地点<br><strong>{{fields.get('location')}}</strong></span>{% endif %}{% if fields.get('industry') %}<span>所属行业<br><strong>{{fields.get('industry')}}</strong></span>{% endif %}</div>{{body_copy(content,page)}}<a class="pro-text-link" href="/cases/">浏览更多案例 →</a></div></section>
|
||||
@@ -0,0 +1,2 @@
|
||||
<section class="pro-inner-hero pro-listing-hero"><div class="pro-wrap"><span class="pro-eyebrow">DISCOVER / {{'CATEGORY' if kind=='category' else 'COLLECTION'}}</span><h1>{{page.title}}</h1><p>{{page.summary or '查看此栏目的全部已发布内容。'}}</p><div class="pro-breadcrumb"><a href="/">首页</a><span>/</span><strong>{{page.title}}</strong></div></div></section>
|
||||
<section class="pro-section pro-list-section"><div class="pro-wrap">{{ filters() }}<div class="pro-filter-scope">{{grid(listing,'catalog')}}</div></div></section>
|
||||
@@ -0,0 +1,7 @@
|
||||
<section class="pro-inner-hero pro-info-hero"><div class="pro-wrap"><span class="pro-eyebrow">{{ 'GET IN TOUCH' if kind=='contact' else ('OUR STORY' if kind=='about' else 'EXPLORE') }}</span><h1>{{page.title}}</h1><p>{{page.summary}}</p></div></section>
|
||||
<section class="pro-section pro-info-section"><div class="pro-wrap pro-info-grid"><div><span class="pro-eyebrow">{{'CONNECT / CONTACT' if kind=='contact' else 'ABOUT / DISCOVER'}}</span><h2>{{'让我们开始沟通。' if kind=='contact' else page.title}}</h2>{% if kind=='contact' and footer_email %}<a class="pro-contact-email" href="mailto:{{footer_email}}">{{footer_email}} ↗</a>{% elif kind=='contact' %}<p class="pro-muted">请先在网站管理后台填写正式联系邮箱。</p>{% endif %}</div><div class="pro-info-copy">{{body_copy(content,page)}}{% if page.cover %}<img class="pro-info-photo" src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% endif %}</div></div></section>
|
||||
|
||||
{% if form_endpoint %}
|
||||
<section class="pro-section"><div class="pro-wrap pro-inquiry-wrap"><div><span class="pro-eyebrow">CONTACT FORM</span><h2>提交业务咨询</h2><p>提交内容将直接发送到网站管理员配置的外部表单服务。</p></div>
|
||||
<form class="pro-inquiry" action="{{form_endpoint}}" method="POST"><label>姓名 <input name="name" autocomplete="name" required maxlength="100"></label><label>邮箱 <input name="email" type="email" autocomplete="email" required maxlength="180"></label><label>咨询内容 <textarea name="message" required minlength="6" maxlength="5000"></textarea></label><input name="website" tabindex="-1" autocomplete="off" class="pro-honeypot" aria-hidden="true"><button type="submit" class="pro-button">提交咨询 ↗</button><p>表单提交由外部服务处理,请确认其隐私政策及垃圾信息过滤设置。</p></form></div></section>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,2 @@
|
||||
<section class="pro-inner-hero pro-info-hero"><div class="pro-wrap"><span class="pro-eyebrow">{{ 'GET IN TOUCH' if kind=='contact' else ('OUR STORY' if kind=='about' else 'EXPLORE') }}</span><h1>{{page.title}}</h1><p>{{page.summary}}</p></div></section>
|
||||
<section class="pro-section pro-info-section"><div class="pro-wrap pro-info-grid"><div><span class="pro-eyebrow">{{'CONNECT / CONTACT' if kind=='contact' else 'ABOUT / DISCOVER'}}</span><h2>{{'让我们开始沟通。' if kind=='contact' else page.title}}</h2>{% if kind=='contact' and footer_email %}<a class="pro-contact-email" href="mailto:{{footer_email}}">{{footer_email}} ↗</a>{% elif kind=='contact' %}<p class="pro-muted">请先在网站管理后台填写正式联系邮箱。</p>{% endif %}</div><div class="pro-info-copy">{{body_copy(content,page)}}{% if page.cover %}<img class="pro-info-photo" src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% endif %}</div></div></section>
|
||||
@@ -0,0 +1,4 @@
|
||||
<section class="pro-home-hero pro-auto-hero"><div class="pro-wrap pro-home-grid"><div class="pro-hero-copy"><span class="pro-eyebrow">01 / THE WORLD IN MOTION</span><h1>{{page.title if page.title not in (site.name,'首页','Home') else site.name}}<em> MOVE BEYOND.</em></h1><p>{{page.summary or site.tagline or '探索企业产品与解决方案。'}}</p><div class="pro-hero-actions"><a class="pro-button" href="/products/">探索车型 <span>↗</span></a><a class="pro-link-light" href="/about/">了解企业 →</a></div></div><div class="pro-hero-art">{% if page.cover %}<img src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% else %}<div class="pro-abstract-car"><span>THE NEXT CHAPTER</span><div class="auto-wheel a"></div><div class="auto-wheel b"></div></div>{% endif %}<b>DRIVE / 01</b></div></div><div class="pro-wrap pro-hero-bottom"><span>DESIGN / TRUST / DELIVERY</span><span>SCROLL TO DISCOVER ↓</span></div></section>
|
||||
{% if showcase %}<section class="pro-section pro-featured"><div class="pro-wrap">{{section_title('02 / COLLECTION','探索精选车型','/products/')}}{{grid(showcase,'vehicle')}}</div></section>{% endif %}
|
||||
<section class="pro-section pro-statement"><div class="pro-wrap pro-statement-layout"><span class="pro-eyebrow">THE COMPANY</span><div><h2>把产品带向更广阔的市场。</h2><p>{{site.tagline or '在这里展示企业独特的产品与服务能力。'}}</p><a class="pro-text-link" href="/about/">认识我们 ↗</a></div></div></section>
|
||||
{% if brands %}<section class="pro-section pro-brand-section"><div class="pro-wrap">{{section_title('03 / BRANDS','品牌与合作伙伴','/brands/')}}<div class="pro-brand-pills">{% for b in brands[:12] %}<a href="{{b.url}}">{{b.name}} ↗</a>{% endfor %}</div></div></section>{% endif %}
|
||||
@@ -0,0 +1,2 @@
|
||||
<section class="pro-inner-hero pro-listing-hero"><div class="pro-wrap"><span class="pro-eyebrow">DISCOVER / {{'CATEGORY' if kind=='category' else 'COLLECTION'}}</span><h1>{{page.title}}</h1><p>{{page.summary or '查看此栏目的全部已发布内容。'}}</p><div class="pro-breadcrumb"><a href="/">首页</a><span>/</span><strong>{{page.title}}</strong></div></div></section>
|
||||
<section class="pro-section pro-list-section"><div class="pro-wrap">{{ filters() }}<div class="pro-filter-scope">{{grid(listing,'catalog')}}</div></div></section>
|
||||
@@ -0,0 +1 @@
|
||||
<section class="pro-404 pro-wrap"><span>ERROR / 404</span><h1>404</h1><p>抱歉,你访问的页面不存在,或者已被移动。</p><a class="pro-button" href="/">返回首页 ↗</a></section>
|
||||
@@ -0,0 +1,3 @@
|
||||
<section class="pro-section pro-product-layout"><div class="pro-wrap"><div class="pro-breadcrumb"><a href="/">首页</a><span>/</span><a href="/products/">产品中心</a><span>/</span><strong>{{page.title}}</strong></div>
|
||||
<div class="pro-product-hero"><div class="pro-product-gallery"><div class="pro-product-media">{{cover(page,'product')}}</div>{% if gallery|length>1 %}<div class="pro-gallery-thumbs" aria-label="产品图片画廊">{% for photo in gallery %}<button type="button" data-gallery-image="{{photo}}" aria-label="查看第{{loop.index}}张图片"><img src="{{photo}}" alt="{{page.title}} · 图{{loop.index}}" loading="lazy"></button>{% endfor %}</div>{% endif %}</div><div class="pro-product-summary"><span class="pro-eyebrow">PRODUCT DETAILS / {{fields.get('brand') or 'COLLECTION'}}</span><h1>{{page.title}}</h1><p>{{page.summary or '在此查看产品详细资料。'}}</p>{% if fields.get('brand') %}<p class="pro-product-brand">品牌 · {{fields.get('brand')}}</p>{% endif %}<a class="pro-button" href="/contact/">咨询该产品 <span>↗</span></a></div></div>
|
||||
<div class="pro-detail-layout"><div><span class="pro-eyebrow">OVERVIEW</span><h2>产品介绍</h2>{{body_copy(content,page)}}</div><div><span class="pro-eyebrow">SPECIFICATIONS</span><h2>规格参数</h2><div class="pro-specs">{% for row in specs %}<div><span>{{row.label}}</span><strong>{{row.value}}</strong></div>{% else %}<p class="pro-muted">尚未填写详细参数。</p>{% endfor %}</div></div></div></div></section>
|
||||
@@ -0,0 +1,2 @@
|
||||
<section class="pro-inner-hero"><div class="pro-wrap"><span class="pro-eyebrow">SEARCH / CONTENT</span><h1>站内搜索</h1><p>搜索本网站的产品、品牌、新闻和案例。</p></div></section>
|
||||
<main id="main-content" class="sf-search-page pro-wrap"><label for="sf-search">输入关键词</label><input type="search" id="sf-search" placeholder="搜索产品、品牌、新闻、案例……" autofocus><div id="sf-search-count" aria-live="polite"></div><div id="sf-search-results"></div></main>
|
||||
@@ -0,0 +1,2 @@
|
||||
<section class="pro-inner-hero pro-info-hero"><div class="pro-wrap"><span class="pro-eyebrow">{{ 'GET IN TOUCH' if kind=='contact' else ('OUR STORY' if kind=='about' else 'EXPLORE') }}</span><h1>{{page.title}}</h1><p>{{page.summary}}</p></div></section>
|
||||
<section class="pro-section pro-info-section"><div class="pro-wrap pro-info-grid"><div><span class="pro-eyebrow">{{'CONNECT / CONTACT' if kind=='contact' else 'ABOUT / DISCOVER'}}</span><h2>{{'让我们开始沟通。' if kind=='contact' else page.title}}</h2>{% if kind=='contact' and footer_email %}<a class="pro-contact-email" href="mailto:{{footer_email}}">{{footer_email}} ↗</a>{% elif kind=='contact' %}<p class="pro-muted">请先在网站管理后台填写正式联系邮箱。</p>{% endif %}</div><div class="pro-info-copy">{{body_copy(content,page)}}{% if page.cover %}<img class="pro-info-photo" src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% endif %}</div></div></section>
|
||||
@@ -0,0 +1,2 @@
|
||||
<section class="pro-inner-hero pro-info-hero"><div class="pro-wrap"><span class="pro-eyebrow">{{ 'GET IN TOUCH' if kind=='contact' else ('OUR STORY' if kind=='about' else 'EXPLORE') }}</span><h1>{{page.title}}</h1><p>{{page.summary}}</p></div></section>
|
||||
<section class="pro-section pro-info-section"><div class="pro-wrap pro-info-grid"><div><span class="pro-eyebrow">{{'CONNECT / CONTACT' if kind=='contact' else 'ABOUT / DISCOVER'}}</span><h2>{{'让我们开始沟通。' if kind=='contact' else page.title}}</h2>{% if kind=='contact' and footer_email %}<a class="pro-contact-email" href="mailto:{{footer_email}}">{{footer_email}} ↗</a>{% elif kind=='contact' %}<p class="pro-muted">请先在网站管理后台填写正式联系邮箱。</p>{% endif %}</div><div class="pro-info-copy">{{body_copy(content,page)}}{% if page.cover %}<img class="pro-info-photo" src="{{safe_image(page.cover)}}" alt="{{page.title}}">{% endif %}</div></div></section>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user