Files
cms-SiteForge/app/site_design_v87.py
T
浅念 601451f22b upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:31:03 +08:00

279 lines
15 KiB
Python

"""Whole-site header/footer/navigation/responsive drafts and publications.
Only site-design `publish` changes the source of static builds. A draft is
versioned and fenced against edits to the underlying site settings.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import uuid
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise
from .core import route_path
router=APIRouter(prefix='/api/sites',tags=['V8.7 site design'])
DEFAULTS={
'header':{'announcement':'','show_announcement':False,'show_search':True,
'show_cta':True,'cta_text':'联系我们','cta_href':'/contact/'},
'footer':{'title':'','description':'','email':'','show_nav':True},
'nav':[],
'mobile_tabs':{'enabled':False,'items':[]},
'tokens':{'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,
'container_width':1200,'button_style':'rounded','header_style':'standard'},
'responsive':{'tablet_gap':64,'mobile_gap':40,'mobile_hide_announcement':True,
'mobile_hide_footer_links':False,'mobile_container_padding':20},
}
def require_legacy_enabled():
"""Block historical direct-to-live design writes unless explicitly enabled.
Operators migrating an old automation may temporarily enable this escape
hatch; the V8.7 editor never uses it.
"""
if os.environ.get('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE') != '1':
raise HTTPException(409,'旧版直接修改正式设计的接口已停用;请使用 site-design 或 studio-draft 草稿发布流程')
def setup():
with db.connect() as c:
c.execute('''CREATE TABLE IF NOT EXISTS site_design_drafts (
site_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, base_hash TEXT NOT NULL,
config_json TEXT NOT NULL, updated_at TEXT NOT NULL)''')
c.execute('''CREATE TABLE IF NOT EXISTS site_design_publications (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, actor TEXT NOT NULL,
revision INTEGER NOT NULL, old_config_json TEXT NOT NULL,
new_config_json TEXT NOT NULL, published_at TEXT NOT NULL)''')
def _site(site_id):
row=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not row:raise HTTPException(404,'网站不存在')
return row
def _settings(row):
return json.loads(row['settings_json'] or '{}')
def _hash(settings):
return hashlib.sha256(json.dumps(settings,sort_keys=True,ensure_ascii=False).encode()).hexdigest()
def _url(value):
if not isinstance(value,str) or len(value)>350 or any(x in value for x in ('<','>','"',"'",'\\','\n','\r','\t')):
raise HTTPException(400,'不安全的网址')
if value.startswith('/') and not value.startswith('//'):
try:return route_path(value)
except ValueError:raise HTTPException(400,'不合法的站内链接')
# External links are intentionally not allowed inside site chrome; the
# enterprise's external CTA can be built with a vetted form-link block.
raise HTTPException(400,'全站导航只允许以 / 开头的站内路径')
def _nav(rows,depth=0,counter=None):
if counter is None:counter=[0]
if not isinstance(rows,list) or len(rows)>18:raise HTTPException(400,'导航项过多')
result=[]
for x in rows:
counter[0]+=1
if counter[0]>48 or not isinstance(x,dict):raise HTTPException(400,'导航结构不合法')
if set(x)-{'title','route','children'}:raise HTTPException(400,'导航出现未支持的字段')
title=x.get('title')
if not isinstance(title,str) or not title.strip() or len(title)>48:raise HTTPException(400,'导航名称不合法')
children=x.get('children',[])
if depth==2 and children:raise HTTPException(400,'最多支持三级导航')
result.append({'title':title.strip(),'route':_url(x.get('route','/')),
'children':_nav(children,depth+1,counter) if depth<2 else []})
return result
def _mobile_tab_url(value):
"""Allow site-internal routes plus safe, explicit user-facing contact links."""
from urllib.parse import urlsplit
if not isinstance(value,str) or not value or len(value)>350 or any(c in value for c in ('<','>','"',"'",'\\','\n','\r','\t')):
raise HTTPException(400,'底部导航网址不安全')
if value.startswith('/') and not value.startswith('//'):
return _url(value)
if re.fullmatch(r'tel:\+?[0-9()\- ]{5,30}',value):
return value
if re.fullmatch(r'mailto:[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}',value):
return value
parsed=urlsplit(value)
if parsed.scheme=='https' and parsed.hostname and not parsed.username and not parsed.password and not any(c.isspace() for c in value):
return value
raise HTTPException(400,'仅支持站内路径、HTTPS、电话或邮箱链接')
def validate(raw):
if not isinstance(raw,dict) or set(raw)-set(DEFAULTS):raise HTTPException(400,'未知的全站设计设置')
normalized={}
for group in ('header','footer','responsive'):
val=raw.get(group,{})
if not isinstance(val,dict) or set(val)-set(DEFAULTS[group]):raise HTTPException(400,'无效的全站设置组: '+group)
normalized[group]={**DEFAULTS[group],**val}
header=normalized['header'];footer=normalized['footer'];mobile=normalized['responsive']
for key in ('show_announcement','show_search','show_cta'):
if type(header[key]) is not bool:raise HTTPException(400,'无效的页眉开关')
for key in ('show_nav',):
if type(footer[key]) is not bool:raise HTTPException(400,'无效的页脚开关')
for key in ('mobile_hide_announcement','mobile_hide_footer_links'):
if type(mobile[key]) is not bool:raise HTTPException(400,'无效的移动端开关')
for field,cap in [('announcement',140),('cta_text',40)]:
if not isinstance(header[field],str) or len(header[field])>cap:raise HTTPException(400,'页眉文字超限')
for field,cap in [('title',120),('description',650),('email',150)]:
if not isinstance(footer[field],str) or len(footer[field])>cap:raise HTTPException(400,'页脚文字超限')
header['cta_href']=_url(header['cta_href'])
for field,lo,hi in [('tablet_gap',20,120),('mobile_gap',16,100),('mobile_container_padding',8,48)]:
n=mobile[field]
if type(n) is not int or not lo<=n<=hi:raise HTTPException(400,'设备间距超出范围: '+field)
tokens=raw.get('tokens',{})
if not isinstance(tokens,dict) or set(tokens)-set(DEFAULTS['tokens']):
raise HTTPException(400,'未知设计规范字段')
tokens={**DEFAULTS['tokens'],**tokens}
for field,options in [('font',('system','serif','modern')),
('button_style',('rounded','square','pill')),('header_style',('standard','compact'))]:
if tokens[field] not in options:raise HTTPException(400,'不支持的设计规范: '+field)
for field,lo,hi in [('heading_scale',.75,1.5),('radius',0,30),
('section_spacing',32,140),('container_width',900,1600)]:
v=tokens[field]
if type(v) not in (float,int) or not lo<=v<=hi:raise HTTPException(400,'设计变量超出范围: '+field)
normalized['tokens']=tokens
normalized['nav']=_nav(raw.get('nav',[]))
bar=raw.get('mobile_tabs',{})
if not isinstance(bar,dict) or set(bar)-{'enabled','items'}:
raise HTTPException(400,'底部导航配置格式不正确')
enabled=bar.get('enabled',False)
items=bar.get('items',[])
if type(enabled) is not bool or not isinstance(items,list) or len(items)>5:
raise HTTPException(400,'底部导航仅支持 0 至 5 项')
allowed_icons={'home','grid','products','news','contact','phone','info','search','menu','star'}
result=[]
for item in items:
if not isinstance(item,dict) or set(item)-{'title','route','icon','action'}:
raise HTTPException(400,'底部导航项目格式不正确')
title=item.get('title')
icon=item.get('icon','grid')
action=item.get('action','link')
if not isinstance(title,str) or not 1<=len(title.strip())<=12:
raise HTTPException(400,'底部导航标题须为 1 至 12 字')
if icon not in allowed_icons or action not in ('link','menu'):
raise HTTPException(400,'底部导航图标或操作不受支持')
if action=='menu':
route='/'
else:
route=_mobile_tab_url(item.get('route','/'))
result.append({'title':title.strip(),'icon':icon,'route':route,'action':action})
if enabled and not result:
raise HTTPException(400,'启用底部导航时至少添加一项;不需要可直接关闭')
normalized['mobile_tabs']={'enabled':enabled,'items':result}
if len(json.dumps(normalized,ensure_ascii=False))>35_000:raise HTTPException(400,'全站设计过大')
return normalized
def published(row_or_settings):
settings=_settings(row_or_settings) if 'settings_json' in row_or_settings else row_or_settings
merged=dict(settings.get('site_design') or {})
if 'tokens' not in merged:merged['tokens']=settings.get('design_tokens') or {}
if 'nav' not in merged:merged['nav']=settings.get('nav') or []
if 'footer' not in merged:merged['footer']={'description':settings.get('footer_text') or '', 'email':settings.get('email') or ''}
return validate(merged)
def read(site_id):
site=_site(site_id)
config=published(site)
draft=db.one('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,))
return {'config':json.loads(draft['config_json']) if draft else config,
'revision':int(draft['revision']) if draft else 0,
'state':'draft' if draft else 'published',
'conflict':bool(draft and draft['base_hash']!=_hash(_settings(site)))}
def write(site_id,payload):
plan=validate(payload.get('config'))
site=_site(site_id)
with db.connect() as c:
old=c.execute('SELECT revision,base_hash FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
revision=old[0] if old else 0
if type(payload.get('expected_revision')) is not int or payload['expected_revision']!=revision:
raise HTTPException(409,'全站草稿已更新,请重新加载')
if old and old[1]!=_hash(_settings(site)):
raise HTTPException(409,'网站信息已在其他窗口修改')
base=old[1] if old else _hash(_settings(site))
c.execute('''INSERT INTO site_design_drafts VALUES(?,?,?,?,?)
ON CONFLICT(site_id) DO UPDATE SET revision=excluded.revision,
config_json=excluded.config_json, updated_at=excluded.updated_at''',
(site_id,revision+1,base,json.dumps(plan,ensure_ascii=False),db.now()))
return {'saved':True,'state':'draft','revision':revision+1,'build_unchanged':True}
def publish(site_id,expected_revision,actor):
site=_site(site_id)
with db.connect() as c:
draft=c.execute('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
if not draft:raise HTTPException(409,'尚无全站设计草稿')
if type(expected_revision) is not int or expected_revision!=draft['revision']:
raise HTTPException(409,'草稿版本不匹配')
live=c.execute('SELECT settings_json FROM sites WHERE id=?',(site_id,)).fetchone()
settings=json.loads(live['settings_json'])
if draft['base_hash']!=_hash(settings):raise HTTPException(409,'网站信息已变更,请重新审核')
before=published(settings)
config=validate(json.loads(draft['config_json']))
settings['site_design']=config
settings['design_tokens']=config['tokens']
# Keep old nav/settings for non-professional legacy templates too.
settings['nav']=config['nav']
c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',
(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
publication_id=uuid.uuid4().hex
c.execute('INSERT INTO site_design_publications VALUES(?,?,?,?,?,?,?)',
(publication_id,site_id,actor,expected_revision,json.dumps(before,ensure_ascii=False),
json.dumps(config,ensure_ascii=False),db.now()))
c.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,))
return {'published':True,'publication_id':publication_id,'build_required':True}
def responsive_css(config):
r=validate(config)['responsive']
tablet=r['tablet_gap'];mobile=r['mobile_gap'];padding=r['mobile_container_padding']
rules=(f'@media(max-width:1023px){{.sf-canvas .sf-section{{padding-block:{tablet}px}}}}'
f'@media(max-width:767px){{.sf-canvas .sf-section{{padding-block:{mobile}px}}'
f'.pro-wrap,.sf-container{{padding-left:{padding}px;padding-right:{padding}px}}')
if r['mobile_hide_announcement']:rules+='body .pro-utility{display:none!important}'
if r['mobile_hide_footer_links']:rules+='body .pro-footer-links{display:none!important}'
return 'body .pro-utility[hidden]{display:none!important}'+rules+'}'
def token_css(tokens):
safe=validate({'tokens':tokens})['tokens']
fonts={'system':'system-ui,-apple-system,BlinkMacSystemFont,sans-serif',
'serif':'Georgia,Times New Roman,serif',
'modern':'Arial,Helvetica,sans-serif'}
radius=safe['radius'];width=safe['container_width'];space=safe['section_spacing']
scale=safe['heading_scale']
button={'rounded':f'{radius}px','square':'0px','pill':'999px'}[safe['button_style']]
return (f':root{{--sf-font:{fonts[safe["font"]]};--sf-radius:{radius}px;'
f'--sf-space:{space}px;--sf-width:{width}px;--sf-heading-scale:{scale};}}'
f'body{{font-family:var(--sf-font)}}'
f'.sf-canvas .sf-section,.sf-canvas .section{{padding-block:var(--sf-space)}}'
f'.container,.sf-container{{max-width:var(--sf-width)}}'
f'.btn,.primary,.request,.button,.nav-btn{{border-radius:{button}}}')
@router.get('/{site_id}/site-design')
def api_read(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return read(site_id)
@router.put('/{site_id}/site-design')
def api_write(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return write(site_id,payload)
@router.post('/{site_id}/site-design/publish')
def api_publish(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
return publish(site_id,payload.get('expected_revision'),enterprise.actor(request))
@router.delete('/{site_id}/site-design')
def api_discard(request:Request,site_id:str):
enterprise.require(request,site_id,'editor')
_site(site_id)
db.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,))
return {'discarded':True}
@router.get('/{site_id}/site-design/publications')
def api_history(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
_site(site_id)
return db.rows('''SELECT id,site_id,actor,revision,published_at FROM site_design_publications
WHERE site_id=? ORDER BY published_at DESC LIMIT 50''',(site_id,))