8cea7dee20
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests) - Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR imports keep post/product types instead of collapsing to page - Docs: trade-pro theme port spec (R3) and implementation plan Co-Authored-By: Claude Code <noreply@anthropic.com>
335 lines
21 KiB
Python
335 lines
21 KiB
Python
"""Factory V2 endpoints: themes, mappings, build comparisons, team 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)
|