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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+68 -11
View File
@@ -1,7 +1,7 @@
"""Declarative mapping from arbitrary tabular source data to SiteForge content.
Never eval mappings, no AI hallucinated fields, preview shows all errors.
"""
import json,re
import json,re,hashlib
from . import db
from .core import route_path,slug_of,save_entry
@@ -53,14 +53,41 @@ def suggest(columns):
return mapping
def source_column(spec):
return spec if isinstance(spec,str) else spec['source']
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('目标字段名称不合法')
for dest,spec in rules.items():
if not isinstance(dest,str) or not re.fullmatch(r'[\w\-]{1,60}',dest,re.UNICODE):
raise ValueError('目标字段名称不合法')
if isinstance(spec,dict):
allowed={'source','trim','case','default','prefix','suffix'}
if set(spec)-allowed or 'source' not in spec:raise ValueError('包含不支持的转换配置')
if type(spec.get('trim',True)) is not bool:raise ValueError('trim 必须为布尔值')
if spec.get('case','none') not in ('none','upper','lower'):raise ValueError('不支持的大小写处理')
for key in ('default','prefix','suffix'):
if key in spec and (not isinstance(spec[key],str) or len(spec[key])>500):
raise ValueError('转换字符串过长或格式非法')
elif not isinstance(spec,str):raise ValueError('映射字段格式非法')
src=source_column(spec)
if not isinstance(src,str) or src not in columns:raise ValueError(f'来源字段不存在: {src}')
if 'title' not in rules:raise ValueError('必须映射标题字段')
def mapped_value(row,spec):
raw=row.get(source_column(spec))
value=json.dumps(raw,ensure_ascii=False) if isinstance(raw,(dict,list)) else str(raw if raw is not None else '')
if isinstance(spec,str):return value.strip()
if spec.get('trim',True):value=value.strip()
if not value:value=spec.get('default','')
case=spec.get('case','none')
if case=='upper':value=value.upper()
elif case=='lower':value=value.lower()
return spec.get('prefix','')+value+spec.get('suffix','')
def transform(rows,rules,kind='product',prefix=None):
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()
@@ -68,9 +95,8 @@ def transform(rows,rules,kind='product',prefix=None):
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()
for target,spec in rules.items():
vals[target]=mapped_value(row,spec)
title=vals.get('title','')
if not title:
errors.append({'row':index+1,'error':'标题为空'});continue
@@ -97,30 +123,61 @@ def transform(rows,rules,kind='product',prefix=None):
return out,errors
def _state_token(site_id,items):
"""Preview token binds mapped payload and existing rows' revision metadata.
This is advisory optimistic checking, not a global distributed lock.
"""
routes=[x['route'] for x in items]
existing={r['route']:r for r in
db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
if r['route'] in routes}
state={'site':site_id,'items':items,'existing':existing}
return hashlib.sha256(json.dumps(state,ensure_ascii=False,sort_keys=True,
separators=(',',':')).encode('utf-8')).hexdigest()
def dry_run(site_id,rows,rules,kind='product',prefix=None):
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}
'unmapped_columns':sorted(set().union(*(x.keys() for x in rows)) -
{source_column(v) for v in rules.values()}) if rows else [],
'sample':items[:10], 'items':items,
'plan_hash':_state_token(site_id,items),
'supported_transforms':['trim','default','upper','lower','prefix','suffix'],
'note':'提交时携带 expected_plan_hash 可阻止预检后发生变化的数据覆盖'}
def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None):
if on_conflict not in ('skip','replace'):raise ValueError('冲突策略只能为 skip 或 replace')
def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None,expected_plan_hash=None):
if on_conflict not in ('skip','replace','abort'):
raise ValueError('冲突策略只能为 skip、replace 或 abort')
report=dry_run(site_id,rows,rules,kind,prefix)
if expected_plan_hash is not None:
if not isinstance(expected_plan_hash,str) or not re.fullmatch(r'[a-f0-9]{64}',expected_plan_hash):
raise ValueError('预检令牌格式无效')
if expected_plan_hash!=report['plan_hash']:
raise ValueError('导入预检已过期:来源数据或已有内容发生变化,请重新预览')
if on_conflict=='abort' and (report['conflicts'] or report['errors']):
raise ValueError('严格导入被阻断:存在 URL 冲突或无效数据,请处理后重新预览')
created=0;updated=0;skipped=0;errors=list(report['errors'])
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
# abort semantics require a preflight without errors; do not turn
# this into an automatic 'replace' policy on a colliding route.
if existing and on_conflict=='abort':raise ValueError('提交期间出现 URL 冲突')
save_entry(site_id,item,existing_id=existing['id'] if existing else None,source='mapped-import')
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']}
if on_conflict=='abort':break
summary={'created':created,'updated':updated,'skipped':skipped,'errors':errors,'mapped':report['mapped'],
'plan_hash':report['plan_hash']}
from .core import uid
db.insert('INSERT INTO import_batches VALUES (?,?,?,?,?,?)',(uid(),site_id,'',f'content-map:{kind}',json.dumps(summary,ensure_ascii=False),db.now()))
return summary
+3
View File
@@ -9,6 +9,9 @@ from . import db
def backup():
# Never manufacture an empty SQLite file while the real application uses PostgreSQL.
if db.POSTGRES:
raise RuntimeError('PostgreSQL 模式不得使用 SQLite 备份;请使用 scripts/pg_disaster_recovery.py backup')
db.init()
storage=Path(db.DATA).resolve()
path=storage/'backups'
+16 -10
View File
@@ -14,7 +14,7 @@ import httpx
from . import safe_http
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87
from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87, multilingual
from .core import uid, route_path, ensure_public_url, sanitize, THEMES
ROOT=Path(__file__).resolve().parent.parent
@@ -23,6 +23,12 @@ ENV=Environment(loader=FileSystemLoader(str(THEME_DIR)),autoescape=select_autoes
TYPE_NAMES={'page':'页面','post':'新闻','product':'产品','service':'服务','case':'案例','brand':'品牌','vehicle':'车型','other':'内容'}
def copy_blocks_css(theme_dir,dist_assets):
"""Theme-provided blocks.css wins; platform default is the fallback."""
candidate=theme_dir/'blocks.css'
src=candidate if candidate.is_file() else ROOT/'app'/'static'/'blocks.css'
shutil.copy2(src,dist_assets/'blocks.css')
def output_path(dist,route):
path=route_path(route)
if path=='/':return dist/'index.html'
@@ -36,12 +42,6 @@ def output_path(dist,route):
if not target.is_relative_to(dist.resolve()):raise ValueError('非法生成目标目录')
return out
def copy_blocks_css(theme_dir,dist_assets):
"""Theme-provided blocks.css wins; platform default is the fallback."""
candidate=theme_dir/'blocks.css'
src=candidate if candidate.is_file() else ROOT/'app'/'static'/'blocks.css'
shutil.copy2(src,dist_assets/'blocks.css')
def link(path):
return (path or '/').lstrip('/') or './'
@@ -166,6 +166,9 @@ def build(site_id,mirror=False):
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not site:raise ValueError('网站不存在')
if site['theme'] not in theme_store.ids():raise ValueError('无效的主题')
multilingual.assert_build_safe(site_id)
locale=multilingual.site_locale(site_id)
language_alternates=multilingual.alternates(site_id)
records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published' ORDER BY type,title",(site_id,))
redirects=db.rows('SELECT * FROM redirects WHERE site_id=?',(site_id,))
settings=json.loads(site['settings_json'] or '{}')
@@ -309,6 +312,7 @@ def build(site_id,mirror=False):
# global custom.css. Reused templates can share block IDs safely.
html=__import__('re').sub(r'<body(?=[\s>])',
'<body data-siteforge-route="'+responsive.route_key(page['route'])+'"',html,count=1,flags=__import__('re').I)
html=multilingual.inject_seo(html,locale,language_alternates.get(page['route'],[]) if 'noindex' not in (page.get('robots') or '').lower() else [])
target=output_path(dist,page['route'])
if target.exists():raise ValueError('多个路由写入同一文件: '+page['route'])
target.parent.mkdir(parents=True,exist_ok=True)
@@ -385,6 +389,7 @@ def build(site_id,mirror=False):
missing_html=env.get_template('pro.html').render(**ctx)
else:
missing_html=template.render(site=site,page=notfound,mode='notfound',content='',blocks='',blueprint_mode=False,entries=local_records,listing_items=[],type_names=TYPE_NAMES,nav=[],link=link,seo_title='404',seo_description='Page not found',canonical='')
missing_html=multilingual.inject_seo(missing_html,locale,[])
(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)
@@ -402,13 +407,14 @@ def build(site_id,mirror=False):
search_html=env.get_template('pro.html').render(**ctx)
else:
search_html=static_site.search_page(site)
search_html=multilingual.inject_seo(search_html,locale,[])
search_file.write_text(search_html,encoding='utf-8')
route_set.add('/search/')
noindex_routes={e['route'] for e in records if 'noindex' in (e['robots'] or '').lower()} | {'/search/'} | generated_noindex
canonical_routes=sorted([x for x in route_set if not x.startswith('/404') and x not in noindex_routes])
if site['domain'].startswith(('https://','http://')):
urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc></url>' for r in canonical_routes)
sitemap='<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+urls+'</urlset>'
urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc>'+multilingual.sitemap_alternates(language_alternates.get(r,[]))+'</url>' for r in canonical_routes)
sitemap='<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'+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=[]
@@ -452,7 +458,7 @@ def build(site_id,mirror=False):
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']),
report={'site':site['name'],'locale':locale,'alternate_pages':len(language_alternates),'version':bid,'pages':totals,'theme':site['theme'],'theme_version':theme_pin['version'] if theme_pin else theme_store.latest(site['theme']),
'warnings':warnings,'errors':issues,'external_assets':external[:200],
'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),
+74
View File
@@ -0,0 +1,74 @@
"""Authorized, versioned navigation contract for the single SiteForge console.
The frontend is not an authorization authority: every business API keeps its
own server-side membership checks. This endpoint only returns allowed context.
"""
from fastapi import APIRouter, HTTPException, Request
from . import db, enterprise
from .version import VERSION
router = APIRouter(prefix='/api/console', tags=['Console'])
NAVIGATION = (
('dashboard', '工作台', '/siteforge/dashboard', None),
('sites', '网站管理', '/siteforge/factory', None),
('content', '内容管理', '/siteforge/content', 'viewer'),
('design', '网站设计', '/siteforge/editor', 'viewer'),
('migration', '数据与迁移', '/siteforge/migration', 'viewer'),
('release', '构建与发布', '/siteforge/releases', 'viewer'),
('system', '系统管理', '/siteforge/enterprise', 'owner'),
)
def available_sites(request: Request):
if request.state.is_superadmin:
return db.rows('SELECT id,name,domain FROM sites ORDER BY updated_at DESC,id')
return db.rows('''SELECT s.id,s.name,s.domain FROM sites s
JOIN team_memberships m ON m.site_id=s.id
JOIN team_users u ON u.id=m.user_id
WHERE m.user_id=? AND u.disabled=0
ORDER BY s.updated_at DESC,s.id''', (request.state.account_id,))
def capabilities(request: Request, site_id: str | None):
role = 'superadmin' if request.state.is_superadmin else 'none'
if site_id and not request.state.is_superadmin:
membership = db.one('SELECT role FROM team_memberships WHERE site_id=? AND user_id=?',
(site_id, request.state.account_id))
role = membership['role'] if membership else 'none'
level = enterprise.ACCESS.get(role, -1)
return dict(role=role, view=level>=0, edit=level>=enterprise.ACCESS['editor'],
review=level>=enterprise.ACCESS['reviewer'],
deploy=level>=enterprise.ACCESS['owner'],
manage_members=level>=enterprise.ACCESS['owner'],
create_site=bool(request.state.is_superadmin),
manage_users=bool(request.state.is_superadmin))
@router.get('/bootstrap')
def bootstrap(request: Request, site_id: str = ''):
"""Return user-scoped menus, never expose unassigned websites or credentials."""
sites = available_sites(request)
ids = {x['id'] for x in sites}
if site_id and site_id not in ids:
raise HTTPException(403, '没有访问该网站的权限')
selected = site_id or (sites[0]['id'] if sites else None)
rights = capabilities(request, selected)
sections = []
for key, title, path, required in NAVIGATION:
if required and not selected:
continue
if required and not rights['view']:
continue
if key == 'system' and not (rights['manage_members'] or rights['manage_users']):
continue
sections.append({'key': key, 'title': title, 'path': path, 'requires_site': bool(required)})
return {
'version': VERSION,
'user': request.state.actor,
'superadmin': bool(request.state.is_superadmin),
'sites': sites,
'selected_site_id': selected,
'capabilities': rights,
'navigation': sections,
}
+2 -3
View File
@@ -4,12 +4,12 @@ import json
import re
import socket
import uuid
from pathlib import Path
from urllib.parse import urlparse, unquote
import bleach
from . import db
ROOT = Path(__file__).resolve().parent.parent
TYPES = ('page','product','post','service','case','brand','vehicle','other')
ROOT = __import__('pathlib').Path(__file__).resolve().parent.parent
def discover_themes():
"""Discover built-in themes from themes/*/manifest.json (single source of truth)."""
@@ -29,7 +29,6 @@ def discover_themes():
found.append(entry.name)
return tuple(found)
TYPES = ('page','product','post','service','case','brand','vehicle','other')
THEMES = discover_themes()
ALLOWED_TAGS = ['p','br','b','i','u','em','strong','h1','h2','h3','h4','h5','h6','ul','ol','li','blockquote','pre','code','a','img','figure','figcaption','table','thead','tbody','tr','th','td','hr','div','span']
ALLOWED_ATTRS = {'a':['href','title','target','rel'], 'img':['src','alt','title','loading','width','height'], 'td':['colspan','rowspan'], 'th':['colspan','rowspan']}
+7 -3
View File
@@ -75,9 +75,13 @@ def init():
result_json TEXT NOT NULL DEFAULT '{}'
);
''')
if not POSTGRES:
from . import schema_registry
schema_registry.ensure(c)
# Migration ledger is shared by SQLite and PostgreSQL.
from . import schema_registry
schema_registry.ensure(c)
# The multilingual schema is a non-destructive additive migration.
# Direct CLI builders, tests and web startup all initialize it.
from . import multilingual
multilingual.setup()
def rows(sql, params=()):
with connect() as c:
+105 -13
View File
@@ -23,7 +23,12 @@ def signature(secret,site,timestamp,body,method,path):
return hmac.new(secret.encode(),envelope,hashlib.sha256).hexdigest()
def authorize(site,request,body):
secret=os.environ.get('SITEFORGE_DEPLOY_SECRET','')
ref=os.environ.get('SITEFORGE_DEPLOY_SECRET_REF','')
if ref:
from .secret_refs import resolve,SecretReferenceError
try:secret=resolve(ref)
except SecretReferenceError:raise HTTPException(503,'Release Agent secret unavailable')
else:secret=os.environ.get('SITEFORGE_DEPLOY_SECRET','')
if len(secret)<32:raise HTTPException(503,'Release agent secret not configured')
stamp=request.headers.get('x-siteforge-timestamp','')
try:
@@ -37,12 +42,16 @@ def validate_archive(raw):
if len(raw)>MAX_ZIP:raise ValueError('Archive too large')
with zipfile.ZipFile(io.BytesIO(raw)) as z:
if len(z.infolist())>20000:raise ValueError('Too many files')
total=0
total=0;seen=set()
for info in z.infolist():
name=info.filename
path=PurePosixPath(name)
if name.startswith('/') or '\\' in name or '..' in path.parts or not name or ':' in name:
if name.startswith('/') or '\\' in name or '..' in path.parts or not name or ':' in name or name.startswith('./'):
raise ValueError('Unsafe archive path')
key=name.lower().rstrip('/')
if key in seen or key=='.siteforge-manifest.json':
raise ValueError('Duplicate or reserved file in archive')
seen.add(key)
# Unix symlink type is 0o120000.
if ((info.external_attr>>16)&0o170000)==0o120000:raise ValueError('Symlinks forbidden')
total+=info.file_size
@@ -53,7 +62,25 @@ def validate_archive(raw):
return True
def install(site,raw):
def _pointer(site):
parent=ROOT/site
current=parent/'current'
version=current.read_text(encoding='ascii').strip() if current.is_file() else None
return version if version and re.fullmatch('[0-9a-f]{20}',version) else None
def _manifest(root):
"""Hash all files of an immutable release. Called on a limited archive."""
hashes={}
for path in sorted(root.rglob('*')):
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise ValueError('Release contains unsafe link')
if path.is_file():hashes[path.relative_to(root).as_posix()]=hashlib.sha256(path.read_bytes()).hexdigest()
return hashes
def stage(site,raw):
"""Stage an immutable release without changing current traffic."""
if not SITE.fullmatch(site):raise ValueError('Bad site ID')
validate_archive(raw)
digest=hashlib.sha256(raw).hexdigest()[:20]
@@ -61,19 +88,54 @@ def install(site,raw):
releases=parent/'releases'
releases.mkdir(parents=True,exist_ok=True)
target=releases/digest
if target.is_symlink():raise ValueError('Invalid immutable release directory')
if not target.is_dir():
stage=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent)))
stage_dir=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent)))
try:
with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage)
if not (stage/'index.html').is_file():raise ValueError('Invalid site')
try:stage.rename(target)
with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage_dir)
if not (stage_dir/'index.html').is_file():raise ValueError('Invalid site')
hashes=_manifest(stage_dir)
(stage_dir/'.siteforge-manifest.json').write_text(json.dumps({'sha256':hashlib.sha256(raw).hexdigest(),
'files':hashes},sort_keys=True),encoding='utf-8')
try:stage_dir.rename(target)
except FileExistsError:pass
finally:
if stage.exists():shutil.rmtree(stage)
pointer=parent/'.current.tmp'
pointer.write_text(digest,encoding='ascii')
os.replace(pointer,parent/'current')
return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(),'published':True}
if stage_dir.exists():shutil.rmtree(stage_dir)
return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(),
'staged':True,'current':_pointer(site)}
def verify_release(site,release):
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise ValueError('Invalid release')
folder=ROOT/site/'releases'/release
meta=folder/'.siteforge-manifest.json'
if not meta.is_file():raise ValueError('Release manifest missing')
manifest=json.loads(meta.read_text(encoding='utf-8'))
expected=manifest.get('files',{})
current=_manifest(folder)
current.pop('.siteforge-manifest.json',None)
if expected != current or 'index.html' not in current:raise ValueError('Release file integrity failure')
return {'site':site,'release':release,'sha256':manifest['sha256'],
'files':len(current),'verified':True,'active':_pointer(site)==release,
'current':_pointer(site)}
def activate(site,release):
verified=verify_release(site,release)
parent=ROOT/site
previous=_pointer(site)
tmp=parent/('.current-'+re.sub('[^0-9a-f]','',release)+'.tmp')
tmp.write_text(release,encoding='ascii')
os.replace(tmp,parent/'current')
return {**verified,'active':True,'previous':previous,'current':release}
def install(site,raw):
"""Compatibility endpoint used by existing clients; new Worker uses stage/verify/activate."""
staged=stage(site,raw)
active=activate(site,staged['release'])
return {**active,'published':True}
@app.get('/health')
def health():return {'healthy':True}
@@ -92,6 +154,33 @@ async def deploy(site:str,request:Request):
try:return install(site,data)
except (ValueError,zipfile.BadZipFile) as e:raise HTTPException(400,str(e))
@app.post('/staging/{site}')
async def stage_upload(site:str,request:Request):
if not SITE.fullmatch(site):raise HTTPException(400)
parts=[];size=0
async for chunk in request.stream():
size+=len(chunk)
if size>MAX_ZIP:raise HTTPException(413)
parts.append(chunk)
raw=b''.join(parts)
authorize(site,request,raw)
try:return stage(site,raw)
except (ValueError,zipfile.BadZipFile) as exc:raise HTTPException(400,str(exc))
@app.get('/sites/{site}/verify/{release}')
def verify_route(site:str,release:str,request:Request):
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400)
authorize(site,request,b'')
try:return verify_release(site,release)
except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc))
@app.post('/sites/{site}/activate/{release}')
def activate_route(site:str,release:str,request:Request):
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400)
authorize(site,request,b'')
try:return activate(site,release)
except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc))
@app.get('/sites/{site}/releases')
def versions(site:str,request:Request):
# No public access to release inventory. Require same signed request semantics.
@@ -107,6 +196,9 @@ async def rollback(site:str,release:str,request:Request):
authorize(site,request,b'')
parent=ROOT/site
if not (parent/'releases'/release/'index.html').is_file():raise HTTPException(404)
if (parent/'releases'/release/'.siteforge-manifest.json').exists():
try:verify_release(site,release)
except (ValueError,OSError,KeyError):raise HTTPException(409,'Historic release failed integrity verification')
tmp=parent/'.current.tmp';tmp.write_text(release);os.replace(tmp,parent/'current')
return {'site':site,'release':release,'rolled_back':True}
+5 -1
View File
@@ -134,7 +134,11 @@ def create_user(request:Request,payload:UserCreate):
person=uid()
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,'用户名已存在')
except Exception as exc:
# PostgreSQL uses psycopg.errors.UniqueViolation rather than sqlite3.IntegrityError.
if isinstance(exc,sqlite3.IntegrityError) or getattr(exc,'sqlstate',None)=='23505':
raise HTTPException(409,'用户名已存在') from exc
raise
return {'id':person,'username':payload.username,'display_name':payload.display_name}
@router.patch('/team/users/{user_id}/disable')
+4 -2
View File
@@ -1,5 +1,6 @@
"""Factory V2 endpoints: themes, mappings, build comparisons, team release readiness."""
import json,csv,io
from typing import Any
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
from fastapi.responses import HTMLResponse, FileResponse
@@ -88,10 +89,11 @@ def save_profile(request:Request,site_id:str,payload:dict=Body(...)):
class MapRows(BaseModel):
rows:list[dict]=Field(max_length=1000)
rules:dict[str,str]=Field(default_factory=dict)
rules:dict[str,Any]=Field(default_factory=dict)
content_type:str='product'
prefix:str|None=None
on_conflict:str='skip'
expected_plan_hash:str|None=None
dry_run:bool=True
@router.post('/sites/{site_id}/mapping/parse-file')
@@ -115,7 +117,7 @@ def suggested_map(request:Request,site_id:str,payload:dict=Body(...)):
def map_rows(request:Request,site_id:str,payload:MapRows):
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)
return assembly.dry_run(site_id,payload.rows,payload.rules,payload.content_type,payload.prefix) if payload.dry_run else assembly.commit(site_id,payload.rows,payload.rules,payload.content_type,payload.on_conflict,payload.prefix,payload.expected_plan_hash)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/migration/readiness')
+25
View File
@@ -0,0 +1,25 @@
"""Infrastructure readiness distinct from lightweight process liveness.
Do not expose DB DSNs, Redis endpoints or filesystem paths in the public response.
Redis wake-ups are optional and are not a readiness requirement.
"""
from pathlib import Path
import tempfile
from . import db
def readiness() -> bool:
try:
# SELECT 1 works on SQLite and PostgreSQL and checks actual connectivity.
with db.connect() as conn:
if conn.execute('SELECT 1 AS ready').fetchone()[0] != 1:
return False
data=Path(db.DATA)
data.mkdir(parents=True,exist_ok=True)
# Build artifacts and job results require writable shared storage.
with tempfile.NamedTemporaryFile(dir=data, prefix='.ready-', delete=True) as probe:
probe.write(b'1')
probe.flush()
return True
except Exception:
return False
+12 -4
View File
@@ -11,7 +11,7 @@ from bs4 import BeautifulSoup
import httpx
from openpyxl import load_workbook
from .core import route_path, save_entry, ensure_public_url, slug_of
from . import db,wordpress_compat,safe_http
from . import db,wordpress_compat,safe_http,wp_complex_v820
KEY_ALIASES={
'title':('title','name','标题','名称','产品名称','post_title'),
@@ -114,7 +114,10 @@ def import_wxr(site_id,content,conflict='skip'):
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}/'
if urlparse(link).query or urlparse(link).fragment:
raise ValueError(f'WXR 记录 {title[:80]} 使用动态参数 URL;请先做 V8.20 预检和显式路由映射')
if not path:
raise ValueError(f'WXR 记录 {title[:80]} 缺少原始 URL,无法保证旧路径不变')
wpmeta={}
for pm in it.findall('wp:postmeta',ns):
key=pm.findtext('wp:meta_key',namespaces=ns)
@@ -133,11 +136,16 @@ def import_wxr(site_id,content,conflict='skip'):
('_stock_status','stock_status'),('_weight','weight')):
if wpmeta.get(source):fields[dest]=str(wpmeta[source])[:1000]
fields.update(taxonomy)
acf=wp_complex_v820.extract_acf(wpmeta)
for key,value in acf['fields'].items():
fields.setdefault(key,value)
for key,value in acf['repeaters'].items():
fields[key]=value
wp_featured=media_index.get(wpmeta.get('_thumbnail_id',''),'')
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=[]
warnings=list(acf['warnings'])
if wpmeta.get('_elementor_data'):
elementor=wordpress_compat.elementor_to_html(wpmeta['_elementor_data'])
if not body.strip() and elementor['html']:
@@ -155,7 +163,7 @@ def import_wxr(site_id,content,conflict='skip'):
'canonical':wpmeta.get('_yoast_wpseo_canonical') or wpmeta.get('rank_math_canonical_url') or '',
'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}})
'meta':{'elementor_widgets':elementor['widgets'] if wpmeta.get('_elementor_data') else 0,'wp_post_id':it.findtext('wp:post_id',default='',namespaces=ns),'wp_type':typ,'wp_metadata':wpmeta,'fields':fields,'acf_repeaters':acf['repeaters'],'migration_warnings':warnings}})
return import_records(site_id,entries,'wordpress-wxr',conflict)
def safe_get(client,url,max_bytes=3_000_000):
+3
View File
@@ -24,6 +24,9 @@ def main():
elif row['kind']=='mysql_scan':
from . import connectors_v85
value=connectors_v85.run_scan(row['site_id'],opts['source_id'],job_id)
elif row['kind'] in ('remote_deploy','remote_rollback'):
from . import release_api
value=release_api.run_delivery(row['site_id'],row['kind'],opts,job_id)
elif row['kind']=='build':
value=builder.build(row['site_id'],bool(opts.get('mirror_images',False)))
else:raise ValueError('未知任务类型')
+42 -5
View File
@@ -1,7 +1,7 @@
'''Durable SQLite build queue. A single job is at most running once per site.
'''Durable build queue: SQLite single-host or PostgreSQL with DB-owned leases.
This implementation is deliberately single-host. For multi-host HA, replace
SQLite with PostgreSQL and Redis (outbox+leases) before production scaling.
Redis is an optional wake-up signal, not an authoritative queue. PostgreSQL
lock/lease semantics require actual multi-worker acceptance on infrastructure.
'''
from __future__ import annotations
import hashlib
@@ -101,9 +101,15 @@ def recover_expired():
if row['cancel_requested']:
state='cancelled'
else:
state='retrying' if row['attempts']<row['max_attempts'] else 'failed'
# Publishing is an external side effect: it may have completed remotely
# even though the Worker lost its lease. Never automatically replay it.
state=('failed' if row['kind'] in ('remote_deploy','remote_rollback')
else ('retrying' if row['attempts']<row['max_attempts'] else 'failed'))
message=('Release lease expired; reconcile remote release before manual retry'
if row['kind'] in ('remote_deploy','remote_rollback') else
'Worker lease expired; verify partial builds')
c.execute('UPDATE build_jobs SET state=?,worker_id=NULL,lease_until=NULL,available_at=?,error=? WHERE id=?',
(state,db.now(),'Worker lease expired; verify partial builds',row['id']))
(state,db.now(),message,row['id']))
return len(rows)
@@ -140,3 +146,34 @@ def submit_migration(site_id,session_id,actor,key=None):
from . import redis_signal
redis_signal.notify()
return {'id':job_id,'state':'queued','existing':False}
def submit_delivery(site_id,kind,payload,actor,key=None):
# One logical release per job; serialized with build jobs by Worker.
if kind not in ('remote_deploy','remote_rollback'):
raise JobConflict('Unknown delivery job kind')
if key is not None and not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
raise JobConflict('无效的幂等键')
normalized=json.dumps(payload,sort_keys=True,separators=(',',':'))
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
if key:
row=c.execute('SELECT * FROM build_jobs WHERE site_id=? AND idempotency_key=?',(site_id,key)).fetchone()
if row:
if row['kind']!=kind or row['payload_json']!=normalized:raise JobConflict('幂等键存在冲突')
return {'id':row['id'],'state':row['state'],'existing':True}
row=c.execute("SELECT id,state FROM build_jobs WHERE site_id=? AND kind=? AND payload_json=? AND state IN ('queued','running','retrying') ORDER BY created_at LIMIT 1",(site_id,kind,normalized)).fetchone()
if row:return {'id':row['id'],'state':row['state'],'existing':True}
active="state IN ('queued','running','retrying')"
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE {active}').fetchone()[0]>=_limit('SITEFORGE_MAX_PENDING_JOBS',100):
raise JobCapacityError('系统任务队列已满')
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE site_id=? AND {active}',(site_id,)).fetchone()[0]>=_limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5):
raise JobCapacityError('网站任务队列已满')
job_id=uuid.uuid4().hex[:16]
c.execute('''INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at,payload_json,
idempotency_key,max_attempts,timeout_seconds,available_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)''',
(job_id,site_id,kind,'queued',actor,db.now(),normalized,key,3,900,db.now()))
from . import redis_signal
redis_signal.notify()
return {'id':job_id,'state':'queued','existing':False}
+24 -3
View File
@@ -12,7 +12,7 @@ from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, Resp
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from . import db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables, jobs, release_api, cms_pipeline, v8_workflows, v8_templates, wp_migration_v81, wp_migration_v82, wp_migration_v83, connectors_v84, connectors_v85, studio_v86, site_design_v87
from . import console_v819, wp_complex_v820, db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables, jobs, release_api, cms_pipeline, v8_workflows, v8_templates, wp_migration_v81, wp_migration_v82, wp_migration_v83, connectors_v84, connectors_v85, studio_v86, site_design_v87, multilingual
from .version import VERSION, BUILD_SHA
from .request_trace import TraceMiddleware
from .preview_rewrite import rewrite_preview_html
@@ -38,6 +38,7 @@ async def lifespan(app):
connectors_v85.setup()
studio_v86.setup()
site_design_v87.setup()
multilingual.setup()
yield
app=FastAPI(title='SiteForge 网站工厂',description='Docker-first visual website factory',version=VERSION,lifespan=lifespan)
@@ -58,15 +59,18 @@ app.include_router(v8_templates.router)
app.include_router(wp_migration_v81.router)
app.include_router(wp_migration_v82.router)
app.include_router(wp_migration_v83.router)
app.include_router(wp_complex_v820.router)
app.include_router(connectors_v84.router)
app.include_router(connectors_v85.router)
app.include_router(studio_v86.router)
app.include_router(site_design_v87.router)
app.include_router(multilingual.router)
app.include_router(console_v819.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/')
public = path in ('/login', '/api/auth/login', '/api/health', '/api/ready', '/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
@@ -261,6 +265,12 @@ def visual_editor(site_id:str,entry_id:str):
@app.get('/api/health')
def health():return {'status':'ok','name':'SiteForge','version':VERSION,'commit':BUILD_SHA}
@app.get('/api/ready')
def ready():
from .healthchecks import readiness
ok=readiness()
return JSONResponse({'status':'ready' if ok else 'unavailable'},status_code=200 if ok else 503)
@app.get('/api/themes')
def themes():
manifests=[]
@@ -536,7 +546,15 @@ def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload):
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=>{
const sections=Array.from(document.querySelectorAll('[data-block]'));
sections.forEach(block=>{
block.setAttribute('data-studio-selectable','true');
block.addEventListener('click',event=>{
if(event.target.closest('a'))event.preventDefault();
sections.forEach(item=>item.classList.remove('sf-editor-selected'));
block.classList.add('sf-editor-selected');
parent.postMessage({source:'siteforge-select-block',blockId:block.dataset.block},'*');
});
for(const [field,selector] of Object.entries(fields)){
const target=block.querySelector(selector);if(!target)continue;
target.title='双击直接修改文字';target.addEventListener('dblclick',()=>{
@@ -550,6 +568,9 @@ def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload):
});
}
});
const style=document.createElement('style');
style.textContent='[data-studio-selectable]:hover{outline:2px dashed #e98b45!important;outline-offset:-2px;cursor:pointer}[data-studio-selectable].sf-editor-selected{outline:2px solid #ea8138!important;outline-offset:-2px}';
document.head.appendChild(style);
})()</script>'''
# The static site's /media/ assets are hosted by the admin at
# /local-media/{site}/. Rewrite only the editor response, never builds.
+265
View File
@@ -0,0 +1,265 @@
"""Explicit multilingual relationships. No automatic fact translation.
One site per locale. Only approved, published and indexed content receives
alternate links. Pending translation records cannot accidentally enter a build.
"""
import json
import re
from urllib.parse import urlparse
from html import escape as html_escape
from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import db, enterprise
from .core import uid
router=APIRouter(prefix='/api/i18n', tags=['Multilingual'])
LOCALE_PATTERN=re.compile(r'^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$')
KEY_PATTERN=re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$')
def locale_value(locale):
value=str(locale or '').strip()
if not LOCALE_PATTERN.fullmatch(value):
raise ValueError('语言标识必须是 BCP47 格式,例如 zh-CN、en、fr-FR')
return value
def _domain(site):
value=(site.get('domain') or '').strip().rstrip('/')
parsed=urlparse(value)
if parsed.scheme!='https' or not parsed.hostname or parsed.username or parsed.password or parsed.path not in ('','/') or parsed.query or parsed.fragment:
raise ValueError('多语言正式站点必须配置独立 HTTPS 域名(不允许路径、账号或查询参数)')
return value
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS language_groups(
id TEXT PRIMARY KEY, name TEXT NOT NULL, default_locale TEXT NOT NULL,
created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS language_sites(
group_id TEXT NOT NULL REFERENCES language_groups(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
locale TEXT NOT NULL, PRIMARY KEY(group_id,site_id),
UNIQUE(group_id,locale), UNIQUE(site_id));
CREATE TABLE IF NOT EXISTS translation_units(
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES language_groups(id) ON DELETE CASCADE,
reference_key TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(group_id,reference_key));
CREATE TABLE IF NOT EXISTS translation_members(
unit_id TEXT NOT NULL REFERENCES translation_units(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
state TEXT NOT NULL CHECK(state IN ('pending','approved')),
PRIMARY KEY(unit_id,site_id),UNIQUE(site_id,entry_id));
CREATE INDEX IF NOT EXISTS idx_translations_site ON translation_members(site_id,state);
''')
def require_group(group_id):
group=db.one('SELECT * FROM language_groups WHERE id=?',(group_id,))
if not group:raise HTTPException(404,'语言组不存在')
return group
def _authorized(request,group_id,role='viewer'):
sites=db.rows('SELECT site_id FROM language_sites WHERE group_id=?',(group_id,))
# At least one managed member is needed to read or edit the group.
if not getattr(request.state,'is_superadmin',False):
if not any(enterprise.level(request,s['site_id'])>=enterprise.ACCESS[role] for s in sites):
raise HTTPException(403,'无权管理此语言组')
return sites
class GroupIn(BaseModel):
name:str=Field(min_length=2,max_length=120)
default_locale:str='en'
class GroupSiteIn(BaseModel):
site_id:str
locale:str
class UnitIn(BaseModel):
reference_key:str
class MemberIn(BaseModel):
site_id:str
entry_id:str
state:str='pending'
@router.post('/groups',status_code=201)
def create_group(data:GroupIn,request:Request):
if not request.state.is_superadmin:raise HTTPException(403,'仅管理员可创建语言组')
try:locale=locale_value(data.default_locale)
except ValueError as exc:raise HTTPException(400,str(exc))
gid=uid()
db.insert('INSERT INTO language_groups VALUES (?,?,?,?)',(gid,data.name,locale,db.now()))
return {'id':gid,'name':data.name,'default_locale':locale}
class LocaleIn(BaseModel):
locale:str
@router.put('/sites/{site_id}/locale')
def set_site_locale(site_id:str,data:LocaleIn,request:Request):
"""Declare this site's content language (BCP47). Used for <html lang>, defaults zh-CN."""
enterprise.require(request,site_id,'owner')
try:locale=locale_value(data.locale)
except ValueError as exc:raise HTTPException(400,str(exc))
site=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404,'网站不存在')
settings=json.loads(site['settings_json'] or '{}')
settings['locale']=locale
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'site_id':site_id,'locale':locale}
@router.get('/groups')
def groups(request:Request):
gs=db.rows('SELECT * FROM language_groups ORDER BY created_at DESC')
if not request.state.is_superadmin:
gs=[g for g in gs if any(enterprise.level(request,s['site_id'])>=enterprise.ACCESS['viewer'] for s in db.rows('SELECT site_id FROM language_sites WHERE group_id=?',(g['id'],)))]
return gs
@router.get('/sites/{site_id}')
def site_language(site_id:str,request:Request):
enterprise.require(request,site_id,'viewer')
membership=db.one('''SELECT s.locale,s.group_id,g.name,g.default_locale FROM language_sites s
JOIN language_groups g ON g.id=s.group_id WHERE s.site_id=?''',(site_id,))
if not membership:return {'group':None,'sites':[],'units':[]}
gid=membership['group_id']
_authorized(request,gid)
sites=db.rows('''SELECT s.locale,s.site_id,t.name,t.domain FROM language_sites s
JOIN sites t ON t.id=s.site_id WHERE s.group_id=? ORDER BY s.locale''',(gid,))
units=db.rows('''SELECT u.id,u.reference_key,m.site_id,m.entry_id,m.state,e.title,e.route,s.locale
FROM translation_units u JOIN translation_members m ON m.unit_id=u.id
JOIN entries e ON e.id=m.entry_id JOIN language_sites s ON s.site_id=m.site_id
WHERE u.group_id=? ORDER BY u.reference_key,s.locale''',(gid,))
return {'group':membership,'sites':sites,'units':units}
@router.post('/groups/{group_id}/sites',status_code=201)
def join_site(group_id:str,data:GroupSiteIn,request:Request):
require_group(group_id)
_authorized(request,group_id,'owner')
enterprise.require(request,data.site_id,'owner')
site=db.one('SELECT * FROM sites WHERE id=?',(data.site_id,))
if not site:raise HTTPException(404,'网站不存在')
try:
locale=locale_value(data.locale)
domain=_domain(site)
except ValueError as exc:raise HTTPException(400,str(exc))
existing=db.rows('''SELECT x.site_id,t.domain FROM language_sites x JOIN sites t ON t.id=x.site_id
WHERE x.group_id=?''',(group_id,))
if any(_domain(s)==domain and s['site_id']!=data.site_id for s in existing):
raise HTTPException(409,'语言组内两个网站不能使用相同域名')
if db.one('SELECT site_id FROM language_sites WHERE site_id=?',(data.site_id,)):
raise HTTPException(409,'该站点已经属于语言组')
try:db.insert('INSERT INTO language_sites VALUES (?,?,?)',(group_id,data.site_id,locale))
except Exception as exc:
if 'UNIQUE' in str(exc).upper():raise HTTPException(409,'组内语言标识已存在')
raise
return {'group_id':group_id,'site_id':data.site_id,'locale':locale}
@router.post('/groups/{group_id}/units',status_code=201)
def create_unit(group_id:str,data:UnitIn,request:Request):
require_group(group_id);_authorized(request,group_id,'editor')
key=data.reference_key.strip()
if not KEY_PATTERN.fullmatch(key):raise HTTPException(400,'翻译组标识只能包含字母、数字、短横线、下划线、点和冒号')
found=db.one('SELECT id FROM translation_units WHERE group_id=? AND reference_key=?',(group_id,key))
if found:return {'id':found['id'],'reference_key':key,'existing':True}
id=uid();db.insert('INSERT INTO translation_units VALUES (?,?,?,?)',(id,group_id,key,db.now()))
return {'id':id,'reference_key':key,'existing':False}
@router.post('/groups/{group_id}/units/{unit_id}/members')
def link_member(group_id:str,unit_id:str,data:MemberIn,request:Request):
require_group(group_id);_authorized(request,group_id,'editor')
enterprise.require(request,data.site_id,'editor')
unit=db.one('SELECT * FROM translation_units WHERE id=? AND group_id=?',(unit_id,group_id))
if not unit:raise HTTPException(404,'翻译内容组不存在')
if not db.one('SELECT site_id FROM language_sites WHERE site_id=? AND group_id=?',(data.site_id,group_id)):
raise HTTPException(400,'网站不属于该语言组')
entry=db.one('SELECT * FROM entries WHERE id=? AND site_id=?',(data.entry_id,data.site_id))
if not entry:raise HTTPException(404,'内容不存在或不属于该网站')
if data.state not in ('pending','approved'):raise HTTPException(400,'无效的翻译状态')
if data.state=='pending' and entry['status']=='published':
raise HTTPException(409,'待翻译内容必须先保存为草稿,避免泄露未完成翻译')
if data.state=='approved' and entry['status']!='published':
raise HTTPException(409,'只有已发布的正式内容才能审核通过翻译关系')
prior=db.one('SELECT unit_id FROM translation_members WHERE site_id=? AND entry_id=?',(data.site_id,data.entry_id))
if prior and prior['unit_id']!=unit_id:raise HTTPException(409,'内容已经属于另一翻译组')
# Atomic upsert: one locale / site has at most one entry in a semantic group.
with db.connect() as c:
c.execute('''INSERT INTO translation_members(unit_id,site_id,entry_id,state) VALUES (?,?,?,?)
ON CONFLICT(unit_id,site_id) DO UPDATE SET entry_id=excluded.entry_id,state=excluded.state''',
(unit_id,data.site_id,data.entry_id,data.state))
return {'unit_id':unit_id,'site_id':data.site_id,'entry_id':data.entry_id,'state':data.state}
@router.delete('/groups/{group_id}/units/{unit_id}/members/{site_id}')
def unlink_member(group_id:str,unit_id:str,site_id:str,request:Request):
require_group(group_id);_authorized(request,group_id,'editor');enterprise.require(request,site_id,'editor')
deleted=db.execute('DELETE FROM translation_members WHERE unit_id=? AND site_id=?',(unit_id,site_id))
if not deleted:raise HTTPException(404,'没有匹配的翻译关联')
return {'removed':True}
def site_locale(site_id):
row=db.one('SELECT locale FROM language_sites WHERE site_id=?',(site_id,))
if row:return row['locale']
# Per-site locale override (e.g. standalone English sites outside any language group)
site=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
try:override=json.loads(site['settings_json'] or '{}').get('locale') if site else None
except ValueError:override=None
if override:
try:return locale_value(override)
except ValueError:pass
return 'zh-CN'
def assert_build_safe(site_id):
invalid=db.rows('''SELECT e.route,e.title FROM translation_members m JOIN entries e ON e.id=m.entry_id
WHERE m.site_id=? AND m.state='pending' AND e.status='published' ''',(site_id,))
if invalid:
raise ValueError('有待翻译内容被设为已发布,禁止构建:'+', '.join(x['route'] for x in invalid[:8]))
def alternates(site_id):
"""Get {route: [(locale,absolute_url)]} with approved cross-site peers only.
Return zero for unlinked/draft/noindex/invalid domain pages, including for
pending translation records. All members use the same symmetric set.
"""
row=db.one('SELECT group_id FROM language_sites WHERE site_id=?',(site_id,))
if not row:return {}
gid=row['group_id'];group=db.one('SELECT * FROM language_groups WHERE id=?',(gid,))
members=db.rows('''SELECT m.unit_id,s.locale,e.route,e.status,e.robots,e.canonical,t.domain,t.id AS site_id
FROM translation_members m JOIN entries e ON e.id=m.entry_id AND e.site_id=m.site_id
JOIN language_sites s ON s.site_id=m.site_id JOIN sites t ON t.id=m.site_id
JOIN translation_units u ON u.id=m.unit_id
WHERE u.group_id=? AND m.state='approved' ''',(gid,))
by_unit={}
for item in members:
if item['status']!='published' or 'noindex' in item['robots'].lower():continue
try:domain=_domain(item)
except ValueError:continue
if item['canonical'] and item['canonical'].rstrip('/') != (domain+item['route']).rstrip('/'):
continue # a canonical elsewhere is not an indexable translation target
by_unit.setdefault(item['unit_id'],[]).append((item['site_id'],item['route'],item['locale'],domain+item['route']))
result={}
for linked in by_unit.values():
if len(linked)<2:continue
peers=sorted([(locale,url) for _,_,locale,url in linked],key=lambda x:x[0])
# x-default is explicit only if an approved default-language peer exists.
default=next((url for locale,url in peers if locale==group['default_locale']),None)
if default:peers.append(('x-default',default))
for sid,route,_,_ in linked:
if sid==site_id:result[route]=peers
return result
def inject_seo(html,locale,links):
"""Lightweight escape-safe HTML head augmentation without reparsing 5k pages."""
html=re.sub(r"(<html\b[^>]*?\blang=)[\"'\"][^\"']*[\"'\"]",lambda m:m.group(1)+'"'+html_escape(locale,quote=True)+'"',html,count=1,flags=re.I)
if not re.search(r'<html\b[^>]*\blang=',html,flags=re.I):
html=re.sub(r'<html\b','<html lang="'+html_escape(locale,quote=True)+'"',html,count=1,flags=re.I)
if links and '</head>' in html.lower():
tags=''.join('<link rel="alternate" hreflang="'+html_escape(language,quote=True)+'" href="'+html_escape(url,quote=True)+'">' for language,url in links)
html=re.sub(r'</head>',tags+'</head>',html,count=1,flags=re.I)
# Visible accessible language switch; never fabricate links for missing translations.
labels={'zh-CN':'简体中文','en':'English','fr-FR':'Français','ar':'العربية',
'es':'Español','ru':'Русский','pt':'Português'}
choices=[(language,url) for language,url in links if language!='x-default']
nav='<nav class="sf-language-switch" aria-label="选择网站语言">'+''.join(
'<a lang="'+html_escape(lang,quote=True)+'" hreflang="'+html_escape(lang,quote=True)+
'" href="'+html_escape(url,quote=True)+'"'+(' aria-current="page"' if lang==locale else '')+
'>'+html_escape(labels.get(lang,lang))+'</a>' for lang,url in choices)+'</nav>'
html=re.sub(r'</header>',lambda _m:nav+'</header>',html,count=1,flags=re.I)
return html
def sitemap_alternates(links):
return ''.join('<xhtml:link rel="alternate" hreflang="'+xml_escape(locale)+'" href="'+xml_escape(url)+'"/>' for locale,url in links)
+78 -25
View File
@@ -1,32 +1,87 @@
"""PostgreSQL DB-API bridge for legacy '?' SQL (optional, requires psycopg[binary]).
"""Conservative PostgreSQL DB-API bridge for the existing SQLite-shaped application.
No automatic conversion of existing SQLite files is attempted. Production migration
requires a tested one-time data migration and reconciliation.
The row adapter intentionally supports BOTH row['column'] and row[0], because
several existing queue paths use positional aggregate results while API paths
serialize rows as dictionaries. PostgreSQL data migration remains explicit.
"""
from __future__ import annotations
import re
from contextlib import contextmanager
class PGRow(dict):
"""Mapping-compatible row with SQLite-style positional indexing."""
__slots__ = ('_values',)
def __init__(self, columns, values):
values = tuple(values)
super().__init__(zip(columns, values))
self._values = values
def __getitem__(self, key):
if isinstance(key, int):
return self._values[key]
return super().__getitem__(key)
def row_factory(cursor):
columns = [col.name for col in (cursor.description or ())]
return lambda values: PGRow(columns, values)
def placeholders(sql: str) -> str:
"""Translate SQLite positional placeholders outside SQL quoted strings."""
out=[];quote=None;i=0
"""Translate ? placeholders without modifying quoted strings or comments."""
out=[]; i=0; state='code'
while i<len(sql):
ch=sql[i]
if quote:
ch=sql[i]; nxt=sql[i+1] if i+1<len(sql) else ''
if state=='code':
if ch in ("'",'"'):
state=ch;out.append(ch)
elif ch=='-' and nxt=='-':
state='line';out.extend((ch,nxt));i+=1
elif ch=='/' and nxt=='*':
state='block';out.extend((ch,nxt));i+=1
elif ch=='?':out.append('%s')
else:out.append(ch)
elif state in ("'",'"'):
out.append(ch)
if ch==quote:
if i+1<len(sql) and sql[i+1]==quote:
out.append(sql[i+1]);i+=1
else:quote=None
elif ch in ("'",'"'):
quote=ch;out.append(ch)
elif ch=='?':out.append('%s')
else:out.append(ch)
if ch==state:
if nxt==state:out.append(nxt);i+=1
else:state='code'
elif state=='line':
out.append(ch)
if ch=='\n':state='code'
elif state=='block':
out.append(ch)
if ch=='*' and nxt=='/':out.append(nxt);i+=1;state='code'
i+=1
return ''.join(out)
def statements(script: str):
"""Split migration DDL at top-level semicolons, not inside quoted literals."""
start=0;i=0;state='code'
while i<len(script):
ch=script[i];nxt=script[i+1] if i+1<len(script) else ''
if state=='code':
if ch in ("'",'"'):state=ch
elif ch=='-' and nxt=='-':state='line';i+=1
elif ch=='/' and nxt=='*':state='block';i+=1
elif ch==';':
candidate=script[start:i].strip()
if candidate:yield candidate
start=i+1
elif state in ("'",'"'):
if ch==state:
if nxt==state:i+=1
else:state='code'
elif state=='line':
if ch=='\n':state='code'
elif state=='block':
if ch=='*' and nxt=='/':i+=1;state='code'
i+=1
candidate=script[start:].strip()
if candidate:yield candidate
def normalize(sql: str) -> str:
sql=sql.strip()
if re.match(r'^INSERT\s+OR\s+IGNORE\s+INTO\b',sql,re.I):
@@ -38,8 +93,7 @@ def normalize(sql: str) -> str:
class PGConnection:
def __init__(self,dsn):
import psycopg
from psycopg.rows import dict_row
self.connection=psycopg.connect(dsn,row_factory=dict_row,connect_timeout=5)
self.connection=psycopg.connect(dsn,row_factory=row_factory,connect_timeout=5)
self.total_changes=0
def execute(self,sql,params=()):
@@ -49,20 +103,19 @@ class PGConnection:
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',table):raise ValueError('Invalid table name')
return self.connection.execute('SELECT column_name AS name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=%s',(table,))
if stripped.upper()=='BEGIN IMMEDIATE':
# Conservative transaction-wide lock while porting SQLite queue semantics.
# Replace with per-site advisory locks before large multi-worker scale.
# Serialize short queue claim/limit transactions, not the worker execution.
# This prevents per-site races between concurrent enqueuers and claimers.
return self.connection.execute('SELECT pg_advisory_xact_lock(823947231)')
if stripped.upper().startswith('PRAGMA '):
return _EmptyResult()
if stripped.upper().startswith('PRAGMA '):return _EmptyResult()
cursor=self.connection.execute(normalize(sql),params)
if cursor.rowcount and cursor.rowcount>0 and stripped.split(None,1)[0].upper() in ('UPDATE','INSERT','DELETE'):
self.total_changes+=cursor.rowcount
return cursor
def executescript(self,script):
# Schema migrations contain only simple DDL and SQLite PRAGMA statements.
for statement in script.split(';'):
if statement.strip():self.execute(statement)
for statement in statements(script):
if not statement.lstrip().startswith('--') or '\n' in statement:
self.execute(statement)
def __enter__(self):return self
def __exit__(self,typ,val,tb):
+239
View File
@@ -0,0 +1,239 @@
"""V8.18 PostgreSQL disaster recovery, deliberately OFFLINE and fail-closed.
The management process and all Workers must be stopped while backing up the
application database and shared filesystem to guarantee an application-level
consistent pair. No switch-over to production is performed by this module.
pg_dump/pg_restore must be installed on the operator's administrative host.
Credentials are passed in child environment (not command arguments or logs).
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import re
import shutil
import stat
import subprocess
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from urllib.parse import parse_qsl, unquote, urlsplit
FORMAT = 'siteforge.pg.offline.v1'
MEDIA_ROOTS = ('media', 'legacy', 'builds', 'archives')
ALLOWED_EXTRA = {'manifest.json', 'database.dump'}
MAX_FILES = 250_000
MAX_UNCOMPRESSED = 30_000_000_000
MAX_RATIO = 1000
class RecoveryError(RuntimeError):
pass
def connection_env(url: str, base: dict | None = None) -> dict:
"""PostgreSQL URL => libpq process env; never include password on argv."""
p = urlsplit(url)
if p.scheme not in ('postgresql', 'postgres') or not p.hostname or not p.path.strip('/'):
raise RecoveryError('Require valid PostgreSQL URL with hostname and database')
if '/' in unquote(p.path[1:]):
raise RecoveryError('Invalid database name')
opts = dict(parse_qsl(p.query, keep_blank_values=True))
allowed = {'sslmode': 'PGSSLMODE', 'sslrootcert': 'PGSSLROOTCERT',
'sslcert': 'PGSSLCERT', 'sslkey': 'PGSSLKEY',
'connect_timeout': 'PGCONNECT_TIMEOUT'}
if set(opts) - set(allowed):
raise RecoveryError('Unsupported PostgreSQL URL options')
try:
port = str(p.port or 5432)
except ValueError as exc:
raise RecoveryError('Invalid PostgreSQL port') from exc
env = dict(os.environ if base is None else base)
env.update({'PGHOST': p.hostname, 'PGPORT': port,
'PGDATABASE': unquote(p.path[1:]),
'PGUSER': unquote(p.username or ''),
'PGPASSWORD': unquote(p.password or ''),
'PGAPPNAME': 'siteforge-v818-maintenance'})
env.update({allowed[k]: v for k, v in opts.items()})
# Maintenance must never silently fall back to a developer's ~/.pgpass.
env['PGPASSFILE'] = os.devnull
return env
def run_pg(argv: list[str], env: dict, timeout: int = 1800) -> subprocess.CompletedProcess:
executable = argv[0]
if shutil.which(executable) is None:
raise RecoveryError(f'{executable} not installed on this host')
try:
result = subprocess.run(argv, env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout, check=False)
except subprocess.TimeoutExpired as exc:
raise RecoveryError(f'{executable} timed out') from exc
except OSError as exc:
raise RecoveryError(f'{executable} execution failed') from exc
if result.returncode:
# pg tools can echo target location or credentials through external configs;
# do not include untrusted tool stderr in user-facing errors.
raise RecoveryError(f'{executable} returned non-zero exit code {result.returncode}')
return result
def sha256_stream(stream) -> str:
digest = hashlib.sha256()
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def sha256_path(path: Path) -> str:
with path.open('rb') as f:
return sha256_stream(f)
def media_files(source: Path):
for folder in MEDIA_ROOTS:
root = source / folder
if root.is_symlink():
raise RecoveryError(f'Symlinked storage root not allowed: {folder}')
if not root.exists():
continue
for node in sorted(root.rglob('*')):
if node.is_symlink():
raise RecoveryError('Symlinked file in storage tree')
if node.is_file():
yield node, node.relative_to(source).as_posix()
def safe_member(name: str) -> bool:
p = PurePosixPath(name)
return bool(name and not name.endswith('/') and '\\' not in name and '\0' not in name
and not p.is_absolute() and ':' not in name and
all(part not in ('', '.', '..') for part in name.split('/')) and
(name in ALLOWED_EXTRA or p.parts[0] in MEDIA_ROOTS))
def _inspect(path: Path):
try:
with zipfile.ZipFile(path) as z:
infos = z.infolist()
if len(infos) > MAX_FILES or sum(i.file_size for i in infos) > MAX_UNCOMPRESSED:
raise RecoveryError('Backup resource limits exceeded')
items = {}
for info in infos:
if not safe_member(info.filename) or info.filename in items:
raise RecoveryError('Unsafe or duplicate archive member')
if stat.S_ISLNK(info.external_attr >> 16):
raise RecoveryError('Symlink in backup')
if info.file_size > 5_000_000 and info.compress_size == 0:
raise RecoveryError('Invalid compressed member')
if info.file_size > 5_000_000 and info.file_size / max(info.compress_size, 1) > MAX_RATIO:
raise RecoveryError('Unreasonable compression ratio')
items[info.filename] = info
if set(ALLOWED_EXTRA) - set(items):
raise RecoveryError('Database dump or manifest missing')
if items['manifest.json'].file_size > 2_000_000:
raise RecoveryError('Manifest exceeds size limit')
manifest = json.loads(z.read('manifest.json'))
if manifest.get('format') != FORMAT or not isinstance(manifest.get('files'), dict):
raise RecoveryError('Not a compatible SiteForge PG backup')
documented = manifest['files']
if set(documented) != set(items) - {'manifest.json'}:
raise RecoveryError('Manifest and ZIP files differ')
for name, rec in documented.items():
if (not isinstance(rec, dict) or not isinstance(rec.get('size'), int)
or rec['size'] != items[name].file_size or
not re.fullmatch('[0-9a-f]{64}', str(rec.get('sha256', '')))):
raise RecoveryError('Invalid file metadata')
with z.open(name) as f:
digest = sha256_stream(f)
if digest != rec['sha256']:
raise RecoveryError('Checksum mismatch: ' + name[:100])
return manifest
except (zipfile.BadZipFile, json.JSONDecodeError, KeyError, UnicodeError) as exc:
raise RecoveryError('Corrupted PostgreSQL backup') from exc
def verify(path: str | Path) -> dict:
return _inspect(Path(path).resolve())
def backup(url: str, data: str | Path, archive: str | Path, *, confirmed_offline: bool = False,
timeout: int = 1800) -> dict:
if not confirmed_offline:
raise RecoveryError('Stop all Web/Workers and pass --confirmed-offline')
env = connection_env(url)
source = Path(data).resolve()
if not source.is_dir():
raise RecoveryError('Shared storage directory does not exist')
destination = Path(archive).resolve()
if destination.exists():
raise RecoveryError('Refusing to overwrite an existing backup')
destination.parent.mkdir(parents=True, exist_ok=True)
staging = destination.with_name(destination.name + '.partial')
if staging.exists():
raise RecoveryError('Partial backup file already exists')
with tempfile.TemporaryDirectory(prefix='sf-pg-backup-') as temp:
dump = Path(temp) / 'database.dump'
run_pg(['pg_dump', '--format=custom', '--no-owner', '--no-acl',
'--file', str(dump)], env, timeout)
if not dump.is_file() or dump.stat().st_size == 0:
raise RecoveryError('Empty database dump')
run_pg(['pg_restore', '--list', str(dump)], env, min(120, timeout))
members = [(dump, 'database.dump'), *media_files(source)]
if len(members) > MAX_FILES:
raise RecoveryError('Too many storage files')
if sum(p.stat().st_size for p, _ in members) > MAX_UNCOMPRESSED:
raise RecoveryError('Too much data for one backup')
manifest = {'format': FORMAT, 'created_at': datetime.now(timezone.utc).isoformat(),
'database': 'postgresql', 'consistent': 'operator-confirmed-offline',
'files': {}}
try:
with zipfile.ZipFile(staging, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=5,
allowZip64=True) as z:
for path, name in members:
manifest['files'][name] = {'size': path.stat().st_size, 'sha256': sha256_path(path)}
z.write(path, name)
z.writestr('manifest.json', json.dumps(manifest, ensure_ascii=False, sort_keys=True))
_inspect(staging)
staging.replace(destination)
finally:
staging.unlink(missing_ok=True)
return {'archive': str(destination), 'files': len(members),
'bytes': sum(x['size'] for x in manifest['files'].values())}
def restore_new(url: str, archive: str | Path, data: str | Path, *, confirmed_offline: bool = False,
timeout: int = 1800) -> dict:
"""Restore ONLY into new, EMPTY PG database and non-existing storage path."""
if not confirmed_offline:
raise RecoveryError('Explicit offline confirmation is required')
path = Path(archive).resolve()
manifest = verify(path)
dest = Path(data).resolve()
if dest.exists():
raise RecoveryError('Target storage must not exist (restore into new directory)')
dest.parent.mkdir(parents=True, exist_ok=True)
env = connection_env(url)
# Empty target database check: exclude PostgreSQL system/extension internals.
sql = "SELECT COUNT(*) FROM pg_catalog.pg_class WHERE relnamespace = 'public'::regnamespace AND relkind IN ('r','p','v','m','S','f')"
result = run_pg(['psql', '--no-psqlrc', '--tuples-only', '--no-align', '--command', sql], env, 40)
if result.stdout.strip() != b'0':
raise RecoveryError('Target database public schema must be empty')
with tempfile.TemporaryDirectory(prefix='.sf-pg-restore-', dir=dest.parent) as tmp:
stage = Path(tmp) / 'siteforge'; stage.mkdir()
with zipfile.ZipFile(path) as z:
for name in manifest['files']:
target = stage / name
target.parent.mkdir(parents=True, exist_ok=True)
with z.open(name) as src, target.open('wb') as out:
shutil.copyfileobj(src, out, 1024 * 1024)
dump = stage / 'database.dump'
run_pg(['pg_restore', '--exit-on-error', '--single-transaction', '--no-owner', '--no-acl',
'--dbname', env['PGDATABASE'], str(dump)], env, timeout)
dump.unlink()
stage.replace(dest)
return {'database': 'restored-into-empty-target', 'storage': str(dest),
'media_files': len(manifest['files']) - 1}
+175 -41
View File
@@ -3,9 +3,9 @@ import hashlib,json,os,time
from pathlib import Path
from fastapi import APIRouter,HTTPException,Request,Body
from pydantic import BaseModel,Field
import httpx
from . import db,enterprise,quality,secret_refs,safe_http
from . import db,enterprise,quality,secret_refs,safe_http,jobs
from .deploy_agent import signature
from . import release_targets
router=APIRouter(prefix='/api/production',tags=['Production delivery'])
@@ -50,55 +50,189 @@ def approved(site_id,build_id):
if not request:raise HTTPException(409,'Independent release approval required')
return row
@router.post('/sites/{site_id}/deploy')
def _target_config(target, *, resolve_secret=True):
if target not in ('staging','production'):
raise HTTPException(400,'无效的目标环境')
prefix='SITEFORGE_DEPLOY_'+target.upper()
url=os.environ.get(prefix+'_URL','').rstrip('/')
ref=os.environ.get(prefix+'_SECRET_REF','')
if not resolve_secret:
if ref:
try:secret_refs.validate_reference(ref)
except secret_refs.SecretReferenceError:raise HTTPException(503,'密钥引用格式无效')
if not url.startswith('https://') or not (ref or os.environ.get(prefix+'_SECRET')):
raise HTTPException(503,'请先配置 HTTPS 发布目标和密钥引用')
return url,''
try:secret=secret_refs.resolve(ref) if ref else os.environ.get(prefix+'_SECRET','')
except secret_refs.SecretReferenceError:raise HTTPException(503,'未能读取远程发布密钥')
if not url.startswith('https://') or len(secret)<32:
raise HTTPException(503,'请先配置 HTTPS 发布目标和长度不少于32位的密钥')
return url,secret
def _remote(site,url,secret,method,path,body=b''):
stamp=str(int(time.time()))
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site,stamp,body,method,path)}
if body:headers['Content-Type']='application/zip'
try:
if method=='POST':response=safe_http.post(url+path,body=body,headers=headers,timeout=120)
else:response=safe_http.fetch(url+path,headers=headers,method='GET',timeout=30)
# 4xx are validation failures, not retryable network errors.
if response.status_code in (400,401,403,404,409,413):
raise HTTPException(409,'远程发布端拒绝操作,请检查签名、存档完整性或版本状态')
response.raise_for_status()
return response.json()
except HTTPException:raise
except (safe_http.OutboundError,OSError,ValueError) as ex:
# Never include URL, secret, request headers or upstream response in error message.
raise HTTPException(502,'远程连接或回应失败,请从任务中心重试') from ex
def _archive_for(site_id,record):
archive=Path(record['zip_path']).resolve()
expected_root=(db.DATA/'archives').resolve()
if not archive.is_relative_to(expected_root) or not archive.name.startswith(site_id+'-') or not archive.is_file():
raise HTTPException(409,'构建 ZIP 不存在或地址不安全')
if archive.stat().st_size>120*1024*1024:
raise HTTPException(409,'构建包超出 Agent 上传限制')
return archive
def run_delivery(site_id,kind,opts,job_id):
"""Called only by Worker subprocess; re-check approvals before changing live pointer."""
target=opts['target']
mode=release_targets.backend(target)
if mode=='agent':url,secret=_target_config(target)
if kind=='remote_deploy':
build_id=opts['build_id']
record=approved(site_id,build_id)
archive=_archive_for(site_id,record)
if mode!='agent':
value=release_targets.deploy(target,site_id,archive)
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,build_id,'worker:'+job_id,'remote:'+target,db.now()))
db.insert('''INSERT INTO remote_releases VALUES (?,?,?,?,?,?)
ON CONFLICT(site_id,target,release_id) DO UPDATE SET
deployed_at=excluded.deployed_at,actor=excluded.actor''',
(site_id,build_id,target,value['release'],'worker:'+job_id,db.now()))
return {**value,'build_id':build_id}
body=archive.read_bytes()
digest=hashlib.sha256(body).hexdigest()
# Stage only: no traffic change on upload or checksum failure.
staged=_remote(site_id,url,secret,'POST',f'/staging/{site_id}',body)
release=staged['release']
if digest!=staged.get('sha256') or release!=digest[:20]:
raise HTTPException(409,'远程存档摘要不匹配,未激活')
verified=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
if not verified.get('verified') or verified.get('sha256')!=digest:
raise HTTPException(409,'远程文件校验失败,未激活')
activated=_remote(site_id,url,secret,'POST',f'/sites/{site_id}/activate/{release}')
if activated.get('current')!=release or not activated.get('active'):
raise HTTPException(502,'远程版本切换未确认')
confirmation=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
# Verify public document is actually served, not only that the pointer changed.
with __import__('zipfile').ZipFile(archive) as package:
index_hash=hashlib.sha256(package.read('index.html')).hexdigest()
probe_ok=False
try:
response=safe_http.fetch(f'{url}/public/{site_id}/',timeout=20,max_bytes=8_000_000)
probe_ok=(response.status_code==200 and hashlib.sha256(response.content).hexdigest()==index_hash)
except (safe_http.OutboundError,OSError,ValueError):
pass
if not confirmation.get('active') or confirmation.get('sha256')!=digest or not probe_ok:
previous=activated.get('previous')
if previous and previous!=release:
try:_remote(site_id,url,secret,'POST',f'/sites/{site_id}/rollback/{previous}')
except Exception:pass
raise HTTPException(502,'发布后内容验证未通过,已尝试恢复旧版本,请人工复核')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,build_id,'worker:'+job_id,'remote:'+target,db.now()))
db.insert('''INSERT INTO remote_releases VALUES (?,?,?,?,?,?)
ON CONFLICT(site_id,target,release_id) DO UPDATE SET
deployed_at=excluded.deployed_at,actor=excluded.actor''',
(site_id,build_id,target,release,'worker:'+job_id,db.now()))
return {'published':True,'release':release,'build_id':build_id,'target':target,
'files':confirmation['files'],'sha256':digest,'previous':activated.get('previous')}
if kind=='remote_rollback':
release=opts['release_id']
old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?',
(site_id,target,release))
if not old:raise HTTPException(404,'只能回滚至本站曾发布的历史版本')
if mode!='agent':
value=release_targets.rollback(target,site_id,release)
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,old['build_id'],'worker:'+job_id,
'remote-rollback:'+target,db.now()))
return {**value,'build_id':old['build_id']}
verified=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
if not verified.get('verified'):raise HTTPException(409,'旧版本文件校验失败')
result=_remote(site_id,url,secret,'POST',f'/sites/{site_id}/rollback/{release}')
confirmation=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}')
if not confirmation.get('active'):raise HTTPException(502,'回滚后未能确认线上版本')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',
(enterprise.uid(),site_id,old['build_id'],'worker:'+job_id,'remote-rollback:'+target,db.now()))
return {'rolled_back':True,'target':target,'release':release,'build_id':old['build_id']}
raise ValueError('Unknown delivery job kind')
@router.get('/sites/{site_id}/delivery-status')
def delivery_status(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
active=db.rows("SELECT id,kind,state,attempts,error,created_at,result_json FROM build_jobs WHERE site_id=? AND kind IN ('remote_deploy','remote_rollback') ORDER BY created_at DESC LIMIT 30",(site_id,))
history=db.rows('SELECT * FROM remote_releases WHERE site_id=? ORDER BY deployed_at DESC LIMIT 30',(site_id,))
# Return target readiness without revealing hostnames or credential refs.
configured={}
modes={}
for t in ('staging','production'):
modes[t]=release_targets.backend(t)
try:
if modes[t]=='agent':_target_config(t,resolve_secret=False)
else:release_targets.config(t)
configured[t]=True
except HTTPException:configured[t]=False
return {'jobs':active,'history':history,'configured':configured,'backends':modes}
@router.get('/sites/{site_id}/preflight/{build_id}')
def preflight(request:Request,site_id:str,build_id:str):
enterprise.require(request,site_id,'viewer')
try:
record=approved(site_id,build_id)
_archive_for(site_id,record)
return {'ready':True,'build_id':build_id,'message':'已满足构建、质量、迁移和独立审批条件'}
except HTTPException as exc:
return {'ready':False,'build_id':build_id,'message':str(exc.detail)}
@router.post('/sites/{site_id}/deploy',status_code=202)
def push(request:Request,site_id:str,payload:DeployRequest):
enterprise.require(request,site_id,'owner')
record=approved(site_id,payload.build_id)
# Environment-specific endpoint is operator managed, never accepted from user JSON.
prefix='SITEFORGE_DEPLOY_'+payload.target.upper()
url=os.environ.get(prefix+'_URL','').rstrip('/')
secret_ref=os.environ.get(prefix+'_SECRET_REF','')
try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','')
except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable')
if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured')
archive=Path(record['zip_path']).resolve()
expected_root=(db.DATA/'archives').resolve()
if (not archive.is_relative_to(expected_root) or not archive.name.startswith(site_id+'-') or not archive.is_file()):raise HTTPException(409,'Build artifact not found')
data=archive.read_bytes()
stamp=str(int(time.time()))
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site_id,stamp,data,'POST',f'/deploy/{site_id}'),'Content-Type':'application/zip'}
try:
response=safe_http.post(f'{url}/deploy/{site_id}',body=data,headers=headers,timeout=120)
response.raise_for_status()
result=response.json()
except (safe_http.OutboundError,OSError,ValueError) as ex:raise HTTPException(502,'Remote release failed')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,payload.build_id,request.state.actor,'remote:'+payload.target,db.now()))
db.insert('INSERT INTO remote_releases VALUES (?,?,?,?,?,?)',(site_id,payload.build_id,payload.target,result['release'],request.state.actor,db.now()))
return {'published':True,'target':payload.target,'build':payload.build_id,'remote':result}
_archive_for(site_id,record)
if release_targets.backend(payload.target)=='agent':_target_config(payload.target,resolve_secret=False)
else:release_targets.config(payload.target)
key=request.headers.get('idempotency-key')
try:return jobs.submit_delivery(site_id,'remote_deploy',
{'target':payload.target,'build_id':payload.build_id},request.state.actor,key)
except jobs.JobConflict as exc:raise HTTPException(409,str(exc))
except jobs.JobCapacityError as exc:raise HTTPException(429,str(exc))
class RollbackRequest(BaseModel):
release_id:str=Field(pattern=r'^[0-9a-f]{20}$')
target:str=Field(default='staging',pattern='^(staging|production)$')
@router.post('/sites/{site_id}/rollback')
@router.post('/sites/{site_id}/rollback',status_code=202)
def rollback_remote(request:Request,site_id:str,payload:RollbackRequest):
enterprise.require(request,site_id,'owner')
old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?',
(site_id,payload.target,payload.release_id))
if not old:raise HTTPException(404,'仅允许回滚到本站曾经部署过的版本')
prefix='SITEFORGE_DEPLOY_'+payload.target.upper()
url=os.environ.get(prefix+'_URL','').rstrip('/')
secret_ref=os.environ.get(prefix+'_SECRET_REF','')
try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','')
except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable')
if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured')
stamp=str(int(time.time()))
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site_id,stamp,b'','POST',f'/sites/{site_id}/rollback/{payload.release_id}')}
try:
r=safe_http.post(f'{url}/sites/{site_id}/rollback/{payload.release_id}',headers=headers,timeout=30)
r.raise_for_status()
result=r.json()
except (safe_http.OutboundError,OSError,ValueError) as ex:raise HTTPException(502,'Remote rollback failed')
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,old['build_id'],request.state.actor,'remote-rollback:'+payload.target,db.now()))
return result
if not old:raise HTTPException(404,'仅允许回滚到本站曾部署过的版本')
if release_targets.backend(payload.target)=='agent':_target_config(payload.target,resolve_secret=False)
else:release_targets.config(payload.target)
key=request.headers.get('idempotency-key')
try:return jobs.submit_delivery(site_id,'remote_rollback',
{'target':payload.target,'release_id':payload.release_id},request.state.actor,key)
except jobs.JobConflict as exc:raise HTTPException(409,str(exc))
except jobs.JobCapacityError as exc:raise HTTPException(429,str(exc))
+367
View File
@@ -0,0 +1,367 @@
"""Native publication backends. Only Worker calls these methods.
SFTP: verified immutable directories and an atomic POSIX symlink switch.
S3: verified immutable object prefixes with a conditional current.json pointer.
S3 requires a pointer-aware edge/gateway; it is not a naked S3 website switch.
"""
from __future__ import annotations
import hashlib, io, json, os, posixpath, re, stat, uuid, zipfile
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlsplit
from fastapi import HTTPException
from . import secret_refs, safe_http
from .deploy_agent import validate_archive
SITE_RE=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$')
RELEASE_RE=re.compile(r'^[0-9a-f]{20}$')
MAX_ARCHIVE=120*1024*1024
def _env(target,field):
return os.getenv(f'SITEFORGE_DEPLOY_{target.upper()}_{field}','').strip()
def backend(target):
if target not in ('staging','production'):raise HTTPException(400,'未知发布环境')
value=_env(target,'TYPE') or 'agent'
if value not in ('agent','sftp','s3'):raise HTTPException(503,'发布目标类型无效')
return value
def _root(value):
if not value.startswith('/') or value=='/' or '\\' in value or '..' in value.split('/'):
raise HTTPException(503,'远程发布根目录必须为安全的绝对路径')
normalized=posixpath.normpath(value)
if normalized in ('/','/tmp') or len(normalized)<4:raise HTTPException(503,'远程发布根目录范围过大')
return normalized
def config(target, *, check_files=False):
kind=backend(target)
if kind=='agent':return {'type':'agent'}
public=_env(target,'PUBLIC_URL')
parsed=urlsplit(public)
if parsed.scheme!='https' or not parsed.netloc or parsed.username or parsed.password:
raise HTTPException(503,'原生 SFTP/S3 发布必须提前配置 HTTPS PUBLIC_URL')
if kind=='sftp':
host=_env(target,'HOST');user=_env(target,'USERNAME')
root=_root(_env(target,'REMOTE_ROOT'))
known=_env(target,'KNOWN_HOSTS_FILE');key=_env(target,'SSH_KEY_FILE')
if not host or not user or not known or not key or '/' in host or ':' in host:
raise HTTPException(503,'SFTP 缺少主机、用户、known_hosts 或私钥配置')
try:port=int(_env(target,'PORT') or 22)
except ValueError:raise HTTPException(503,'SFTP 端口无效')
if not 1<=port<=65535:raise HTTPException(503,'SFTP 端口无效')
if check_files and (not Path(known).is_file() or not Path(key).is_file()):
raise HTTPException(503,'SFTP 私钥或 known_hosts 文件未挂载')
return {'type':kind,'host':host,'user':user,'root':root,'known_hosts':known,'ssh_key':key,'port':port}
bucket=_env(target,'BUCKET');region=_env(target,'REGION');prefix=_env(target,'PREFIX') or 'siteforge'
if not bucket or not region or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9._-]{1,62}',bucket):
raise HTTPException(503,'S3 存储桶或区域配置不完整')
if prefix.startswith('/') or '..' in prefix.split('/') or not re.fullmatch(r'[a-zA-Z0-9/_-]+',prefix):
raise HTTPException(503,'S3 对象前缀不安全')
endpoint=_env(target,'ENDPOINT_URL')
if endpoint:
parsed=urlsplit(endpoint)
if parsed.scheme!='https' or not parsed.netloc or parsed.username or parsed.password or parsed.path.strip('/'):
raise HTTPException(503,'S3 兼容服务端点必须为 HTTPS 根地址')
if _env(target,'POINTER_AWARE')!='1':
raise HTTPException(503,'S3 必须配置可解析 current.json 的发布网关(POINTER_AWARE=1)')
return {'type':kind,'bucket':bucket,'region':region,'prefix':prefix.strip('/'), 'endpoint':endpoint}
def _archive(raw:bytes):
if len(raw)>MAX_ARCHIVE:raise ValueError('超出单次发布大小上限')
validate_archive(raw)
output={}
with zipfile.ZipFile(io.BytesIO(raw)) as z:
for member in z.infolist():
if member.is_dir():continue
path=member.filename
# validate_archive already checked all paths, duplicates, symlinks and size bounds.
output[path]=z.read(member)
hashes={k:hashlib.sha256(v).hexdigest() for k,v in output.items()}
digest=hashlib.sha256(raw).hexdigest()
return digest[:20],digest,output,hashes
def _manifest(digest,hashes):
return json.dumps({'sha256':digest,'files':hashes},sort_keys=True,separators=(',',':')).encode()
def _probe(target,site,body):
public_url=_env(target,'PUBLIC_URL')
if not public_url:
raise HTTPException(503,'正式发布必须配置站点 PUBLIC_URL 以执行上线探测')
parsed=urlsplit(public_url)
if parsed.scheme!='https' or not parsed.netloc or parsed.username or parsed.password:
raise HTTPException(503,'PUBLIC_URL 必须为 HTTPS 网站地址')
url=public_url.rstrip('/')+'/'
try:
response=safe_http.fetch(url,timeout=25,max_bytes=8_000_000)
if response.status_code!=200 or hashlib.sha256(response.content).digest()!=hashlib.sha256(body).digest():
raise HTTPException(502,'上线首页内容与构建版本不一致')
except safe_http.OutboundError as exc:
raise HTTPException(502,'上线地址不可访问,需检查发布和 DNS/CDN 状态') from exc
return True
# ---------------- SFTP ----------------
@contextmanager
def _sftp_connection(cfg):
"""Strict host-key verification; private key mounted into Worker, never transmitted to API."""
try:import paramiko
except ImportError as exc:raise HTTPException(503,'缺少可选的 Paramiko SFTP 依赖') from exc
client=paramiko.SSHClient()
client.load_host_keys(cfg['known_hosts'])
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(cfg['host'],port=cfg['port'],username=cfg['user'],
key_filename=cfg['ssh_key'],look_for_keys=False,allow_agent=False,
timeout=12,auth_timeout=12,banner_timeout=12)
conn=client.open_sftp()
try:yield conn
finally:conn.close()
finally:client.close()
def _sftp_type(sftp,path):
try:return sftp.lstat(path).st_mode
except OSError:return None
def _mkdirs(sftp,path):
cur=''
for part in path.split('/'):
if not part:continue
cur+='/'+part
mode=_sftp_type(sftp,cur)
if mode is None:
sftp.mkdir(cur)
mode=_sftp_type(sftp,cur)
if not mode or not stat.S_ISDIR(mode):raise ValueError('远端目录包含符号链接或非目录')
def _read(sftp,path,limit=4*1024*1024):
with sftp.file(path,'rb') as f:raw=f.read(limit+1)
if len(raw)>limit:raise ValueError('远程校验文件过大')
return raw
def _write(sftp,path,data):
with sftp.file(path,'wb') as f:f.write(data);f.flush()
def _current_sftp(sftp,root):
pointer=root+'/current'
mode=_sftp_type(sftp,pointer)
if mode is None:return None
if not stat.S_ISLNK(mode):raise ValueError('current 不是受管符号链接')
link=sftp.readlink(pointer)
if not re.fullmatch(r'releases/[0-9a-f]{20}',link):raise ValueError('current 指向不安全')
return link.rsplit('/',1)[1]
def _verify_sftp(sftp,root,release):
folder=root+'/releases/'+release
mode=_sftp_type(sftp,folder)
if mode is None or not stat.S_ISDIR(mode):raise ValueError('远程版本目录缺失或不安全')
raw=_read(sftp,folder+'/.siteforge-manifest.json')
obj=json.loads(raw)
checks=obj.get('files')
if not isinstance(checks,dict) or 'index.html' not in checks:raise ValueError('远端文件清单无效')
for name,expected in checks.items():
if name.startswith('/') or '..' in name.split('/') or not re.fullmatch('[a-f0-9]{64}',str(expected)):
raise ValueError('远端文件清单包含非法路径或哈希')
mode=_sftp_type(sftp,folder+'/'+name)
if mode is None or not stat.S_ISREG(mode):raise ValueError('远端发布资源缺失或不是普通文件')
with sftp.file(folder+'/'+name,'rb') as f:
dig=hashlib.sha256()
while True:
chunk=f.read(256*1024)
if not chunk:break
dig.update(chunk)
if dig.hexdigest()!=expected:raise ValueError('远端文件哈希不匹配')
return obj
def _activate_sftp(sftp,root,release):
previous=_current_sftp(sftp,root)
candidate=root+'/current-'+release+'.next'
if _sftp_type(sftp,candidate) is not None:raise ValueError('发现冲突的发布指针临时文件')
sftp.symlink('releases/'+release,candidate)
try:
# OpenSSH posix-rename is atomic and replaces current. Fail closed if unsupported.
sftp.posix_rename(candidate,root+'/current')
finally:
if _sftp_type(sftp,candidate) is not None:sftp.remove(candidate)
if _current_sftp(sftp,root)!=release:raise ValueError('发布指针切换未确认')
return previous
def deploy_sftp(target,site,raw,cfg=None):
cfg=cfg or config(target,check_files=True)
release,digest,files,hashes=_archive(raw)
root=cfg['root'].rstrip('/')+'/'+site
with _sftp_connection(cfg) as sftp:
_mkdirs(sftp,root+'/releases')
folder=root+'/releases/'+release
if _sftp_type(sftp,folder) is None:
tmp=root+'/releases/.staging-'+release+'-'+uuid.uuid4().hex[:12]
_mkdirs(sftp,tmp)
try:
for name,body in files.items():
parent=posixpath.dirname(name)
if parent:_mkdirs(sftp,tmp+'/'+parent)
_write(sftp,tmp+'/'+name,body)
_write(sftp,tmp+'/.siteforge-manifest.json',_manifest(digest,hashes))
# Immutable version path is not overwritten on retry.
sftp.rename(tmp,folder)
except Exception:
# No traffic change on failure. Interrupted transfers leave an
# isolated temporary directory; a retry uses a fresh name.
raise
meta=_verify_sftp(sftp,root,release)
if meta.get('sha256')!=digest:raise ValueError('现有版本摘要与当前构建不一致')
previous=_current_sftp(sftp,root)
if previous!=release:_activate_sftp(sftp,root,release)
try:_probe(target,site,files['index.html'])
except Exception:
if previous and previous!=release:
with _sftp_connection(cfg) as sftp:
_verify_sftp(sftp,root,previous)
_activate_sftp(sftp,root,previous)
raise
return {'published':True,'release':release,'previous':previous,'files':len(files),'sha256':digest,'target':target,'backend':'sftp'}
def rollback_sftp(target,site,release,cfg=None,expected_home=None):
if not RELEASE_RE.fullmatch(release):raise ValueError('无效的版本号')
cfg=cfg or config(target,check_files=True)
root=cfg['root'].rstrip('/')+'/'+site
with _sftp_connection(cfg) as sftp:
_verify_sftp(sftp,root,release)
previous=_current_sftp(sftp,root)
if previous!=release:_activate_sftp(sftp,root,release)
content=_read(sftp,root+'/releases/'+release+'/index.html',8*1024*1024)
try:_probe(target,site,expected_home if expected_home is not None else content)
except Exception:
if previous and previous!=release:
with _sftp_connection(cfg) as sftp:_activate_sftp(sftp,root,previous)
raise
return {'rolled_back':True,'release':release,'backend':'sftp','target':target}
# ---------------- S3 / compatible object store ----------------
def _s3_client(cfg):
try:import boto3
except ImportError as exc:raise HTTPException(503,'缺少可选的 boto3 S3 依赖') from exc
return boto3.client('s3',region_name=cfg['region'],endpoint_url=cfg['endpoint'] or None)
def _s3_key(cfg,site,path):return cfg['prefix']+'/sites/'+site+'/'+path
def _s3_bytes(s3,bucket,key,max_bytes=4*1024*1024):
response=s3.get_object(Bucket=bucket,Key=key)
data=response['Body'].read(max_bytes+1)
if len(data)>max_bytes:raise ValueError('S3 读取超过限制')
return data
def _s3_current(s3,cfg,site):
key=_s3_key(cfg,site,'current.json')
try:
response=s3.get_object(Bucket=cfg['bucket'],Key=key)
except Exception as exc:
code=getattr(exc,'response',{}).get('Error',{}).get('Code','')
if str(code) in ('404','NoSuchKey','NotFound'):return None,None
raise
data=response['Body'].read(4096)
obj=json.loads(data)
if not RELEASE_RE.fullmatch(obj.get('release','')):raise ValueError('当前 S3 发布指针无效')
return obj['release'],response.get('ETag')
def _s3_verify(s3,cfg,site,release):
path=_s3_key(cfg,site,'releases/'+release+'/')
obj=json.loads(_s3_bytes(s3,cfg['bucket'],path+'.siteforge-manifest.json'))
checks=obj['files']
if 'index.html' not in checks:raise ValueError('S3 版本没有入口页')
for name,digest in checks.items():
if name.startswith('/') or '..' in name.split('/') or not re.fullmatch('[a-f0-9]{64}',str(digest)):
raise ValueError('S3 文件清单路径或哈希非法')
response=s3.get_object(Bucket=cfg['bucket'],Key=path+name)
hasher=hashlib.sha256()
while True:
chunk=response['Body'].read(256*1024)
if not chunk:break
hasher.update(chunk)
if hasher.hexdigest()!=digest:raise ValueError('S3 对象校验失败')
return obj
def _s3_point(s3,cfg,site,release,previous,etag):
key=_s3_key(cfg,site,'current.json')
body=json.dumps({'release':release,'previous':previous},sort_keys=True).encode()
args={'Bucket':cfg['bucket'],'Key':key,'Body':body,'ContentType':'application/json','CacheControl':'no-store'}
if etag:args['IfMatch']=etag
else:args['IfNoneMatch']='*'
s3.put_object(**args)
current,_=_s3_current(s3,cfg,site)
if current!=release:raise ValueError('S3 发布指针未确认')
def _s3_immutable_put(s3,bucket,key,body,**extra):
"""Create-only object write. A retry can reuse only byte-identical keys."""
try:
s3.put_object(Bucket=bucket,Key=key,Body=body,IfNoneMatch='*',**extra)
return
except Exception as exc:
code=str(getattr(exc,'response',{}).get('Error',{}).get('Code',''))
if code not in ('PreconditionFailed','412','ConditionalRequestConflict','409'):
raise
existing=_s3_bytes(s3,bucket,key,max_bytes=max(8_000_000,len(body)))
if hashlib.sha256(existing).digest()!=hashlib.sha256(body).digest():
raise ValueError('现有 S3 对象与当前发布文件不一致,禁止覆盖')
def deploy_s3(target,site,raw,cfg=None):
cfg=cfg or config(target)
release,digest,files,hashes=_archive(raw)
s3=_s3_client(cfg);bucket=cfg['bucket']
base=_s3_key(cfg,site,'releases/'+release+'/')
manifest_key=base+'.siteforge-manifest.json'
try:
exists=_s3_bytes(s3,bucket,manifest_key)
except Exception as exc:
code=getattr(exc,'response',{}).get('Error',{}).get('Code','')
if str(code) not in ('404','NoSuchKey','NotFound'):raise
exists=None
if exists is None:
for name,body in files.items():
import mimetypes
ct=mimetypes.guess_type(name)[0] or 'application/octet-stream'
cache='no-cache' if name.endswith('.html') else 'public, max-age=31536000, immutable'
_s3_immutable_put(s3,bucket,base+name,body,ContentType=ct,CacheControl=cache)
_s3_immutable_put(s3,bucket,manifest_key,_manifest(digest,hashes),
ContentType='application/json',CacheControl='no-store')
manifest=_s3_verify(s3,cfg,site,release)
if manifest.get('sha256')!=digest:raise ValueError('S3 版本摘要冲突')
previous,etag=_s3_current(s3,cfg,site)
if previous!=release:_s3_point(s3,cfg,site,release,previous,etag)
try:_probe(target,site,files['index.html'])
except Exception:
if previous and previous!=release:
current,next_etag=_s3_current(s3,cfg,site)
if current==release:_s3_point(s3,cfg,site,previous,release,next_etag)
raise
return {'published':True,'release':release,'previous':previous,'files':len(files),'sha256':digest,'target':target,'backend':'s3'}
def rollback_s3(target,site,release,cfg=None,expected_home=None):
if not RELEASE_RE.fullmatch(release):raise ValueError('无效的版本号')
cfg=cfg or config(target)
s3=_s3_client(cfg)
_s3_verify(s3,cfg,site,release)
previous,etag=_s3_current(s3,cfg,site)
if previous!=release:_s3_point(s3,cfg,site,release,previous,etag)
body=_s3_bytes(s3,cfg['bucket'],_s3_key(cfg,site,'releases/'+release+'/index.html'),8*1024*1024)
try:_probe(target,site,expected_home if expected_home is not None else body)
except Exception:
if previous and previous!=release:
current,new_etag=_s3_current(s3,cfg,site)
if current==release:_s3_point(s3,cfg,site,previous,release,new_etag)
raise
return {'rolled_back':True,'release':release,'target':target,'backend':'s3'}
def deploy(target,site,archive):
if not SITE_RE.fullmatch(site):raise ValueError('无效的网站标识')
cfg=config(target,check_files=True)
raw=Path(archive).read_bytes()
if cfg['type']=='sftp':return deploy_sftp(target,site,raw,cfg)
if cfg['type']=='s3':return deploy_s3(target,site,raw,cfg)
raise ValueError('不是原生发布目标')
def rollback(target,site,release):
if not SITE_RE.fullmatch(site):raise ValueError('无效的网站标识')
cfg=config(target,check_files=True)
if cfg['type']=='sftp':return rollback_sftp(target,site,release,cfg)
if cfg['type']=='s3':return rollback_s3(target,site,release,cfg)
raise ValueError('不是原生发布目标')
+71
View File
@@ -0,0 +1,71 @@
"""Pointer-aware, read-only static website gateway for SiteForge S3 releases.
This process requires ONLY S3 read permissions. It never takes publishing
credentials and never writes to the bucket. Deploy behind HTTPS reverse proxy.
"""
from __future__ import annotations
import json,os,re
from urllib.parse import unquote
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from .release_targets import SITE_RE, RELEASE_RE
app=FastAPI(title='SiteForge S3 read-only site gateway',docs_url=None,redoc_url=None,openapi_url=None)
def gateway_config():
bucket=os.getenv('SITEFORGE_S3_GATEWAY_BUCKET','').strip()
region=os.getenv('SITEFORGE_S3_GATEWAY_REGION','').strip()
prefix=os.getenv('SITEFORGE_S3_GATEWAY_PREFIX','siteforge').strip('/')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{1,62}',bucket) or not region:
raise HTTPException(503,'S3 gateway storage not configured')
if not prefix or not re.fullmatch(r'[A-Za-z0-9/_-]+',prefix) or '..' in prefix.split('/'):
raise HTTPException(503,'Invalid S3 gateway prefix')
return bucket,region,prefix
def client(region):
try:import boto3
except ImportError as ex:raise HTTPException(503,'boto3 missing from release image') from ex
endpoint=os.getenv('SITEFORGE_S3_GATEWAY_ENDPOINT_URL','').strip()
if endpoint and not endpoint.startswith('https://'):
raise HTTPException(503,'S3 gateway endpoint must use TLS')
return boto3.client('s3',region_name=region,endpoint_url=endpoint or None)
def _read(s3,bucket,key,limit=32*1024*1024):
try:
result=s3.get_object(Bucket=bucket,Key=key)
body=result['Body'].read(limit+1)
if len(body)>limit:raise HTTPException(413,'Static object exceeds gateway response limit')
return body, result
except HTTPException:raise
except Exception as ex:
code=str(getattr(ex,'response',{}).get('Error',{}).get('Code',''))
if code in ('404','NoSuchKey','NotFound'):raise HTTPException(404,'Static resource not found') from ex
raise HTTPException(502,'Storage unavailable') from ex
def resolve_object(site,path,s3,bucket,prefix):
if not SITE_RE.fullmatch(site):raise HTTPException(404,'Invalid site')
path=unquote(path or '').lstrip('/')
if '\\' in path or '%' in path or '..' in path.split('/') or len(path)>2048:
raise HTTPException(404,'Invalid static path')
if not path or path.endswith('/'):path+='index.html'
pointer,_=_read(s3,bucket,f'{prefix}/sites/{site}/current.json',4096)
try:release=json.loads(pointer)['release']
except (ValueError,TypeError,KeyError) as ex:raise HTTPException(502,'Invalid release pointer') from ex
if not RELEASE_RE.fullmatch(release):raise HTTPException(502,'Invalid release pointer')
objkey=f'{prefix}/sites/{site}/releases/{release}/{path}'
body,meta=_read(s3,bucket,objkey)
return body, meta.get('ContentType') or 'application/octet-stream', release
@app.get('/health')
def health():return {'healthy':True,'gateway':'read-only-s3'}
@app.get('/{site}/{path:path}')
def static_site(site:str,path:str=''):
bucket,region,prefix=gateway_config()
body,content_type,release=resolve_object(site,path,client(region),bucket,prefix)
return Response(content=body,media_type=content_type,headers={
# URLs stay stable across releases, therefore cache must be revalidated.
'Cache-Control':'no-cache, must-revalidate',
'X-SiteForge-Release':release,
'X-Content-Type-Options':'nosniff',
})
+44
View File
@@ -19,6 +19,7 @@ DEFAULTS={
'show_cta':True,'cta_text':'联系我们','cta_href':'/contact/'},
'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,
@@ -82,6 +83,22 @@ def _nav(rows,depth=0,counter=None):
'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={}
@@ -117,6 +134,33 @@ def validate(raw):
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
+154
View File
@@ -0,0 +1,154 @@
"""Offline, verifiable SQLite -> EMPTY PostgreSQL database migration (V8.18).
Data only. Shared files (/data/media, builds, archives, legacy) must be moved
separately, under the same maintenance window, before site activation.
This never clears target rows. PostgreSQL target schema is bootstrapped by SiteForge
before copying; application must be fully stopped in both installations.
"""
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
import tempfile
from pathlib import Path
from .pg_disaster_recovery import RecoveryError
IDENT=re.compile(r'^[a-z_][a-z0-9_]*$',re.I)
LEDGER='schema_migrations'
def quote(name):
if not IDENT.fullmatch(name):
raise RecoveryError('Unsupported table or column identifier')
return '"'+name+'"'
def plan_tables(connection: sqlite3.Connection) -> list[str]:
tables=[r[0] for r in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
if 'sites' not in tables:raise RecoveryError('SQLite source has no sites table')
graph={t:set() for t in tables}
for table in tables:
for dep in connection.execute(f'PRAGMA foreign_key_list({quote(table)})'):
parent=dep[2]
if parent != table:
if parent not in graph:raise RecoveryError('Missing FK table '+parent)
graph[table].add(parent)
result=[]
while graph:
ready=sorted(name for name,deps in graph.items() if not deps)
if not ready:raise RecoveryError('Circular foreign keys require separate migration')
for name in ready:
result.append(name);graph.pop(name)
for refs in graph.values():refs.difference_update(ready)
return result
def columns(conn: sqlite3.Connection, table: str) -> list[str]:
return [row[1] for row in conn.execute('PRAGMA table_info('+quote(table)+')')]
def _hash_rows(rows) -> str:
"""Order independent deterministic content hash, for migration verification."""
canonical=[]
for row in rows:
canonical.append(json.dumps(list(row),sort_keys=True,ensure_ascii=False,
separators=(',',':'),default=str))
digest=hashlib.sha256()
for line in sorted(canonical):digest.update((line+'\n').encode('utf-8'))
return digest.hexdigest()
def bootstrap_target():
# Same ordered migrations as FastAPI lifespan and PostgreSQL bootstrap smoke.
from . import (db,enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual)
if not db.POSTGRES:raise RecoveryError('SITEFORGE_DATABASE_URL must point at PostgreSQL')
db.init()
for module in (enterprise,factory_api,migration_audit,reusables,release_api,
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual):
module.setup()
def check_destination_empty(pg, source: sqlite3.Connection, tables: list[str]) -> None:
for table in tables:
name=quote(table)
# Check source column coverage. Unknown source columns mean unsafe truncation.
target_cols={r['column_name'] for r in pg.execute(
'SELECT column_name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=?',
(table,)).fetchall()}
src_cols=set(columns(source,table))
if not src_cols or not src_cols.issubset(target_cols):
raise RecoveryError('PostgreSQL schema mismatch at table '+table)
if table == LEDGER:
# Destination is bootstrapped and has its own required migration ledger.
old=source.execute('SELECT version,checksum FROM schema_migrations').fetchall()
for version,checksum in old:
row=pg.execute('SELECT checksum FROM schema_migrations WHERE version=?',(version,)).fetchone()
if not row or row[0]!=checksum:
raise RecoveryError('Migration ledger mismatch: '+version)
continue
count=pg.execute(f'SELECT COUNT(*) FROM {name}').fetchone()[0]
if count:
raise RecoveryError('Destination is not empty: '+table)
def transfer_rows(pg, source, tables, batch=250) -> dict:
if not (1<=batch<=500):raise RecoveryError('Batch size must be between 1 and 500')
result={}
for table in tables:
if table==LEDGER:continue
cols=columns(source,table)
select=f'SELECT {", ".join(map(quote,cols))} FROM {quote(table)}'
original=source.execute(select)
n=0
# Keep below PostgreSQL's 65,535 bind-parameter limit.
limit=min(batch,max(1,65000//len(cols)))
while True:
rows=original.fetchmany(limit)
if not rows:break
placeholders='('+','.join('?' for _ in cols)+')'
insert=f'INSERT INTO {quote(table)} ({", ".join(map(quote,cols))}) VALUES '+','.join([placeholders]*len(rows))
pg.execute(insert,[value for row in rows for value in row])
n+=len(rows)
source_hash=_hash_rows(source.execute(select))
destination_rows=pg.execute(select).fetchall()
destination_hash=_hash_rows([[row[col] for col in cols] for row in destination_rows])
if n!=len(destination_rows) or source_hash!=destination_hash:
raise RecoveryError('Data validation failed at table '+table)
result[table]={'rows':n,'sha256':source_hash}
return result
def migrate(source_path: str | Path, *, confirmed_offline=False, batch=250,
connect=None, bootstrap=True) -> dict:
"""Caller supplies PG connection in tests; production uses app.db.connect."""
if not confirmed_offline:raise RecoveryError('Stop all services and use --confirmed-offline')
src=Path(source_path).resolve()
if not src.is_file():raise RecoveryError('SQLite source file not found')
from . import db
if connect is None and not db.POSTGRES:
raise RecoveryError('PostgreSQL URL is required')
# Capture an immutable consistent snapshot; does not create or change source DB.
with tempfile.TemporaryDirectory(prefix='siteforge-sqlite-pg-') as temp:
snapshot=Path(temp)/'snapshot.sqlite3'
with sqlite3.connect(src.as_uri()+'?mode=ro',uri=True) as old, sqlite3.connect(snapshot) as new:
old.backup(new)
with sqlite3.connect(snapshot.as_uri()+'?mode=ro',uri=True) as sqlite:
if sqlite.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
raise RecoveryError('SQLite integrity check failed')
ordered=plan_tables(sqlite)
if bootstrap:bootstrap_target()
connect_fn=connect or db.connect
# All data inserts and verification occur inside ONE PG transaction.
with connect_fn() as target:
check_destination_empty(target,sqlite,ordered)
counts=transfer_rows(target,sqlite,ordered,batch)
return {'source':'sqlite', 'destination':'postgresql',
'tables':len(counts),'records':sum(x['rows'] for x in counts.values()),
'verified':True,'details':counts,
'note':'Database only. Transfer shared files before traffic cutover.'}
+51
View File
@@ -31,3 +31,54 @@ a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,te
.pro-pages-nav a{padding:.7rem 1.1rem;border:1px solid currentColor;border-radius:.4rem;text-decoration:none}
.pro-pages-nav a:focus-visible{outline:3px solid var(--accent,#2563eb);outline-offset:3px}
.pro-pages-nav span{font-size:.875rem}
/* P6 international sites: small, keyboard-operable language switch. */
.sf-language-switch{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;margin:8px 12px;padding:4px;border:1px solid rgba(120,130,145,.22);border-radius:10px;background:rgba(255,255,255,.08)}
.sf-language-switch a{font:inherit;font-size:12px;line-height:1.5;padding:4px 7px;border-radius:6px;color:inherit;text-decoration:none}
.sf-language-switch a[aria-current="page"]{font-weight:700;text-decoration:underline;text-underline-offset:3px}
.sf-language-switch a:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
@media(max-width:640px){.sf-language-switch{margin:6px 0;width:auto}}
/* V8.21.3 — Four-theme unified mobile off-canvas navigation & optional tab bar.
Desktop remains unchanged. Phone / tablet use one accessible drawer. */
.sf-mobile-tabs{display:none!important}
.sf-drawer-header,.sf-mobile-drawer-backdrop,.sf-drawer-extra{display:none}
@media(max-width:1024px){
.pro-head-inner{flex-wrap:nowrap!important;gap:12px!important}
.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto!important;flex:0 0 44px;min-width:44px;min-height:44px;position:relative;z-index:3}
.pro-catalog-toggle{display:none!important}
.pro-navigation,.pro-navigation.is-open{display:flex!important;position:fixed!important;inset:0 0 0 auto!important;width:min(88vw,360px)!important;height:100dvh!important;height:100vh;max-height:100dvh!important;flex:unset!important;flex-direction:column!important;align-items:stretch!important;order:unset!important;margin:0!important;padding:0 0 calc(20px + env(safe-area-inset-bottom,0px))!important;overflow-x:hidden;overflow-y:auto!important;background:var(--pro-background,#fff)!important;color:var(--pro-text,#19283c)!important;border:0!important;border-left:1px solid var(--pro-border,#ddd)!important;box-shadow:-15px 0 45px #0002!important;z-index:1201!important;transform:translateX(105%);visibility:hidden;pointer-events:none;transition:transform .24s ease,visibility .24s ease!important;overscroll-behavior:contain}
.pro-navigation.is-open{transform:translateX(0);visibility:visible;pointer-events:auto}
.sf-drawer-header{display:flex!important;align-items:center;justify-content:space-between;gap:12px;padding:20px 18px;border-bottom:1px solid var(--pro-border,#dde3e9);font-size:17px;font-weight:750;position:sticky;top:0;background:var(--pro-background,#fff);z-index:2}
.sf-drawer-close{min-width:44px;min-height:44px;border:1px solid var(--pro-border,#dde3e9);color:inherit;background:transparent;border-radius:10px;font-size:24px;cursor:pointer}
.sf-mobile-drawer-backdrop{display:block;position:fixed;inset:0;z-index:1200;background:#0b1b31a3;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .24s ease,visibility .24s ease}
.sf-mobile-drawer-backdrop.is-open{opacity:1;visibility:visible;pointer-events:auto}
body.sf-drawer-open{overflow:hidden!important}
.pro-navigation>a,.pro-navigation .pro-nav-group>a,.pro-navigation .sf-drawer-extra>a{display:flex;align-items:center;min-height:50px;padding:13px 20px!important;font-size:15px!important;color:inherit!important;white-space:normal;border-bottom:1px solid var(--pro-border,#dde3e9)!important;text-decoration:none!important}
.pro-navigation .pro-nav-group{display:flex;position:relative;flex-wrap:wrap!important;width:100%;gap:0;align-items:center;justify-content:space-between}
.pro-navigation .pro-nav-group>a{flex:1;min-width:0}
.pro-navigation .pro-nav-disclose{margin-right:12px;min-width:44px;min-height:44px;border-radius:9px;background:var(--pro-surface,#f4f6f8)}
.pro-navigation .pro-nav-children{position:static!important;width:100%!important;max-width:100%!important;box-shadow:none!important;border-left:3px solid var(--pro-accent,#e77730);border-radius:0!important;margin:0 0 8px 14px!important;flex-basis:calc(100% - 14px)!important;background:var(--pro-surface,#f6f8fa)}
.pro-navigation .pro-nav-children a{padding:12px 16px!important;font-size:14px!important;color:inherit!important;min-height:44px}
.sf-drawer-extra{display:block;padding:12px 0 5px}.sf-drawer-extra strong{display:block;font-size:12px;letter-spacing:.06em;padding:12px 20px;opacity:.65}.sf-drawer-extra a{font-size:14px!important}
.sf-drawer-close:focus-visible,.pro-mobile-toggle:focus-visible,.pro-nav-disclose:focus-visible,.sf-mobile-tab:focus-visible{outline:3px solid var(--pro-accent,#ff8200);outline-offset:2px}
}
@media(max-width:767px){
.pro-header-cta{display:none!important}
.pro-head-inner .pro-logo{min-width:0;max-width:calc(100% - 65px)}
.pro-logo-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pro-home-hero .pro-home-grid{min-height:0!important}
.pro-home-hero .pro-hero-art{min-height:230px}
.pro-card,.pro-card-cover,.pro-product-media{max-width:100%;min-width:0}
.pro-rich img,.pro-content img,.sf-canvas img{max-width:100%;height:auto}
.pro-rich table{max-width:100%;overflow:auto}
.pro-grid,.pro-footer-top{min-width:0}
.sf-mobile-tabs{display:grid!important;position:fixed;z-index:1100;bottom:0;left:0;right:0;grid-template-columns:repeat(var(--sf-mobile-tab-count,4),minmax(0,1fr));align-items:stretch;padding:5px 6px calc(5px + env(safe-area-inset-bottom,0px));min-height:63px;background:var(--pro-background,#fff);color:var(--pro-text,#263446);border-top:1px solid var(--pro-border,#dfe4eb);box-shadow:0 -6px 24px #13223914}
.sf-mobile-tab{appearance:none;border:0!important;background:transparent!important;color:inherit!important;text-decoration:none!important;padding:6px 2px;min-height:50px;min-width:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:2px;border-radius:9px;cursor:pointer;font-family:inherit}
.sf-tab-icon{font-size:23px;line-height:1.15;font-weight:500}
.sf-tab-label{font-size:11px;font-weight:600;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sf-mobile-tab[aria-current=page]{color:var(--pro-accent,#e47730)!important;background:var(--pro-surface,#f5f6f8)!important}
body:has(> .sf-mobile-tabs){padding-bottom:calc(76px + env(safe-area-inset-bottom,0px))!important}
}
@media(min-width:768px){.sf-mobile-tabs{display:none!important}}
@media(prefers-reduced-motion:reduce){.pro-navigation,.sf-mobile-drawer-backdrop{transition:none!important}}
+39
View File
@@ -0,0 +1,39 @@
/* Safe, deterministic editor operations. Exported for Node regression tests. */
(function (root, factory) {
const api=factory();
if(typeof module==='object' && module.exports)module.exports=api;
root.SiteForgeEditorHelpers=api;
})(typeof window!=='undefined'?window:globalThis, function(){
'use strict';
const deepCopy=value=>JSON.parse(JSON.stringify(value));
function reorder(items, from, to){
if(!Array.isArray(items)||!Number.isInteger(from)||!Number.isInteger(to)||from<0||to<0||from>=items.length||to>=items.length)return null;
if(from===to)return items.slice();
const next=items.slice();next.splice(to,0,next.splice(from,1)[0]);return next;
}
function duplicate(block, idFactory){
if(!block || typeof idFactory!=='function')throw Error('无效的区块');
const item=deepCopy(block);
const rewrite=node=>{
node.id=idFactory();
if(node.type==='layout')for(const col of node.columns||[])for(const child of col.blocks||[])rewrite(child);
};
rewrite(item);return item;
}
function filterBlocks(blocks, term, names={}){
const query=String(term||'').trim().toLocaleLowerCase();
if(!query)return blocks.map((block,index)=>({block,index}));
return blocks.flatMap((block,index)=>{
const description=[block.title,block.subtitle,block.type,names[block.type]?.name]
.concat(block.type==='layout'?(block.columns||[]).flatMap(c=>(c.blocks||[]).map(b=>b.title||b.type)):[])
.filter(x=>typeof x==='string').join(' ').toLocaleLowerCase();
return description.includes(query)?[{block,index}]:[];
});
}
function historyState(snapshots,cursor){return {canUndo:cursor>0,canRedo:cursor>=0&&cursor<snapshots.length-1};}
function clampNumber(value,min,max,fallback){const n=Number(value);return Number.isFinite(n)?Math.max(min,Math.min(max,n)):fallback;}
function shouldAcceptPreview(requestedPage,requestedSeq,currentPage,currentSeq){
return requestedPage===currentPage&&requestedSeq===currentSeq;
}
return {reorder,duplicate,filterBlocks,historyState,clampNumber,shouldAcceptPreview};
});
+86
View File
@@ -2,3 +2,89 @@
@media(max-width:1200px){.studio-body{grid-template-columns:240px minmax(0,1fr) 290px}.preview-note{display:none}.studio-top{gap:8px}.file-name{max-width:180px}}@media(max-width:950px){.studio-body{grid-template-columns:210px minmax(0,1fr)}.right-pane{position:fixed;right:0;top:72px;bottom:0;width:310px;z-index:3;box-shadow:-8px 0 22px #0001}.studio-top .ui-btn.ghost{display:none}}@media(max-width:650px){.studio-body{grid-template-columns:1fr}.left-pane{display:none}.right-pane{display:none}.studio-top{padding:0 12px}.file-name{display:none}.save-status{display:none}}
.layout-editor-col{border:1px solid #dfe5ed;border-radius:12px;padding:10px;margin:12px 0;background:#f8fafc}.layout-editor-title{display:flex;align-items:center;gap:7px;flex-wrap:wrap;justify-content:space-between}.layout-editor-title input{width:56px}.layout-editor-title button,.layout-child button{border:0;background:none;color:#54647a;cursor:pointer}.layout-child{background:white;border:1px solid #e5eaf3;border-radius:9px;margin:8px 0;padding:9px}.layout-child label{display:block;font-size:11px;color:#475569;margin:7px 0}.layout-child input,.layout-child select{display:block;width:100%;margin-top:3px;padding:7px;border:1px solid #e1e6ee;border-radius:6px}.layout-child-toolbar{display:flex;flex-wrap:wrap;gap:4px}.layout-child-toolbar button{background:#e8effa;border:0;padding:5px 7px;border-radius:6px;cursor:pointer;font-size:11px}
/* V8.21.1 visual studio usability refresh — no saved blueprint schema changes. */
:root{--studio-accent:#ed742d;--studio-focus:#e48b42;--studio-border:#e6eaf0;--studio-shadow:0 20px 54px rgba(31,48,69,.08)}
.studio-top{box-shadow:0 1px 9px rgba(29,47,75,.035);padding-inline:18px;gap:9px}
.brand>a{background:#252e3c;border-radius:10px;color:#ff9959}
.top-center{margin:auto;background:#edf1f6}
.icon-btn.on{color:#b95722}
.ui-btn.primary{background:#dc6b2b;border-color:#dc6b2b}
.ui-btn:focus-visible,.icon-btn:focus-visible,.block-row:focus-visible,.palette button:focus-visible,.row-actions button:focus-visible,.inspector-nav button:focus-visible{outline:2px solid var(--studio-focus);outline-offset:2px}
.ui-btn:disabled,.row-actions button:disabled{opacity:.3;cursor:not-allowed}
.pane-switcher{display:flex;gap:3px}
.pane-switcher .ui-btn{padding:8px 10px}
.studio-body{grid-template-columns:minmax(270px,290px) minmax(0,1fr) minmax(320px,350px)}
.left-pane,.right-pane{background:#fff}
.pane-heading{padding:22px 18px 14px}
.pane-heading h3{font-size:16px;letter-spacing:0}
.section-label{display:flex;justify-content:space-between;align-items:center;padding:14px 19px 9px;color:#56677b;font-size:11px;letter-spacing:0}
.section-caption{font-size:10px;color:#a0abbb;font-weight:500}
.structure-filter{margin:4px 13px 10px;display:flex;align-items:center;border:1px solid #dfe6ee;background:#f7f9fc;border-radius:9px;overflow:hidden;min-height:38px}
.structure-filter:focus-within{border-color:var(--studio-focus);box-shadow:0 0 0 3px #f39a4923}
.structure-filter input{background:transparent;border:0;outline:0;min-width:0;flex:1;padding:9px 12px;font-size:12px;color:#25364e}
.structure-filter button{border:0;background:transparent;font-size:19px;color:#8796a7;width:34px}
.structure-hint{color:#8190a3;font-size:10px;padding:8px 19px 17px;line-height:1.7}
.block-list{padding:0 11px;min-height:60px}
.block-row{background:#fff;box-shadow:0 2px 10px #334b6207;padding:9px 8px;margin-bottom:6px;gap:7px;border-color:#e9edf2;transition:border-color .15s,background .15s}
.block-row:hover{border-color:#c4cedc;background:#fafbfd}
.block-row.selected{background:#fff7f1;border-color:#f19b59;box-shadow:0 0 0 1px #f8e3d5}
.block-row.is-hidden{opacity:.55}
.block-icon{width:29px;height:29px;color:#d67b35;background:#fff8f2;flex-shrink:0}
.block-copy strong{font-size:12px;color:#303e51}
.block-copy small{font-size:10px;color:#7e90a5}
.drag-handle{font-size:14px}
.row-actions{display:flex;gap:0;flex-shrink:0}
.row-actions button{font-size:12px;min-width:18px;padding:4px 2px;border-radius:5px}
.row-actions button:hover:not(:disabled){background:#fff0e4;color:#c96728}
.block-list .no-selection{padding:15px;margin:0 2px 10px}
.palette{grid-template-columns:repeat(2,minmax(0,1fr));padding-inline:13px}
.palette button{padding:10px 9px;border-style:solid}
.palette button:hover{border-color:#e8aa76;background:#fff8f3}
.palette button span{color:#d77936}
.preview-workspace{background:#eff2f6}
.preview-head{padding:0 16px;gap:9px}
.preview-note{max-width:260px;line-height:1.3;text-align:right}
.live-dot{background:#3aa688}
.live-dot.loading{background:#db983a;animation:studio-pulse 1.25s infinite}
.live-dot.error{background:#cb4e4e}
@keyframes studio-pulse{50%{opacity:.3;box-shadow:0 0 0 7px #efab4626}}
.stage{padding:22px 15px}
.device-frame{border-radius:8px;box-shadow:var(--studio-shadow);overflow:hidden;min-height:300px}
.device-frame.tablet,.device-frame.phone{max-width:calc(100% - 4px)}
iframe{min-height:590px;height:calc(100vh - 224px)}
.stage-footer{min-height:35px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 19px;font-size:10px;color:#8b9bad;border-top:1px solid #e1e7ee}
.inspector-nav{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;padding:4px;margin:0 16px 17px;border-radius:9px;background:#f1f4f8}
.inspector-nav button{background:none;border:0;border-radius:6px;padding:10px 5px;font-size:12px;color:#78869a;font-weight:650}
.inspector-nav button[aria-selected=true]{background:white;box-shadow:0 1px 5px #233d5f12;color:#b95c2b}
.inspector{padding:0 19px}
.inspector [data-inspector-section][hidden]{display:none!important}
.inspector-title{margin:0 0 16px}
.inspector-title h3{font-size:14px}
.right-foot{background:#faf8f5;border-color:#f0e6d7;margin-top:auto}
.studio .section-label,.studio .pane-heading h3{user-select:none}
.sr-only{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important}
@media(max-width:1400px){.studio-body{grid-template-columns:255px minmax(0,1fr) 310px}.studio-top{gap:6px}.studio-top>.ui-btn{padding:9px 10px}}
@media(max-width:1130px){.studio-top>.ui-btn.ghost:not(#studioUndo):not(#studioRedo){display:none}.studio-body{grid-template-columns:245px minmax(0,1fr) 295px}.file-name{max-width:150px}.pane-switcher{display:flex}}
@media(max-width:950px){.studio-body{grid-template-columns:230px minmax(0,1fr)}.right-pane{transform:translateX(105%);transition:transform .2s;display:flex!important;z-index:7;top:72px;width:min(340px,92vw)}body.show-inspector .right-pane{transform:translateX(0)}.preview-note{display:none}.studio-top>.ui-btn.ghost{display:none}}
@media(max-width:650px){.studio{min-height:480px}.studio-body{grid-template-columns:minmax(0,1fr)}.left-pane{display:flex!important;position:fixed;z-index:7;top:72px;bottom:0;left:0;width:min(315px,88vw);transform:translateX(-106%);transition:transform .2s;box-shadow:6px 0 20px #17283f12}body.show-structure .left-pane{transform:translateX(0)}.right-pane{display:flex!important}.studio-top{padding-inline:8px}.pane-switcher .ui-btn{padding:8px}.studio-top .brand{display:none}.top-center{margin-inline:auto}.studio-top .ui-btn.primary{padding:9px 10px}.preview-head{height:42px;min-height:42px;padding:0 9px}.stage{padding:10px 6px}.device-frame.phone,.device-frame.tablet{max-width:100%}.stage-footer{padding:8px 10px}.studio-top .save-status{display:none}}
@media(max-width:950px){.studio-top>#saveDraftButton.ui-btn.ghost{display:inline-flex!important;align-items:center;padding:9px 10px;color:#c5652d;background:#fff6ed;border-color:#eec49e}}
@media(max-width:430px){.studio-top #saveDraftButton{font-size:11px}.studio-top .ui-btn.primary{font-size:11px}.icon-btn{padding:6px 7px}}
.studio-actions{position:relative;flex-shrink:0}.studio-actions summary{display:flex;gap:6px;align-items:center;list-style:none;border:1px solid #e5eaf0;border-radius:8px;background:#fff;color:#394a60;padding:10px 13px;font-weight:650;font-size:12px;cursor:pointer;user-select:none}.studio-actions summary::-webkit-details-marker{display:none}.studio-actions[open] summary{border-color:#dbaa89;background:#fff8f1}.studio-actions-menu{position:absolute;right:0;top:calc(100% + 8px);display:grid;gap:5px;min-width:205px;z-index:12;padding:9px;border:1px solid #e2e7ed;border-radius:12px;background:#fff;box-shadow:0 15px 42px #283b5328}.studio-actions-menu .ui-btn{width:100%;text-align:left;border:0;padding:11px 12px;background:transparent}.studio-actions-menu .ui-btn:hover{background:#f5f7fa}.studio-actions-menu .ui-btn.ghost{display:block!important}@media(max-width:650px){.studio-actions summary{font-size:11px;padding:9px 7px}.studio-actions-menu{right:-15px;min-width:185px}}
/* V8.21.2 Easy Studio — task-first UX; no schema or API changes */
.sf-easy-top{height:70px;min-height:70px;padding:0 20px;gap:12px;z-index:15}
.sf-easy-top .brand{min-width:138px}.sf-easy-top .brand small{font-size:10px;color:#a1aab7}.sf-easy-file{display:flex;flex-direction:column;gap:3px;min-width:130px;max-width:210px}.sf-easy-file small{color:#8b98a9;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-easy-file strong{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-easy-device{margin-left:auto;margin-right:auto;flex:0 0 auto;display:flex}.sf-easy-device .icon-btn{font-size:12px;padding:8px 12px;font-weight:700}.sf-easy-save-status{padding:7px 10px;max-width:120px;overflow:hidden;text-overflow:ellipsis;background:#f3f6f9;border-radius:7px}.sf-easy-save-status.dirty{color:#b96d21;background:#fff5e8}.sf-save-btn{border-color:#e7a36d!important;color:#b65d23!important;background:#fffaf5!important;padding-inline:15px}.sf-publish-btn{padding-inline:19px;background:#df752f!important}.sf-easy-top .studio-actions{margin:0}.sf-easy-top .studio-actions summary{padding:10px 11px}.sf-easy-top .studio-actions-menu{min-width:195px;max-height:80vh;overflow-y:auto}.sf-easy-top .studio-actions-menu .ui-btn{font-size:12px}
.sf-steps{display:flex;align-items:center;gap:13px;padding:10px 22px;background:#fffaf6;border-bottom:1px solid #f2e3d7;color:#7d8290;min-height:45px;font-size:12px}.sf-steps-label{color:#b2683a;font-weight:800;flex-shrink:0}.sf-step{white-space:nowrap;font-weight:640;border-radius:8px;padding:5px 8px}.sf-step.active{background:#fce7d7;color:#b85b23}.sf-step-separator{color:#d6ad90}.sf-guide-link{margin-left:auto;border:0;background:none;color:#bc6529;font-weight:750;font-size:12px;white-space:nowrap;padding:5px 8px;text-decoration:underline;text-underline-offset:3px}
.studio-body{min-height:0;grid-template-columns:270px minmax(0,1fr) 320px}.left-pane,.right-pane{min-height:0}.left-pane .pane-heading{padding-bottom:10px}.left-pane .pane-heading h3,.right-pane .pane-heading h3{font-size:17px}.sf-page-picker{margin-bottom:10px!important}.sf-page-picker label{color:#5f7184;font-size:11px}.sf-page-picker select{font-size:12px}.sf-add-area{padding:2px 15px 15px}.sf-add-button{display:block;width:100%;border:1px solid #dc732f;background:#e47b38;color:#fff;border-radius:10px;padding:14px 12px;font-size:14px;font-weight:800;box-shadow:0 4px 11px #dc732f25;text-align:center;cursor:pointer}.sf-add-button:hover{background:#ce6427}.sf-add-area p{margin:9px 1px 0;color:#8a97a8;font-size:11px;line-height:1.6}.sf-library-panel{margin:0 13px 12px;border:1px solid #f2cfb4;background:#fffaf6;border-radius:12px;padding:9px 0;box-shadow:0 7px 15px #b55e1f12}.sf-library-panel[hidden]{display:none!important}.sf-panel-caption{display:flex;justify-content:space-between;gap:5px;align-items:center;padding:4px 12px 10px;font-size:11px;font-weight:700;color:#895b3c}.sf-panel-caption button{border:none;background:transparent;color:#b86327;font-size:11px}.sf-library-panel .palette{grid-template-columns:repeat(2,minmax(0,1fr));padding:2px 9px 12px;gap:7px}.sf-library-panel .palette button{min-height:42px;background:#fff;border-style:solid;font-size:11px}.sf-subsection{margin:0 9px 6px;padding-top:8px;border-top:1px solid #f2ddc7}.sf-subsection>summary{padding:7px 5px;cursor:pointer;color:#85624b;font-size:11px;font-weight:750}.sf-subsection .palette{padding:8px 0;grid-template-columns:1fr}.left-pane .section-label{padding-top:13px;font-size:11px;color:#56677b}.block-list{padding:0 12px;min-height:75px}.block-row{min-height:54px}.block-copy strong{font-size:12px}.row-actions button{min-width:19px}.sf-advanced{margin:6px 13px 18px;border:1px solid #e6eaf0;border-radius:10px;background:#fafbfc}.sf-advanced summary{display:flex;align-items:center;justify-content:space-between;gap:5px;padding:13px;cursor:pointer;font-weight:750;font-size:12px;color:#67758b}.sf-advanced summary::after{content:'⌄';font-size:15px;margin-left:auto;color:#99a7b9}.sf-advanced summary span{font-size:10px;font-weight:400;color:#9aa6b5}.sf-advanced[open]{background:#fff}.sf-advanced[open] summary{border-bottom:1px solid #edf0f4}.sf-advanced .field{margin:13px}.sf-advanced-buttons{display:grid;gap:7px;padding:0 13px 10px}.sf-advanced-buttons button{white-space:normal;text-align:left}.sf-advanced-note{font-size:10px;color:#9aa6b5;line-height:1.7;margin:4px 13px 13px}
.structure-hint{padding-bottom:12px}.preview-workspace{min-width:0}.preview-head{min-height:48px;height:48px}.preview-note{font-size:10px}.right-pane .pane-heading{padding-bottom:15px}.right-pane .overline{letter-spacing:.4px;color:#d67832;font-size:10px}.right-pane .inspector-nav{margin-top:0}.sf-advanced-foot{display:block;padding:0;margin:22px 15px;border:1px solid #eceff2;background:#fafbfc}.sf-advanced-foot summary{padding:13px 15px;cursor:pointer;font-size:11px;font-weight:750;color:#617084}.sf-advanced-foot p{margin:0 15px 14px}.right-pane .no-selection b{display:block;color:#53667d;font-size:14px}.right-pane .no-selection p{font-size:12px;line-height:1.7}.right-pane .no-selection .ui-btn{margin-top:8px}.right-pane .prop label{font-size:12px;color:#5c6a7f}.right-pane .prop input,.right-pane .prop textarea,.right-pane .prop select{font-size:13px;padding:11px 12px}.sf-help-dialog{border:1px solid #dfe4eb;border-radius:18px;width:min(535px,94vw);padding:26px;box-shadow:0 25px 100px #23324835}.sf-help-dialog::backdrop{background:#1c2c405a}.sf-help-head{display:flex;align-items:start;justify-content:space-between;gap:12px}.sf-help-head h2{margin:4px 0 15px;font-size:23px;color:#28364a}.sf-help-steps{padding-left:29px;line-height:1.65}.sf-help-steps li{margin:11px 0 17px;padding-left:5px;font-size:13px}.sf-help-steps b{font-size:15px;color:#c8682a}.sf-help-steps p{margin:5px 0;color:#6a7a8b}.sf-guide-note{border-radius:10px;padding:12px 15px;background:#f6f7f9;color:#718091;font-size:12px;line-height:1.6}.sf-guide-done{width:100%;margin-top:18px;font-size:14px}
@media(max-width:1330px){.studio-body{grid-template-columns:245px minmax(0,1fr) 305px}.sf-easy-top{gap:8px;padding:0 12px}.sf-easy-top .brand{min-width:100px}.sf-easy-file{max-width:148px}.sf-easy-device .icon-btn{padding:8px}}
@media(max-width:1060px){.studio-body{grid-template-columns:230px minmax(0,1fr)}.right-pane{position:fixed;right:0;top:115px;bottom:0;width:min(338px,92vw);display:flex!important;transform:translateX(106%);z-index:11;box-shadow:-10px 0 25px #1c2c4025}body.show-inspector .right-pane{transform:translateX(0)}.sf-easy-top .brand small,.sf-easy-save-status{display:none}.sf-easy-file{max-width:120px}.studio-top .ui-btn.ghost.sf-save-btn{display:inline-flex!important}.sf-steps{font-size:11px;gap:7px}}
@media(max-width:720px){.sf-steps{min-height:39px;padding:5px 10px;gap:5px}.sf-step-separator,.sf-step,.sf-steps-label{display:none}.sf-steps::before{content:'① 选内容 → ② 改文字与样式 → ③ 保存发布';font-weight:700;font-size:11px;color:#a4653b}.sf-guide-link{font-size:11px}.studio-body{grid-template-columns:minmax(0,1fr)}.left-pane{position:fixed;top:109px;bottom:0;left:0;width:min(315px,92vw);z-index:11;display:flex!important;transform:translateX(-106%);box-shadow:10px 0 25px #1c2c4020}body.show-structure .left-pane{transform:translateX(0)}.right-pane{top:109px}.sf-easy-top{height:70px;min-height:70px;padding-inline:8px}.sf-easy-top .brand,.sf-easy-file{display:none}.sf-easy-device{margin-left:0;margin-right:auto}.sf-easy-device .icon-btn{font-size:11px;padding:7px}.sf-save-btn,.sf-publish-btn{font-size:11px;padding:9px!important}.sf-easy-top .studio-actions summary{padding:9px 8px;font-size:11px}.sf-easy-top .studio-actions-menu{right:0}.pane-switcher{display:none}.stage{padding:9px 6px}.device-frame.phone,.device-frame.tablet{max-width:100%}}
@media(max-width:395px){.sf-easy-device .icon-btn{padding:6px;font-size:10px}.sf-guide-link{font-size:10px}.sf-save-btn{padding:8px 6px!important}.sf-publish-btn{padding:8px 6px!important}.sf-steps::before{content:'选区块 → 修改 → 保存发布';font-size:10px}}
/* Responsive panel toggle is always available on narrow screens. */
.sf-easy-top .sf-pane-controls{display:none;gap:5px}
@media(max-width:1060px){.sf-easy-top .sf-pane-controls{display:flex}.sf-easy-top .sf-pane-controls button{padding:7px 9px;font-size:11px;border:1px solid #e2e6ed;background:white;border-radius:8px}}
/* Accessible repeated-item forms for galleries, carousels, FAQs and features */
.sf-array-editor{display:grid;gap:10px}.sf-array-hint{font-size:11px;line-height:1.6;color:#8391a3;margin:0 0 4px}.sf-array-card{border:1px solid #e5eaf0;background:#fafbfd;padding:11px;border-radius:10px}.sf-array-card-head{display:flex;align-items:center;justify-content:space-between;gap:7px;margin-bottom:12px}.sf-array-card-head b{font-size:12px;color:#586a7f}.sf-array-card-head div{display:flex;gap:3px}.sf-array-card-head button{border:1px solid #e2e7ed;border-radius:5px;color:#6c7a8e;background:white;padding:4px 7px;font-size:11px}.sf-array-card-head button:last-child{color:#b95d62}.sf-array-card-head button:disabled{opacity:.3;cursor:not-allowed}.sf-array-field{display:block!important;margin:10px 0!important;font-size:11px!important;font-weight:650!important}.sf-array-field input,.sf-array-field textarea{display:block;width:100%;margin-top:5px;background:white}.sf-array-field textarea{min-height:65px}.sf-array-add{border:1px dashed #e5a77c;border-radius:9px;background:#fff8f2;color:#be672a;padding:13px 9px;font-size:12px;font-weight:800}
+144 -20
View File
@@ -8,31 +8,67 @@ const api=async(p,opt={})=>{if(!['GET','HEAD','OPTIONS'].includes((opt.method||'
const req=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const 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 editorTools=window.SiteForgeEditorHelpers;
let libraryOpen=false;
let inspectorTab='content',blockSearch='',previewRequest=0,previewController=null,savePromise=null,lastContinuousEdit=0;
let snapshots=[],snapshotCursor=-1,draggedChild=null,draftRevision=0,siteDesign=null,siteDesignRevision=0,siteDesignPreview=null;
function recordSnapshot(){const current=JSON.stringify({blocks,kind,layoutMode});if(snapshotCursor>=0 && snapshots[snapshotCursor]===current)return;snapshots=snapshots.slice(0,snapshotCursor+1);snapshots.push(current);if(snapshots.length>75)snapshots.shift();snapshotCursor=snapshots.length-1}
function restoreSnapshot(index){if(index<0||index>=snapshots.length)return;snapshotCursor=index;const state=JSON.parse(snapshots[index]);blocks=state.blocks;kind=state.kind;layoutMode=state.layoutMode;selected=Math.min(selected,blocks.length-1);$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';Studio.draw();Studio.refreshPreview()}
function restoreSnapshot(index){if(index<0||index>=snapshots.length)return;snapshotCursor=index;const state=JSON.parse(snapshots[index]);blocks=state.blocks;kind=state.kind;layoutMode=state.layoutMode;selected=Math.min(selected,blocks.length-1);$('#pageTemplate').value=kind;$('#layoutMode').value=layoutMode;dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');Studio.updateHistoryButtons();Studio.draw();Studio.refreshPreview()}
const Studio={
async init(){try{csrf=(await api('/api/auth/me')).csrf;site=await api(`/api/sites/${siteId}`);pages=await api(`/api/sites/${siteId}/entries`);schema=await api('/api/editor/blocks');$('#siteName').textContent=site.name;const select=$('#pageSelect');select.innerHTML=pages.map(e=>`<option value="${html(e.id)}">${html(e.title)} (${html(e.route)})</option>`).join('');for(let [k,v] of Object.entries(schema.page_templates))$('#pageTemplate').innerHTML+=`<option value="${k}">${html(v.name)}</option>`;Studio.renderPalette();await Studio.loadReusables();await Studio.choosePage(pageId||pages[0]?.id)}catch(e){toast('初始化失败:'+e.message,true);console.error(e)}},
async choosePage(id){if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/studio-draft`);draftRevision=data.revision;blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;snapshots=[];snapshotCursor=-1;recordSnapshot();Studio.draw();Studio.refreshPreview()},
async choosePage(id){if(savePromise){toast('保存仍在进行,请稍后切换页面',true);$('#pageSelect').value=pageId;return}if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/studio-draft`);draftRevision=data.revision;blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;snapshots=[];snapshotCursor=-1;lastContinuousEdit=0;blockSearch='';$('#blockSearch').value='';recordSnapshot();Studio.updateHistoryButtons();$('#saveStatus').textContent=draftRevision?'草稿 v'+draftRevision:'未修改';$('#saveStatus').classList.remove('dirty');Studio.draw();Studio.refreshPreview()},
async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<button onclick="Studio.insertReusable('${html(x.id)}')">✦ ${html(x.name)} <small>v${x.version}</small></button>`).join('')||'<span style="font-size:12px;opacity:.55;padding:10px">暂无保存的组件</span>'}catch(e){console.warn(e)}},
async saveReusable(){const block=blocks[selected];if(!block)return toast('请先选择一个区块',true);const name=prompt('为此组件命名',block.title||schema.blocks[block.type]?.name);if(!name?.trim())return;try{await api(`/api/sites/${siteId}/components`,req('POST',{name,block}));await Studio.loadReusables();toast('组件已保存到本站组件库')}catch(e){toast(e.message,true)}},
insertReusable(id){const saved=reusables.find(x=>x.id===id);if(!saved)return;const copy=structuredClone(saved.block);const refreshIds=item=>{item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);if(item.type==='layout')(item.columns||[]).forEach(c=>(c.blocks||[]).forEach(refreshIds))};refreshIds(copy);blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();},
insertReusable(id){const saved=reusables.find(x=>x.id===id);if(!saved)return;const copy=editorTools.duplicate(saved.block,()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();},
renderPalette(){let p=$('#palette');p.innerHTML=Object.entries(schema.blocks).map(([key,b])=>`<button onclick="Studio.add('${key}')"><span>${html(b.icon)}</span>${html(b.name)}</button>`).join('')},
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()},
toggleLibrary(force){libraryOpen=typeof force==='boolean'?force:!libraryOpen;const el=$('#sfLibraryPanel'),btn=$('#sfAddBlock');if(el)el.hidden=!libraryOpen;if(btn){btn.setAttribute('aria-expanded',String(libraryOpen));btn.textContent=libraryOpen?'× 收起区块库':'+ 添加内容区块'};Studio.updateGuidance()},
showHelp(){const dlg=$('#sfGuideDialog');if(dlg&&!dlg.open)dlg.showModal()},
updateGuidance(){const one=$('#sfStep1'),two=$('#sfStep2'),three=$('#sfStep3');if(!one||!two||!three)return;one.classList.toggle('active',selected<0||libraryOpen);two.classList.toggle('active',selected>=0&&!dirty&&!libraryOpen);three.classList.toggle('active',dirty);},
draw(){
const list=$('#blockList');$('#blockCount').textContent=blocks.length+' 区块';
const found=editorTools.filterBlocks(blocks,blockSearch,schema.blocks);
list.innerHTML=found.map(({block:b,index:i})=>`<div class="block-row ${selected===i?'selected':''} ${b.visible===false?'is-hidden':''}" role="listitem" tabindex="0" draggable="true" data-index="${i}" ondragstart="Studio.drag(event,${i})" ondragend="this.classList.remove('dragging')" ondragover="event.preventDefault()" ondrop="Studio.drop(event,${i})" onkeydown="Studio.blockKeydown(event,${i})" onclick="Studio.select(${i})"><div class="drag-handle" title="拖动排序" aria-hidden="true">⠿</div><div class="block-icon">${html(schema.blocks[b.type]?.icon||'▣')}</div><div class="block-copy"><strong>${html(schema.blocks[b.type]?.name||b.type)}</strong><small>${html(b.title||b.subtitle||b.type)}</small></div><div class="row-actions"><button type="button" aria-label="向上移动区块" title="上移" ${i===0?'disabled':''} onclick="event.stopPropagation();Studio.moveBlock(${i},-1)">↑</button><button type="button" aria-label="向下移动区块" title="下移" ${i===blocks.length-1?'disabled':''} onclick="event.stopPropagation();Studio.moveBlock(${i},1)">↓</button><button type="button" title="${b.visible===false?'显示':'隐藏'}" aria-label="切换区块可见性" onclick="event.stopPropagation();Studio.toggleVisible(${i})">${b.visible===false?'◌':'◉'}</button><button type="button" title="复制" aria-label="复制区块" onclick="event.stopPropagation();Studio.duplicate(${i})">⧉</button><button type="button" title="删除" aria-label="删除区块" onclick="event.stopPropagation();Studio.remove(${i})">×</button></div></div>`).join('')||`<div class="no-selection">${blockSearch?'没有找到匹配区块,试试清除搜索。':'当前没有区块,点击下方按钮添加。'}</div>`;
Studio.inspector();Studio.updateGuidance();
},
filter(value){blockSearch=String(value||'').slice(0,100);Studio.draw()},
clearFilter(){blockSearch='';$('#blockSearch').value='';Studio.draw();$('#blockSearch').focus()},
moveBlock(i,offset){const target=i+offset;const next=editorTools.reorder(blocks,i,target);if(!next||target===i)return;blocks=next;selected=target;Studio.modified();Studio.draw();},
toggleVisible(i){if(!blocks[i])return;blocks[i].visible=blocks[i].visible===false;Studio.modified();Studio.draw()},
blockKeydown(event,i){if(event.target!==event.currentTarget)return;if(event.key==='Enter'||event.key===' '){event.preventDefault();Studio.select(i)}else if(event.altKey && (event.key==='ArrowUp'||event.key==='ArrowDown')){event.preventDefault();Studio.moveBlock(i,event.key==='ArrowUp'?-1:1)}},
changeInspectorTab(tab){if(!['content','style','responsive'].includes(tab))return;inspectorTab=tab;Studio.applyInspectorTab()},
applyInspectorTab(){for(const key of ['content','style','responsive']){const active=key===inspectorTab;const btn=$('#tab'+key.charAt(0).toUpperCase()+key.slice(1));btn?.setAttribute('aria-selected',String(active));const pane=$('[data-inspector-section="'+key+'"]');if(pane)pane.hidden=!active}},
togglePane(which){const type=which==='left'?'show-structure':'show-inspector';document.body.classList.toggle(type);if(type==='show-structure')document.body.classList.remove('show-inspector');else document.body.classList.remove('show-structure');},
updateHistoryButtons(){const state=editorTools.historyState(snapshots,snapshotCursor);$('#studioUndo').disabled=!state.canUndo;$('#studioRedo').disabled=!state.canRedo},
select(i){selected=i;Studio.draw();if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
add(type){let data=structuredClone(schema.blocks[type].defaults);data.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);data.type=type;data.visible=true;blocks.push(data);selected=blocks.length-1;Studio.modified();Studio.draw();Studio.toggleLibrary(false);if(window.matchMedia('(max-width:950px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}},
duplicate(i){const b=editorTools.duplicate(blocks[i],()=>crypto.randomUUID().replace(/-/g,'').slice(0,10));blocks.splice(i+1,0,b);selected=i+1;Studio.modified();Studio.draw()},
remove(i){if(!confirm('确定删除这个区块?'))return;blocks.splice(i,1);selected=Math.min(i,blocks.length-1);Studio.modified();Studio.draw()},
drag(e,i){e.dataTransfer.setData('text/plain',String(i));e.dataTransfer.effectAllowed='move';e.currentTarget.classList.add('dragging')},
drop(e,i){e.preventDefault();let src=Number(e.dataTransfer.getData('text/plain'));if(!Number.isInteger(src)||src<0||src>=blocks.length||src===i)return;let [b]=blocks.splice(src,1);blocks.splice(i,0,b);selected=i;Studio.modified();Studio.draw()},
drop(e,i){e.preventDefault();let src=Number(e.dataTransfer.getData('text/plain'));if(!Number.isInteger(src)||src<0||src>=blocks.length||src===i)return;const moved=editorTools.reorder(blocks,src,i);if(!moved)return;blocks=moved;selected=i;Studio.modified();Studio.draw()},
field(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();Studio.draw()},
fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();const row=$('#blockList').children[selected];if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type},
fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified(true);const row=$('#blockList').querySelector(`[data-index="${selected}"]`);if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type},
arrayEditor(field,value,type){
const rows=Array.isArray(value)?value:[];
const labels={title:'标题',text:'文字介绍',value:'数字',label:'名称',question:'问题',answer:'回答',url:'图片地址',alt:'图片说明',image:'图片地址',caption:'图片说明',name:'名称',price:'价格',features:'方案包含的服务',href:'按钮跳转路径'};
const spec=schema.blocks[type];const sample=(spec?.defaults?.[field]||[])[0]||{url:'',alt:''};
const keys=Object.keys(sample);
return `<div class="sf-array-editor"><p class="sf-array-hint">无需填写 JSON:按顺序编辑每一项,修改会自动显示在预览中。</p>`+
rows.map((row,i)=>`<div class="sf-array-card"><div class="sf-array-card-head"><b>第 ${i+1} 项</b><div><button type="button" title="上移" ${i===0?'disabled':''} onclick="Studio.moveItem('${field}',${i},-1)">↑</button><button type="button" title="下移" ${i===rows.length-1?'disabled':''} onclick="Studio.moveItem('${field}',${i},1)">↓</button><button type="button" title="删除这一项" onclick="Studio.removeItem('${field}',${i})">删除</button></div></div>`+
keys.map(key=>`<label class="sf-array-field">${html(labels[key]||key)}${key==='text'||key==='answer'||key==='features'?`<textarea oninput="Studio.itemField('${field}',${i},'${key}',this.value)">${html(Array.isArray(row?.[key])?(row[key]||[]).join('\n'):row?.[key]??'')}</textarea>`:`<input type="text" value="${html(row?.[key]??'')}" oninput="Studio.itemField('${field}',${i},'${key}',this.value)">`}</label>`).join('')+
`</div>`).join('')+`<button type="button" class="sf-array-add" onclick="Studio.addItem('${field}')">+ 添加${field==='images'?'图片':'一项'}</button></div>`;
},
itemField(field,index,key,value){const block=blocks[selected],row=block?.[field]?.[index];if(!row)return;row[key]=key==='features'?String(value).split('\n').map(x=>x.trim()).filter(Boolean):String(value).slice(0,1500);Studio.modified(true)},
addItem(field){const block=blocks[selected];if(!block)return;const rows=block[field];if(!Array.isArray(rows))return;const limit=field==='images'?24:16;if(rows.length>=limit)return toast(`最多添加 ${limit} 项`,true);const sample=schema.blocks[block.type]?.defaults?.[field]?.[0]||{url:'',alt:''};const next={};for(const [key,val] of Object.entries(sample))next[key]=Array.isArray(val)?[]:'';rows.push(next);Studio.modified();Studio.inspector()},
removeItem(field,index){const rows=blocks[selected]?.[field];if(!Array.isArray(rows)||!rows[index])return;rows.splice(index,1);Studio.modified();Studio.inspector()},
moveItem(field,index,by){const rows=blocks[selected]?.[field],target=index+by;if(!Array.isArray(rows)||target<0||target>=rows.length)return;[rows[index],rows[target]]=[rows[target],rows[index]];Studio.modified();Studio.inspector()},
jsonField(key,value){try{let data=JSON.parse(value);if(!Array.isArray(data))throw Error('必须为数组');Studio.fieldQuiet(key,data);$('#jsonError').textContent='✓ JSON 格式有效';$('#jsonError').style.color='#238460'}catch(e){$('#jsonError').textContent='JSON 格式有误:'+e.message;$('#jsonError').style.color='#bf5a58'}},
styleField(key,value){const b=blocks[selected];b.style={...(b.style||{}),[key]:value};Studio.modified()},
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection">请从左侧选择区块并编辑属性。</div>';return}const spec=schema.blocks[b.type];let out=`<div class="inspector-title"><h3>${html(spec.name)}</h3><label class="toggle"><input type="checkbox" ${b.visible!==false?'checked':''} onchange="Studio.field('visible',this.checked)">显示</label></div>`;
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection"><b>还没有选中内容</b><p>先到左侧选择一个区块,或点击“添加内容区块”。</p><button type="button" class="ui-btn primary" onclick="Studio.toggleLibrary(true)">+ 添加内容</button></div>';return}const spec=schema.blocks[b.type];let out=`<div class="inspector-title"><h3>${html(spec.name)}</h3><label class="toggle"><input type="checkbox" ${b.visible!==false?'checked':''} onchange="Studio.field('visible',this.checked)">显示</label></div><div data-inspector-section="content">`;
for(const field of spec.fields){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度'})[field]||field;
out+=`<div class="prop"><label>${html(label)}</label>`;
if(field==='items'||field==='images')out+=`<textarea spellcheck="false" onchange="Studio.jsonField('${field}',this.value)">${html(JSON.stringify(value,null,2))}</textarea><span id="jsonError" class="prop-tip">请输入 JSON 数组,可编辑多个项目</span>`;
if(field==='items'||field==='images')out+=Studio.arrayEditor(field,value,b.type);
else if(field==='source')out+=`<select onchange="Studio.field('${field}',this.value)">${['product','post','page','vehicle','brand','case','service','other','all'].map(t=>`<option value="${t}" ${t===value?'selected':''}>${({product:'产品',post:'文章',page:'普通页面',vehicle:'车型',brand:'品牌',case:'案例',service:'服务',other:'其他',all:'全部类型'})[t]}</option>`).join('')}</select><span class="prop-tip">网站已有内容将自动绑定并生成卡片。</span>`;
else if(field==='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>`;
@@ -41,6 +77,7 @@ const Studio={
if(['title','subtitle','description','image','body'].includes(field))out+='<small class="prop-tip">可输入 {{site.name}}、{{page.title}} 等变量。</small>';
out+='</div>';
}
out+='</div><div data-inspector-section="responsive">';
if(b.type==='layout'){
out+=`<div class="subheading">列内组件 · 按设备排版</div><p class="prop-tip">分别调整平板/手机的列数、顺序、跨度与显隐。保存草稿后在设备预览切换查看效果。</p><div class="prop"><label>平板每行列数 <input type="number" min="1" max="3" value="${html(b.tablet_columns||2)}" onchange="Studio.setDeviceColumns('tablet',this.value)"></label><label>手机每行列数 <input type="number" min="1" max="2" value="${html(b.mobile_columns||1)}" onchange="Studio.setDeviceColumns('mobile',this.value)"></label></div>`;
b.columns.forEach((column,ci)=>{
@@ -55,20 +92,72 @@ const Studio={
});
out+='<button class="ui-btn ghost" onclick="Studio.addColumn()">+ 添加列</button>';
}
out+=`<div class="subheading">区块设计(按设备覆盖)</div><div class="prop"><label>背景颜色</label><input type="color" value="${html(b.style?.background||'#ffffff')}" onchange="Studio.styleField('background',this.value)"></div><div class="prop"><label>上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.padding??82)}" onchange="Studio.styleField('padding',Number(this.value))"></div><div class="prop"><label>平板上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.tablet_padding??50)}" onchange="Studio.styleField('tablet_padding',Number(this.value))"></div><div class="prop"><label>手机上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.mobile_padding??28)}" onchange="Studio.styleField('mobile_padding',Number(this.value))"></div><div class="prop"><label><input type="checkbox" ${b.style?.hide_mobile?'checked':''} onchange="Studio.styleField('hide_mobile',this.checked)"> 手机上隐藏区块</label></div><div class="prop"><label>已上传图片</label><button class="ui-btn ghost" onclick="Studio.chooseMedia()">打开本站素材库</button></div><div class="prop"><label>上传区块图片</label><input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0])"></div>`;
out+=`<div class="subheading">响应式样式</div><div class="prop"><label>平板上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.tablet_padding??50)}" onchange="Studio.styleField('tablet_padding',Number(this.value))"></div><div class="prop"><label>手机上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.mobile_padding??28)}" onchange="Studio.styleField('mobile_padding',Number(this.value))"></div><div class="prop"><label><input type="checkbox" ${b.style?.hide_mobile?'checked':''} onchange="Studio.styleField('hide_mobile',this.checked)"> 手机上隐藏区块</label></div>`;
out+=`</div><div data-inspector-section="style"><div class="subheading">外观设置</div><div class="prop"><label>背景颜色</label><input type="color" value="${html(b.style?.background||'#ffffff')}" onchange="Studio.styleField('background',this.value)"></div><div class="prop"><label>上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.padding??82)}" onchange="Studio.styleField('padding',Number(this.value))"></div><div class="prop"><label>从图库选择图片</label><button type="button" class="ui-btn ghost" onclick="Studio.chooseMedia()">打开本站素材库</button></div><div class="prop"><label>上传图片</label><input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0])"></div></div>`;
out+='<div style="display:flex;gap:8px;margin-bottom:22px"><button class="small-btn" onclick="Studio.duplicate('+selected+')">复制区块</button><button class="small-btn red-btn" onclick="Studio.remove('+selected+')">删除区块</button></div>';
target.innerHTML=out;
target.innerHTML=out;Studio.applyInspectorTab();
},
undo(){restoreSnapshot(snapshotCursor-1)},
redo(){restoreSnapshot(snapshotCursor+1)},
modified(){recordSnapshot();dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),500)},
async refreshPreview(){if(!pageId)return;try{let frame=$('#sitePreview');let doc=await api(`/api/sites/${siteId}/pages/${pageId}/editor-preview`,req('POST',{blocks,page_template:kind,layout_mode:layoutMode,site_design:siteDesignPreview}));frame.srcdoc=doc}catch(e){toast('预览失败:'+e.message,true)}},
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)},
modified(continuous=false){
const now=Date.now(),snap=JSON.stringify({blocks,kind,layoutMode});
if(continuous&&lastContinuousEdit&&now-lastContinuousEdit<850&&snapshotCursor>0&&snapshotCursor===snapshots.length-1){snapshots[snapshotCursor]=snap;}
else recordSnapshot();
lastContinuousEdit=continuous?now:0;
dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');
Studio.updateHistoryButtons();Studio.updateGuidance();clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),550);
},
async refreshPreview(){
if(!pageId)return;
const page=pageId,seq=++previewRequest;
if(previewController)previewController.abort();previewController=new AbortController();
const frame=$('#sitePreview');$('#previewState').textContent='正在更新预览…';$('#previewDot').classList.add('loading');
try{
const doc=await api(`/api/sites/${siteId}/pages/${page}/editor-preview`,{...req('POST',{blocks,page_template:kind,layout_mode:layoutMode,site_design:siteDesignPreview}),signal:previewController.signal});
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest))return;
frame.srcdoc=doc;$('#previewState').textContent='实时预览 · 已更新';$('#previewDot').classList.remove('loading','error');
}catch(e){
if(!editorTools.shouldAcceptPreview(page,seq,pageId,previewRequest)||e.name==='AbortError')return;
$('#previewState').textContent='预览更新失败';$('#previewDot').classList.remove('loading');$('#previewDot').classList.add('error');toast('预览失败:'+e.message,true);
}
},
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);$('#deviceDetails').textContent=({desktop:'桌面端 · 自适应宽度',tablet:'平板端 · 780px',phone:'手机端 · 390px'})[type]||'桌面端';for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)},
changeTemplateKind(t){kind=t;Studio.modified()},
setLayoutMode(v){layoutMode=v;Studio.modified()},
async resetPreset(){if(!confirm('用当前主题的预设布局替换草稿?不会更改正式页面。'))return;try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/preset?page_template=${encodeURIComponent(kind)}`);blocks=result.blocks;selected=blocks.length?0:-1;Studio.modified();Studio.draw();toast('已加载预设到草稿,记得保存')}catch(e){toast(e.message,true)}},
async save(){try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft`,req('PUT',{blocks,page_template:kind,layout_mode:layoutMode,expected_revision:draftRevision}));draftRevision=result.revision;dirty=false;$('#saveStatus').textContent='草稿 v'+draftRevision;$('#saveStatus').classList.remove('dirty');toast('设计草稿已保存,正式构建不受影响')}catch(e){toast('草稿保存失败:'+e.message,true)}},
async publishDesign(){if(dirty){await Studio.save();if(dirty)return}if(!draftRevision)return toast('当前没有待发布草稿');if(!confirm('发布设计将更新内容库中的页面布局,下次静态构建才会生效。是否发布?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/publish`,req('POST',{expected_revision:draftRevision}));draftRevision=0;$('#saveStatus').textContent='已发布到内容库';toast('设计已发布;请构建网站生成新的正式静态文件')}catch(e){toast('发布失败:'+e.message,true)}},
async showHistory(){
if(!pageId)return toast('请先选择页面',true);
$('#studioHistoryModal').showModal();$('#studioHistoryItems').textContent='读取历史记录中…';
try{const data=await api(`/api/sites/${siteId}/pages/${pageId}/studio-history?limit=50`);
$('#studioHistoryItems').innerHTML=(data.items||[]).map(x=>`<div style="padding:12px 0;border-bottom:1px solid #e2e8f0;display:flex;gap:12px;align-items:center;justify-content:space-between"><div><strong>${x.kind==='published'?'已发布':'草稿'} · v${html(x.revision)}</strong><div style="font-size:12px;color:#64748b">${html(x.created_at)} · ${html(x.actor)} · SHA ${html(x.snapshot_hash.slice(0,12))}</div></div><button class="ui-btn ghost" type="button" onclick="Studio.restoreHistory('${html(x.id)}')">恢复为新草稿</button></div>`).join('')||'<p>当前页面尚无保存记录。</p>';
}catch(e){$('#studioHistoryItems').textContent='读取失败:'+e.message}
},
async restoreHistory(id){
if(dirty&&!confirm('当前有未保存的本地修改。恢复历史记录将舍弃这些本地修改,继续吗?'))return;
if(!confirm('确定以这个历史快照创建一个新的设计草稿?已发布的页面不会立即改变。'))return;
try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-history/${encodeURIComponent(id)}/restore`,req('POST',{expected_revision:draftRevision}));
dirty=false;$('#studioHistoryModal').close();await Studio.choosePage(pageId);
toast('历史版本已恢复为新草稿。核对后再点击发布设计。');
}catch(e){toast('历史恢复失败:'+e.message,true)}
},
async save(){
if(savePromise)return savePromise;
const page=pageId;const snapshot=JSON.stringify({blocks,kind,layoutMode});const frozen=JSON.parse(snapshot);
$('#saveStatus').textContent='保存中…';
savePromise=(async()=>{
try{
const result=await api(`/api/sites/${siteId}/pages/${page}/studio-draft`,req('PUT',{blocks:frozen.blocks,page_template:frozen.kind,layout_mode:frozen.layoutMode,expected_revision:draftRevision}));
if(page!==pageId)return;
draftRevision=result.revision;
const unchanged=snapshot===JSON.stringify({blocks,kind,layoutMode});
dirty=!unchanged;$('#saveStatus').textContent=unchanged?'草稿 v'+draftRevision:'有新修改待保存';
$('#saveStatus').classList.toggle('dirty',dirty);
toast(unchanged?'设计草稿已保存,正式构建不受影响':'草稿已保存,但有新修改,需再次保存');
}catch(e){dirty=true;$('#saveStatus').textContent='保存失败';$('#saveStatus').classList.add('dirty');toast('草稿保存失败:'+e.message,true)}
finally{savePromise=null;Studio.updateGuidance()}
})();return savePromise;
},
async publishDesign(){if(dirty){await Studio.save();if(dirty)return}if(!draftRevision)return toast('当前没有待发布草稿');if(!confirm('发布设计将更新内容库中的页面布局,下次静态构建才会生效。是否发布?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft/publish`,req('POST',{expected_revision:draftRevision}));draftRevision=0;$('#saveStatus').textContent='已发布到内容库';Studio.updateGuidance();toast('设计已发布到内容库。要更新线上网站,请在“更多”中构建并部署。')}catch(e){toast('发布失败:'+e.message,true)}},
async discardDraft(){if(!draftRevision)return;if(!confirm('确定放弃草稿?'))return;try{await api(`/api/sites/${siteId}/pages/${pageId}/studio-draft`,{method:'DELETE'});dirty=false;await Studio.choosePage(pageId);toast('草稿已放弃')}catch(e){toast(e.message,true)}},
dragChild(event,col,index){draggedChild={container:selected,col,index};event.dataTransfer.setData('application/siteforge-child','1');event.dataTransfer.effectAllowed='move';event.stopPropagation()},
dropChild(event,destCol){if(!draggedChild)return;event.preventDefault();event.stopPropagation();const drag=draggedChild;draggedChild=null;const src=blocks[drag.container],dest=blocks[selected];if(!src||!dest||src.type!=='layout'||dest.type!=='layout')return;const moved=src.columns?.[drag.col]?.blocks.splice(drag.index,1)[0];if(!moved)return;dest.columns[destCol].blocks.push(moved);Studio.modified();Studio.draw()},
@@ -80,15 +169,36 @@ const Studio={
for(const [name,value] of Object.entries(f)){const key='footer_'+name,el=form.elements.namedItem(key);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}}
for(const [name,value] of Object.entries(r)){const el=form.elements.namedItem(name);if(el){if(el.type==='checkbox')el.checked=value;else el.value=value}}
form.elements.namedItem('nav').value=JSON.stringify(siteDesign.nav,null,2);
form.elements.namedItem('mobile_tabs_enabled').checked=Boolean(siteDesign.mobile_tabs?.enabled);
Studio.drawMobileTabs(siteDesign.mobile_tabs?.items||[]);
Studio.toggleMobileTabsForm();
$('#siteDesignStatus').textContent=result.state==='draft'?`待发布的全站草稿 v${siteDesignRevision}${result.conflict?' · 网站已被其他人修改':''}`:'当前为已发布设计';
$('#siteDesignModal').showModal();
}catch(e){toast('全站设计读取失败:'+e.message,true)}},
drawMobileTabs(items){const root=$('#mobileTabsRows');root.replaceChildren();items.forEach((item,i)=>Studio.addMobileTab(item,i));},
toggleMobileTabsForm(){const on=$('#siteDesignForm').elements.namedItem('mobile_tabs_enabled').checked;$('#mobileTabsEditor').hidden=!on;},
addMobileTab(item={title:'新导航',icon:'grid',route:'/',action:'link'},index){
const root=$('#mobileTabsRows');if(root.children.length>=5){toast('底部最多支持 5 个 Tab',true);return}
const n=root.children.length;
const row=document.createElement('div');row.className='mobile-tab-row';row.style.cssText='display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border:1px solid #dce3ed;border-radius:10px;background:#fafcff';
const label=document.createElement('label');label.textContent='按钮名称';const title=document.createElement('input');title.className='mt-title';title.maxLength=12;title.value=item.title||'';label.append(title);
const iconLabel=document.createElement('label');iconLabel.textContent='按钮图标';const icon=document.createElement('select');icon.className='mt-icon';
for(const [v,t] of Object.entries({home:'⌂ 首页',grid:'▦ 栏目',products:'▤ 产品',news:'☷ 文章',contact:'✉ 联系',phone:'☎ 电话',info:'ⓘ 关于',search:'⌕ 搜索',menu:'☰ 菜单',star:'☆ 收藏'})){const opt=document.createElement('option');opt.value=v;opt.textContent=t;icon.append(opt)}icon.value=item.icon||'grid';iconLabel.append(icon);
const actionLabel=document.createElement('label');actionLabel.textContent='点击操作';const action=document.createElement('select');action.className='mt-action';for(const [v,t] of [['link','打开站内页面'],['menu','打开导航抽屉']]){const o=document.createElement('option');o.value=v;o.textContent=t;action.append(o)}action.value=item.action||'link';actionLabel.append(action);
const routeLabel=document.createElement('label');routeLabel.textContent='链接地址';const route=document.createElement('input');route.className='mt-route';route.placeholder='/products/、https://wa.me/... 或 tel:+...';route.value=item.route||'/';routeLabel.append(route);
function updateRoute(){routeLabel.hidden=action.value==='menu'}action.addEventListener('change',updateRoute);updateRoute();
const tools=document.createElement('div');tools.style.cssText='grid-column:1/-1;display:flex;gap:7px;justify-content:flex-end';
for(const [name,fn] of [['↑ 上移',()=>{const prev=row.previousElementSibling;if(prev)root.insertBefore(row,prev)}],['↓ 下移',()=>{const next=row.nextElementSibling;if(next)root.insertBefore(next,row)}],['删除',()=>row.remove()]]){const b=document.createElement('button');b.type='button';b.className='ui-btn ghost';b.textContent=name;b.addEventListener('click',fn);tools.append(b)}
row.append(label,iconLabel,actionLabel,routeLabel,tools);root.append(row);
},
collectSiteDesign(){const form=$('#siteDesignForm');if(!siteDesign)throw Error('请先打开全站设计');
const v=(n)=>form.elements.namedItem(n).value;
const yes=(n)=>form.elements.namedItem(n).checked;
let nav;try{nav=JSON.parse(v('nav'))}catch(e){throw Error('导航必须是合法 JSON:'+e.message)}
if(!Array.isArray(nav))throw Error('导航必须是数组');
return {header:{announcement:v('announcement'),show_announcement:yes('show_announcement'),show_search:yes('show_search'),
const tabs=[...$('#mobileTabsRows').children].map(row=>({title:row.querySelector('.mt-title').value.trim(),icon:row.querySelector('.mt-icon').value,route:row.querySelector('.mt-route').value.trim(),action:row.querySelector('.mt-action').value}));
if(yes('mobile_tabs_enabled')&&!tabs.length)throw Error('开启底部 Tabs 后,请至少添加一个按钮');
return {tokens:structuredClone(siteDesign.tokens),mobile_tabs:{enabled:yes('mobile_tabs_enabled'),items:tabs},header:{announcement:v('announcement'),show_announcement:yes('show_announcement'),show_search:yes('show_search'),
show_cta:yes('show_cta'),cta_text:v('cta_text'),cta_href:v('cta_href')},
footer:{title:v('footer_title'),description:v('footer_description'),email:v('footer_email'),show_nav:yes('footer_show_nav')},nav,
responsive:{tablet_gap:Number(v('tablet_gap')),mobile_gap:Number(v('mobile_gap')),
@@ -143,6 +253,12 @@ window.addEventListener('message',event=>{
const frame=document.querySelector('#sitePreview');
if(!frame||event.source!==frame.contentWindow)return;
const data=event.data||{};
if(data.source==='siteforge-select-block'){
if(typeof data.blockId!=='string'||data.blockId.length>150)return;
const findTop=rows=>{for(let i=0;i<rows.length;i++){const row=rows[i];if(row.id===data.blockId)return i;if(row.type==='layout'&&row.columns?.some(col=>col.blocks?.some(child=>child.id===data.blockId)))return i}return -1};
const index=findTop(blocks);if(index>=0){selected=index;Studio.draw();if(window.matchMedia('(max-width:1060px)').matches){document.body.classList.add('show-inspector');document.body.classList.remove('show-structure')}}
return;
}
if(data.source!=='siteforge-inline-editor')return;
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;
@@ -152,4 +268,12 @@ window.addEventListener('message',event=>{
toast('已修改文字,点击保存页面写入数据库');
});
window.addEventListener('keydown',event=>{if(!(event.ctrlKey||event.metaKey)||event.altKey)return;const el=document.activeElement;if(el&&['INPUT','TEXTAREA'].includes(el.tagName))return;if(event.key.toLowerCase()==='z'){event.preventDefault();event.shiftKey?Studio.redo():Studio.undo()}else if(event.key.toLowerCase()==='y'){event.preventDefault();Studio.redo()}});
window.addEventListener('keydown',event=>{
if(!(event.ctrlKey||event.metaKey)||event.altKey)return;
const key=event.key.toLowerCase();
if(key==='s'){event.preventDefault();Studio.save();return}
const el=document.activeElement;
if(el&&(['INPUT','TEXTAREA'].includes(el.tagName)||el.isContentEditable))return;
if(key==='z'){event.preventDefault();event.shiftKey?Studio.redo():Studio.undo()}
else if(key==='y'){event.preventDefault();Studio.redo()}
});
+18 -5
View File
@@ -43,11 +43,24 @@ async function readData(){let f=$('#dataFile').files?.[0];if(!f)return toast('
async function previewRelations(){try{const r=await api(factoryUrl('/relationships'));$('#relationReport').innerHTML=`<div class="card"><b>产品-品牌关联检查</b><p>已有品牌 ${r.brands.length} 个、分类 ${r.categories.length} 个;缺少独立品牌页 ${r.brand_pages_missing.length} 个</p><p>${H(r.brand_pages_missing.map(x=>x.brand).join(' / ')||'无')}</p>${r.issues.slice(0,10).map(x=>`<p>${H(x.route)} · ${H(x.message)}</p>`).join('')}</div>`;}catch(e){toast(e.message,true)}}
async function 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)}}
function drawMapping(){
const fields=['title','route','summary','body','cover','brand','category','model','year','energy','sku','seo_title','seo_description'];
const labels={title:'标题',route:'URL 原路径',summary:'摘要',body:'正文',cover:'封面',brand:'品牌',category:'分类',model:'型号',year:'年份',energy:'动力类型',sku:'SKU',seo_title:'SEO 标题',seo_description:'SEO 描述'};
state.mappingPlanHash=null;
$('#mappingUI').innerHTML=`<div style="margin-top:27px"><h2>字段映射(已读取 ${state.importRows.length} 行)</h2><p class="muted">可以为每个字段选择大小写变换;所有转换都由固定规则执行,不运行代码。</p>${fields.map(t=>`<div class="rule"><strong>${H(labels[t])}</strong><select data-target="${t}" class="mapfield" onchange="state.mappingPlanHash=null"><option value="">不映射</option>${state.fields.map(x=>`<option value="${H(x)}" ${state.mapping[t]===x?'selected':''}>${H(x)}</option>`).join('')}</select><select data-transform="${t}" class="mapcase" aria-label="${H(labels[t])}的大小写转换" onchange="state.mappingPlanHash=null"><option value="none">原样</option><option value="upper">英文大写</option><option value="lower">英文小写</option></select></div>`).join('')}<div class="field"><label>URL 冲突处理<select id="mapConflict" onchange="state.mappingPlanHash=null"><option value="abort">遇到冲突立即阻断(推荐)</option><option value="skip">跳过已有 URL</option><option value="replace">覆盖已有 URL(谨慎)</option></select></label></div><button class="outline" onclick="mappingPreview()">预览数据和错误</button><button class="primary" onclick="mappingCommit()">按本次预检确认导入</button><div id="mappingReport" style="margin-top:16px"></div></div>`;
}
function gather(){let rules={};$$('.mapfield').forEach(x=>{if(x.value){const mode=document.querySelector(`[data-transform="${x.dataset.target}"]`)?.value||'none';rules[x.dataset.target]=mode==='none'?x.value:{source:x.value,case:mode}}});return {rows:state.importRows,rules,content_type:$('#mapKind').value}}
async function mappingPreview(){try{const payload=gather(),r=await api(factoryUrl('/mapping/run'),'POST',{...payload,dry_run:true});state.mappingPlanHash=r.plan_hash;$('#mappingReport').innerHTML=`<div class="card"><b>预览:${r.mapped} 条可映射</b><p>错误 ${r.errors.length} 条,URL 冲突 ${r.conflicts.length} 条</p><p>预检指纹:${H(r.plan_hash.slice(0,16))}…(提交时验证内容未变化)</p><div class="scroll"><table><thead><tr><th>标题</th><th>新 URL</th><th>内容类型</th></tr></thead><tbody>${r.sample.map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.route)}</td><td>${H(x.type)}</td></tr>`).join('')}</tbody></table></div>${r.errors.length?'<p class="error">'+H(JSON.stringify(r.errors.slice(0,6)))+'</p>':''}</div>`}catch(e){state.mappingPlanHash=null;toast(e.message,true)}}
async function mappingCommit(){
if(!state.mappingPlanHash)return toast('请先执行映射预览,确认 URL 和冲突后再导入',true);
const policy=$('#mapConflict').value;
if(policy==='replace'&&!confirm('覆盖模式将修改已有同 URL 内容。请确认已备份并核对预览。'))return;
if(!confirm('将当前预检结果写入当前网站?'))return;
try{const r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:false,on_conflict:policy,expected_plan_hash:state.mappingPlanHash});state.mappingPlanHash=null;toast(`新增 ${r.created} 条,更新 ${r.updated} 条,跳过 ${r.skipped} 条,错误 ${r.errors.length} 条`);await render()}
catch(e){state.mappingPlanHash=null;toast('导入失败,请重新预检:'+e.message,true)}
}
async function migration(){let [data,assets,builds]=await Promise.all([api(factoryUrl('/migration/readiness')),api(factoryUrl('/assets/inventory')),api(siteUrl('/builds'))]);
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)}}
+8 -4
View File
@@ -14,14 +14,15 @@ function formatJobs(list){return list.length?list.map(j=>`<div class="row"><div>
async function chooseSite(){siteId=$('#site').value;window.SiteForgeShell?.selectSite(siteId);lastPreview=null;$('#commitImport').disabled=true;await reload()}
async function init(){try{csrf=(await request('/api/auth/me')).csrf;sites=await request('/api/sites');$('#site').innerHTML=sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');siteId=sites[0]?.id||'';$('#site').onchange=chooseSite;$('#reload').onclick=reload;$('#previewImport').onclick=()=>importJSON(false);$('#commitImport').onclick=()=>importJSON(true);$('#build').onclick=buildSite;$('#deploy').onclick=deploySite;$('#refreshRelease').onclick=reload;await reload()}catch(e){notify(e.message,true)}}
async function reload(){if(!siteId)return;try{
const [list,jobsData,buildData,approvals,history,reviewData]=await Promise.all([
request(`/api/sites/${siteId}`),request(`/api/enterprise/sites/${siteId}/jobs`),request(`/api/sites/${siteId}/builds`),request(`/api/factory/sites/${siteId}/release-requests`),request(`/api/enterprise/sites/${siteId}/releases`),request(`/api/migrations/sites/${siteId}/reviews`)]);
const [list,jobsData,buildData,approvals,history,reviewData,deliveryData]=await Promise.all([
request(`/api/sites/${siteId}`),request(`/api/enterprise/sites/${siteId}/jobs`),request(`/api/sites/${siteId}/builds`),request(`/api/factory/sites/${siteId}/release-requests`),request(`/api/enterprise/sites/${siteId}/releases`),request(`/api/migrations/sites/${siteId}/reviews`),request(`/api/production/sites/${siteId}/delivery-status`)]);
jobs=jobsData;builds=buildData;releases=history;$('#statBuild').textContent=builds.length;$('#statJobs').textContent=jobs.filter(j=>['queued','running','retrying'].includes(j.state)).length;$('#statApprovals').textContent=approvals.filter(j=>j.state==='pending').length;$('#statMode').textContent=list.mode==='migration'?'迁移':'新建';$('#jobList').innerHTML=formatJobs(jobs);
$('#buildList').innerHTML=builds.length?builds.slice(0,14).map(b=>`<div class="row"><div><b>${esc(b.id)}</b><small>${esc(b.created_at)} · ${esc(b.total_pages)} 页</small></div><button class="secondary" onclick="requestApproval('${esc(b.id)}')">提交审批</button></div>`).join(''):'当前没有构建';
$('#releaseBuild').innerHTML=builds.map(b=>`<option value="${esc(b.id)}">${esc(b.id)} · ${esc(b.total_pages)} 页</option>`).join('');
$('#approvals').innerHTML=approvals.length?approvals.slice(0,12).map(a=>`<div class="row"><div><b>${esc(a.build_id)} <span class="flag ${a.state==='pending'?'warn':''}">${esc(a.state)}</span></b><small>由 ${esc(a.requested_by)} 提交</small></div>${a.state==='pending'?`<button class="secondary" onclick="approve('${esc(a.id)}')">批准</button>`:''}</div>`).join(''):'暂无审批记录';
$('#reviews').innerHTML=reviewData.items.length?reviewData.items.map(x=>`<div class="row"><div><b>${esc(x.platform)} · 第 ${esc(x.row_number)} 条</b><small>${esc(x.issue)}</small></div><button class="secondary" onclick="resolveReview('${esc(x.id)}')">已检查并处理</button></div>`).join(''):'暂无待复核事项';
$('#releases').innerHTML=history.length?history.slice(0,10).map(x=>`<div class="row"><div><b>${esc(x.build_id)} · ${esc(x.action)}</b><small>${esc(x.created_at)}</small></div></div>`).join(''):'暂无部署记录';
$('#releaseBackend').textContent=['staging','production'].map(t=>`${t}: ${deliveryData.backends?.[t]||'agent'} · ${deliveryData.configured?.[t]?'已配置':'未配置'}`).join(' | ');
$('#releases').innerHTML=deliveryData.history?.length?deliveryData.history.slice(0,10).map(x=>`<div class="row"><div><b>${esc(x.build_id)} · ${esc(x.target)} · ${esc(x.release_id)}</b><small>${esc(x.deployed_at)}</small></div><button class="secondary" onclick="rollbackRelease('${esc(x.release_id)}','${esc(x.target)}')">回滚</button></div>`).join(''): '暂无远程发布记录';
const entries=await request(`/api/sites/${siteId}/entries`);const entry=entries.find(p=>p.route==='/')||entries[0];$('#edit').href=entry?`/editor/${siteId}/${entry.id}`:'/factory-studio';
}catch(e){notify(e.message,true)}}
async function importJSON(commit){const file=$('#file').files?.[0];if(!file)return notify('请选择 JSON 文件',true);if(!siteId)return;const platform=$('#platform').value;
@@ -37,6 +38,9 @@ async function buildSite(){try{const task=await post(`/api/sites/${siteId}/build
async function cancelJob(id){try{await post(`/api/enterprise/sites/${siteId}/jobs/${id}/cancel`,{});await reload()}catch(e){notify(e.message,true)}}
async function requestApproval(build){try{await post(`/api/factory/sites/${siteId}/release-requests`,{build_id:build});notify('已提交发布审批');await reload()}catch(e){notify(e.message,true)}}
async function approve(id){if(!confirm('审批人必须与申请人不同。确定审批通过吗?'))return;try{await post(`/api/factory/sites/${siteId}/release-requests/${id}/review`,{state:'approved'});notify('审批已完成');await reload()}catch(e){notify(e.message,true)}}
async function deploySite(){if(!confirm('请确保目标环境已配置 HTTPS 和发布 Agent。继续发布?'))return;try{const data=await post(`/api/production/sites/${siteId}/deploy`,{build_id:$('#releaseBuild').value,target:$('#target').value});notify(`已发布:${data.remote.release}`);await reload()}catch(e){notify(e.message,true)}}
async function deploySite(){if(!confirm('系统将使用目标环境预先配置的发布适配器。请确认构建已审批、DNS 与版本回退方案已准备好。继续发布?'))return;try{const data=await post(`/api/production/sites/${siteId}/deploy`,{build_id:$('#releaseBuild').value,target:$('#target').value});notify(`发布任务 ${data.id} 已提交,Worker 将先上传、校验,再切换线上版本。`);await reload()}catch(e){notify(e.message,true)}}
async function rollbackRelease(release,target){if(!confirm(`确认将 ${target} 恢复为历史版本 ${release}?`))return;
try{const result=await post(`/api/production/sites/${siteId}/rollback`,{release_id:release,target});notify(`回滚任务 ${result.id} 已进入队列`);await reload()}catch(e){notify(e.message,true)}}
window.rollbackRelease=rollbackRelease;
window.resolveReview=resolveReview;window.cancelJob=cancelJob;window.requestApproval=requestApproval;window.approve=approve;
window.addEventListener('DOMContentLoaded',init);
+46
View File
@@ -1,6 +1,52 @@
'use strict';(()=>{
const menu=document.querySelector('[data-pro-menu]'); const nav=document.querySelector('#pro-navigation');
if(menu&&nav){menu.addEventListener('click',()=>{const open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));nav.classList.toggle('is-open',open);menu.setAttribute('aria-label',open?'关闭导航':'打开导航');});}
// Drawer navigation. The same markup is used across the four professional themes.
const mobileNav=document.querySelector('#pro-navigation');
let drawerBackdrop=null,drawerClose=null,returnFocus=null;
if(menu&&mobileNav){
const originalParent=mobileNav.parentNode,originalNext=mobileNav.nextSibling;
const heading=document.createElement('div');heading.className='sf-drawer-header';
const title=document.createElement('strong');title.textContent='网站导航';
drawerClose=document.createElement('button');drawerClose.type='button';drawerClose.className='sf-drawer-close';drawerClose.textContent='×';drawerClose.setAttribute('aria-label','关闭网站导航');
heading.append(title,drawerClose);mobileNav.prepend(heading);
drawerBackdrop=document.createElement('div');drawerBackdrop.className='sf-mobile-drawer-backdrop';drawerBackdrop.hidden=false;document.body.append(drawerBackdrop);
function setDrawer(open,focusBack=true){
const isMobile=window.matchMedia('(max-width:1024px)').matches;
open=Boolean(open&&isMobile);
if(open){returnFocus=document.activeElement;document.body.append(mobileNav);}
else if(mobileNav.parentNode!==originalParent){if(originalNext&&originalNext.parentNode===originalParent)originalParent.insertBefore(mobileNav,originalNext);else originalParent.append(mobileNav);}
mobileNav.classList.toggle('is-open',open);drawerBackdrop.classList.toggle('is-open',open);
document.body.classList.toggle('sf-drawer-open',open);
menu.setAttribute('aria-expanded',String(open));menu.setAttribute('aria-label',open?'关闭导航':'打开导航');
mobileNav.setAttribute('aria-hidden',String(!open&&isMobile));
if('inert' in mobileNav)mobileNav.inert=!open&&isMobile;
if(open)drawerClose.focus();
else if(focusBack&&returnFocus instanceof HTMLElement){returnFocus.focus();returnFocus=null;}
}
// Replace the old inline-expansion listener: use capture to stop the old listener.
menu.addEventListener('click',e=>{if(!window.matchMedia('(max-width:1024px)').matches)return;e.stopImmediatePropagation();setDrawer(!mobileNav.classList.contains('is-open'));},true);
drawerClose.addEventListener('click',()=>setDrawer(false));
drawerBackdrop.addEventListener('click',()=>setDrawer(false));
document.querySelectorAll('[data-mobile-open-menu]').forEach(b=>b.addEventListener('click',()=>setDrawer(true)));
mobileNav.addEventListener('click',e=>{if(e.target.closest('a'))setDrawer(false,false)});
document.addEventListener('keydown',e=>{
if(e.key==='Escape'&&mobileNav.classList.contains('is-open')){e.preventDefault();setDrawer(false);}
if(e.key!=='Tab'||!mobileNav.classList.contains('is-open'))return;
const els=[...mobileNav.querySelectorAll('button:not([disabled]),a[href]')].filter(el=>el.getClientRects().length&&!el.closest('[hidden]'));
if(!els.length)return;const first=els[0],last=els[els.length-1];
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
});
window.matchMedia('(max-width:1024px)').addEventListener('change',()=>setDrawer(false,false));
if('inert' in mobileNav&&window.matchMedia('(max-width:1024px)').matches)mobileNav.inert=true;
// Do not strand catalogue links when the old mobile mega-button is hidden.
const megaLinks=document.querySelectorAll('[data-pro-mega] a');
if(megaLinks.length){const wrap=document.createElement('div');wrap.className='sf-drawer-extra';const h=document.createElement('strong');h.textContent='更多分类与系列';wrap.append(h);
[...megaLinks].slice(0,20).forEach(a=>{const clone=a.cloneNode(true);wrap.append(clone);});mobileNav.append(wrap);}
}
const tabbar=document.querySelector('[data-mobile-tabs]');
if(tabbar){tabbar.style.setProperty('--sf-mobile-tab-count',String(tabbar.children.length));}
const megaButton=document.querySelector('[data-pro-catalog]'), mega=document.querySelector('[data-pro-mega]');
if(megaButton&&mega){function setMega(open){mega.hidden=!open;mega.classList.toggle('is-open',open);megaButton.setAttribute('aria-expanded',String(open));}
megaButton.addEventListener('click',()=>setMega(mega.hidden));
+1
View File
@@ -2,4 +2,5 @@
/* V8.12: task progress and dangerous actions, consistent focus and mobile sizing */
.btn-danger{border:1px solid #f1c3c7;color:#a92840;background:#fff5f6}.btn-danger:hover{background:#feecee}.btn:disabled{opacity:.55;cursor:not-allowed}.build-row{min-width:0}.build-meta{overflow-wrap:anywhere}.site-card:focus-visible,.quick-card:focus-visible{outline:3px solid #80aaf6;outline-offset:2px}@media(max-width:570px){.build-row{align-items:flex-start;flex-wrap:wrap}.build-row>.inline-actions{width:100%;padding-left:0}.site-picker-wrap select{max-width:130px}}
.theme-preview.trade-pro{background:linear-gradient(125deg,#0b3d91,#3d6dc1);color:#e8f1fb}.site-icon.trade-pro{background:#e8f1fb;color:#0b3d91}
+53 -14
View File
@@ -7,8 +7,8 @@ const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',br
const fmtDate=t=>{if(!t)return '—';const d=new Date(t);return Number.isNaN(d.getTime())?String(t).slice(0,16):d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})};
const sfSiteKey='siteforge:selected-site';
const sfRemember=id=>{try{if(id)localStorage.setItem(sfSiteKey,id)}catch{}};
const state={sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null,selectionToken:0,building:false};
const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',settings:'网站设置'};
const state={sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null,selectionToken:0,building:false,delivery:{jobs:[],history:[],configured:{}},language:{group:null,sites:[],units:[]},languageGroups:[],languageEntries:[]};
const headings={home:'工作台',sites:'我的网站',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'构建与交付',languages:'多语言管理',settings:'网站设置'};
async function api(url,method='GET',data=null,extraHeaders={}){
const headers={...extraHeaders};if(method!=='GET'&&method!=='HEAD')headers['X-SiteForge-CSRF']=state.csrf;
let body=data;if(data!=null && !(data instanceof FormData)){headers['Content-Type']='application/json';body=JSON.stringify(data)}
@@ -86,28 +86,61 @@ function builds(){if(!currentSite())return noSite();const latest=latestDone();co
(state.jobs.length?state.jobs.slice(0,8).map(j=>`<div class="build-row"><span class="quick-ico">${j.state==='done'?'✓':j.state==='failed'?'!':'◷'}</span><div class="build-meta"><h3>${esc(buildStatusLabel(j.state))}</h3><p>${esc(fmtDate(j.created_at))} · 任务 ${esc(j.id.slice(0,12))}${j.state==='failed'&&j.error?' · '+esc(j.error):''}</p></div>${['queued','running','retrying'].includes(j.state)?`<button class="btn btn-tiny btn-danger" data-action="cancel-job" data-id="${esc(j.id)}">取消任务</button>`:''}</div>`).join(''):'<p class="muted">当前网站还没有后台构建任务。</p>')+`</div>`+
`<div class="panel" style="margin-top:18px"><div class="panel-head"><div><h2>构建版本</h2><p>建议先预览和核对质量报告,再下载 ZIP。预览不代表正式发布。</p></div></div>`+
(state.builds.length?state.builds.slice(0,20).map(b=>`<div class="build-row"><span class="quick-ico">${['done','completed','success','complete'].includes(b.status)?'✓':'◷'}</span><div class="build-meta"><h3>${esc(fmtDate(b.created_at))} · ${esc(b.total_pages||'—')} 个页面</h3><p>版本 ${esc(b.id.slice(0,12))} · ${esc(b.status||'未知')}</p></div><div class="inline-actions">${['done','completed','success','complete'].includes(b.status)?`<a class="btn btn-tiny" href="/preview/${encodeURIComponent(state.site.id)}/${encodeURIComponent(b.id)}/" target="_blank" rel="noopener">预览 ↗</a><button class="btn btn-tiny" data-action="report" data-id="${esc(b.id)}">质量报告</button><a class="btn btn-tiny btn-primary" href="/api/sites/${encodeURIComponent(state.site.id)}/builds/${encodeURIComponent(b.id)}/download">下载 ZIP</a>`:'<span class="muted">尚未完成,暂不能下载</span>'}</div></div>`).join(''):emptyState('尚无完成版本','准备好网站数据后,在上方提交构建任务。'))+
`<div id="reportContent"></div></div>`;
`<div id="reportContent"></div></div>`+
`<div class="panel" style="margin-top:18px"><div class="panel-head"><div><h2>正式发布与回滚</h2><p>先完成质量检查与独立审批,Worker 负责上传、校验和切换版本。没有配置目标服务器时不能发布。</p></div><a href="/operations" class="btn btn-tiny">审批与复核工具 ↗</a></div>
<div class="field-grid"><label>选择已完成构建<select id="deliveryBuild">${state.builds.filter(b=>b.status==='complete').map(b=>`<option value="${esc(b.id)}">${esc(b.id.slice(0,12))} · ${esc(fmtDate(b.created_at))}</option>`).join('')}</select></label>
<label>目标环境<select id="deliveryTarget"><option value="staging">测试环境(Staging)</option><option value="production">正式环境(Production)</option></select></label></div>
<div class="form-actions"><button class="btn" data-action="release-preflight">检查发布条件</button><button class="btn" data-action="release-approval">提交独立审批</button><button class="btn btn-primary" data-action="release-deploy">提交发布任务</button></div>
<div id="deliveryNotice" class="message" aria-live="polite" hidden></div>
<h3>最近发布任务</h3>${(state.delivery.jobs||[]).length?state.delivery.jobs.slice(0,8).map(j=>`<div class="build-row"><div class="build-meta"><strong>${j.kind==='remote_rollback'?'回滚':'部署'} · ${esc(buildStatusLabel(j.state))}</strong><p>${esc(j.id.slice(0,12))} · ${esc(j.created_at)}${j.error?' · '+esc(j.error):''}</p></div>${['queued','running','retrying'].includes(j.state)?`<button class="btn btn-tiny btn-danger" data-action="cancel-job" data-id="${esc(j.id)}">取消</button>`:''}</div>`).join(''):'<p class="muted">暂无发布任务。</p>'}
<h3>已发布版本</h3>${(state.delivery.history||[]).length?state.delivery.history.slice(0,8).map(r=>`<div class="build-row"><div class="build-meta"><strong>${esc(r.target)} · ${esc(r.release_id)}</strong><p>构建 ${esc(r.build_id.slice(0,12))} · ${esc(r.deployed_at)}</p></div><button class="btn btn-tiny" data-action="release-rollback" data-target="${esc(r.target)}" data-id="${esc(r.release_id)}">回滚到此版本</button></div>`).join(''):'<p class="muted">当前没有远程部署记录。</p>'}</div>`;
}
function settings(){if(!currentSite())return noSite();const s=state.site;return htmlHeading('网站设置','这里只管理公司资料、域名与站点属性。网页样式请到“外观与设计”修改。')+
`<div class="panel"><h2>基本资料</h2><p class="panel-desc">没有域名也可以制作与预览网站,正式上线前再填写即可。</p><form id="settingsForm"><div class="field-grid"><label class="full">网站名称<input name="name" required value="${esc(s.name)}"></label><label class="full">正式域名<input name="domain" placeholder="https://www.example.com" value="${esc(s.domain||'')}"></label><label class="full">企业介绍 / 网站标语<textarea name="tagline" rows="3">${esc(s.tagline||'')}</textarea></label><label class="full">Logo 图片地址<input name="logo" value="${esc(s.logo||'')}"></label><label>网站类型<select name="mode"><option value="new" ${s.mode==='new'?'selected':''}>新建网站</option><option value="migration" ${s.mode==='migration'?'selected':''}>旧站迁移</option><option value="hybrid" ${s.mode==='hybrid'?'selected':''}>混合建站</option></select></label><label>主题色<input type="color" name="accent" value="${esc(s.accent||'#245ac6')}" style="padding:4px;height:43px"></label></div><div class="form-actions"><button class="btn btn-primary" type="submit">保存网站设置</button></div></form></div>`;
}
function languages(){
if(!currentSite())return noSite();
const d=state.language||{group:null,sites:[],units:[]};const g=d.group;
const groups=state.languageGroups||[];
const header=htmlHeading('多语言管理','多个语言版本分别拥有独立的网站、域名和内容。系统只关联已审核的翻译,不会自动翻译企业事实。','<button class="btn" data-action="language-refresh">刷新语言状态</button>');
if(!g)return header+`<div class="panel"><h2>第一步 · 建立语言组</h2><p class="panel-desc">例如“集团全球网站”包含中文版、英文版、法文版。创建语言组后将网站加入对应语言。</p><form id="langGroupForm"><div class="field-grid"><label>语言组名称<input name="name" required minlength="2" placeholder="HESU 国际站"></label><label>默认语言<select name="default_locale"><option value="en">English (en)</option><option value="zh-CN">简体中文 (zh-CN)</option><option value="fr-FR">Français (fr-FR)</option><option value="ar">العربية (ar)</option></select></label><label>当前网站的实际语言<input name="current_locale" value="zh-CN" required placeholder="例如 zh-CN 或 en"></label></div><div class="form-actions"><button class="btn btn-primary">创建语言组并关联当前网站</button></div></form></div>`+
(groups.length?`<div class="panel"><h2>加入已有语言组</h2><p class="panel-desc">仅可关联您有管理权限、且已配置独立 HTTPS 域名的网站。</p><form id="langJoinForm"><div class="field-grid"><label>语言组<select name="group_id">${groups.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></label><label>当前网站的语言<input name="locale" value="zh-CN" required placeholder="zh-CN / en / fr-FR"></label></div><div class="form-actions"><button class="btn btn-primary">加入语言组</button></div></form></div>`:'')+
`<div class="help-hint"><strong>上线前提示:</strong>网站设置中的正式域名应为 https://example.com 形式;不同语言网站不能共用一个域名。当前域名:${esc(state.site.domain||'未设置')}</div>`;
const groupSites=d.sites||[]; const uniqueUnits=[...new Map((d.units||[]).map(u=>[u.id,u])).values()];
const canJoin=state.sites.filter(x=>!groupSites.some(m=>m.site_id===x.id));
return header+`<div class="panel"><div class="panel-head"><div><h2>${esc(g.name)}</h2><p>当前站语言 ${esc(g.locale)} · 默认语言 ${esc(g.default_locale)} · ${groupSites.length} 个语言站点</p></div></div><div class="table-wrap"><table class="data-table"><thead><tr><th>语言</th><th>网站</th><th>正式域名</th></tr></thead><tbody>${groupSites.map(x=>`<tr><td>${esc(x.locale)}</td><td>${esc(x.name)}</td><td>${esc(x.domain)}</td></tr>`).join('')}</tbody></table></div></div>`+
`<div class="panel"><h2>第二步 · 添加其他语言网站</h2><form id="langJoinForm"><div class="field-grid"><input type="hidden" name="group_id" value="${esc(g.group_id)}"><label>已有网站<select name="site_id">${canJoin.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></label><label>对应语言<input name="locale" required placeholder="例如 en、fr-FR"></label></div><div class="form-actions"><button class="btn btn-primary" ${!canJoin.length?'disabled':''}>加入语言站</button></div></form><p class="muted">先通过“我的网站”创建另一语言站并设置 HTTPS 域名,再加入语言组。</p></div>`+
`<div class="panel"><h2>第三步 · 建立翻译内容关系</h2><p class="panel-desc">相同产品、文章或页面可以使用不同 URL。用一个内容标识关联它们,只有正式发布且审核通过的内容才会生成 hreflang。</p><form id="langUnitForm"><div class="field-grid"><label class="full">翻译组标识<input name="reference_key" required placeholder="例如 product:rox-01 或 about-page"></label></div><div class="form-actions"><button class="btn btn-primary">创建内容翻译组</button></div></form></div>`+
`<div class="panel"><h2>第四步 · 关联各语言的实际内容</h2><form id="langLinkForm"><div class="field-grid"><label>翻译内容组<select name="unit_id">${uniqueUnits.map(x=>`<option value="${esc(x.id)}">${esc(x.reference_key)}</option>`).join('')}</select></label><label>语言网站<select id="langMemberSite" name="site_id">${groupSites.map(x=>`<option value="${esc(x.site_id)}" ${x.site_id===state.site.id?'selected':''}>${esc(x.locale)} · ${esc(x.name)}</option>`).join('')}</select></label><label class="full">对应内容<select id="langMemberEntry" name="entry_id">${(state.languageEntries||[]).map(x=>`<option value="${esc(x.id)}">${esc(x.title)} (${esc(x.route)}) · ${x.status==='published'?'正式':'草稿'}</option>`).join('')}</select></label><label>审核状态<select name="state"><option value="pending">待翻译(仅允许草稿)</option><option value="approved">翻译已确认(仅允许正式内容)</option></select></label></div><div class="form-actions"><button class="btn btn-primary" ${uniqueUnits.length?'':'disabled'}>保存关联</button></div></form></div>`+
`<div class="panel"><h2>已有语言关系</h2><p class="panel-desc">没有已审核的双向内容关系,就不会输出假语言链接。</p>${d.units.length?`<div class="table-wrap"><table class="data-table"><thead><tr><th>内容组</th><th>语言</th><th>内容 / URL</th><th>状态</th></tr></thead><tbody>${d.units.map(u=>`<tr><td>${esc(u.reference_key)}</td><td>${esc(u.locale)}</td><td>${esc(u.title)}<br><small>${esc(u.route)}</small></td><td>${u.state==='approved'?'已审核':'待翻译'}</td></tr>`).join('')}</tbody></table></div>`:'<p class="muted">还没有关联内容。</p>'}</div>`;
}
async function loadLanguage(){const sid=state.site?.id;if(!sid)return;try{
const [data,groups]=await Promise.all([api('/api/i18n/sites/'+encodeURIComponent(sid)),api('/api/i18n/groups')]);
if(state.site?.id!==sid)return;state.language=data;state.languageGroups=groups;await loadLanguageEntries(sid,false);if(state.view==='languages')rerender();
}catch(e){notifyError(e)}}
async function loadLanguageEntries(siteId,redraw=true){if(!siteId)return;try{const rows=await api('/api/sites/'+encodeURIComponent(siteId)+'/entries');state.languageEntries=rows;if(redraw){const target=$('#langMemberEntry');if(target)target.innerHTML=rows.map(x=>`<option value="${esc(x.id)}">${esc(x.title)} (${esc(x.route)}) · ${x.status==='published'?'正式':'草稿'}</option>`).join('')}}catch(e){notifyError(e);state.languageEntries=[]}}
async function submitLanguageGroup(ev){ev.preventDefault();if(!state.site)return;try{const d=Object.fromEntries(new FormData(ev.target));const locale=d.current_locale;delete d.current_locale;const group=await api('/api/i18n/groups','POST',d);await api('/api/i18n/groups/'+group.id+'/sites','POST',{site_id:state.site.id,locale});toast('语言组已创建');await loadLanguage()}catch(e){notifyError(e)}}
async function submitLanguageJoin(ev){ev.preventDefault();try{const d=Object.fromEntries(new FormData(ev.target));await api('/api/i18n/groups/'+d.group_id+'/sites','POST',{site_id:d.site_id||state.site.id,locale:d.locale});toast('语言站已关联');await loadLanguage()}catch(e){notifyError(e)}}
async function submitLanguageUnit(ev){ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));try{await api('/api/i18n/groups/'+state.language.group.group_id+'/units','POST',d);toast('翻译组已创建');await loadLanguage()}catch(e){notifyError(e)}}
async function submitLanguageLink(ev){ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));try{await api('/api/i18n/groups/'+state.language.group.group_id+'/units/'+encodeURIComponent(d.unit_id)+'/members','POST',{site_id:d.site_id,entry_id:d.entry_id,state:d.state});toast('内容翻译关系已保存');await loadLanguage()}catch(e){notifyError(e)}}
function rerender(){const area=$('#mainContent');const selected=state.view;$('#breadcrumbTitle').textContent=headings[selected]||'工作台';$('#sideSiteName').textContent=state.site?.name||'请先选择网站';
document.querySelectorAll('[data-view].nav-item').forEach(el=>{const yes=el.dataset.view===selected;el.classList.toggle('active',yes);yes?el.setAttribute('aria-current','page'):el.removeAttribute('aria-current')});
area.innerHTML=({home,sites,overview,content,design,import:imports,build:builds,settings}[selected]||home)();
area.innerHTML=({home,sites,overview,content,design,import:imports,build:builds,languages,settings}[selected]||home)();
}
async function loadSites(){state.sites=await api('/api/sites');const picker=$('#sitePicker'),prev=state.site?.id||'';picker.innerHTML='<option value="">选择网站…</option>'+state.sites.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');picker.value=state.sites.some(s=>s.id===prev)?prev:''}
async function readDelivery(siteId){try{return await api(`/api/production/sites/${encodeURIComponent(siteId)}/delivery-status`)}catch(e){return {jobs:[],history:[],configured:{}}}}
async function readJobs(siteId){try{return await api(`/api/enterprise/sites/${encodeURIComponent(siteId)}/jobs`)}catch(e){if(![403,404].includes(Number(e.status)))console.warn('加载任务状态失败',e);return []}}
async function selectSite(id,go='overview',historyMode='push'){
const token=++state.selectionToken;
if(!id){state.site=null;state.entries=[];state.builds=[];state.jobs=[];state.job=null;state.view=go==='home'?'home':'sites';$('#sitePicker').value='';rerender();if(historyMode!=='none')rememberView(historyMode==='replace');return}
if(!id){state.site=null;state.entries=[];state.builds=[];state.jobs=[];state.delivery={jobs:[],history:[],configured:{}};state.job=null;state.view=go==='home'?'home':'sites';$('#sitePicker').value='';rerender();if(historyMode!=='none')rememberView(historyMode==='replace');return}
if(!state.sites.some(x=>x.id===id)){toast('该网站不在您的可见列表中',true);return}
try{const [site,entries,builds,jobs]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`),readJobs(id)]);
try{const [site,entries,builds,jobs,delivery]=await Promise.all([api(`/api/sites/${encodeURIComponent(id)}`),api(`/api/sites/${encodeURIComponent(id)}/entries`),api(`/api/sites/${encodeURIComponent(id)}/builds`),readJobs(id),readDelivery(id)]);
if(token!==state.selectionToken)return;
state.site=site;sfRemember(id);state.entries=entries;state.builds=builds;state.jobs=jobs;state.job=null;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();if(historyMode!=='none')rememberView(historyMode==='replace');closeMobile();window.scrollTo({top:0,behavior:'instant'});
state.site=site;sfRemember(id);state.entries=entries;state.builds=builds;state.jobs=jobs;state.delivery=delivery;state.job=null;state.view=go;state.search='';state.typ='';$('#sitePicker').value=id;rerender();if(historyMode!=='none')rememberView(historyMode==='replace');if(go==='languages')loadLanguage();closeMobile();window.scrollTo({top:0,behavior:'instant'});
}catch(e){if(token===state.selectionToken)notifyError(e)}
}
function navigate(to,historyMode='push'){if(!state.site&&!['home','sites'].includes(to)){state.view='sites';rerender();toast('请先选择网站项目');return}state.view=to;rerender();closeMobile();if(historyMode!=='none')rememberView(historyMode==='replace');if(to==='build')refreshBuilds();window.scrollTo({top:0,behavior:'instant'})}
async function refreshBuilds(){const id=state.site?.id;if(!id)return;try{const previouslyRunning=activeJobs().length>0;const [jobs,builds]=await Promise.all([readJobs(id),api(`/api/sites/${encodeURIComponent(id)}/builds`)]);if(state.site?.id!==id)return;state.jobs=jobs;state.builds=builds;if(previouslyRunning&&!activeJobs().length){const last=jobs[0];state.job=last?.state==='done'?'后台构建已完成,请预览并查看质量报告。':last?.state==='failed'?'本次构建失败,请在任务中心检查原因。':last?.state==='cancelled'?'本次构建已取消。':null}if(state.view==='build')rerender()}catch(e){notifyError(e)}}
function navigate(to,historyMode='push'){if(!state.site&&!['home','sites'].includes(to)){state.view='sites';rerender();toast('请先选择网站项目');return}state.view=to;rerender();closeMobile();if(historyMode!=='none')rememberView(historyMode==='replace');if(to==='build')refreshBuilds();if(to==='languages')loadLanguage();window.scrollTo({top:0,behavior:'instant'})}
async function refreshBuilds(){const id=state.site?.id;if(!id)return;try{const previouslyRunning=activeJobs().length>0;const [jobs,builds,delivery]=await Promise.all([readJobs(id),api(`/api/sites/${encodeURIComponent(id)}/builds`),readDelivery(id)]);if(state.site?.id!==id)return;state.jobs=jobs;state.builds=builds;state.delivery=delivery;if(previouslyRunning&&!activeJobs().length){const last=jobs[0];state.job=last?.state==='done'?'后台构建已完成,请预览并查看质量报告。':last?.state==='failed'?'本次构建失败,请在任务中心检查原因。':last?.state==='cancelled'?'本次构建已取消。':null}if(state.view==='build')rerender()}catch(e){notifyError(e)}}
function openModal(name){const node=$('#'+name);node.showModal();node.querySelector('input:not([type=hidden]),select')?.focus()}
function closeModal(name){$('#'+name)?.close()}
function openEntry(id){const form=$('#entryForm');form.reset();form.elements.entry_id.value='';$('#entryModalTitle').textContent=id?'修改内容':'添加内容';if(id){const e=state.entries.find(x=>x.id===id);if(!e)return toast('这条内容不存在',true);for(const field of ['title','route','summary','body','seo_title','seo_description','type','status']){if(form.elements[field])form.elements[field].value=e[field]||''}form.elements.entry_id.value=id}openModal('entryModal')}
@@ -125,6 +158,12 @@ async function buildNow(){if(!state.site)return toast('请先选择网站',true)
try{navigate('build');state.job='正在提交构建任务…';rerender();const job=await api(`/api/sites/${encodeURIComponent(id)}/builds`,'POST',{mirror_images:false},{'Idempotency-Key':crypto.randomUUID()});
if(state.site?.id!==id)return;state.job=`任务 ${job.id?.slice(0,12)||'已提交'} 已进入队列,可以离开页面;重新打开“构建与交付”查看结果。`;toast('已提交构建任务');await refreshBuilds();
}catch(e){notifyError(e)}finally{state.building=false;if(state.site?.id===id&&state.view==='build')rerender()}}
function selectedReleaseBuild(){const id=$('#deliveryBuild')?.value;if(!id)throw Error('没有可发布的构建版本,请先完成网站构建');return id}
function showDeliveryMessage(message){const el=$('#deliveryNotice');if(el){el.hidden=false;el.textContent=message}toast(message)}
async function releasePreflight(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id;const r=await api(`/api/production/sites/${encodeURIComponent(s)}/preflight/${encodeURIComponent(id)}`);if(state.site?.id!==s)return;showDeliveryMessage(r.ready?'已通过发布前置检查:'+r.message:'尚不能发布:'+r.message)}catch(e){notifyError(e)}}
async function releaseApproval(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id;const r=await api(`/api/factory/sites/${encodeURIComponent(s)}/release-requests`,'POST',{build_id:id});showDeliveryMessage('审批申请已创建,请由另一名有审核权限的人员批准。申请编号:'+r.id)}catch(e){notifyError(e)}}
async function releaseDeploy(){if(!state.site)return;try{const id=selectedReleaseBuild(),s=state.site.id,target=$('#deliveryTarget')?.value;if(!state.delivery.configured?.[target])throw Error('尚未配置此目标服务器,请管理员配置目标发布方式(Agent/SFTP/S3)、服务器凭据与 HTTPS 验证地址');if(!confirm(`确定将构建 ${id.slice(0,12)} 提交到${target==='production'?'正式':'测试'}环境吗?必须已有独立审批。`))return;const r=await api(`/api/production/sites/${encodeURIComponent(s)}/deploy`,'POST',{build_id:id,target},{'Idempotency-Key':crypto.randomUUID()});showDeliveryMessage(`发布任务 ${r.id} 已排队。Worker 会先暂存和校验,再激活新版本。`);await refreshBuilds()}catch(e){notifyError(e)}}
async function releaseRollback(target,release){if(!state.site)return;if(!confirm(`确定在 ${target} 环境恢复历史版本 ${release} 吗?请确认其内容仍符合当前业务要求。`))return;try{const r=await api(`/api/production/sites/${encodeURIComponent(state.site.id)}/rollback`,'POST',{target,release_id:release},{'Idempotency-Key':crypto.randomUUID()});showDeliveryMessage(`回滚任务 ${r.id} 已排队,请在任务列表查看结果。`);await refreshBuilds()}catch(e){notifyError(e)}}
async function checkReadiness(){if(!state.site)return;const siteId=state.site.id;
try{const result=await api(`/api/factory/sites/${encodeURIComponent(siteId)}/migration/readiness`);
if(state.site?.id!==siteId)return;
@@ -139,21 +178,21 @@ function closeMobile(){$('#sideMenu').classList.remove('open');$('#mobileBackdro
async function init(){try{const me=await api('/api/auth/me');state.csrf=me.csrf;await loadSites();const health=await api('/api/health');$('#appVersion').textContent='版本 '+health.version;
// Restore URL first, then a previously selected site *only* after verifying /api/sites.
const params=new URLSearchParams(location.search),requested=params.get('site');
const target=params.get('view');const allowed=new Set(['home','sites','overview','content','design','import','build','settings']);
const target=params.get('view');const allowed=new Set(['home','sites','overview','content','design','import','build','languages','settings']);
let stored='';try{stored=localStorage.getItem(sfSiteKey)||''}catch{}
const chosen=[requested,stored].find(id=>id&&state.sites.some(x=>x.id===id));
if(chosen){await selectSite(chosen,allowed.has(target)?target:'overview','replace')}
else {state.view=target==='sites'?'sites':'home';rerender();rememberView(true)}
}catch(e){notifyError(e);$('#mainContent').innerHTML=emptyState('后台加载失败',e.message,'<button class="btn btn-primary" onclick="location.reload()">重新加载</button>')}}
// Delegated UI interactions keep navigation and form actions centrally auditable.
document.addEventListener('click',async ev=>{const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='new-site')openModal('siteModal');if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='refresh-builds')await refreshBuilds();if(action==='check-readiness')await checkReadiness();if(action==='cancel-job')await cancelJob(control.dataset.id);if(action==='report')await showReport(control.dataset.id)}catch(e){notifyError(e)}});
document.addEventListener('click',async ev=>{const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='language-refresh')await loadLanguage();if(action==='new-site')openModal('siteModal');if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='refresh-builds')await refreshBuilds();if(action==='check-readiness')await checkReadiness();if(action==='cancel-job')await cancelJob(control.dataset.id);if(action==='report')await showReport(control.dataset.id);if(action==='release-preflight')await releasePreflight();if(action==='release-approval')await releaseApproval();if(action==='release-deploy')await releaseDeploy();if(action==='release-rollback')await releaseRollback(control.dataset.target,control.dataset.id)}catch(e){notifyError(e)}});
document.addEventListener('click',ev=>{const btn=ev.target.closest('[data-close]');if(btn)closeModal(btn.dataset.close)});
$('#siteForm').addEventListener('submit',saveSite);$('#entryForm').addEventListener('submit',saveEntry);
document.addEventListener('submit',ev=>{if(ev.target.id==='settingsForm')saveSettings(ev);if(ev.target.id==='importForm')importFile(ev)});
document.addEventListener('submit',ev=>{if(ev.target.id==='settingsForm')saveSettings(ev);if(ev.target.id==='importForm')importFile(ev);if(ev.target.id==='langGroupForm')submitLanguageGroup(ev);if(ev.target.id==='langJoinForm')submitLanguageJoin(ev);if(ev.target.id==='langUnitForm')submitLanguageUnit(ev);if(ev.target.id==='langLinkForm')submitLanguageLink(ev)});
document.addEventListener('input',ev=>{if(ev.target.id==='contentSearch'){state.search=ev.target.value;const cursor=ev.target.selectionStart;const type=state.typ;rerender();const el=$('#contentSearch');el.focus();el.setSelectionRange(cursor,cursor);state.typ=type}});
document.addEventListener('change',ev=>{if(ev.target.id==='contentType'){state.typ=ev.target.value;rerender()}});
document.addEventListener('change',ev=>{if(ev.target.id==='contentType'){state.typ=ev.target.value;rerender()}if(ev.target.id==='langMemberSite')loadLanguageEntries(ev.target.value)});
$('#sitePicker').addEventListener('change',ev=>selectSite(ev.target.value));
window.addEventListener('popstate',async()=>{const p=new URLSearchParams(location.search);const site=p.get('site'),view=p.get('view')||'home';const allowed=['home','sites','overview','content','design','import','build','settings'];if(site&&state.sites.some(s=>s.id===site))await selectSite(site,allowed.includes(view)?view:'overview','none');else{state.selectionToken++;state.site=null;state.entries=[];state.jobs=[];state.builds=[];state.view=view==='sites'?'sites':'home';rerender()}});
window.addEventListener('popstate',async()=>{const p=new URLSearchParams(location.search);const site=p.get('site'),view=p.get('view')||'home';const allowed=['home','sites','overview','content','design','import','build','languages','settings'];if(site&&state.sites.some(s=>s.id===site))await selectSite(site,allowed.includes(view)?view:'overview','none');else{state.selectionToken++;state.site=null;state.entries=[];state.jobs=[];state.builds=[];state.view=view==='sites'?'sites':'home';rerender()}});
setInterval(()=>{if(!document.hidden&&state.view==='build'&&activeJobs().length)refreshBuilds()},5000);
$('#newSiteTop').addEventListener('click',()=>openModal('siteModal'));
$('#logoutBtn').addEventListener('click',async()=>{try{await api('/api/auth/logout','POST');location.href='/login'}catch(e){notifyError(e)}});
+70 -2
View File
@@ -6,6 +6,7 @@ studio uses only /studio-draft endpoints; a build never consumes these drafts.
from __future__ import annotations
import hashlib
import json
import uuid
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, blueprints, enterprise
from .core import save_entry
@@ -24,6 +25,14 @@ def setup():
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
revision INTEGER NOT NULL, actor TEXT NOT NULL, published_at TEXT NOT NULL,
blueprint_hash TEXT NOT NULL)''')
c.execute('''CREATE TABLE IF NOT EXISTS studio_history (
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
revision INTEGER NOT NULL,kind TEXT NOT NULL,
snapshot_json TEXT NOT NULL,snapshot_hash TEXT NOT NULL,
actor TEXT NOT NULL,created_at TEXT NOT NULL)''')
c.execute('''CREATE INDEX IF NOT EXISTS idx_studio_history_site_page
ON studio_history(site_id,entry_id,created_at,revision)''')
def _entry(site_id, entry_id):
@@ -59,7 +68,7 @@ def read(site_id,entry_id):
'layout_mode':meta.get('layout_mode','append'),'revision':0,'conflict':False,'state':'published', 'entry':{'id':row['id'],'title':row['title'],'route':row['route']}}
def write(site_id,entry_id,payload):
def write(site_id,entry_id,payload,actor="system"):
row=_entry(site_id,entry_id)
plan=_validate(payload)
# A draft is detached from the public entry. Only the final publish
@@ -76,6 +85,7 @@ def write(site_id,entry_id,payload):
ON CONFLICT(site_id,entry_id) DO UPDATE SET revision=excluded.revision,
blueprint_json=excluded.blueprint_json,updated_at=excluded.updated_at''',
(site_id,entry_id,next_rev,base,json.dumps(plan,ensure_ascii=False),db.now()))
_journal(c,site_id,entry_id,next_rev,'draft',plan,actor)
return {'saved':True,'state':'draft','revision':next_rev,'published_unchanged':True}
@@ -101,11 +111,69 @@ def publish(site_id,entry_id,expected_revision,actor):
pid=uuid.uuid4().hex
c.execute('INSERT INTO studio_publications VALUES (?,?,?,?,?,?,?)',
(pid,site_id,entry_id,draft['revision'],actor,db.now(),hashlib.sha256(json.dumps(plan,sort_keys=True).encode()).hexdigest()))
_journal(c,site_id,entry_id,draft['revision'],'published',plan,actor)
c.execute('DELETE FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
return {'published':True,'publication_id':pid,'revision':draft['revision'],
'build_required':True,'message':'页面设计已发布到内容库;需要重新构建静态站点才会上线'}
def _journal(connection, site_id, entry_id, revision, kind, plan, actor):
# Store a complete, immutable validated snapshot; never executable user code.
canonical=json.dumps(plan,ensure_ascii=False,sort_keys=True,separators=(',',':'))
connection.execute('''INSERT INTO studio_history VALUES (?,?,?,?,?,?,?,?,?)''',
(uuid.uuid4().hex,site_id,entry_id,revision,kind,canonical,
hashlib.sha256(canonical.encode('utf-8')).hexdigest(),actor,db.now()))
def history(site_id,entry_id,limit=30):
_entry(site_id,entry_id)
if type(limit) is not int or not 1<=limit<=100:raise HTTPException(400,'历史条数必须为1-100')
rows=db.rows('''SELECT id,revision,kind,snapshot_hash,actor,created_at
FROM studio_history WHERE site_id=? AND entry_id=?
ORDER BY created_at DESC, revision DESC, id DESC LIMIT ?''',(site_id,entry_id,limit))
return {'items':rows,'note':'历史记录不会直接修改已发布页面;恢复时只生成新的草稿修订'}
def history_snapshot(site_id,entry_id,history_id):
_entry(site_id,entry_id)
row=db.one('SELECT * FROM studio_history WHERE site_id=? AND entry_id=? AND id=?',
(site_id,entry_id,history_id))
if not row:raise HTTPException(404,'历史记录不存在')
if hashlib.sha256(row['snapshot_json'].encode('utf-8')).hexdigest()!=row['snapshot_hash']:
raise HTTPException(409,'历史快照完整性校验失败,不允许恢复')
plan=_validate(json.loads(row['snapshot_json']))
return {'id':row['id'],'revision':row['revision'],'kind':row['kind'],
'snapshot_hash':row['snapshot_hash'],'plan':plan}
def restore_history(site_id,entry_id,history_id,expected_revision,actor):
if type(expected_revision) is not int:raise HTTPException(400,'必须指定当前草稿修订号')
previous=history_snapshot(site_id,entry_id,history_id)
# Optimistic revision is enforced by write(). Restore never directly alters
# published content and cannot bypass a conflicting published page.
plan={**previous['plan'],'expected_revision':expected_revision}
result=write(site_id,entry_id,plan,actor)
return {**result,'restored_from':history_id,'requires_publish':True}
@router.get('/{site_id}/pages/{entry_id}/studio-history')
def api_history(request:Request,site_id:str,entry_id:str,limit:int=30):
enterprise.require(request,site_id,'viewer')
return history(site_id,entry_id,limit)
@router.get('/{site_id}/pages/{entry_id}/studio-history/{history_id}')
def api_history_snapshot(request:Request,site_id:str,entry_id:str,history_id:str):
enterprise.require(request,site_id,'viewer')
return history_snapshot(site_id,entry_id,history_id)
@router.post('/{site_id}/pages/{entry_id}/studio-history/{history_id}/restore')
def api_history_restore(request:Request,site_id:str,entry_id:str,history_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return restore_history(site_id,entry_id,history_id,payload.get('expected_revision'),enterprise.actor(request))
@router.get('/{site_id}/pages/{entry_id}/studio-draft')
def api_read(request:Request,site_id:str,entry_id:str):
enterprise.require(request,site_id,'viewer')
@@ -115,7 +183,7 @@ def api_read(request:Request,site_id:str,entry_id:str):
@router.put('/{site_id}/pages/{entry_id}/studio-draft')
def api_write(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return write(site_id,entry_id,payload)
return write(site_id,entry_id,payload,enterprise.actor(request))
@router.post('/{site_id}/pages/{entry_id}/studio-draft/publish')
+67 -18
View File
@@ -1,27 +1,61 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge Studio · 可视化建站</title><link rel="stylesheet" href="/ui/editor.css"><script defer src="/ui/editor.js"></script><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head>
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge Studio · 可视化建站</title><link rel="stylesheet" href="/ui/editor.css"><script defer src="/ui/editor-helpers.js"></script><script defer src="/ui/editor.js"></script><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head>
<body data-sf-page="editor">
<div class="studio">
<header class="studio-top">
<div class="brand"><a href="/" title="返回网站管理">◈</a><div><b>SiteForge</b><small>VISUAL STUDIO</small></div></div>
<span class="divide"></span><div class="file-name"><span id="siteName">加载中</span><small> / </small><strong id="pageName">页面编辑器</strong></div>
<div class="top-center"><button id="deviceDesktop" class="icon-btn on" onclick="Studio.device('desktop')" title="PC预览">▣</button><button id="deviceTablet" class="icon-btn" onclick="Studio.device('tablet')" title="平板预览">▤</button><button id="devicePhone" class="icon-btn" onclick="Studio.device('phone')" title="手机预览">▯</button></div>
<button class="ui-btn ghost" onclick="Studio.undo()" title="撤销 Ctrl+Z">↶ 撤销</button><button class="ui-btn ghost" onclick="Studio.redo()" title="重做 Ctrl+Y">↷ 重做</button><button class="ui-btn ghost" onclick="Studio.showTokens()">设计规范</button><button class="ui-btn ghost" onclick="Studio.showSiteDesign()">全站页眉与页脚</button><span id="saveStatus" class="save-status">未修改</span><button class="ui-btn ghost" onclick="Studio.refreshPreview()">↻ 刷新预览</button><button class="ui-btn ghost" onclick="Studio.buildSite()">构建静态站</button><button class="ui-btn ghost" onclick="Studio.discardDraft()">放弃草稿</button><button class="ui-btn ghost" onclick="Studio.save()">保存草稿</button><button class="ui-btn primary" onclick="Studio.publishDesign()">发布设计 ↗</button>
<header class="studio-top sf-easy-top">
<div class="brand"><a href="/" title="返回网站管理">◈</a><div><b>SiteForge</b><small>页面编辑</small></div></div>
<div class="file-name sf-easy-file"><small id="siteName">加载中</small><strong id="pageName">页面编辑器</strong></div>
<div class="sf-pane-controls"><button type="button" onclick="Studio.togglePane('left')">☰ 内容</button><button type="button" onclick="Studio.togglePane('right')">⚙ 编辑</button></div>
<div class="top-center sf-easy-device" role="group" aria-label="预览设备">
<button id="deviceDesktop" class="icon-btn on" onclick="Studio.device('desktop')" title="电脑预览" aria-label="电脑预览">电脑</button>
<button id="deviceTablet" class="icon-btn" onclick="Studio.device('tablet')" title="平板预览" aria-label="平板预览">平板</button>
<button id="devicePhone" class="icon-btn" onclick="Studio.device('phone')" title="手机预览" aria-label="手机预览">手机</button>
</div>
<span id="saveStatus" class="save-status sf-easy-save-status" role="status" aria-live="polite">读取中…</span>
<button id="saveDraftButton" type="button" class="ui-btn ghost sf-save-btn" onclick="Studio.save()">保存草稿</button>
<button id="publishDesignButton" type="button" class="ui-btn primary sf-publish-btn" onclick="Studio.publishDesign()">发布设计</button>
<details class="studio-actions"><summary title="其他功能">更多 ▾</summary><div class="studio-actions-menu">
<button id="studioUndo" class="ui-btn ghost" onclick="Studio.undo()" disabled>↶ 撤销</button>
<button id="studioRedo" class="ui-btn ghost" onclick="Studio.redo()" disabled>↷ 重做</button>
<button class="ui-btn ghost" onclick="Studio.showHistory()">版本历史</button>
<button class="ui-btn ghost" onclick="Studio.showSiteDesign()">全站页眉与页脚</button>
<button class="ui-btn ghost" onclick="Studio.showTokens()">全站设计规范</button>
<button class="ui-btn ghost" onclick="Studio.refreshPreview()">刷新预览</button>
<button class="ui-btn ghost" onclick="Studio.buildSite()">构建静态网站</button>
<button class="ui-btn ghost" onclick="Studio.discardDraft()">放弃草稿</button>
</div></details>
</header>
<div class="sf-steps" aria-label="编辑操作指引"><div class="sf-steps-label">只需 3 步</div><span class="sf-step" id="sfStep1">① 选择或添加区块</span><span class="sf-step-separator">→</span><span class="sf-step" id="sfStep2">② 修改右侧内容</span><span class="sf-step-separator">→</span><span class="sf-step" id="sfStep3">③ 保存并发布</span><button type="button" class="sf-guide-link" onclick="Studio.showHelp()">新手使用指南 ?</button></div>
<div class="studio-body">
<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 class="left-pane" aria-label="页面内容和区块">
<div class="pane-heading"><div><span class="overline">页面内容</span><h3>编辑页面</h3></div><span class="chip" id="blockCount">0 个区块</span></div>
<div class="field sf-page-picker"><label for="pageSelect">当前编辑的页面</label><select id="pageSelect" onchange="Studio.choosePage(this.value)"></select></div>
<div class="sf-add-area"><button type="button" id="sfAddBlock" class="sf-add-button" aria-expanded="false" aria-controls="sfLibraryPanel" onclick="Studio.toggleLibrary()">+ 添加内容区块</button><p>例如图片横幅、公司简介、产品列表等</p></div>
<section id="sfLibraryPanel" class="sf-library-panel" hidden aria-label="添加区块"><div class="sf-panel-caption">点击类型即可添加到当前页面 <button type="button" onclick="Studio.toggleLibrary(false)" aria-label="收起添加面板">收起 ×</button></div><div class="palette" id="palette"></div><details class="sf-subsection"><summary>从我的组件库添加</summary><div class="palette" id="reusablePalette"></div></details></section>
<div class="section-label">当前页面的内容 <span class="section-caption">点击一项进行编辑</span></div>
<div class="structure-filter"><label for="blockSearch" class="sr-only">搜索页面内容</label><input id="blockSearch" type="search" placeholder="查找横幅、文字、产品…" autocomplete="off" oninput="Studio.filter(this.value)"><button type="button" onclick="Studio.clearFilter()" aria-label="清除搜索">×</button></div>
<div id="blockList" class="block-list" role="list" aria-label="当前页面区块"></div>
<p class="structure-hint">点击区块进入右侧编辑;拖动 ⠿ 或使用 ↑ ↓ 调整顺序。</p>
<details class="sf-advanced"><summary>高级页面设置 <span>模板、布局和组件保存</span></summary>
<div class="field"><label for="pageTemplate">页面模板</label><select id="pageTemplate" onchange="Studio.changeTemplateKind(this.value)"></select></div>
<div class="field"><label for="layoutMode">排版方式</label><select id="layoutMode" onchange="Studio.setLayoutMode(this.value)"><option value="append">保留原页面,追加区块</option><option value="replace">用这些区块重新排版页面</option></select></div>
<div class="sf-advanced-buttons"><button type="button" class="ui-btn ghost" onclick="Studio.resetPreset()">使用模板预设布局</button><button type="button" class="ui-btn ghost" onclick="Studio.saveReusable()">将所选区块存为组件</button></div>
<p class="sf-advanced-note">更换模板预设可能替换当前草稿,请先保存;不会直接改变在线网站。</p>
</details>
</aside>
<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>
<section class="preview-workspace"><div class="preview-head"><span class="live-dot" id="previewDot"></span><span id="previewState" role="status" aria-live="polite">实时预览</span><code id="routeDisplay">/</code><span class="preview-note">修改后自动预览 · 不会直接改变线上网站</span></div><div class="stage"><div class="device-frame" id="deviceFrame"><iframe id="sitePreview" title="页面预览" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div></div>
<div class="stage-footer"><span id="deviceDetails">桌面端 · 自适应宽度</span><span>当前是草稿预览,不是线上网站</span></div></section>
<aside class="right-pane"><div class="pane-heading"><div><span class="overline">步骤 2 · 修改内容</span><h3>编辑选中的区块</h3></div><span class="chip" id="blockTypeLabel">页面</span></div>
<div class="inspector-nav" role="tablist" aria-label="属性编辑分类"><button type="button" role="tab" id="tabContent" aria-selected="true" onclick="Studio.changeInspectorTab('content')">内容</button><button type="button" role="tab" id="tabStyle" aria-selected="false" onclick="Studio.changeInspectorTab('style')">样式</button><button type="button" role="tab" id="tabResponsive" aria-selected="false" onclick="Studio.changeInspectorTab('responsive')">手机/平板</button></div>
<div class="inspector" id="inspector"><div class="no-selection">先从左侧点击一个内容区块,或者点击“添加内容区块”。</div></div>
<details class="right-foot sf-advanced-foot"><summary>高级:动态数据变量</summary><p>支持 <code>{{site.name}}</code>、<code>{{page.title}}</code> 等变量;动态内容列表可以按网站内容类型绑定。</p></details>
</aside>
</div>
<dialog id="sfGuideDialog" class="sf-help-dialog" aria-labelledby="sfGuideTitle">
<div class="sf-help-head"><div><span class="overline">新手指南</span><h2 id="sfGuideTitle">3 分钟学会编辑页面</h2></div><button type="button" class="ui-btn ghost" onclick="document.querySelector('#sfGuideDialog').close()">关闭 ×</button></div>
<ol class="sf-help-steps"><li><b>先选页面</b><p>左侧顶部切换要编辑的页面;选择现有内容区块,或点击橙色的“添加内容区块”。</p></li><li><b>修改内容</b><p>在右侧“内容”里改标题、文案和图片;切换“样式”或“手机/平板”调整外观。中间自动展示效果。</p></li><li><b>保存草稿,再发布</b><p>点击右上角“保存草稿”可以保留工作。核对后点击“发布设计”,它会更新内容库;正式网站还需要执行静态构建和部署。</p></li></ol>
<div class="sf-guide-note">撤销、版本历史、全站页眉、静态构建等低频功能在右上角「更多」。</div>
<button type="button" class="ui-btn primary sf-guide-done" onclick="document.querySelector('#sfGuideDialog').close()">知道了,开始编辑</button>
</dialog>
<dialog id="designModal" style="border:1px solid #e2e8f0;border-radius:14px;padding:24px;width:min(460px,90vw);box-shadow:0 16px 52px #142a492f"><h2>全站设计规范</h2><p>调整后保存并刷新预览,发布构建自动采用该规范。</p><form id="designForm" method="dialog" style="display:grid;gap:12px"><label>标题缩放 <input name="heading_scale" type="number" min="0.8" max="1.5" step="0.1"></label><label>圆角 <input name="radius" type="number" min="0" max="32"></label><label>版心宽度 <input name="container_width" type="number" min="880" max="1600"></label><label>板块间距 <input name="section_spacing" type="number" min="20" max="140"></label><label>字体 <select name="font"><option value="system">系统字体</option><option value="serif">衬线字体</option><option value="modern">现代字体</option></select></label><label>按钮风格 <select name="button_style"><option value="rounded">圆角</option><option value="square">直角</option><option value="pill">胶囊</option></select></label><label>导航风格 <select name="header_style"><option value="standard">标准</option><option value="compact">紧凑</option></select></label><div style="display:flex;gap:12px"><button class="ui-btn primary" type="button" onclick="Studio.saveTokens()">保存全站规范</button><button class="ui-btn ghost" value="cancel">取消</button></div></form></dialog>
<dialog id="siteDesignModal" style="border:1px solid #e2e8f0;border-radius:14px;padding:22px;width:min(750px,96vw);max-height:88vh;overflow:auto;box-shadow:0 18px 56px #142a492f">
@@ -53,6 +87,15 @@
<label><input name="mobile_hide_announcement" type="checkbox"> 手机隐藏提示栏</label>
<label><input name="mobile_hide_footer_links" type="checkbox"> 手机隐藏页脚导航</label>
</fieldset>
<fieldset style="border:1px solid #e2e8f0;border-radius:10px;padding:15px;display:grid;gap:12px"><legend>手机底部快捷导航 Tabs(可选)</legend>
<p style="margin:0;color:#64748b;font-size:13px">默认关闭。需要时开启后添加 1–5 个按钮。它们只显示在手机上,平板和电脑不显示。</p>
<label><input name="mobile_tabs_enabled" type="checkbox" onchange="Studio.toggleMobileTabsForm()"> 开启手机底部 Tabs</label>
<div id="mobileTabsEditor" hidden style="display:grid;gap:10px">
<div id="mobileTabsRows" style="display:grid;gap:9px"></div>
<button class="ui-btn ghost" type="button" onclick="Studio.addMobileTab()">+ 添加一个 Tab(最多 5 个)</button>
<small style="color:#64748b">标题建议不超过 4 个字;链接使用站内路径(如 /products/)。菜单按钮会打开右侧导航抽屉。</small>
</div>
</fieldset>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<button class="ui-btn ghost" type="button" onclick="Studio.previewSiteDesign()">预览草稿</button>
<button class="ui-btn ghost" type="button" onclick="Studio.discardSiteDesign()">放弃全站草稿</button>
@@ -67,5 +110,11 @@
<div id="siteMediaGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(135px,1fr));gap:12px"></div>
<button type="button" class="ui-btn ghost" onclick="document.querySelector('#siteMediaModal').close()" style="margin-top:15px">关闭</button>
</dialog>
<div id="toast" class="toast"></div>
<dialog id="studioHistoryModal" aria-labelledby="studioHistoryTitle" style="border:1px solid #dce5f1;border-radius:14px;padding:24px;width:min(680px,95vw);max-height:82vh;overflow:auto">
<h2 id="studioHistoryTitle">页面设计版本历史</h2>
<p style="color:#64748b">保存草稿、发布设计都会创建不可变快照。恢复只写入新草稿,不会直接修改已发布站点。</p>
<div id="studioHistoryItems" aria-live="polite"></div>
<button class="ui-btn ghost" type="button" onclick="document.querySelector('#studioHistoryModal').close()">关闭</button>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</div></body></html>
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录 · SiteForge 网站工厂</title><style>
:root{font-family:Inter,system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#263952}*{box-sizing:border-box}body{margin:0;background:#f5f7fa;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(430px,100%);background:#fff;border:1px solid #e1e8ef;border-radius:20px;box-shadow:0 25px 70px #19345512;padding:42px}.symbol{width:56px;height:56px;border-radius:15px;background:#173250;color:#fff;display:grid;place-items:center;font-size:26px;font-weight:700;margin-bottom:24px}h1{font-size:27px;margin:0 0 6px}p{font-size:13px;color:#7c8ca0;line-height:1.8;margin:0 0 29px}label{display:block;font-size:13px;font-weight:600;margin-bottom:9px}input{width:100%;height:48px;border:1px solid #d8e0e9;border-radius:10px;padding:0 14px;font-size:15px;outline:0}input:focus{border-color:#386aab;box-shadow:0 0 0 3px #417cbc1c}button{width:100%;margin-top:22px;background:#19446f;color:#fff;height:48px;border:0;border-radius:10px;font-size:14px;font-weight:650;cursor:pointer}button:disabled{opacity:.65}small{display:block;text-align:center;margin-top:23px;color:#96a4b6}#error{margin:12px 0 0;color:#b63d3d;min-height:18px;font-size:13px}
</style></head><body><main><div class="symbol">S✳</div><h1>欢迎使用 SiteForge</h1><p>登录网站生产工作台,管理网站、内容、主题和静态构建版本。</p><form id="auth"><label for="pass">账号密码</label><input id="username" type="text" value="admin" required autocomplete="username" placeholder="管理员或团队用户名"><label for="pass">管理员密码 / 团队密码</label><input id="pass" type="password" required autofocus autocomplete="current-password" placeholder="请输入部署时设置的密码"><div id="error" role="alert"></div><button id="submit" type="submit">登录工作台 →</button></form><small>SiteForge Web · Factory 7.1.0</small></main><script>
document.getElementById('auth').addEventListener('submit',async e=>{e.preventDefault();const b=document.getElementById('submit'),err=document.getElementById('error');b.disabled=true;err.textContent='';try{const res=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('username').value,password:document.getElementById('pass').value})});if(!res.ok){const d=await res.json();throw new Error(d.detail||'登录失败')}location.replace('/')}catch(e){err.textContent=e.message;b.disabled=false}});
</style></head><body><main><div class="symbol">S✳</div><h1>欢迎使用 SiteForge</h1><p>登录网站生产工作台,管理网站、内容、主题和静态构建版本。</p><form id="auth"><label for="pass">账号密码</label><input id="username" type="text" value="admin" required autocomplete="username" placeholder="管理员或团队用户名"><label for="pass">管理员密码 / 团队密码</label><input id="pass" type="password" required autofocus autocomplete="current-password" placeholder="请输入部署时设置的密码"><div id="error" role="alert"></div><button id="submit" type="submit">登录工作台 →</button></form><small>SiteForge Web · Enterprise 8.19</small></main><script>
document.getElementById('auth').addEventListener('submit',async e=>{e.preventDefault();const b=document.getElementById('submit'),err=document.getElementById('error');b.disabled=true;err.textContent='';try{const res=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('username').value,password:document.getElementById('pass').value})});if(!res.ok){const d=await res.json();throw new Error(d.detail||'登录失败')}const next=new URLSearchParams(location.search).get('next')||'/';const safe=next==='/'||next.startsWith('/siteforge/')&&!next.startsWith('//');location.replace(safe?next:'/')}catch(e){err.textContent=e.message;b.disabled=false}});
</script></body></html>
+1 -1
View File
@@ -5,6 +5,6 @@
<div class="two"><section><div class="section-head"><h2>01 · CMS 数据迁移</h2><small>先预演,再写入网站数据库</small></div><div class="fields"><label>来源系统<select id="platform"><option value="wordpress-rest">WordPress REST JSON</option><option value="woocommerce">WooCommerce 产品 JSON</option><option value="drupal-jsonapi">Drupal JSON:API</option><option value="shopify">Shopify 商品 JSON</option></select></label><label>导出 JSON<input id="file" type="file" accept=".json,application/json"></label></div><div class="actions"><button id="previewImport">预演与查错</button><button class="secondary" id="commitImport" disabled>确认导入</button></div><div id="report" class="report">选择文件后,预演将显示内容数量、URL 错误和复杂字段待复核事项。</div></section>
<section><div class="section-head"><h2>02 · 导入风险复核</h2><small>处理插件字段与内容关系;迁移站上线前必须清零</small></div><div id="reviews" class="job-list">暂无待复核事项</div><p class="muted">复核人必须填写处理说明;仅更改记录状态不会自动修复旧站内容,请人工检查预览。</p></section></div>
<div class="two"><section><div class="section-head"><h2>03 · 生产构建任务</h2><small>由独立 Worker 处理</small></div><p class="muted">保存内容后提交任务。相同的构建请求会去重,构建可取消、重试或在超时后恢复。</p><div class="actions"><button id="build">提交异步构建</button><a id="edit" class="secondary link" href="#">打开网站编辑器 ↗</a></div><div id="jobList" class="job-list">加载中…</div></section></div>
<div class="two"><section><div class="section-head"><h2>04 · 构建质量与审批</h2><small>分权审核发布</small></div><div id="buildList" class="job-list"></div><h3>发布审批记录</h3><div id="approvals" class="job-list"></div></section><section><div class="section-head"><h2>05 · 远程发布</h2><small>HTTPS 签名发布 Agent</small></div><p class="muted">仅能部署已审批、质量检查通过的构建;目标服务器地址由管理员提前设置,不能在这里随意填写。</p><div class="fields"><label>目标环境<select id="target"><option value="staging">测试环境 staging</option><option value="production">生产环境 production</option></select></label><label>已构建版本<select id="releaseBuild"></select></label></div><div class="actions"><button id="deploy">发布所选版本</button><button class="secondary" id="refreshRelease">刷新部署历史</button></div><h3>已发布版本</h3><div id="releases" class="job-list">暂无部署记录</div></section></div>
<div class="two"><section><div class="section-head"><h2>04 · 构建质量与审批</h2><small>分权审核发布</small></div><div id="buildList" class="job-list"></div><h3>发布审批记录</h3><div id="approvals" class="job-list"></div></section><section><div class="section-head"><h2>05 · 远程发布</h2><small>Agent / SFTP / S3</small></div><p class="muted">只能部署已审批且质量检查通过的构建;发布方式由管理员预先配置,S3 需指针感知的 HTTPS 网关。</p><div class="fields"><label>目标环境<select id="target"><option value="staging">测试环境 staging</option><option value="production">生产环境 production</option></select></label><label>已构建版本<select id="releaseBuild"></select></label></div><div class="actions"><button id="deploy">发布所选版本</button><button class="secondary" id="refreshRelease">刷新部署历史</button></div><p class="muted" id="releaseBackend" aria-live="polite">读取发布目标配置…</p><h3>已发布版本与回滚</h3><div id="releases" class="job-list">暂无部署记录</div></section></div>
<footer>企业运行与迁移中心 · 第三方 CMS 复杂插件、PostgreSQL/Redis 和公网部署应分别完成真实环境验收。</footer>
</main></div></body></html>
+7 -1
View File
@@ -4,7 +4,7 @@
[hidden]{display:none!important}.tool-switch{display:flex;flex-wrap:wrap;gap:9px;background:#fff;padding:12px;margin:18px 0 0;border-radius:12px;border:1px solid #e1e8f1}.tool-btn{background:#f6f8fc;color:#50627c;border-color:transparent;padding:11px 14px}.tool-btn.active{background:#e8f0ff;color:#174ea7;border:1px solid #a5c5f5}.tool-help{font-size:12px;color:#64748b;margin:12px 5px 16px}.advanced-layout{grid-template-columns:minmax(0,1fr)}.advanced-layout>.panel{width:100%}.nav a:first-child{border:1px solid #7793bd;padding:9px 11px;border-radius:7px;background:#fff1}@media(max-width:720px){.tool-switch{gap:5px}.tool-btn{padding:9px 11px}}
</style><link rel="stylesheet" href="/ui/professional-ui.css"><script defer src="/ui/professional-ui.js"></script></head><body data-sf-page="production">
<header><div><h1>SiteForge · 网站生产控制台 <span style="font-size:11px;color:#acd5ff">V8.11 · 高级工具</span></h1><small>整站模板 · 数据批次导入 · 可审计回滚 · 静态构建</small></div><div class="nav"><a href="/">返回网站工作台</a><a href="/factory-studio">页面编辑器</a><a href="/theme-gallery">主题中心</a><a href="/operations">任务中心</a></div></header>
<header><div><h1>SiteForge · 网站生产控制台 <span style="font-size:11px;color:#acd5ff">V8.21 · 高级工具</span></h1><small>整站模板 · 数据批次导入 · 可审计回滚 · 静态构建</small></div><div class="nav"><a href="/">返回网站工作台</a><a href="/factory-studio">页面编辑器</a><a href="/theme-gallery">主题中心</a><a href="/operations">任务中心</a></div></header>
<main>
<div class="panel"><div class="row" style="justify-content:space-between"><div><h2>选择要操作的网站</h2><div class="muted">所有批次、模板和构建记录均按网站隔离;新网站可先在网站工作台创建。</div></div><select id="site" style="max-width:410px" aria-label="选择网站"></select></div></div>
<section class="kpi"><article><span class="muted">当前网站</span><strong id="siteName">—</strong></article><article><span class="muted">内容记录</span><strong id="entryCount">—</strong></article><article><span class="muted">导入批次</span><strong id="batchCount">—</strong></article></section>
@@ -51,6 +51,11 @@
<button id="wpCancel" class="danger">取消迁移</button>
<button id="wpReload">刷新状态</button><button id="wpAudit">对比最新构建的 URL/SEO</button>
</div>
<div class="row" style="margin-top:12px;align-items:flex-end">
<div style="flex:2;min-width:250px"><label for="wpPreflightFile">V8.20 离线 WXR 兼容预检(不会导入或修改内容)</label><input id="wpPreflightFile" type="file" accept=".xml,application/xml"></div>
<button id="wpPreflight" class="primary">检查旧 URL / Elementor / ACF / 动态功能</button>
</div>
<pre id="wpPreflightResult" class="message">上传有权迁移的 WordPress WXR 文件,可检查路径冲突、复杂字段和动态功能。预检不等于迁移完成。</pre>
<pre id="wpResult" class="message">选择站点并新建迁移会话。每页请求独立保存检查点;采集完成后可明确审核 URL、SEO、缺失内容。</pre>
</section>
@@ -128,6 +133,7 @@ $('preflight').onclick=()=>applyTemplate(false);$('apply').onclick=()=>applyTemp
$('build').onclick=async()=>{try{show('buildResult',await api(`/api/sites/${current()}/builds`,'POST',{mirror_images:false}))}catch(e){show('buildResult','错误:'+e.message)}};
async function loadWpSessions(){try{if(!current())return;const result=await api(`/api/migrations/sites/${current()}/wordpress/sessions`);const select=$('wpSession');const chosen=select.value;select.replaceChildren(...result.items.map(s=>opt(s.id,`${s.origin} · ${s.state} · ${s.fetched} 条`)));if(result.items.some(s=>s.id===chosen))select.value=chosen;if(select.value){const item=await api(`/api/migrations/sites/${current()}/wordpress/sessions/${select.value}`);show('wpResult',item)}}catch(e){show('wpResult','查询错误:'+e.message)}}
async function wpAction(action){const sid=$('wpSession').value;if(!sid)throw Error('请先创建迁移会话');return api(`/api/migrations/sites/${current()}/wordpress/sessions/${sid}/${action}`,'POST',{})}
$('wpPreflight').onclick=async()=>{try{if(!current())throw Error('请先选择网站');const file=$('wpPreflightFile').files[0];if(!file)throw Error('请选择 WordPress WXR XML 文件');const form=new FormData();form.append('file',file);const result=await api(`/api/migrations/sites/${current()}/wordpress/wxr-preflight`,'POST',form);show('wpPreflightResult',result)}catch(e){show('wpPreflightResult','兼容性预检失败:'+e.message)}};
$('wpCreate').onclick=async()=>{try{const data=await api(`/api/migrations/sites/${current()}/wordpress/sessions`,'POST',{origin:$('wpOrigin').value,max_items:Number($('wpLimit').value)});await loadWpSessions();$('wpSession').value=data.id;show('wpResult',data)}catch(e){show('wpResult','错误:'+e.message)}};
let wpAutoRunning=false;
$('wpAuto').onclick=async()=>{if(wpAutoRunning)return;wpAutoRunning=true;let count=0;try{while(wpAutoRunning&&count<200){const r=await wpAction('advance');show('wpResult',r);count++;if(['ready','completed','cancelled','limit_reached'].includes(r.state))break;await new Promise(done=>setTimeout(done,350))}await loadWpSessions()}catch(e){show('wpResult','暂停:'+e.message)}finally{wpAutoRunning=false}};
+1
View File
@@ -16,6 +16,7 @@
<button data-view="design" class="nav-item"><span class="nav-ico">◈</span><span>外观与设计</span></button>
<button data-view="import" class="nav-item"><span class="nav-ico">⇩</span><span>导入与迁移</span></button>
<button data-view="build" class="nav-item"><span class="nav-ico">↗</span><span>构建与交付</span></button>
<button data-view="languages" class="nav-item"><span class="nav-ico">文</span><span>多语言管理</span></button>
<button data-view="settings" class="nav-item"><span class="nav-ico">⚙</span><span>网站设置</span></button>
</div>
<details class="advanced"><summary><span class="nav-ico">⌘</span>高级工具 <span class="chevron">⌄</span></summary>
+5 -4
View File
@@ -16,15 +16,15 @@ from markupsafe import Markup
from .core import sanitize, route_path, THEMES
ROOT=Path(__file__).resolve().parent.parent
LOADER=Environment(loader=FileSystemLoader(str(ROOT/'themes')),autoescape=select_autoescape(['html','xml']))
_ENVS={}
def env_for(theme):
"""Per-theme Jinja env: theme dir first, shared themes/ root as fallback.
Lets a theme override shared macros (_professional_components.html) while
inheriting everything else. Cached: one env per theme across large builds.
Only built-in theme names are accepted; callers are expected to enforce the
THEMES whitelist too (render does), this is defense in depth.
inheriting everything else. Cached per theme; degraded envs are not cached.
"""
if theme not in THEMES:raise ValueError('未知主题')
env=_ENVS.get(theme)
@@ -33,7 +33,7 @@ def env_for(theme):
canonical=theme_dir.is_dir()
search=[str(theme_dir),str(ROOT/'themes')] if canonical else [str(ROOT/'themes')]
env=Environment(loader=FileSystemLoader(search),autoescape=select_autoescape(['html','xml']))
if canonical:_ENVS[theme]=env # degraded envs are not cached
if canonical:_ENVS[theme]=env
return env
TITLES={'product':'产品中心','vehicle':'车型中心','post':'新闻资讯','case':'项目案例','brand':'品牌中心','service':'服务与解决方案'}
@@ -189,6 +189,7 @@ def make_context(site,page,records,mode='detail',listing_items=None,prepared=Non
'features':groups['service'][:3], 'stories':groups['post'][:3], 'cases':groups['case'][:3],
'brands':assets['brands'],'categories':assets['categories'], 'nav':nav,
'chrome':chrome,'chrome_is_custom':bool(settings.get('site_design')),
'mobile_tab_icons':{'home':'⌂','grid':'▦','products':'▤','news':'☷','contact':'✉','phone':'☎','info':'ⓘ','search':'⌕','menu':'☰','star':'☆'},
'footer_nav':nav if chrome['footer']['show_nav'] else [],'specs':spec,
'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 '',
+1 -1
View File
@@ -8,10 +8,10 @@ from __future__ import annotations
import io, json, re, zipfile, shutil
from pathlib import Path
from . import db
from .core import THEMES as BUILTIN
BASE=Path(__file__).resolve().parent.parent/'themes'
THEME_ROOT=db.DATA/'theme_packages'
from .core import THEMES as BUILTIN
THEME_ID=re.compile(r'^[a-z][a-z0-9-]{2,39}$')
VERSION=re.compile(r'^\d+\.\d+\.\d+$')
+20 -2
View File
@@ -192,6 +192,13 @@ def parse_wxr(content):
if len(content)>MAX_UPLOAD:raise ValueError('XML 文件超过限制')
root=ET.fromstring(content)
if root.tag!='rss':raise ValueError('这不是 WordPress WXR RSS 导出')
# Stage only unambiguous permanent paths; dynamic functions remain review items.
from .wp_complex_v820 import inspect_wxr
checks=inspect_wxr(content)
blockers=[x for x in checks['issues'] if x['code'] in ('invalid_permalink','duplicate_route')]
if blockers or (checks['findings_truncated'] and checks['blocking_issues']):
raise ValueError('存在不能安全映射的 WordPress URL,请先运行 V8.20 WXR 预检并修正:'+
', '.join(x['code']+':'+x['route'] for x in blockers[:5]))
rows=[];ids=[];warnings=[]
for it in root.findall('./channel/item'):
get=lambda name:next((str(node.text or '') for node in it if node.tag.endswith('}'+name) or node.tag==name),'')
@@ -216,12 +223,23 @@ def parse_wxr(content):
if k:metas[k]=v[:10000]
if any(k in metas for k in ('_elementor_data','_product_attributes')):
warnings.append({'row':len(rows)+1,'issue':'含复杂 Elementor/WooCommerce 组件,导入后需人工核对'})
rows.append({'标题':title,'路径':route,'正文':get('encoded'),
rows.append({'标题':title,'路径':route,'正文':get('encoded'),'类型':raw_typ,
'SEO标题':metas.get('_yoast_wpseo_title') or metas.get('rank_math_title',''),
'SEO描述':metas.get('_yoast_wpseo_metadesc') or metas.get('rank_math_description',''),
'分类':raw_typ})
ids.append(f'wordpress:{wid}' if wid else f'wordpress:path:{route}')
return rows,ids,warnings
for finding in checks['issues']:
if finding['severity'] not in ('review','blocker'):continue
if finding['code'] in ('invalid_permalink','duplicate_route'):continue
route=finding['route']
index=next((i+1 for i,row in enumerate(rows) if row['路径']==route),0)
if index:
if finding['code'].startswith('elementor') and any(w.get('row')==index and 'Elementor' in w.get('issue','') for w in warnings):
continue
warnings.append({'row':index,'issue':finding['code']+': '+finding['message']})
# Bound warnings written into batch metadata and shown for human review.
return rows,ids,warnings[:200]
@router.post('/{site_id}/staging')
def api_stage(request:Request,site_id:str,payload:dict=Body(...)):
+1 -1
View File
@@ -1,5 +1,5 @@
"""Single source of truth for the SiteForge platform release identity."""
import os
VERSION = "8.12.0-dev.1"
VERSION = "8.21.3-dev.1"
PLATFORM_NAME = "SiteForge Factory"
BUILD_SHA = os.environ.get("SITEFORGE_GIT_SHA", "unknown").strip()[:40] or "unknown"
+10 -4
View File
@@ -6,7 +6,7 @@ import subprocess
import sys
import time
import uuid
from . import db, enterprise, builder, jobs
from . import db, enterprise, builder, jobs, worker_health
running=True
WORKER_ID=uuid.uuid4().hex[:12]
@@ -17,7 +17,7 @@ def heartbeat():
This single-worker probe is deliberately not used as a multi-worker
membership system; multi-worker orchestration needs separate leases.
"""
path=db.DATA/'worker-heartbeat'
path=worker_health.heartbeat_path()
path.parent.mkdir(parents=True,exist_ok=True)
temp=path.with_suffix(f'.{WORKER_ID}.tmp')
temp.write_text(WORKER_ID,encoding='ascii')
@@ -49,11 +49,17 @@ def complete(job, value=None, error=None, retryable=False, cancelled=False, time
current=c.execute('SELECT * FROM build_jobs WHERE id=?',(job['id'],)).fetchone()
if not current or current['state']!='running' or current['worker_id']!=WORKER_ID:
return
# A networked release may already have changed the remote pointer.
# Cancellation/timeout/crash is UNKNOWN remote outcome, not safe retry.
external=current['kind'] in ('remote_deploy','remote_rollback')
if cancelled or current['cancel_requested']:
state='cancelled'
if external:
state='failed'
error='Release interrupted; reconcile target before manual retry'
else:state='cancelled'
elif error is None:
state='done'
elif retryable and current['attempts']<current['max_attempts']:
elif retryable and not external and current['attempts']<current['max_attempts']:
state='retrying'
else:state='failed'
delay=min(60,2**current['attempts']) if state=='retrying' else 0
+22 -5
View File
@@ -1,18 +1,35 @@
"""Docker worker liveness probe: no localhost Web dependency."""
import os
import time
import socket
import re
from pathlib import Path
from . import db
def healthy(path=None, max_age=None):
file=Path(path) if path is not None else db.DATA/'worker-heartbeat'
def heartbeat_path():
instance=os.environ.get('SITEFORGE_WORKER_INSTANCE', '')
if not instance and db.POSTGRES:
instance=socket.gethostname()
if instance:
if not re.fullmatch(r'[A-Za-z0-9._-]{1,120}',instance):
raise ValueError('Invalid worker instance identifier')
return db.DATA/'worker-heartbeats'/instance
return db.DATA/'worker-heartbeat'
def healthy(path=None, max_age=None, check_store=False):
file=Path(path) if path is not None else heartbeat_path()
limit=float(max_age if max_age is not None else os.getenv('SITEFORGE_WORKER_HEALTH_MAX_AGE','45'))
if limit <= 0:return False
try:
age=time.time()-file.stat().st_mtime
return -5 <= age < limit
except OSError:
fresh=-5 <= age < limit
if not fresh:return False
if check_store:
from .healthchecks import readiness
return readiness()
return True
except (OSError,ValueError):
return False
if __name__=='__main__':
raise SystemExit(0 if healthy() else 1)
raise SystemExit(0 if healthy(check_store=True) else 1)
+183
View File
@@ -0,0 +1,183 @@
"""V8.20 offline WordPress WXR compatibility preflight.
No network access, PHP evaluation or database writes. Findings are blockers or
explicit review items, never evidence of pixel-identical migration.
"""
from __future__ import annotations
import re
from collections import Counter
from urllib.parse import urlsplit
from defusedxml import ElementTree as SafeET
from bs4 import BeautifulSoup
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from . import enterprise
from .core import route_path
router = APIRouter(prefix='/api/migrations/sites',tags=['WordPress V8.20 preflight'])
MAX_XML = 16_000_000
MAX_ITEMS = 20_000
MAX_FINDINGS = 500
MAX_META = 500
SHORTCODE = re.compile(r'\[(?!/)(contact-form-7|gravityform|woocommerce|products|wpforms|elementor-template|rev_slider)\b',re.I)
SERIALIZED = re.compile(r'^(?:a:\d+:\{|O:\d+:|C:\d+:|s:\d+:|b:[01];)',re.S)
def postmeta(item):
"""Preserve duplicate keys as a finding instead of silently overwriting them."""
result={};duplicate=[]
for node in item:
if node.tag.rsplit('}',1)[-1]!='postmeta':continue
key=value=''
for field in node:
label=field.tag.rsplit('}',1)[-1]
if label=='meta_key':key=field.text or ''
elif label=='meta_value':value=field.text or ''
if key:
if key in result:duplicate.append(key[:100])
result[key]=value
if len(result)>MAX_META:raise ValueError('单篇文章元数据字段超过安全上限')
return result,duplicate[:30]
def extract_acf(meta):
"""Bounded, literal ACF postmeta extraction. Never PHP-unserialize or infer field schema.
Can safely represent flat fields and simple `repeater_0_subfield` records.
Repeater field types are not guaranteed by the exported meta alone.
"""
if len(meta)>MAX_META:raise ValueError('ACF meta exceeds safety limit')
scalar={};repeaters={};warnings=[]
referenced={k[1:] for k,v in meta.items() if k.startswith('_') and isinstance(v,str) and v.startswith('field_')}
for key in sorted(referenced):
if not key or key not in meta:continue
value=meta[key]
if not isinstance(value,str):continue
if SERIALIZED.match(value.strip()):
warnings.append(f'ACF 字段 {key[:75]} 含 PHP 序列化结构,未进行反序列化')
continue
if len(value.encode('utf-8'))>10_000:
warnings.append(f'ACF 字段 {key[:75]} 超出单值安全上限')
continue
match=re.fullmatch(r'(.{1,80})_(\d{1,2})_([A-Za-z][A-Za-z0-9_]*)',key)
if match:
group,index,field=match.group(1),int(match.group(2)),match.group(3)
if index>=50:continue
repeaters.setdefault(group,{}).setdefault(index,{})[field]=value
elif re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,100}',key):
scalar[key]=value
result={}
for group,values in sorted(repeaters.items()):
count=meta.get(group)
if not (isinstance(count,str) and count.isdigit() and 1<=int(count)<=50):
warnings.append(f'ACF 类重复字段 {group} 缺少有效行数,保留原始键供人工核对')
continue
expected=int(count)
if any(i>=expected for i in values):
warnings.append(f'ACF 类重复字段 {group} 存在超出声明行数的字段')
# Only expose a complete contiguous set. Partial repeaters remain metadata-only.
if any(i not in values for i in range(expected)):
warnings.append(f'ACF 类重复字段 {group} 行不连续,必须人工复核')
continue
if sum(len(x) for x in values.values())>1500:
warnings.append(f'ACF 类重复字段 {group} 子字段超出安全上限')
continue
result[group]=[values[i] for i in range(expected)]
if repeaters:
warnings.append('ACF Repeater 由字段命名模式推断;必须与源站字段组定义核对')
return {'fields':scalar,'repeaters':result,'warnings':list(dict.fromkeys(warnings))[:80]}
def inspect_wxr(data:bytes):
if not data or len(data)>MAX_XML:raise ValueError('WXR 必须非空且不超过 16 MB')
root=SafeET.fromstring(data)
if root.tag.rsplit('}',1)[-1]!='rss':raise ValueError('需要 WordPress WXR RSS XML')
items=root.findall('./channel/item')
if not items:raise ValueError('WXR 缺少 channel/item 记录')
if len(items)>MAX_ITEMS:raise ValueError('WXR 超过 20000 条记录上限')
counts=Counter();issues=[];routes={};features=Counter();example=[];hosts=set()
def issue(severity,code,wp_id,message,route=''):
counts['findings_'+severity]+=1
if len(issues)<MAX_FINDINGS:
issues.append({'severity':severity,'code':code,'wp_post_id':str(wp_id)[:50],
'route':route,'message':message[:240]})
from . import wordpress_compat
for node in items:
direct={n.tag.rsplit('}',1)[-1]:(n.text or '') for n in node if n.tag.rsplit('}',1)[-1] in ('post_type','status','post_id','link','title')}
kind=direct.get('post_type','post');status=direct.get('status','publish');wp_id=direct.get('post_id','')
counts['total_records']+=1;counts['type_'+kind[:40]]+=1
if kind in ('attachment','revision','nav_menu_item'):
if kind=='attachment':counts['attachments']+=1
continue
if status!='publish':
counts['non_public']+=1
continue
counts['published_content']+=1
link=direct.get('link','')
try:
p=urlsplit(link)
if p.scheme not in ('http','https') or not p.hostname or p.username or p.password or p.query or p.fragment or not p.path:
raise ValueError('缺少固定 HTTP(S) 路径或使用动态参数 URL')
route=route_path(p.path)
except (ValueError,TypeError):
issue('blocker','invalid_permalink',wp_id,'原链接无法无损映射为静态 URL;需要显式路径/301 映射')
continue
host=p.hostname.lower()
if hosts and host not in hosts:
issue('review','mixed_source_hosts',wp_id,'源站出现多个主机名,请核对 www/非 www、旧域和路径所属关系',route)
hosts.add(host)
if route in routes:
issue('blocker','duplicate_route',wp_id,'URL 路径与其他已发布内容冲突',route)
continue
routes[route]=wp_id
if len(example)<30:example.append({'route':route,'type':kind,'wp_post_id':wp_id})
meta,dup=postmeta(node)
if kind in ('product','product_variation') or '_product_attributes' in meta:
features['woocommerce']+=1
issue('review','woocommerce',wp_id,'WooCommerce 产品、变体、库存、定价与购物车行为需独立迁移验收',route)
for key in dup[:5]:issue('review','duplicate_postmeta',wp_id,'重复 WordPress 自定义字段:'+key,route)
acf=extract_acf(meta)
if acf['fields'] or acf['repeaters']:
features['acf']+=1
for warning in acf['warnings'][:5]:issue('review','acf_field',wp_id,warning,route)
for key,value in meta.items():
if not key.startswith('_') and isinstance(value,str) and SERIALIZED.match(value.strip()):
features['serialized_meta']+=1
issue('review','serialized_meta',wp_id,'含 PHP 序列化元数据:'+key[:60]+',不会执行或自动解析',route)
if features['serialized_meta']>10:break
if meta.get('_elementor_data'):
features['elementor']+=1
result=wordpress_compat.elementor_to_html(meta['_elementor_data'])
if result['warnings']:
issue('review','elementor',wp_id,'Elementor 非无损转换:'+result['warnings'][0],route)
else:issue('review','elementor_layout',wp_id,'Elementor 样式、容器、响应式布局需要视觉对照验收',route)
body=next((e.text or '' for e in node if e.tag.startswith('{http://purl.org/rss/1.0/modules/content/}') and e.tag.endswith('encoded')),'')
s=BeautifulSoup(body,'html.parser')
if SHORTCODE.search(body):
features['dynamic_shortcodes']+=1
issue('blocker','dynamic_shortcode',wp_id,'存在动态短代码,纯静态导出不能提供同等业务行为',route)
if s.select('form,iframe'):
features['dynamic_html']+=1
issue('review','dynamic_html',wp_id,'表单或 iframe 需单独确认服务、第三方依赖与可用性',route)
if any(n.get('src','').startswith(('http://','https://')) for n in s.select('img[src]')):
features['external_images']+=1
issue('review','external_media',wp_id,'正文使用远程图片;需执行本地化及完整性对账',route)
if kind not in ('post','page','product','service','case','brand','vehicle'):
features['custom_post_types']+=1
issue('review','custom_post_type',wp_id,'自定义文章类型 '+kind[:60]+' 将映射 other;模板/字段关系需人工确认',route)
blocked=counts['findings_blocker']
from .version import VERSION
return {'version':VERSION,'input_bytes':len(data),'counts':dict(counts),
'unique_published_routes':len(routes),'features':dict(features),
'issues':issues,'findings_truncated':sum(v for k,v in counts.items() if k.startswith('findings_'))>len(issues),
'sample_routes':example,'source_hosts':sorted(hosts),'ready_for_automatic_publish':False,
'blocking_issues':blocked,
'note':'仅离线识别兼容性与风险;预检不改动站点、不执行 PHP,不构成 1:1 迁移验收。'}
@router.post('/{site_id}/wordpress/wxr-preflight')
async def wxr_preflight(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
data=await file.read(MAX_XML+1)
try:return inspect_wxr(data)
except (ValueError,SafeET.ParseError) as exc:raise HTTPException(400,str(exc))
+33 -6
View File
@@ -3,6 +3,7 @@ and durable worker-backed REST scanning. All flows require explicit authorizatio
and remain subject to the existing SiteForge release-review gates.
"""
from __future__ import annotations
import re
import hashlib
import json
import uuid
@@ -160,15 +161,36 @@ def _srcset_urls(value):
return result
# CSS background images are common in Elementor containers and ACF galleries.
# Restrict to syntactically explicit remote URLs, never evaluate arbitrary CSS.
CSS_URL = re.compile(r'''url\(\s*([\"\']?)((?:https?:)?//[^\"\')\s]+)\1\s*\)''', re.I)
def _css_urls(raw):
return [match.group(2) for match in CSS_URL.finditer(str(raw or ''))]
def _rewrite_css_urls(raw,converted):
def replace(match):
url=match.group(2)
if url not in converted:return match.group(0)
quote=match.group(1)
return f'url({quote}{converted[url]}{quote})'
updated=CSS_URL.sub(replace,str(raw or ''))
return updated,updated!=str(raw or '')
def _media_urls(content):
"""Collect all remote media references, including responsive and lazy images."""
"""Collect images plus responsive/lazy/CSS backgrounds for explicit review."""
urls=[content.get('cover') or '']
soup=BeautifulSoup(content.get('body') or '','html.parser')
for tag in soup.find_all(['img','source','video']):
for key in ('src','data-src','poster'):
for tag in soup.find_all(True):
for key in ('src','data-src','poster','data-background','data-background-image'):
if tag.get(key):urls.append(tag[key])
for key in ('srcset','data-srcset'):
urls.extend(_srcset_urls(tag.get(key)))
if tag.get('style'):urls.extend(_css_urls(tag['style']))
if tag.name=='style':urls.extend(_css_urls(tag.get_text()))
return list(dict.fromkeys(u for u in urls if isinstance(u,str)
and u.startswith(('https://','http://','//'))))
@@ -187,8 +209,8 @@ def _rewrite_srcset(value,converted):
def _rewrite_media_html(body,converted):
soup=BeautifulSoup(body,'html.parser');changed=False
for tag in soup.find_all(['img','source','video']):
for key in ('src','data-src','poster'):
for tag in soup.find_all(True):
for key in ('src','data-src','poster','data-background','data-background-image'):
if tag.get(key) in converted:
tag[key]=converted[tag[key]];changed=True
for key in ('srcset','data-srcset'):
@@ -196,11 +218,16 @@ def _rewrite_media_html(body,converted):
new,updated=_rewrite_srcset(tag[key],converted)
if updated:
tag[key]=new;changed=True
# A single redundant srcset has no value and can be removed.
if tag.name=='img' and key=='srcset' and len(new.split(','))==1:
fields=new.strip().split()
if fields and fields[0]==tag.get('src'):
tag.attrs.pop(key,None)
if tag.get('style'):
value,updated=_rewrite_css_urls(tag['style'],converted)
if updated:tag['style']=value;changed=True
if tag.name=='style':
value,updated=_rewrite_css_urls(tag.string if tag.string is not None else tag.get_text(),converted)
if updated:tag.string=value;changed=True
return str(soup) if changed else body,changed