chore: import SiteForge Factory V5 source (baseline)

导入 SiteForge Factory V5 基线源码:
- FastAPI + SQLite + Docker Compose(Web + Worker)架构
- 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照)
- 四种建站模式骨架 + 迁移审计 + 发布门禁
- 52 项 pytest 测试
- 补全计划主文档 docs/superpowers/plans/

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 10:42:19 +08:00
commit 3c57c167f2
264 changed files with 11682 additions and 0 deletions
View File
+100
View File
@@ -0,0 +1,100 @@
"""CMS adapter framework: bounded public-site detection and WP REST import.
Private/paid/SSO content is intentionally not accessed by this adapter. Other
platforms should provide a compatible adapter for their authorised APIs.
"""
from dataclasses import dataclass
from urllib.parse import urljoin,urlparse
from bs4 import BeautifulSoup
import httpx
from .core import ensure_public_url, route_path, save_entry
from . import db
USER_AGENT='SiteForge/1.0 (+authorized-migration)'
@dataclass(frozen=True)
class SiteAnalysis:
url:str
platform:str
title:str
rest_api:str|None
hints:list[str]
def _get(client,url):
ensure_public_url(url)
r=client.get(url,follow_redirects=False)
if r.is_redirect:raise ValueError('重定向目标需由管理员核实后再次扫描')
r.raise_for_status()
if len(r.content)>4_000_000:raise ValueError('响应内容超过限制')
return r
def analyze(url):
ensure_public_url(url)
with httpx.Client(timeout=httpx.Timeout(13,connect=5),trust_env=False,headers={'User-Agent':USER_AGENT}) as client:
r=_get(client,url)
soup=BeautifulSoup(r.text,'html.parser')
generator=soup.find('meta',attrs={'name':'generator'})
gen=(generator.get('content','') if generator else '').lower()
rest=soup.find('link',rel='https://api.w.org/')
api_url=rest.get('href') if rest else None
platform='WordPress' if 'wordpress' in gen or api_url or 'wp-content/' in r.text[:200000] else ('Drupal' if 'drupal' in gen else 'Joomla' if 'joomla' in gen else 'Unknown / Custom')
hints=[]
if soup.find('meta',attrs={'name':'robots'}):hints.append('检测到 robots meta')
if soup.find('link',rel='canonical'):hints.append('检测到 Canonical')
if soup.find('script',attrs={'type':'application/ld+json'}):hints.append('检测到结构化数据')
if not api_url and platform=='WordPress':api_url=urljoin(url,'/wp-json/')
return SiteAnalysis(url,platform,soup.title.get_text(' ',strip=True)[:200] if soup.title else '',api_url,hints).__dict__
def import_wordpress_rest(site_id,origin,max_items=150,on_conflict='skip'):
"""Import public published posts/pages from a WP REST API, preserving URL paths.
Special Elementor/ACF/Yoast fields require additional adapter support.
"""
if max_items<1 or max_items>1000:raise ValueError('允许导入 1-1000 条公开内容')
if on_conflict not in ('skip','update'):raise ValueError('无效的冲突策略')
root=origin.rstrip('/')+'/'
ensure_public_url(root)
result={'imported':0,'updated':0,'skipped':0,'errors':[]}
with httpx.Client(timeout=httpx.Timeout(18,connect=6),trust_env=False,headers={'User-Agent':USER_AGENT}) as client:
for kind in ('pages','posts'):
batch_size=min(100,max_items)
remaining=max_items-(result['imported']+result['updated']+result['skipped'])
if remaining<=0:break
page=1
while remaining>0 and page<=20:
api=urljoin(root,f'wp-json/wp/v2/{kind}?per_page={batch_size}&page={page}&_embed=1')
try:
response=_get(client,api)
content=response.json()
if not isinstance(content,list):raise ValueError('API 返回内容不是数组')
except httpx.HTTPStatusError as exc:
if exc.response.status_code in (400,404):break
result['errors'].append({'source':kind,'page':page,'error':str(exc)[:150]});break
except (ValueError,httpx.HTTPError) as exc:
result['errors'].append({'source':kind,'page':page,'error':str(exc)[:150]});break
if not content:break
for item in content:
try:
permalink=item.get('link') or ''
parsed=urlparse(permalink)
if not parsed.path:raise ValueError('未返回页面 URL')
if parsed.hostname!=urlparse(root).hostname:raise ValueError('返回了不同域名的链接,需手动映射')
route=route_path(parsed.path)
title=BeautifulSoup(item.get('title',{}).get('rendered',''),'html.parser').get_text(' ',strip=True)
excerpt=BeautifulSoup(item.get('excerpt',{}).get('rendered',''),'html.parser').get_text(' ',strip=True)
seo=item.get('yoast_head_json') or {}
data={'type':'post' if kind=='posts' else 'page', 'route':route,'title':title,
'summary':excerpt,'body':item.get('content',{}).get('rendered',''),
'seo_title':seo.get('title',''),'seo_description':seo.get('description',''),
'canonical':seo.get('canonical',''),'meta':{'wp_id':item.get('id'),'wp_slug':item.get('slug'),'wp_modified':item.get('modified')}}
previous=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route))
if previous and on_conflict=='skip':result['skipped']+=1;continue
save_entry(site_id,data,existing_id=previous['id'] if previous else None,source='wp-rest')
result['updated' if previous else 'imported']+=1
except Exception as exc:
result['errors'].append({'item':item.get('id'),'error':str(exc)[:180]})
remaining-=1
if remaining<=0:break
if len(content)<batch_size:break
page+=1
return result
+126
View File
@@ -0,0 +1,126 @@
"""Declarative mapping from arbitrary tabular source data to SiteForge content.
Never eval mappings, no AI hallucinated fields, preview shows all errors.
"""
import json,re
from . import db
from .core import route_path,slug_of,save_entry
ALIASES={
'title':('title','name','product_name','model_name','名称','标题','产品名称','车型','车型名称','商品名称'),
'route':('route','url','path','permalink','链接','路径','旧站链接','原网址'),
'summary':('summary','description','excerpt','简介','摘要','产品简介'),
'body':('body','content','html','正文','内容','详细介绍'),
'cover':('cover','image','thumbnail','featured_image','图片','封面','主图'),
'brand':('brand','manufacturer','品牌','厂商'),
'category':('category','分类','类目'),
'seo_title':('seo_title','meta_title','SEO标题','seo标题'),
'seo_description':('seo_description','meta_description','SEO描述','seo描述'),
'canonical':('canonical','规范网址'),
'model':('model','model_name','车型型号','型号','规格型号'),
'energy':('energy','energy_type','fuel_type','动力形式','能源类型','燃料类型'),
'range':('range','battery_range','纯电续航','续航','续航里程'),
'year':('year','model_year','年款','年份'),
'sku':('sku','product_sku','产品编号','货号'),
'gallery':('gallery','images','image_gallery','产品图片','图库','详情图片'),
'price':('price','msrp','售价','价格'),
'specs':('specs','attributes','parameters','技术参数','产品参数'),
}
SYSTEM_FIELDS={'title','route','summary','body','cover','seo_title','seo_description','canonical'}
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS mapping_profiles (
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,content_type TEXT NOT NULL, rules_json TEXT NOT NULL,
created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS import_batches (
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
profile_id TEXT, source TEXT NOT NULL, result_json TEXT NOT NULL,
created_at TEXT NOT NULL);
''')
def suggest(columns):
clean=lambda x:re.sub(r'[\s\-_]+','',str(x).casefold())
src={clean(col):col for col in columns}
mapping={}
for target,aliases in ALIASES.items():
for alias in aliases:
if clean(alias) in src:
mapping[target]=src[clean(alias)];break
return mapping
def validate_rules(rules,columns):
if not isinstance(rules,dict) or len(rules)>100:raise ValueError('字段映射规则不合法')
for dest,src in rules.items():
if not re.fullmatch(r'[\w\-]{1,60}',dest,re.UNICODE):raise ValueError('目标字段名称不合法')
if not isinstance(src,str) or src not in columns:raise ValueError(f'来源字段不存在: {src}')
if 'title' not in rules:raise ValueError('必须映射标题字段')
def transform(rows,rules,kind='product',prefix=None):
if kind not in ('product','post','page','brand','vehicle','service','case','other'):raise ValueError('不支持的内容类型')
columns=set().union(*(r.keys() for r in rows)) if rows else set()
validate_rules(rules,columns)
seen=set();out=[];errors=[]
for index,row in enumerate(rows):
vals={}
for target,src in rules.items():
raw=row.get(src)
vals[target]=(json.dumps(raw,ensure_ascii=False) if isinstance(raw,(dict,list)) else str(raw if raw is not None else '')).strip()
title=vals.get('title','')
if not title:
errors.append({'row':index+1,'error':'标题为空'});continue
route=vals.get('route','')
if not route:
route=f'/{prefix or kind}/{slug_of(title)}/'
try:route=route_path(route)
except ValueError as exc:
errors.append({'row':index+1,'error':f'路径非法:{exc}'});continue
if route in seen:
errors.append({'row':index+1,'error':'导入文件内 URL 重复:'+route});continue
seen.add(route)
fields={k:v for k,v in vals.items() if k not in SYSTEM_FIELDS}
if fields.get('gallery'):
try:
values=json.loads(fields['gallery'])
if isinstance(values,list):fields['gallery']=[str(x.get('url','') if isinstance(x,dict) else x) for x in values if x]
except ValueError:
fields['gallery']=[x.strip() for x in fields['gallery'].split(';') if x.strip()][:40]
out.append({'type':kind,'title':title,'route':route,'summary':vals.get('summary',''),
'body':vals.get('body',''),'cover':vals.get('cover',''),
'seo_title':vals.get('seo_title',''),'seo_description':vals.get('seo_description',''),
'canonical':vals.get('canonical',''),'meta':{'fields':fields}})
return out,errors
def dry_run(site_id,rows,rules,kind='product',prefix=None):
if len(rows)>1000:raise ValueError('单批最多1000条内容')
items,errors=transform(rows,rules,kind,prefix)
existing={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))}
collisions=[r['route'] for r in items if r['route'] in existing]
return {'input':len(rows),'mapped':len(items),'errors':errors,'conflicts':collisions,
'unmapped_columns':sorted(set().union(*(x.keys() for x in rows)) - set(rules.values())) if rows else [],
'sample':items[:10], 'items':items}
def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None):
if on_conflict not in ('skip','replace'):raise ValueError('冲突策略只能为 skip 或 replace')
report=dry_run(site_id,rows,rules,kind,prefix)
created=0;updated=0;skipped=0;errors=list(report['errors'])
for idx,item in enumerate(report['items'],1):
try:
existing=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,item['route']))
if existing and on_conflict=='skip':skipped+=1;continue
save_entry(site_id,item,existing_id=existing['id'] if existing else None,source='mapped-import')
if existing:updated+=1
else:created+=1
except Exception as exc:
errors.append({'row':idx,'error':str(exc)[:250]})
summary={'created':created,'updated':updated,'skipped':skipped,'errors':errors,'mapped':report['mapped']}
from .core import uid
db.insert('INSERT INTO import_batches VALUES (?,?,?,?,?,?)',(uid(),site_id,'',f'content-map:{kind}',json.dumps(summary,ensure_ascii=False),db.now()))
return summary
+35
View File
@@ -0,0 +1,35 @@
"""Consistent SQLite + asset backup. Run: docker compose exec siteforge python -m app.backup"""
import os
import sqlite3
import tempfile
import zipfile
from pathlib import Path
from datetime import datetime, timezone
from . import db
def backup():
db.init()
storage=Path(db.DATA).resolve()
path=storage/'backups'
path.mkdir(exist_ok=True,parents=True)
name='siteforge-backup-'+datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')+'.zip'
dest=path/name
# SQLite online backup is consistent even when the DB uses WAL mode.
with tempfile.TemporaryDirectory() as tmp:
snapshot=Path(tmp)/'siteforge.sqlite3'
with sqlite3.connect(db.DB_FILE) as source, sqlite3.connect(snapshot) as target:
source.backup(target)
with zipfile.ZipFile(dest,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=5) as output:
output.write(snapshot,'siteforge.sqlite3')
for subdir in ('media','legacy','builds','archives'):
root=storage/subdir
if not root.is_dir():continue
for file in root.rglob('*'):
if file.is_file() and not file.is_symlink():
output.write(file, file.relative_to(storage).as_posix())
print(f'备份成功: {dest}')
print('注意:备份文件仍在 Docker 数据卷内。请使用 docker compose cp 或其他方式再复制到外部设备。')
return dest
if __name__=='__main__':backup()
+230
View File
@@ -0,0 +1,230 @@
"""Declarative theme/page blueprints, editor schema, and safe content bindings.
No eval and no arbitrary template code can be submitted by visitors.
"""
import copy
import json
import re
from html import escape
from urllib.parse import urlparse
from .core import sanitize, route_path
BLOCKS = {
'hero': {'name':'封面横幅','icon':'✦','fields':['title','subtitle','description','image','button','href','align'], 'defaults':{'title':'让品牌从这里出发','subtitle':'GLOBAL EXPERIENCE','description':'在这里呈现企业最重要的价值。','button':'探索产品','href':'/product/','align':'left'}},
'richtext':{'name':'富文本','icon':'≡','fields':['title','body'],'defaults':{'title':'关于我们','body':'<p>在这里输入图文内容。</p>'}},
'features':{'name':'核心优势','icon':'▦','fields':['title','items'],'defaults':{'title':'我们的优势','items':[{'title':'专业服务','text':'从咨询到交付'},{'title':'品质保障','text':'严格的质量流程'},{'title':'全球网络','text':'连接不同市场'}]}},
'stats':{'name':'数字指标','icon':'#','fields':['title','items'],'defaults':{'title':'我们的实力','items':[{'value':'50,000+','label':'服务案例'},{'value':'70+','label':'市场覆盖'},{'value':'30+','label':'合作伙伴'}]}},
'collection':{'name':'动态内容列表','icon':'▣','fields':['title','subtitle','source','limit','columns','button','href'],'defaults':{'title':'精选产品','subtitle':'为客户提供适合的解决方案','source':'product','limit':6,'columns':3,'button':'查看全部','href':'/product/'}},
'image_text':{'name':'图文组合','icon':'◧','fields':['title','subtitle','description','image','image_side','button','href'],'defaults':{'title':'从采购到交付','subtitle':'OUR APPROACH','description':'展示业务流程和服务理念。','image':'','image_side':'right','button':'了解更多','href':'/about/'}},
'gallery':{'name':'图片画廊','icon':'▧','fields':['title','images','columns'],'defaults':{'title':'精彩瞬间','images':[],'columns':3}},
'faq':{'name':'常见问题','icon':'?','fields':['title','items'],'defaults':{'title':'常见问题','items':[{'question':'如何开始合作?','answer':'您可以通过联系我们页面提交需求。'}]}},
'cta':{'name':'行动号召','icon':'↗','fields':['title','subtitle','button','href'],'defaults':{'title':'开启新的合作','subtitle':'告诉我们您的需求,我们将提供适合的方案。','button':'联系我们','href':'/contact/'}},
'attributes':{'name':'参数信息表','icon':'▤','fields':['title'],'defaults':{'title':'产品参数'}},
'layout':{'name':'多列自由布局','icon':'▥','fields':['columns','gap'], 'defaults':{'columns':[{'width':1,'blocks':[]},{'width':1,'blocks':[]}],'gap':24}},
'spacer':{'name':'上下留白','icon':'↕','fields':['height'],'defaults':{'height':48}},
}
BINDABLE = ['site.name','site.tagline','site.logo','page.title','page.summary','page.cover','page.body']
CONTENT_MODELS={
'product': [{'name':'brand','label':'品牌','type':'text'},{'name':'model','label':'型号','type':'text'},{'name':'category','label':'分类','type':'text'},{'name':'specs','label':'技术参数','type':'textarea'}],
'vehicle':[{'name':'brand','label':'品牌','type':'text'},{'name':'energy','label':'能源类型','type':'text'},{'name':'range','label':'续航里程','type':'text'}],
'post':[{'name':'author','label':'作者','type':'text'},{'name':'publish_date','label':'发布日期','type':'date'}],
'case':[{'name':'location','label':'项目地点','type':'text'},{'name':'industry','label':'行业','type':'text'}],
'brand':[{'name':'country','label':'所属地区','type':'text'},{'name':'website','label':'官方网站','type':'text'}]
}
PAGE_TEMPLATES={
'home':{'name':'首页','description':'全站形象与动态内容展示'},
'about':{'name':'关于我们','description':'企业介绍、优势与里程碑'},
'contact':{'name':'联系我们','description':'联系方式与业务咨询'},
'listing':{'name':'产品或文章列表','description':'内容查询、列表与分类展示'},
'product':{'name':'产品详情','description':'产品简介、图片、技术参数'},
'article':{'name':'新闻文章','description':'正文、作者与相关内容'},
'brand':{'name':'品牌详情','description':'品牌介绍、品牌产品'},
'standard':{'name':'标准内容页','description':'自由组合的自定义页面'},
'notfound':{'name':'404','description':'访问异常页面'},
'category':{'name':'分类详情','description':'按具体分类查看内容'},
'case':{'name':'案例详情','description':'项目介绍、项目图片和成果'},
'service':{'name':'服务详情','description':'业务服务介绍'},
'faq':{'name':'常见问题','description':'问题与回答'},
}
def sample_blocks(kind):
presets={
'home':[('hero',{}),('stats',{}),('collection',{}),('features',{}),('cta',{})],
'about':[('image_text',{'title':'认识我们'}),('stats',{}),('features',{})],
'contact':[('richtext',{'title':'联系我们','body':'<p>请填写企业联系方式与地址。</p>'}),('cta',{'title':'欢迎联系'})],
'listing':[('collection',{'title':'所有产品','limit':24})],
'product':[('image_text',{'title':'产品介绍','description':'{{page.summary}}','image':'{{page.cover}}'}),('attributes',{'title':'技术参数'}),('richtext',{'title':'产品详情','body':'{{page.body}}'}),('cta',{})],
'article':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'})],
'brand':[('image_text',{'title':'{{page.title}}','description':'{{page.summary}}','image':'{{page.cover}}'}),('collection',{'title':'品牌产品','source':'product'})],
'standard':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'})],
'notfound':[('cta',{'title':'页面暂时无法访问','href':'/','button':'返回首页'})],
'category':[('collection',{'source':'product','limit':24})],
'case':[('image_text',{'title':'{{page.title}}','image':'{{page.cover}}','description':'{{page.summary}}'}),('richtext',{'title':'项目介绍','body':'{{page.body}}'})],
'service':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'}),('features',{})],
'faq':[('faq',{})]
}
return [new_block(k,overrides) for k,overrides in presets.get(kind,presets['standard'])]
def new_block(kind, overrides=None):
import uuid
if kind not in BLOCKS:raise ValueError('未知区块类型: '+str(kind))
return {'id':uuid.uuid4().hex[:10], 'type':kind, 'visible':True, **copy.deepcopy(BLOCKS[kind]['defaults']), **(overrides or {})}
def validate_blocks(blocks):
"""Validate nested no-code rows; cap depth, blocks and payload size.
Children must always be safe declared block types. No arbitrary HTML or JS.
"""
found=set()
total=[0]
def normalize(rows,depth=0):
if not isinstance(rows,list) or len(rows)>60:raise ValueError('区块必须是数组,最多 60 个')
clean=[]
for block in rows:
if not isinstance(block,dict) or block.get('type') not in BLOCKS:raise ValueError('包含不支持的区块类型')
import uuid
total[0]+=1
if total[0]>120:raise ValueError('页面最多 120 个区块(含列内组件)')
ident=str(block.get('id') or uuid.uuid4().hex[:10]).strip()
if not re.fullmatch('[a-zA-Z0-9_-]{4,50}',ident) or ident in found:raise ValueError('区块 ID 缺失或重复')
found.add(ident)
spec=BLOCKS[block['type']]
allowed=set(spec['fields'])|{'id','type','visible','style'}
normalized={key:val for key,val in block.items() if key in allowed}
normalized['id']=ident
if 'style' in normalized:
style=normalized['style']
if not isinstance(style,dict):raise ValueError('样式必须为对象')
normalized['style']={k:v for k,v in style.items() if k in ('background','text_color','padding','tablet_padding','mobile_padding','hide_mobile','hide_tablet') and isinstance(v,(str,int,float,bool))}
if block['type']=='layout':
if depth>=2:raise ValueError('多列容器最多嵌套两层')
cols=block.get('columns')
if not isinstance(cols,list) or not 1<=len(cols)<=4:raise ValueError('一行需要1-4列')
sanitized=[]
for col in cols:
if not isinstance(col,dict):raise ValueError('列配置无效')
width=col.get('width',1)
if not isinstance(width,(int,float)) or not .5<=width<=4:raise ValueError('列宽必须在0.5-4之间')
sanitized.append({'width':float(width),'blocks':normalize(col.get('blocks',[]),depth+1)})
normalized['columns']=sanitized
normalized['gap']=max(0,min(64,int(block.get('gap',24))))
if len(json.dumps(normalized,ensure_ascii=False))>100_000:raise ValueError('单一区块数据过大')
clean.append(normalized)
return clean
return normalize(blocks)
def resolve_text(value, site, page):
if not isinstance(value,str):return str(value or '')
def repl(m):
group,name=m.group(1),m.group(2)
if group=='site' and name in ('name','tagline','logo'): return str(site.get(name) or '')
if group=='page' and name in ('title','summary','cover','body'):return str(page.get(name) or '')
return ''
return re.sub(r'\{\{\s*(site|page)\.([a-z_]+)\s*\}\}', repl,value)
def safe_media(raw):
value=(raw or '').strip()
p=urlparse(value)
if p.scheme and p.scheme not in ('http','https'):return ''
if value.startswith('//') or any(c in value for c in ('"','<','>','\n','\r')):return ''
return escape(value.lstrip('/') if value.startswith('/') else value,quote=True)
def safe_href(raw):
value=(raw or '').strip()
p=urlparse(value)
if p.scheme and p.scheme not in ('http','https','mailto','tel'):return '/'
if value.startswith('//') or any(c in value for c in ('"','<','>','\n','\r')):return '/'
if not p.scheme:
try:return escape(route_path(value).lstrip('/') or './',quote=True)
except ValueError:return '/'
return escape(value,quote=True)
def render_blocks(blocks, site, page, entries):
"""One trusted rendering implementation for public HTML and editor previews."""
parts=[]
for block in validate_blocks(blocks):
if block.get('visible') is False:continue
typ=block['type']; resolve=lambda key,default='':resolve_text(block.get(key,default),site,page)
title=escape(resolve('title')); subtitle=escape(resolve('subtitle'));desc=escape(resolve('description'))
raw_style=block.get('style') or {}; s=[]
for key,var in [('background','--sf-bg'),('text_color','--sf-fg')]:
val=str(raw_style.get(key,'')).strip()
if re.fullmatch(r'#[a-fA-F0-9]{6}',val):s.append(f'{var}:{val}')
if 'padding' in raw_style:
try:pad=max(0,min(120,int(raw_style['padding'])));s.append(f'--sf-pad:{pad}px')
except (ValueError,TypeError):pass
style=' style="'+escape(';'.join(s),quote=True)+'"' if s else ''
start=f'<section class="sf-section sf-{typ}" data-block="{escape(block["id"])}"{style}><div class="sf-container">'
end='</div></section>'
if typ=='hero':
image=safe_media(resolve('image'))
art=f'<div class="sf-hero-img"><img src="{image}" alt="{title}" loading="lazy"></div>' if image else '<div class="sf-hero-img sf-hero-abstract"><span>✧</span></div>'
parts.append(start+'<div class="sf-hero-grid"><div class="sf-hero-copy"><span class="sf-kicker">'+subtitle+'</span><h1>'+title+'</h1><p>'+desc+'</p><a class="sf-button" href="'+safe_href(resolve('href','/product/'))+'">'+escape(resolve('button','探索更多'))+' <span>↗</span></a></div>'+art+'</div>'+end)
elif typ=='richtext':
body=sanitize(resolve('body'))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-prose">{body}</div>'+end)
elif typ=='features':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'<article class="sf-feature"><span>{i+1:02}</span><h3>{escape(str(item.get("title","")))}</h3><p>{escape(str(item.get("text","")))}</p></article>' for i,item in enumerate(items[:12]) if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-features">{cards}</div>'+end)
elif typ=='stats':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'<div><strong>{escape(str(item.get("value","")))}</strong><span>{escape(str(item.get("label","")))}</span></div>' for item in items[:12] if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-stats">{cards}</div>'+end)
elif typ=='collection':
source=str(block.get('source') or 'product');source=source if source in ('product','post','page','service','case','brand','vehicle','other','all') else 'product'
bound=[r for r in entries if r.get('route')!=page.get('route') and (source=='all' or r.get('type')==source)]
if page.get('type')=='brand' and source in ('product','vehicle'):
def brand_of(record):
try:return str((json.loads(record.get('meta_json') or '{}').get('fields') or {}).get('brand','')).casefold().strip()
except (ValueError,TypeError,AttributeError):return ''
bound=[r for r in bound if brand_of(r)==str(page.get('title','')).casefold().strip()]
count=max(1,min(48,int(block.get('limit') or 6)));cols=max(2,min(4,int(block.get('columns') or 3)))
cards=''.join(f'<a class="sf-item" data-category="{escape(str((json.loads(r.get("meta_json") or "{}").get("fields") or {}).get("category", "")),quote=True)}" data-brand="{escape(str((json.loads(r.get("meta_json") or "{}").get("fields") or {}).get("brand", "")),quote=True)}" href="{safe_href(r.get("route") or "/")}"><div class="sf-item-img">'+(f'<img src="{safe_media(r.get("cover"))}" alt="{escape(r.get("title",""),quote=True)}" loading="lazy">' if safe_media(r.get('cover')) else '<div class="sf-placeholder">'+escape(r.get('title','')[:2])+'</div>')+f'</div><div class="sf-item-copy"><small>{escape(r.get("type",""))}</small><h3>{escape(r.get("title",""))}</h3><p>{escape((r.get("summary") or "")[:140])}</p><span>查看详情 →</span></div></a>' for r in bound[:count])
parts.append(start+f'<div class="sf-section-head"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2></div><a href="{safe_href(resolve("href","/product/"))}">{escape(resolve("button","查看全部"))} →</a></div><div class="sf-collection" style="--sf-cols:{cols}">{cards if cards else "<p class=\"sf-muted\">当前暂无相关内容,请导入或添加数据。</p>"}</div>'+end)
elif typ=='image_text':
im=safe_media(resolve('image')); img=f'<img src="{im}" alt="{title}" loading="lazy">' if im else '<div class="sf-placeholder sf-large">✧</div>'
side=' sf-reverse' if block.get('image_side')=='left' else ''
parts.append(start+f'<div class="sf-image-text{side}"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2><p>{desc}</p><a class="sf-link" href="{safe_href(resolve("href","/about/"))}">{escape(resolve("button","了解更多"))} →</a></div><div class="sf-image-side">{img}</div></div>'+end)
elif typ=='attributes':
raw=page.get('meta_json') or '{}'
try: meta=json.loads(raw) if isinstance(raw,str) else raw
except (ValueError,TypeError):meta={}
props=(meta.get('fields') if isinstance(meta.get('fields'),dict) else meta) or {}
specs=(site.get('schemas') or {}).get(page.get('type'),[])
candidates=[(field['name'],field.get('label') or field['name']) for field in specs]
if not candidates:
candidates=[(k,k.replace('_',' ').title()) for k in props.keys() if k not in ('blocks','page_template','wp_metadata','wp_post_id','wp_type','fields') and isinstance(props[k],(str,int,float,bool))]
pairs=''.join(f'<div class="sf-attribute"><span>{escape(str(label))}</span><strong>{escape(str(props[key]))}</strong></div>' for key,label in candidates if props.get(key) not in (None,''))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-attributes">{pairs if pairs else "<p class=\"sf-muted\">参数尚未填写</p>"}</div>'+end)
elif typ=='gallery':
pics=block.get('images') if isinstance(block.get('images'),list) else []
imgs=''.join(f'<img src="{safe_media(str(img.get("url") or ""))}" alt="{escape(str(img.get("alt") or ""),quote=True)}" loading="lazy">' for img in pics[:24] if isinstance(img,dict) and safe_media(str(img.get('url') or '')))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-gallery">{imgs or "<p>可在编辑器中添加图片</p>"}</div>'+end)
elif typ=='faq':
items=block.get('items') if isinstance(block.get('items'),list) else []
body=''.join(f'<details><summary>{escape(str(item.get("question","")))}</summary><p>{escape(str(item.get("answer","")))}</p></details>' for item in items[:20] if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-faq">{body}</div>'+end)
elif typ=='cta':
parts.append(start+f'<div class="sf-cta-wrap"><div><span class="sf-kicker">LET’S CONNECT</span><h2>{title}</h2><p>{subtitle}</p></div><a class="sf-button" href="{safe_href(resolve("href","/contact/"))}">{escape(resolve("button","联系我们"))} ↗</a></div>'+end)
elif typ=='layout':
columns=block['columns'];gap=block.get('gap',24)
weights=' '.join(f'{col["width"]}fr' for col in columns)
html_cols=[]
for col in columns:
html_cols.append('<div class="sf-layout-column">'+render_blocks(col['blocks'],site,page,entries)+'</div>')
parts.append(start+f'<div class="sf-layout-grid" style="--sf-layout-cols:{escape(weights,quote=True)};--sf-layout-gap:{gap}px">'+''.join(html_cols)+'</div>'+end)
elif typ=='spacer':
try:height=max(8,min(160,int(block.get('height',48))))
except (ValueError,TypeError):height=48
parts.append(f'<div class="sf-spacer" style="height:{height}px"></div>')
return ''.join(parts)
def theme_blocks(theme,kind,version=None):
from pathlib import Path
from . import theme_store
path=theme_store.resolve(theme,version)/'page-presets.json'
if not path.is_file():return sample_blocks(kind)
data=json.loads(path.read_text(encoding='utf-8'))
# IDs must be unique per created page.
return [new_block(b['type'],{k:v for k,v in b.items() if k not in ('type','id')}) for b in data.get(kind, data['standard'])]
+412
View File
@@ -0,0 +1,412 @@
"""Static site assembly, audit, ZIP export, immutable build versions."""
import json
import re
import shutil
import hashlib
import mimetypes
import zipfile
from pathlib import Path
from urllib.parse import urlparse, urljoin
from html import escape
from xml.sax.saxutils import escape as xml_escape
from bs4 import BeautifulSoup
import httpx
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from . import db, blueprints, theme_store, static_site, responsive, theme_pro
from .core import uid, route_path, ensure_public_url, sanitize, THEMES
ROOT=Path(__file__).resolve().parent.parent
THEME_DIR=ROOT/'themes'
ENV=Environment(loader=FileSystemLoader(str(THEME_DIR)),autoescape=select_autoescape(['html','xml']))
TYPE_NAMES={'page':'页面','post':'新闻','product':'产品','service':'服务','case':'案例','brand':'品牌','vehicle':'车型','other':'内容'}
def output_path(dist,route):
path=route_path(route)
if path=='/':return dist/'index.html'
clean=path.lstrip('/')
# Treat explicit .html/.htm as files and other endpoints as directory/index.
if clean.lower().endswith(('.html','.htm')):
out=dist/clean
else:
out=dist/clean/'index.html'
target=out.resolve()
if not target.is_relative_to(dist.resolve()):raise ValueError('非法生成目标目录')
return out
def link(path):
return (path or '/').lstrip('/') or './'
def public_url(site,route):
origin=site['domain'].strip().rstrip('/')
if not origin.startswith(('https://','http://')):return ''
return origin+route
def render_blocks(blocks):
parts=[]
if not isinstance(blocks,list):return ''
for block in blocks[:30]:
if not isinstance(block,dict):continue
typ=str(block.get('type','text'))
title=escape(str(block.get('title','')))
sub=escape(str(block.get('subtitle','')))
body=sanitize(str(block.get('body','')))
if typ=='hero':
parts.append(f'<section class="block-hero"><div class="container"><span class="eyebrow">{sub}</span><h1>{title}</h1><p>{escape(str(block.get("description","")))}</p></div></section>')
elif typ=='features':
features=block.get('items',[])
cards=''.join(f'<div class="feature"><div class="feature-mark">0{i+1}</div><h3>{escape(str(x.get("title","")))}</h3><p>{escape(str(x.get("text","")))}</p></div>' for i,x in enumerate(features[:12]) if isinstance(x,dict))
parts.append(f'<section class="section"><div class="container"><div class="section-heading"><h2>{title}</h2></div><div class="feature-grid">{cards}</div></div></section>')
elif typ=='text':
parts.append(f'<section class="section"><div class="container prose"><h2>{title}</h2>{body}</div></section>')
elif typ=='stats':
vals=''.join(f'<div class="stat"><strong>{escape(str(x.get("value","")))}</strong><span>{escape(str(x.get("label","")))}</span></div>' for x in block.get('items',[])[:8] if isinstance(x,dict))
parts.append(f'<section class="section stats-section"><div class="container stats">{vals}</div></section>')
elif typ=='cta':
target=block.get('href','/contact/')
try:target=link(route_path(target))
except ValueError:target='./'
parts.append(f'<section class="section"><div class="container cta"><h2>{title}</h2><p>{sub}</p><a class="btn" href="{escape(target,quote=True)}">{escape(str(block.get("button","Contact us")))}</a></div></section>')
return ''.join(parts)
def url_fix(link_value,origin_host,known_routes):
"""Convert imported internal absolute URLs to static-site-relative links."""
if not link_value:return link_value
parsed=urlparse(link_value)
if parsed.scheme in ('mailto','tel'):return link_value
if parsed.scheme in ('https','http') and parsed.hostname==origin_host and parsed.path in known_routes:
return link(parsed.path) + (('?' + parsed.query) if parsed.query else '')
if not parsed.scheme and link_value.startswith('/') and parsed.path in known_routes:
return link(parsed.path)
return link_value
def rewrite_internal_links(body,origin_host,known_routes):
soup=BeautifulSoup(body or '', 'html.parser')
for a in soup.find_all('a',href=True):
a['href']=url_fix(a['href'],origin_host,known_routes)
return str(soup)
def copy_media(site_id,dist):
src=db.DATA/'media'/site_id
target=dist/'media'
if src.exists():shutil.copytree(src,target,dirs_exist_ok=True)
legacy=db.DATA/'legacy'/site_id
if legacy.exists():shutil.copytree(legacy,dist,dirs_exist_ok=True)
def normalize_local_images(html):
soup=BeautifulSoup(html or '', 'html.parser')
for tag in soup.find_all('img',src=True):
src=tag['src']
if src.startswith('/') and not src.startswith('//'):
tag['src']=src.lstrip('/')
return str(soup)
def mirror_images(html,dist,origin_host,client,cache,warnings):
soup=BeautifulSoup(html or '', 'html.parser')
for tag in soup.find_all('img',src=True):
tag['src']=mirror_image_url(tag['src'],dist,origin_host,client,cache,warnings)
return str(soup)
def mirror_image_url(raw,dist,origin_host,client,cache,warnings):
if not raw:return raw
parsed=urlparse(raw)
if parsed.scheme not in ('http','https'):
if raw.startswith('/'):
return raw.lstrip('/')
return raw
if raw in cache:return cache[raw]
try:
ensure_public_url(raw)
with client.stream('GET',raw,follow_redirects=False) as response:
if response.status_code in (301,302,303,307,308):
raise ValueError('图片重定向需人工检查')
response.raise_for_status()
mime=response.headers.get('content-type','').split(';')[0].lower()
allowed={'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','image/svg+xml':'.svg','image/avif':'.avif'}
if mime not in allowed:raise ValueError(f'图片类型不支持:{mime}')
buf=bytearray()
for chunk in response.iter_bytes():
buf.extend(chunk)
if len(buf)>8_000_000:raise ValueError('图片超过 8MB')
# SVG can have scripts; disallow external SVG for safety.
if mime=='image/svg+xml':raise ValueError('SVG 外部资源需人工审查')
orig=parsed.path.lstrip('/')
# Original path is preserved for same-domain assets with safe extensions.
if parsed.hostname==origin_host and orig and re.fullmatch(r'[\w\-./%]+',orig) and not any(p in ('.','..') for p in orig.split('/')) and Path(orig).suffix.lower() in allowed.values():
rel=orig
else:
rel='media/remote-'+hashlib.sha256(raw.encode()).hexdigest()[:16]+allowed[mime]
dst=dist/rel
if not dst.resolve().is_relative_to(dist.resolve()):raise ValueError('图片路径越界')
dst.parent.mkdir(parents=True,exist_ok=True)
dst.write_bytes(buf)
cache[raw]=rel
return rel
except Exception as exc:
warnings.append(f'外部图片未本地化: {raw[:100]} ({str(exc)[:120]})')
return raw
def emit_nginx(dist,redirects,slashless):
# Include snippets to be inserted inside the real site's `server {}` block.
lines=['# SiteForge 生成:将本文件内容合并到 Nginx server {} 内','index index.html;','error_page 404 /404.html;',
'location / { try_files $uri $uri/ =404; }']
for old,new,code in redirects:
lines.append(f'location = {old} {{ return {int(code)} {new}; }}')
for route in slashless:
lines.append(f'location = {route} {{ try_files {route}/index.html =404; }}')
(dist/'nginx-snippet.conf').write_text('\n'.join(lines)+'\n',encoding='utf-8')
def build(site_id,mirror=False):
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not site:raise ValueError('网站不存在')
if site['theme'] not in theme_store.ids():raise ValueError('无效的主题')
records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published' ORDER BY type,title",(site_id,))
redirects=db.rows('SELECT * FROM redirects WHERE site_id=?',(site_id,))
settings=json.loads(site['settings_json'] or '{}')
site['schemas']={k:v for k,v in blueprints.CONTENT_MODELS.items()}
for row in db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)):
site['schemas'][row['content_type']]=json.loads(row['fields_json'])
site['email']=settings.get('email','')
site['footer_text']=settings.get('footer_text','')
routes={e['route'] for e in records}
issues=[];warnings=[];slashless=[]
if '/' not in routes:warnings.append('缺少 / 首页:已自动生成默认首页')
if not site['domain'].startswith(('http://','https://')):warnings.append('尚未配置完整域名:未生成绝对 canonical')
route_set=set(routes)
reserved={'/sitemap.xml','/robots.txt','/404.html'}
for e in records:
if e['route'] in reserved:issues.append('页面路由与系统文件冲突: '+e['route'])
if e['route']!='/' and not e['route'].endswith('/') and not e['route'].endswith(('.html','.htm')):
slashless.append(e['route'])
if any(r['old_path'] in route_set for r in redirects):issues.append('重定向来源和有效页面冲突')
if issues:raise ValueError('构建失败:'+';'.join(issues))
bid=uid()
dist=db.DATA/'builds'/site_id/bid/'dist'
dist.mkdir(parents=True,exist_ok=True)
copy_media(site_id,dist)
(dist/'assets').mkdir(exist_ok=True)
theme_pin=theme_store.pinned(site_id)
theme_dir=theme_store.resolve(site['theme'],theme_pin['version'] if theme_pin and theme_pin['theme_id']==site['theme'] else None)
shutil.copy2(theme_dir/'theme.css',dist/'assets'/'theme.css')
shutil.copy2(ROOT/'app'/'static'/'blocks.css',dist/'assets'/'blocks.css')
shutil.copy2(ROOT/'app'/'static'/'pro-base.css',dist/'assets'/'pro-base.css')
shutil.copy2(ROOT/'app'/'static'/'commercial.css',dist/'assets'/'commercial.css')
visuals=theme_dir/'visual'
if visuals.is_dir():
shutil.copytree(visuals,dist/'assets'/'visual',dirs_exist_ok=True)
shutil.copy2(ROOT/'app'/'static'/'pro.js',dist/'assets'/'pro.js')
if (theme_dir/'pro.css').exists():
shutil.copy2(theme_dir/'pro.css',dist/'assets'/'pro.css')
else:
(dist/'assets'/'pro.css').write_text('',encoding='utf-8')
accent=site['accent'] if re.fullmatch(r'#[0-9a-fA-F]{6}',site['accent'] or '') else '#2563eb'
tokens=settings.get('design_tokens') or {}
allowed_fonts={'system':'system-ui,-apple-system,BlinkMacSystemFont,sans-serif',
'serif':'Georgia,Times New Roman,serif',
'modern':'Arial,Helvetica,sans-serif'}
font=allowed_fonts.get(tokens.get('font'),'system-ui,-apple-system,sans-serif')
radius=int(max(0,min(30,tokens.get('radius',12))))
space=int(max(32,min(140,tokens.get('section_spacing',72))))
width=int(max(900,min(1600,tokens.get('container_width',1200))))
scale=float(max(.75,min(1.5,tokens.get('heading_scale',1))))
button={'rounded':f'{radius}px','pill':'999px','square':'0px'}.get(tokens.get('button_style'),f'{radius}px')
theme_css=(f':root{{--accent:{accent};--sf-font:{font};--sf-radius:{radius}px;'
f'--sf-space:{space}px;--sf-width:{width}px;--sf-heading-scale:{scale};}}'
f'body{{font-family:var(--sf-font)}} .sf-canvas .sf-section,.sf-canvas .section{{padding-block:var(--sf-space)}}'
f'.container,.sf-container{{max-width:var(--sf-width)}}'
f'.btn,.primary,.request,.button,.nav-btn{{background-color:{accent};border-radius:{button}}}'
f'body .sf-canvas h1{{font-size:clamp(2.5rem,5vw,calc(4.8rem * var(--sf-heading-scale)))}}')
(dist/'assets'/'custom.css').write_text(theme_css+responsive.site_css(records),encoding='utf-8')
# For installed theme packages, Jinja loads only the trusted included template.
professional_template=(theme_dir/'pro.html').exists()
if professional_template and site['theme'] not in theme_pro.THEMES:
shutil.copy2(theme_dir/'theme.css',dist/'assets'/'theme-overrides.css')
template=ENV.get_template(site['theme']+'/site.html') if site['theme'] in THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve() else Environment(loader=FileSystemLoader(str(theme_dir)),autoescape=True).get_template('site.html')
orig=urlparse(settings.get('origin') or site['domain'])
origin_host=orig.hostname
local_records=[dict(e) for e in records]
remote_cache={}
try:
with httpx.Client(timeout=httpx.Timeout(10,connect=4),trust_env=False,headers={'User-Agent':'SiteForgeAssetMirror/0.1'}) as client:
for e in local_records:
e['body']=normalize_local_images(rewrite_internal_links(e['body'],origin_host,route_set))
if mirror:
e['body']=mirror_images(e['body'],dist,origin_host,client,remote_cache,warnings)
if e['cover']:
e['cover']=mirror_image_url(e['cover'],dist,origin_host,client,remote_cache,warnings)
if mirror and site['logo']:
site['logo']=mirror_image_url(site['logo'],dist,origin_host,client,remote_cache,warnings)
except Exception as exc:
warnings.append('资源镜像操作异常: '+str(exc))
# Derive brand and category landing pages from actual content, not fabricated business data.
# Existing imported URLs always take priority; derived routes never rewrite source records.
derived=[]
product_records=[x for x in local_records if x.get('type') in ('product','vehicle')]
for cat in theme_pro.categories(product_records):
path='/categories/'+theme_pro.slug(cat)+'/'
if path not in route_set:
derived.append({'id':'derived-category','route':path,'title':cat,'category':cat,'type':'page',
'summary':'分类产品与相关信息','body':'','cover':'','meta_json':'{}','seo_title':'','seo_description':'','canonical':'','robots':'index,follow'})
route_set.add(path)
for brand in theme_pro.brands(local_records):
if brand['url'] not in route_set:
derived.append({'id':'derived-brand','route':brand['url'],'title':brand['name'],'type':'brand',
'summary':'相关品牌及产品','body':'','cover':'','meta_json':'{}','seo_title':'','seo_description':'','canonical':'','robots':'index,follow'})
route_set.add(brand['url'])
# Detail pages, including custom-type documents.
totals=0
def render_one(page,mode='detail',listing_items=None):
nonlocal totals
page=page or {'title':site['name'],'route':'/','summary':site['tagline'],'body':'','cover':'','seo_title':'','seo_description':'','robots':'index,follow','canonical':'','type':'page','meta_json':'{}'}
meta=json.loads(page.get('meta_json') or '{}')
if not isinstance(meta,dict):meta={}
defined_blocks=meta.get('blocks',[])
use_blueprint=isinstance(defined_blocks,list) and len(defined_blocks)>0
blocks_html=blueprints.render_blocks(defined_blocks,site,page,local_records) if use_blueprint else ''
# Starter/scaffold preset blocks are layout defaults, not additional content;
# the full theme already renders the corresponding page family. Keep edited
# blocks, even on starter pages, so the editor remains effective.
if professional_template and use_blueprint and meta.get('layout_mode')!='replace' and page.get('source') in ('starter','site-bootstrap','scaffold'):
kind=theme_pro.infer_kind(page,mode)
try:
def signature(rows):
return [{k:v for k,v in row.items() if k!='id'} for row in rows]
# Starter layouts may belong to any previously selected theme.
is_default=any(signature(defined_blocks)==signature(blueprints.theme_blocks(t,kind)) for t in theme_pro.THEMES)
if not is_default and page.get('source')=='site-bootstrap' and kind=='listing':
# Bootstrap customizes collection title/source only; native listing
# already renders that content without the duplicate block.
is_default=all(b.get('type') in ('collection','spacer') for b in defined_blocks)
if is_default:blocks_html=''
except (ValueError,TypeError,KeyError):pass
canon=page.get('canonical') or public_url(site,page['route'])
if professional_template:
# Version-resolved trusted templates; uploaded themes cannot execute arbitrary code.
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
html=theme_pro.render(site['theme'],site,page,local_records,mode,listing_items,blocks_html)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,page,local_records,mode,listing_items)
ctx.update(custom_blocks=Markup(blocks_html),has_custom=bool(blocks_html),noindex=(ctx['kind']=='notfound'),theme_overrides=True,designer_replace=meta.get('layout_mode')=='replace')
html=env.get_template('pro.html').render(**ctx)
else:
html=template.render(site=site,page=page,mode=mode,content=Markup(page.get('body') or ''),
blocks=Markup(blocks_html),blueprint_mode=use_blueprint,entries=local_records,listing_items=listing_items or [],
type_names=TYPE_NAMES,nav=settings.get('nav') or [e for e in local_records if e['type']=='page' and e['route'] in ('/','/about/','/about-us/','/contact/','/contact-us/','/services/','/products/')],
link=link, asset=lambda x: x.lstrip('/') if x.startswith('/') else x, canonical=canon, seo_title=page.get('seo_title') or page['title'],
seo_description=page.get('seo_description') or page.get('summary') or site['tagline'])
html=static_site.shell(html,site,page,local_records,settings.get('nav') or [])
target=output_path(dist,page['route'])
if target.exists():raise ValueError('多个路由写入同一文件: '+page['route'])
target.parent.mkdir(parents=True,exist_ok=True)
target.write_text(html,encoding='utf-8')
totals+=1
for e in local_records:render_one(e,'home' if e['route']=='/' else 'detail')
for e in derived:render_one(e,'detail')
if '/' not in routes:render_one(None,'home')
for typ in sorted({e['type'] for e in local_records if e['type']!='page'} | {'product'}):
list_route='/'+typ+'/'
if list_route in route_set:continue
itemlist=[e for e in local_records if e['type']==typ]
render_one({'type':'page','route':list_route,'title':TYPE_NAMES.get(typ,typ)+'展示','summary':'Explore our '+typ,'meta_json':'{}'},'listing',itemlist)
route_set.add(list_route)
notfound={'title':'404 · 页面不存在','route':'/404.html','summary':'','body':'','cover':'','meta_json':'{}','robots':'noindex,follow'}
if professional_template:
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
missing_html=theme_pro.render(site['theme'],site,notfound,local_records,'notfound')
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,notfound,local_records,'notfound')
ctx.update(custom_blocks=Markup(''),has_custom=False,noindex=True,theme_overrides=True)
missing_html=env.get_template('pro.html').render(**ctx)
else:
missing_html=template.render(site=site,page=notfound,mode='notfound',content='',blocks='',blueprint_mode=False,entries=local_records,listing_items=[],type_names=TYPE_NAMES,nav=[],link=link,seo_title='404',seo_description='Page not found',canonical='')
(dist/'404.html').write_text(missing_html,encoding='utf-8')
# A complete static search page is packaged for every theme, not a live API.
static_site.write_assets(dist,local_records)
if '/search/' not in route_set:
search_file=dist/'search'/'index.html'
search_file.parent.mkdir(parents=True,exist_ok=True)
search_entry={'title':'站内搜索','route':'/search/','type':'page','summary':'搜索本站内容','body':'','meta_json':'{}','robots':'noindex,follow'}
if professional_template:
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
search_html=theme_pro.render(site['theme'],site,search_entry,local_records)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,search_entry,local_records)
ctx.update(custom_blocks=Markup(''),has_custom=False,noindex=True,theme_overrides=True)
search_html=env.get_template('pro.html').render(**ctx)
else:
search_html=static_site.search_page(site)
search_file.write_text(search_html,encoding='utf-8')
route_set.add('/search/')
noindex_routes={e['route'] for e in records if 'noindex' in (e['robots'] or '').lower()} | {'/search/'}
canonical_routes=sorted([x for x in route_set if not x.startswith('/404') and x not in noindex_routes])
if site['domain'].startswith(('https://','http://')):
urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc></url>' for r in canonical_routes)
sitemap='<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+urls+'</urlset>'
(dist/'sitemap.xml').write_text(sitemap,encoding='utf-8')
(dist/'robots.txt').write_text('User-agent: *\nAllow: /\n'+(f'Sitemap: {site["domain"].rstrip("/")}/sitemap.xml\n' if site['domain'].startswith('http') else ''),encoding='utf-8')
rlist=[]
for r in redirects:
old,new=route_path(r['old_path']),r['new_path']
if not (new.startswith('/') or new.startswith('https://')):raise ValueError('非法重定向目标')
rlist.append((old,new,int(r['status_code'])))
emit_nginx(dist,rlist,slashless)
(dist/'.htaccess').write_text('Options -MultiViews\nRewriteEngine On\n'+''.join(f'RewriteRule ^{re.escape(old.lstrip("/"))}$ {new} [R={code},L,NE]\n' for old,new,code in rlist),encoding='utf-8')
# Evaluate packaged files; external src references are reported (not silently asserted migrated).
external=[];missing=[];broken_links=[];legacy_links=[];canonical_mismatches=[]
target_host=urlparse(site['domain']).hostname
for e in records:
c=urlparse(e['canonical'] or '')
if c.hostname and target_host and c.hostname != target_host:
canonical_mismatches.append(e['route'])
for f in dist.rglob('*.html'):
soup=BeautifulSoup(f.read_text(encoding='utf-8'),'html.parser')
for a in soup.select('a[href]'):
href=a.get('href','')
p=urlparse(href)
if p.scheme in ('http','https'):
if p.hostname==origin_host and p.path not in route_set and href not in legacy_links:
legacy_links.append(href)
elif href and href not in ('.','./','index.html') and not href.startswith(('#','mailto:','tel:','javascript:')):
candidate=p.path
if candidate and not candidate.startswith('/'):
candidate='/'+candidate
if candidate and candidate not in route_set and candidate not in ('/index.html',) and not (dist/candidate.lstrip('/')).is_file() and candidate not in broken_links:
broken_links.append(candidate)
for img in soup.select('img[src]'):
src=img.get('src','')
if src.startswith(('http://','https://')):
if src not in external:external.append(src)
else:
rel=src.lstrip('/')
if rel and not (dist/rel).is_file() and rel not in missing:missing.append(rel)
if external:warnings.append(f'{len(external)} 个外部图片 URL 尚未打包(可开启镜像重建)')
if broken_links:warnings.append(f'{len(broken_links)} 个内部链接无对应页面或文件')
if legacy_links:warnings.append(f'{len(legacy_links)} 个指向源站但未能映射的链接,需人工核验')
if canonical_mismatches:warnings.append(f'{len(canonical_mismatches)} 个页面 canonical 域名与新站域名不一致')
if missing:warnings.append(f'{len(missing)} 个本地引用图片缺失')
if slashless:warnings.append(f'{len(slashless)} 个无后缀、无尾斜杠 URL 需要按照生成的 Nginx 规则部署')
report={'site':site['name'],'version':bid,'pages':totals,'theme':site['theme'],'theme_version':theme_pin['version'] if theme_pin else theme_store.latest(site['theme']),
'warnings':warnings,'errors':issues,'external_assets':external[:200],
'missing_local_images':missing[:200],'broken_links':broken_links[:200],'legacy_links':legacy_links[:200],'canonical_mismatches':canonical_mismatches[:200],'slashless_routes':slashless,
'redirects':len(redirects),'mirrored_images':len(remote_cache),
'quality_status':'warning' if warnings else 'passed','blueprint_blocks':sum(len(json.loads(x.get('meta_json') or '{}').get('blocks',[])) for x in local_records)}
# Separate static quality from readiness for commercial hand-off.
from . import commercial_readiness
handoff=commercial_readiness.inspect(dist)
report['commercial_readiness']={'ready_for_review':handoff['ready_for_review'],
'commercially_certified':False,'blockers':len(handoff['blockers']),
'warnings':len(handoff['warnings'])}
(dist/'commercial-readiness.json').write_text(json.dumps(handoff,ensure_ascii=False,indent=2),encoding='utf-8')
(dist/'siteforge-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
archive_dir=db.DATA/'archives';archive_dir.mkdir(parents=True,exist_ok=True)
archive=archive_dir/(site_id+'-'+bid+'.zip')
with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=6) as z:
for file in dist.rglob('*'):
if file.is_file():z.write(file,file.relative_to(dist))
db.insert('INSERT INTO builds (id,site_id,created_at,status,total_pages,report_json,zip_path) VALUES (?,?,?,?,?,?,?)',
(bid,site_id,db.now(),'complete',totals,json.dumps(report,ensure_ascii=False),str(archive)))
return report
+48
View File
@@ -0,0 +1,48 @@
"""Commercial-delivery readiness report, separate from technical/SEO pass.
It does NOT certify a site's business claims, copyright clearance, legal compliance,
WCAG conformance or any real-world server/SEO outcome. It reports missing items.
"""
from __future__ import annotations
from pathlib import Path
from bs4 import BeautifulSoup
import json
REQUIRED=('index.html','about/index.html','contact/index.html','products/index.html','brands/index.html','news/index.html','cases/index.html','404.html')
DEMO_MARKERS=('example.com','example.test','这是可替换的','虚构示例','演示内容','演示产品','此为 SiteForge','请在后台配置')
def inspect(dist:Path)->dict:
dist=Path(dist)
blockers=[];warnings=[];metrics={}
for f in REQUIRED:
if not (dist/f).is_file():blockers.append({'code':'page_missing','path':f,'message':'缺少标准栏目页面'})
homepage=dist/'index.html'
if homepage.is_file():
soup=BeautifulSoup(homepage.read_text(encoding='utf-8'),'html.parser')
logo=soup.select_one('header img.pro-brand-logo')
if not logo:warnings.append({'code':'brand_logo','message':'尚未替换成企业正式 Logo'})
if not soup.find('meta',attrs={'name':'description'}):blockers.append({'code':'seo_description','message':'首页缺少 SEO 描述标签'})
if len(soup.select('h1'))!=1:blockers.append({'code':'home_h1','message':'首页 H1 数量不符合预期'})
else:soup=None
htmls=list(dist.rglob('*.html'))
texts=' '.join(p.read_text(encoding='utf-8',errors='replace') for p in htmls)
metrics['html_pages']=len(htmls)
metrics['artwork_fallbacks']=sum(x.count('class="pro-art ') for x in [texts])
metrics['pages_with_body']=sum(bool(BeautifulSoup(p.read_text(encoding='utf-8'),'html.parser').select_one('.pro-rich')) for p in htmls)
if metrics['artwork_fallbacks']:
warnings.append({'code':'placeholder_images','count':metrics['artwork_fallbacks'],'message':'部分页面仍使用示意图,正式发布前请替换为真实且有使用权的图片'})
if any(marker in texts for marker in DEMO_MARKERS):
warnings.append({'code':'demo_content','message':'检测到演示或默认文案,正式发布前请逐页核对'})
if not (dist/'assets'/'commercial.css').is_file():
warnings.append({'code':'commercial_style','message':'当前构建未包含商业视觉样式(可能是历史主题版本)'})
contact=dist/'contact/index.html'
if contact.is_file():
con=BeautifulSoup(contact.read_text(encoding='utf-8'),'html.parser')
if not con.select_one('form.pro-inquiry') and not con.select_one('a[href^="mailto:"]'):
blockers.append({'code':'contact_method','message':'联系页既无已配置表单,也无有效电子邮箱入口'})
elif not con.select_one('form.pro-inquiry'):
warnings.append({'code':'form_not_configured','message':'尚未配置 HTTPS 询盘接收服务;目前仅提供邮件联系'})
if not any((dist/p).is_file() for p in ('privacy/index.html','privacy-policy/index.html')):
warnings.append({'code':'legal_pages','message':'请根据目标国家/地区法律审查是否需要隐私政策与 Cookie 声明'})
return {'ready_for_review':not blockers,'commercially_certified':False,'blockers':blockers,'warnings':warnings,
'metrics':metrics,'note':'仅为本地结构化交付清单,不等于合法合规、内容准确、无障碍、性能或生产上线认证'}
+109
View File
@@ -0,0 +1,109 @@
"""Shared site/route/content rules, with content sanitization."""
import ipaddress
import json
import re
import socket
import uuid
from urllib.parse import urlparse, unquote
import bleach
from . import db
TYPES = ('page','product','post','service','case','brand','vehicle','other')
THEMES = ('automotive','industrial','corporate','minimal')
ALLOWED_TAGS = ['p','br','b','i','u','em','strong','h1','h2','h3','h4','h5','h6','ul','ol','li','blockquote','pre','code','a','img','figure','figcaption','table','thead','tbody','tr','th','td','hr','div','span']
ALLOWED_ATTRS = {'a':['href','title','target','rel'], 'img':['src','alt','title','loading','width','height'], 'td':['colspan','rowspan'], 'th':['colspan','rowspan']}
def uid():
return uuid.uuid4().hex[:12]
def sanitize(html):
return bleach.clean(html or '', tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, protocols=['http','https','mailto'], strip=True)
def route_path(value, default='/'):
s = (value or default).strip()
if s.startswith(('http://','https://')):
s = urlparse(s).path or '/'
# Query strings cannot be represented as ordinary static file routes.
s = s.split('?',1)[0].split('#',1)[0]
if not s.startswith('/'):
s = '/' + s
if '\\' in s or '\x00' in s or any(part in ('.','..') for part in unquote(s).split('/')):
raise ValueError('不允许路径穿越或反斜杠')
if s.startswith(('/api/','/preview/','/__siteforge/')):
raise ValueError('该路径已保留给系统')
if len(s)>500:
raise ValueError('URL 路径过长')
return s
def slug_of(title):
s = re.sub(r'[^\w\-]+','-', title.strip().lower(), flags=re.UNICODE).strip('-')
return s[:70] or uid()
def clean_hex(s):
if not re.fullmatch(r'#[0-9a-fA-F]{6}', s or ''):
raise ValueError('品牌色需要 #RRGGBB 格式')
return s
def ensure_public_url(url):
parsed=urlparse(url)
if parsed.scheme not in ('https','http') or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('仅支持公开的 HTTP/HTTPS URL')
hostname=parsed.hostname.lower()
if hostname in ('localhost','localhost.localdomain') or hostname.endswith(('.local','.internal')):
raise ValueError('禁止访问内网域名')
try:
addrs=socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme=='https' else 80), type=socket.SOCK_STREAM)
for info in addrs:
ip=ipaddress.ip_address(info[4][0])
if not ip.is_global:
raise ValueError('不允许访问内网、回环或保留 IP 地址')
except socket.gaierror:
raise ValueError('无法解析域名')
return url
def save_entry(site_id, data, existing_id=None, source='manual', replace_route=False):
if not db.one('SELECT id FROM sites WHERE id=?',(site_id,)):
raise ValueError('网站不存在')
kind=str(data.get('type') or 'page').lower()
if kind not in TYPES:
kind='other'
title=str(data.get('title') or '').strip()
if not title:
raise ValueError('内容标题不能为空')
route=route_path(data.get('route'), '/' if kind=='page' and title.lower() in ('home','首页') else f'/{kind}/{slug_of(title)}/')
if route=='/' and kind!='page':
raise ValueError('首页必须是普通页面类型')
old=db.one('SELECT * FROM entries WHERE site_id=? AND route=?',(site_id,route))
if old and old['id'] != existing_id:
if replace_route:
existing_id=old['id']
else:
raise ValueError(f'URL 冲突:{route}')
eid=existing_id or uid()
prior=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid)) if existing_id else None
if existing_id and not prior:
raise ValueError('内容不存在')
meta=data.get('meta', {})
if isinstance(meta,str):
try: meta=json.loads(meta)
except json.JSONDecodeError: meta={}
if not isinstance(meta,dict): meta={}
if 'blocks' in meta:
from .blueprints import validate_blocks
meta['blocks']=validate_blocks(meta['blocks'])
vals=(kind,title,route,slug_of(title),str(data.get('summary') or ''),sanitize(str(data.get('body') or '')),
str(data.get('cover') or ''),str(data.get('seo_title') or ''),str(data.get('seo_description') or ''),
str(data.get('canonical') or ''),str(data.get('robots') or 'index,follow'),json.dumps(meta,ensure_ascii=False),
str(data.get('status') or 'published'))
if prior:
db.execute('''UPDATE entries SET type=?,title=?,route=?,slug=?,summary=?,body=?,cover=?,seo_title=?,seo_description=?,canonical=?,robots=?,meta_json=?,status=?,updated_at=? WHERE site_id=? AND id=?''',(*vals,db.now(),site_id,eid))
else:
db.insert('''INSERT INTO entries (type,title,route,slug,summary,body,cover,seo_title,seo_description,canonical,robots,meta_json,status,created_at,updated_at,site_id,id,source)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',(*vals,db.now(),db.now(),site_id,eid,source))
return eid
def entry_dict(e):
v=dict(e)
try:v['meta']=json.loads(v.pop('meta_json'))
except (ValueError,TypeError):v['meta']={}
return v
+93
View File
@@ -0,0 +1,93 @@
"""Local SQLite data store for SiteForge MVP. One database, hard site_id isolation."""
import os
import sqlite3
import json
from pathlib import Path
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parent.parent
DATA = Path(os.getenv('SITEFORGE_DATA_DIR', str(ROOT / 'storage'))).resolve()
DB_FILE = DATA / 'siteforge.sqlite3'
def now():
return datetime.now(timezone.utc).isoformat(timespec='seconds')
def connect():
DATA.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(DB_FILE)
c.row_factory = sqlite3.Row
c.execute('PRAGMA foreign_keys = ON')
c.execute('PRAGMA busy_timeout=5000')
return c
def init():
with connect() as c:
c.executescript('''
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS sites (
id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT NOT NULL DEFAULT '',
tagline TEXT NOT NULL DEFAULT '', theme TEXT NOT NULL DEFAULT 'corporate',
accent TEXT NOT NULL DEFAULT '#2563eb', logo TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'new',
settings_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
type TEXT NOT NULL, title TEXT NOT NULL, route TEXT NOT NULL, slug TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '',
cover TEXT NOT NULL DEFAULT '', seo_title TEXT NOT NULL DEFAULT '',
seo_description TEXT NOT NULL DEFAULT '', canonical TEXT NOT NULL DEFAULT '',
robots TEXT NOT NULL DEFAULT 'index,follow',
meta_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'published',
source TEXT NOT NULL DEFAULT 'manual', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(site_id, route)
);
CREATE INDEX IF NOT EXISTS idx_entries_site ON entries(site_id, type, status);
CREATE TABLE IF NOT EXISTS redirects (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
old_path TEXT NOT NULL, new_path TEXT NOT NULL, status_code INTEGER NOT NULL DEFAULT 301,
UNIQUE(site_id, old_path)
);
CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
created_at TEXT NOT NULL, status TEXT NOT NULL, total_pages INTEGER NOT NULL DEFAULT 0,
report_json TEXT NOT NULL DEFAULT '{}', zip_path TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_builds_site ON builds(site_id, created_at);
CREATE TABLE IF NOT EXISTS presets (
id TEXT PRIMARY KEY,name TEXT NOT NULL,theme TEXT NOT NULL,
settings_json TEXT NOT NULL,home_blocks_json TEXT NOT NULL,created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS content_schemas (
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
content_type TEXT NOT NULL, fields_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY(site_id,content_type)
);
CREATE TABLE IF NOT EXISTS imports (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
created_at TEXT NOT NULL, source TEXT NOT NULL,
result_json TEXT NOT NULL DEFAULT '{}'
);
''')
def rows(sql, params=()):
with connect() as c:
return [dict(r) for r in c.execute(sql, params).fetchall()]
def one(sql, params=()):
with connect() as c:
r=c.execute(sql, params).fetchone()
return dict(r) if r else None
def execute(sql, params=()):
with connect() as c:
cur = c.execute(sql, params)
return cur.rowcount
def insert(sql, params=()):
with connect() as c:
c.execute(sql, params)
if __name__ == '__main__':
init()
+338
View File
@@ -0,0 +1,338 @@
"""SiteForge team accounts, RBAC, content history, audit, jobs, migration baseline and release governance.
This module is deliberately database-backed and independent of the frontend. It
extends the existing site factory without allowing editors to bypass permissions.
"""
import csv
import hashlib
import hmac
import io
import json
import os
import secrets
import sqlite3
import uuid
from pathlib import Path
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
from fastapi.responses import Response
from pydantic import BaseModel, Field
from . import db
from .core import route_path
router = APIRouter(prefix='/api/enterprise', tags=['Enterprise'])
ROLES = ('owner','editor','reviewer','viewer')
ACCESS = {'viewer':0, 'reviewer':1, 'editor':2, 'owner':3, 'superadmin':4}
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS team_users (
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
display_name TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS team_memberships (
user_id TEXT NOT NULL REFERENCES team_users(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
role TEXT NOT NULL, PRIMARY KEY(user_id,site_id));
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY, actor TEXT NOT NULL, site_id TEXT, operation TEXT NOT NULL,
path TEXT NOT NULL, status INTEGER NOT NULL, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_events(created_at);
CREATE TABLE IF NOT EXISTS content_revisions (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
actor TEXT NOT NULL, action TEXT NOT NULL, snapshot_json TEXT NOT NULL,
created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_revision_entry ON content_revisions(site_id,entry_id,created_at);
CREATE TABLE IF NOT EXISTS build_jobs (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
kind TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '', result_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT,
retry_at TEXT);
CREATE INDEX IF NOT EXISTS idx_build_jobs_state ON build_jobs(state,created_at);
CREATE TABLE IF NOT EXISTS migration_baseline (
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
route TEXT NOT NULL, seo_title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '', canonical TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 200, PRIMARY KEY(site_id,route));
CREATE TABLE IF NOT EXISTS release_history (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
build_id TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL,
created_at TEXT NOT NULL);
''')
# Lightweight forward migration for V2-V5 databases created before
# delayed job retries were introduced.
columns={row['name'] for row in c.execute('PRAGMA table_info(build_jobs)').fetchall()}
if 'retry_at' not in columns:
c.execute('ALTER TABLE build_jobs ADD COLUMN retry_at TEXT')
def uid(): return uuid.uuid4().hex[:16]
def hash_password(password, salt=None):
if len(password)<12: raise ValueError('密码至少12位')
salt=salt or secrets.token_bytes(16)
digest=hashlib.scrypt(password.encode(),salt=salt,n=2**14,r=8,p=1,dklen=32)
return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}'
def check_password(password, encoded):
try:
algorithm,n,r,p,salt,expected=encoded.split('$')
if algorithm!='scrypt':return False
result=hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=int(n),r=int(r),p=int(p),dklen=32)
return hmac.compare_digest(result,bytes.fromhex(expected))
except (ValueError,TypeError,MemoryError):return False
def actor(request):
return request.state.actor if hasattr(request.state,'actor') else 'system'
def level(request, site_id=None):
user=getattr(request.state,'account_id',None)
if not user: return ACCESS['superadmin'] if getattr(request.state,'is_superadmin',False) else -1
if not site_id:return ACCESS['viewer']
item=db.one('SELECT role FROM team_memberships WHERE site_id=? AND user_id=?',(site_id,user))
return ACCESS.get(item['role'],-1) if item else -1
def require(request, site_id=None, role='viewer'):
if level(request,site_id)<ACCESS[role]:raise HTTPException(403,'没有访问权限')
def user_by_name(username):
return db.one('SELECT * FROM team_users WHERE username=?',(username,))
def account_from_session(session):
if session.get('sub')=='admin':return {'actor':'admin','id':None,'superadmin':True}
if session.get('sub')!='user':return None
account=db.one('SELECT id,username,disabled FROM team_users WHERE id=?',(session.get('user_id'),))
if not account or account['disabled']:return None
return {'actor':account['username'],'id':account['id'],'superadmin':False}
def log_event(actor_name,path,status,method,site_id=None):
try:
db.insert('INSERT INTO audit_events VALUES (?,?,?,?,?,?,?)',(uid(),actor_name,site_id,method,path,status,db.now()))
except sqlite3.Error:pass
def save_revision(site_id,entry_id,actor_name,action):
row=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
if row:
db.insert('INSERT INTO content_revisions VALUES (?,?,?,?,?,?,?)',
(uid(),site_id,entry_id,actor_name,action,json.dumps(row,ensure_ascii=False),db.now()))
class UserCreate(BaseModel):
username:str=Field(min_length=3,max_length=70,pattern=r'^[a-zA-Z0-9._-]+$')
display_name:str=Field(min_length=1,max_length=90)
password:str=Field(min_length=12,max_length=200)
class AssignRole(BaseModel):
user_id:str
role:str
@router.get('/team/users')
def list_users(request:Request):
require(request,role='owner')
if not request.state.is_superadmin:raise HTTPException(403)
return db.rows('SELECT id,username,display_name,disabled,created_at FROM team_users ORDER BY created_at DESC')
@router.post('/team/users',status_code=201)
def create_user(request:Request,payload:UserCreate):
if not request.state.is_superadmin:raise HTTPException(403)
try:
person=uid()
db.insert('INSERT INTO team_users(id,username,password_hash,display_name,created_at) VALUES (?,?,?,?,?)',
(person,payload.username,hash_password(payload.password),payload.display_name,db.now()))
except sqlite3.IntegrityError:raise HTTPException(409,'用户名已存在')
return {'id':person,'username':payload.username,'display_name':payload.display_name}
@router.patch('/team/users/{user_id}/disable')
def disable_user(request:Request,user_id:str,payload:dict=Body(default_factory=dict)):
if not request.state.is_superadmin:raise HTTPException(403)
if not db.one('SELECT id FROM team_users WHERE id=?',(user_id,)):raise HTTPException(404)
db.execute('UPDATE team_users SET disabled=? WHERE id=?',(int(bool(payload.get('disabled',True))),user_id))
return {'ok':True}
@router.put('/sites/{site_id}/members')
def grant_site(request:Request,site_id:str,payload:AssignRole):
require(request,site_id,'owner')
if payload.role not in ROLES:raise HTTPException(400,'不支持该角色')
if not db.one('SELECT id FROM team_users WHERE id=?',(payload.user_id,)):raise HTTPException(404,'用户不存在')
db.insert('''INSERT INTO team_memberships(user_id,site_id,role) VALUES (?,?,?)
ON CONFLICT(user_id,site_id) DO UPDATE SET role=excluded.role''',(payload.user_id,site_id,payload.role))
return {'assigned':True}
@router.get('/sites/{site_id}/members')
def site_members(request:Request,site_id:str):
require(request,site_id,'owner')
return db.rows('''SELECT u.id,u.username,u.display_name,m.role FROM team_memberships m
JOIN team_users u ON u.id=m.user_id WHERE m.site_id=?''',(site_id,))
@router.get('/audit')
def audits(request:Request,site_id:str='',limit:int=100):
if site_id:require(request,site_id,'owner')
elif not request.state.is_superadmin:raise HTTPException(403)
limit=min(max(limit,1),250)
if site_id:return db.rows('SELECT * FROM audit_events WHERE site_id=? ORDER BY created_at DESC,id DESC LIMIT ?',(site_id,limit))
return db.rows('SELECT * FROM audit_events ORDER BY created_at DESC,id DESC LIMIT ?',(limit,))
@router.get('/sites/{site_id}/entries/{entry_id}/revisions')
def revisions(request:Request,site_id:str,entry_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT id,actor,action,created_at FROM content_revisions WHERE site_id=? AND entry_id=? ORDER BY created_at DESC,id DESC LIMIT 100',(site_id,entry_id))
@router.get('/sites/{site_id}/revisions/{revision_id}')
def revision_detail(request:Request,site_id:str,revision_id:str):
require(request,site_id,'viewer')
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
if not row:raise HTTPException(404)
return {'id':revision_id,'entry':json.loads(row['snapshot_json']),'actor':row['actor']}
@router.post('/sites/{site_id}/revisions/{revision_id}/restore')
def revision_restore(request:Request,site_id:str,revision_id:str):
require(request,site_id,'editor')
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
if not row:raise HTTPException(404)
payload=json.loads(row['snapshot_json'])
entry_id=payload['id']
save_revision(site_id,entry_id,actor(request),'pre_restore')
columns=['type','title','route','slug','summary','body','cover','seo_title','seo_description','canonical','robots','meta_json','status']
with db.connect() as c:
c.execute(f'''UPDATE entries SET {','.join(f'{col}=?' for col in columns)},updated_at=? WHERE site_id=? AND id=?''',
[payload[k] for k in columns]+[db.now(),site_id,entry_id])
if c.total_changes==0:raise HTTPException(404,'原页面已删除,请先创建')
return {'restored':True,'entry_id':entry_id}
@router.post('/sites/{site_id}/jobs',status_code=202)
def enqueue(request:Request,site_id:str,payload:dict=Body(default_factory=dict)):
require(request,site_id,'editor')
kind=payload.get('kind','build')
if kind!='build':raise HTTPException(400,'当前任务队列支持构建任务')
row_id=uid()
db.insert('INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at) VALUES (?,?,?,?,?,?)',
(row_id,site_id,kind,'queued',actor(request),db.now()))
return {'id':row_id,'state':'queued'}
@router.get('/sites/{site_id}/jobs')
def jobs(request:Request,site_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT * FROM build_jobs WHERE site_id=? ORDER BY created_at DESC LIMIT 50',(site_id,))
class BaselineItem(BaseModel):
route:str
seo_title:str=''
description:str=''
canonical:str=''
status:int=200
@router.put('/sites/{site_id}/baseline')
def set_baseline(request:Request,site_id:str,payload:list[BaselineItem]):
require(request,site_id,'editor')
if len(payload)>20000:raise HTTPException(413,'URL 基线最多20000条')
routes=set();rows=[]
for item in payload:
route=route_path(item.route)
if route in routes:raise HTTPException(400,'存在重复 URL:'+route)
routes.add(route)
rows.append((site_id,route,item.seo_title,item.description,item.canonical,item.status))
with db.connect() as c:
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)',rows)
return {'saved':len(rows)}
@router.post('/sites/{site_id}/baseline/snapshot')
def baseline_snapshot(request:Request,site_id:str):
require(request,site_id,'editor')
entries=db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,))
with db.connect() as c:
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)',
[(site_id,x['route'],x['seo_title'],x['seo_description'],x['canonical'],200) for x in entries])
return {'captured':len(entries)}
def diff_baseline(site_id):
baseline=db.rows('SELECT * FROM migration_baseline WHERE site_id=?',(site_id,))
entries={x['route']:x for x in db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,))}
redirects={x['old_path']:x['new_path'] for x in db.rows('SELECT old_path,new_path FROM redirects WHERE site_id=?',(site_id,))}
problems=[]
for old in baseline:
new=entries.get(old['route'])
if not new:
problems.append({'route':old['route'],'field':'url','severity':'error','from':'200','to':f'redirect→{redirects[old["route"]]}' if old['route'] in redirects else 'missing'})
continue
for oldcol,newcol in [('seo_title','seo_title'),('description','seo_description'),('canonical','canonical')]:
if old[oldcol] and old[oldcol]!=new[newcol]:
problems.append({'route':old['route'],'field':oldcol,'severity':'warning','from':old[oldcol],'to':new[newcol]})
return {'baseline_count':len(baseline),'current_count':len(entries),'issues':problems,
'errors':sum(x['severity']=='error' for x in problems),'warnings':sum(x['severity']=='warning' for x in problems),
'passed':not any(x['severity']=='error' for x in problems)}
@router.get('/sites/{site_id}/baseline/report')
def baseline_report(request:Request,site_id:str):
require(request,site_id,'viewer')
return diff_baseline(site_id)
@router.get('/sites/{site_id}/baseline/export.csv')
def baseline_export(request:Request,site_id:str):
require(request,site_id,'viewer')
records=db.rows('SELECT route,seo_title,description,canonical,status FROM migration_baseline WHERE site_id=? ORDER BY route',(site_id,))
dest=io.StringIO(); writer=csv.DictWriter(dest,fieldnames=['route','seo_title','description','canonical','status']);writer.writeheader();writer.writerows(records)
return Response('\ufeff'+dest.getvalue(),media_type='text/csv',headers={'Content-Disposition':'attachment; filename=siteforge-seo-baseline.csv'})
@router.get('/sites/{site_id}/releases')
def release_list(request:Request,site_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT * FROM release_history WHERE site_id=? ORDER BY created_at DESC',(site_id,))
@router.post('/sites/{site_id}/releases/{build_id}/rollback')
def release_rollback(request:Request,site_id:str,build_id:str):
require(request,site_id,'owner')
from .main import publish_local
response=publish_local(site_id,build_id)
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(uid(),site_id,build_id,actor(request),'rollback',db.now()))
return response
# Design tokens are site-specific and independent of the reusable theme package.
DESIGN_DEFAULTS = {'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,'container_width':1200,'button_style':'rounded','header_style':'standard'}
@router.get('/sites/{site_id}/design-tokens')
def get_tokens(request:Request,site_id:str):
require(request,site_id,'viewer')
row=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
if not row:raise HTTPException(404)
settings=json.loads(row['settings_json'])
return {**DESIGN_DEFAULTS,**settings.get('design_tokens',{})}
@router.put('/sites/{site_id}/design-tokens')
def save_tokens(request:Request,site_id:str,payload:dict=Body(...)):
require(request,site_id,'editor')
tokens={**DESIGN_DEFAULTS}
if set(payload)-set(tokens):raise HTTPException(400,'存在不支持的设计变量')
tokens.update(payload)
if tokens['font'] not in ('system','serif','modern'):raise HTTPException(400,'不支持字体')
if tokens['button_style'] not in ('rounded','square','pill'):raise HTTPException(400,'不支持按钮形状')
if tokens['header_style'] not in ('standard','compact'):raise HTTPException(400,'不支持导航布局')
for field,lo,hi in [('heading_scale',0.75,1.5),('radius',0,30),('section_spacing',32,140),('container_width',900,1600)]:
if not isinstance(tokens[field],(float,int)) or not lo<=tokens[field]<=hi:raise HTTPException(400,'设计变量超出范围: '+field)
record=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
if not record:raise HTTPException(404)
settings=json.loads(record['settings_json']);settings['design_tokens']=tokens
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return tokens
@router.get('/sites/{site_id}/builds/{build_id}/quality')
def quality_report(request:Request,site_id:str,build_id:str):
require(request,site_id,'viewer')
if not db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id)):raise HTTPException(404)
from .quality import report
return report(site_id,build_id)
@router.post('/sites/{site_id}/analyze')
def scan_source(request:Request,site_id:str,payload:dict=Body(...)):
require(request,site_id,'editor')
from .adapters import analyze
try:return analyze(str(payload.get('url','')))
except Exception as exc:raise HTTPException(400,str(exc)[:300])
@router.post('/sites/{site_id}/wordpress-rest')
def wordpress_rest(request:Request,site_id:str,payload:dict=Body(...)):
require(request,site_id,'editor')
from .adapters import import_wordpress_rest
try:return import_wordpress_rest(site_id,str(payload.get('origin','')),int(payload.get('max_items',150)),str(payload.get('on_conflict','skip')))
except Exception as exc:raise HTTPException(400,str(exc)[:300])
+334
View File
@@ -0,0 +1,334 @@
"""Factory V5 endpoints: themes, mappings, build comparisons and release readiness."""
import json,csv,io
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel,Field
from . import db,theme_store,assembly,enterprise,builder
router=APIRouter(prefix='/api/factory',tags=['Website Factory v2'])
def setup():
theme_store.setup();assembly.setup()
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS release_requests (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
build_id TEXT NOT NULL, requested_by TEXT NOT NULL,
reviewed_by TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT 'pending',
review_note TEXT NOT NULL DEFAULT '', requested_at TEXT NOT NULL,reviewed_at TEXT);
CREATE INDEX IF NOT EXISTS idx_release_request_site ON release_requests(site_id,build_id);
''')
@router.get('/themes')
def all_themes(request:Request):
return theme_store.catalog()
@router.post('/themes/install')
async def install_theme(request:Request,file:UploadFile=File(...)):
if not request.state.is_superadmin:raise HTTPException(403,'仅系统管理员可安装主题')
try:return theme_store.install(await file.read(8_000_001))
except (ValueError,UnicodeError,KeyError,json.JSONDecodeError) as exc:raise HTTPException(400,str(exc))
@router.get('/themes/{theme_id}/compatibility')
def theme_compatibility(request:Request,theme_id:str,version:str=''):
return theme_store.compatibility(theme_id,version or None)
@router.get('/themes/{theme_id}/versions')
def versions(request:Request,theme_id:str):
if theme_id in theme_store.BUILTIN:
archive=theme_store.BASE/theme_id/'versions'
historical=[x.name for x in archive.iterdir() if x.is_dir()] if archive.exists() else []
return [{'theme_id':theme_id,'version':v,'builtin':True} for v in [theme_store.latest(theme_id)]+sorted(historical,reverse=True)]
return db.rows('SELECT * FROM theme_versions WHERE theme_id=? ORDER BY installed_at DESC',(theme_id,))
@router.put('/sites/{site_id}/theme')
def choose_theme(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return theme_store.pin(site_id,str(payload.get('theme','')),payload.get('version') or None)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/theme')
def site_theme(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
site=db.one('SELECT theme FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
pin=theme_store.pinned(site_id)
return {'theme':site['theme'],'version':pin['version'] if pin else theme_store.latest(site['theme'])}
@router.get('/sites/{site_id}/relationships')
def preview_relationships(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
from . import relations
return relations.preview(site_id)
@router.post('/sites/{site_id}/relationships/assemble')
def assemble_relationships(request:Request,site_id:str):
enterprise.require(request,site_id,'editor')
from . import relations
try:return relations.commit(site_id)
except ValueError as exc:raise HTTPException(409,str(exc))
@router.get('/sites/{site_id}/mapping-profiles')
def profiles(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT * FROM mapping_profiles WHERE site_id=? ORDER BY updated_at DESC',(site_id,))
@router.post('/sites/{site_id}/mapping-profiles')
def save_profile(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
from .core import uid
name=str(payload.get('name','')).strip()[:100]
if not name:raise HTTPException(400,'请输入映射方案名称')
rules=payload.get('rules',{})
if not isinstance(rules,dict) or 'title' not in rules:raise HTTPException(400,'请设置标题字段映射')
kind=str(payload.get('content_type','product'))
id=uid()
db.insert('INSERT INTO mapping_profiles VALUES (?,?,?,?,?,?,?)',(id,site_id,name,kind,json.dumps(rules,ensure_ascii=False),db.now(),db.now()))
return {'id':id,'name':name,'rules':rules}
class MapRows(BaseModel):
rows:list[dict]=Field(max_length=1000)
rules:dict[str,str]=Field(default_factory=dict)
content_type:str='product'
prefix:str|None=None
on_conflict:str='skip'
dry_run:bool=True
@router.post('/sites/{site_id}/mapping/parse-file')
async def parse_mapping_file(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'viewer')
from .table_loader import parse_table
try:
decoded=parse_table(file.filename or '',await file.read(12_000_001))
return {**decoded,'suggested':assembly.suggest(decoded['columns'])}
except (ValueError,UnicodeDecodeError,json.JSONDecodeError) as exc:
raise HTTPException(400,str(exc))
@router.post('/sites/{site_id}/mapping/suggest')
def suggested_map(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'viewer')
columns=payload.get('columns',[])
if not isinstance(columns,list) or len(columns)>150:raise HTTPException(400,'字段数量过多')
return {'rules':assembly.suggest(columns)}
@router.post('/sites/{site_id}/mapping/run')
def map_rows(request:Request,site_id:str,payload:MapRows):
enterprise.require(request,site_id,'viewer' if payload.dry_run else 'editor')
try:
return assembly.dry_run(site_id,payload.rows,payload.rules,payload.content_type,payload.prefix) if payload.dry_run else assembly.commit(site_id,payload.rows,payload.rules,payload.content_type,payload.on_conflict,payload.prefix)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/migration/readiness')
def readiness(request:Request,site_id:str,build_id:str=''):
enterprise.require(request,site_id,'viewer')
site=db.one('SELECT id,mode FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
base=enterprise.diff_baseline(site_id)
builds=db.rows('SELECT * FROM builds WHERE site_id=? ORDER BY created_at DESC LIMIT 1',(site_id,))
latest=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id)) if build_id else (builds[0] if builds else None)
report=json.loads(latest['report_json']) if latest else None
gates=[]
if not latest:gates.append({'level':'block','message':'还没有完成静态网站构建'})
if site['mode']=='migration' and base['baseline_count']==0:gates.append({'level':'block','message':'迁移项目尚未导入原站 URL 基线'})
if site['mode']=='migration' and base['errors']:gates.append({'level':'block','message':f'仍有 {base["errors"]} 个旧站 URL 没有原路径页面'})
if base['warnings']:gates.append({'level':'review','message':f'{base["warnings"]} 项 SEO 差异待核查'})
if report:
for key in ('missing_local_images','broken_links'):
if report.get(key):gates.append({'level':'block','message':f'{key} 有 {len(report[key])} 项需处理'})
if report.get('external_assets'):gates.append({'level':'review','message':'仍存在未本地化的远程资源'})
if latest and site['mode']=='migration' and base['baseline_count']:
from . import migration_audit
detail=migration_audit.audit(site_id,latest['id'])
if detail['errors']:
gates.append({'level':'block','message':f'构建文件与原站基线存在 {detail["errors"]} 个阻断问题'})
if detail['warnings']:
gates.append({'level':'review','message':f'生成 HTML 存在 {detail["warnings"]} 项源站内容/SEO 差异'})
if detail.get('unreviewed_warnings'):
gates.append({'level':'block','message':f'还有 {detail["unreviewed_warnings"]} 项差异未经审核,暂不允许发布'})
return {'site_id':site_id,'build_id':latest['id'] if latest else None,'baseline':base,
'report':report,'gates':gates,'can_publish':not any(x['level']=='block' for x in gates),
'must_review':any(x['level']=='review' for x in gates)}
@router.get('/sites/{site_id}/assets/inventory')
def inventory(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
records=db.rows('SELECT route,cover,body,meta_json FROM entries WHERE site_id=?',(site_id,))
from bs4 import BeautifulSoup
assets={}
for row in records:
for url in [row['cover']]+[x.get('src') for x in BeautifulSoup(row['body'] or '','html.parser').select('img[src]')]:
if url:assets.setdefault(url,[]).append(row['route'])
legacy=db.DATA/'legacy'/site_id
media=db.DATA/'media'/site_id
existing=[str(p.relative_to(d)) for d in (legacy,media) if d.exists() for p in d.rglob('*') if p.is_file()]
return {'references':len(assets),'files':len(existing),'assets':[{'url':a,'used_by':routes} for a,routes in sorted(assets.items())][:5000]}
@router.post('/sites/{site_id}/bootstrap')
def bootstrap_site(request:Request,site_id:str):
"""Create full navigation/landing-page structure while preserving existing URLs.
Does not fabricate business descriptions or products; placeholder blocks stay
visibly editable and static empty collections clearly identify missing data.
"""
enterprise.require(request,site_id,'editor')
from . import blueprints
from .core import save_entry
site=db.one('SELECT theme,name FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
pages=[('about','关于我们','/about/','企业介绍与经营理念'),
('contact','联系我们','/contact/','业务咨询与联系方式'),
('listing','产品中心','/products/','按分类浏览产品', 'product'),
('listing','品牌中心','/brands/','了解我们的合作品牌','brand'),
('listing','新闻资讯','/news/','企业资讯与行业动态','post'),
('listing','项目案例','/cases/','精选客户项目','case'),
('standard','服务与解决方案','/services/','业务流程与服务能力'),
('standard','常见问题','/faq/','了解合作流程')]
created=[];skipped=[]
for page in pages:
kind,title,route,description=page[:4]
if db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route)):
skipped.append(route);continue
blocks=blueprints.theme_blocks(site['theme'],kind)
if kind=='listing' and len(page)>4:
for b in blocks:
if b['type']=='collection':b['source']=page[4];b['title']=title;b['limit']=36
if route=='/faq/':blocks=[blueprints.new_block('faq')]
save_entry(site_id,{'type':'page','title':title,'route':route,'summary':description,
'meta':{'page_template':kind,'blocks':blocks}},source='site-bootstrap')
created.append(route)
nav=[{'title':v,'route':r} for v,r in [('首页','/'),('产品中心','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于我们','/about/'),('联系我们','/contact/')]]
raw=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
settings=json.loads(raw['settings_json'] or '{}')
if not settings.get('nav'):
settings['nav']=nav
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'created':created,'skipped':skipped,'navigation':settings.get('nav'),
'message':'页面已建立;企业介绍、联系方式、产品等实际业务内容仍需补充。'}
@router.get('/sites/{site_id}/release-requests')
def requests_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT * FROM release_requests WHERE site_id=? ORDER BY requested_at DESC',(site_id,))
@router.post('/sites/{site_id}/release-requests')
def request_release(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
build_id=str(payload.get('build_id',''))
if not db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id)):
raise HTTPException(404,'构建版本不存在')
status=readiness(request,site_id,build_id)
if not status['can_publish']:
raise HTTPException(409,'迁移/质量门禁尚未通过,不允许提交发布审批')
from . import quality
qa=quality.report(site_id,build_id)
if not qa['passed']:raise HTTPException(409,f'构建质量检测包含 {qa["errors"]} 个错误')
from .core import uid
rid=uid();db.insert('INSERT INTO release_requests(id,site_id,build_id,requested_by,requested_at) VALUES (?,?,?,?,?)',
(rid,site_id,build_id,request.state.actor,db.now()))
return {'id':rid,'state':'pending','build_id':build_id,'review_required':True}
@router.post('/sites/{site_id}/release-requests/{request_id}/review')
def review_release(request:Request,site_id:str,request_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'reviewer')
item=db.one('SELECT * FROM release_requests WHERE id=? AND site_id=?',(request_id,site_id))
if not item:raise HTTPException(404)
if item['state']!='pending':raise HTTPException(409,'该请求已处理')
if item['requested_by']==request.state.actor:raise HTTPException(403,'审批人不能审核自己提交的发布申请')
state=payload.get('state')
if state not in ('approved','rejected'):raise HTTPException(400,'必须选择 approved 或 rejected')
note=str(payload.get('note') or '')[:500]
db.execute('UPDATE release_requests SET state=?,reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND site_id=?',
(state,request.state.actor,note,db.now(),request_id,site_id))
return {'id':request_id,'state':state,'reviewed_by':request.state.actor}
@router.get('/themes/{theme_id}/package')
def download_theme_package(request:Request,theme_id:str,version:str=''):
if not request.state.is_superadmin:raise HTTPException(403,'只有系统管理员可以导出主题包')
import io,zipfile
from fastapi.responses import Response
try:folder=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
buffer=io.BytesIO()
with zipfile.ZipFile(buffer,'w',compression=zipfile.ZIP_DEFLATED) as archive:
manifest=json.loads((folder/'manifest.json').read_text(encoding='utf-8'))
manifest['layout_family']=theme_id if theme_id in theme_store.BUILTIN else manifest.get('layout_family','corporate')
archive.writestr('manifest.json',json.dumps(manifest,ensure_ascii=False,indent=2))
for part in ('theme.css','page-presets.json'):
archive.write(folder/part,arcname=part)
for image_name in ('preview.png','preview.jpg','preview.webp'):
if (folder/image_name).exists():archive.write(folder/image_name,arcname=image_name)
return Response(buffer.getvalue(),media_type='application/zip',headers={'Content-Disposition':f'attachment; filename="{theme_id}-{version or theme_store.latest(theme_id)}.zip"'})
@router.get('/themes/{theme_id}/preview.css')
def theme_preview_css(theme_id:str,version:str=''):
from fastapi.responses import Response
try:dir=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
return Response((dir/'theme.css').read_text(encoding='utf-8'),media_type='text/css')
@router.get('/themes/{theme_id}/preview.pro.css')
def theme_preview_pro_css(theme_id:str,version:str=''):
from fastapi.responses import Response
try:folder=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
asset=folder/'pro.css'
return Response(asset.read_text(encoding='utf-8') if asset.exists() else '',media_type='text/css')
@router.get('/themes/{theme_id}/visual/hero.svg')
def theme_preview_hero(theme_id:str,version:str=''):
from fastapi.responses import Response
try:directory=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
art=directory/'visual'/'hero.svg'
if not art.is_file():raise HTTPException(404,'该主题版本没有内置视觉素材')
return Response(art.read_bytes(),media_type='image/svg+xml',headers={'Cache-Control':'public,max-age=300'})
@router.get('/themes/{theme_id}/preview',response_class=HTMLResponse)
def theme_preview(theme_id:str,version:str='',page_type:str='home'):
from jinja2 import Environment,FileSystemLoader,select_autoescape
from markupsafe import Markup
from html import escape
from . import blueprints,static_site
try:directory=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
if page_type not in blueprints.PAGE_TEMPLATES:
raise HTTPException(400, '不支持的预览页面类型')
manifest=json.loads((directory/'manifest.json').read_text(encoding='utf-8'))
site={'id':'preview','theme':theme_id,'name':manifest.get('name','品牌企业'),
'tagline':'Independent design · Integrated content · Global delivery',
'domain':'','email':'hello@example.com','logo':'','accent':'#346aca'}
page={'title':{'home':'创造有影响力的网站','about':'关于我们','contact':'联系我们','listing':'产品中心','product':'旗舰产品 A','article':'最新企业新闻','brand':'合作品牌','standard':'我们的服务','notfound':'404 · 页面不存在','case':'项目案例','service':'专业服务','faq':'常见问题','category':'演示分类'}[page_type],
'route':'/' if page_type=='home' else ('/404.html' if page_type=='notfound' else ('/categories/demo/' if page_type=='category' else '/preview-example/')),'type':{'product':'product','article':'post','brand':'brand','case':'case','service':'service'}.get(page_type,'page'),
'summary':'从完整主题出发,连接产品、品牌和客户。',
'cover':'','body':'','robots':'index,follow','meta_json':json.dumps({'page_template':page_type})}
records=[{'title':t,'type':'product','route':'/product/example-'+str(i)+'/','summary':'展示产品与解决方案',
'cover':'','meta_json':'{}','status':'published'} for i,t in enumerate(('旗舰产品 A','核心产品 B','定制解决方案 C'))]
site['schemas']={}
blocks=blueprints.theme_blocks(theme_id,page_type,version or None)
block_html=blueprints.render_blocks(blocks,site,page,records)
from . import theme_pro
if (directory/'pro.html').exists():
# Online previews and published pages use the identical professional renderer.
if theme_id in theme_pro.THEMES and directory.resolve()==(theme_store.BASE/theme_id).resolve():
html=theme_pro.render(theme_id,site,page,records,'home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),[],'')
else:
env=Environment(loader=FileSystemLoader([str(directory),str(theme_store.BASE)]),autoescape=select_autoescape(['html']))
context=theme_pro.make_context(site,page,records,'home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),[])
context.update(custom_blocks=Markup(''),has_custom=False,noindex=page_type=='notfound',theme_overrides=True)
html=env.get_template('pro.html').render(**context)
html=html.replace('assets/pro-base.css','/ui/pro-base.css').replace('assets/pro.js','/ui/pro.js').replace('assets/commercial.css','/ui/commercial.css')
html=html.replace('assets/pro.css',f'/api/factory/themes/{theme_id}/preview.pro.css?version={version or theme_store.latest(theme_id)}')
html=html.replace('assets/theme-overrides.css',f'/api/factory/themes/{theme_id}/preview.css?version={version or theme_store.latest(theme_id)}')
else:
env=Environment(loader=FileSystemLoader(str(directory)),autoescape=select_autoescape(['html']))
html=env.get_template('site.html').render(site=site,page=page,mode='home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),content=Markup(''),
blocks=Markup(block_html),blueprint_mode=True,entries=records,listing_items=[],
type_names={'product':'产品','page':'页面'},nav=[],link=lambda x:x.lstrip('/') or './',
asset=lambda x:x,canonical='',seo_title='主题预览 · '+manifest.get('name',''),
seo_description='SiteForge 主题模板预览')
html=static_site.shell(html,site,page,records,[])
html=html.replace('assets/theme.css',f'/api/factory/themes/{theme_id}/preview.css?version={version or theme_store.latest(theme_id)}')
html=html.replace('assets/blocks.css','/ui/blocks.css').replace('assets/factory.css','/ui/factory.css')
html=html.replace('assets/custom.css','/ui/factory.css').replace('assets/site-runtime.js','/ui/site-runtime.js')
return HTMLResponse(html)
+239
View File
@@ -0,0 +1,239 @@
"""Data-import adapters for CSV/JSON/XLSX/WordPress WXR/public HTML crawl."""
import csv
import io
import json
import re
import time
from urllib.parse import urlparse, urljoin, urldefrag
from collections import deque
from defusedxml import ElementTree as SafeET
from bs4 import BeautifulSoup
import httpx
from openpyxl import load_workbook
from .core import route_path, save_entry, ensure_public_url, slug_of
from . import db,wordpress_compat
KEY_ALIASES={
'title':('title','name','标题','名称','产品名称','post_title'),
'type':('type','post_type','类型','内容类型'),
'route':('route','url','path','permalink','link','路径','链接'),
'summary':('summary','excerpt','description','摘要','简介'),
'body':('body','content','text','details','正文','内容'),
'cover':('cover','image','image_url','featured_image','图片','封面'),
'seo_title':('seo_title','meta_title','SEO标题'),
'seo_description':('seo_description','meta_description','SEO描述'),
'canonical':('canonical',),
'status':('status',),
}
def normalize(rec):
r={str(k).strip().lower():v for k,v in rec.items()}
clean={}
used=set()
for canonical,aliases in KEY_ALIASES.items():
for alias in aliases:
if alias.lower() in r:
clean[canonical]=r[alias.lower()] if r[alias.lower()] is not None else ''
used.add(alias.lower());break
extra={k:v for k,v in r.items() if k not in used}
embedded=r.get('meta') if isinstance(r.get('meta'),dict) else {}
clean['meta']={**embedded,**{k:v for k,v in extra.items() if k not in ('id','meta')}}
if not clean.get('type'):clean['type']='product'
return clean
def import_records(site_id, records, origin='file', on_conflict='skip'):
if on_conflict not in ('skip','update'):
raise ValueError('无效的冲突策略')
result={'imported':0,'updated':0,'skipped':0,'errors':[]}
for index,rec in enumerate(records,1):
try:
data=normalize(rec)
if not data.get('title'):raise ValueError('缺少 title/name')
kind=data.get('type') or 'product'
if not data.get('route'):
data['route']=f'/{kind}/{slug_of(str(data["title"]))}/'
if '?' in str(data['route']) and not str(data['route']).startswith('http'):
raise ValueError('带查询参数的动态 URL 需要专项映射,不能直接转换为静态路由')
route=route_path(data['route'])
existing=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route))
if existing and on_conflict=='skip':
result['skipped']+=1;continue
save_entry(site_id,data,existing_id=existing['id'] if existing else None,source=origin)
result['updated' if existing else 'imported']+=1
except Exception as exc:
if len(result['errors'])<80:
result['errors'].append({'row':index,'message':str(exc)[:250]})
return result
def import_data_file(site_id, filename, content, conflict):
lower=filename.lower()
if lower.endswith('.csv'):
txt=content.decode('utf-8-sig')
records=list(csv.DictReader(io.StringIO(txt)))
elif lower.endswith('.json'):
parsed=json.loads(content.decode('utf-8-sig'))
if isinstance(parsed,dict):parsed=parsed.get('entries',parsed.get('items',[]))
if not isinstance(parsed,list):raise ValueError('JSON 应当为数据数组或包含 entries/items 数组')
records=parsed
elif lower.endswith('.xlsx'):
wb=load_workbook(io.BytesIO(content),read_only=True,data_only=True)
ws=wb.active
values=ws.iter_rows(values_only=True)
headers=[str(x or '').strip() for x in next(values)]
records=(dict(zip(headers,row)) for row in values)
elif lower.endswith('.xml'):
return import_wxr(site_id,content,conflict)
else:
raise ValueError('仅支持 .csv/.json/.xlsx/.xml')
return import_records(site_id,records,filename,conflict)
def import_wxr(site_id,content,conflict='skip'):
root=SafeET.fromstring(content)
ns={'content':'http://purl.org/rss/1.0/modules/content/',
'excerpt':'http://wordpress.org/export/1.2/excerpt/',
'wp':'http://wordpress.org/export/1.2/'}
# Support alternate WXR versions (e.g. 1.1)
raw_content=None; raw_wp=None; raw_excerpt=None
for e in root.iter():
if e.tag.startswith('{'):
uri=e.tag.split('}')[0][1:]
if uri.endswith('/modules/content/'):raw_content=uri
if 'wordpress.org/export/' in uri and uri.endswith('/') :raw_wp=uri
if uri.endswith('/modules/excerpt/'):raw_excerpt=uri
if raw_content:ns['content']=raw_content
if raw_wp:ns['wp']=raw_wp
if raw_excerpt:ns['excerpt']=raw_excerpt
entries=[]
media_index=wordpress_compat.wp_media_map(root)
for it in root.findall('./channel/item'):
typ=(it.findtext('wp:post_type',default='post',namespaces=ns) or 'post')
status=(it.findtext('wp:status',default='publish',namespaces=ns) or 'publish')
if typ in ('attachment','nav_menu_item','revision') or status not in ('publish','private','draft'):
continue
slug=it.findtext('wp:post_name',namespaces=ns) or slug_of(it.findtext('title') or 'page')
title=it.findtext('title') or slug
link=it.findtext('link') or ''
path=urlparse(link).path if link.startswith(('http://','https://')) else ''
if not path: path=f'/{slug}/' if typ=='page' else f'/{typ}/{slug}/'
wpmeta={}
for pm in it.findall('wp:postmeta',ns):
key=pm.findtext('wp:meta_key',namespaces=ns)
if key:wpmeta[key]=pm.findtext('wp:meta_value',default='',namespaces=ns)
body=it.findtext('content:encoded',default='',namespaces=ns)
taxonomy=wordpress_compat.wp_taxonomy(it)
# ACF stores human-readable fields as plain postmeta keys, while
# underscore-prefixed field references point to the ACF field schema.
# Preserve *all* raw metadata, but expose only safe scalar values as
# structured content fields for the visual parameter component.
fields={k:v[:10000] for k,v in wpmeta.items()
if k and not k.startswith('_') and not k.lower().startswith('field_')
and isinstance(v,str) and len(v)<=10000}
for source,dest in (('_sku','sku'),('_regular_price','regular_price'),
('_sale_price','sale_price'),('_price','price'),
('_stock_status','stock_status'),('_weight','weight')):
if wpmeta.get(source):fields[dest]=str(wpmeta[source])[:1000]
fields.update(taxonomy)
wp_featured=media_index.get(wpmeta.get('_thumbnail_id',''),'')
gallery_ids=[x.strip() for x in wpmeta.get('_product_image_gallery','').split(',') if x.strip()]
gallery=[media_index[x] for x in gallery_ids if x in media_index]
if gallery:fields['gallery']=gallery
warnings=[]
if wpmeta.get('_elementor_data'):
elementor=wordpress_compat.elementor_to_html(wpmeta['_elementor_data'])
if not body.strip() and elementor['html']:
body=elementor['html'];warnings.append('Elementor 仅转换已支持的文本与图片组件,原始布局需人工检查')
warnings.extend(elementor['warnings'])
warnings.append('检测到 Elementor 页面构建器 JSON;原生布局不能直接转换为 SiteForge 区块,需人工对照检查。')
if any(k.startswith('_product_attributes') for k in wpmeta):
warnings.append('WooCommerce 序列化商品属性待专项解析,原始 metadata 已保留。')
body=body or ''
entries.append({'type':typ if typ in ('page','post','product','service','case','brand','vehicle') else 'other',
'title':title,'route':path,'body':body,'cover':wp_featured,
'summary':it.findtext('excerpt:encoded',default='',namespaces=ns),
'seo_title':wpmeta.get('_yoast_wpseo_title') or wpmeta.get('rank_math_title') or '',
'seo_description':wpmeta.get('_yoast_wpseo_metadesc') or wpmeta.get('rank_math_description') or '',
'canonical':wpmeta.get('_yoast_wpseo_canonical') or wpmeta.get('rank_math_canonical_url') or '',
'robots':'noindex,follow' if wpmeta.get('_yoast_wpseo_meta-robots-noindex')=='1' or wpmeta.get('rank_math_robots')=='noindex' else 'index,follow',
'status':'published' if status=='publish' else 'draft',
'meta':{'elementor_widgets':elementor['widgets'] if wpmeta.get('_elementor_data') else 0,'wp_post_id':it.findtext('wp:post_id',default='',namespaces=ns),'wp_type':typ,'wp_metadata':wpmeta,'fields':fields,'migration_warnings':warnings}})
return import_records(site_id,entries,'wordpress-wxr',conflict)
def safe_get(client,url,max_bytes=3_000_000):
"""Validate each hop to prevent trivial redirect-to-private host SSRF."""
for _ in range(4):
ensure_public_url(url)
with client.stream('GET',url, follow_redirects=False) as response:
if response.status_code in (301,302,303,307,308):
loc=response.headers.get('location')
if not loc: raise ValueError('重定向缺少 Location')
url=urljoin(url,loc);continue
response.raise_for_status()
if 'text/html' not in response.headers.get('content-type','text/html'):
raise ValueError('页面不是 HTML')
buff=bytearray()
for part in response.iter_bytes():
buff.extend(part)
if len(buff)>max_bytes:raise ValueError('HTML 页面超过 3 MB')
return bytes(buff),str(response.url)
raise ValueError('页面重定向次数过多')
def extract_page(html,url):
s=BeautifulSoup(html,'html.parser')
title=s.title.get_text(' ',strip=True) if s.title else ''
description=s.find('meta',attrs={'name':re.compile('^description$',re.I)})
canonical=s.find('link',attrs={'rel':lambda val: bool(val) and ('canonical' in val if isinstance(val,list) else 'canonical' in val)})
robots=s.find('meta',attrs={'name':re.compile('^robots$',re.I)})
h1=s.find('h1')
heading=h1.get_text(' ',strip=True) if h1 else title
main=s.find('main') or s.find('article') or s.body
body=''
if main:
for tag in main.select('script,style,nav,footer,header,aside,form,iframe,svg'):
tag.decompose()
for image in main.find_all('img'):
src=image.get('src') or image.get('data-src')
if src:image['src']=urljoin(url,src)
for a in main.find_all('a',href=True):
if a['href'].startswith(('http','/','.')):a['href']=urljoin(url,a['href'])
body=str(main)
links=[]
for a in s.find_all('a',href=True):
abs_url=urljoin(url,a['href'])
if urlparse(abs_url).scheme in ('http','https'):
links.append(urldefrag(abs_url)[0])
return ({'type':'page' if urlparse(url).path=='/' else 'post',
'title':heading or 'Untitled','route':urlparse(url).path or '/',
'body':body,'summary':description.get('content','') if description else '',
'seo_title':title,'seo_description':description.get('content','') if description else '',
'canonical':canonical.get('href','') if canonical else '',
'robots':robots.get('content','index,follow') if robots else 'index,follow'},links)
def crawl_site(site_id,start,max_pages=25,conflict='skip'):
ensure_public_url(start)
max_pages=max(1,min(int(max_pages),60))
root=urlparse(start)
host=root.hostname
queue=deque([start]);visited=set();pages=[];errors=[]
# Strict same-host traversal, not open web crawling.
with httpx.Client(timeout=httpx.Timeout(12,connect=5),headers={'User-Agent':'SiteForgeLocalMigration/0.1'},trust_env=False) as client:
while queue and len(visited)<max_pages:
url=queue.popleft()
if url in visited:continue
parsed=urlparse(url)
if parsed.hostname!=host:continue
if re.search(r'/(wp-admin|wp-login|cart|checkout|admin)(/|$)',parsed.path,re.I):continue
visited.add(url)
try:
data,actual=safe_get(client,url)
if urlparse(actual).hostname!=host:raise ValueError('跳转至站外地址')
page,links=extract_page(data,actual)
pages.append(page)
for link in links:
if urlparse(link).hostname==host and link not in visited and link not in queue:
if not re.search(r'\.(pdf|zip|jpg|jpeg|png|gif|webp|js|css)(\?|$)',link,re.I):
queue.append(link)
except Exception as exc:
if len(errors)<40:errors.append({'url':url,'error':str(exc)[:180]})
result=import_records(site_id,pages,'crawler',conflict)
result.update({'crawled':len(visited),'discovered':len(pages),'fetch_errors':errors})
return result
+772
View File
@@ -0,0 +1,772 @@
"""SiteForge Docker-first website factory API and Web console."""
import io
from contextlib import asynccontextmanager
import json
import re
import shutil
import uuid
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Body, Request
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, Response, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.trustedhost import TrustedHostMiddleware
from pydantic import BaseModel, Field
from . import db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables
from .meta import APP_NAME, APP_VERSION, API_TITLE, API_DESCRIPTION
from .core import uid, THEMES, TYPES, clean_hex, save_entry, route_path, entry_dict
ROOT=Path(__file__).resolve().parent.parent
@asynccontextmanager
async def lifespan(app):
security.configure()
db.init()
enterprise.setup()
factory_api.setup()
migration_audit.setup()
reusables.setup()
yield
app=FastAPI(title=API_TITLE,description=API_DESCRIPTION,version=APP_VERSION,lifespan=lifespan)
allowed_hosts=[x.strip() for x in __import__('os').environ.get('SITEFORGE_ALLOWED_HOSTS','').split(',') if x.strip()]
if allowed_hosts:
app.add_middleware(TrustedHostMiddleware,allowed_hosts=allowed_hosts)
app.mount('/ui',StaticFiles(directory=str(ROOT/'app'/'static')),name='static')
app.include_router(enterprise.router)
app.include_router(factory_api.router)
app.include_router(migration_audit.router)
app.include_router(reusables.router)
@app.middleware('http')
async def require_login(request:Request, call_next):
path = request.url.path
public = path in ('/login', '/api/auth/login', '/api/health', '/favicon.ico') or path.startswith('/ui/')
if not public:
session = security.read_session(request.cookies.get(security.COOKIE))
identity = enterprise.account_from_session(session) if session else None
if not identity:
if path.startswith('/api/') or path in ('/openapi.json',):
return JSONResponse({'detail':'请先登录 SiteForge'},status_code=401)
return RedirectResponse('/login',status_code=303)
request.state.account_id=identity['id']
request.state.is_superadmin=identity['superadmin']
request.state.actor=identity['actor']
if request.method not in ('GET','HEAD','OPTIONS'):
if not security.verify_csrf(session,request.headers.get('x-siteforge-csrf','')):
return JSONResponse({'detail':'安全验证失败,请刷新页面'},status_code=403)
origin=request.headers.get('origin')
if origin:
from urllib.parse import urlparse
parsed=urlparse(origin)
forwarded=request.headers.get('x-forwarded-host') or request.headers.get('host') or ''
if parsed.netloc.lower()!=forwarded.lower():
return JSONResponse({'detail':'不允许跨站请求'},status_code=403)
if not identity['superadmin']:
import re
segments=re.match(r'^/api/sites/([^/]+)(?:/|$)',path)
enterprise_segments=re.match(r'^/api/(?:enterprise|factory|audit)/sites/([^/]+)(?:/|$)',path)
asset_segments=re.match(r'^/(?:editor|preview|local-media)/([^/]+)(?:/|$)',path)
site_id=(segments or enterprise_segments or asset_segments)
if site_id:
site_id=site_id.group(1)
role=('viewer' if request.method in ('GET','HEAD') else 'editor')
if path.startswith('/api/factory/sites/') and '/release-requests/' in path and path.endswith('/review'):
role='reviewer'
if any(t in path for t in ('/publish-local','/rollback','/members')) or request.method=='DELETE' and segments and segments.group(0).rstrip('/').endswith(site_id):
role='owner'
if path.endswith('/baseline') and request.method=='GET':role='viewer'
try:enterprise.require(request,site_id,role)
except HTTPException:
return JSONResponse({'detail':'没有访问该网站的权限'},status_code=403)
elif path in ('/api/sites','/api/presets') and request.method not in ('GET','HEAD'):
return JSONResponse({'detail':'只有系统管理员可创建站点/全局模板'},status_code=403)
elif path.startswith('/api/enterprise/team/') or path in ('/docs','/openapi.json'):
return JSONResponse({'detail':'没有全局管理权限'},status_code=403)
if request.method in ('PATCH','PUT','DELETE') and path.startswith('/api/sites/') and '/entries/' in path:
pieces=path.strip('/').split('/')
if len(pieces)>=5:
enterprise.save_revision(pieces[2],pieces[4],identity['actor'],'before_'+request.method.lower())
response=await call_next(request)
if not public:
if request.method in ('POST','PUT','PATCH','DELETE'):
site_match=__import__('re').match(r'^/api/(?:(?:enterprise|factory)/)?sites/([^/]+)',path)
enterprise.log_event(request.state.actor,path,response.status_code,request.method,
site_match.group(1) if site_match else None)
if request.method=='GET' and path=='/api/sites' and not request.state.is_superadmin and response.status_code==200:
# Apply the same site-scope filter at the top-level list boundary.
import json as _json
permitted=enterprise.db.rows('SELECT site_id FROM team_memberships WHERE user_id=?',(request.state.account_id,))
ids={r['site_id'] for r in permitted}
from starlette.responses import StreamingResponse
raw=b''
async for part in response.body_iterator:raw+=part
data=_json.loads(raw)
response=JSONResponse([x for x in data if x['id'] in ids])
response.headers['X-Content-Type-Options']='nosniff'
# The authenticated theme gallery embeds same-origin previews. Block
# cross-origin framing without breaking that internal workflow.
response.headers['X-Frame-Options']='SAMEORIGIN'
response.headers['Referrer-Policy']='strict-origin-when-cross-origin'
response.headers['Permissions-Policy']='camera=(), microphone=(), geolocation=(), payment=(), usb=()'
response.headers['Content-Security-Policy']=(
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; "
"script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; "
"form-action 'self' https:"
)
response.headers['Cache-Control']='no-store' if path.startswith('/api/auth/') else response.headers.get('Cache-Control','no-cache')
return response
@app.get('/theme-gallery',response_class=HTMLResponse)
def theme_gallery():
return (ROOT/'app'/'templates'/'theme-gallery.html').read_text(encoding='utf-8')
@app.get('/factory-studio',response_class=HTMLResponse)
def factory_studio():
return (ROOT/'app'/'templates'/'factory-v2.html').read_text(encoding='utf-8')
@app.get('/login',response_class=HTMLResponse)
def login_page():
return (ROOT/'app'/'templates'/'login.html').read_text(encoding='utf-8')
class LoginIn(BaseModel):
password: str
username: str = "admin"
@app.post('/api/auth/login')
def login(payload:LoginIn, request:Request):
remote = request.client.host if request.client else 'unknown'
security.check_login_rate(remote)
if payload.username=='admin':
authenticated=security.check_password(payload.password)
token=security.sign_session() if authenticated else None
else:
user=enterprise.user_by_name(payload.username)
authenticated=bool(user and not user['disabled'] and enterprise.check_password(payload.password,user['password_hash']))
token=security.sign_user_session(user['id']) if authenticated else None
if not authenticated:
security.note_login_failure(remote)
raise HTTPException(401, '账号或密码不正确')
security.note_login_success(remote)
response = JSONResponse({'ok':True})
response.set_cookie(security.COOKIE, token, max_age=security.MAX_AGE,
httponly=True, secure=security.COOKIE_SECURE, samesite='strict', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
@app.get('/api/auth/me')
def whoami(request:Request):
session=security.read_session(request.cookies.get(security.COOKIE))
return {'authenticated':True,'user':request.state.actor,'superadmin':request.state.is_superadmin,'csrf':security.csrf(session),'version':APP_VERSION}
@app.post('/api/auth/logout')
def logout():
response=JSONResponse({'ok':True})
response.delete_cookie(security.COOKIE,path='/')
return response
class SiteIn(BaseModel):
name:str = Field(min_length=1,max_length=150)
domain:str = ''
tagline:str = ''
theme:str = 'corporate'
accent:str = '#2563eb'
logo:str = ''
mode:str = 'new'
origin:str = ''
preset_id:str = ''
class SitePatch(BaseModel):
name:str|None=None
domain:str|None=None
tagline:str|None=None
theme:str|None=None
accent:str|None=None
logo:str|None=None
mode:str|None=None
origin:str|None=None
class CrawlIn(BaseModel):
url:str
max_pages:int=20
on_conflict:str='skip'
class BuildIn(BaseModel):
mirror_images:bool=False
class RedirectIn(BaseModel):
old_path:str
new_path:str
status_code:int=301
def get_site(site_id):
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404,'找不到网站')
site['settings']=json.loads(site.pop('settings_json'))
return site
def get_entry(site_id,eid):
val=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid))
if not val:raise HTTPException(404,'找不到内容')
return entry_dict(val)
def fail(exc):
if isinstance(exc,HTTPException):raise exc
raise HTTPException(400,detail=str(exc)[:500])
@app.get('/',response_class=HTMLResponse)
def home():
return (ROOT/'app'/'templates'/'admin.html').read_text(encoding='utf-8')
@app.get('/editor/{site_id}/{entry_id}',response_class=HTMLResponse)
def visual_editor(site_id:str,entry_id:str):
get_entry(site_id,entry_id)
return (ROOT/'app'/'templates'/'editor.html').read_text(encoding='utf-8')
@app.get('/api/health')
def health():return {'status':'ok','name':APP_NAME,'version':APP_VERSION}
@app.get('/api/themes')
def themes():
manifests=[]
for theme in THEMES:
meta=json.loads((ROOT/'themes'/theme/'manifest.json').read_text(encoding='utf-8'))
manifests.append(meta)
return manifests
@app.get('/api/editor/blocks')
def editor_blocks():
return {'blocks':blueprints.BLOCKS,'bindings':blueprints.BINDABLE,'page_templates':blueprints.PAGE_TEMPLATES}
@app.get('/api/sites')
def list_sites():
arr=db.rows('SELECT * FROM sites ORDER BY updated_at DESC')
for s in arr:
s.pop('settings_json',None)
s['entries_count']=db.one('SELECT count(*) n FROM entries WHERE site_id=?',(s['id'],))['n']
s['build_count']=db.one('SELECT count(*) n FROM builds WHERE site_id=?',(s['id'],))['n']
return arr
@app.post('/api/sites',status_code=201)
def create_site(payload:SiteIn):
if payload.theme not in theme_store.ids():raise HTTPException(400,'没有该模板')
try:clean_hex(payload.accent)
except Exception as exc:fail(exc)
site_id=uid()
settings={'origin':payload.origin,'nav':[],'email':'','footer_text':''}
homeblocks=blueprints.theme_blocks(payload.theme,'home')
if payload.preset_id:
p=db.one('SELECT * FROM presets WHERE id=?',(payload.preset_id,))
if not p:raise HTTPException(404,'模板预设不存在')
payload.theme=p['theme'];settings.update(json.loads(p['settings_json']))
homeblocks=json.loads(p['home_blocks_json'])
with db.connect() as c:
c.execute('''INSERT INTO sites(id,name,domain,tagline,theme,accent,logo,mode,settings_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)''',
(site_id,payload.name,payload.domain,payload.tagline,payload.theme,payload.accent,payload.logo,payload.mode,json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
save_entry(site_id,{'type':'page','title':'首页','route':'/','summary':payload.tagline,
'seo_title':payload.name,'meta':{'blocks':homeblocks}},source='starter')
return get_site(site_id)
@app.get('/api/sites/{site_id}')
def site_detail(site_id:str):
site=get_site(site_id)
site['counts']={r['type']:r['n'] for r in db.rows('SELECT type,count(*) n FROM entries WHERE site_id=? GROUP BY type',(site_id,))}
site['builds']=db.rows('SELECT id,created_at,total_pages,status FROM builds WHERE site_id=? ORDER BY created_at DESC LIMIT 10',(site_id,))
return site
@app.patch('/api/sites/{site_id}')
def patch_site(site_id:str,patch:SitePatch):
old=get_site(site_id)
data=patch.model_dump(exclude_unset=True)
if 'theme' in data and data['theme'] not in theme_store.ids():raise HTTPException(400,'没有该模板')
if 'accent' in data:
try:clean_hex(data['accent'])
except Exception as exc:fail(exc)
settings=old['settings']
if 'origin' in data:settings['origin']=data.pop('origin') or ''
valid=['name','domain','tagline','theme','accent','logo','mode']
fields=[k for k in data if k in valid]
with db.connect() as c:
for k in fields:c.execute(f'UPDATE sites SET {k}=? WHERE id=?',(data[k],site_id))
c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return get_site(site_id)
@app.delete('/api/sites/{site_id}')
def delete_site(site_id:str):
get_site(site_id)
db.execute('DELETE FROM sites WHERE id=?',(site_id,))
for relative in (db.DATA/'media'/site_id,db.DATA/'builds'/site_id):
if relative.exists():shutil.rmtree(relative)
for z in (db.DATA/'archives').glob(site_id+'-*.zip') if (db.DATA/'archives').exists() else []:
z.unlink()
return {'deleted':True}
@app.post('/api/sites/{site_id}/clone',status_code=201)
def clone_site(site_id:str,settings:dict[str,Any]=Body(default_factory=dict)):
original=get_site(site_id)
clone=create_site(SiteIn(name=settings.get('name') or original['name']+' 副本',theme=original['theme'],
domain=settings.get('domain',''),tagline=original['tagline'],accent=original['accent'],logo=original['logo']))
entries=db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
for old in entries:
e=entry_dict(old)
try:save_entry(clone['id'],e,source='clone',replace_route=True)
except Exception:pass
return get_site(clone['id'])
@app.get('/api/sites/{site_id}/entries')
def list_entries(site_id:str,typ:str='',q:str=''):
get_site(site_id)
sql='SELECT * FROM entries WHERE site_id=?';params=[site_id]
if typ:sql+=' AND type=?';params.append(typ)
if q:sql+=' AND (title LIKE ? OR route LIKE ?)';params.extend(['%'+q+'%','%'+q+'%'])
sql+=' ORDER BY CASE WHEN route="/" THEN 0 ELSE 1 END, type, updated_at DESC'
return [entry_dict(x) for x in db.rows(sql,params)]
@app.post('/api/sites/{site_id}/entries',status_code=201)
def create_entry(site_id:str,payload:dict[str,Any]):
try:
eid=save_entry(site_id,payload)
return get_entry(site_id,eid)
except Exception as exc:fail(exc)
@app.patch('/api/sites/{site_id}/entries/{entry_id}')
def update_entry(site_id:str,entry_id:str,payload:dict[str,Any]):
previous=get_entry(site_id,entry_id)
data={**previous,**payload}
try:
save_entry(site_id,data,existing_id=entry_id)
return get_entry(site_id,entry_id)
except Exception as exc:fail(exc)
@app.delete('/api/sites/{site_id}/entries/{entry_id}')
def delete_entry(site_id:str,entry_id:str):
get_entry(site_id,entry_id)
db.execute('DELETE FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
return {'deleted':True}
def infer_page_template(entry):
route=str(entry.get('route','')).lower()
typ=entry.get('type')
if route=='/':return 'home'
if typ in ('product','vehicle'):return 'product'
if typ=='post':return 'article'
if typ=='brand':return 'brand'
if any(k in route for k in ('/about', '/company', '/our-story')):return 'about'
if any(k in route for k in ('/contact', '/inquiry')):return 'contact'
return 'standard'
@app.post('/api/sites/{site_id}/scaffold')
def scaffold_standard_pages(site_id:str):
"""Add commonly needed enterprise pages while never overwriting existing URLs."""
site=get_site(site_id)
defaults=[('about','关于我们','/about/','企业介绍、经营理念与核心服务能力'),
('contact','联系我们','/contact/','联系方式、业务咨询与合作对接'),
('standard','服务与解决方案','/services/','适合不同客户需求的业务与服务')]
created=[];skipped=[]
for kind,title,route,summary in defaults:
if db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route)):
skipped.append(route);continue
meta={'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))}
save_entry(site_id,{'type':'page','title':title,'route':route,'summary':summary,'meta':meta},source='scaffold')
created.append(route)
return {'created':created,'skipped':skipped,'note':'不覆盖已有页面或修改任何原始 URL'}
@app.post('/api/sites/{site_id}/apply-theme')
def apply_theme_templates(site_id:str,payload:dict=Body(default_factory=dict)):
"""Re-assemble existing content with the selected theme without modifying URLs/SEO."""
site=get_site(site_id)
mode=payload.get('mode','missing')
if mode not in ('missing','overwrite'):raise HTTPException(400,'无效的套版模式')
entries=[entry_dict(x) for x in db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))]
changed=0;skipped=0
for entry in entries:
if mode=='missing' and entry['meta'].get('blocks'):
skipped+=1;continue
kind=infer_page_template(entry)
entry['meta']={**entry['meta'],'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))}
save_entry(site_id,entry,existing_id=entry['id'])
changed+=1
return {'updated':changed,'skipped':skipped,'total':len(entries),'theme':site['theme'],'mode':mode,
'note':'只修改页面模板/区块,不改变正文原数据、URL、SEO 和 Canonical'}
@app.get('/api/sites/{site_id}/schemas')
def site_schemas(site_id:str):
get_site(site_id)
rows=db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,))
result={k:v for k,v in blueprints.CONTENT_MODELS.items()}
for row in rows:result[row['content_type']]=json.loads(row['fields_json'])
return result
@app.put('/api/sites/{site_id}/schemas/{kind}')
def update_schema(site_id:str,kind:str,payload:list[dict]=Body(...)):
get_site(site_id)
if kind not in TYPES:raise HTTPException(400,'未知内容类型')
if len(payload)>40:raise HTTPException(400,'最多允许40个自定义字段')
clean=[];names=set()
for field in payload:
name=str(field.get('name') or '')
if not re.fullmatch('[a-zA-Z_][a-zA-Z0-9_]{0,50}',name) or name in names:raise HTTPException(400,'字段名称不合法或重复')
typ=field.get('type','text')
if typ not in ('text','textarea','number','date','url','boolean'):raise HTTPException(400,'不支持该字段类型')
names.add(name);clean.append({'name':name,'label':str(field.get('label') or name)[:80],'type':typ})
with db.connect() as conn:
conn.execute('INSERT INTO content_schemas(site_id,content_type,fields_json) VALUES (?,?,?) ON CONFLICT(site_id,content_type) DO UPDATE SET fields_json=excluded.fields_json',(site_id,kind,json.dumps(clean,ensure_ascii=False)))
return {'content_type':kind,'fields':clean}
@app.get('/api/sites/{site_id}/pages/{entry_id}/blueprint')
def read_blueprint(site_id:str,entry_id:str):
entry=get_entry(site_id,entry_id)
kind=entry['meta'].get('page_template') or ('home' if entry['route']=='/' else 'product' if entry['type'] in ('product','vehicle') else 'article' if entry['type']=='post' else 'standard')
return {'entry_id':entry_id,'page_template':kind,'layout_mode':entry['meta'].get('layout_mode','append'),'blocks':entry['meta'].get('blocks',[]),'entry':entry}
class BlueprintPayload(BaseModel):
blocks:list[dict]
page_template:str='standard'
layout_mode:str='append'
@app.put('/api/sites/{site_id}/pages/{entry_id}/blueprint')
def save_blueprint(site_id:str,entry_id:str,payload:BlueprintPayload):
entry=get_entry(site_id,entry_id)
if payload.page_template not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'不支持的页面模板')
try:validated=blueprints.validate_blocks(payload.blocks)
except ValueError as exc:raise HTTPException(400,str(exc))
if payload.layout_mode not in ('append','replace'):raise HTTPException(400,'不支持的页面设计模式')
meta={**entry['meta'],'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated}
save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id)
return {'saved':True,'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated}
@app.post('/api/sites/{site_id}/pages/{entry_id}/blueprint/preset')
def reset_blueprint(site_id:str,entry_id:str,payload:dict=Body(default_factory=dict)):
entry=get_entry(site_id,entry_id)
kind=payload.get('page_template','home' if entry['route']=='/' else 'standard')
if kind not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'未知的页面模板')
site=get_site(site_id)
blocks=blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))
meta={**entry['meta'],'blocks':blocks,'page_template':kind}
save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id)
return {'page_template':kind,'blocks':blocks}
@app.post('/api/sites/{site_id}/pages/{entry_id}/editor-preview',response_class=HTMLResponse)
def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload):
site=get_site(site_id);page=get_entry(site_id,entry_id)
try:blocks=blueprints.validate_blocks(payload.blocks)
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
site['schemas']={k:v for k,v in blueprints.CONTENT_MODELS.items()}
for row in db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)):
site['schemas'][row['content_type']]=json.loads(row['fields_json'])
content=blueprints.render_blocks(blocks,site,page,[entry_dict(x) for x in db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,))])
# The editor now uses the same trusted theme renderer as production builds.
# CSS assets are inlined because srcdoc is sandboxed and has no /assets mount.
from . import theme_pro, theme_store, responsive
import re as _re
from markupsafe import Markup
from jinja2 import Environment,FileSystemLoader,select_autoescape
settings=site.get('settings') or {}
site['settings_json']=json.dumps(settings,ensure_ascii=False)
records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,))
page['meta_json']=json.dumps({**page['meta'],'layout_mode':payload.layout_mode},ensure_ascii=False)
data=theme_store.pinned(site_id)
theme_dir=theme_store.resolve(site['theme'],data['version'] if data else None)
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(ROOT/'themes'/site['theme']).resolve():
html=theme_pro.render(site['theme'],site,page,records,blocks_html=content)
elif (theme_dir/'pro.html').exists():
env=Environment(loader=FileSystemLoader([str(theme_dir),str(ROOT/'themes')]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,page,records)
ctx.update(custom_blocks=Markup(content),has_custom=bool(content),noindex=False,
designer_replace=payload.layout_mode=='replace',theme_overrides=True)
html=env.get_template('pro.html').render(**ctx)
else:
html='<html><head><meta charset="utf-8"></head><body>'+content+'</body></html>'
styles='\n'.join([
(ROOT/'app'/'static'/'pro-base.css').read_text(encoding='utf-8'),
(ROOT/'app'/'static'/'commercial.css').read_text(encoding='utf-8'),
(ROOT/'app'/'static'/'blocks.css').read_text(encoding='utf-8'),
(ROOT/'app'/'static'/'factory.css').read_text(encoding='utf-8'),
(theme_dir/'pro.css').read_text(encoding='utf-8') if (theme_dir/'pro.css').exists() else '',
(theme_dir/'theme.css').read_text(encoding='utf-8'),
responsive.editor_css(blocks)])
html=_re.sub(r'<link\s+rel="stylesheet"[^>]*>', '', html)
html=_re.sub(r'<script\s+defer\s+src="assets/[^"]+"[^>]*></script>', '', html)
html=html.replace('</head>','<style>'+styles+'</style></head>')
bridge = r'''<script>(function(){
const fields={'title':'h1,h2','description':'.sf-hero-copy p,.sf-image-text p'};
document.querySelectorAll('[data-block]').forEach(block=>{
for(const [field,selector] of Object.entries(fields)){
const target=block.querySelector(selector);if(!target)continue;
target.title='双击直接修改文字';target.addEventListener('dblclick',()=>{
target.contentEditable='plaintext-only';target.focus();
target.style.outline='2px dashed #367bdd';
});
target.addEventListener('blur',()=>{
if(!target.isContentEditable)return;
target.removeAttribute('contenteditable');target.style.outline='';
parent.postMessage({source:'siteforge-inline-editor',blockId:block.dataset.block,field:field,value:target.textContent},'*');
});
}
});
})()</script>'''
return HTMLResponse(html.replace('</body>',bridge+'</body>'))
@app.put('/api/sites/{site_id}/contact-settings')
def update_contact_settings(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
site=get_site(site_id)
email=str(payload.get('email') or '').strip()[:180]
endpoint=str(payload.get('form_endpoint') or '').strip()
if email and not re.fullmatch(r'[^@\s]+@[^@\s]+\.[^@\s]+',email):
raise HTTPException(400,'联系邮箱格式不正确')
if endpoint and (not endpoint.startswith('https://') or any(c in endpoint for c in ('\"','<','>','\n','\r')) or len(endpoint)>500):
raise HTTPException(400,'表单服务地址必须是有效的 HTTPS URL')
settings={**site['settings'],'email':email,'form_endpoint':endpoint,
'footer_text':str(payload.get('footer_text') or '')[:600]}
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'saved':True,'email':email,'form_endpoint':endpoint,'footer_text':settings['footer_text'],
'note':'外部表单服务必须另外开通、配置隐私与防垃圾验证,SiteForge不保存表单数据'}
@app.put('/api/sites/{site_id}/navigation')
def update_navigation(site_id:str,items:list[dict]=Body(...)):
site=get_site(site_id)
if len(items)>25:raise HTTPException(400,'最多25个导航项目')
nav=[]
for item in items:
label=str(item.get('title') or '').strip()[:60]
path=route_path(item.get('route') or '/')
if not label:raise HTTPException(400,'导航标题不能为空')
nav.append({'title':label,'route':path})
settings={**site['settings'],'nav':nav}
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'nav':nav}
@app.post('/api/sites/{site_id}/import')
async def import_file(site_id:str,file:UploadFile=File(...),on_conflict:str=Form('skip')):
get_site(site_id)
content=await file.read(15_000_001)
if len(content)>15_000_000:raise HTTPException(413,'导入文件不能超过 15MB')
try:result=importers.import_data_file(site_id,file.filename or '',content,on_conflict)
except Exception as exc:fail(exc)
db.insert('INSERT INTO imports(id,site_id,created_at,source,result_json) VALUES (?,?,?,?,?)',
(uid(),site_id,db.now(),file.filename or 'upload',json.dumps(result,ensure_ascii=False)))
return result
@app.post('/api/sites/{site_id}/crawl')
def crawl(site_id:str,payload:CrawlIn):
get_site(site_id)
try:result=importers.crawl_site(site_id,payload.url,payload.max_pages,payload.on_conflict)
except Exception as exc:fail(exc)
db.insert('INSERT INTO imports(id,site_id,created_at,source,result_json) VALUES (?,?,?,?,?)',
(uid(),site_id,db.now(),'crawler: '+payload.url,json.dumps(result,ensure_ascii=False)))
old=get_site(site_id);settings=old['settings'];settings['origin']=payload.url
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings),db.now(),site_id))
return result
@app.get('/api/sites/{site_id}/imports')
def imports(site_id:str):
get_site(site_id)
return db.rows('SELECT * FROM imports WHERE site_id=? ORDER BY created_at DESC LIMIT 25',(site_id,))
@app.post('/api/sites/{site_id}/media')
async def upload_image(site_id:str,file:UploadFile=File(...)):
get_site(site_id)
allowed={'.png','image/png','.jpg','image/jpeg','.jpeg','.webp','image/webp','.gif','image/gif','.avif','image/avif'}
ext=Path(file.filename or '').suffix.lower()
if ext not in ('.png','.jpg','.jpeg','.webp','.gif','.avif'):
raise HTTPException(400,'仅支持 PNG/JPG/WebP/GIF/AVIF 图片')
data=await file.read(8_000_001)
if len(data)>8_000_000:raise HTTPException(413,'图片不能超过 8 MB')
if not (data.startswith(b'\x89PNG') or data.startswith(b'\xff\xd8') or data.startswith(b'GIF8') or data[4:8]==b'ftyp' or (len(data)>12 and data[8:12]==b'WEBP')):
raise HTTPException(400,'不是有效图片格式')
folder=db.DATA/'media'/site_id;folder.mkdir(parents=True,exist_ok=True)
dest=folder/(uid()+ext);dest.write_bytes(data)
return {'url':'media/'+dest.name,'size':len(data)}
@app.get('/api/sites/{site_id}/media')
def media_list(site_id:str):
get_site(site_id)
folder=db.DATA/'media'/site_id
return [{'url':'media/'+p.name,'name':p.name,'bytes':p.stat().st_size} for p in sorted(folder.glob('*')) if p.is_file()] if folder.exists() else []
@app.get('/local-media/{site_id}/{filename}')
def media_view(site_id:str,filename:str):
get_site(site_id)
if not re.fullmatch(r'[0-9a-f]{12}\.(png|jpg|jpeg|webp|gif|avif)',filename):raise HTTPException(404)
path=db.DATA/'media'/site_id/filename
if not path.is_file():raise HTTPException(404)
return FileResponse(path)
@app.post('/api/sites/{site_id}/redirects')
def add_redirect(site_id:str,payload:RedirectIn):
get_site(site_id)
if payload.status_code not in (301,302,307,308):raise HTTPException(400,'无效的 HTTP 重定向状态')
old=route_path(payload.old_path)
if payload.new_path==old:raise HTTPException(400,'无法重定向到自身')
if not (payload.new_path.startswith('/') or payload.new_path.startswith('https://')):raise HTTPException(400,'无效目标')
try:
db.insert('INSERT INTO redirects(id,site_id,old_path,new_path,status_code) VALUES (?,?,?,?,?)',
(uid(),site_id,old,payload.new_path,payload.status_code))
except Exception as exc:fail(exc)
return {'created':True}
@app.get('/api/sites/{site_id}/redirects')
def list_redirects(site_id:str):
get_site(site_id)
return db.rows('SELECT * FROM redirects WHERE site_id=? ORDER BY old_path',(site_id,))
@app.delete('/api/sites/{site_id}/redirects/{redirect_id}')
def remove_redirect(site_id:str,redirect_id:str):
get_site(site_id)
db.execute('DELETE FROM redirects WHERE site_id=? AND id=?',(site_id,redirect_id))
return {'deleted':True}
@app.post('/api/sites/{site_id}/builds')
def new_build(site_id:str,payload:BuildIn):
get_site(site_id)
try:return builder.build(site_id,payload.mirror_images)
except Exception as exc:fail(exc)
@app.get('/api/sites/{site_id}/builds')
def list_builds(site_id:str):
get_site(site_id)
return db.rows('SELECT id,created_at,status,total_pages,report_json FROM builds WHERE site_id=? ORDER BY created_at DESC',(site_id,))
@app.get('/api/sites/{site_id}/builds/{build_id}/report')
def build_report(site_id:str,build_id:str):
record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not record:raise HTTPException(404)
return json.loads(record['report_json'])
@app.get('/api/sites/{site_id}/builds/{build_id}/download')
def download_build(site_id:str,build_id:str):
record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not record:raise HTTPException(404)
archive=Path(record['zip_path'])
if not archive.is_file():raise HTTPException(404,'构建压缩包丢失')
return FileResponse(archive,filename=f'siteforge-{site_id}-{build_id}.zip',media_type='application/zip')
@app.get('/preview/{site_id}/{build_id}/{path:path}')
def preview_path(site_id:str,build_id:str,path:str):
b=db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not b:raise HTTPException(404)
dist=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
target=dist/(path or 'index.html')
if target.is_dir():target=target/'index.html'
if not target.exists() and not Path(path).suffix:target=dist/path/'index.html'
if not target.resolve().is_relative_to(dist):raise HTTPException(404)
if not target.is_file():raise HTTPException(404)
if target.suffix.lower() in ('.html','.htm'):
html=target.read_text(encoding='utf-8')
html=html.replace('<base href="/">',f'<base href="/preview/{site_id}/{build_id}/">')
return HTMLResponse(html)
return FileResponse(target)
@app.get('/preview/{site_id}/{build_id}')
def preview_root(site_id:str,build_id:str):
return RedirectResponse(f'/preview/{site_id}/{build_id}/')
@app.get('/api/presets')
def get_presets():
return db.rows('SELECT id,name,theme,created_at FROM presets ORDER BY created_at DESC')
@app.post('/api/sites/{site_id}/save-preset')
def save_preset(site_id:str,payload:dict=Body(default_factory=dict)):
s=get_site(site_id)
homepage=db.one('SELECT meta_json FROM entries WHERE site_id=? AND route="/"',(site_id,))
meta=json.loads(homepage['meta_json'] or '{}') if homepage else {}
blocks=meta.get('blocks',[])
pid=uid()
name=str(payload.get('name') or s['name']+' 模板')[:150]
db.insert('INSERT INTO presets(id,name,theme,settings_json,home_blocks_json,created_at) VALUES (?,?,?,?,?,?)',
(pid,name,s['theme'],json.dumps(s['settings'],ensure_ascii=False),json.dumps(blocks,ensure_ascii=False),db.now()))
return {'id':pid,'name':name,'theme':s['theme']}
@app.post('/api/sites/{site_id}/media-archive')
async def import_media_archive(site_id:str,file:UploadFile=File(...)):
"""Upload a WordPress wp-content/uploads ZIP. Reject executable formats and unsafe paths."""
import zipfile
import stat
get_site(site_id)
if not (file.filename or '').lower().endswith('.zip'):
raise HTTPException(400,'请上传 .zip 媒体压缩包')
data=await file.read(80_000_001)
if len(data)>80_000_000:raise HTTPException(413,'压缩包最大 80MB')
count=0;skipped=0;size=0
allowed={'.png','.jpg','.jpeg','.gif','.webp','.avif','.pdf','.woff','.woff2','.ttf','.ico'}
target=db.DATA/'legacy'/site_id
try:
with zipfile.ZipFile(io.BytesIO(data)) as z:
infos=z.infolist()
if len(infos)>4000:raise ValueError('压缩包文件数过多')
for member in infos:
size+=member.file_size
if size>250_000_000:raise ValueError('解压后的数据总量超过 250MB')
for member in infos:
name=member.filename.replace('\\','/').lstrip('/')
parts=name.split('/')
if member.is_dir():continue
if any(p in ('.','..') for p in parts) or ':' in name or name.startswith('~'):
skipped+=1;continue
if stat.S_ISLNK(member.external_attr>>16):skipped+=1;continue
if Path(name).suffix.lower() not in allowed or member.file_size>25_000_000:
skipped+=1;continue
if 'wp-content' in parts:
name='/'.join(parts[parts.index('wp-content'):])
elif 'uploads' in parts:
name='wp-content/'+'/'.join(parts[parts.index('uploads'):])
else:
name='media/imported/'+name
dst=target/name
if not dst.resolve().is_relative_to(target.resolve()):skipped+=1;continue
dst.parent.mkdir(parents=True,exist_ok=True)
with z.open(member) as source, open(dst,'wb') as out:
shutil.copyfileobj(source,out)
count+=1
except Exception as exc:fail(exc)
return {'imported':count,'skipped':skipped,'bytes_uncompressed':size,
'message':'旧媒体将按原路径打包进下次构建;单独的源站页面和 URL 仍需核验'}
@app.post('/api/sites/{site_id}/builds/{build_id}/publish-local')
def publish_local(site_id:str,build_id:str):
"""Opt-in atomic local release; requires SITEFORGE_PUBLISH_ROOT env."""
import os
root=os.environ.get('SITEFORGE_PUBLISH_ROOT','').strip()
if not root:raise HTTPException(403,'本地部署未启用,请设置 SITEFORGE_PUBLISH_ROOT')
get_site(site_id)
build_record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
if not build_record:raise HTTPException(404,'构建版本不存在')
site=get_site(site_id)
if site['mode']=='migration':
report=enterprise.diff_baseline(site_id)
if report['baseline_count']==0:raise HTTPException(409,'迁移网站尚未导入旧 URL 基线,禁止直接发布')
if report['errors']:raise HTTPException(409,f'旧站 URL 基线检查未通过:缺失 {report["errors"]} 项')
# Four-eyes rule is enabled by default in the V2 Docker configuration.
if __import__('os').environ.get('SITEFORGE_REQUIRE_RELEASE_APPROVAL','false').lower() in ('1','true','yes'):
approval=db.one('SELECT id FROM release_requests WHERE site_id=? AND build_id=? AND state=? ORDER BY reviewed_at DESC LIMIT 1',
(site_id,build_id,'approved'))
if not approval:raise HTTPException(409,'当前构建版本尚未获得独立审批,禁止发布')
# Migration gate is re-evaluated on publish, not only at approval time.
if site['mode']=='migration' and enterprise.diff_baseline(site_id)['errors']:
raise HTTPException(409,'迁移基线再次检测未通过')
src=db.DATA/'builds'/site_id/build_id/'dist'
if not src.is_dir():raise HTTPException(404,'构建文件缺失')
parent=Path(root).resolve()/site_id
releases=parent/'releases';releases.mkdir(parents=True,exist_ok=True)
dest=releases/build_id
if not dest.exists():shutil.copytree(src,dest)
current=parent/'current'
if os.name=='nt':
previous=parent/'previous'
if previous.exists():shutil.rmtree(previous)
if current.exists():current.rename(previous)
shutil.copytree(dest,current)
else:
tmp=parent/('current-tmp-'+uid())
tmp.symlink_to(dest,target_is_directory=True)
os.replace(tmp,current)
enterprise.db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,build_id,'system','publish',db.now()))
return {'published':True,'root':str(current),'release':build_id,'message':'请确认 Nginx/Caddy 的站点根目录指向 current;DNS/CDN 不会被自动修改'}
+6
View File
@@ -0,0 +1,6 @@
"""Application identity shared by the API, health checks and UI clients."""
APP_NAME = "SiteForge"
APP_VERSION = "5.0.0"
API_TITLE = "SiteForge 网站工厂"
API_DESCRIPTION = "Docker-first visual website factory"
+245
View File
@@ -0,0 +1,245 @@
"""Offline, reproducible website migration inventory and built-artifact comparison.
No network calls. The source manifest is treated as untrusted input and never as
an instruction. Unsupported dynamic URLs are reported instead of rewritten.
"""
from __future__ import annotations
import csv, hashlib, io, json, re
from pathlib import Path
from urllib.parse import urlsplit
from bs4 import BeautifulSoup
from defusedxml import ElementTree as ET
from fastapi import APIRouter, Body, File, HTTPException, Request, UploadFile
from . import db, enterprise, builder
from .core import route_path
router=APIRouter(prefix='/api/audit',tags=['Migration Audit V4'])
MAX_BASELINE=20_000
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS source_audit_fields(
site_id TEXT NOT NULL, route TEXT NOT NULL,
robots TEXT NOT NULL DEFAULT '', h1 TEXT NOT NULL DEFAULT '',
original_url TEXT NOT NULL DEFAULT '',
PRIMARY KEY(site_id,route),
FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS source_audit_assets(
site_id TEXT NOT NULL, url TEXT NOT NULL,
source_route TEXT NOT NULL DEFAULT '',
PRIMARY KEY(site_id,url,source_route),
FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS source_audit_exceptions(
site_id TEXT NOT NULL, build_id TEXT NOT NULL, issue_id TEXT NOT NULL,
reviewer TEXT NOT NULL, explanation TEXT NOT NULL, decided_at TEXT NOT NULL,
PRIMARY KEY(site_id,build_id,issue_id));
''')
def _route(value:str):
text=str(value or '').strip()
if not text:raise ValueError('缺少页面路径')
u=urlsplit(text)
if u.query:raise ValueError('需要单独处理带查询参数的动态 URL')
if u.scheme and u.scheme not in ('https','http'):raise ValueError('不支持的 URL 协议')
return route_path(u.path or '/')
def _str(row,*keys):
for key in keys:
if row.get(key) is not None and str(row[key]).strip():return str(row[key]).strip()
return ''
def _normalize(row):
original=_str(row,'url','loc','original_url','link','route','path')
route=_route(original)
status=int(row.get('status') or 200)
if status not in (200,301,302,307,308,404,410):raise ValueError('不支持的 HTTP 状态')
return {'route':route,'original_url':original,'seo_title':_str(row,'seo_title','title','meta_title'),
'description':_str(row,'description','seo_description','meta_description'),
'canonical':_str(row,'canonical'),'robots':_str(row,'robots'),
'h1':_str(row,'h1'),'status':status}
def _wp_item(element):
def get(local,default=''):
for ch in element:
if ch.tag.rsplit('}',1)[-1]==local:return (ch.text or default)
return default
def get_content(local):
for ch in element:
if ch.tag.rsplit('}',1)[-1]==local:return ch.text or ''
return ''
meta={}
for child in element:
if child.tag.rsplit('}',1)[-1]=='postmeta':
key=value=''
for part in child:
if part.tag.endswith('meta_key'):key=part.text or ''
elif part.tag.endswith('meta_value'):value=part.text or ''
if key:meta[key]=value
return (get('post_type','post'),get('status','publish'),get('link'),get('title'),get_content('encoded'),meta)
def from_file(filename:str,blob:bytes):
if len(blob)>16_000_000:raise ValueError('源站清单不能超过 16 MB')
name=filename.lower();rows=[];assets=[];warnings=[]
if name.endswith('.csv'):
rows=list(csv.DictReader(io.StringIO(blob.decode('utf-8-sig'))))
elif name.endswith('.json'):
data=json.loads(blob.decode('utf-8-sig'))
rows=data.get('urls',data.get('pages',[])) if isinstance(data,dict) else data
if not isinstance(rows,list):raise ValueError('JSON 应包含 pages/urls 数组')
elif name.endswith('.xml'):
root=ET.fromstring(blob)
root_name=root.tag.rsplit('}',1)[-1]
if root_name=='urlset':
rows=[{'url':next((c.text or '' for c in node if c.tag.rsplit('}',1)[-1]=='loc'),'')} for node in root]
elif root_name=='rss':
for item in root.findall('./channel/item'):
typ,status,url,title,body,meta=_wp_item(item)
if typ=='attachment':
if url:assets.append((url,''))
continue
if status!='publish' or typ in ('revision','nav_menu_item'):continue
if not url:
warnings.append('WXR 某条记录缺少真实链接,无法推断原路径:'+title[:70]);continue
rows.append({'url':url,'title':meta.get('_yoast_wpseo_title') or meta.get('rank_math_title') or title,
'description':meta.get('_yoast_wpseo_metadesc') or meta.get('rank_math_description') or '',
'canonical':meta.get('_yoast_wpseo_canonical') or meta.get('rank_math_canonical_url') or '',
'robots':'noindex,follow' if meta.get('_yoast_wpseo_meta-robots-noindex')=='1' else '',
'h1':title})
soup=BeautifulSoup(body,'html.parser')
for img in soup.select('img[src]'):
assets.append((img.get('src',''),url))
else:raise ValueError('XML 需要 WordPress WXR 或 sitemap.xml')
else:raise ValueError('仅支持 .csv / .json / WordPress WXR.xml / sitemap.xml')
if not 1<=len(rows)<=MAX_BASELINE:raise ValueError('URL 清单需要1至20000条')
out=[];errors=[];seen=set()
for i,row in enumerate(rows,1):
try:
if not isinstance(row,dict):raise ValueError('记录不是对象')
normalized=_normalize(row)
if normalized['route'] in seen:raise ValueError('原路径重复')
seen.add(normalized['route']);out.append(normalized)
except Exception as exc:errors.append({'row':i,'message':str(exc)[:180]})
return {'records':out,'assets':assets,'errors':errors,'warnings':warnings,'total':len(rows)}
def save(site_id,manifest,replace=True):
if manifest['errors']:raise ValueError('URL 清单存在格式错误,请修正后再导入')
with db.connect() as c:
if replace:
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
c.execute('DELETE FROM source_audit_fields WHERE site_id=?',(site_id,))
c.execute('DELETE FROM source_audit_assets WHERE site_id=?',(site_id,))
for row in manifest['records']:
c.execute('''INSERT INTO migration_baseline(site_id,route,seo_title,description,canonical,status)
VALUES(?,?,?,?,?,?) ON CONFLICT(site_id,route) DO UPDATE SET
seo_title=excluded.seo_title,description=excluded.description,canonical=excluded.canonical,status=excluded.status''',
(site_id,row['route'],row['seo_title'],row['description'],row['canonical'],row['status']))
c.execute('''INSERT INTO source_audit_fields(site_id,route,robots,h1,original_url) VALUES(?,?,?,?,?)
ON CONFLICT(site_id,route) DO UPDATE SET robots=excluded.robots,h1=excluded.h1,original_url=excluded.original_url''',
(site_id,row['route'],row['robots'],row['h1'],row['original_url']))
for url,origin in manifest['assets'][:MAX_BASELINE]:
if not url or len(url)>1000:continue
c.execute('INSERT OR IGNORE INTO source_audit_assets VALUES(?,?,?)',(site_id,url,origin[:500]))
return {'imported':len(manifest['records']),'asset_references':len(manifest['assets']),'warnings':manifest['warnings']}
def _tag(soup,name,attrs=None):
node=soup.find(name,attrs=attrs or {})
return node.get('content','').strip() if node and name=='meta' else node.get_text(' ',strip=True) if node else ''
def audit(site_id,build_id):
# Actual generated bytes, not source entry metadata, are the authority.
root=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
if not root.is_dir() or not db.one('SELECT id FROM builds WHERE id=? AND site_id=?',(build_id,site_id)):
raise ValueError('构建版本不存在')
baseline=db.rows('''SELECT b.*,x.robots,x.h1,x.original_url FROM migration_baseline b
LEFT JOIN source_audit_fields x ON b.site_id=x.site_id AND b.route=x.route
WHERE b.site_id=? ORDER BY b.route''',(site_id,))
if not baseline:return {'baseline_count':0,'errors':1,'warnings':0,'passed':False,
'issues':[{'severity':'error','field':'baseline','message':'未导入源站 URL 清单'}]}
redirects={r['old_path']:(r['new_path'],r['status_code']) for r in db.rows('SELECT old_path,new_path,status_code FROM redirects WHERE site_id=?',(site_id,))}
issues=[];matched=0
for old in baseline:
route=old['route'];expect_status=old['status']; target=builder.output_path(root,route)
if expect_status in (301,302,307,308,404,410):
if expect_status in (301,302,307,308):
if route not in redirects:
issues.append({'route':route,'field':'redirect','severity':'error','message':'历史重定向未配置'})
elif redirects[route][1]!=expect_status:
issues.append({'route':route,'field':'redirect_status','severity':'error','message':'重定向状态码与旧站不一致'})
elif target.is_file():
issues.append({'route':route,'field':'http_status','severity':'error','message':'旧站的不存在/已删除页面被错误地生成了内容'})
continue
if not target.is_file():
issues.append({'route':route,'field':'url','severity':'error','message':'旧站正常页面在生成网站中缺失'})
continue
matched+=1
soup=BeautifulSoup(target.read_text(encoding='utf-8',errors='replace'),'html.parser')
title=_tag(soup,'title')
desc_node=soup.find('meta',attrs={'name':re.compile('^description$',re.I)})
description=(desc_node.get('content','') if desc_node else '').strip()
canonical_node=soup.find('link',rel=lambda value:value and 'canonical' in value)
canonical=(canonical_node.get('href','') if canonical_node else '').strip()
robot_node=soup.find('meta',attrs={'name':re.compile('^robots$',re.I)})
robots=(robot_node.get('content','') if robot_node else '').strip().lower()
h1=_tag(soup,'h1')
for source,current,label in [('seo_title',title,'title'),('description',description,'description'),('canonical',canonical,'canonical'),('robots',robots,'robots'),('h1',h1,'h1')]:
if old.get(source) and ' '.join(str(old[source]).split())!=' '.join(str(current).split()):
issues.append({'route':route,'field':label,'severity':'warning','message':'旧新页面字段不一致',
'before':str(old[source])[:160],'after':str(current)[:160]})
other=__import__('app.quality',fromlist=['report']).report(site_id,build_id)
for q in other['issues']:
if q['severity']=='error':issues.append({'route':q['file'],'field':q['type'],'severity':'error','message':str(q.get('target','构建质量问题'))[:180]})
decisions={x['issue_id']:x for x in db.rows('SELECT * FROM source_audit_exceptions WHERE site_id=? AND build_id=?',(site_id,build_id))}
for issue in issues:
token=json.dumps([issue.get(k,'') for k in ('route','field','message','before','after')],ensure_ascii=False,sort_keys=True)
issue['issue_id']=hashlib.sha256(token.encode()).hexdigest()[:24]
approved=decisions.get(issue['issue_id'])
issue['reviewed']=bool(approved) if issue['severity']=='warning' else False
if approved:
issue['reviewer']=approved['reviewer']
issue['review_reason']=approved['explanation']
outstanding=sum(i['severity']=='warning' and not i['reviewed'] for i in issues)
return {'baseline_count':len(baseline),'matched_pages':matched,
'errors':sum(x['severity']=='error' for x in issues),
'warnings':sum(x['severity']=='warning' for x in issues),
'passed':not any(x['severity']=='error' for x in issues),'unreviewed_warnings':outstanding,
'quality':{'files':other['files'],'errors':other['errors'],'warnings':other['warnings']},
'issues':issues[:2000]}
@router.post('/sites/{site_id}/import-baseline')
async def import_baseline(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
data=await file.read(16_000_001)
try:
manifest=from_file(file.filename or '',data)
if manifest['errors']:
return {'imported':0,'errors':manifest['errors'][:200],'total':manifest['total'],'warnings':manifest['warnings']}
return save(site_id,manifest)
except (ValueError,UnicodeDecodeError) as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/builds/{build_id}/report')
def report(request:Request,site_id:str,build_id:str):
enterprise.require(request,site_id,'viewer')
try:return audit(site_id,build_id)
except ValueError as exc:raise HTTPException(404,str(exc))
@router.post('/sites/{site_id}/builds/{build_id}/review-warning')
def approve_warning(request:Request,site_id:str,build_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'reviewer')
issue_id=str(payload.get('issue_id',''))
reason=str(payload.get('reason','')).strip()
if len(reason)<10 or len(reason)>2000:raise HTTPException(400,'请填写至少10个字符的复核原因及处理依据')
evidence=audit(site_id,build_id)
issue=next((x for x in evidence['issues'] if x['issue_id']==issue_id and x['severity']=='warning'),None)
if not issue:raise HTTPException(404,'只能复核此构建版本当前存在的警告')
db.insert('INSERT INTO source_audit_exceptions(site_id,build_id,issue_id,reviewer,explanation,decided_at) VALUES(?,?,?,?,?,?) ON CONFLICT(site_id,build_id,issue_id) DO UPDATE SET reviewer=excluded.reviewer,explanation=excluded.explanation,decided_at=excluded.decided_at',
(site_id,build_id,issue_id,request.state.actor,reason,db.now()))
return {'approved':True,'issue_id':issue_id,'reviewer':request.state.actor}
@router.get('/sites/{site_id}/builds/{build_id}/report.csv')
def report_csv(request:Request,site_id:str,build_id:str):
from fastapi.responses import Response
enterprise.require(request,site_id,'viewer')
result=audit(site_id,build_id)
dest=io.StringIO();writer=csv.DictWriter(dest,fieldnames=['severity','route','field','message','before','after'],extrasaction='ignore')
writer.writeheader();writer.writerows(result['issues'])
return Response('\ufeff'+dest.getvalue(),media_type='text/csv',headers={'Content-Disposition':'attachment; filename="migration-audit.csv"'})
+56
View File
@@ -0,0 +1,56 @@
"""Offline static-build quality gate. No external network requests or speculative SEO scores."""
from pathlib import Path
from urllib.parse import urlsplit,unquote
from bs4 import BeautifulSoup
from . import db
def report(site_id, build_id):
root=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
if not root.is_dir():raise FileNotFoundError('构建版本不存在')
files=list(root.rglob('*.html'))
site=db.one('SELECT domain FROM sites WHERE id=?',(site_id,)) or {}
hostname=urlsplit(site.get('domain') or '').hostname
issues=[];seen_titles={};seen_descriptions={}
for p in files:
rel=p.relative_to(root).as_posix()
soup=BeautifulSoup(p.read_text(encoding='utf-8',errors='replace'),'html.parser')
title=(soup.title.get_text(' ',strip=True) if soup.title else '')
h1=soup.find_all('h1')
if not title:issues.append({'file':rel,'type':'missing_title','severity':'error'})
if len(h1)!=1:issues.append({'file':rel,'type':'h1_count','severity':'warning','count':len(h1)})
if title and title in seen_titles and p.name!='404.html':issues.append({'file':rel,'type':'duplicate_title','severity':'warning','other':seen_titles[title]})
seen_titles[title]=rel
description=soup.find('meta',attrs={'name':'description'})
desc=description.get('content','').strip() if description else ''
if not desc:issues.append({'file':rel,'type':'missing_description','severity':'warning'})
if desc and desc in seen_descriptions:issues.append({'file':rel,'type':'duplicate_description','severity':'warning','other':seen_descriptions[desc]})
seen_descriptions[desc]=rel
for img in soup.find_all('img'):
if not img.get('alt'):issues.append({'file':rel,'type':'missing_image_alt','severity':'warning','src':img.get('src','')[:180]})
for node in soup.find_all(['a','img','script','link']):
attr='href' if node.name in ('a','link') else 'src'
target=node.get(attr)
if not target:continue
parsed=urlsplit(target)
if parsed.scheme in ('http','https'):
if parsed.hostname!=hostname:continue
path=parsed.path
elif parsed.scheme or target.startswith('//'):continue
elif target.startswith('#'):continue
else:path=parsed.path
if not path:continue
if node.name=='link' and node.get('rel') and 'canonical' in node.get('rel'):continue
from_root=path.startswith('/')
# HTML uses <base href="/">, which makes relative paths site-root relative.
base=soup.find('base',href=True)
if base and base.get('href')=='/':from_root=True
raw=unquote(path.lstrip('/'))
if not from_root:raw=(p.relative_to(root).parent/raw).as_posix()
test=(root/raw)
if target.endswith('/') or not Path(raw).suffix:test=test/'index.html'
if not test.resolve().is_relative_to(root) or not test.is_file():
if node.name=='link' and ('alternate' in (node.get('rel') or [])):continue
issues.append({'file':rel,'type':'broken_internal_reference','severity':'error','target':target[:220]})
return {'files':len(files),'errors':sum(i['severity']=='error' for i in issues),
'warnings':sum(i['severity']=='warning' for i in issues),
'passed':not any(i['severity']=='error' for i in issues),'issues':issues[:500]}
+50
View File
@@ -0,0 +1,50 @@
"""Content assembly: stable, site-scoped brand/category relationships.
Only materializes associations supported by source data. Generated brand shells
are clearly marked and contain no fabricated copy.
"""
from collections import defaultdict
from . import db
from .core import save_entry
from .theme_pro import fields,slug
KINDS=('product','vehicle')
def preview(site_id):
records=db.rows("SELECT * FROM entries WHERE site_id=? AND type IN ('product','vehicle','brand')",(site_id,))
brand_pages={r['title'].casefold():r for r in records if r['type']=='brand'}
routes={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))}
brands=defaultdict(list);cats=defaultdict(list);issues=[]
for row in records:
if row['type'] not in KINDS:continue
attrs=fields(row)
brand=str(attrs.get('brand') or '').strip();category=str(attrs.get('category') or '').strip()
if brand: brands[brand].append(row['route'])
else:issues.append({'route':row['route'],'severity':'review','message':'未填写品牌'})
if category:cats[category].append(row['route'])
else:issues.append({'route':row['route'],'severity':'review','message':'未填写分类'})
if not row['cover']:issues.append({'route':row['route'],'severity':'review','message':'缺少产品封面图'})
candidates=[]
for name,products in brands.items():
if name.casefold() in brand_pages:continue
url=f'/brands/{slug(name)}/'
if url in routes:
issues.append({'route':url,'severity':'block','message':'品牌自动生成路径与已有页面冲突'});continue
candidates.append({'brand':name,'route':url,'products':len(products)})
return {'brands':[{ 'name':k,'items':len(v)} for k,v in brands.items()],
'categories':[{'name':k,'items':len(v)} for k,v in cats.items()],
'brand_pages_missing':candidates,'issues':issues,
'products':sum(len(x) for x in brands.values()),
'note':'仅根据已有产品字段建立关系;缺少真实品牌介绍/图片时不会自动编造。'}
def commit(site_id):
result=preview(site_id)
if any(issue['severity']=='block' for issue in result['issues']):
raise ValueError('存在路径冲突,请先处理后再装配')
created=[]
for brand in result['brand_pages_missing']:
save_entry(site_id,{'type':'brand','title':brand['brand'],'route':brand['route'],
'summary':'','body':'','status':'published','meta':{'fields':{'source':'auto-assembled'},'source_auto_assembled':True}},
source='relations-assembly')
created.append(brand['route'])
return {'created_brand_pages':created,'count':len(created),'preview':result}
+25
View File
@@ -0,0 +1,25 @@
"""Safe per-device block CSS using validated data-block identifiers."""
import re,json
def editor_css(blocks):
tablet=[];mobile=[]
for b in blocks:
uid=b.get('id','')
if not re.fullmatch('[a-zA-Z0-9_-]{4,50}',uid):continue
s=b.get('style') or {}
try:
tp=min(120,max(0,int(s.get('tablet_padding',50))))
mp=min(120,max(0,int(s.get('mobile_padding',28))))
except (TypeError,ValueError):tp,mp=50,28
tablet.append(f'.sf-section[data-block="{uid}"]{{--sf-pad:{tp}px}}')
mobile.append(f'.sf-section[data-block="{uid}"]{{--sf-pad:{mp}px}}')
if s.get('hide_tablet') is True:tablet.append(f'.sf-section[data-block="{uid}"]{{display:none}}')
if s.get('hide_mobile') is True:mobile.append(f'.sf-section[data-block="{uid}"]{{display:none}}')
return '@media(max-width:820px){'+''.join(tablet)+'}@media(max-width:540px){'+''.join(mobile)+'}'
def site_css(entries):
items=[]
for entry in entries:
meta=json.loads(entry.get('meta_json') or '{}')
items.extend(meta.get('blocks',[]) if isinstance(meta.get('blocks'),list) else [])
return editor_css(items)
+53
View File
@@ -0,0 +1,53 @@
"""Site-scoped reusable editor component library with versioned snapshots."""
import json
from fastapi import APIRouter,Body,HTTPException,Request
from . import db,enterprise,blueprints
from .core import uid
router=APIRouter(prefix='/api/sites',tags=['Reusable Components'])
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS reusable_components(
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,version INTEGER NOT NULL DEFAULT 1,block_json TEXT NOT NULL,
updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_reuse_site ON reusable_components(site_id);
''')
@router.get('/{site_id}/components')
def all_components(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return [{**r,'block':json.loads(r.pop('block_json'))} for r in db.rows(
'SELECT * FROM reusable_components WHERE site_id=? ORDER BY updated_at DESC',(site_id,))]
@router.post('/{site_id}/components')
def save_component(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
name=str(payload.get('name','')).strip()[:100]
if not name:raise HTTPException(400,'组件名称不能为空')
try:block=blueprints.validate_blocks([payload.get('block')])[0]
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
cid=uid()
db.insert('INSERT INTO reusable_components VALUES (?,?,?,?,?,?)',
(cid,site_id,name,1,json.dumps(block,ensure_ascii=False),db.now()))
return {'id':cid,'name':name,'version':1,'block':block}
@router.put('/{site_id}/components/{component_id}')
def update_component(request:Request,site_id:str,component_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
current=db.one('SELECT * FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id))
if not current:raise HTTPException(404)
if payload.get('expected_version')!=current['version']:raise HTTPException(409,'组件已被修改,请重新加载')
try:block=blueprints.validate_blocks([payload.get('block')])[0]
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
db.execute('UPDATE reusable_components SET name=?,block_json=?,version=?,updated_at=? WHERE id=? AND site_id=?',
(str(payload.get('name') or current['name'])[:100],json.dumps(block,ensure_ascii=False),current['version']+1,db.now(),component_id,site_id))
return {'id':component_id,'version':current['version']+1}
@router.delete('/{site_id}/components/{component_id}')
def delete_component(request:Request,site_id:str,component_id:str):
enterprise.require(request,site_id,'editor')
if not db.execute('DELETE FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id)):
raise HTTPException(404)
return {'deleted':True}
+104
View File
@@ -0,0 +1,104 @@
"""One-owner authentication for the SiteForge Docker Web edition.
Session cookie is signed and HttpOnly. JSON mutations additionally require a
session-bound CSRF header; this is intentionally a small-team / single-admin
MVP, not a multi-tenant identity provider.
"""
import hashlib
import hmac
import os
import secrets
import time
from collections import defaultdict
from pathlib import Path
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from fastapi import HTTPException
COOKIE = 'siteforge_session'
MAX_AGE = 8 * 3600
SERIALIZER = None
PASSWORD_HASH = b''
PASSWORD_SALT = b''
CSRF_KEY = b''
COOKIE_SECURE = False
FAILURES = defaultdict(list)
def configure():
global SERIALIZER, PASSWORD_HASH, PASSWORD_SALT, CSRF_KEY, COOKIE_SECURE
password_file = os.environ.get('SITEFORGE_ADMIN_PASSWORD_FILE', '').strip()
if password_file:
password = Path(password_file).read_text(encoding='utf-8').strip('\r\n')
else:
password = os.environ.get('SITEFORGE_ADMIN_PASSWORD', '')
secret = os.environ.get('SITEFORGE_SESSION_SECRET', '')
weak = {'password', 'admin123456', 'change-me', 'replace-me', 'changeme', '123456789', 'REPLACE_WITH_STRONG_PASSWORD'}
if len(password) < 12 or password in weak:
raise RuntimeError('请设置至少 12 位的 SITEFORGE_ADMIN_PASSWORD,或配置 SITEFORGE_ADMIN_PASSWORD_FILE')
if len(secret) < 32 or secret in weak or secret == 'REPLACE_WITH_64_RANDOM_CHARACTERS':
raise RuntimeError('请配置至少 32 位的随机 SITEFORGE_SESSION_SECRET')
SERIALIZER = URLSafeTimedSerializer(secret_key=secret, salt='siteforge-web-v02')
# Keep the deployment secret out of memory after startup and make each
# verification deliberately expensive, consistent with team accounts.
PASSWORD_SALT = secrets.token_bytes(16)
PASSWORD_HASH = hashlib.scrypt(password.encode('utf-8'),salt=PASSWORD_SALT,n=2**14,r=8,p=1,dklen=32)
CSRF_KEY = hashlib.sha256(('siteforge-csrf-v02:'+secret).encode()).digest()
COOKIE_SECURE = os.environ.get('SITEFORGE_COOKIE_SECURE', 'false').lower() in ('1', 'true', 'yes')
FAILURES.clear()
def check_password(password):
try:
candidate=hashlib.scrypt(password.encode('utf-8'),salt=PASSWORD_SALT,n=2**14,r=8,p=1,dklen=32)
return hmac.compare_digest(candidate,PASSWORD_HASH)
except (ValueError,TypeError,MemoryError):
return False
def sign_session():
session_id = secrets.token_urlsafe(32)
return SERIALIZER.dumps({'sub': 'admin', 'sid': session_id, 'v': 2})
def sign_user_session(user_id):
return SERIALIZER.dumps({'sub':'user','user_id':user_id,'sid':secrets.token_urlsafe(32),'v':3})
def read_session(cookie):
if not cookie or SERIALIZER is None:
return None
try:
value = SERIALIZER.loads(cookie, max_age=MAX_AGE)
if (value.get('sub'),value.get('v')) not in (('admin',2),('user',3)):
return None
return value
except (BadSignature, SignatureExpired, ValueError, TypeError):
return None
def csrf(session):
return hmac.new(CSRF_KEY, session['sid'].encode(), hashlib.sha256).hexdigest()
def verify_csrf(session, supplied):
return bool(supplied) and hmac.compare_digest(csrf(session), supplied)
def check_login_rate(remote):
now = time.monotonic()
# Drop expired buckets so spoofed source addresses cannot grow memory
# without bound on a long-running process.
for key in list(FAILURES):
recent=[t for t in FAILURES[key] if now-t < 900]
if recent:FAILURES[key]=recent
else:FAILURES.pop(key,None)
FAILURES[remote] = [t for t in FAILURES[remote] if now-t < 900]
if len(FAILURES[remote]) >= 8:
raise HTTPException(429, '登录尝试次数过多,请稍后重试')
def note_login_failure(remote):
FAILURES[remote].append(time.monotonic())
def note_login_success(remote):
FAILURES.pop(remote, None)
File diff suppressed because one or more lines are too long
+96
View File
@@ -0,0 +1,96 @@
'use strict';
const $=(q,el=document)=>el.querySelector(q);
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const ATTR=s=>E(s);
const cfg={automotive:{label:'汽车与出行',desc:'高级质感 · 深色车型展厅',prev:'auto',title:'Move beyond.'},industrial:{label:'工业制造',desc:'专业理性 · 工业产品展示',prev:'ind',title:'Built to last.'},corporate:{label:'企业品牌',desc:'现代明亮 · 集团企业官网',prev:'corp',title:'Ideas become impact.'},minimal:{label:'极简设计',desc:'编辑式排版 · 极简作品展示',prev:'min',title:'Less, but better.'}};
let csrfToken='';
const state={sites:[],siteId:null,site:null,entries:[],builds:[],tab:'overview',presets:[],query:'',typ:''};
const api=async(path,opt={})=>{
if(!['GET','HEAD','OPTIONS'].includes((opt.method||'GET').toUpperCase())) {opt.headers={...(opt.headers||{}),'X-SiteForge-CSRF':csrfToken}}
const res=await fetch(path,opt);if(res.status===401){location.assign('/login');throw new Error('登录已过期')}let data;try{data=await res.json()}catch{data={detail:'请求失败,响应不是 JSON'}}
if(!res.ok)throw new Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail||data));return data;
};
const json=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const notice=(s,bad=false)=>{const t=$('#toast');t.innerText=s;t.style.background=bad?'#a03e45':'#243952';t.classList.add('show');clearTimeout(t._timer);t._timer=setTimeout(()=>t.classList.remove('show'),4300)};
const onError=e=>{console.error(e);notice(e.message||String(e),true)};
const simpleDate=s=>s?new Date(s).toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'—';
const b=(text,fn,extra='')=>`<button class="btn light small" ${extra} onclick="${fn}">${text}</button>`;
const tabs=[['overview','工作概览'],['entries','内容管理'],['templates','模板与设计'],['design','可视化页面设计'],['models','内容字段模型'],['import','旧站 / 数据导入'],['seo','SEO / URL'],['builds','构建与导出']];
function topTitle(name){$('#topTitle').textContent=name}
function hero(title,desc,buttons='',eyebrow='SITEFORGE / WEBSITE FACTORY'){return `<div class="page-head"><div><span class="eyebrow">${E(eyebrow)}</span><h1>${E(title)}</h1><p>${E(desc)}</p></div><div class="top-actions">${buttons}</div></div>`}
function themeCard(key,action){let t=cfg[key];return `<div class="template-card"><div class="template-art preview-${t.prev}${key==='minimal'?' min':''}"><span>THEME / ${E(key.toUpperCase())}</span><strong>${E(t.title)}</strong></div><div class="detail"><div><h3>${E(t.label)}</h3><p>${E(t.desc)}</p></div>${action}</div></div>`}
const App={
async init(){try{const me=await api('/api/auth/me');csrfToken=me.csrf;state.sites=await api('/api/sites');state.presets=await api('/api/presets');App.renderSiteNav();App.openDashboard()}catch(e){onError(e)}},
async logout(){try{await api('/api/auth/logout',{method:'POST'});location.assign('/login')}catch(e){onError(e)}},
async reload(){state.sites=await api('/api/sites');App.renderSiteNav();if(state.siteId){state.site=await api('/api/sites/'+state.siteId);state.entries=await api('/api/sites/'+state.siteId+'/entries');state.builds=await api('/api/sites/'+state.siteId+'/builds')}},
renderSiteNav(){$('#siteNav').innerHTML=state.sites.map(s=>`<button class="${s.id===state.siteId?'active':''}" onclick="App.openSite('${s.id}')"><span class="site-dot">${E((s.name||'S')[0])}</span><span title="${ATTR(s.name)}">${E(s.name)}</span></button>`).join('')||'<div style="padding:10px 12px;font-size:11px;color:#7185a2">暂无网站项目</div>'},
openDashboard(){state.siteId=null;topTitle('网站总览');App.renderSiteNav();document.querySelectorAll('.side-link').forEach(x=>x.classList.remove('selected'));$('#nav-dashboard').classList.add('selected');
let n=state.sites.length, counts=state.sites.reduce((x,s)=>x+s.entries_count,0),builds=state.sites.reduce((x,s)=>x+s.build_count,0);
$('#view').innerHTML=hero('网站生产工作台','从零创建、迁移旧站、导入内容、更换模板,统一生产独立静态网站。',`<button class="btn primary" onclick="App.openNewSite()">+ 创建网站</button>`,'WELCOME TO YOUR FACTORY')+
`<div class="metrics-grid"><div class="metric"><small>网站总数</small><strong>${n}</strong><span>独立站点项目</span></div><div class="metric"><small>内容条目</small><strong>${counts}</strong><span>页面、产品、文章等</span></div><div class="metric"><small>构建版本</small><strong>${builds}</strong><span>可预览与下载</span></div><div class="metric"><small>可用主题</small><strong>4</strong><span>支持每站独立风格</span></div></div>`+
`<div class="page-head" style="margin-top:35px"><div><h1 style="font-size:21px">我的网站</h1><p>选择任意站点,继续编辑、导入数据或构建版本</p></div></div><div class="cards">`+
state.sites.map(s=>`<article class="site-card" onclick="App.openSite('${s.id}')"><div class="card-preview preview-${cfg[s.theme]?.prev||'corp'}"><span class="card-tag">${E(cfg[s.theme]?.label||'企业站')}</span><div class="brandmark">${E(s.name)}</div></div><div class="site-card-body"><h3>${E(s.name)}</h3><p>${E(s.domain||'待绑定域名')} · ${E(s.tagline||'尚未填写站点简介')}</p><div class="site-card-meta"><span>${s.entries_count} 条内容</span><span>${s.build_count} 次构建 →</span></div></div></article>`).join('')+`<article class="add-card" onclick="App.openNewSite()"><div class="add-circle">+</div><h3>创建新网站</h3><p>旧站迁移 / 新建站点 / 导入内容</p></article></div>`+
(!state.sites.length?`<div class="panel" style="margin-top:26px"><div class="panel-title"><div><h3>第一次体验 SiteForge?</h3><p>生成演示网站,并立即构建一个可独立下载的纯静态网站。</p></div><button class="btn light" onclick="App.seedDemo()">快速创建演示网站 →</button></div></div>`:'');
},
async openSite(id,tab='overview'){try{state.siteId=id;state.tab=tab;await App.reload();topTitle(state.site.name);App.renderSiteNav();App.renderSite()}catch(e){onError(e)}},
renderSite(){const s=state.site;let head=hero(s.name,s.domain||'独立网站 · 尚未设置域名',`<button class="btn light" onclick="App.openEntry()">+ 添加内容</button><button class="btn primary" onclick="App.build()">↗ 构建网站</button>`,'WEBSITE PROJECT / '+s.theme.toUpperCase());
let navigation=`<div class="tabs">${tabs.map(([id,name])=>`<button class="tab ${state.tab===id?'current':''}" onclick="App.changeTab('${id}')">${name}</button>`).join('')}</div>`;
$('#view').innerHTML=head+`<div class="metrics-grid"><div class="metric"><small>页面 / 内容</small><strong>${state.entries.length}</strong><span>统一内容数据中心</span></div><div class="metric"><small>主题</small><strong style="font-size:17px;padding:9px 0">${E(cfg[s.theme]?.label)}</strong><span>可随时切换</span></div><div class="metric"><small>构建次数</small><strong>${state.builds.length}</strong><span>独立构建版本</span></div><div class="metric"><small>SEO 模式</small><strong style="font-size:17px;padding:9px 0">${s.mode==='migration'?'原址保留':'灵活新建'}</strong><span>路由独立管理</span></div></div>`+navigation+`<div id="tab-content"></div>`;App.renderTab();
},
changeTab(tab){state.tab=tab;App.renderSite()},
renderTab(){let html='';switch(state.tab){case'overview':html=App.overview();break;case'entries':html=App.entriesView();break;case'templates':html=App.templateView();break;case'design':html=App.designView();break;case'models':html=App.modelsView();break;case'import':html=App.importView();break;case'seo':html=App.seoView();break;case'builds':html=App.buildView();break;}$('#tab-content').innerHTML=html;if(state.tab==='import')App.loadMedia();if(state.tab==='seo')App.loadRedirects();if(state.tab==='models')App.loadModels()},
overview(){let s=state.site, latest=state.builds[0];return `<div class="panel"><div class="panel-title"><div><h3>网站生产流程</h3><p>每次修改内容、主题或域名后都可以重新构建</p></div></div><div class="steps"><div class="step"><span>STEP 01</span><strong>创建网站</strong><p>选择主题,完成独立站点初始化</p></div><div class="step"><span>STEP 02</span><strong>添加或导入内容</strong><p>手动录入、旧站采集、文件批量导入</p></div><div class="step"><span>STEP 03</span><strong>主题与 SEO</strong><p>自定义视觉样式、路由及页面元信息</p></div><div class="step"><span>STEP 04</span><strong>构建并导出</strong><p>生成静态 HTML 文件,预览并下载 ZIP</p></div></div></div><div class="split"><div class="panel"><div class="panel-title"><div><h3>网站基本资料</h3><p>设置域名、品牌和原网站来源</p></div></div>${App.settingsForm()}</div><div class="panel"><div class="panel-title"><div><h3>最近构建版本</h3><p>构建文件与管理后台彼此独立</p></div></div>${latest?`<div class="pill green">静态构建完成</div><h2 style="margin:16px 0">${latest.total_pages} 个 HTML 页面</h2><p style="color:#8292a8">${simpleDate(latest.created_at)}</p><div class="panel-footer" style="justify-content:start"><a class="btn light" target="_blank" href="/preview/${s.id}/${latest.id}/">查看预览 ↗</a><a class="btn primary" href="/api/sites/${s.id}/builds/${latest.id}/download">下载 ZIP</a></div>`:`<div class="empty"><div>⌁</div><h3>还没有构建版本</h3><p>保存内容并点击「构建网站」,即可生成首个网站。</p><button class="btn primary" onclick="App.build()">开始第一次构建</button></div>`}</div></div>`},
settingsForm(){let s=state.site;return `<form id="settingsForm" onsubmit="App.saveSettings(event)"><div class="setting-grid"><label class="full">网站名称<input name="name" value="${ATTR(s.name)}" required></label><label class="full">正式网站地址<input name="domain" value="${ATTR(s.domain)}" placeholder="https://domain.com"></label><label class="full">网站简介 / 标语<textarea name="tagline" rows="2">${E(s.tagline)}</textarea></label><label class="full">源站地址(旧站导入)<input name="origin" value="${ATTR(s.settings.origin||'')}" placeholder="https://oldsite.com"></label><label>品牌色<input name="accent" type="color" value="${ATTR(s.accent)}" style="height:42px;padding:3px"></label><label>站点模式<select name="mode"><option value="new" ${s.mode==='new'?'selected':''}>新建</option><option value="migration" ${s.mode==='migration'?'selected':''}>迁移</option><option value="hybrid" ${s.mode==='hybrid'?'selected':''}>混合</option></select></label><label class="full">Logo 图片 URL<input name="logo" value="${ATTR(s.logo||'')}"></label></div><div class="panel-footer"><button class="btn primary" type="submit">保存网站设置</button></div></form>`},
async saveSettings(ev){ev.preventDefault();try{let data=Object.fromEntries(new FormData(ev.target));state.site=await api('/api/sites/'+state.siteId,json('PATCH',data));await App.reload();notice('已保存站点设置');App.renderSite()}catch(e){onError(e)}},
entriesView(){let arr=state.entries.filter(e=>(!state.typ||e.type===state.typ)&&(!state.query||e.title.toLowerCase().includes(state.query.toLowerCase())||e.route.toLowerCase().includes(state.query.toLowerCase())));return `<div class="panel"><div class="panel-title"><div><h3>内容库</h3><p>页面 / 产品 / 文章 / 自定义内容,支持独立 URL 和 SEO</p></div><button class="btn primary small" onclick="App.openEntry()">+ 新建内容</button></div><div class="toolbar"><input id="searchEntry" placeholder="搜索标题或 URL" value="${ATTR(state.query)}" oninput="App.filterContent()"><select id="typeFilter" onchange="App.filterContent()"><option value="">全部类型</option>${['page','product','post','service','case','vehicle','brand','other'].map(t=>`<option value="${t}" ${state.typ===t?'selected':''}>${{page:'页面',product:'产品',post:'文章',service:'服务',case:'案例',vehicle:'车型',brand:'品牌',other:'自定义'}[t]}</option>`).join('')}</select><span class="muted">${arr.length} 条结果</span></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>标题</th><th>类型</th><th>路由</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead><tbody>${arr.map(e=>`<tr><td><strong>${E(e.title)}</strong><div style="font-size:11px;color:#9da5b2;max-width:240px;overflow:hidden;text-overflow:ellipsis">${E(e.summary||'')}</div></td><td>${E(e.type)}</td><td class="mono">${E(e.route)}</td><td><span class="pill ${e.status==='published'?'green':'yellow'}">${e.status==='published'?'已发布':'草稿'}</span></td><td>${simpleDate(e.updated_at)}</td><td><div class="row-actions"><a class="btn light tiny" href="/editor/${state.siteId}/${e.id}" target="_blank">页面设计</a><button class="btn light tiny" onclick="App.openEntry('${e.id}')">编辑内容</button><button class="btn light tiny" onclick="App.deleteEntry('${e.id}')">删除</button></div></td></tr>`).join('')}</tbody></table>${arr.length?'':'<div class="empty"><h3>尚无匹配内容</h3><p>新建内容或使用导入中心批量导入</p></div>'}</div></div>`},
filterContent(){const active=document.activeElement?.id;const caret=active==='searchEntry'?$('#searchEntry').selectionStart:null;state.query=$('#searchEntry')?.value||'';state.typ=$('#typeFilter')?.value||'';$('#tab-content').innerHTML=App.entriesView();if(active==='searchEntry'){const input=$('#searchEntry');input.focus();if(caret!==null)input.setSelectionRange(caret,caret)}},
async updateNavigation(ev){ev.preventDefault();try{const raw=$('#navRoutes').value.split('\n').map(x=>x.trim()).filter(Boolean);const entries=raw.map(line=>{const sep=line.indexOf('|');if(sep<0)throw new Error('每行必须为:导航文字 | /路径/');return {title:line.slice(0,sep).trim(),route:line.slice(sep+1).trim()}});let resp=await api('/api/sites/'+state.siteId+'/navigation',json('PUT',entries));state.site=await api('/api/sites/'+state.siteId);notice('导航已保存:'+resp.nav.length+'项')}catch(e){onError(e)}},
async scaffold(){if(!confirm('创建关于我们、联系我们和服务页面?已有同路径页面不会覆盖。'))return;try{let r=await api('/api/sites/'+state.siteId+'/scaffold',{method:'POST'});await App.reload();App.renderSite();notice('创建 '+r.created.length+' 个页面,跳过 '+r.skipped.length+' 个')}catch(e){onError(e)}},
modelsView(){return `<div class="panel"><div class="panel-title"><div><h3>内容字段模型</h3><p>自定义产品、车型、品牌、文章等数据字段。字段保存在每个站点自己的配置内,不影响其他站点。</p></div></div><div class="toolbar"><label>内容类型 <select id="modelKind" onchange="App.renderModelFields()">${['product','vehicle','post','brand','case','service','other'].map(x=>`<option value="${x}">${x}</option>`).join('')}</select></label><button class="btn primary" onclick="App.addModelField()">+ 添加字段</button></div><div id="modelFields"><p>正在读取字段定义……</p></div><div class="panel-footer"><button class="btn primary" onclick="App.saveModelFields()">保存字段模型</button></div></div>`},
async loadModels(){try{state.models=await api('/api/sites/'+state.siteId+'/schemas');App.renderModelFields()}catch(e){onError(e)}},
renderModelFields(){let el=$('#modelFields');if(!el||!state.models)return;let kind=$('#modelKind').value;let defs=state.models[kind]||[];el.innerHTML=`<table class="data-table"><thead><tr><th>字段键名</th><th>显示名称</th><th>字段类型</th><th></th></tr></thead><tbody id="fieldRows">${defs.map(d=>App.modelRow(d)).join('')}</tbody></table>`},
modelRow(d={}){return `<tr class="field-row"><td><input class="field-key" value="${ATTR(d.name||'')}" placeholder="例如:wheelbase" style="width:100%"></td><td><input class="field-label" value="${ATTR(d.label||'')}" placeholder="字段名称" style="width:100%"></td><td><select class="field-type">${['text','textarea','number','date','url','boolean'].map(t=>`<option value="${t}" ${d.type===t?'selected':''}>${t}</option>`).join('')}</select></td><td><button class="btn light tiny" onclick="this.closest('tr').remove()">删除</button></td></tr>`},
addModelField(){if(!$('#fieldRows'))return;$('#fieldRows').insertAdjacentHTML('beforeend',App.modelRow())},
async saveModelFields(){try{const fields=[...document.querySelectorAll('.field-row')].map(tr=>({name:tr.querySelector('.field-key').value.trim(),label:tr.querySelector('.field-label').value.trim(),type:tr.querySelector('.field-type').value}));let kind=$('#modelKind').value;let result=await api('/api/sites/'+state.siteId+'/schemas/'+kind,json('PUT',fields));state.models[kind]=result.fields;notice('已保存 '+kind+' 字段模型')}catch(e){onError(e)}},
designView(){const s=state.site;return `<div class="panel"><div class="panel-title"><div><h3>Visual Studio · 页面可视化工作台</h3><p>拖动区块顺序、修改属性、自动绑定内容,并在同一个界面预览 PC 和移动端。</p></div></div><div class="cards">${state.entries.filter(e=>e.type==='page'||e.type==='product'||e.type==='post').slice(0,36).map(e=>`<div class="site-card" style="cursor:default;padding:20px"><div class="pill">${E(e.type)}</div><h3>${E(e.title)}</h3><p style="color:#8999a9">${E(e.route)}</p><a class="btn primary small" href="/editor/${s.id}/${e.id}" target="_blank">打开可视化编辑器 ↗</a></div>`).join('')}</div><p style="margin-top:22px;color:#7f90a2">页面编辑后点击构建,系统生成对应的独立静态页面,不会丢失原有 URL 和 SEO。 <button class="btn light small" style="margin-left:15px" onclick="App.scaffold()">一键补齐企业常用页面</button></p></div><div class="panel"><div class="panel-title"><div><h3>顶部导航配置</h3><p>每行:标题 | /原始路径/,不填写则按网站已有栏目自动生成导航。</p></div></div><form onsubmit="App.updateNavigation(event)"><textarea id="navRoutes" style="width:100%;min-height:115px;border:1px solid #dce3ed;padding:15px;border-radius:10px">${E((state.site.settings.nav||[]).map(n=>n.title+' | '+n.route).join('\n'))}</textarea><div class="panel-footer"><button class="btn primary" type="submit">保存导航结构</button></div></form></div>`},
async applyTemplates(mode){if(!confirm(mode==='overwrite'?'将用当前主题的默认布局覆盖所有页面区块,内容和SEO不变,确定继续?':'将给尚未设置布局的页面自动套用当前主题,确定继续?'))return;try{let r=await api('/api/sites/'+state.siteId+'/apply-theme',json('POST',{mode}));await App.reload();notice('已装配 '+r.updated+' 个页面,跳过 '+r.skipped+' 个');App.renderSite()}catch(e){onError(e)}},
templateView(){let s=state.site;return `<div class="panel"><div class="panel-title"><div><h3>主题样式库</h3><p>切换后内容、原有 URL 和 SEO 数据不变,重新构建立即产生全新视觉版本</p></div></div><div class="template-grid">${Object.keys(cfg).map(t=>themeCard(t,`<button class="btn ${s.theme===t?'light':'primary'} small" ${s.theme===t?'disabled':''} onclick="App.setTheme('${t}')">${s.theme===t?'✓ 正在使用':'使用主题'}</button>`)).join('')}</div></div><div class="panel"><div class="panel-title"><div><h3>模板工厂</h3><p>保存当前主题与首页区块配置,以后创建其他网站时可以直接套用。</p></div></div><div class="toolbar"><input id="presetName" placeholder="例如:HESU 汽车企业基础模板" style="max-width:400px"><button class="btn primary" onclick="App.savePreset()">保存为新模板</button><button class="btn light" onclick="App.cloneSite()">克隆整个站点</button></div><div class="toolbar" style="margin:16px 0"><button class="btn primary" onclick="App.applyTemplates('missing')">一键把已有内容装入新主题(保留已设计的页面)</button><button class="btn light" onclick="App.applyTemplates('overwrite')">全部应用当前主题预设布局</button></div><div class="tip">v0.3 已提供页面区块拖拽排序、PC / 手机预览、动态内容绑定。完全自由定位的像素级编辑仍不支持。</div></div>`},
importView(){return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>批量导入文件</h3><p>支持 WordPress XML、CSV、Excel XLSX、JSON</p></div></div><form id="importForm" onsubmit="App.importFile(event)"><label class="field">选择内容数据文件<input type="file" name="file" required accept=".xml,.csv,.json,.xlsx"></label><label class="field" style="margin-top:18px">遇到相同 URL 时<select name="on_conflict"><option value="skip">跳过原有页面(安全默认)</option><option value="update">覆盖当前内容(请先备份)</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">开始批量导入</button></div></form><div class="tip" style="margin-top:18px">建议 CSV 字段:type, title, route, summary, body, cover, seo_title, seo_description。</div></div><div class="panel"><div class="panel-title"><div><h3>旧站公开页面采集</h3><p>支持 WordPress、普通 HTML 与其他 CMS 的公开内容</p></div></div><form id="crawlForm" onsubmit="App.crawl(event)"><label class="field">旧网站首页 URL<input name="url" type="url" required value="${ATTR(state.site.settings.origin||'')}" placeholder="https://oldsite.com/"></label><label class="field" style="margin-top:18px">最大抓取页面数<select name="max_pages"><option value="10">10 页(快速验证)</option><option value="20" selected>20 页</option><option value="50">50 页</option></select></label><label class="field" style="margin-top:18px">冲突策略<select name="on_conflict"><option value="skip">保留本地内容</option><option value="update">用采集内容更新</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">扫描并导入公开页面</button></div></form><div class="tip warning" style="margin-top:18px">公开爬取无法保证包含私有字段、插件数据或复杂动态页面。推荐尽量使用原站完整数据库/导出文件。</div></div></div><div class="panel"><div class="panel-title"><div><h3>素材中心</h3><p>上传图片,生成本地媒体路径,内容页可直接引用。</p></div><label class="btn light small">上传图片<input type="file" accept="image/*" hidden onchange="App.uploadMedia(this.files[0])"></label></div><div class="tip">上传文件 URL 将返回类似 media/abc123.jpg,静态站构建时自动携带。</div><div class="toolbar" style="margin-top:18px"><label class="btn light small">批量导入 WordPress 媒体 ZIP<input type="file" accept=".zip" hidden onchange="App.uploadMediaArchive(this.files[0])"></label></div><div id="mediaGrid" style="padding-top:12px"></div></div>`},
seoView(){return `<div class="split"><div class="panel"><div class="panel-title"><div><h3>SEO 保留策略</h3><p>不因改版随意改变页面 URL</p></div></div><div class="feature-option"><div class="feature-icon">↗</div><b>页面 URL Registry</b><p>所有现有 URL 由内容库的 route 精确定义;冲突直接禁止保存。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">◇</div><b>SEO Metadata</b><p>编辑内容可以设置 Title、Description、Canonical 和 Robots。</p></div><div class="feature-option" style="margin-top:10px"><div class="feature-icon">▤</div><b>Sitemap + 服务器规则</b><p>构建时生成 sitemap.xml、robots.txt、Nginx snippet 和 .htaccess。</p></div></div><div class="panel"><div class="panel-title"><div><h3>301 / 302 重定向管理</h3><p>原 URL 不能保留时,人工审批后配置对应跳转。</p></div></div><form id="redirectForm" onsubmit="App.createRedirect(event)"><label class="field">原 URL Path<input name="old_path" required placeholder="/old-product/"/></label><label class="field" style="margin-top:14px">目标 URL Path<input name="new_path" required placeholder="/new-product/"/></label><label class="field" style="margin-top:14px">HTTP 状态码<select name="status_code"><option value="301">301 永久</option><option value="302">302 临时</option><option value="307">307 临时保留方法</option><option value="308">308 永久保留方法</option></select></label><div class="panel-footer"><button class="btn primary" type="submit">添加重定向</button></div></form><div id="redirectList"></div></div></div>`},
buildView(){return `<div class="panel"><div class="panel-title"><div><h3>静态构建中心</h3><p>将当前内容、模板和 SEO 路由组合成完全独立的 HTML 文件。</p></div></div><div class="split"><div><div class="feature-option"><div class="feature-icon">▦</div><b>静态 HTML / CSS / JS</b><p>无需 PHP、WordPress 或中心 CMS,就能独立部署访问。</p></div></div><div><div class="feature-option"><div class="feature-icon">▰</div><b>可回溯构建版本</b><p>每次构建产生独立 ZIP 和版本报告,不会覆盖旧版本。</p></div></div></div><div class="toolbar" style="margin-top:22px"><label style="display:flex;align-items:center;gap:9px"><input id="mirrorChoice" type="checkbox" style="width:18px;height:18px">打包外部图片(较慢,外部资源可能下载失败)</label></div><div class="panel-footer"><button class="btn primary" onclick="App.build()">↗ 生成新的静态网站版本</button></div></div><div class="panel"><div class="panel-title"><div><h3>历史构建</h3><p>选择版本查看预览、下载 ZIP、检查 SEO/媒体报告</p></div></div><div class="table-wrapper"><table class="data-table"><thead><tr><th>版本 ID</th><th>构建时间</th><th>页面数量</th><th>报告</th><th>操作</th></tr></thead><tbody>${state.builds.map(x=>{let r=JSON.parse(x.report_json||'{}');return `<tr><td class="mono">${x.id}</td><td>${simpleDate(x.created_at)}</td><td>${x.total_pages}</td><td><span class="pill ${r.quality_status==='warning'?'yellow':'green'}">${r.quality_status==='warning'?'有告警':'已通过基础检查'}</span></td><td><div class="row-actions"><a class="btn light tiny" target="_blank" href="/preview/${state.siteId}/${x.id}/">预览 ↗</a><a class="btn light tiny" href="/api/sites/${state.siteId}/builds/${x.id}/download">ZIP ↓</a><button class="btn light tiny" onclick="App.showReport('${x.id}')">报告</button><button class="btn light tiny" onclick="App.publishLocal('${x.id}')" title="发布到 Docker 持久化发布目录">发布</button></div></td></tr>`}).join('')}</tbody></table>${state.builds.length?'':'<div class="empty"><div>⌘</div><h3>尚无构建记录</h3><p>完成内容编辑后,点击上方按钮生成版本。</p></div>'}</div><div id="reportBox"></div></div>`},
async openTemplates(){state.siteId=null;topTitle('模板资源库');App.renderSiteNav();document.querySelectorAll('.side-link').forEach(x=>x.classList.remove('selected'));$('#nav-template').classList.add('selected');$('#view').innerHTML=hero('模板与主题资源库','从四套行业视觉风格开始,逐步沉淀企业自己的模板库。',`<button class="btn primary" onclick="App.openNewSite()">使用模板建站 →</button>`)+`<div class="template-grid">${Object.keys(cfg).map(t=>themeCard(t,`<button class="btn primary small" onclick="App.openNewSite('${t}')">基于模板创建</button>`)).join('')}</div><div class="panel" style="margin-top:20px"><div class="panel-title"><div><h3>已保存的模板预设</h3><p>由已有网站保存,复用主题及首页区块结构。</p></div></div>${state.presets.length?state.presets.map(p=>`<div class="feature-option" style="margin-bottom:9px"><b>${E(p.name)}</b><p>${E(cfg[p.theme]?.label||p.theme)} · ${simpleDate(p.created_at)}</p><button class="btn light tiny" style="margin-top:11px" onclick="App.openNewSite('${p.theme}','${p.id}')">使用此模板建站</button></div>`).join(''):'<div class="empty"><h3>暂未保存企业自定义模板</h3><p>从任意网站的「模板与设计」中保存为模板。</p></div>'}</div>`},
openNewSite(theme='',preset=''){const f=$('#siteForm');f.reset();if(theme)f.elements.theme.value=theme;$('#presetSelector').innerHTML='<option value="">不使用自定义模板</option>'+state.presets.map(p=>`<option value="${p.id}">${E(p.name)} · ${E(p.theme)}</option>`).join('');if(preset)f.elements.preset_id.value=preset;$('#siteDialog').showModal()},
closeDialog(id){$('#'+id).close()},
async createSite(ev){ev.preventDefault();try{let values=Object.fromEntries(new FormData(ev.target));let site=await api('/api/sites',json('POST',values));App.closeDialog('siteDialog');notice('网站已创建,可以开始添加内容');await App.openSite(site.id)}catch(e){onError(e)}},
openEntry(id=''){let f=$('#entryForm');f.reset();let e=state.entries.find(x=>x.id===id);f.elements.entry_id.value=id;$('#entryHeading').textContent=id?'编辑内容':'新建内容';if(e){for(let key of ['title','route','summary','body','cover','seo_title','seo_description','canonical','type','status','robots'])if(f.elements[key])f.elements[key].value=e[key]??'';f.elements.meta.value=JSON.stringify(e.meta||{},null,2)}else{f.elements.meta.value='{}';f.elements.type.value='page';f.elements.status.value='published'}$('#entryDialog').showModal();App.loadEntryFields()},
async loadEntryFields(){try{state.models=await api('/api/sites/'+state.siteId+'/schemas');App.renderEntryFields()}catch(e){onError(e)}},
renderEntryFields(){let el=$('#dynamicContentFields');if(!el)return;let kind=$('#entryType').value;let list=(state.models||{})[kind]||[];let parsed={};try{parsed=JSON.parse($('#entryMeta').value||'{}')}catch{};const values=parsed.fields||parsed;el.innerHTML=list.length?`<div class="panel-title"><div><h3>业务字段 · ${E(kind)}</h3><p>来自当前网站的字段模型,保存时同步写入内容数据。</p></div></div><div class="setting-grid">${list.map(f=>`<label>${E(f.label||f.name)}<input class="sf-data-field" data-key="${ATTR(f.name)}" type="${['number','date','url'].includes(f.type)?f.type:'text'}" value="${ATTR(values[f.name]||'')}"></label>`).join('')}</div>`:''},
addBlock(type){const el=$('#entryMeta');let parsed={};try{parsed=JSON.parse(el.value||'{}')}catch{return notice('当前 JSON 格式不合法,先修复后再添加区块',true)}if(!Array.isArray(parsed.blocks))parsed.blocks=[];let obj=({hero:{type:'hero',title:'核心主标题',subtitle:'OUR VISION',description:'可编辑的首页主视觉描述'},features:{type:'features',title:'核心优势',items:[{title:'专业实力',text:'填写优势简介'},{title:'可靠交付',text:'填写优势简介'},{title:'服务网络',text:'填写优势简介'}]},stats:{type:'stats',items:[{value:'30+',label:'合作品牌'},{value:'70+',label:'服务市场'}]},cta:{type:'cta',title:'期待与您合作',subtitle:'联系我们,了解更多服务',button:'联系我们',href:'/contact/'}})[type];parsed.blocks.push(obj);el.value=JSON.stringify(parsed,null,2);notice('已添加区块,请编辑 JSON 中的标题与内容')},
async saveEntry(ev){ev.preventDefault();try{let v=Object.fromEntries(new FormData(ev.target)),id=v.entry_id;delete v.entry_id;v.meta=JSON.parse(v.meta||'{}');const fieldInputs=[...document.querySelectorAll('#dynamicContentFields .sf-data-field')];if(fieldInputs.length){let fields={...(v.meta.fields||{})};for(const input of fieldInputs)fields[input.dataset.key]=input.value;v.meta.fields=fields}if(!id&&!v.route)delete v.route;let endpoint='/api/sites/'+state.siteId+'/entries'+(id?'/'+id:'');await api(endpoint,json(id?'PATCH':'POST',v));App.closeDialog('entryDialog');await App.openSite(state.siteId,'entries');notice(id?'已更新内容':'已创建内容')}catch(e){onError(e)}},
async deleteEntry(id){if(!confirm('确定删除此内容吗?删除之后重新构建才会影响线上静态网站。'))return;try{await api('/api/sites/'+state.siteId+'/entries/'+id,{method:'DELETE'});await App.openSite(state.siteId,'entries');notice('内容已删除')}catch(e){onError(e)}},
async setTheme(theme){if(!confirm('切换到「'+cfg[theme].label+'」?内容和 URL 保持不变,需重新构建才能更新站点。'))return;try{await api('/api/sites/'+state.siteId,json('PATCH',{theme}));await App.openSite(state.siteId,'templates');notice('主题已更换,请重新构建预览')}catch(e){onError(e)}},
async savePreset(){let name=$('#presetName')?.value?.trim();if(!name)return notice('请输入新模板名称',true);try{let result=await api('/api/sites/'+state.siteId+'/save-preset',json('POST',{name}));state.presets=await api('/api/presets');notice('已保存模板:'+result.name)}catch(e){onError(e)}},
async cloneSite(){let name=prompt('新站名称:',state.site.name+' 副本');if(!name)return;try{let newsite=await api('/api/sites/'+state.siteId+'/clone',json('POST',{name}));notice('已克隆全部内容到新网站');await App.openSite(newsite.id)}catch(e){onError(e)}},
async importFile(ev){ev.preventDefault();let btn=$('button[type=submit]',ev.target);btn.disabled=true;btn.innerText='导入中...';try{let form=new FormData(ev.target);let result=await api('/api/sites/'+state.siteId+'/import',{method:'POST',body:form});await App.openSite(state.siteId,'import');notice(`导入完成:新增 ${result.imported},更新 ${result.updated},跳过 ${result.skipped},错误 ${result.errors?.length||0}`);if(result.errors?.length)alert(JSON.stringify(result.errors.slice(0,15),null,2))}catch(e){onError(e)}finally{btn.disabled=false;btn.innerText='开始批量导入'}},
async crawl(ev){ev.preventDefault();if(!confirm('确认对该公开网站进行限定范围采集?请仅采集你拥有或获授权迁移的站点。'))return;let btn=$('button[type=submit]',ev.target);btn.disabled=true;btn.innerText='正在采集...';try{const input=Object.fromEntries(new FormData(ev.target));input.max_pages=+input.max_pages;const r=await api('/api/sites/'+state.siteId+'/crawl',json('POST',input));await App.openSite(state.siteId,'import');notice(`采集完成:发现 ${r.discovered} 页,新增 ${r.imported} 页,页面获取失败 ${r.fetch_errors?.length||0}`)}catch(e){onError(e)}finally{btn.disabled=false;btn.innerText='扫描并导入公开页面'}},
async uploadMediaArchive(file){if(!file)return;try{let form=new FormData();form.append('file',file);notice('正在解压媒体文件...');let r=await api('/api/sites/'+state.siteId+'/media-archive',{method:'POST',body:form});notice(`媒体导入:${r.imported} 文件,跳过 ${r.skipped} 文件`)}catch(e){onError(e)}},
async uploadMedia(file){if(!file)return;try{let fd=new FormData();fd.append('file',file);let r=await api('/api/sites/'+state.siteId+'/media',{method:'POST',body:fd});notice('图片已上传:'+r.url);App.loadMedia()}catch(e){onError(e)}},
async uploadCover(file){if(!file)return;try{let fd=new FormData();fd.append('file',file);let r=await api('/api/sites/'+state.siteId+'/media',{method:'POST',body:fd});$('#coverInput').value=r.url;notice('封面上传成功')}catch(e){onError(e)}},
async loadMedia(){if(state.tab!=='import')return;try{let data=await api('/api/sites/'+state.siteId+'/media');$('#mediaGrid').innerHTML=data.map(x=>`<div style="display:inline-flex;flex-direction:column;gap:4px;border:1px solid #ddd;padding:9px;margin:5px"><img style="width:95px;height:70px;object-fit:contain" src="/local-media/${state.siteId}/${E(x.name)}"><button class="btn light tiny" onclick="navigator.clipboard.writeText('${x.url}');notice('已复制图片地址')">复制路径</button></div>`).join('')||'<p class="muted">暂无本地素材</p>'}catch(e){onError(e)}},
async createRedirect(ev){ev.preventDefault();let value=Object.fromEntries(new FormData(ev.target));value.status_code=+value.status_code;try{await api('/api/sites/'+state.siteId+'/redirects',json('POST',value));ev.target.reset();notice('已创建重定向规则');App.loadRedirects()}catch(e){onError(e)}},
async loadRedirects(){if(state.tab!=='seo')return;try{let data=await api('/api/sites/'+state.siteId+'/redirects');$('#redirectList').innerHTML=data.length?'<h4 style="margin-top:24px">已有重定向</h4>'+data.map(x=>`<p class="mono" style="display:flex;justify-content:space-between;gap:12px">${E(x.old_path)} ${x.status_code} → ${E(x.new_path)} <button class="btn light tiny" onclick="App.deleteRedirect('${x.id}')">删除</button></p>`).join(''):'<p style="margin-top:18px;color:#9aa5b2;font-size:12px">当前没有重定向规则。</p>'}catch(e){onError(e)}},
async deleteRedirect(id){if(!confirm('确认删除这条规则?'))return;try{await api('/api/sites/'+state.siteId+'/redirects/'+id,{method:'DELETE'});App.loadRedirects()}catch(e){onError(e)}},
async publishLocal(id){if(!confirm('将此版本发布到 Docker 持久化目录?请确认配置好独立站点服务器。'))return;try{const x=await api('/api/sites/'+state.siteId+'/builds/'+id+'/publish-local',{method:'POST'});notice('已发布:'+x.release+'(发布文件位于 Docker 数据卷)')}catch(e){onError(e)}},
async build(){let s=state.siteId;if(!s)return;let mirror=$('#mirrorChoice')?.checked||false;notice('开始构建,请检查完成后的质量报告...');try{let report=await api('/api/sites/'+s+'/builds',json('POST',{mirror_images:mirror}));await App.openSite(s,'builds');notice(`构建成功:${report.pages} 个页面 · ${report.warnings.length} 项警告`);App.showReport(report.version)}catch(e){onError(e)}},
async showReport(id){try{const r=await api('/api/sites/'+state.siteId+'/builds/'+id+'/report');let box=$('#reportBox');if(!box){alert(JSON.stringify(r,null,2));return}box.innerHTML=`<div class="panel" style="margin-top:20px;background:#fafdff"><div class="panel-title"><div><h3>质量报告:${E(id)}</h3><p>本报告只覆盖当前实现的基础结构与资源检查,不代表 SEO 排名保证。</p></div></div><div class="metrics-grid"><div class="metric"><small>静态 HTML 数</small><strong>${r.pages}</strong></div><div class="metric"><small>重定向数</small><strong>${r.redirects}</strong></div><div class="metric"><small>已镜像图片</small><strong>${r.mirrored_images}</strong></div><div class="metric"><small>质量告警</small><strong>${r.warnings.length}</strong></div></div>${r.warnings.length?`<div class="tip warning">${r.warnings.map(x=>`• ${E(x)}`).join('<br>')}</div>`:'<div class="tip">✓ 当前规则下未发现告警</div>'}<h4>详细报告</h4><pre class="pre">${E(JSON.stringify(r,null,2))}</pre></div>`;box.scrollIntoView({behavior:'smooth',block:'nearest'})}catch(e){onError(e)}},
async seedDemo(){try{const s=await api('/api/sites',json('POST',{name:'HESU Global Motors · 演示站',tagline:'全球汽车采购、出口与交付服务',theme:'automotive',domain:'https://demo.example.com',mode:'new'}));let base='/api/sites/'+s.id+'/entries';for(let v of [
{type:'page',title:'关于我们',route:'/about/',summary:'专注全球汽车出口与本地交付',body:'<p>这里是示例公司介绍。请在内容管理中替换为你的真实资料。</p>',seo_title:'About us'},
{type:'product',title:'ROX 01',route:'/product/rox-01/',summary:'豪华全地形 SUV · 示例数据,可在后台编辑',body:'<h2>产品概览</h2><p>此条目为演示内容,不代表实际库存或车型参数。</p>'},
{type:'product',title:'BYD ATTO 3',route:'/product/byd-atto-3/',summary:'纯电 SUV · 示例车型信息',body:'<p>可以从 WordPress、Excel 或 JSON 重新导入真正的车型数据。</p>'},
{type:'post',title:'网站动态示例',route:'/news/first-news/',summary:'一个独立的新网站如何快速发布',body:'<p>编辑内容、切换主题、点击构建,即可导出静态网站。</p>'}
])await api(base,json('POST',v));await App.openSite(s.id);await App.build();notice('演示网站已创建并成功构建,可直接预览或下载 ZIP')}catch(e){onError(e)}},
};
window.App=App;
$('#siteForm').addEventListener('submit',App.createSite);
$('#entryForm').addEventListener('submit',App.saveEntry);
window.addEventListener('DOMContentLoaded',()=>App.init());
+13
View File
@@ -0,0 +1,13 @@
/* Portable SiteForge blocks, loaded with every theme (all styles scoped to sf-). */
.sf-section{background:var(--sf-bg,transparent);color:var(--sf-fg,inherit);padding:var(--sf-pad,82px) 0;margin:0}.sf-section:nth-of-type(even){background:var(--sf-bg,#f4f6f9)}.sf-container{max-width:1200px;margin:auto;padding:0 32px}.sf-section h1,.sf-section h2,.sf-section h3,.sf-section p{color:inherit;line-height:1.3}.sf-section h1{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}.sf-section h2{font-size:clamp(27px,3.2vw,44px);font-weight:760;letter-spacing:-.037em}.sf-kicker{display:block;font-size:12px;letter-spacing:.17em;text-transform:uppercase;font-weight:740;color:var(--accent,#3569d3);margin-bottom:18px}.sf-hero-grid,.sf-image-text{display:grid;grid-template-columns:1.08fr .92fr;gap:68px;align-items:center}.sf-hero-copy p{font-size:18px;line-height:1.75;max-width:520px;opacity:.75}.sf-hero-img,.sf-image-side{overflow:hidden;border-radius:9px;min-height:380px;background:linear-gradient(130deg,#d5e1ee,#eef3f9);display:grid;place-items:center}.sf-hero-img img,.sf-image-side img{width:100%;height:100%;max-height:580px;object-fit:cover}.sf-hero-abstract{font-size:110px;color:#a7bdd4}.sf-button{display:inline-flex;align-items:center;justify-content:center;gap:28px;background:var(--accent,#315ee2);color:white!important;padding:16px 26px;border-radius:6px;font-size:14px;text-decoration:none;font-weight:720;margin-top:24px}.sf-section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:32px;gap:24px}.sf-section-head h2{margin:0}.sf-section-head>a,.sf-link{color:var(--accent,#3569d3);font-weight:700;text-decoration:none}.sf-prose{font-size:16px;line-height:1.85;max-width:930px}.sf-prose p{line-height:1.85}.sf-features,.sf-collection,.sf-stats{display:grid;gap:20px}.sf-features{grid-template-columns:repeat(3,minmax(0,1fr))}.sf-feature{border:1px solid #e0e6eb;padding:32px 28px;border-radius:8px;background:#fff;color:#253247}.sf-feature>span{font-size:12px;font-weight:800;color:var(--accent,#346add)}.sf-feature h3{font-size:22px;margin:30px 0 8px}.sf-feature p{font-size:14px;line-height:1.7;color:#647181}.sf-stats{grid-template-columns:repeat(4,1fr);padding:40px 0;border-top:1px solid #b7c0cd66;border-bottom:1px solid #b7c0cd66}.sf-stats>div{display:flex;flex-direction:column;gap:8px}.sf-stats strong{font-size:clamp(28px,4vw,53px);letter-spacing:-.04em}.sf-stats span{font-size:12px;opacity:.65;letter-spacing:.04em}.sf-collection{grid-template-columns:repeat(var(--sf-cols,3),minmax(0,1fr))}.sf-item{display:block;text-decoration:none;color:#233044;background:#fff;border:1px solid #e8edf1;border-radius:8px;overflow:hidden;transition:transform .2s,box-shadow .2s}.sf-item:hover{transform:translateY(-4px);box-shadow:0 18px 38px #344d7018}.sf-item-img{height:210px;overflow:hidden;background:#e4eaf0}.sf-item-img img{height:100%;width:100%;object-fit:cover}.sf-placeholder{display:grid;place-items:center;width:100%;height:100%;min-height:190px;background:linear-gradient(130deg,#e2eaf2,#c9d7e6);font-size:72px;color:#8399b2;font-weight:800}.sf-large{min-height:360px}.sf-item-copy{padding:25px}.sf-item-copy small{text-transform:uppercase;letter-spacing:.13em;color:var(--accent,#376edc)}.sf-item-copy h3{font-size:22px;margin:12px 0 7px}.sf-item-copy p{font-size:14px;line-height:1.6;color:#7a8796;min-height:42px}.sf-item-copy span{font-size:12px;color:var(--accent,#376edc);font-weight:800}.sf-image-text>div>p{font-size:17px;line-height:1.8;max-width:520px;opacity:.7}.sf-reverse>div:first-child{order:2}.sf-gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.sf-gallery img{width:100%;height:230px;object-fit:cover;border-radius:6px}.sf-faq{max-width:920px}.sf-faq details{border-bottom:1px solid #dce3eb;padding:22px 0}.sf-faq summary{font-size:17px;cursor:pointer;font-weight:700}.sf-faq p{color:#647181;line-height:1.75}.sf-cta-wrap{display:flex;justify-content:space-between;align-items:center;gap:24px}.sf-cta-wrap h2{margin:0}.sf-cta-wrap p{font-size:16px;opacity:.75}.sf-section a{cursor:pointer}.sf-muted{opacity:.65}
@media(max-width:820px){.sf-container{padding:0 20px}.sf-section{padding:50px 0}.sf-hero-grid,.sf-image-text{grid-template-columns:1fr;gap:26px}.sf-reverse>div:first-child{order:initial}.sf-hero-img,.sf-image-side{min-height:250px}.sf-features,.sf-collection{grid-template-columns:repeat(2,minmax(0,1fr))}.sf-stats{grid-template-columns:repeat(2,1fr)}.sf-cta-wrap{align-items:flex-start;flex-direction:column}}
@media(max-width:540px){.sf-features,.sf-collection,.sf-gallery{grid-template-columns:1fr}.sf-section-head{display:block}.sf-section-head>a{display:block;margin-top:13px}.sf-item-img{height:220px}.sf-hero-grid{gap:22px}.sf-hero-copy p{font-size:16px}.sf-stats{gap:28px}}
.sf-attributes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 32px;border-top:1px solid #e5eaf0}.sf-attribute{display:flex;justify-content:space-between;align-items:center;gap:18px;border-bottom:1px solid #e5eaf0;padding:19px 10px;font-size:14px}.sf-attribute span{color:#748497}.sf-attribute strong{font-weight:720;text-align:right}@media(max-width:640px){.sf-attributes{grid-template-columns:1fr}}
/* SiteForge responsive no-code grid container */
.sf-layout-grid{display:grid;grid-template-columns:var(--sf-layout-cols,1fr 1fr);gap:var(--sf-layout-gap,24px);align-items:stretch}
.sf-layout-column{min-width:0}.sf-layout-column>.sf-section{padding:0;margin:0 0 18px}.sf-layout-column .sf-container{width:100%;padding:0}
.sf-layout-column>.sf-section:last-child{margin-bottom:0}
@media(max-width:820px){.sf-layout-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
@media(max-width:540px){.sf-layout-grid{grid-template-columns:minmax(0,1fr)!important}}
+27
View File
@@ -0,0 +1,27 @@
/* SiteForge commercial theme UX layer: accessibility, layout rhythm, media, responsive navigation. */
:root{--sf-shadow:0 20px 55px rgba(17,33,55,.08);--sf-shadow-hover:0 26px 65px rgba(17,33,55,.15)}
html{scroll-padding-top:104px}body{font-feature-settings:"kern";text-rendering:optimizeLegibility}
a,button,input,select,textarea{-webkit-tap-highlight-color:transparent}
a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid var(--pro-accent);outline-offset:4px}
.pro-wrap{width:100%;max-width:min(var(--pro-wide),100%)}.pro-header{isolation:isolate;box-shadow:0 1px 0 rgba(15,35,60,.035)}.pro-head-inner{gap:clamp(15px,2vw,29px)}.pro-logo{min-width:0;flex-shrink:0;gap:12px}.pro-brand-logo{max-width:155px;max-height:46px;width:auto;height:auto;object-fit:contain}.pro-logo-name{overflow-wrap:anywhere}.pro-navigation{gap:clamp(11px,1.6vw,24px)}.pro-header-cta{white-space:nowrap}
.pro-mobile-toggle,.pro-catalog-toggle,.pro-nav-disclose{border:0;background:transparent;color:inherit;line-height:1.2}
.pro-mobile-toggle{min-height:44px;min-width:44px;border:1px solid currentColor;border-radius:6px;font-size:19px}.pro-nav-group{position:relative;display:flex;align-items:center;gap:2px}
.pro-nav-disclose{padding:9px;font-size:13px}.pro-nav-children{position:absolute;top:100%;left:0;min-width:215px;max-width:330px;display:grid;gap:2px;padding:13px;box-shadow:var(--sf-shadow);border-radius:10px;z-index:45;background:var(--pro-background);border:1px solid var(--pro-border);color:var(--pro-text)}
.pro-nav-children[hidden],.pro-mega[hidden]{display:none!important}.pro-nav-children a{display:block;font-size:13px;white-space:normal;padding:10px;border-radius:5px}.pro-nav-children a:hover{background:var(--pro-surface)}
.pro-catalog-toggle{padding:11px 3px;white-space:nowrap;font-size:12px;font-weight:700}.pro-mega{position:absolute;top:100%;left:50%;transform:translateX(-50%);width:min(100%,var(--pro-wide));box-shadow:var(--sf-shadow);z-index:41;display:grid;grid-template-columns:215px 1fr;background:var(--pro-background);color:var(--pro-text);border:1px solid var(--pro-border);border-radius:0 0 15px 15px;padding:29px;align-items:start}.pro-mega>span{font-size:12px;letter-spacing:.06em}.pro-mega>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px 22px;overflow:visible}.pro-mega>div a{white-space:normal;padding:10px 5px;border-bottom:1px solid var(--pro-border)}
.pro-home-hero{overflow:hidden}.pro-home-hero .pro-home-grid{min-height:660px;gap:5%}.pro-home-hero .pro-hero-copy{position:relative;z-index:2}.pro-home-hero .pro-hero-copy h1{font-size:clamp(46px,5.7vw,92px);line-height:1.06;letter-spacing:-.064em;max-width:15ch}.pro-home-hero .pro-hero-copy h1 em{font-style:normal;display:block}.pro-home-hero .pro-hero-copy p{font-size:clamp(15px,1.3vw,19px);line-height:1.75;max-width:44ch}.pro-home-hero .pro-hero-art{min-width:0;min-height:425px;position:relative;isolation:isolate}.pro-home-hero .pro-hero-art>img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0}.pro-hero-art:has(.pro-abstract-car),.pro-hero-art:has(.pro-technical),.pro-hero-art:has(.pro-corporate-shape){background-position:center;background-size:cover;background-repeat:no-repeat}.pro-abstract-car,.pro-technical,.pro-corporate-shape{position:relative;z-index:1;pointer-events:none}
.pro-hero-actions{display:flex;flex-wrap:wrap;align-items:center;gap:15px}.pro-button,.pro-header-cta,.pro-outline-button{min-height:48px;transition:transform .24s ease,box-shadow .24s ease,opacity .24s ease}.pro-button:hover,.pro-header-cta:hover,.pro-outline-button:hover{transform:translateY(-2px);box-shadow:0 10px 22px rgba(0,0,0,.10)}
.pro-section{padding-top:clamp(76px,7vw,112px)!important;padding-bottom:clamp(76px,7vw,112px)!important}.pro-section-head{gap:16px}.pro-section-head h2{font-size:clamp(32px,3.5vw,53px);max-width:22ch}.pro-section-head .pro-text-link{align-self:end}
.pro-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.pro-card{min-width:0;display:flex;flex-direction:column;transition:transform .28s ease,box-shadow .28s ease;border:1px solid var(--pro-border);background:var(--pro-background);overflow:hidden}.pro-card:hover{transform:translateY(-6px);box-shadow:var(--sf-shadow-hover)}.pro-card-cover{aspect-ratio:1.42;overflow:hidden;background:var(--pro-surface);position:relative}.pro-card-cover .pro-photo{width:100%;height:100%;object-fit:cover;transition:transform .45s ease}.pro-card:hover .pro-photo{transform:scale(1.045)}.pro-card-body{display:flex;flex-direction:column;flex:1}.pro-card-body h3{font-size:clamp(19px,1.6vw,24px);line-height:1.26}.pro-card-body p{font-size:14px}.pro-card-link{margin-top:auto;padding-top:14px;font-size:13px;font-weight:700}
.pro-art{min-height:100%;width:100%;position:relative;overflow:hidden;display:grid;place-items:center;isolation:isolate}.pro-art-ring{width:60%;aspect-ratio:1;border:1px solid currentColor;opacity:.13;border-radius:50%;transform:rotate(20deg);box-shadow:0 0 0 23px #ffffff10,0 0 0 57px #ffffff07}.pro-art-center{font-size:clamp(38px,5vw,80px);position:absolute;font-weight:800;letter-spacing:-.065em;opacity:.65}.pro-art-label{position:absolute;bottom:19px;left:22px;font-size:10px;letter-spacing:.22em;opacity:.7}
.pro-breadcrumb{display:flex;gap:11px;flex-wrap:wrap;font-size:12px;align-items:center}.pro-breadcrumb a{text-decoration:underline;text-underline-offset:4px}.pro-product-hero{align-items:start;gap:clamp(28px,5vw,80px)}.pro-product-media{aspect-ratio:1.12;background:var(--pro-surface);overflow:hidden}.pro-product-media>img,.pro-product-media>.pro-art{width:100%;height:100%;object-fit:contain}.pro-product-summary{padding:clamp(12px,3vw,50px) 0}.pro-product-summary h1{font-size:clamp(36px,4.3vw,72px)}.pro-detail-layout{border-top:1px solid var(--pro-border);padding-top:50px}.pro-specs>div{display:grid;grid-template-columns:minmax(100px,40%) 1fr;gap:10px;align-items:baseline;padding:15px 0;border-bottom:1px solid var(--pro-border)}.pro-specs strong{overflow-wrap:anywhere}.pro-gallery-thumbs button{flex-basis:96px;height:76px}.pro-gallery-lightbox{backdrop-filter:blur(6px)}
.pro-filter-panel{display:flex;align-items:end;flex-wrap:wrap;gap:16px;background:var(--pro-surface);border:1px solid var(--pro-border);padding:20px;margin-bottom:25px;border-radius:var(--pro-radius)}.pro-filter-panel label{display:grid;gap:7px;min-width:180px;flex:1}.pro-filter-panel input,.pro-filter-panel select{width:100%;background:var(--pro-background);border:1px solid var(--pro-border);padding:12px 14px;color:var(--pro-text);border-radius:6px}.pro-filter-count{white-space:nowrap;font-size:13px;padding:12px 0}.pro-card[hidden]{display:none!important}.pro-filter-empty{padding:38px;border:1px dashed var(--pro-border);text-align:center;grid-column:1/-1}
.pro-inquiry input,.pro-inquiry textarea{min-height:46px}.pro-inquiry textarea{min-height:150px}.pro-inquiry-wrap{gap:clamp(32px,7vw,110px)}.pro-inquiry .pro-button{border:0;justify-self:start}.pro-footer-links a{padding-block:5px;display:inline-block}
.pro-end-cta{overflow:hidden}.pro-end-cta h2{font-size:clamp(30px,4vw,64px)}
.pro-rich{line-height:1.85}.pro-rich img{border-radius:var(--pro-radius);margin-block:24px}.pro-rich table{width:100%;display:block;overflow:auto;border-collapse:collapse}.pro-rich td,.pro-rich th{border:1px solid var(--pro-border);padding:10px 14px}.pro-rich a{text-decoration:underline;text-underline-offset:3px}
@media(min-width:1025px){.pro-navigation>.pro-nav-group:hover .pro-nav-children:not([hidden]){visibility:visible}}
@media(max-width:1150px){.pro-head-inner{gap:15px}.pro-navigation{gap:12px}.pro-logo-name{font-size:15px}.pro-header-cta{padding:13px 16px}}
@media(max-width:1024px){.pro-head-inner{min-height:72px;flex-wrap:wrap}.pro-mobile-toggle{display:inline-flex;align-items:center;justify-content:center;margin-left:auto}.pro-navigation{display:none;width:100%;flex-basis:100%;flex-direction:column;align-items:stretch;gap:0;order:5;margin:0;padding:8px 0 20px;border-top:1px solid var(--pro-border)}.pro-navigation.is-open{display:flex}.pro-navigation>a,.pro-nav-group>a{padding:14px 8px;font-size:15px}.pro-nav-group{display:flex;flex-wrap:wrap;justify-content:space-between}.pro-nav-children{position:static;flex-basis:100%;max-width:none;width:100%;box-shadow:none;border-radius:0;border-left:2px solid var(--pro-accent);margin:0 0 12px 8px}.pro-nav-disclose{min-width:44px;min-height:44px}.pro-catalog-toggle{margin-left:auto}.pro-mega{top:100%;display:block;padding:20px}.pro-mega>div{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:12px}.pro-home-hero .pro-home-grid{min-height:560px}.pro-home-hero .pro-hero-art{min-height:325px}.pro-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:700px){.pro-wrap{padding-left:20px;padding-right:20px}.pro-utility{display:none}.pro-head-inner{min-height:66px}.pro-logo-name{max-width:140px;font-size:14px}.pro-brand-logo{max-width:125px}.pro-header-cta{display:none}.pro-catalog-toggle{font-size:11px;padding-inline:7px;margin-left:0}.pro-home-hero .pro-home-grid{display:flex;flex-direction:column;gap:36px;padding-top:48px;padding-bottom:48px}.pro-home-hero .pro-hero-copy h1{font-size:clamp(43px,10vw,68px)}.pro-home-hero .pro-hero-copy p{font-size:15px}.pro-home-hero .pro-hero-art{width:100%;min-height:290px}.pro-home-hero .pro-hero-bottom{display:none}.pro-section{padding-top:68px!important;padding-bottom:68px!important}.pro-section-head{align-items:flex-start}.pro-grid{grid-template-columns:1fr;gap:18px}.pro-card-cover{aspect-ratio:1.45}.pro-product-hero,.pro-detail-layout,.pro-info-grid,.pro-inquiry-wrap{display:grid;grid-template-columns:1fr;gap:30px}.pro-product-summary{padding:0}.pro-specs>div{grid-template-columns:1fr 1fr}.pro-mega{top:100%;max-height:65vh;overflow-y:auto}.pro-mega>div{grid-template-columns:1fr}.pro-filter-panel{display:grid;grid-template-columns:1fr;gap:12px}.pro-filter-panel label{min-width:0}.pro-footer-top{display:grid;grid-template-columns:1fr;gap:27px}.pro-footer-bottom{gap:12px;flex-wrap:wrap}}
@media(max-width:410px){.pro-logo-name{max-width:115px}.pro-logo-symbol{font-size:28px}.pro-catalog-toggle{font-size:10px}.pro-mobile-toggle{min-width:42px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
File diff suppressed because one or more lines are too long
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const $=id=>document.querySelector(id);
const html=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pathParts=location.pathname.split('/').filter(Boolean);
const siteId=pathParts[1],initialPage=pathParts[2];
let csrf='',site=null,pages=[],schema=null,reusables=[],pageId=initialPage,blocks=[],kind='standard',layoutMode='append',selected=-1,dirty=false,timer=null;
const api=async(p,opt={})=>{if(!['GET','HEAD','OPTIONS'].includes((opt.method||'GET').toUpperCase()))opt.headers={...(opt.headers||{}),'X-SiteForge-CSRF':csrf};let r=await fetch(p,opt);if(r.status===401){location.assign('/login');throw Error('会话已过期')}let t=r.headers.get('content-type')||'';let body=t.includes('json')?await r.json():await r.text();if(!r.ok)throw Error(body.detail||body);return body};
const req=(method,data)=>({method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const toast=(s,err=false)=>{const t=$('#toast');t.textContent=s;t.style.background=err?'#a23c44':'#1c3555';t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3400)};
const activePage=()=>pages.find(e=>e.id===pageId);
const Studio={
async init(){try{csrf=(await api('/api/auth/me')).csrf;site=await api(`/api/sites/${siteId}`);pages=await api(`/api/sites/${siteId}/entries`);schema=await api('/api/editor/blocks');$('#siteName').textContent=site.name;const select=$('#pageSelect');select.innerHTML=pages.map(e=>`<option value="${html(e.id)}">${html(e.title)} (${html(e.route)})</option>`).join('');for(let [k,v] of Object.entries(schema.page_templates))$('#pageTemplate').innerHTML+=`<option value="${k}">${html(v.name)}</option>`;Studio.renderPalette();await Studio.loadReusables();await Studio.choosePage(pageId||pages[0]?.id)}catch(e){toast('初始化失败:'+e.message,true);console.error(e)}},
async choosePage(id){if(dirty&&!confirm('当前页面尚未保存,确定切换?')){$('#pageSelect').value=pageId;return}pageId=id;$('#pageSelect').value=id;const data=await api(`/api/sites/${siteId}/pages/${id}/blueprint`);blocks=data.blocks.map(b=>structuredClone(b));kind=data.page_template;layoutMode=data.layout_mode||'append';$('#layoutMode').value=layoutMode;selected=blocks.length?0:-1;$('#pageTemplate').value=kind;$('#pageName').textContent=data.entry.title;$('#routeDisplay').textContent=data.entry.route;dirty=false;Studio.draw();Studio.refreshPreview()},
async loadReusables(){try{reusables=await api(`/api/sites/${siteId}/components`);$('#reusablePalette').innerHTML=reusables.map(x=>`<button onclick="Studio.insertReusable('${html(x.id)}')">✦ ${html(x.name)} <small>v${x.version}</small></button>`).join('')||'<span style="font-size:12px;opacity:.55;padding:10px">暂无保存的组件</span>'}catch(e){console.warn(e)}},
async saveReusable(){const block=blocks[selected];if(!block)return toast('请先选择一个区块',true);const name=prompt('为此组件命名',block.title||schema.blocks[block.type]?.name);if(!name?.trim())return;try{await api(`/api/sites/${siteId}/components`,req('POST',{name,block}));await Studio.loadReusables();toast('组件已保存到本站组件库')}catch(e){toast(e.message,true)}},
insertReusable(id){const saved=reusables.find(x=>x.id===id);if(!saved)return;const copy=structuredClone(saved.block);const refreshIds=item=>{item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);if(item.type==='layout')(item.columns||[]).forEach(c=>(c.blocks||[]).forEach(refreshIds))};refreshIds(copy);blocks.push(copy);selected=blocks.length-1;Studio.modified();Studio.draw();},
renderPalette(){let p=$('#palette');p.innerHTML=Object.entries(schema.blocks).map(([key,b])=>`<button onclick="Studio.add('${key}')"><span>${html(b.icon)}</span>${html(b.name)}</button>`).join('')},
draw(){let list=$('#blockList');$('#blockCount').textContent=blocks.length+' 区块';list.innerHTML=blocks.map((b,i)=>`<div class="block-row ${selected===i?'selected':''}" draggable="true" data-index="${i}" ondragstart="Studio.drag(event,${i})" ondragover="event.preventDefault()" ondrop="Studio.drop(event,${i})" onclick="Studio.select(${i})"><div class="drag-handle" title="拖动排序">⠿</div><div class="block-icon">${html(schema.blocks[b.type]?.icon||'▣')}</div><div class="block-copy"><strong>${html(schema.blocks[b.type]?.name||b.type)}</strong><small>${html(b.title||b.subtitle||b.type)}</small></div><div class="row-actions"><button title="复制" onclick="event.stopPropagation();Studio.duplicate(${i})">⧉</button><button title="删除" onclick="event.stopPropagation();Studio.remove(${i})">×</button></div></div>`).join('')||'<div class="no-selection">当前没有区块,点击下方按钮添加。</div>';Studio.inspector()},
select(i){selected=i;Studio.draw()},
add(type){let data=structuredClone(schema.blocks[type].defaults);data.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);data.type=type;data.visible=true;blocks.push(data);selected=blocks.length-1;Studio.modified();Studio.draw()},
duplicate(i){let b=structuredClone(blocks[i]);b.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);blocks.splice(i+1,0,b);selected=i+1;Studio.modified();Studio.draw()},
remove(i){if(!confirm('确定删除这个区块?'))return;blocks.splice(i,1);selected=Math.min(i,blocks.length-1);Studio.modified();Studio.draw()},
drag(e,i){e.dataTransfer.setData('text/plain',String(i));e.dataTransfer.effectAllowed='move';e.currentTarget.classList.add('dragging')},
drop(e,i){e.preventDefault();let src=Number(e.dataTransfer.getData('text/plain'));if(!Number.isInteger(src)||src<0||src>=blocks.length||src===i)return;let [b]=blocks.splice(src,1);blocks.splice(i,0,b);selected=i;Studio.modified();Studio.draw()},
field(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();Studio.draw()},
fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified();const row=$('#blockList').children[selected];if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type},
jsonField(key,value){try{let data=JSON.parse(value);if(!Array.isArray(data))throw Error('必须为数组');Studio.fieldQuiet(key,data);$('#jsonError').textContent='✓ JSON 格式有效';$('#jsonError').style.color='#238460'}catch(e){$('#jsonError').textContent='JSON 格式有误:'+e.message;$('#jsonError').style.color='#bf5a58'}},
styleField(key,value){const b=blocks[selected];b.style={...(b.style||{}),[key]:value};Studio.modified()},
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection">请从左侧选择区块并编辑属性。</div>';return}const spec=schema.blocks[b.type];let out=`<div class="inspector-title"><h3>${html(spec.name)}</h3><label class="toggle"><input type="checkbox" ${b.visible!==false?'checked':''} onchange="Studio.field('visible',this.checked)">显示</label></div>`;
for(const field of spec.fields){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度'})[field]||field;
out+=`<div class="prop"><label>${html(label)}</label>`;
if(field==='items'||field==='images')out+=`<textarea spellcheck="false" onchange="Studio.jsonField('${field}',this.value)">${html(JSON.stringify(value,null,2))}</textarea><span id="jsonError" class="prop-tip">请输入 JSON 数组,可编辑多个项目</span>`;
else if(field==='source')out+=`<select onchange="Studio.field('${field}',this.value)">${['product','post','page','vehicle','brand','case','service','other','all'].map(t=>`<option value="${t}" ${t===value?'selected':''}>${({product:'产品',post:'文章',page:'普通页面',vehicle:'车型',brand:'品牌',case:'案例',service:'服务',other:'其他',all:'全部类型'})[t]}</option>`).join('')}</select><span class="prop-tip">网站已有内容将自动绑定并生成卡片。</span>`;
else if(field==='image_side'||field==='align')out+=`<select onchange="Studio.field('${field}',this.value)">${(field==='align'?['left','center']:['left','right']).map(v=>`<option value="${v}" ${value===v?'selected':''}>${v==='left'?'左侧':v==='right'?'右侧':'居中'}</option>`).join('')}</select>`;
else if(field==='body'||field==='description')out+=`<textarea onchange="Studio.fieldQuiet('${field}',this.value)">${html(value)}</textarea>`;
else if(['limit','columns','height'].includes(field))out+=`<input type="number" min="1" max="120" value="${html(value)}" onchange="Studio.fieldQuiet('${field}',Number(this.value))">`;
else out+=`<input type="text" value="${html(value)}" oninput="Studio.fieldQuiet('${field}',this.value)">`;
if(['title','subtitle','description','image','body'].includes(field))out+='<small class="prop-tip">可输入 {{site.name}}、{{page.title}} 等变量。</small>';
out+='</div>';
}
if(b.type==='layout'){
out+=`<div class="subheading">列内组件 · 无代码布局</div><p class="prop-tip">一行最多四列,拖动整个容器可改变行顺序。列在手机上自动变为单列。</p>`;
b.columns.forEach((column,ci)=>{
out+=`<div class="layout-editor-col"><div class="layout-editor-title"><strong>第${ci+1}列</strong><label>宽度 <input type="number" min="0.5" max="4" step="0.5" value="${html(column.width)}" onchange="Studio.setColumnWidth(${ci},this.value)"></label><button onclick="Studio.removeColumn(${ci})">删除列</button></div>`;
column.blocks.forEach((child,j)=>{out+=`<div class="layout-child"><strong>${html(schema.blocks[child.type]?.name||child.type)}</strong><button onclick="Studio.removeChild(${ci},${j})" title="删除">×</button>`;
for(const key of (schema.blocks[child.type]?.fields||[]).filter(x=>['title','subtitle','description','image','body','href','button'].includes(x))){out+=`<label>${html(key)}<input value="${html(child[key]??'')}" onchange="Studio.childField(${ci},${j},'${key}',this.value)"></label>`}
if(b.columns.length>1){out+=`<select onchange="Studio.moveChild(${ci},${j},Number(this.value))"><option value="">移动到其他列</option>${b.columns.map((c,ki)=>ki===ci?'':`<option value="${ki}">第${ki+1}列</option>`).join('')}</select>`}
out+='</div>';
});
out+=`<div class="layout-child-toolbar">${['richtext','image_text','collection','cta','features'].map(t=>`<button onclick="Studio.addChild(${ci},'${t}')">+${html(schema.blocks[t].name)}</button>`).join('')}</div></div>`;
});
out+='<button class="ui-btn ghost" onclick="Studio.addColumn()">+ 添加列</button>';
}
out+=`<div class="subheading">区块设计(按设备覆盖)</div><div class="prop"><label>背景颜色</label><input type="color" value="${html(b.style?.background||'#ffffff')}" onchange="Studio.styleField('background',this.value)"></div><div class="prop"><label>上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.padding??82)}" onchange="Studio.styleField('padding',Number(this.value))"></div><div class="prop"><label>平板上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.tablet_padding??50)}" onchange="Studio.styleField('tablet_padding',Number(this.value))"></div><div class="prop"><label>手机上下间距(px)</label><input type="number" min="0" max="120" value="${html(b.style?.mobile_padding??28)}" onchange="Studio.styleField('mobile_padding',Number(this.value))"></div><div class="prop"><label><input type="checkbox" ${b.style?.hide_mobile?'checked':''} onchange="Studio.styleField('hide_mobile',this.checked)"> 手机上隐藏区块</label></div><div class="prop"><label>上传区块图片</label><input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0])"></div>`;
out+='<div style="display:flex;gap:8px;margin-bottom:22px"><button class="small-btn" onclick="Studio.duplicate('+selected+')">复制区块</button><button class="small-btn red-btn" onclick="Studio.remove('+selected+')">删除区块</button></div>';
target.innerHTML=out;
},
modified(){dirty=true;$('#saveStatus').textContent='未保存更改';$('#saveStatus').classList.add('dirty');clearTimeout(timer);timer=setTimeout(()=>Studio.refreshPreview(),500)},
async refreshPreview(){if(!pageId)return;try{let frame=$('#sitePreview');let doc=await api(`/api/sites/${siteId}/pages/${pageId}/editor-preview`,req('POST',{blocks,page_template:kind,layout_mode:layoutMode}));frame.srcdoc=doc}catch(e){toast('预览失败:'+e.message,true)}},
device(type){$('#deviceFrame').className='device-frame '+(type==='desktop'?'':type);for(let name of ['Desktop','Tablet','Phone'])$('#device'+name).classList.toggle('on',name.toLowerCase()===type)},
changeTemplateKind(t){kind=t;Studio.modified()},
setLayoutMode(v){layoutMode=v;Studio.modified()},
async resetPreset(){if(!confirm('使用预设区块将覆盖当前页面布局,是否继续?'))return;try{let result=await api(`/api/sites/${siteId}/pages/${pageId}/blueprint/preset`,req('POST',{page_template:kind}));blocks=result.blocks;selected=0;dirty=false;Studio.draw();Studio.refreshPreview();toast('已加载页面预设并保存')}catch(e){toast(e.message,true)}},
async save(){try{const result=await api(`/api/sites/${siteId}/pages/${pageId}/blueprint`,req('PUT',{blocks,page_template:kind,layout_mode:layoutMode}));blocks=result.blocks;dirty=false;$('#saveStatus').textContent='已保存';$('#saveStatus').classList.remove('dirty');toast('页面蓝图已保存,重新构建后会生成正式静态页面')}catch(e){toast('保存失败:'+e.message,true)}},
addColumn(){const b=blocks[selected];if(!b||b.type!=='layout'||b.columns.length>=4)return;b.columns.push({width:1,blocks:[]});Studio.modified();Studio.inspector()},
removeColumn(i){const b=blocks[selected];if(!b||b.type!=='layout'||b.columns.length<=1)return;if(b.columns[i].blocks.length&&!confirm('这一列含有组件,确认删除?'))return;b.columns.splice(i,1);Studio.modified();Studio.inspector()},
setColumnWidth(i,value){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].width=Math.max(.5,Math.min(4,Number(value)||1));Studio.modified()},
addChild(i,type){const b=blocks[selected];if(!b||b.type!=='layout')return;const item=structuredClone(schema.blocks[type].defaults);item.type=type;item.id=crypto.randomUUID().replace(/-/g,'').slice(0,10);item.visible=true;b.columns[i].blocks.push(item);Studio.modified();Studio.inspector()},
removeChild(i,j){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks.splice(j,1);Studio.modified();Studio.inspector()},
childField(i,j,key,value){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks[j][key]=value;Studio.modified()},
moveChild(ci,ri,target){const b=blocks[selected];if(!b||b.type!=='layout'||target<0||target>=b.columns.length)return;let [component]=b.columns[ci].blocks.splice(ri,1);b.columns[target].blocks.push(component);Studio.modified();Studio.inspector()},
async uploadImage(file){if(!file)return;try{const form=new FormData();form.append('file',file);let r=await api(`/api/sites/${siteId}/media`,{method:'POST',body:form});Studio.field('image',r.url);toast('图片已上传,保存页面后参与构建')}catch(e){toast(e.message,true)}},
async buildSite(){if(dirty){await Studio.save();if(dirty)return}try{toast('开始构建网站……');let result=await api(`/api/sites/${siteId}/builds`,req('POST',{mirror_images:false}));toast('构建完成:'+result.pages+' 个页面');window.open(`/preview/${siteId}/${result.version}/`,'_blank','noopener')}catch(e){toast('构建失败:'+e.message,true)}},
};
window.Studio=Studio;
window.addEventListener('DOMContentLoaded',()=>Studio.init());
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
window.addEventListener('message',event=>{
const frame=document.querySelector('#sitePreview');
if(!frame||event.source!==frame.contentWindow)return;
const data=event.data||{};
if(data.source!=='siteforge-inline-editor')return;
const findChild=rows=>{for(const row of rows){if(row.id===data.blockId)return {block:row,parent:row};if(row.type==='layout'){for(const col of row.columns||[]){const child=findChild(col.blocks||[]);if(child)return {block:child.block,parent:row};}}}return null};
const found=findChild(blocks),b=found?.block;
if(!b||!['title','description'].includes(data.field)||typeof data.value!=='string')return;
b[data.field]=data.value.slice(0,500);
selected=blocks.indexOf(found.parent);Studio.modified();Studio.draw();
toast('已修改文字,点击保存页面写入数据库');
});
+58
View File
@@ -0,0 +1,58 @@
'use strict';
// Enterprise pages extend the existing reliable Docker Web console without requiring a CDN.
tabs.push(['design-system','设计规范'],['migration-qa','迁移验收'],['operations','任务 / 版本'],['permissions','成员 / 审计']);
const sfEnterpriseApi=async (path,method='GET',data)=>api(path,method==='GET'?{}:json(method,data));
const sfRoot='/api/enterprise';
const _oldRenderTab=App.renderTab.bind(App);
App.renderTab=function(){
if(!['design-system','migration-qa','operations','permissions'].includes(state.tab))return _oldRenderTab();
const el=document.getElementById('tab-content');
el.innerHTML='<div class="panel"><p class="muted">正在读取站点工作数据...</p></div>';
if(state.tab==='design-system')EnterpriseUI.design().catch(onError);
if(state.tab==='migration-qa')EnterpriseUI.migration().catch(onError);
if(state.tab==='operations')EnterpriseUI.operations().catch(onError);
if(state.tab==='permissions')EnterpriseUI.permissions().catch(onError);
};
const EnterpriseUI={
section(name,caption='',actions=''){return `<div class="panel-title"><div><h3>${E(name)}</h3><p>${E(caption)}</p></div>${actions}</div>`},
async design(){
const x=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/design-tokens`);
const fields=[['font','全站字体','select',['system','serif','modern']],['heading_scale','标题比例','number'],['radius','圆角半径(px)','number'],['section_spacing','板块上下间距(px)','number'],['container_width','内容最大宽度(px)','number'],['button_style','按钮形状','select',['rounded','pill','square']],['header_style','导航密度','select',['standard','compact']]];
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('全站设计规范','同一个主题的不同网站可拥有完全不同的字体、间距、圆角和排版节奏。')}<form id="tokensForm" onsubmit="EnterpriseUI.saveDesign(event)"><div class="setting-grid">${fields.map(([k,name,type,options])=>`<label>${E(name)}${type==='select'?`<select name="${k}">${options.map(o=>`<option ${o===x[k]?'selected':''} value="${o}">${o}</option>`).join('')}</select>`:`<input type="number" name="${k}" step="${k==='heading_scale'?'0.05':'1'}" value="${ATTR(x[k])}">`}</label>`).join('')}</div><div class="panel-footer"><button class="btn primary" type="submit">保存设计规范</button></div></form></div>`;
},
async saveDesign(event){event.preventDefault();try{
const raw=Object.fromEntries(new FormData(event.target));
for(const k of ['heading_scale','radius','section_spacing','container_width'])raw[k]=Number(raw[k]);
await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/design-tokens`,'PUT',raw);
notice('设计规范已保存,重新构建即可查看新效果');
}catch(err){onError(err)}},
async migration(){
const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/baseline/report`);
const badge=r.errors?`<span class="pill">${r.errors} 项 URL 错误</span>`:'<span class="pill green">无 URL 缺失</span>';
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('旧站 URL / SEO 基线验收','迁移模式发布前必须建立旧站基线,杜绝静默漏页。',`<button class="btn light small" onclick="EnterpriseUI.snapshot()">从当前内容建立基线</button>`)}<div class="metrics-grid"><div class="metric"><small>旧 URL 清单</small><strong>${r.baseline_count}</strong></div><div class="metric"><small>当前页面</small><strong>${r.current_count}</strong></div><div class="metric"><small>URL 缺失</small><strong>${r.errors}</strong></div><div class="metric"><small>SEO 差异</small><strong>${r.warnings}</strong></div></div><div class="panel-footer" style="justify-content:space-between">${badge}<a class="btn light" href="${sfRoot}/sites/${state.siteId}/baseline/export.csv">下载旧站 URL 清单</a></div><p class="muted" style="margin-top:15px">注意:最可靠的做法是在导入旧站后、换模板和调整内容前建立基线。也可调用 API 批量导入旧站已知 URL 清单。</p><div style="max-height:420px;overflow:auto">${r.issues.map(x=>`<div class="tip ${x.severity==='error'?'warning':''}"><strong>${E(x.route)} · ${E(x.field)}</strong><div style="overflow-wrap:anywhere">${E(x.from)} → ${E(x.to)}</div></div>`).join('')||'<div class="tip">暂无 URL 或 SEO 差异。</div>'}</div></div>`;
},
async snapshot(){if(!confirm('这会用当前站点的 URL 与 SEO 覆盖旧基线。推荐在旧站导入完成后、进行改版前操作。是否继续?'))return;
try{const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/baseline/snapshot`,'POST',{});notice(`已记录 ${r.captured} 个原始页面`);await this.migration()}catch(err){onError(err)}},
async operations(){
const jobs=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs`);
const releases=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/releases`);
const builds=state.builds;
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('异步构建任务','后台 Worker 负责执行耗时构建,关闭浏览器也不影响任务。',`<button class="btn primary small" onclick="EnterpriseUI.queueBuild()">+ 提交异步构建</button>`)}<table class="sf-table" style="width:100%"><thead><tr><th>任务 ID</th><th>状态</th><th>尝试次数</th><th>创建时间</th></tr></thead><tbody>${jobs.map(j=>`<tr><td><code>${E(j.id)}</code></td><td>${E(j.state)}</td><td>${j.attempts}</td><td>${simpleDate(j.created_at)}</td></tr>`).join('')||'<tr><td colspan="4">暂无任务</td></tr>'}</tbody></table></div><div class="panel" style="margin-top:22px">${this.section('构建版本与质量检测','检查 HTML / 站内链接 / 图片 ALT / SEO,再决定是否发布。')}<div class="setting-grid">${builds.slice(0,15).map(b=>`<div class="tip"><strong>${E(b.id)} · ${b.total_pages} 页</strong><p>${simpleDate(b.created_at)}</p><button class="btn light small" onclick="EnterpriseUI.quality('${b.id}')">质量检测</button> <button class="btn light small" onclick="EnterpriseUI.rollback('${b.id}')">发布 / 回滚至此版本</button></div>`).join('')||'<p>暂无构建,请先提交任务。</p>'}</div><div id="sf-quality-result"></div></div><div class="panel" style="margin-top:22px">${this.section('本地发布记录','发布记录保留全部构建版本,方便核对和恢复。')}${releases.slice(0,30).map(r=>`<p>${E(r.action)} · <code>${E(r.build_id)}</code> · ${simpleDate(r.created_at)}</p>`).join('')||'<p>暂无发布历史</p>'}</div>`;
},
async queueBuild(){try{const j=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/jobs`,'POST',{kind:'build'});notice('构建任务已提交 '+j.id);await this.operations()}catch(err){onError(err)}},
async quality(id){try{const r=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/builds/${id}/quality`);const el=document.getElementById('sf-quality-result');if(el)el.innerHTML=`<div class="tip ${r.errors?'warning':''}"><h4>检查结果 · ${r.files} 页 · ${r.errors} 错误 · ${r.warnings} 警告</h4>${r.issues.slice(0,60).map(i=>`<p><strong>${E(i.file)}</strong> ${E(i.type)} ${E(i.target||'')}</p>`).join('')||'通过基础静态质量检查'}</div>`}catch(err){onError(err)}},
async rollback(id){if(!confirm('确认将此构建版本设为当前本地发布版本?迁移站发布前必须通过 URL 基线检查。'))return;
try{const result=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/releases/${id}/rollback`,'POST',{});notice(result.published?'发布成功':'发布失败');await this.operations()}catch(err){onError(err)}},
async permissions(){
const logs=await sfEnterpriseApi(`${sfRoot}/audit?site_id=${state.siteId}&limit=50`);
let members=[];try{members=await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/members`)}catch{}
let users=[];try{users=await sfEnterpriseApi(`${sfRoot}/team/users`)}catch{}
document.getElementById('tab-content').innerHTML=`<div class="panel">${this.section('网站团队与角色','Owner 可以管理成员,Editor 可以修改内容,Reviewer 和 Viewer 为只读角色。')}<div class="setting-grid">${members.map(m=>`<div class="tip"><strong>${E(m.display_name||m.username)}</strong><p>${E(m.username)} · ${E(m.role)}</p></div>`).join('')||'<p>尚未添加团队成员。系统管理员拥有全部权限。</p>'}</div>${users.length?`<form onsubmit="EnterpriseUI.assign(event)" class="setting-grid"><label>已有账号<select name="user_id">${users.map(u=>`<option value="${u.id}">${E(u.username)}</option>`).join('')}</select></label><label>角色<select name="role"><option>owner</option><option>editor</option><option>reviewer</option><option>viewer</option></select></label><div><button class="btn primary" type="submit">授予站点访问权限</button></div></form>`:''}</div><div class="panel" style="margin-top:22px">${this.section('操作审计日志','记录修改 API 的操作者、时间、路径与响应状态。')}<div style="max-height:450px;overflow:auto">${logs.map(l=>`<div class="tip"><strong>${E(l.actor)} · ${E(l.operation)} · ${l.status}</strong><p style="overflow-wrap:anywhere">${E(l.path)} · ${simpleDate(l.created_at)}</p></div>`).join('')||'<p>暂无操作日志</p>'}</div></div>`;
},
async assign(event){event.preventDefault();try{await sfEnterpriseApi(`${sfRoot}/sites/${state.siteId}/members`,'PUT',Object.fromEntries(new FormData(event.target)));notice('权限已更新');await this.permissions()}catch(err){onError(err)}},
async team(){try{const users=await sfEnterpriseApi(`${sfRoot}/team/users`);state.siteId=null;topTitle('团队账号');
document.getElementById('view').innerHTML=hero('团队与权限','创建协作者,再为每个网站分配独立角色。')+`<div class="split"><div class="panel">${this.section('新增团队账号','至少12位密码,使用 scrypt 加盐哈希存储。')}<form onsubmit="EnterpriseUI.addUser(event)" class="setting-grid"><label>账号<input required name="username" placeholder="editor_01"></label><label>显示名称<input required name="display_name" placeholder="运营编辑"></label><label class="full">临时密码<input required type="password" name="password" minlength="12" autocomplete="new-password"></label><div><button class="btn primary">创建账号</button></div></form></div><div class="panel">${this.section('现有成员','站点权限可在各个网站的“成员 / 审计”中配置。')}${users.map(u=>`<div class="tip"><strong>${E(u.username)}</strong><p>${E(u.display_name)} · ${u.disabled?'停用':'启用'}</p></div>`).join('')||'<p>尚无协作者。</p>'}</div></div>`;
}catch(err){onError(err)}},
async addUser(event){event.preventDefault();try{await sfEnterpriseApi(`${sfRoot}/team/users`,'POST',Object.fromEntries(new FormData(event.target)));notice('团队账号创建成功');await this.team()}catch(err){onError(err)}}
};
window.EnterpriseUI=EnterpriseUI;
File diff suppressed because one or more lines are too long
+65
View File
@@ -0,0 +1,65 @@
'use strict';
const $=s=>document.querySelector(s), H=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state={sites:[],themes:[],site:null,tab:'overview',csrf:'',importRows:[],fields:[],mapping:{},kind:'product'};
const toast=(s,error=false)=>{let el=$('#alert');el.textContent=s;el.className='on';el.style.background=error?'#ffe6e6':'#e3f7ed';el.style.color=error?'#b13445':'#176e50';setTimeout(()=>el.className='',5500)};
async function api(url,method='GET',data=null){const opt={method,credentials:'same-origin',headers:{}};if(!['GET','HEAD'].includes(method))opt.headers['X-SiteForge-CSRF']=state.csrf;
if(data instanceof FormData)opt.body=data;else if(data!==null){opt.headers['Content-Type']='application/json';opt.body=JSON.stringify(data)}
const r=await fetch(url,opt);if(r.status===401){location.href='/login';throw Error('请登录')};let body=(r.headers.get('content-type')||'').includes('json')?await r.json():await r.text();if(!r.ok)throw Error(body.detail||body);return body;}
const siteUrl=path=>`/api/sites/${state.site.id}${path}`;
const factoryUrl=path=>`/api/factory/sites/${state.site.id}${path}`;
function selected(){if(!state.site){$('#panel').innerHTML='<div class="section"><h2>创建第一个网站</h2><p>选择主题后即可开始添加新内容,或导入旧网站的数据。</p><button class="primary" onclick="openCreate()">创建网站</button></div>';return false}return true;}
function setTab(tab){state.tab=tab;$$('#navtabs button').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));render().catch(e=>toast(e.message,true))}
function $$(s){return [...document.querySelectorAll(s)]}
function setHeading(h,desc){$('#heading').textContent=h;$('#description').textContent=desc;$('#currentTab').textContent=h;}
async function init(){try{state.csrf=(await api('/api/auth/me')).csrf;await refresh();document.querySelectorAll('#navtabs button').forEach(btn=>btn.addEventListener('click',()=>setTab(btn.dataset.tab)));
$('#sitePicker').onchange=()=>{state.site=state.sites.find(s=>s.id===$('#sitePicker').value);render().catch(e=>toast(e.message,true))};$('#reload').onclick=refresh;$('#newSite').onclick=openCreate;$('#closeCreate').onclick=()=>$('#createDialog').close();$('#createForm').onsubmit=create;
$('#buildQuick').onclick=()=>buildNow();await render()}catch(e){toast('初始化失败:'+e.message,true)}}
async function refresh(){state.sites=await api('/api/sites');state.themes=await api('/api/factory/themes');let old=state.site?.id;state.site=state.sites.find(x=>x.id===old)||state.sites[0]||null;
$('#sitePicker').innerHTML=state.sites.map(s=>`<option value="${H(s.id)}">${H(s.name)}</option>`).join('');if(state.site)$('#sitePicker').value=state.site.id;
$('#newTheme').innerHTML=state.themes.map(t=>`<option value="${H(t.id||t.theme_id)}">${H(t.name)}</option>`).join('');}
function openCreate(){$('#createDialog').showModal()}
async function create(e){e.preventDefault();try{let d=Object.fromEntries(new FormData(e.target));let result=await api('/api/sites','POST',d);state.site=result;await api(`/api/factory/sites/${result.id}/bootstrap`,'POST',{});$('#createDialog').close();toast('新网站已创建并生成基础页面结构');await refresh();await render()}catch(err){toast(err.message,true)}}
async function render(){$('#openEditor').href=state.site?`/editor/${state.site.id}`:'#';if(!selected())return;
const maps={overview:['网站总览','查看生产进度、页面数量和建站方式'],themes:['主题与版本','安装主题、切换主题、固定版本以及批量套版'],content:['内容与自动装配','导入 CSV/JSON、自动识别字段、预览映射并建立新内容'],migration:['迁移与 SEO 验收','核对旧站 URL、媒体资源和 SEO 差异'],build:['构建与发布','生成独立 HTML 站点、检测问题、下载与版本管理']};setHeading(...maps[state.tab]);
const fn={overview:overview,themes:themes,content:content,migration:migration,build:buildPanel}[state.tab];$('#panel').innerHTML='<div class="loading">加载中……</div>';await fn();}
async function overview(){let [entries,builds,source]=await Promise.all([api(siteUrl('/entries')),api(siteUrl('/builds')),api(factoryUrl('/theme'))]);let s=state.site;
$('#panel').innerHTML=`<div class="cards"><div class="card"><span class="overline">当前站点</span><strong>${H(s.name)}</strong><small>${H(s.mode==='migration'?'旧站迁移':'新站搭建')} · ${H(s.theme)}</small></div><div class="card"><span class="overline">内容总数</span><strong>${entries.length}</strong><small>页面/产品/品牌/文章</small></div><div class="card"><span class="overline">构建版本</span><strong>${builds.length}</strong><small>静态网站历史记录</small></div><div class="card"><span class="overline">主题版本</span><strong>${H(source.version)}</strong><small>已固定版本可追踪</small></div></div>
<div class="section"><h2>网站工厂生产流程</h2><div class="steps"><div class="step">01<b>网站结构</b><small>首页、栏目页和导航</small></div><div class="step">02<b>内容自动装配</b><small>导入新数据或旧内容</small></div><div class="step">03<b>设计编辑</b><small>切换主题、可视化编辑</small></div><div class="step">04<b>静态站交付</b><small>构建、验收、发布</small></div></div></div>
<div class="columns"><div class="section"><div class="toolbar"><h2>网站页面</h2><button class="outline" onclick="bootstrap()">补齐标准页面</button></div><div class="scroll"><table><thead><tr><th>页面/内容</th><th>类型</th><th>URL</th><th>操作</th></tr></thead><tbody>${entries.slice(0,25).map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.type)}</td><td>${H(x.route)}</td><td><a href="/editor/${H(s.id)}/${H(x.id)}">设计页面 ↗</a></td></tr>`).join('')}</tbody></table></div></div><div class="section"><h2>联系信息与表单服务</h2><p>邮箱会出现在主题的联系页与页脚。外部表单地址为空时,不显示假提交按钮。</p><label class="field">联系邮箱<input id="configEmail" type="email" value="${H(s.settings?.email||'')}"></label><label class="field">HTTPS 表单接收地址(可选)<input id="configForm" value="${H(s.settings?.form_endpoint||'')}"></label><label class="field">页脚说明<input id="configFooter" value="${H(s.settings?.footer_text||'')}"></label><button class="outline" onclick="saveContactSettings()">保存网站联系配置</button></div><div class="section"><h2>常用功能</h2><div class="stack"><button class="outline" onclick="setTab('themes')">切换完整主题 →</button><button class="outline" onclick="setTab('content')">导入内容 / 字段映射 →</button><button class="outline" onclick="setTab('migration')">检查 SEO 和旧站路径 →</button><button class="primary" onclick="buildNow()">生成网站 HTML →</button></div><p>当没有真实产品时,主题不会虚构业务数据;可先完善基础页面,再逐步导入。</p></div></div>`;}
async function bootstrap(){try{let r=await api(factoryUrl('/bootstrap'),'POST',{});toast(`新增 ${r.created.length} 个基础页面;不会覆盖原 URL`);await render()}catch(e){toast(e.message,true)}}
async function themes(){let cur=await api(factoryUrl('/theme'));const schemes={automotive:['#172840','#cfab79','MOVE BEYOND'],industrial:['#dcebf4','#14517d','BUILD FURTHER'],corporate:['#e7e9fb','#535bb4','CREATE IMPACT'],minimal:['#e9e5dc','#3d3b35','LESS IS MORE']};
$('#panel').innerHTML=`<div class="section"><div class="toolbar"><h2>可安装、可版本化的主题包</h2><span class="badge">当前主题:${H(cur.theme)} · v${H(cur.version)}</span></div><div class="gridthemes">${state.themes.map(t=>{let id=t.id||t.theme_id,p=schemes[id]||['#e9eff8','#246c9e','YOUR BRAND'];return `<article class="theme-card"><div class="theme-visual" style="background:${p[0]};color:${p[1]}"><div class="mocknav"></div><div class="mocktitle">${H(p[2])}</div><div class="mockbtn"></div></div><div class="theme-info"><h3>${H(t.name)} <small class="muted">v${H(t.version)}</small></h3><p>${H(t.description)}</p><div class="actions"><button class="outline" onclick="chooseTheme('${H(id)}','${H(t.version)}')">${id===cur.theme?'重新应用':'应用主题'}</button><button class="outline" onclick="showVersions('${H(id)}')">版本管理</button><button class="outline" onclick="checkTheme('${H(id)}','${H(t.version)}')">兼容检查</button><a class="outline" target="_blank" href="/theme-gallery">全部页面预览</a><a class="outline" target="_blank" href="/api/factory/themes/${H(id)}/preview?version=${H(t.version)}">首页预览</a></div></div></article>`}).join('')}</div></div>
<div class="columns"><div class="section"><h2>安装新主题 ZIP</h2><p>主题包由 manifest.json、theme.css、page-presets.json 组成。只能安装受控的页面组件和 CSS,不执行上传的 JavaScript 或模板逻辑。</p><label class="field">上传 ZIP 文件<input type="file" id="themePackage" accept=".zip"></label><button class="primary" onclick="uploadTheme()">安装并登记版本</button></div><div class="section"><h2>批量重装配现有内容</h2><p>会按页面类型给已有内容应用当前主题;保留现有 URL、SEO 和正文。选择「只补缺失」可避免覆盖已手工设计的区块。</p><button class="outline" onclick="applyTheme('missing')">仅装配缺失页面</button> <button class="outline" onclick="applyTheme('overwrite')">覆盖全部布局(谨慎)</button><div id="themeVersions" class="muted" style="margin-top:18px"></div></div></div>`;}
async function chooseTheme(id,version){if(!confirm(`为当前网站选择主题 ${id} v${version}?将保留网站内容。`))return;try{await api(factoryUrl('/theme'),'PUT',{theme:id,version});await refresh();toast('主题切换完成。需要重新构建才能更新正式网站');await render()}catch(e){toast(e.message,true)}}
async function showVersions(id){let list=await api(`/api/factory/themes/${id}/versions`);$('#themeVersions').textContent='已安装版本:'+list.map(x=>x.version).join(' / ')}
async function checkTheme(id,version){try{const r=await api(`/api/factory/themes/${id}/compatibility?version=${encodeURIComponent(version)}`);$('#themeVersions').textContent=`${r.valid?'✓ 主题结构可用':'✗ 存在阻断'} · ${r.checked_page_types||0} 类页面 · ${[...(r.errors||[]),...(r.warnings||[])].join(';')||'无结构警告'}。正式交付前仍需实际浏览器验收。`;}catch(e){toast(e.message,true)}}
async function uploadTheme(){let f=$('#themePackage').files?.[0];if(!f)return toast('请选择主题 ZIP',true);let data=new FormData();data.append('file',f);try{let r=await api('/api/factory/themes/install','POST',data);toast(`已安装:${r.name} v${r.version}`);await refresh();await render()}catch(e){toast(e.message,true)}}
async function applyTheme(mode){if(mode==='overwrite'&&!confirm('覆盖已有页面设计(内容和 SEO 不变),继续吗?'))return;try{let r=await api(siteUrl('/apply-theme'),'POST',{mode});toast(`套版完成:更新 ${r.updated} 个页面`)}catch(e){toast(e.message,true)}}
async function content(){let entries=await api(siteUrl('/entries'));$('#panel').innerHTML=`<div class="columns"><div class="section"><h2>新内容 / 旧内容导入装配</h2><p>上传 Excel、JSON 数组或 CSV;系统自动建议字段对应关系,例如「车型名称 → 标题」「品牌 → 产品品牌」。映射会经过预览与冲突检查。</p><label class="field">文件<input id="dataFile" type="file" accept=".csv,.json,.xlsx"></label><label class="field">导入内容类型<select id="mapKind"><option value="product">产品</option><option value="vehicle">车型</option><option value="brand">品牌</option><option value="post">新闻</option><option value="case">案例</option><option value="service">服务</option><option value="page">页面</option></select></label><button class="primary" onclick="readData()">读取文件并识别字段</button><div id="mappingUI"></div><div class="toolbar"><button class="outline" onclick="previewRelations()">预览产品-品牌关系</button><button class="outline" onclick="assembleRelations()">生成缺失的品牌页面</button></div><div id="relationReport"></div></div><div class="section"><h2>本站内容统计</h2>${['page','product','vehicle','brand','post','case','service'].map(t=>`<div class="row"><strong>${H(({page:'页面',product:'产品',vehicle:'车型',brand:'品牌',post:'新闻',case:'案例',service:'服务'})[t])}</strong><span class="badge">${entries.filter(e=>e.type===t).length}</span></div>`).join('')}<p>其他导入方式(WordPress XML/REST、Excel、媒体 ZIP)可以在原版站点管理工作台使用,使用相同的数据存储。</p><a class="outline" href="/">打开完整内容管理 →</a></div></div>`;}
function parseCSV(text){let rows=[],row=[],cell='',quoted=false;for(let i=0;i<text.length;i++){let ch=text[i];if(ch==='"'){if(quoted&&text[i+1]==='"'){cell+='"';i++}else quoted=!quoted}else if(ch===','&&!quoted){row.push(cell);cell=''}else if((ch==='\n'||ch==='\r')&&!quoted){if(ch==='\r'&&text[i+1]==='\n')i++;row.push(cell);if(row.some(x=>x.trim()))rows.push(row);row=[];cell=''}else cell+=ch;}row.push(cell);if(row.some(x=>x.trim()))rows.push(row);let cols=rows.shift()?.map(s=>s.trim())||[];return rows.map(r=>Object.fromEntries(cols.map((c,i)=>[c,r[i]||''])))}
async function readData(){let f=$('#dataFile').files?.[0];if(!f)return toast('请选择 Excel、CSV 或 JSON 文件',true);
try{let data=new FormData();data.append('file',f);const parsed=await api(factoryUrl('/mapping/parse-file'),'POST',data);state.importRows=parsed.rows;state.fields=parsed.columns;state.mapping=parsed.suggested;drawMapping();}
catch(e){toast(e.message,true)}}
async function previewRelations(){try{const r=await api(factoryUrl('/relationships'));$('#relationReport').innerHTML=`<div class="card"><b>产品-品牌关联检查</b><p>已有品牌 ${r.brands.length} 个、分类 ${r.categories.length} 个;缺少独立品牌页 ${r.brand_pages_missing.length} 个</p><p>${H(r.brand_pages_missing.map(x=>x.brand).join(' / ')||'无')}</p>${r.issues.slice(0,10).map(x=>`<p>${H(x.route)} · ${H(x.message)}</p>`).join('')}</div>`;}catch(e){toast(e.message,true)}}
async function assembleRelations(){if(!confirm('依据真实产品品牌字段创建缺少的品牌页面?不会自动生成虚构文案。'))return;try{const r=await api(factoryUrl('/relationships/assemble'),'POST',{});toast(`已创建 ${r.count} 个品牌页面`);await previewRelations()}catch(e){toast(e.message,true)}}
function drawMapping(){let fields=['title','route','summary','body','cover','brand','category','model','energy','range','gallery','specs','sku','price','seo_title','seo_description','canonical'];let labels={title:'标题(必填)',route:'旧站 URL',summary:'摘要',body:'详情正文',cover:'封面',brand:'品牌',category:'分类',seo_title:'SEO标题',seo_description:'SEO描述',canonical:'Canonical',model:'型号',energy:'动力形式',range:'续航',gallery:'图库(JSON数组或分号分隔)',specs:'技术参数',sku:'SKU',price:'价格'};
$('#mappingUI').innerHTML=`<div style="margin-top:27px"><h2>字段映射(已读取 ${state.importRows.length} 行)</h2>${fields.map(t=>`<div class="rule"><strong>${H(labels[t])}</strong><select data-target="${t}" class="mapfield"><option value="">不映射</option>${state.fields.map(x=>`<option value="${H(x)}" ${state.mapping[t]===x?'selected':''}>${H(x)}</option>`).join('')}</select></div>`).join('')}<button class="outline" onclick="mappingPreview()">预览数据和错误</button><button class="primary" onclick="mappingCommit()">确认导入</button><div id="mappingReport" style="margin-top:16px"></div></div>`;}
function gather(){let rules={};$$('.mapfield').forEach(x=>{if(x.value)rules[x.dataset.target]=x.value});return {rows:state.importRows,rules,content_type:$('#mapKind').value}}
async function mappingPreview(){try{let r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:true});$('#mappingReport').innerHTML=`<div class="card"><b>预览:${r.mapped} 条可映射</b><p>错误 ${r.errors.length} 条,URL 冲突 ${r.conflicts.length} 条</p><div class="scroll"><table><thead><tr><th>标题</th><th>新 URL</th><th>内容类型</th></tr></thead><tbody>${r.sample.map(x=>`<tr><td>${H(x.title)}</td><td>${H(x.route)}</td><td>${H(x.type)}</td></tr>`).join('')}</tbody></table></div>${r.errors.length?'<p class="error">'+H(JSON.stringify(r.errors.slice(0,6)))+'</p>':''}</div>`}catch(e){toast(e.message,true)}}
async function mappingCommit(){if(!confirm('确认将已映射的数据写入当前网站?'))return;try{let r=await api(factoryUrl('/mapping/run'),'POST',{...gather(),dry_run:false,on_conflict:'skip'});toast(`新增 ${r.created} 条,跳过 ${r.skipped} 条,错误 ${r.errors.length} 条`);await render()}catch(e){toast(e.message,true)}}
async function migration(){let [data,assets,builds]=await Promise.all([api(factoryUrl('/migration/readiness')),api(factoryUrl('/assets/inventory')),api(siteUrl('/builds'))]);
let audit=builds.length?await api(`/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report`).catch(()=>null):null;const gate=data.gates||[];$('#panel').innerHTML=`<div class="cards"><div class="card"><span class="overline">旧站 URL 基线</span><strong>${data.baseline.baseline_count}</strong><small>应保持有效的历史路径</small></div><div class="card"><span class="overline">缺失 URL</span><strong>${data.baseline.errors}</strong><small>发布阻断项</small></div><div class="card"><span class="overline">SEO 差异</span><strong>${data.baseline.warnings}</strong><small>需要人工核查</small></div><div class="card"><span class="overline">资源引用</span><strong>${assets.references}</strong><small>实际已识别的内容图片</small></div></div><div class="columns"><div class="section"><h2>迁移验收门禁</h2>${gate.length?gate.map(g=>`<div class="row"><div><span class="badge ${g.level==='block'?'danger':'warn'}">${g.level==='block'?'阻止发布':'需人工复核'}</span><p>${H(g.message)}</p></div></div>`).join(''):'<p class="success">当前已检测范围内未发现阻断项。</p>'}<div class="toolbar"><label>上传原站清单(WXR / Sitemap XML / CSV / JSON)<input type="file" id="baselineFile" accept=".xml,.csv,.json"></label><button class="primary" onclick="uploadBaseline()">导入原站 URL 基线</button></div><div class="toolbar"><button class="outline" onclick="snapshot()">用当前内容建立 URL 基线(仅限新站)</button><a class="outline" href="${factoryUrl('/assets/inventory')}" target="_blank">资源清单 JSON</a><button class="outline" onclick="openLatestAudit()">构建后查看完整差异报告</button></div><div id="baselineImportResult"></div><p>提示:完整旧站 URL 基线应来自迁移前的原网站清单,不要用迁移后不完整的网站替代。</p></div><div class="section"><h2>实际生成 HTML 与旧站对比</h2>${audit?`<p>旧站 ${audit.baseline_count} 条 / 已匹配 ${audit.matched_pages||0} 条;阻断 ${audit.errors} 项;待复核 ${audit.unreviewed_warnings||0} 项</p><a class="outline" href="/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report.csv">下载差异 CSV</a><div class="scroll"><table><thead><tr><th>问题</th><th>路径</th><th>操作</th></tr></thead><tbody>${audit.issues.slice(0,40).map(i=>`<tr><td>${H(i.field)} · ${H(i.message)}</td><td>${H(i.route)}</td><td>${i.severity==='warning'&&!i.reviewed?`<button class="outline" onclick="approveAuditWarning('${H(builds[0].id)}','${H(i.issue_id)}')">复核接受</button>`:i.reviewed?'已复核':'必须修复'}</td></tr>`).join('')}</tbody></table></div>`:'<p>请先构建新站,再与旧站基线对比。</p>'}</div><div class="section"><h2>前 12 项差异</h2>${data.baseline.issues.slice(0,12).map(x=>`<div class="row"><div><strong>${H(x.route)}</strong><small>${H(x.field)} · ${H(x.severity)}</small></div></div>`).join('')||'<p>没有基线差异。</p>'}</div></div>`;}
async function saveContactSettings(){try{await api(siteUrl('/contact-settings'),'PUT',{email:$('#configEmail').value,form_endpoint:$('#configForm').value,footer_text:$('#configFooter').value});toast('网站联系配置已保存');await refresh()}catch(e){toast(e.message,true)}}
async function approveAuditWarning(bid,issue){const reason=prompt('填写核查原因及处理依据(至少10字,审核后对本构建生效):','');if(!reason)return;try{await api(`/api/audit/sites/${state.site.id}/builds/${bid}/review-warning`,'POST',{issue_id:issue,reason});toast('已记录审查决定');await render()}catch(e){toast(e.message,true)}}
async function uploadBaseline(){const file=$('#baselineFile')?.files?.[0];if(!file)return toast('请选择原站清单文件',true);const data=new FormData();data.append('file',file);
try{let r=await api(`/api/audit/sites/${state.site.id}/import-baseline`,'POST',data);if(r.errors?.length){$('#baselineImportResult').textContent=JSON.stringify(r.errors.slice(0,15));toast('源站清单存在错误,未写入基线',true);return}toast(`已导入 ${r.imported} 个旧站 URL`);await render()}catch(e){toast(e.message,true)}}
async function openLatestAudit(){try{let builds=await api(siteUrl('/builds'));if(!builds.length)return toast('请先构建网站',true);window.open(`/api/audit/sites/${state.site.id}/builds/${builds[0].id}/report`,'_blank','noopener')}catch(e){toast(e.message,true)}}
async function snapshot(){if(!confirm('仅适用于尚未迁移的测试网站。真实迁移应导入旧站原始 URL 清单!继续吗?'))return;try{let r=await api(`/api/enterprise/sites/${state.site.id}/baseline/snapshot`,'POST',{});toast('已建立 '+r.captured+' 条当前内容基线');await render()}catch(e){toast(e.message,true)}}
async function buildPanel(){let [builds,ready,releaseRequests]=await Promise.all([api(siteUrl('/builds')),api(factoryUrl('/migration/readiness')),api(factoryUrl('/release-requests'))]);$('#panel').innerHTML=`<div class="columns"><div class="section"><h2>静态构建版本</h2><div class="toolbar"><button class="primary" onclick="buildNow()">+ 构建新版本</button><span class="badge ${ready.can_publish?'':'danger'}">${ready.can_publish?'可进入发布审查':'发布前有阻断项'}</span></div><div class="scroll"><table><thead><tr><th>版本</th><th>页面数</th><th>生成时间</th><th>交付</th></tr></thead><tbody>${builds.slice(0,15).map(b=>`<tr><td>${H(b.id)}</td><td>${b.total_pages}</td><td>${H(b.created_at)}</td><td><a target="_blank" href="/preview/${state.site.id}/${b.id}/">预览</a> · <a href="/api/sites/${state.site.id}/builds/${b.id}/download">下载</a> · <button class="outline" onclick="requestRelease('${b.id}')">提交审批</button> · <button class="outline" onclick="publishRelease('${b.id}')">发布</button></td></tr>`).join('')}</tbody></table></div></div><div class="section"><h2>交付说明</h2><p>静态网站包含 HTML、CSS、JavaScript、独立搜索索引、站点地图和 Nginx 重定向示例配置。</p><p>通过实际发布前,需要校验 SEO 基线、静态资源和功能;当前本地发布仅适用于事先配置的发布目录,公网 DNS/CDN 连接器仍需另行开发。</p><div class="stack">${ready.gates.map(g=>`<div class="row"><span class="badge ${g.level==='block'?'danger':'warn'}">${H(g.level)}</span><p>${H(g.message)}</p></div>`).join('')}</div><h2 style="margin-top:24px">发布审批记录</h2>${releaseRequests.slice(0,8).map(r=>`<div class="row"><div><strong>${H(r.build_id)}</strong><small>${H(r.requested_by)} · ${H(r.state)}</small></div><div>${r.state==='pending'?`<button class="outline" onclick="reviewRelease('${r.id}','approved')">通过</button> <button class="outline" onclick="reviewRelease('${r.id}','rejected')">驳回</button>`:''}</div></div>`).join('')||'<p>暂无发布审批记录。</p>'}</div></div>`;}
async function requestRelease(build){try{let r=await api(factoryUrl('/release-requests'),'POST',{build_id:build});toast('审批申请已提交:'+r.id);await render()}catch(e){toast(e.message,true)}}
async function reviewRelease(id,status){try{let note=prompt('请输入审批说明(可留空)','')||'';await api(factoryUrl('/release-requests/'+id+'/review'),'POST',{state:status,note});toast('审批状态已更新');await render()}catch(e){toast(e.message,true)}}
async function publishRelease(build){if(!confirm('确认将此版本发布到预先配置的本地持久化站点目录?'))return;try{let r=await api(siteUrl('/builds/'+build+'/publish-local'),'POST',{});toast('已发布:'+r.release);await render()}catch(e){toast(e.message,true)}}
async function buildNow(){if(!state.site)return;try{toast('构建中……');let r=await api(siteUrl('/builds'),'POST',{mirror_images:false});toast(`构建成功,${r.pages} 个页面`);await render();window.open(`/preview/${state.site.id}/${r.version}/`,'_blank','noopener')}catch(e){toast(e.message,true)}}
window.addEventListener('DOMContentLoaded',init);
window.requestRelease=requestRelease;window.reviewRelease=reviewRelease;window.publishRelease=publishRelease;window.bootstrap=bootstrap;window.chooseTheme=chooseTheme;window.checkTheme=checkTheme;window.showVersions=showVersions;window.uploadTheme=uploadTheme;window.applyTheme=applyTheme;window.readData=readData;window.uploadBaseline=uploadBaseline;window.approveAuditWarning=approveAuditWarning;window.saveContactSettings=saveContactSettings;window.openLatestAudit=openLatestAudit;window.previewRelations=previewRelations;window.assembleRelations=assembleRelations;window.mappingPreview=mappingPreview;window.mappingCommit=mappingCommit;window.snapshot=snapshot;window.buildNow=buildNow;window.setTab=setTab;window.openCreate=openCreate;
+27
View File
@@ -0,0 +1,27 @@
/* SiteForge trusted global shell. Site themes remain visually independent. */
:root{--sf-shell:#12243d;--sf-shell-accent:#dcae71;--sf-shell-soft:#edf2f8;--sf-shell-fg:#173049}
html{scroll-behavior:smooth}body{overflow-x:hidden}body>.header,body>.utility,body>.top,body>header,body>footer{display:none!important}
.sf-theme-industrial{--sf-shell:#114e79;--sf-shell-accent:#92d5ed;--sf-shell-soft:#ebf6fa;--sf-shell-fg:#0c4267}
.sf-theme-corporate{--sf-shell:#232d62;--sf-shell-accent:#b8bdff;--sf-shell-soft:#f3f3fa;--sf-shell-fg:#333468}
.sf-theme-minimal{--sf-shell:#1e1e1c;--sf-shell-accent:#ded6c5;--sf-shell-soft:#f6f4ef;--sf-shell-fg:#252520}
.sf-site-head{position:relative;z-index:25;background:var(--sf-shell);color:#fff;font-family:system-ui,-apple-system,sans-serif}
.sf-head-inner{max-width:1240px;margin:0 auto;min-height:78px;padding:0 28px;display:flex;align-items:center;gap:28px}
.sf-logo{display:flex;align-items:center;gap:11px;font-size:18px;text-decoration:none!important;color:#fff!important;white-space:nowrap}
.sf-logo-mark{width:36px;height:36px;display:grid;place-items:center;background:var(--sf-shell-accent);color:var(--sf-shell);font-size:22px;font-weight:900;border-radius:9px}
.sf-primary-links{display:flex;gap:23px;margin-left:auto;align-items:center}
.sf-primary-links a,.sf-mega a{color:#fff!important;text-decoration:none!important;font-size:13px;font-weight:650;white-space:nowrap}
.sf-primary-links a:hover{color:var(--sf-shell-accent)!important}
.sf-head-cta{font-size:13px;font-weight:750;text-decoration:none;background:var(--sf-shell-accent);color:var(--sf-shell)!important;padding:13px 18px;border-radius:4px;white-space:nowrap}
.sf-menu-toggle{display:none;margin-left:auto;color:white;border:1px solid #ffffff55;background:none;border-radius:4px;padding:8px 12px;font-size:19px;cursor:pointer}
.sf-mega{display:flex;gap:18px;max-width:1240px;margin:auto;padding:0 28px 9px;overflow-x:auto}
.sf-mega a{display:block;min-width:120px;border-top:1px solid #ffffff2e;padding:12px 8px}.sf-mega small{display:block;color:#ffffff99;max-width:160px;overflow:hidden;text-overflow:ellipsis;font-size:10px;margin-top:6px}
.sf-site-foot{background:var(--sf-shell);color:white!important;font:14px/1.7 system-ui,-apple-system,sans-serif;padding:72px 28px 26px}
.sf-foot-grid{max-width:1200px;margin:auto;display:grid;grid-template-columns:2fr 1fr 1fr;gap:70px}.sf-foot-grid h2{font-size:25px;color:white;margin:0 0 17px}.sf-foot-grid h4{font-size:13px;color:var(--sf-shell-accent);letter-spacing:.07em;margin:0 0 17px}.sf-foot-grid p{color:#ffffffae;max-width:380px}.sf-foot-grid a{display:block;width:fit-content;color:#fff!important;text-decoration:none;margin-bottom:8px}.sf-foot-lower{border-top:1px solid #ffffff33;max-width:1200px;margin:45px auto 0;padding-top:20px;display:flex;justify-content:space-between;color:#ffffff9e;font-size:12px}
.sf-filters{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin:0 0 30px}.sf-filters input,.sf-filters select{padding:11px 13px;border-radius:6px;border:1px solid #cfd9e4;background:#fff;color:#263a51;max-width:280px}.sf-filter-empty{padding:22px;color:#778598}
.sf-search-page{min-height:76vh;max-width:1060px;padding:90px 24px;margin:auto;font-family:system-ui,sans-serif;color:var(--sf-shell-fg)}.sf-search-page h1{font-size:clamp(35px,5vw,62px);margin:20px 0}.sf-search-page>label{display:block;margin:18px 0 8px}.sf-search-page input{display:block;width:100%;padding:17px;font-size:18px;border:1px solid #b7c6d7;border-radius:8px}.sf-search-page #sf-search-count{margin:22px 0;color:#607589}.sf-search-result{padding:22px 0;border-bottom:1px solid #dae1ea}.sf-search-result a{color:var(--sf-shell-fg);font-size:21px;font-weight:750;text-decoration:none}.sf-search-result small{display:block;color:#7992a7}.sf-search-result p{margin:7px 0;color:#657487}
@media(max-width:1050px){.sf-head-inner{gap:13px}.sf-primary-links{gap:12px}.sf-primary-links a{font-size:12px}.sf-head-cta{padding:10px}}
@media(max-width:850px){.sf-head-inner{min-height:68px}.sf-menu-toggle{display:block}.sf-head-cta{display:none}.sf-primary-links{display:none;position:absolute;top:68px;left:0;right:0;margin:0;background:var(--sf-shell);padding:20px 28px;flex-direction:column;align-items:stretch;box-shadow:0 20px 24px #0002}.sf-primary-links.is-open{display:flex}.sf-mega{display:none}.sf-foot-grid{grid-template-columns:1fr 1fr;gap:35px}}
@media(max-width:560px){.sf-foot-grid{grid-template-columns:1fr}.sf-foot-lower{display:block}.sf-site-foot{padding-top:48px}.sf-head-inner{padding:0 16px}}
.sf-page-intro{padding:80px 28px;background:var(--sf-shell-soft);color:var(--sf-shell-fg);font-family:system-ui,sans-serif}.sf-page-intro>div{max-width:1200px;margin:auto}.sf-page-intro p{font-size:11px;font-weight:850;letter-spacing:.19em;text-transform:uppercase;color:var(--sf-shell-fg);opacity:.58}.sf-page-intro h1{font-size:clamp(38px,5vw,67px);letter-spacing:-.04em;line-height:1.08;margin:13px 0}.sf-page-intro span{font-size:16px;opacity:.72}
.sf-item[hidden]{display:none!important}
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
'use strict';(()=>{
const menu=document.querySelector('[data-pro-menu]'); const nav=document.querySelector('#pro-navigation');
if(menu&&nav){menu.addEventListener('click',()=>{const open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));nav.classList.toggle('is-open',open);menu.setAttribute('aria-label',open?'关闭导航':'打开导航');});}
const megaButton=document.querySelector('[data-pro-catalog]'), mega=document.querySelector('[data-pro-mega]');
if(megaButton&&mega){function setMega(open){mega.hidden=!open;mega.classList.toggle('is-open',open);megaButton.setAttribute('aria-expanded',String(open));}
megaButton.addEventListener('click',()=>setMega(mega.hidden));
document.addEventListener('keydown',e=>{if(e.key==='Escape')setMega(false);});
document.addEventListener('click',e=>{if(!mega.hidden&&!mega.contains(e.target)&&!megaButton.contains(e.target))setMega(false);});
}
document.querySelectorAll('.pro-nav-disclose').forEach(button=>{button.addEventListener('click',()=>{let panel=button.parentElement.querySelector('.pro-nav-children');let visible=button.getAttribute('aria-expanded')==='true';button.setAttribute('aria-expanded',String(!visible));panel.hidden=visible;});});
document.querySelectorAll('.pro-gallery-thumbs').forEach(container=>{const main=container.parentElement.querySelector('.pro-product-media img');const buttons=[...container.querySelectorAll('[data-gallery-image]')];
buttons.forEach(b=>{b.addEventListener('click',()=>{buttons.forEach(x=>x.setAttribute('aria-pressed',String(x===b)));if(main){main.src=b.dataset.galleryImage;main.alt=b.querySelector('img')?.alt||'';}});});
if(!main)return;main.tabIndex=0;main.style.cursor='zoom-in';const open=()=>{const pane=document.createElement('div');pane.className='pro-gallery-lightbox';pane.setAttribute('role','dialog');pane.setAttribute('aria-label','产品大图预览');let img=document.createElement('img');img.src=main.src;img.alt=main.alt;const close=document.createElement('button');close.type='button';close.textContent='关闭 ×';pane.append(img,close);document.body.append(pane);const done=()=>{pane.remove();document.removeEventListener('keydown',esc);main.focus();};const esc=e=>{if(e.key==='Escape')done();};document.addEventListener('keydown',esc);close.addEventListener('click',done);pane.addEventListener('click',e=>{if(e.target===pane)done();});close.focus();};main.addEventListener('click',open);main.addEventListener('keydown',e=>{if(e.key==='Enter')open();});
});
document.querySelectorAll('.pro-filter-scope').forEach(scope=>{
const panel=scope.previousElementSibling;if(!panel||!panel.classList.contains('pro-filter-panel'))return;
const cards=Array.from(scope.querySelectorAll('.pro-card'));
const blank=document.createElement('div');blank.className='pro-filter-empty';blank.hidden=true;blank.textContent='没有找到符合条件的内容,请调整关键词或筛选条件。';scope.append(blank);
const q=panel.querySelector('[data-pro-filter-search]'),b=panel.querySelector('[data-pro-filter-brand]'),c=panel.querySelector('[data-pro-filter-category]'),count=panel.querySelector('[data-pro-filter-count]');
function options(sel,key){const vals=[...new Set(cards.map(x=>x.dataset[key]||'').filter(Boolean))].sort();vals.forEach(x=>sel.add(new Option(x,x)));if(!vals.length)sel.closest('label').hidden=true;}
options(b,'brand');options(c,'category');
function run(){const str=q.value.trim().toLocaleLowerCase();let visible=0;
cards.forEach(el=>{const hit=el.textContent.toLocaleLowerCase().includes(str)&&(!b.value||el.dataset.brand===b.value)&&(!c.value||el.dataset.category===c.value);el.hidden=!hit;visible+=Number(hit);});count.textContent=`共 ${visible} 条`;blank.hidden=visible!==0;}
[q,b,c].forEach(x=>x.addEventListener(x===q?'input':'change',run));run();
});
})();
+31
View File
@@ -0,0 +1,31 @@
'use strict';
(()=>{
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const menu=$('.sf-menu-toggle');if(menu){menu.addEventListener('click',()=>{let open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));$('#sf-primary-links')?.classList.toggle('is-open',open);});}
// Filter actual static content cards, no API is needed at runtime.
$$('.sf-collection').forEach(grid=>{
const cards=$$('.sf-item',grid);
if(cards.length<2)return;
const toolbar=document.createElement('div');toolbar.className='sf-filters';
const search=document.createElement('input');search.type='search';search.placeholder='筛选当前列表';search.setAttribute('aria-label','筛选当前列表');
const status=document.createElement('span');status.setAttribute('aria-live','polite');status.style.fontSize='12px';
const brand=document.createElement('select');brand.setAttribute('aria-label','按品牌筛选');
const category=document.createElement('select');category.setAttribute('aria-label','按分类筛选');
function fill(el,field,all){el.append(new Option(all,''));const vals=[...new Set(cards.map(x=>x.dataset[field]||'').filter(Boolean))].sort();vals.forEach(v=>el.append(new Option(v,v)));if(!vals.length)el.hidden=true;}
fill(brand,'brand','全部品牌');fill(category,'category','全部分类');
toolbar.append(search,brand,category,status);grid.parentNode.insertBefore(toolbar,grid);
function apply(){let val=search.value.trim().toLocaleLowerCase();let visible=0;cards.forEach(card=>{let hit=card.textContent.toLocaleLowerCase().includes(val)&&(!brand.value||card.dataset.brand===brand.value)&&(!category.value||card.dataset.category===category.value);card.hidden=!hit;if(hit)visible++;});status.textContent=`共 ${visible} 项`;}
search.addEventListener('input',apply);brand.addEventListener('change',apply);category.addEventListener('change',apply);apply();
});
const input=$('#sf-search'),root=$('#sf-search-results'),count=$('#sf-search-count');
if(input&&root){
let data=[];const params=new URLSearchParams(location.search);input.value=params.get('q')||'';
let debounce=null;
const render=()=>{let q=input.value.trim().toLocaleLowerCase();let found=q?data.filter(d=>`${d.title} ${d.summary} ${d.brand} ${d.type}`.toLocaleLowerCase().includes(q)).slice(0,100):data.slice(0,30);
root.replaceChildren();count.textContent=`${found.length} 条结果${!q?'(显示前30条)':''}`;
found.forEach(d=>{let article=document.createElement('article'),a=document.createElement('a'),small=document.createElement('small'),p=document.createElement('p');article.className='sf-search-result';a.textContent=d.title;a.href=d.url.replace(/^\//,'');small.textContent=d.type;p.textContent=d.summary;article.append(a,small,p);root.append(article);});
};const base=document.querySelector('base')?.href||location.href;
fetch(new URL('search-index.json',base)).then(r=>{if(!r.ok)throw Error('index unavailable');return r.json();}).then(v=>{data=v;render()}).catch(()=>{count.textContent='搜索索引加载失败,请检查站点文件部署路径';});
input.addEventListener('input',()=>{clearTimeout(debounce);debounce=setTimeout(render,100)});
}
})();
+56
View File
@@ -0,0 +1,56 @@
"""Trusted shared site shell for the four visual theme designs and installed themes.
Client-side search/filter works without a server after static export.
"""
from html import escape as e
import json
from pathlib import Path
from . import db
ASSETS=Path(__file__).resolve().parent/'static'
def shell(html,site,page,records,nav):
theme=site.get('theme','corporate')
label=lambda key: e(str(key or ''),quote=True)
links=[]
if not nav:
nav=[{'title':'首页','route':'/'},{'title':'产品中心','route':'/product/'},{'title':'品牌','route':'/brand/'},
{'title':'新闻资讯','route':'/post/'},{'title':'成功案例','route':'/case/'},{'title':'关于我们','route':'/about/'},{'title':'联系我们','route':'/contact/'}]
seen=set()
for n in nav[:25]:
route=n.get('route','/')
if route in seen:continue
seen.add(route)
links.append(f'<a href="{label(route.lstrip("/") or "./")}">{label(n.get("title"))}</a>')
# Themes can vary greatly in typography, but share an accessible responsive site shell.
name=label(site['name']); tag=label(site.get('tagline',''))
panels=''.join(f'<a href="{label(x["route"].lstrip("/") or "./")}"><span>{label(x["title"])}</span><small>{label(x["summary"][:86])}</small></a>' for x in records if x.get('type') in ('brand','service') and x.get('status')=='published')[:12000]
header=f'''<div class="sf-site-head" id="sf-site-head"><div class="sf-head-inner"><a class="sf-logo" href="./"><span class="sf-logo-mark">S</span><strong>{name}</strong></a>
<button type="button" class="sf-menu-toggle" aria-controls="sf-primary-links" aria-expanded="false" aria-label="展开网站菜单">☰</button>
<nav class="sf-primary-links" id="sf-primary-links" aria-label="主导航">{''.join(links)}<a href="search/" aria-label="站内搜索">⌕ 搜索</a></nav>
<a class="sf-head-cta" href="contact/">合作咨询 ↗</a></div>{('<div class="sf-mega">'+panels+'</div>') if panels else ''}</div>'''
foot=f'''<div class="sf-site-foot"><div class="sf-foot-grid"><div><h2>{name}</h2><p>{tag}</p></div><div><h4>快速导航</h4>{''.join(links[:6])}</div><div><h4>联系我们</h4><p>{label(site.get('email') or '请在管理后台填写联系邮箱')}</p><a href="contact/">提交咨询 →</a></div></div><div class="sf-foot-lower"><span>© {name}</span><span>GLOBAL SOURCING · GLOBAL DELIVERY</span></div></div>'''
# Every generated landing/detail page needs exactly one visible page heading.
# Blueprints for information pages start with h2 blocks by default.
if page.get('route') != '/' and '<main class="sf-canvas">' in html and '<h1' not in html:
intro=f'<section class="sf-page-intro"><div><p>EXPLORE / {label(page.get("type","PAGE")).upper()}</p><h1>{label(page.get("title", ""))}</h1><span>{label(page.get("summary", ""))}</span></div></section>'
html=html.replace('<main class="sf-canvas">',intro+'<main class="sf-canvas">',1)
html=html.replace('<body>','<body class="sf-theme-'+label(theme)+'">'+header,1)
html=html.replace('</body>',foot+'<script defer src="assets/site-runtime.js"></script></body>',1)
html=html.replace('</head>','<link rel="stylesheet" href="assets/factory.css"></head>',1)
return html
def write_assets(dist,records):
from shutil import copy2
copy2(ASSETS/'factory.css',dist/'assets'/'factory.css')
copy2(ASSETS/'site-runtime.js',dist/'assets'/'site-runtime.js')
search=[{'title':r['title'],'url':r['route'],'summary':(r.get('summary') or '')[:360],
'type':r['type'], 'brand':str((json.loads(r.get('meta_json') or '{}').get('fields') or {}).get('brand',''))}
for r in records if r.get('route') and r['route']!='/']
(dist/'search-index.json').write_text(json.dumps(search,ensure_ascii=False),encoding='utf-8')
def search_page(site):
from html import escape
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="/"><title>站内搜索 - {escape(site['name'])}</title><meta name="robots" content="noindex,follow"><link rel="stylesheet" href="assets/theme.css"><link rel="stylesheet" href="assets/factory.css"></head>
<body class="sf-theme-{escape(site['theme'])}"><main class="sf-search-page"><a href="./">← 返回首页</a><h1>搜索网站内容</h1><label for="sf-search">关键词</label><input id="sf-search" type="search" autofocus placeholder="搜索产品、新闻、品牌、案例……"><div id="sf-search-count" aria-live="polite"></div><div id="sf-search-results"></div></main><script defer src="assets/site-runtime.js"></script></body></html>'''
+38
View File
@@ -0,0 +1,38 @@
"""Decode Excel/CSV/JSON source files for a no-side-effect mapping preview."""
import csv,io,json
from openpyxl import load_workbook
MAX_ROWS=1000
def parse_table(name,data):
if len(data)>12_000_000:raise ValueError('文件超过12 MB')
lower=name.lower()
if lower.endswith('.csv'):
rows=list(csv.DictReader(io.StringIO(data.decode('utf-8-sig'))))
elif lower.endswith('.json'):
rows=json.loads(data.decode('utf-8-sig'))
if isinstance(rows,dict):rows=rows.get('rows',rows.get('entries',rows.get('items',[])))
elif lower.endswith('.xlsx'):
wb=load_workbook(io.BytesIO(data),read_only=True,data_only=True)
try:
ws=wb.active;iterator=ws.iter_rows(values_only=True)
header=next(iterator,None)
if header is None:raise ValueError('Excel 工作表为空')
titles=[str(x or '').strip() for x in header]
if not all(titles) or len(set(titles))!=len(titles):raise ValueError('Excel 列标题不能为空或重复')
rows=[]
for i,values in enumerate(iterator):
if i>=MAX_ROWS:raise ValueError('一次最多导入1000条记录')
if any(x is not None and str(x).strip() for x in values):
rows.append(dict(zip(titles,values)))
finally:wb.close()
else:raise ValueError('支持 CSV、JSON 或 XLSX 文件')
if not isinstance(rows,list) or not 1<=len(rows)<=MAX_ROWS or any(not isinstance(r,dict) for r in rows):
raise ValueError('内容必须包含1-1000条对象记录')
keys=list(dict.fromkeys(str(k) for r in rows for k in r))
if not keys or len(keys)>150:raise ValueError('字段数量不合法')
# Date/datetime -> string to avoid JSON encoder serialization exceptions.
normalized=[]
for row in rows:
normalized.append({str(k):v.isoformat() if hasattr(v,'isoformat') else v for k,v in row.items()})
return {'filename':name,'columns':keys,'count':len(rows),'rows':normalized}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 智能网站工厂</title><link rel="stylesheet" href="/ui/admin.css"><script defer src="/ui/admin.js"></script><script defer src="/ui/enterprise-ui.js"></script></head><body>
<div class="workspace">
<aside class="sidebar"><div class="identity"><div class="logo">S<span>✳</span></div><div><strong>SiteForge</strong><small>WEBSITE FACTORY</small></div></div>
<div class="side-caption">工作空间</div><a class="side-link" href="/factory-studio" style="text-decoration:none;display:block">◈ &nbsp;网站工厂 V5 →</a><button class="side-link selected" id="nav-dashboard" onclick="App.openDashboard()"><span>▦</span> 网站项目</button><button class="side-link" onclick="App.openNewSite()"><span>⊕</span> 创建新网站</button><button class="side-link" onclick="EnterpriseUI.team()"><span>♙</span> 团队账号</button><button class="side-link" id="nav-template" onclick="App.openTemplates()"><span>▤</span> 模板资源库</button>
<div class="side-caption between">我的网站 <button title="新建网站" onclick="App.openNewSite()">+</button></div><div id="siteNav" class="site-nav"></div>
<div class="sidebar-bottom"><div class="status-dot"></div><div><strong>SiteForge Enterprise v1.0</strong><small>站点独立静态发布</small></div></div>
</aside>
<div class="shell"><header class="topbar"><div class="breadcrumb"><span>工作空间</span><span class="crumb-sep">/</span><strong id="topTitle">网站总览</strong></div><div class="top-actions"><span class="env-label">● ENTERPRISE EDITION</span><button class="btn light small" onclick="App.openTemplates()">浏览模板</button><button class="btn light small" onclick="App.logout()">退出登录</button><button class="btn primary small" onclick="App.openNewSite()">+ 创建网站</button></div></header>
<main class="main" id="view"><div class="loading">正在初始化 SiteForge...</div></main>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<dialog id="siteDialog" class="dialog"><form id="siteForm" method="dialog"><div class="dialog-head"><div><span class="eyebrow">CREATE WEBSITE</span><h2>创建全新网站</h2><p>选择模板并创建站点,后续可继续导入旧内容或添加新数据。</p></div><button type="button" class="icon-btn" onclick="App.closeDialog('siteDialog')">×</button></div><div class="form-grid"><label>网站名称 <input name="name" required maxlength="150" placeholder="例如:HESU Global"/></label><label>新网站域名 <input name="domain" placeholder="https://example.com(可稍后设置)"/></label><label class="full">网站简介 / 标语 <input name="tagline" placeholder="全球采购 · 本地服务 · 高效交付"/></label><label>建站模式 <select name="mode"><option value="new">新站搭建</option><option value="migration">旧站改版 / 迁移</option><option value="hybrid">混合建站</option></select></label><label>主题风格 <select name="theme" id="siteTheme"><option value="automotive">汽车与出行</option><option value="industrial">工业制造</option><option value="corporate" selected>企业品牌</option><option value="minimal">极简设计</option></select></label><label class="full">基于已保存模板 <select name="preset_id" id="presetSelector"><option value="">不使用自定义模板</option></select></label></div><div class="dialog-actions"><button class="btn light" type="button" onclick="App.closeDialog('siteDialog')">取消</button><button class="btn primary" type="submit">创建并进入工作台 →</button></div></form></dialog>
<dialog id="entryDialog" class="dialog wide"><form id="entryForm" method="dialog"><div class="dialog-head"><div><span class="eyebrow">CONTENT EDITOR</span><h2 id="entryHeading">新增页面 / 内容</h2><p>所有字段均属于当前网站,修改后重新构建即可更新静态文件。</p></div><button type="button" class="icon-btn" onclick="App.closeDialog('entryDialog')">×</button></div><input type="hidden" name="entry_id"><div class="form-grid"><label>内容类型 <select name="type" id="entryType" onchange="App.renderEntryFields()"><option value="page">页面 Page</option><option value="product">产品 Product</option><option value="post">文章 Post</option><option value="service">服务 Service</option><option value="case">案例 Case</option><option value="vehicle">车型 Vehicle</option><option value="brand">品牌 Brand</option><option value="other">自定义内容</option></select></label><label>发布状态 <select name="status"><option value="published">已发布</option><option value="draft">草稿</option></select></label><label class="full">标题 <input name="title" required placeholder="页面或产品名称"/></label><label class="full">URL 路径(不会自动更改既有 URL) <input name="route" placeholder="/product/example/"/><small>建议:/products/example/,若为迁移站必须填写原有路径</small></label><label class="full">摘要 <textarea name="summary" rows="2" placeholder="列表描述、首页标语等"></textarea></label><label class="full">内容 HTML <textarea name="body" rows="6" placeholder="<p>正文内容</p>"></textarea></label><label class="full">封面图片 URL <div class="inline-input"><input id="coverInput" name="cover" placeholder="media/xxx.jpg 或 https://..."/><button type="button" class="btn light small" onclick="document.querySelector('#coverFile').click()">上传图片</button><input id="coverFile" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" hidden onchange="App.uploadCover(this.files[0])"></div></label><label>SEO Title <input name="seo_title" placeholder="留空则使用页面标题"/></label><label>Canonical <input name="canonical" placeholder="默认根据域名与路径生成"/></label><label class="full">Meta Description <textarea name="seo_description" rows="2"></textarea></label><label>Robots <select name="robots"><option value="index,follow">index,follow</option><option value="noindex,follow">noindex,follow</option><option value="noindex,nofollow">noindex,nofollow</option></select></label><label>其他结构化字段 JSON <textarea name="meta" id="entryMeta" rows="4" placeholder='{"blocks": []}'></textarea></label><div id="dynamicContentFields" class="full"></div><div class="full editor-tools"><strong>首页区块快捷添加</strong><button type="button" class="btn light tiny" onclick="App.addBlock('hero')">+ Hero</button><button type="button" class="btn light tiny" onclick="App.addBlock('features')">+ 优势</button><button type="button" class="btn light tiny" onclick="App.addBlock('stats')">+ 数字统计</button><button type="button" class="btn light tiny" onclick="App.addBlock('cta')">+ CTA</button><small>基础区块使用 JSON 配置;进入「可视化页面设计」可进行拖动排序和属性调整。</small></div></div><div class="dialog-actions"><button type="button" class="btn light" onclick="App.closeDialog('entryDialog')">取消</button><button type="submit" class="btn primary">保存内容</button></div></form></dialog>
</body></html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge Studio · 可视化建站</title><link rel="stylesheet" href="/ui/editor.css"><script defer src="/ui/editor.js"></script></head>
<body>
<div class="studio">
<header class="studio-top">
<div class="brand"><a href="/" title="返回网站管理">◈</a><div><b>SiteForge</b><small>VISUAL STUDIO</small></div></div>
<span class="divide"></span><div class="file-name"><span id="siteName">加载中</span><small> / </small><strong id="pageName">页面编辑器</strong></div>
<div class="top-center"><button id="deviceDesktop" class="icon-btn on" onclick="Studio.device('desktop')" title="PC预览">▣</button><button id="deviceTablet" class="icon-btn" onclick="Studio.device('tablet')" title="平板预览">▤</button><button id="devicePhone" class="icon-btn" onclick="Studio.device('phone')" title="手机预览">▯</button></div>
<span id="saveStatus" class="save-status">未修改</span><button class="ui-btn ghost" onclick="Studio.refreshPreview()">↻ 刷新预览</button><button class="ui-btn ghost" onclick="Studio.buildSite()">构建静态站</button><button class="ui-btn primary" onclick="Studio.save()">保存页面 <span>↗</span></button>
</header>
<div class="studio-body">
<aside class="left-pane"><div class="pane-heading"><div><span class="overline">STRUCTURE</span><h3>页面结构</h3></div><span class="chip" id="blockCount">0 区块</span></div>
<div class="field"><label>页面</label><select id="pageSelect" onchange="Studio.choosePage(this.value)"></select></div>
<div class="field"><label>页面模板</label><select id="pageTemplate" onchange="Studio.changeTemplateKind(this.value)"></select></div>
<div style="padding:0 19px 7px"><button class="ui-btn ghost" style="width:100%" onclick="Studio.resetPreset()">◈ 应用此页面模板的预设区块</button></div>
<div class="field"><label>页面布局模式</label><select id="layoutMode" onchange="Studio.setLayoutMode(this.value)"><option value="append">保留主题原页面 + 追加区块</option><option value="replace">全页自由排版(保留导航与页脚)</option></select></div><div class="section-label">内容区块 · 拖拽可调整排序</div><div id="blockList" class="block-list"></div>
<div class="section-label">添加功能区块</div><div class="palette" id="palette"></div><div class="section-label">可复用组件库</div><button class="ui-btn ghost" style="margin:10px" onclick="Studio.saveReusable()">将当前区块存为组件</button><div class="palette" id="reusablePalette"></div>
<div class="sidebar-foot">页面由区块驱动,所见预览使用服务端与正式构建相同的数据绑定引擎。</div>
</aside>
<section class="preview-workspace"><div class="preview-head"><span class="live-dot"></span><span>实时预览</span><code id="routeDisplay">/</code><span class="preview-note">正在展示正式主题中的完整页面,编辑后需保存并重新构建</span></div><div class="stage"><div class="device-frame" id="deviceFrame"><iframe id="sitePreview" title="页面预览" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div></div></section>
<aside class="right-pane"><div class="pane-heading"><div><span class="overline">PROPERTIES</span><h3>属性设置</h3></div><span class="chip" id="blockTypeLabel">页面</span></div>
<div class="inspector" id="inspector"><div class="no-selection">在左侧选择一个区块,在这里编辑它的文字、图片、布局及数据来源。</div></div>
<div class="right-foot"><div>动态数据绑定</div><p>支持 <code>{{site.name}}</code>、<code>{{page.title}}</code> 等变量;“动态内容列表”按类型自动查询当前网站的数据。</p></div>
</aside>
</div>
<div id="toast" class="toast"></div>
</div></body></html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge 3 · 网站生产中心</title><link rel="stylesheet" href="/ui/factory-v2.css"><script defer src="/ui/factory-v2.js"></script></head>
<body><div class="shell">
<aside class="sidebar"><a class="identity" href="/factory-studio"><span class="mark">SF</span><span><strong>SiteForge</strong><small>WEBSITE FACTORY V5</small></span></a>
<div class="side-title">生产中心 / WORKSPACE</div>
<nav id="navtabs"><button data-tab="overview" class="active">◫ &nbsp; 网站总览</button><button data-tab="themes">▧ &nbsp; 主题与版本</button><button data-tab="content">▤ &nbsp; 内容与数据装配</button><button data-tab="migration">⇄ &nbsp; 迁移与 SEO 验收</button><button data-tab="build">◈ &nbsp; 构建与发布</button></nav>
<div class="side-bottom"><a href="/theme-gallery">↗ 完整主题预览中心</a> · <a href="/">旧版后台</a><small>Docker · Static Sites · Multi Tenant</small></div></aside>
<div class="work"><header class="top"><div class="top-crumb">SiteForge / <span id="currentTab">网站总览</span></div><div class="top-controls"><label>当前网站 <select id="sitePicker" aria-label="当前网站"></select></label><button class="outline" id="reload">刷新</button><button class="primary" id="newSite">+ 新建网站</button></div></header>
<div class="content"><div id="alert" role="status"></div><div class="heading"><div><div class="eyebrow">MANAGE · ASSEMBLE · DEPLOY</div><h1 id="heading">网站生产工作台</h1><p id="description">将新内容或旧网站数据与主题组合,构建独立可部署的网站。</p></div><div class="head-actions"><a class="outline" id="openEditor" href="#">可视化编辑器 ↗</a><button class="primary" id="buildQuick">生成静态网站</button></div></div>
<section id="panel" aria-live="polite"></section></div>
</div></div>
<dialog id="createDialog"><form id="createForm" method="dialog"><h2>创建网站</h2><p>不需要旧网站,也能直接选择主题、添加内容、构建整套官网。</p><label>网站名称<input name="name" required placeholder="例如 Hesu Global"></label><label>域名<input name="domain" placeholder="https://example.com"></label><label>站点模式<select name="mode"><option value="new">全新网站</option><option value="migration">旧站迁移改版</option><option value="hybrid">混合建站</option></select></label><label>主题<select name="theme" id="newTheme"></select></label><div class="form-actions"><button type="button" class="outline" id="closeCreate">取消</button><button class="primary" type="submit">创建并初始化网站</button></div></form></dialog>
</body></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录 · SiteForge 网站工厂</title><style>
:root{font-family:Inter,system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#263952}*{box-sizing:border-box}body{margin:0;background:#f5f7fa;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(430px,100%);background:#fff;border:1px solid #e1e8ef;border-radius:20px;box-shadow:0 25px 70px #19345512;padding:42px}.symbol{width:56px;height:56px;border-radius:15px;background:#173250;color:#fff;display:grid;place-items:center;font-size:26px;font-weight:700;margin-bottom:24px}h1{font-size:27px;margin:0 0 6px}p{font-size:13px;color:#7c8ca0;line-height:1.8;margin:0 0 29px}label{display:block;font-size:13px;font-weight:600;margin-bottom:9px}input{width:100%;height:48px;border:1px solid #d8e0e9;border-radius:10px;padding:0 14px;font-size:15px;outline:0}input:focus{border-color:#386aab;box-shadow:0 0 0 3px #417cbc1c}button{width:100%;margin-top:22px;background:#19446f;color:#fff;height:48px;border:0;border-radius:10px;font-size:14px;font-weight:650;cursor:pointer}button:disabled{opacity:.65}small{display:block;text-align:center;margin-top:23px;color:#96a4b6}#error{margin:12px 0 0;color:#b63d3d;min-height:18px;font-size:13px}
</style></head><body><main><div class="symbol">S✳</div><h1>欢迎使用 SiteForge</h1><p>登录网站生产工作台,管理网站、内容、主题和静态构建版本。</p><form id="auth"><label for="pass">账号密码</label><input id="username" type="text" value="admin" required autocomplete="username" placeholder="管理员或团队用户名"><label for="pass">管理员密码 / 团队密码</label><input id="pass" type="password" required autofocus autocomplete="current-password" placeholder="请输入部署时设置的密码"><div id="error" role="alert"></div><button id="submit" type="submit">登录工作台 →</button></form><small>SiteForge Web · Enterprise Edition v1.0</small></main><script>
document.getElementById('auth').addEventListener('submit',async e=>{e.preventDefault();const b=document.getElementById('submit'),err=document.getElementById('error');b.disabled=true;err.textContent='';try{const res=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('username').value,password:document.getElementById('pass').value})});if(!res.ok){const d=await res.json();throw new Error(d.detail||'登录失败')}location.replace('/')}catch(e){err.textContent=e.message;b.disabled=false}});
</script></body></html>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge · 完整主题预览中心</title>
<style>*{box-sizing:border-box}body{margin:0;background:#eef1f5;font:14px/1.6 system-ui,'Microsoft YaHei',sans-serif;color:#172c42}header{display:flex;align-items:center;justify-content:space-between;padding:14px 26px;background:#fff;border-bottom:1px solid #dce3eb;gap:24px;flex-wrap:wrap}header strong{font-size:17px}header strong span{background:#15253e;color:#fff;border-radius:6px;padding:6px 8px;margin-right:8px}header a{color:#255b99;text-decoration:none}.controls{display:flex;align-items:end;flex-wrap:wrap;gap:12px;padding:18px 26px;border-bottom:1px solid #dce3eb;background:white}.controls label{font-weight:700;color:#5c7083;font-size:11px;display:grid;gap:6px}.controls select{border:1px solid #ccd5df;background:#fff;color:#192d41;padding:10px 14px;border-radius:7px;min-width:132px;font-size:13px}.devices{display:flex;gap:4px;margin-left:auto;background:#f0f3f8;border-radius:8px;padding:4px}.devices button{border:0;background:none;padding:9px 14px;border-radius:6px;cursor:pointer;color:#52697e}.devices button.active{background:#1e446c;color:white}.stage{padding:22px 18px 80px;display:flex;justify-content:center;overflow:auto}.frame{width:100%;height:calc(100vh - 210px);min-height:520px;background:white;box-shadow:0 16px 50px #12304720;border-radius:10px;overflow:hidden;transition:width .25s}.frame iframe{border:0;width:100%;height:100%;background:white}.help{text-align:center;color:#6d8296;font-size:12px;margin:-55px auto 28px}.error{color:#c14f45;display:none}@media(max-width:700px){header,.controls{padding:12px}header strong{font-size:14px}.devices{margin-left:0}.frame{min-height:450px}}</style></head>
<body><header><strong><span>SF</span> SiteForge · 主题预览中心</strong><a href="/factory-studio">返回网站工厂 →</a></header>
<section class="controls"><label>主题<select id="theme"><option value="automotive">Automotive · 汽车与出行</option><option value="industrial">Industrial · 工业制造</option><option value="corporate">Corporate · 企业品牌</option><option value="minimal">Minimal · 极简设计</option></select></label>
<label>页面类型<select id="page"><option value="home">首页</option><option value="about">关于我们</option><option value="listing">产品 / 分类列表</option><option value="category">分类详情</option><option value="product">产品详情</option><option value="brand">品牌详情</option><option value="article">新闻详情</option><option value="case">案例详情</option><option value="service">服务详情</option><option value="contact">联系我们</option><option value="faq">常见问题</option><option value="standard">标准页面</option><option value="notfound">404</option></select></label>
<label>主题版本<select id="version"><option value="">最新</option></select></label><div class="devices"><button data-size="100%" class="active">PC</button><button data-size="820px">平板</button><button data-size="390px">手机</button></div><span class="error" id="error">预览失败,请确认已登录。</span></section>
<div class="stage"><div class="frame" id="frame"><iframe id="preview" title="主题真实页面预览" loading="eager"></iframe></div></div>
<div class="help">预览使用真实页面模板与演示数据。实际网站构建后将使用你的品牌信息和内容。</div>
<script>
const theme=document.querySelector('#theme'),page=document.querySelector('#page'),version=document.querySelector('#version'),frame=document.querySelector('#frame'),iframe=document.querySelector('#preview');
function show(){iframe.src=`/api/factory/themes/${encodeURIComponent(theme.value)}/preview?page_type=${encodeURIComponent(page.value)}&version=${encodeURIComponent(version.value)}`;}
async function loadVersions(){const response=await fetch(`/api/factory/themes/${encodeURIComponent(theme.value)}/versions`,{credentials:'same-origin'});if(!response.ok)throw Error('无法读取主题版本');const arr=await response.json();version.replaceChildren();arr.forEach(x=>{const op=new Option('v'+x.version,x.version);version.append(op);});show();}
theme.addEventListener('change',()=>loadVersions().catch(()=>document.querySelector('#error').style.display='inline'));
[page,version].forEach(x=>x.addEventListener('change',show));document.querySelectorAll('.devices button').forEach(x=>x.addEventListener('click',()=>{document.querySelectorAll('.devices button').forEach(y=>y.classList.remove('active'));x.classList.add('active');frame.style.width=x.dataset.size;}));loadVersions().catch(()=>document.querySelector('#error').style.display='inline');
</script></body></html>
+174
View File
@@ -0,0 +1,174 @@
"""Full, version-aware page-family theme renderer.
All content is escaped by Jinja's HTML autoescaping. Templates packaged with the
application are trusted; uploaded declarative themes cannot supply executable Jinja.
"""
from __future__ import annotations
import hashlib
import datetime
import json
import re
from html import escape
from pathlib import Path
from urllib.parse import quote
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from .core import sanitize, route_path
THEMES=('automotive','industrial','corporate','minimal')
ROOT=Path(__file__).resolve().parent.parent
LOADER=Environment(loader=FileSystemLoader(str(ROOT/'themes')),autoescape=select_autoescape(['html','xml']))
TITLES={'product':'产品中心','vehicle':'车型中心','post':'新闻资讯','case':'项目案例','brand':'品牌中心','service':'服务与解决方案'}
def meta(item):
try:
raw=item.get('meta_json') or '{}'
val=json.loads(raw) if isinstance(raw,str) else raw
return val if isinstance(val,dict) else {}
except (TypeError,ValueError):return {}
def fields(item):
v=meta(item).get('fields') or {}
return v if isinstance(v,dict) else {}
def text(item,key,limit=500):
return str(item.get(key) or '')[:limit]
def slug(value):
# Conservative URL generation for newly derived pages; never rewrite imported URLs.
raw=str(value).strip().casefold()
ascii_slug=re.sub(r'[^a-z0-9-]+','-',raw).strip('-')[:54]
return ascii_slug or ('term-'+hashlib.sha256(raw.encode()).hexdigest()[:12])
def all_groups(records):
return {typ:[e for e in records if e.get('type')==typ] for typ in TITLES}
def categories(records):
return sorted({str(fields(e).get('category') or '').strip() for e in records if e.get('type') in ('product','vehicle') and str(fields(e).get('category') or '').strip()},key=str.casefold)
def brands(records):
found={}
for entry in records:
if entry.get('type')=='brand':found[entry['title'].casefold()]={'name':entry['title'],'url':entry['route']}
for entry in records:
if entry.get('type') not in ('product','vehicle'):continue
name=str(fields(entry).get('brand') or '').strip()
if name and name.casefold() not in found:found[name.casefold()]={'name':name,'url':'/brands/'+slug(name)+'/'}
return sorted(found.values(),key=lambda e:e['name'].casefold())
def infer_kind(page,mode='detail'):
path=page.get('route') or '/'
typ=page.get('type') or 'page'
explicit=str(meta(page).get('page_template') or '').strip().lower()
if mode=='home' or path=='/':return 'home'
if mode=='notfound':return 'notfound'
if path=='/search/':return 'search'
if path.startswith('/categories/'):return 'category'
if mode=='listing' or explicit=='listing' or path in ('/products/','/brands/','/news/','/cases/','/product/','/post/','/brand/','/case/','/service/','/vehicle/'):
return 'listing'
if typ in ('product','vehicle'):return 'product'
if typ=='post':return 'article'
if typ=='brand':return 'brand'
if typ=='case':return 'case'
if typ=='service':return 'service'
if explicit in ('home','about','contact','listing','product','article','brand','standard','notfound','category','case','service','faq'):return explicit
if path.startswith('/about'):return 'about'
if path.startswith('/contact'):return 'contact'
if path.startswith('/faq'):return 'faq'
if path.startswith('/services'):return 'service'
return 'standard'
def safe_href(route):
if not isinstance(route,str):return '/'
if route.startswith(('mailto:','tel:','https://','http://')):return route
try:return route_path(route)
except ValueError:return '/'
def safe_image(value):
if not value or not isinstance(value,str):return ''
value=value.strip()
if value.startswith('//') or any(c in value for c in ('<','>','"','\n','\r')):return ''
if value.startswith(('https://','http://')):return value
if value.startswith('/') or not value.startswith(('data:','javascript:')):return '/'+value.lstrip('/')
return ''
def media_gallery(page):
"""Accept JSON gallery arrays or explicit semicolon/newline separated URLs."""
attrs=fields(page)
raw=attrs.get('gallery') or attrs.get('images') or meta(page).get('gallery') or []
if isinstance(raw,str):
try: raw=json.loads(raw)
except (ValueError,TypeError):raw=re.split(r'[;\n]+',raw)
if not isinstance(raw,list):raw=[]
results=[]
for item in [page.get('cover')]+raw:
uri=item.get('url','') if isinstance(item,dict) else item
safe=safe_image(uri)
if safe and safe not in results:results.append(safe)
if len(results)>=20:break
return results
def select_listing(page,kind,groups,all_records,requested):
if kind=='category':
category=text(page,'category') or text(page,'title')
return [e for e in groups['product']+groups['vehicle'] if str(fields(e).get('category') or '').casefold()==category.casefold()]
path=(page.get('route') or '').lower()
title=text(page,'title').lower()
template=meta(page)
source=next((b.get('source') for b in template.get('blocks',[]) if isinstance(b,dict) and b.get('type')=='collection' and b.get('source')),'')
category= 'post' if ('news' in path or '新闻' in title) else ('brand' if ('brand' in path or '品牌' in title) else ('case' if ('case' in path or '案例' in title) else ('service' if ('service' in path or '服务' in title) else 'product')))
category=source if source in groups else category
if requested and kind=='listing' and not source and path.startswith(('/product/','/post/','/brand/','/case/','/service/')):
category={'/product/':'product','/post/':'post','/brand/':'brand','/case/':'case','/service/':'service/'} .get(path,category)
if category=='product':return groups['product']+groups['vehicle']
return groups.get(category,[])
def make_context(site,page,records,mode='detail',listing_items=None):
pg=dict(page or {})
kind=infer_kind(pg,mode)
groups=all_groups(records)
listing=select_listing(pg,kind,groups,records,listing_items)
if listing_items and mode=='listing' and not (pg.get('route') or '').startswith('/categories/'):
listing=list(listing_items)
detail_fields=fields(pg)
spec=[{'label':str(k).replace('_',' ').capitalize(),'value':str(v)} for k,v in detail_fields.items() if isinstance(v,(str,int,float)) and v not in ('',None) and k not in ('brand','category','model','description','image','cover','url')]
ownbrand=str(detail_fields.get('brand') or pg.get('title') or '').strip()
related=[x for x in groups['product']+groups['vehicle'] if str(fields(x).get('brand') or '').casefold()==ownbrand.casefold()] if kind=='brand' else []
settings=json.loads(site.get('settings_json') or '{}') if isinstance(site.get('settings_json'),str) else site.get('settings_json',{}) or {}
nav=settings.get('nav') or [
{'title':'首页','route':'/'},{'title':'产品','route':'/products/'},
{'title':'品牌','route':'/brands/'},{'title':'新闻','route':'/news/'},
{'title':'案例','route':'/cases/'},{'title':'关于','route':'/about/'},
{'title':'联系','route':'/contact/'}]
def nav_item(row,depth=0):
output={'title':str(row.get('title',''))[:50],'route':safe_href(row.get('route') or '/')}
children=row.get('children',[]) if depth<2 else []
output['children']=[nav_item(c,depth+1) for c in children[:20] if isinstance(c,dict)] if isinstance(children,list) else []
return output
nav=[nav_item(x) for x in nav if isinstance(x,dict)][:20]
assets={'categories':[{'name':c,'url':'/categories/'+slug(c)+'/'} for c in categories(records)],
'brands':brands(records)}
title=(pg.get('title') or site.get('name') or '')
canonical=(pg.get('canonical') or (site.get('domain','').rstrip('/')+pg.get('route','/') if site.get('domain','').startswith(('http://','https://')) else ''))
form_endpoint=settings.get('form_endpoint') or ''
form_endpoint=form_endpoint if isinstance(form_endpoint,str) and form_endpoint.startswith('https://') and not any(x in form_endpoint for x in ('<','>','\"','\n')) else ''
return {'year':datetime.datetime.now(datetime.timezone.utc).year,'theme_color':{'automotive':'#0e1c2c','industrial':'#113f59','corporate':'#f5f6fa','minimal':'#f8f7f3'}.get(site.get('theme'),'#ffffff'),'gallery':media_gallery(pg),'form_endpoint':form_endpoint,'site':site,'page':pg,'kind':kind,'records':records,'groups':groups,'listing':listing,
'showcase':groups['product'][:6]+groups['vehicle'][:max(0,6-len(groups['product'][:6]))],
'features':groups['service'][:3], 'stories':groups['post'][:3], 'cases':groups['case'][:3],
'brands':assets['brands'],'categories':assets['categories'], 'nav':nav, 'specs':spec,
'related':related,'fields':detail_fields,'content':Markup(sanitize(pg.get('body') or '')),
'title':title, 'seo_title':pg.get('seo_title') or title,'seo_description':pg.get('seo_description') or pg.get('summary') or site.get('tagline') or '',
'canonical':canonical,'safe_href':safe_href,'safe_image':safe_image,
'fields_of':fields,'labels':TITLES,'footer_email':settings.get('email') or site.get('email') or '',
'footer_text':settings.get('footer_text') or site.get('tagline') or '',
'route':pg.get('route','/'),
'pretty_type':TITLES.get(pg.get('type'),'内容'),
'empty':len(listing)==0,
}
def render(theme,site,page,records,mode='detail',listing_items=None,blocks_html=''):
if theme not in THEMES:raise ValueError('不是内置专业主题')
ctx=make_context(site,page,records,mode,listing_items)
ctx.update(custom_blocks=Markup(blocks_html or ''),has_custom=bool(blocks_html),noindex=(ctx['kind']=='notfound'),designer_replace=meta(page).get('layout_mode')=='replace')
return LOADER.get_template(theme+'/pro.html').render(**ctx)
+171
View File
@@ -0,0 +1,171 @@
"""Versioned *declarative* theme packages. Never execute user uploaded templates.
Uploads can customize styles and built-in page blueprint presets. The trusted
Jinja template is copied from the built-in base. Arbitrary HTML/Jinja/JS in
uploaded archives is deliberately not supported.
"""
from __future__ import annotations
import io, json, re, zipfile, shutil, time
from pathlib import Path
from . import db
BASE=Path(__file__).resolve().parent.parent/'themes'
THEME_ROOT=db.DATA/'theme_packages'
BUILTIN=('automotive','industrial','corporate','minimal')
THEME_ID=re.compile(r'^[a-z][a-z0-9-]{2,39}$')
VERSION=re.compile(r'^\d+\.\d+\.\d+$')
class ThemeError(ValueError):pass
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS theme_versions (
theme_id TEXT NOT NULL, version TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', installed_at TEXT NOT NULL,
PRIMARY KEY(theme_id,version));
CREATE TABLE IF NOT EXISTS site_theme_pins (
site_id TEXT PRIMARY KEY REFERENCES sites(id) ON DELETE CASCADE,
theme_id TEXT NOT NULL, version TEXT NOT NULL);
''')
def catalog():
builtin=[]
for tid in BUILTIN:
obj=json.loads((BASE/tid/'manifest.json').read_text(encoding='utf-8'))
builtin.append({**obj,'builtin':True,'installed':True})
custom=[dict(r,builtin=False,installed=True) for r in db.rows('SELECT * FROM theme_versions ORDER BY installed_at DESC')]
return builtin+custom
def ids():
return set(BUILTIN)|{r['theme_id'] for r in db.rows('SELECT DISTINCT theme_id FROM theme_versions')}
def latest(tid):
if tid in BUILTIN:
return json.loads((BASE/tid/'manifest.json').read_text(encoding='utf-8')).get('version','1.0.0')
items=db.rows('SELECT version FROM theme_versions WHERE theme_id=?',(tid,))
if not items:raise ThemeError('主题不存在')
return sorted((r['version'] for r in items),key=lambda s:tuple(map(int,s.split('.'))))[-1]
def resolve(tid,version=None):
if tid in BUILTIN:
current=latest(tid)
if not version or version==current:return BASE/tid
if not VERSION.fullmatch(version):raise ThemeError('非法主题版本')
previous=(BASE/tid/'versions'/version).resolve()
if not previous.is_relative_to((BASE/tid/'versions').resolve()) or not previous.is_dir():
raise ThemeError('内置主题的历史版本不存在')
return previous
ver=version or latest(tid)
if not db.one('SELECT theme_id FROM theme_versions WHERE theme_id=? AND version=?',(tid,ver)):
raise ThemeError('主题版本不存在')
result=(THEME_ROOT/tid/ver).resolve()
if not result.is_relative_to(THEME_ROOT.resolve()) or not result.is_dir():raise ThemeError('主题目录丢失')
return result
def pin(site_id,tid,ver=None):
target=resolve(tid,ver)
ver=ver or latest(tid)
with db.connect() as c:
c.execute('INSERT INTO site_theme_pins VALUES (?,?,?) ON CONFLICT(site_id) DO UPDATE SET theme_id=excluded.theme_id,version=excluded.version',(site_id,tid,ver))
c.execute('UPDATE sites SET theme=?,updated_at=? WHERE id=?',(tid,db.now(),site_id))
return {'theme':tid,'version':ver,'path':str(target)}
def pinned(site_id):
return db.one('SELECT theme_id,version FROM site_theme_pins WHERE site_id=?',(site_id,))
def compatibility(theme_id,version=None):
"""Theme build/renderer compatibility, not a subjective visual quality score."""
from . import blueprints
try:folder=resolve(theme_id,version)
except ThemeError as exc:return {'valid':False,'errors':[str(exc)],'warnings':[]}
issues=[];warnings=[]
for resource in ('manifest.json','theme.css','page-presets.json','pro.html','pro.css'):
if not (folder/resource).is_file():issues.append('缺少 '+resource)
if not issues:
try:manifest=json.loads((folder/'manifest.json').read_text(encoding='utf-8'))
except (ValueError,UnicodeError) as exc:issues.append('主题清单解析失败:'+str(exc));manifest={}
if manifest.get('id')!=theme_id:issues.append('清单 ID 与文件夹不匹配')
try:presets=json.loads((folder/'page-presets.json').read_text(encoding='utf-8'))
except (ValueError,UnicodeError):presets={};issues.append('页面预设 JSON 不合法')
missing=[kind for kind in blueprints.PAGE_TEMPLATES if kind not in presets]
if missing:warnings.append('缺少可编辑页面预设:'+','.join(missing))
layout_family=manifest.get('layout_family',theme_id)
family=layout_family if layout_family in BUILTIN else 'corporate'
page_root=(folder/'pages') if (folder/'pages').exists() else BASE/family/'pages'
for kind in ('home','about','contact','listing','product','brand','article','case','service','faq','category','search','standard','notfound'):
if not (page_root/(kind+'.html')).is_file():issues.append('缺少实际页面布局:'+kind)
return {'valid':not issues,'theme':theme_id,'version':version or (latest(theme_id) if not issues else version),
'checked_page_types':14,'errors':issues,'warnings':warnings,
'note':'技术兼容性检查不会代替企业内容准确性和真实设备视觉验收'}
def install(blob:bytes):
if len(blob)>8_000_000:raise ThemeError('主题包最大 8 MB')
try:archive=zipfile.ZipFile(io.BytesIO(blob))
except zipfile.BadZipFile:raise ThemeError('不是 ZIP 压缩包')
names={n.filename for n in archive.infolist()}
permitted={'manifest.json','theme.css','page-presets.json','preview.png','preview.jpg','preview.webp'}
if not {'manifest.json','theme.css','page-presets.json'}.issubset(names):raise ThemeError('缺少主题清单、样式或页面预设')
if len(names)>6 or any(name not in permitted for name in names):raise ThemeError('主题包只能包含受支持的声明式文件,不允许模板脚本')
for f in archive.infolist():
if f.file_size>1_500_000 or f.is_dir():raise ThemeError('主题资源体积或结构不符合要求')
manifest=json.loads(archive.read('manifest.json'))
tid=manifest.get('id',''); ver=manifest.get('version','')
if not THEME_ID.fullmatch(tid) or not VERSION.fullmatch(ver) or tid in BUILTIN:raise ThemeError('主题 ID 或 SemVer 不合法,内置主题不能覆盖')
if db.one('SELECT theme_id FROM theme_versions WHERE theme_id=? AND version=?',(tid,ver)):
raise ThemeError('同版本主题已存在,请升级版本号')
if str(manifest.get('layout_family') or 'corporate') not in BUILTIN:
raise ThemeError('layout_family 只能选择已审查的基础布局')
title=str(manifest.get('name',''))[:100]; description=str(manifest.get('description',''))[:400]
if not title:raise ThemeError('主题名称不能为空')
presets=json.loads(archive.read('page-presets.json'))
if not isinstance(presets,dict):raise ThemeError('页面模板预设必须为对象')
from . import blueprints
for kind,blocks in presets.items():
if kind not in blueprints.PAGE_TEMPLATES:raise ThemeError('未知的页面模板:'+kind)
if not isinstance(blocks,list):raise ThemeError('页面预设内容必须是区块数组')
for b in blocks:
if not isinstance(b,dict):raise ThemeError('页面预设区块格式不正确')
blueprints.validate_blocks([blueprints.new_block(b['type'],b) for b in blocks])
css=archive.read('theme.css').decode('utf-8')
# Style packages are declarative: no network requests or executable CSS extensions.
if len(css)>180000 or re.search(r'@import\b|url\s*\(|expression\s*\(|behavior\s*:|</style|<script',css,re.I):
raise ThemeError('主题样式不允许引用外部地址或脚本行为')
temp=THEME_ROOT/tid/(ver+'-upload')
path=THEME_ROOT/tid/ver
if path.exists():raise ThemeError('主题版本文件已存在')
temp.mkdir(parents=True,exist_ok=False)
try:
(temp/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False),encoding='utf-8')
(temp/'theme.css').write_text(css,encoding='utf-8')
(temp/'page-presets.json').write_text(json.dumps(presets,ensure_ascii=False),encoding='utf-8')
# This Jinja template is shipped and reviewed with SiteForge itself.
family=str(manifest.get('layout_family') or 'corporate')
if family not in BUILTIN:raise ThemeError('不支持的专业主题版式')
shutil.copy2(BASE/family/'site.html',temp/'site.html')
shutil.copy2(BASE/family/'pro.html',temp/'pro.html')
shutil.copy2(BASE/family/'pro.css',temp/'pro.css')
# Custom declarative themes inherit the trusted family's local visual assets.
if (BASE/family/'visual').is_dir():
shutil.copytree(BASE/family/'visual',temp/'visual')
for n in ('preview.png','preview.jpg','preview.webp'):
if n in names:(temp/n).write_bytes(archive.read(n))
# Windows virus scanners and indexers can briefly hold a freshly
# written directory. Retry the atomic rename, then fall back to a
# checked copy while the database record still has not been created.
for attempt in range(5):
try:
temp.rename(path)
break
except PermissionError:
if attempt==4:
shutil.copytree(temp,path)
shutil.rmtree(temp)
break
time.sleep(0.05*(attempt+1))
db.insert('INSERT INTO theme_versions VALUES (?,?,?,?,?)',(tid,ver,title,description,db.now()))
except Exception:
shutil.rmtree(temp,ignore_errors=True)
if path.exists() and not db.one('SELECT theme_id FROM theme_versions WHERE theme_id=? AND version=?',(tid,ver)):
shutil.rmtree(path,ignore_errors=True)
raise
return {'id':tid,'version':ver,'name':title,'page_templates':list(presets)}
+83
View File
@@ -0,0 +1,83 @@
"""Conservative WordPress WXR adapter helpers.
Never execute PHP, Elementor widgets, shortcodes or serialized metadata.
Only known text/image widgets are converted to sanitized HTML with warnings
for unknown proprietary elements; original metadata is always retained.
"""
import json, re
from html import escape
from .core import sanitize
WIDGETS={'heading','text-editor','image','button','icon-box','image-box','divider','spacer','video','html'}
def elementor_to_html(raw):
if not isinstance(raw,str) or not raw.strip():return {'html':'','warnings':[],'widgets':0}
try:data=json.loads(raw)
except (ValueError,TypeError):return {'html':'','warnings':['Elementor JSON 解析失败:保留原始字段,需人工检查'],'widgets':0}
if not isinstance(data,list):return {'html':'','warnings':['Elementor 数据根节点不是数组,需人工检查'],'widgets':0}
parts=[];warnings=[];count=[0]
def walk(elements,depth=0):
if depth>12: warnings.append('Elementor 嵌套超过安全限制');return
if not isinstance(elements,list):return
for item in elements[:300]:
if not isinstance(item,dict):continue
typ=item.get('widgetType') or ''
settings=item.get('settings') or {}
if not isinstance(settings,dict):settings={}
if typ:
count[0]+=1
if typ not in WIDGETS:
if len(warnings)<60:warnings.append('未转换的 Elementor 组件:'+str(typ)[:65])
elif typ=='heading':
size=settings.get('header_size','h2');size=size if size in ('h1','h2','h3','h4','h5','h6') else 'h2'
parts.append(f'<{size}>{escape(str(settings.get("title") or ""))}</{size}>')
elif typ=='text-editor':
parts.append(sanitize(str(settings.get('editor') or '')))
elif typ in ('image','image-box'):
image=settings.get('image') or {}
url=image.get('url','') if isinstance(image,dict) else ''
if isinstance(url,str) and url.startswith(('https://','http://','/')):
parts.append(f'<p><img src="{escape(url,quote=True)}" alt="{escape(str(settings.get("title") or ""),quote=True)}"></p>')
if typ=='image-box' and settings.get('title_text'):
parts.append('<h3>'+escape(str(settings['title_text']))+'</h3>')
if typ=='image-box' and settings.get('description_text'):
parts.append('<p>'+escape(str(settings['description_text']))+'</p>')
elif typ=='button':
href=settings.get('link') or {}
dest=href.get('url','') if isinstance(href,dict) else ''
if isinstance(dest,str) and dest.startswith(('https://','http://','/')):
parts.append(f'<a href="{escape(dest,quote=True)}">{escape(str(settings.get("text") or "更多信息"))}</a>')
elif typ=='html':
parts.append(sanitize(str(settings.get('html') or '')))
elif typ in ('video','divider','spacer','icon-box'):
if len(warnings)<60:warnings.append(f'{typ} 布局/嵌入需人工复核')
walk(item.get('elements') or [],depth+1)
walk(data)
return {'html':'\n'.join(parts),'warnings':warnings,'widgets':count[0]}
def wp_taxonomy(item):
data={}
for node in item:
if node.tag.rsplit('}',1)[-1]!='category':continue
domain=node.get('domain','')
name=(node.text or '').strip()
if not name:continue
if domain in ('category','product_cat','vehicle_cat'):data['category']=name
elif domain in ('product_brand','brand','pa_brand'):data['brand']=name
elif domain=='product_type':data['product_type']=name
elif domain.startswith('pa_'):data[domain[3:]]=name
return data
def wp_media_map(root):
"""WXR attachment ID -> original URL, useful for featured and gallery images."""
media={}
for item in root.findall('./channel/item'):
typ=post_id=url=''
for node in item:
local=node.tag.rsplit('}',1)[-1]
if local=='post_type':typ=node.text or ''
elif local=='post_id':post_id=node.text or ''
elif local=='attachment_url':url=node.text or ''
if typ=='attachment' and post_id and url and url.startswith(('http://','https://')):
media[post_id]=url
return media
+60
View File
@@ -0,0 +1,60 @@
"""Dedicated Docker worker: database-backed queue, serialized claim, retry + restart recovery."""
import json
import os
import signal
import time
from datetime import datetime, timedelta, timezone
from . import db, enterprise, builder
running=True
def stop(*_):
global running
running=False
def claim():
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
job=c.execute("""SELECT id FROM build_jobs
WHERE state='queued' AND (retry_at IS NULL OR retry_at<=?)
ORDER BY created_at,id LIMIT 1""",(db.now(),)).fetchone()
if not job:return None
c.execute("UPDATE build_jobs SET state='running',attempts=attempts+1,started_at=?,retry_at=NULL WHERE id=?",(db.now(),job['id']))
return dict(c.execute('SELECT * FROM build_jobs WHERE id=?',(job['id'],)).fetchone())
def run_once():
job=claim()
if job is None:return None
try:
if job['kind']=='build': result=builder.build(job['site_id'],False)
else:raise ValueError('Unsupported job')
db.execute("UPDATE build_jobs SET state='done',result_json=?,finished_at=? WHERE id=?",
(json.dumps(result,ensure_ascii=False),db.now(),job['id']))
except Exception as exc:
maximum=max(1,int(os.getenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')))
if job['attempts']<maximum:
delay=min(300,5*(2**max(job['attempts']-1,0)))
retry_at=(datetime.now(timezone.utc)+timedelta(seconds=delay)).isoformat(timespec='seconds')
db.execute("""UPDATE build_jobs SET state='queued',error=?,started_at=NULL,
finished_at=NULL,retry_at=? WHERE id=?""",
(str(exc)[:1200],retry_at,job['id']))
else:
db.execute("UPDATE build_jobs SET state='failed',error=?,finished_at=?,retry_at=NULL WHERE id=?",
(str(exc)[:1200],db.now(),job['id']))
return job['id']
def main():
db.init();enterprise.setup()
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
# A crash before the build record is committed is safe to retry: build IDs
# are generated inside builder.build and output directories are immutable.
maximum=max(1,int(os.getenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')))
db.execute("""UPDATE build_jobs SET state='queued',error='Worker interrupted; retry scheduled',
started_at=NULL,finished_at=NULL,retry_at=?
WHERE state='running' AND attempts<?""",(db.now(),maximum))
db.execute("""UPDATE build_jobs SET state='failed',error='Worker interrupted; retry limit reached',
finished_at=?,retry_at=NULL WHERE state='running'""",(db.now(),))
while running:
if not run_once():time.sleep(float(os.getenv('SITEFORGE_JOB_POLL_SECONDS','1.5')))
if __name__=='__main__':main()