feat: add 18 independent industry demo templates

This commit is contained in:
2026-11-03 22:33:03 +08:00
parent 1d08bdb6a5
commit 2b60c0d5e0
7 changed files with 632 additions and 1 deletions
+50 -1
View File
@@ -6,8 +6,10 @@ apps.core 全局 Snippet 只读接口。挂载在 /api/v1/custom/core/
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from django.http import Http404
from .models import NavigationMenu, Partner, SiteSettings, TeamMember, Testimonial
from .industry_templates import INDUSTRIES, demo_path, demo_root
from .models import NavigationMenu, Partner, SimpleContentPage, SiteSettings, TeamMember, Testimonial
from .serializers import (
NavigationMenuSerializer,
PartnerSerializer,
@@ -65,3 +67,50 @@ class SiteSettingsView(APIView):
def get(self, request, *args, **kwargs):
settings_obj = SiteSettings.for_request(request)
return Response(SiteSettingsSerializer(settings_obj).data)
class IndustryDemoView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "public"
def get(self, request, industry=None, *args, **kwargs):
if industry is None:
return Response({"items": [
{"slug": slug, "label": spec["label"], "company": spec["company"],
"description": spec["description"], "url": demo_path(slug)}
for slug, spec in INDUSTRIES.items()
if (root := demo_root(slug)) is not None and root.live
]})
if industry not in INDUSTRIES:
raise Http404
root = demo_root(industry)
if root is None or not root.live or not type(root).objects.public().filter(pk=root.pk).exists():
raise Http404
path = request.query_params.get("path", "").strip("/")
page = root
if path:
for segment in path.split("/"):
if not segment or segment in (".", ".."):
raise Http404
child = page.get_children().live().public().filter(slug=segment).first()
if child is None:
raise Http404
page = child.specific
if not page.get_view_restrictions().exists() and not root.get_view_restrictions().exists():
body = getattr(page, "body", None)
blocks = body.stream_block.get_api_representation(body, context={"request": request}) if hasattr(body, "stream_block") else []
children = [
{"title": child.title, "url": demo_path(industry, f"{path}/{child.slug}")}
for child in page.get_children().live().public()
]
navigation = [{"title": "首页", "url": demo_path(industry)}] + [
{"title": child.title, "url": demo_path(industry, child.slug)}
for child in root.get_children().live().public()
if child.slug != "privacy-policy"
]
return Response({"industry": industry, "label": INDUSTRIES[industry]["label"],
"company": root.title, "title": page.title, "blocks": blocks,
"html": str(body) if isinstance(page, SimpleContentPage) else "",
"intro": getattr(page, "intro", "") or getattr(page, "summary", ""),
"children": children, "navigation": navigation})
raise Http404
+285
View File
@@ -0,0 +1,285 @@
"""独立行业演示内容树,不修改默认站点或已有页面。"""
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from django.utils.html import format_html
from wagtail.models import Page
from apps.blog.models import BlogIndexPage, BlogPage
from apps.cases.models import CaseStudyIndexPage, CaseStudyPage
from apps.forms.models import FormDefinition
from apps.home.models import HomePage
from apps.products.models import ProductIndexPage, ProductPage
from apps.solutions.models import SolutionIndexPage, SolutionPage
from .management.commands.seed_contact_page import CONTACT_FORM_FIELDS
from .models import ContactPage, SimpleContentPage
from .site_setup import _ensure_page
INDUSTRIES = {
"software": {
"label": "软件服务", "company": "云启软件(虚构演示)",
"headline": "让软件贴合业务,让团队专注增长",
"description": "从需求分析、应用开发到系统集成,展示软件服务企业的完整交付能力。",
"products": [("content-platform", "企业内容平台", "统一管理品牌内容、产品资料与咨询入口。"),
("custom-development", "定制开发服务", "围绕企业业务流程开发 Web 应用与接口。")],
"solution": ("digital-workplace", "数字化协同方案", "梳理现有流程,连接应用和数据,分阶段开展数字化建设。"),
"case": "内容平台建设案例", "article": "软件项目如何定义验收标准",
"features": [("需求共创", "通过访谈与原型明确业务边界。"), ("迭代交付", "以可验证成果推进开发。"), ("运维支持", "提供上线培训与持续维护。")],
"steps": ["需求分析", "原型验证", "开发测试", "上线维护"],
},
"manufacturing": {
"label": "制造业", "company": "精工制造(虚构演示)",
"headline": "从图纸到交付,呈现可靠的制造流程",
"description": "面向采购与工程团队,展示加工能力、产品规格与质量管理流程。",
"products": [("precision-parts", "精密零部件", "按技术图纸评估材料、尺寸公差与加工工艺。"),
("automation-equipment", "自动化工装设备", "围绕生产工序设计工装与自动化辅助设备。")],
"solution": ("flexible-production", "柔性生产方案", "评估批量、工艺与检验要求,制定打样和批次交付计划。"),
"case": "生产工装改造案例", "article": "采购询价前需要准备哪些资料",
"features": [("工艺评审", "结合图纸与应用场景评估可制造性。"), ("质量检验", "记录检验要求与批次追溯信息。"), ("交付协同", "确认打样、包装和交期安排。")],
"steps": ["图纸评审", "样件确认", "生产检验", "批次交付"],
},
"consulting": {
"label": "管理咨询", "company": "知行咨询(虚构演示)",
"headline": "从问题诊断到行动方案,让改善有章可循",
"description": "展示咨询服务范围、工作方法和交付物,帮助企业判断合作是否适合。",
"products": [("strategy-advisory", "战略规划咨询", "梳理目标、市场假设与资源约束,形成行动路线。"),
("process-advisory", "流程优化咨询", "识别协作瓶颈,设计职责和流程改进方案。")],
"solution": ("organization-growth", "组织成长方案", "围绕组织目标开展访谈、流程诊断与实施辅导。"),
"case": "组织流程诊断案例", "article": "如何准备一次有效的咨询需求访谈",
"features": [("业务诊断", "用访谈与资料分析形成问题清单。"), ("方案共创", "与团队确认改进优先级及实施路径。"), ("落地辅导", "跟进执行反馈,调整行动计划。")],
"steps": ["需求访谈", "诊断分析", "方案研讨", "实施辅导"],
},
"education": {
"label": "教育培训", "company": "启学教育(虚构演示)",
"headline": "围绕学习目标,呈现清晰的课程与教学安排",
"description": "展示成人学习课程、企业培训及教学服务流程,不承诺考试通过、升学或就业结果。",
"products": [("skills-courses", "成人技能课程", "展示课程目标、适用基础、教学形式与学习任务。"),
("corporate-training", "企业团队培训", "根据岗位需求规划课程主题、练习与反馈安排。")],
"solution": ("learning-program", "团队学习方案", "梳理学习需求、安排课程与练习,以过程反馈改进学习计划。"),
"case": "团队课程组织案例", "article": "选择培训课程前如何核对课程信息",
"features": [("目标梳理", "明确适用人群与课程边界。"), ("教学安排", "展示课程结构与学习任务。"), ("学习反馈", "记录参与反馈,不作成绩或证书保证。")],
"steps": ["需求沟通", "课程规划", "学习实施", "反馈复盘"],
},
"healthcare": {
"label": "医疗健康", "company": "安禾健康(虚构演示)",
"headline": "清晰展示健康服务信息与预约流程",
"description": "展示机构服务说明、预约指引与健康信息管理流程;不提供诊断、治疗建议或疗效承诺。",
"products": [("appointment-guidance", "预约服务指引", "介绍服务时间、预约流程及到访前的信息确认方式。"),
("health-information", "健康信息服务", "展示公开信息栏目与服务说明,不提供个体化医疗建议。")],
"solution": ("service-coordination", "服务协同流程", "明确预约确认、信息指引和服务反馈环节,不收集病历或患者资料。"),
"case": "预约流程优化案例(非患者案例)", "article": "浏览健康服务网站时需要核对哪些信息",
"features": [("信息透明", "说明服务范围,真实资质需正式上线前核验。"), ("预约指引", "提供流程说明,不代替专业医疗咨询。"), ("隐私保护", "演示不收集病历、诊断或其他健康敏感信息。")],
"steps": ["服务了解", "流程确认", "信息指引", "服务反馈"],
},
"dining": {
"label": "餐饮", "company": "禾味餐饮(虚构演示)",
"headline": "从菜单到到店体验,展示餐饮品牌的日常服务",
"description": "展示菜单结构、门店服务及活动餐饮流程,食材、过敏原、价格和营业信息需正式上线前核实。",
"products": [("seasonal-menu", "季节菜单", "展示菜品分类与用餐场景,实际供应、价格和过敏原信息需另行确认。"),
("event-catering", "活动餐饮服务", "围绕人数、场地与用餐安排沟通餐饮服务范围。")],
"solution": ("group-dining", "团体用餐方案", "确认人数、饮食需求与服务时间,安排菜单沟通和现场服务。"),
"case": "团体用餐组织案例", "article": "预约团体用餐前需要确认哪些事项",
"features": [("菜单展示", "清晰介绍品类与供应说明。"), ("到店指引", "确认营业时间与预订规则。"), ("服务沟通", "核对人数、饮食限制与服务安排。")],
"steps": ["需求确认", "菜单沟通", "安排确认", "用餐反馈"],
},
"construction": {
"label": "建筑工程", "company": "筑行工程(虚构演示)",
"headline": "从项目需求到交付节点,清晰展示工程服务流程",
"description": "展示工程策划、施工协同与交付管理;真实资质、安全要求和承包范围需正式合作前核验。",
"products": [("project-planning", "工程项目策划", "梳理项目目标、场地条件与阶段任务,不替代专业设计及审批。"),
("construction-coordination", "施工协同服务", "展示进度沟通、资料记录与现场协作流程,不提供施工操作指导。")],
"solution": ("project-delivery", "工程交付协同方案", "确认职责、阶段节点与验收资料,实际标准和责任以合同及适用规定为准。"),
"case": "工程节点协同案例", "article": "工程项目沟通前需要准备哪些资料",
"features": [("范围确认", "明确服务边界与参与方职责。"), ("节点管理", "展示进度计划及变更沟通机制。"), ("资料交接", "按约定整理验收和交付资料。")],
"steps": ["需求梳理", "范围确认", "实施协同", "交付核对"],
},
"real-estate": {
"label": "房地产", "company": "栖城置业(虚构演示)",
"headline": "清晰呈现空间信息与看房咨询流程",
"description": "展示住宅与商业空间栏目及咨询流程;全部空间均为虚构示例,不构成真实房源、报价或投资建议。",
"products": [("residential-spaces", "住宅空间展示", "介绍空间布局与使用场景,面积、价格、产权及销售许可须核验。"),
("commercial-spaces", "商业空间展示", "展示办公与经营空间信息结构,实际用途、可用状态和租售条件须另行确认。")],
"solution": ("viewing-consultation", "看房咨询流程", "梳理空间需求、资料核对与到访沟通,不提供在线交易、贷款审批或收益承诺。"),
"case": "空间资料整理案例(非真实成交)", "article": "浏览房源信息时需要核对哪些事项",
"features": [("信息展示", "区分示例信息与经核验的实际房源。"), ("需求沟通", "了解使用场景与到访安排。"), ("资料核对", "正式上线前核实权属、许可及费用说明。")],
"steps": ["需求了解", "资料核对", "到访沟通", "反馈整理"],
},
"home-renovation": {
"label": "家居装修", "company": "和居设计(虚构演示)",
"headline": "从空间需求到方案沟通,展示家居装修服务",
"description": "展示空间设计、材料沟通和装修协同流程;实际报价、材料性能、施工许可及质量标准需核实。",
"products": [("interior-design", "室内空间设计", "围绕生活习惯与空间条件沟通布局和风格,不替代结构安全评估。"),
("material-selection", "材料选配服务", "展示材料清单与选配流程,环保、防火及其他性能以合规检测资料为准。")],
"solution": ("renovation-coordination", "家居装修协同方案", "明确设计确认、预算范围、阶段沟通与验收安排,变更及质保条件按合同确认。"),
"case": "居住空间方案沟通案例", "article": "装修需求沟通前如何整理空间与预算信息",
"features": [("空间沟通", "明确使用需求与现有条件。"), ("清单确认", "核对材料、预算和服务边界。"), ("阶段反馈", "记录变更与验收事项,不作零污染承诺。")],
"steps": ["需求访谈", "方案沟通", "清单确认", "阶段验收"],
},
"agri-food": {
"label": "农业食品", "company": "禾源农食(虚构演示)",
"headline": "从产地信息到供货沟通,展示农业食品服务",
"description": "展示农产品、食品供应与批次信息流程;产地、生产许可、检测、储运和过敏原信息须正式上线前核验。",
"products": [("farm-produce", "农产品展示", "介绍品类、规格及供应季节,不宣称未经核验的有机认证或品质等级。"),
("food-supply", "食品供应服务", "展示包装规格与供货沟通流程,保质期、配料、过敏原及储存条件以合规标签为准。")],
"solution": ("supply-traceability", "供货信息协同方案", "梳理批次资料、供货安排与交接记录,不替代食品安全检测或真实追溯系统。"),
"case": "批次资料协同案例", "article": "食品采购沟通前需要核对哪些信息",
"features": [("品类说明", "明确规格与实际供应范围。"), ("资料核对", "核验许可、标签及检测资料。"), ("交接沟通", "确认批次和储运要求,不提供食品处理操作指导。")],
"steps": ["需求沟通", "资料核对", "供货确认", "交接反馈"],
},
"energy-environment": {
"label": "能源环保", "company": "清衡能源(虚构演示)",
"headline": "清晰展示能源管理与环保服务的实施边界",
"description": "展示能源信息梳理与环保项目协同;资质、监测数据、节能减排效果及合规要求须核验,不作收益或达标保证。",
"products": [("energy-management", "能源管理服务", "展示用能资料整理与管理流程,不提供电气安装操作或未经验证的节能数据。"),
("environmental-coordination", "环保项目协同", "梳理项目资料、监测要求与参与方职责,不替代法定检测、审批或污染处理操作规范。")],
"solution": ("resource-management", "资源管理协同方案", "确认数据范围、评估依据与阶段反馈,实际成效需由合规方法及可核验数据支持。"),
"case": "用能资料整理案例(非减排业绩)", "article": "能源环保项目沟通前需要准备哪些资料",
"features": [("边界明确", "说明服务范围及专业责任。"), ("数据核验", "区分示例数据与实际监测结果。"), ("阶段协同", "记录资料、评估和交接事项,不作零排放承诺。")],
"steps": ["需求梳理", "资料核对", "方案沟通", "阶段反馈"],
},
"auto-services": {
"label": "汽车服务", "company": "行安汽车(虚构演示)",
"headline": "从服务了解到账单确认,展示汽车服务流程",
"description": "展示车辆保养、到店检查与服务沟通;维修资质、适用车型、配件、费用及质保条件须核实。",
"products": [("maintenance-services", "车辆保养服务", "介绍保养项目与预约流程,实际周期和项目以车辆手册及专业检查为准。"),
("inspection-consultation", "到店检查咨询", "说明车辆情况沟通与检查安排,不提供远程故障诊断或维修操作指导。")],
"solution": ("service-appointment", "到店服务协同方案", "确认服务需求、到店时间、报价授权与交付记录,不替代专业检查或车辆安全检验。"),
"case": "到店服务沟通案例", "article": "预约车辆服务前需要确认哪些事项",
"features": [("需求了解", "梳理车型与服务诉求,演示不收集车牌或车架号。"), ("费用确认", "施工前核对服务范围和报价授权。"), ("交付说明", "记录完成项目与质保边界,不作绝对安全保证。")],
"steps": ["需求沟通", "到店确认", "报价授权", "交付反馈"],
},
"culture-media": {
"label": "文化传媒", "company": "映川传媒(虚构演示)",
"headline": "从创意沟通到内容交付,展示文化传媒服务",
"description": "展示品牌内容策划、活动传播与制作协同;作品、肖像、音乐及素材授权须核验,不承诺传播量或营销效果。",
"products": [("content-planning", "品牌内容策划", "梳理受众、主题与内容形式,示例不使用真实客户作品或未经授权的素材。"),
("event-communication", "活动传播服务", "展示活动内容与传播安排,实际许可、场地、执行范围和发布渠道须确认。")],
"solution": ("creative-delivery", "创意制作协同方案", "确认创意范围、素材授权、审核节点与交付规格,版权归属及修改次数以合同为准。"),
"case": "内容审核协同案例(非真实客户作品)", "article": "内容制作前如何整理需求与授权资料",
"features": [("需求策划", "明确受众、主题及交付形式。"), ("授权核对", "核验素材使用范围与肖像许可。"), ("审核交付", "记录版本反馈与发布授权,不作流量保证。")],
"steps": ["需求沟通", "创意确认", "审核协同", "内容交付"],
},
"sports-fitness": {
"label": "体育健身", "company": "跃动健身(虚构演示)",
"headline": "清晰展示运动课程与到店体验流程",
"description": "展示运动课程、场馆服务与报名沟通;教练资质、适用人群、费用及场馆条件须核验,不承诺减重或身体改善效果。",
"products": [("fitness-classes", "运动课程展示", "介绍课程形式、参与基础与安排,不提供个体训练处方、康复或医疗建议。"),
("venue-services", "场馆体验服务", "展示开放时间、预约流程与使用规则,实际设施和安全要求须到店确认。")],
"solution": ("activity-enrollment", "运动参与协同方案", "梳理课程需求、报名规则与反馈流程,不替代专业健康评估,演示不收集健康敏感信息。"),
"case": "课程报名组织案例(非效果证明)", "article": "报名运动课程前需要确认哪些事项",
"features": [("课程说明", "明确参与要求与课程边界。"), ("预约沟通", "核对时间、费用及退改规则。"), ("参与反馈", "记录服务反馈,不作成绩或健身效果保证。")],
"steps": ["课程了解", "规则确认", "预约沟通", "服务反馈"],
},
"pet-services": {
"label": "宠物服务", "company": "伴爪宠物(虚构演示)",
"headline": "从服务了解到账单确认,展示宠物生活服务",
"description": "展示宠物美容、寄养与到店沟通;经营资质、服务条件及费用须核实,不提供动物诊疗、用药或行为治疗建议。",
"products": [("pet-grooming", "宠物美容服务", "介绍服务项目与预约要求,适用情况及操作由合格服务人员现场评估。"),
("pet-boarding", "宠物寄养服务", "展示环境说明、入住规则与交接安排,健康及防疫要求由实际机构确认。")],
"solution": ("pet-care-coordination", "宠物服务交接方案", "明确服务范围、授权事项、交接记录与紧急联系流程;异常情况应联系合格兽医,不作绝对安全保证。"),
"case": "寄养交接沟通案例", "article": "预约宠物服务前需要核对哪些事项",
"features": [("服务说明", "区分生活服务与专业动物诊疗。"), ("条件确认", "核对入住规则、费用及服务边界。"), ("交接记录", "记录授权和反馈,不在演示中提交真实联系方式或宠物医疗资料。")],
"steps": ["需求沟通", "条件确认", "服务交接", "反馈核对"],
},
"legal-services": {
"label": "法律服务", "company": "明衡法律(虚构演示)",
"headline": "清晰展示法律服务范围与委托沟通流程",
"description": "展示企业法律服务、合同事务与委托流程;机构和人员执业资质须核验,不构成个案法律意见或胜诉承诺。",
"products": [("business-legal", "企业法律服务", "介绍服务范围与沟通方式,具体意见应由具备资质的专业人员结合事实提供。"),
("contract-services", "合同事务服务", "展示合同事务沟通与审核流程,演示不接收合同原件、案件证据或保密材料。")],
"solution": ("engagement-coordination", "法律委托协同方案", "说明需求沟通、利益冲突核查、委托确认及交付安排;浏览或提交演示表单不建立委托关系。"),
"case": "委托流程整理案例(非真实案件)", "article": "选择法律服务前需要确认哪些事项",
"features": [("范围说明", "明确服务边界与执业资质核验要求。"), ("委托确认", "核对费用、利益冲突和保密约定。"), ("交付沟通", "说明交付形式,不作结果或办理时限保证。")],
"steps": ["需求沟通", "冲突核查", "委托确认", "交付反馈"],
},
"finance-tax": {
"label": "财税服务", "company": "清账财税(虚构演示)",
"headline": "从服务范围到资料交接,展示财税协同流程",
"description": "展示账务协同、税务事务与交接流程;服务资质、适用政策及费用须核验,不提供个别税务建议或节税保证。",
"products": [("accounting-coordination", "账务协同服务", "介绍账务资料整理与交接流程,演示不接收发票、银行账户或真实财务凭证。"),
("tax-affairs", "税务事务服务", "展示事项沟通与资料核对安排,具体处理须依据现行规定及企业实际情况由专业人员确认。")],
"solution": ("finance-handover", "财税资料交接方案", "明确服务范围、责任分工与交接记录,不替代法定申报、审计或专业税务判断。"),
"case": "财税交接流程案例(非节税业绩)", "article": "委托财税服务前需要核对哪些事项",
"features": [("范围确认", "核实服务资质与职责边界。"), ("资料保护", "正式业务通过经授权的安全渠道交接。"), ("进度沟通", "记录事项和反馈,不作退税或零风险承诺。")],
"steps": ["需求梳理", "范围确认", "交接核对", "事项反馈"],
},
"human-resources": {
"label": "人力资源", "company": "同程人才(虚构演示)",
"headline": "清晰呈现企业人才服务与招聘协同流程",
"description": "展示企业招聘协同、岗位信息与人事服务流程;许可、岗位真实性及服务条件须核验,不承诺录用、薪酬或招聘结果。",
"products": [("recruitment-coordination", "招聘协同服务", "介绍企业岗位需求与招聘沟通流程,所有岗位均为虚构示例,不开放真实简历投递。"),
("hr-process-services", "人事流程服务", "展示入职资料及流程协同范围,不接收身份证、薪资记录或员工个人资料,不提供个案劳动法律意见。")],
"solution": ("talent-service", "人才服务协同方案", "确认岗位职责、合法合理的任职要求、服务分工与反馈机制,不替代真实招聘系统或劳动合同。"),
"case": "岗位需求沟通案例(非真实招聘成果)", "article": "委托人力资源服务前需要确认哪些事项",
"features": [("岗位核对", "明确真实岗位信息与任职要求。"), ("授权保护", "正式业务按授权及适用规定处理个人信息。"), ("协同反馈", "记录流程进度,不作就业或人才到岗保证。")],
"steps": ["需求访谈", "岗位核对", "协同确认", "反馈复盘"],
},
}
def demo_root(industry):
if industry not in INDUSTRIES:
raise ValidationError("未知行业模板。")
return HomePage.objects.child_of(Page.get_first_root_node()).filter(slug=f"demo-{industry}").first()
def demo_path(industry, path=""):
return f"/demo/{industry}/" + path.strip("/")
@transaction.atomic
def seed_industry_templates():
root = Page.objects.select_for_update().get(pk=Page.get_first_root_node().pk)
created = []
for industry, spec in INDUSTRIES.items():
def ensure(parent, model, slug, title, **fields):
return _ensure_page(parent, model, slug, title, created, **fields)
def cta():
return ("cta", {"heading": "讨论您的项目需求", "description": "仅用于演示,请勿提交真实个人或商业敏感信息。",
"button": {"text": "体验咨询表单", "link": demo_path(industry, "contact")}})
home = ensure(root, HomePage, f"demo-{industry}", spec["company"], body=[
("hero", {"title": spec["headline"], "subtitle": spec["description"], "background_image": None,
"buttons": [{"text": "了解产品与服务", "link": demo_path(industry, "products")},
{"text": "项目咨询", "link": demo_path(industry, "contact")}]}),
("feature_grid", {"heading": "核心能力", "items": [
{"icon": str(i + 1), "title": title, "description": description}
for i, (title, description) in enumerate(spec["features"])]}),
("product_card", {"heading": "产品与服务", "items": [
{"title": title, "description": description, "image": None, "link": demo_path(industry, f"products/{slug}")}
for slug, title, description in spec["products"]]}),
("faq", {"items": [{"question": "这些企业和案例是真实的吗?", "answer": "<p>不是。所有企业、案例及能力描述均为虚构模板演示,不代表真实业绩、资质或承诺。</p>"}]}), cta(),
])
products = ensure(home, ProductIndexPage, "products", "产品与服务", intro=spec["description"])
for slug, title, description in spec["products"]:
ensure(products, ProductPage, slug, title, summary=description, body=[
("richtext", str(format_html("<h2>{}</h2><p>{}</p><p>示例内容:实际规格、范围与交付条件需另行确认。</p>", title, description))), cta()])
solutions = ensure(home, SolutionIndexPage, "solutions", "解决方案", intro="以业务场景为起点,明确实施范围。")
slug, title, description = spec["solution"]
ensure(solutions, SolutionPage, slug, title, industry=spec["label"], summary=description, body=[
("richtext", str(format_html("<h2>{}</h2><p>{}</p>", title, description))),
("feature_grid", {"heading": "实施步骤", "items": [
{"icon": str(i + 1), "title": step, "description": "与客户确认本阶段成果和下一步安排。"}
for i, step in enumerate(spec["steps"])]}), cta()])
cases = ensure(home, CaseStudyIndexPage, "cases", "示例案例", intro="案例均为虚构,不代表实际客户或业绩。")
ensure(cases, CaseStudyPage, "example-project", f"{spec['case']}(虚构)", client_name="虚构示例客户",
industry=spec["label"], published_at=timezone.now(), summary="展示项目背景、实施过程与验收方式,不代表真实成果。",
body=[("richtext", "<h2>项目背景(虚构)</h2><p>示例客户希望改善现有业务流程。</p><h2>实施过程</h2><p>确认需求、评审方案、分阶段验收。</p><h2>模板提示</h2><p>请替换为获得客户授权的真实案例,不使用虚构数字或资质。</p>"), cta()])
blog = ensure(home, BlogIndexPage, "blog", "行业洞察", intro="分享方法与实践,以下为模板示例。")
ensure(blog, BlogPage, "project-checklist", spec["article"], published_at=timezone.now(), intro=spec["description"],
body=[("richtext", "<h2>准备清单</h2><ul><li>明确业务目标与需求范围。</li><li>整理现有资料与约束。</li><li>确认交付物及验收方式。</li><li>约定沟通渠道与责任人。</li></ul><p>本文为演示材料,不构成专业建议。</p>")])
ensure(home, SimpleContentPage, "about", "关于我们", body=str(format_html(
"<h2>{}</h2><p>{}</p><p>此企业为虚构演示,未声明真实团队、客户、资质或业绩。</p>", spec["company"], spec["description"])))
ensure(home, SimpleContentPage, "privacy-policy", "演示隐私说明", body="<p>演示表单会在本地测试数据库保存提交内容,不发送外部通知。请勿提交真实个人或商业敏感信息。正式上线前需制定真实隐私政策。</p>")
if not home.get_children().filter(slug="contact").exists():
form = FormDefinition.objects.create(name=f"{spec['label']}演示咨询", notification_email="",
success_message="演示提交已记录,不会发送外部通知。")
for order, field in enumerate(CONTACT_FORM_FIELDS):
form.fields.create(sort_order=order, **field)
form.save()
ensure(home, ContactPage, "contact", "联系咨询", body=[
("richtext", "<h2>演示咨询表单</h2><p>请使用虚构资料测试,不要提交真实个人信息。</p>"), ("form", {"form": form})])
elif not isinstance(home.get_children().get(slug="contact").specific, ContactPage):
raise ValidationError("演示联系页面类型冲突。")
return created
@@ -0,0 +1,15 @@
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from apps.core.industry_templates import seed_industry_templates
class Command(BaseCommand):
help = "创建已配置行业的独立演示模板;只补齐缺失内容,不修改默认站点。"
def handle(self, *args, **options):
try:
created = seed_industry_templates()
except ValidationError as error:
raise CommandError(";".join(error.messages)) from error
self.stdout.write(self.style.SUCCESS(f"行业演示模板已就绪,新增 {len(created)} 个页面。"))
+96
View File
@@ -0,0 +1,96 @@
import pytest
from django.core.exceptions import ValidationError
from django.test import Client
from wagtail.models import Page, Site
from apps.forms.models import FormDefinition
from .industry_templates import INDUSTRIES, demo_root, seed_industry_templates
from .models import NavigationMenu
@pytest.mark.django_db
def test_industry_templates_are_independent_and_non_overwriting():
site = Site.objects.get(is_default_site=True)
original_root = site.root_page_id
original_pages = list(Page.objects.values_list("pk", "title", "latest_revision_id"))
original_menus = list(NavigationMenu.objects.values_list("pk", "name"))
created = seed_industry_templates()
assert len(created) == 13 * len(INDUSTRIES)
assert len(set(demo_root(slug).pk for slug in INDUSTRIES)) == len(INDUSTRIES)
for slug in INDUSTRIES:
assert demo_root(slug).get_descendants().count() == 12
home = demo_root("manufacturing")
home.title = "人工编辑制造演示"
home.save()
revision = home.save_revision()
assert seed_industry_templates() == []
home.refresh_from_db()
assert home.title == "人工编辑制造演示"
assert home.latest_revision_id == revision.pk
site.refresh_from_db()
assert site.root_page_id == original_root
assert list(Page.objects.filter(pk__in=[p[0] for p in original_pages]).values_list("pk", "title", "latest_revision_id")) == original_pages
assert list(NavigationMenu.objects.values_list("pk", "name")) == original_menus
assert FormDefinition.objects.filter(name__contains="演示咨询").count() == len(INDUSTRIES)
assert not FormDefinition.objects.filter(name__contains="演示咨询").exclude(notification_email="").exists()
@pytest.mark.django_db
def test_industry_api_is_scoped_and_serializes_blocks():
seed_industry_templates()
client = Client()
listing = client.get("/api/v1/custom/core/demos/").json()
assert len(listing["items"]) == len(INDUSTRIES)
for slug, spec in INDUSTRIES.items():
result = client.get(f"/api/v1/custom/core/demos/{slug}/").json()
assert result["company"] == spec["company"]
assert result["blocks"][0]["type"] == "hero"
assert all(link["url"].startswith(f"/demo/{slug}/") for link in result["navigation"])
product_slug = spec["products"][0][0]
detail = client.get(f"/api/v1/custom/core/demos/{slug}/", {"path": f"products/{product_slug}"})
assert detail.status_code == 200
assert detail.json()["title"] == spec["products"][0][1]
contact = client.get(f"/api/v1/custom/core/demos/{slug}/", {"path": "contact"}).json()
form = next(block for block in contact["blocks"] if block["type"] == "form")["value"]["form"]
assert len(form["fields"]) == 5
assert client.get("/api/v1/custom/core/demos/unknown/").status_code == 404
for path in ["../demo-software", "products/content-platform", "products//missing", "missing"]:
assert client.get("/api/v1/custom/core/demos/manufacturing/", {"path": path}).status_code == 404
product = demo_root("software").get_children().get(slug="products").get_children().first().specific
product.unpublish()
assert client.get("/api/v1/custom/core/demos/software/", {"path": f"products/{product.slug}"}).status_code == 404
@pytest.mark.django_db
def test_industry_initialization_rolls_back_on_conflict():
root = Page.get_first_root_node()
root.add_child(instance=Page(title="冲突页面", slug="demo-manufacturing"))
before = Page.objects.count()
with pytest.raises(ValidationError):
seed_industry_templates()
assert Page.objects.count() == before
assert demo_root("software") is None
@pytest.mark.django_db
@pytest.mark.parametrize("existing_slugs", [
("software", "manufacturing", "consulting"),
("software", "manufacturing", "consulting", "education", "healthcare", "dining"),
("software", "manufacturing", "consulting", "education", "healthcare", "dining",
"construction", "real-estate", "home-renovation"),
("software", "manufacturing", "consulting", "education", "healthcare", "dining",
"construction", "real-estate", "home-renovation", "agri-food", "energy-environment", "auto-services"),
("software", "manufacturing", "consulting", "education", "healthcare", "dining",
"construction", "real-estate", "home-renovation", "agri-food", "energy-environment", "auto-services",
"culture-media", "sports-fitness", "pet-services"),
])
def test_adding_new_industries_preserves_existing_demos(monkeypatch, existing_slugs):
existing = {slug: spec for slug, spec in INDUSTRIES.items() if slug in existing_slugs}
with monkeypatch.context() as patch:
patch.setattr("apps.core.industry_templates.INDUSTRIES", existing)
seed_industry_templates()
before = list(Page.objects.order_by("pk").values_list("pk", "title", "latest_revision_id"))
assert len(seed_industry_templates()) == 13 * (len(INDUSTRIES) - len(existing))
assert list(Page.objects.filter(pk__in=[row[0] for row in before]).order_by("pk").values_list("pk", "title", "latest_revision_id")) == before
for slug in INDUSTRIES.keys() - existing.keys():
assert demo_root(slug).get_descendants().count() == 12
+3
View File
@@ -1,6 +1,7 @@
from django.urls import path
from .api import (
IndustryDemoView,
NavigationMenuView,
PartnerListView,
SiteSettingsView,
@@ -9,6 +10,8 @@ from .api import (
)
urlpatterns = [
path("demos/", IndustryDemoView.as_view(), name="industry-demo-list"),
path("demos/<slug:industry>/", IndustryDemoView.as_view(), name="industry-demo"),
path("team/", TeamMemberListView.as_view(), name="team-list"),
path("testimonials/", TestimonialListView.as_view(), name="testimonial-list"),
path("partners/", PartnerListView.as_view(), name="partner-list"),
+94
View File
@@ -0,0 +1,94 @@
"""本机 Docker 行业演示 HTTP 验收,不改变默认官网。"""
import os
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
if os.environ.get("DJANGO_SETTINGS_MODULE") != "wagtailcms.settings.docker_local":
raise SystemExit("仅允许在 docker_local 隔离环境执行。")
import django
import requests
from bs4 import BeautifulSoup
django.setup()
from wagtail.models import Page, Site
from apps.core.industry_templates import INDUSTRIES, seed_industry_templates
from apps.core.models import NavigationMenu, SiteSettings
from apps.forms.models import Lead
def require(condition, message):
if not condition:
raise AssertionError(message)
def main():
session = requests.Session()
backend = "http://localhost:8000/api/v1/custom/core/demos/"
frontend = "http://frontend:3000"
site = Site.objects.get(is_default_site=True)
original_root = site.root_page_id
settings = list(SiteSettings.objects.values())
menus = list(NavigationMenu.objects.values())
page_state = list(Page.objects.order_by("pk").values_list("pk", "title", "live", "latest_revision_id"))
require(seed_industry_templates() == [], "重复初始化仍新增页面")
require(page_state == list(Page.objects.order_by("pk").values_list("pk", "title", "live", "latest_revision_id")), "初始化改变页面或修订")
site.refresh_from_db()
require(site.root_page_id == original_root, "默认官网根页面发生变化")
require(list(SiteSettings.objects.values()) == settings, "默认企业配置发生变化")
require(list(NavigationMenu.objects.values()) == menus, "原有导航被修改")
print("PASS: 初始化幂等、页面修订及默认官网配置保留")
listing = session.get(backend, timeout=30)
listing.raise_for_status()
require(len(listing.json()["items"]) == len(INDUSTRIES), "演示模板未全部初始化")
response = session.get(f"{frontend}/demo", timeout=30)
require(response.status_code == 200, "演示入口不可用")
for industry, spec in INDUSTRIES.items():
base = f"/demo/{industry}"
home = session.get(f"{backend}{industry}/", timeout=30)
home.raise_for_status()
data = home.json()
require(data["company"] == spec["company"], "行业企业资料串用")
paths = ["", "products", "solutions", "cases", "blog", "about", "contact", "privacy-policy",
*[f"products/{product[0]}" for product in spec["products"]],
f"solutions/{spec['solution'][0]}", "cases/example-project", "blog/project-checklist"]
for path in paths:
time.sleep(3)
response = session.get(f"{frontend}{base}/{path}", timeout=30)
require(response.status_code == 200, f"页面失败:{industry}/{path} HTTP {response.status_code}")
soup = BeautifulSoup(response.text, "html.parser")
require(spec["company"] in soup.header.get_text(), f"页头企业串用:{industry}/{path}")
nav = soup.find("nav", {"aria-label": "行业导航"})
require(nav is not None and all(a["href"].rstrip("/") == base or a["href"].startswith(base + "/") for a in nav.find_all("a")), "行业导航越界")
require("虚构" in response.text, "缺少演示标注")
require(soup.find("meta", {"name": "robots"}) is not None, "缺少演示搜索限制")
contact = session.get(f"{backend}{industry}/", params={"path": "contact"}, timeout=30).json()
form = next(block["value"]["form"] for block in contact["blocks"] if block["type"] == "form")
require(len(form["fields"]) == 5, "表单字段未持久化")
marker = f"industry-smoke-{uuid.uuid4().hex}"
payload = {
"form_id": form["id"], "data": {"name": "虚构测试", "company": spec["company"], "phone": "010-12345678", "email": "demo@example.test", "message": marker},
"source_url": f"http://localhost:13000{base}/contact", "website": "",
}
for attempt in range(3):
response = session.post("http://localhost:8000/api/v1/custom/leads/", json=payload, timeout=30)
if response.status_code != 429 or attempt == 2:
break
wait = min(120, max(1, int(response.headers.get("Retry-After", "60"))))
print(f"表单限流:遵守 Retry-After 等待 {wait} 秒")
time.sleep(wait)
require(response.status_code == 201, f"行业表单提交失败:{response.status_code}")
require(Lead.objects.filter(form_id=form["id"], data__message=marker).exists(), "行业线索未入库")
require(not Lead.objects.get(data__message=marker).form.notification_email, "演示表单配置了外部邮箱")
print(f"PASS: {spec['label']} 13 页面、独立导航、演示标注及表单入库")
for path in ["/demo/unknown", "/demo/software/missing", "/demo/manufacturing/products/content-platform"]:
require(session.get(frontend + path, timeout=30).status_code == 404, f"未知或跨行业路径未拒绝:{path}")
print("ALL INDUSTRY HTTP SMOKE CHECKS PASSED")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
# 行业官网模板演示
## 本机地址
- 模板入口:http://localhost:13000/demo
- 软件服务:http://localhost:13000/demo/software
- 制造业:http://localhost:13000/demo/manufacturing
- 管理咨询:http://localhost:13000/demo/consulting
- 教育培训:http://localhost:13000/demo/education
- 医疗健康:http://localhost:13000/demo/healthcare
- 餐饮:http://localhost:13000/demo/dining
- 建筑工程:http://localhost:13000/demo/construction
- 房地产:http://localhost:13000/demo/real-estate
- 家居装修:http://localhost:13000/demo/home-renovation
- 农业食品:http://localhost:13000/demo/agri-food
- 能源环保:http://localhost:13000/demo/energy-environment
- 汽车服务:http://localhost:13000/demo/auto-services
- 文化传媒:http://localhost:13000/demo/culture-media
- 体育健身:http://localhost:13000/demo/sports-fitness
- 宠物服务:http://localhost:13000/demo/pet-services
- 法律服务:http://localhost:13000/demo/legal-services
- 财税服务:http://localhost:13000/demo/finance-tax
- 人力资源:http://localhost:13000/demo/human-resources
顶部“切换行业”导航切换独立 URL,各行业独立维护内容,不会重置原官网或其他行业内容。
## 初始化与部署
在仓库根目录执行:
1. `docker.exe compose -f docker-compose.local.yml up -d --build --wait`
2. `docker.exe compose -f docker-compose.local.yml exec -T backend python manage.py seed_industry_templates`
若 Windows 系统中的同名 docker 程序无输出,使用 Docker Desktop 的完整程序路径 `C:\Program Files\Docker\Docker\resources\bin\docker.exe`。
初始化是显式操作,不随普通启动自动创建演示内容。重复执行仅创建缺失页面,不覆盖已有编辑、不重新发布已有草稿、不修改默认 Site 或原有全局导航。页面类型冲突时整体事务回滚。
本机验证时后端新增代码通过 Compose cp 部署到现有容器,并重启本项目后端;前端镜像已重新构建。若重新创建后端容器,应按上述命令重建后端镜像,确保新代码被打包。复制到容器中的文件不是镜像持久变更。
## 每行业内容
每套 13 页面:首页、产品列表与两个产品详情、方案列表与详情、案例列表与详情、文章列表与详情、关于、联系、演示隐私说明。
- 软件服务:企业内容平台、定制开发、数字化协同。
- 制造业:精密零部件、自动化工装、柔性生产。
- 管理咨询:战略规划、流程优化、组织成长。
- 教育培训:成人技能课程、企业团队培训、团队学习方案;不承诺考试通过、升学或就业结果。
- 医疗健康:预约服务指引、公开健康信息、服务协同流程;不提供个体诊断、治疗建议或疗效承诺,案例为流程示例而非患者案例。
- 餐饮:季节菜单、活动餐饮、团体用餐;实际供应、价格、过敏原和营业信息须正式上线前核实。
- 建筑工程:工程策划、施工协同、工程交付;真实资质、安全要求、承包范围及验收标准须核验,模板不提供施工操作指导。
- 房地产:住宅与商业空间展示、看房咨询流程;空间均为虚构,不构成真实房源或投资建议,价格、产权、用途及许可须核验。
- 家居装修:室内设计、材料选配、装修协同;报价、施工许可及材料性能须核实,不替代结构安全评估,不作零污染承诺。
- 农业食品:农产品展示、食品供应、供货信息协同;许可、标签、过敏原、检测及储运资料须核验,不宣称未经核验的有机认证。
- 能源环保:能源管理、环保项目协同、资源管理流程;资质、监测数据及节能减排效果须核验,不作收益、达标或零排放保证。
- 汽车服务:车辆保养、到店检查咨询、服务协同;维修资质、配件、费用及质保条件须核实,不提供远程故障诊断或维修操作指导。
- 文化传媒:品牌内容策划、活动传播、创意制作协同;素材、音乐、肖像及作品授权须核验,不承诺传播量或营销效果。
- 体育健身:运动课程、场馆体验、运动参与协同;教练资质、参与要求、费用及退改规则须核验,不提供个体训练处方或医疗建议,不承诺健身效果。
- 宠物服务:宠物美容、寄养、服务交接;经营资质、服务条件及费用须核实,不提供诊疗、用药或行为治疗建议。
- 法律服务:企业法律服务、合同事务、委托协同;执业资质须核验,不构成个案意见或胜诉承诺,演示表单不建立委托关系,也不接收案件材料。
- 财税服务:账务协同、税务事务、资料交接;资质与适用政策须核验,不提供个别税务建议或节税保证,不接收财务凭证和银行账户。
- 人力资源:招聘协同、人事流程、人才服务;岗位均为虚构,不开放真实简历投递,不承诺录用或薪酬,不接收身份证及员工资料。
十八行业共 234 页面。已有十五行业环境再次执行初始化命令,仅补齐新增三行业的 39 页面;已有十二、九、六、三行业环境分别补齐 78、117、156、195 页面,均保留原有页面及修订。
所有企业和案例均为虚构,不代表客户、资质、业绩或承诺。演示页带 noindex 标记,不作为正式官网上线材料。请勿提交真实个人或商业敏感信息。
## 后台编辑
在 Wagtail 页面管理中找到根级 `demo-software`、`demo-manufacturing`、`demo-consulting`、`demo-education`、`demo-healthcare`、`demo-dining`、`demo-construction`、`demo-real-estate`、`demo-home-renovation`、`demo-agri-food`、`demo-energy-environment`、`demo-auto-services` 十二个首页内容树。编辑区块后发布,即可在对应演示 URL 查看。演示详情接口只返回发布的公开内容;草稿及受限内容不能通过演示详情接口读取。
其余六行业根级内容树为 `demo-culture-media`、`demo-sports-fitness`、`demo-pet-services`、`demo-legal-services`、`demo-finance-tax`、`demo-human-resources`,与上述十二棵树一起独立维护。
行业页头使用独立首页标题,导航从该行业内容树生成,不使用原官网全局菜单。产品、方案、案例及文章列表按各自内容树生成。当前十八行业共享布局与区块组件,使用不同强调色和独立内容,并非十八套完全独立的视觉设计系统。
每行业有独立演示咨询表单,初始通知邮箱为空,提交会存储在本地线索数据库。不要配置真实外部通知邮箱。停止服务不会删除数据;只有确认不需要本地测试数据时才删除卷。
医疗健康演示仅供网站内容与流程展示,不作为真实诊疗或预约系统;不要在通用咨询表单中提交病历、诊断或其他健康敏感信息。
## 验证
- 后端:`docker.exe compose -f docker-compose.local.yml exec -T backend python -m pytest --ds=wagtailcms.settings.docker_local --create-db -q`
- 行业 HTTP:`docker.exe compose -f docker-compose.local.yml exec -T backend python docker/smoke_industries.py`
- 前端镜像构建阶段执行 Node 测试与 Next.js 生产构建。
已完成原生与 Docker PostgreSQL 后端 96 项测试、前端 18 项测试及生产构建。升级回归覆盖从三、六、九、十二、十五行业升级到十八行业且保留已有页面及修订。HTTP 验收覆盖十八行业 234 页面、导航隔离、演示标注、表单字段与入库、初始化幂等性、默认站点与配置保留,以及未知或跨行业路径 404。后端仍有 12 项既有警告。
验收未覆盖真实浏览器视觉布局、移动端交互和后台预览自动化。演示 HTTP 脚本每次完整运行会创建十八条虚构测试线索,中途失败时已创建线索也会保留。巡检按每页至少 3 秒节流,表单遇到 429 时遵守 Retry-After,未放宽服务端限流策略。