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:
+68
-11
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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']}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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('不是原生发布目标')
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.'}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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};
|
||||
});
|
||||
@@ -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
@@ -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()}
|
||||
});
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user