320 lines
16 KiB
Python
320 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""生成产线平台新 index.ui:登录→工作台(原 index.ui 内容),未登录→后现代主页(bricks 声明式)。
|
||
用法: python3 scripts/gen_home_index.py (在 pipeline-app 仓库根目录跑)"""
|
||
import json, os, io
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
IDX = os.path.join(ROOT, 'wwwroot', 'index.ui')
|
||
|
||
with io.open(IDX, encoding='utf-8') as f:
|
||
old = f.read()
|
||
|
||
# 幂等:若已是双分支版本,先抽出工作台分支(old),再重拼门户分支
|
||
PREFIX = '{% if get_user() %}\n'
|
||
MARK = '\n{% else %}\n{'
|
||
if old.startswith(PREFIX):
|
||
i = old.rfind(MARK)
|
||
assert i > 0, 'generated file but marker not found'
|
||
old = old[len(PREFIX):i] + '\n'
|
||
else:
|
||
assert '{% if get_user() %}' not in old, 'unexpected mixed file'
|
||
|
||
def T(s, css=None, **kw):
|
||
"""TextBase 三件套:otext+text+i18n(W1 规范)。binds/id 必须在 widget 顶层(bricks 不读 options.binds)。"""
|
||
o = {'otext': s, 'text': s, 'i18n': True, 'halign': 'left'}
|
||
if css:
|
||
o['css'] = css
|
||
top = {k: kw.pop(k) for k in ('binds', 'id') if k in kw}
|
||
o.update(kw)
|
||
w = {'widgettype': 'Text', 'options': o}
|
||
w.update(top)
|
||
return w
|
||
|
||
def TN(s, css=None, **kw):
|
||
"""不翻译的 Text(数字/英文装饰字)。binds/id 同样提到顶层。"""
|
||
o = {'text': s, 'halign': 'left'}
|
||
if css:
|
||
o['css'] = css
|
||
top = {k: kw.pop(k) for k in ('binds', 'id') if k in kw}
|
||
o.update(kw)
|
||
w = {'widgettype': 'Text', 'options': o}
|
||
w.update(top)
|
||
return w
|
||
|
||
def login_bind():
|
||
return {'wid': 'self', 'event': 'click', 'actiontype': 'urlwidget', 'target': 'self',
|
||
'options': {'url': "{{entire_url('/rbac/user/login.ui')}}"}}
|
||
|
||
def scroll_script(anchor_id):
|
||
return ("var e=document.getElementById(%s);if(e){e.scrollIntoView({behavior:'smooth',block:'start'});}"
|
||
% json.dumps(anchor_id))
|
||
|
||
def btn(label, css, bind=None, script=None, small_id=None):
|
||
w = {'widgettype': 'Button', 'options': {'label': label, 'css': css}}
|
||
if small_id:
|
||
w['id'] = small_id
|
||
b = bind if bind is not None else {
|
||
'wid': 'self', 'event': 'click', 'actiontype': 'script', 'target': 'self', 'script': script}
|
||
w['binds'] = [b]
|
||
return w
|
||
|
||
THEME_SCRIPT = ("var h=document.documentElement;var t=h.getAttribute('data-theme')||'light';"
|
||
"var n=t=='light'?'dark':'light';h.setAttribute('data-theme',n);"
|
||
"try{localStorage.setItem('pipeline_ui_theme',n);}catch(e){}"
|
||
"if(window.applyPipelineTheme){window.applyPipelineTheme(n);}")
|
||
|
||
# ---------- header ----------
|
||
header = {
|
||
'widgettype': 'HBox',
|
||
'options': {'css': 'pl-header', 'width': '100%', 'padding': '0 clamp(20px,6vw,90px)',
|
||
'alignItems': 'center', 'gap': '14px', 'cheight': 3.2},
|
||
'subwidgets': [
|
||
{'widgettype': 'VBox', 'options': {'css': 'pl-logo-box', 'gap': '2px', 'alignItems': 'flex-start'},
|
||
'subwidgets': [T('产线平台', 'pl-logo'), TN('PIPELINE PLATFORM', 'pl-logo-sub')]},
|
||
{'widgettype': 'Filler'},
|
||
T('能力', 'pl-nav', binds=[
|
||
{'wid': 'self', 'event': 'click', 'actiontype': 'script', 'target': 'self',
|
||
'script': scroll_script('sec_caps')}]),
|
||
T('产线', 'pl-nav', binds=[
|
||
{'wid': 'self', 'event': 'click', 'actiontype': 'script', 'target': 'self',
|
||
'script': scroll_script('sec_pipes')}]),
|
||
T('流程', 'pl-nav', binds=[
|
||
{'wid': 'self', 'event': 'click', 'actiontype': 'script', 'target': 'self',
|
||
'script': scroll_script('sec_flow')}]),
|
||
btn('🌙', 'pl-btn-ghost theme-icon-btn', script=THEME_SCRIPT, small_id='theme_toggle_btn'),
|
||
{'widgettype': 'urlwidget', 'options': {'url': "{{entire_url('i18n/language.ui')}}"}},
|
||
btn('登录 / 注册', 'pl-btn pl-btn-sm', bind=login_bind()),
|
||
],
|
||
}
|
||
|
||
# ---------- hero ----------
|
||
hero = {
|
||
'widgettype': 'VBox',
|
||
'options': {'css': 'pl-hero', 'width': '100%'},
|
||
'subwidgets': [
|
||
{'widgettype': 'VBox', 'options': {'css': 'pl-hero-deco'},
|
||
'subwidgets': [
|
||
{'widgettype': 'VBox', 'options': {'css': 'pl-grid'}},
|
||
{'widgettype': 'VBox', 'options': {'css': 'pl-orb pl-orb-1'}},
|
||
{'widgettype': 'VBox', 'options': {'css': 'pl-orb pl-orb-2'}},
|
||
{'widgettype': 'VBox', 'options': {'css': 'pl-orb pl-orb-3'}},
|
||
{'widgettype': 'VBox', 'options': {'css': 'pl-noise'}},
|
||
]},
|
||
{'widgettype': 'VBox',
|
||
'options': {'css': 'pl-hero-content', 'width': '100%', 'gap': '22px', 'alignItems': 'flex-start'},
|
||
'subwidgets': [
|
||
T('AI 原生 · 产线操作系统', 'pl-badge pl-rise pl-d1'),
|
||
T('把业务装进产线', 'pl-h1 pl-stroke pl-rise pl-d2'),
|
||
T('让 Agent 自主运转', 'pl-h1 pl-grad pl-rise pl-d2'),
|
||
T('产线平台是 Agent 驱动的企业智能产线操作系统:商机、投标、开发一键开工,'
|
||
'大模型智能体自主推进任务,人类门禁把关质量,产出即产品、跑完即记账。',
|
||
'pl-sub pl-rise pl-d3', wrap=True),
|
||
{'widgettype': 'HBox',
|
||
'options': {'gap': '14px', 'flexWrap': 'wrap', 'alignItems': 'center'},
|
||
'subwidgets': [
|
||
btn('登录 / 注册', 'pl-btn pl-rise pl-d4', bind=login_bind()),
|
||
btn('查看平台能力', 'pl-btn-ghost pl-rise pl-d4', script=scroll_script('sec_caps')),
|
||
]},
|
||
{'widgettype': 'HBox',
|
||
'options': {'css': 'pl-rise pl-d5', 'gap': '40px', 'flexWrap': 'wrap',
|
||
'padding': '18px 0 0 0', 'alignItems': 'flex-start'},
|
||
'subwidgets': [
|
||
{'widgettype': 'VBox', 'options': {'gap': '2px'},
|
||
'subwidgets': [TN('5', 'pl-stat-n'), T('产线矩阵', 'pl-stat-t')]},
|
||
{'widgettype': 'VBox', 'options': {'gap': '2px'},
|
||
'subwidgets': [TN('30+', 'pl-stat-n'), T('功能模块', 'pl-stat-t')]},
|
||
{'widgettype': 'VBox', 'options': {'gap': '2px'},
|
||
'subwidgets': [TN('24×7', 'pl-stat-n'), T('自主运转', 'pl-stat-t')]},
|
||
{'widgettype': 'VBox', 'options': {'gap': '2px'},
|
||
'subwidgets': [TN('4', 'pl-stat-n'), T('语言国际化', 'pl-stat-t')]},
|
||
]},
|
||
]},
|
||
],
|
||
}
|
||
|
||
# ---------- marquee ----------
|
||
mq_items = ['商机挖掘', '投标生产', '软件开发', '模型网关', '产品商品化',
|
||
'计费记账', '供销协同', '知识库', '人机协同门禁']
|
||
def mq_track():
|
||
return {'widgettype': 'HBox', 'options': {'css': 'pl-mq-track'},
|
||
'subwidgets': [T(x, 'pl-mq-item') for x in mq_items * 2]}
|
||
marquee = {'widgettype': 'HBox', 'options': {'css': 'pl-marquee', 'width': '100%'},
|
||
'subwidgets': [mq_track()]}
|
||
|
||
# ---------- capabilities (bento) ----------
|
||
def card(icon, title, desc, tag, lg=False, flex='1 1 260px'):
|
||
css = 'pl-card pl-card-lg' if lg else 'pl-card'
|
||
subs = []
|
||
if tag:
|
||
subs.append(TN(tag, 'pl-card-tag'))
|
||
subs.append(TN(icon, 'pl-card-icon'))
|
||
subs.append(T(title, 'pl-card-t'))
|
||
subs.append(T(desc, 'pl-card-d', wrap=True))
|
||
return {'widgettype': 'VBox',
|
||
'options': {'css': css, 'flex': flex, 'gap': '10px', 'alignItems': 'flex-start'},
|
||
'subwidgets': subs}
|
||
|
||
caps = {
|
||
'id': 'sec_caps',
|
||
'widgettype': 'VBox',
|
||
'options': {'css': 'pl-sec', 'width': '100%', 'gap': '18px', 'alignItems': 'flex-start'},
|
||
'subwidgets': [
|
||
TN('CAPABILITIES', 'pl-kicker'),
|
||
T('平台能力', 'pl-h2'),
|
||
T('不是聊天机器人,是一条条能自主干活、能被审计、能直接计费的智能产线。', 'pl-sec-sub', wrap=True),
|
||
{'widgettype': 'HBox',
|
||
'options': {'width': '100%', 'flexWrap': 'wrap', 'gap': '16px', 'padding': '18px 0 0 0',
|
||
'alignItems': 'stretch'},
|
||
'subwidgets': [
|
||
card('🤖', 'Agent 驱动产线引擎',
|
||
'会话式开工:一句话启动流程,Agent 自动拆解任务、调用大模型、产出交付件;'
|
||
'LLM 三层容错兜底,产线不因单点模型故障停摆。', 'CORE', lg=True, flex='2 2 540px'),
|
||
card('🛰', '模型网关',
|
||
'多供应商大模型统一接入:余额门禁、会话粘性轮询、故障自动路由、按调用出账,密钥不出进程。', 'GATEWAY'),
|
||
card('🛒', '产品商品化',
|
||
'资源→产品→定价→折扣→订购→计费全链路;显示价按「真实定价×客户折扣」计算,跑产线即消费、即记账。', 'COMMERCE'),
|
||
card('🔔', '平台待办中枢',
|
||
'全局异步通知中枢:门禁、确认、上传等产线事件统一冒泡为人类待办,同一事项只发一次、处理完自动补位。', 'HUMAN LOOP'),
|
||
card('🛡', '人机协同门禁',
|
||
'PM 审核、QC 质检、人工确认层层把关——机器的速度,人的判断,审计日志全程可追溯。', 'GATE'),
|
||
card('🔐', '安全与多租户',
|
||
'RBAC 细粒度路径权限、机构隔离、短令牌内部代理、登录审计与失败锁定。', 'SECURITY'),
|
||
card('🌐', '国际化与双主题',
|
||
'中 / 英 / 日 / 韩四语实时切换,明暗双主题一键换装,桌面与手机 H5 同构。', 'I18N'),
|
||
]},
|
||
],
|
||
}
|
||
|
||
# ---------- pipelines ----------
|
||
def pipe(no, name, desc, live):
|
||
tag = T('运行中' if live else '规划中', 'pl-tag ' + ('pl-tag-live' if live else 'pl-tag-soon'))
|
||
return {'widgettype': 'HBox',
|
||
'options': {'css': 'pl-step', 'width': '100%', 'gap': '20px', 'alignItems': 'center',
|
||
'flexWrap': 'wrap'},
|
||
'subwidgets': [
|
||
TN(no, 'pl-step-n'),
|
||
{'widgettype': 'VBox',
|
||
'options': {'css': 'filler', 'flex': '1 1 320px', 'gap': '4px', 'minWidth': '240px'},
|
||
'subwidgets': [T(name, 'pl-step-t'), T(desc, 'pl-step-d', wrap=True)]},
|
||
tag,
|
||
]}
|
||
|
||
pipes = {
|
||
'id': 'sec_pipes',
|
||
'widgettype': 'VBox',
|
||
'options': {'css': 'pl-sec', 'width': '100%', 'gap': '16px', 'alignItems': 'flex-start'},
|
||
'subwidgets': [
|
||
TN('PIPELINES', 'pl-kicker'),
|
||
T('产线矩阵', 'pl-h2'),
|
||
T('每条产线都是一个完整的业务闭环:从情报输入到可交付产出,全程 Agent 推进、人类门禁把关。', 'pl-sec-sub', wrap=True),
|
||
{'widgettype': 'VBox',
|
||
'options': {'width': '100%', 'gap': '12px', 'padding': '18px 0 0 0'},
|
||
'subwidgets': [
|
||
pipe('01', '商机产线', '招标情报采集 → 商机研判 → 研发报告 → 人工确认,把市场信号变成可跟进的商机。', True),
|
||
pipe('02', '投标产线', '标书智能编制:逐项应答评分标准、技术/商务分册生成、材料缺口清单、多轮 QC 门禁。', True),
|
||
pipe('03', '开发产线', 'SDLC 任务链:需求 → 设计 → 开发 → 测试 → 部署,PM 审核门控 + 交付件管理。', True),
|
||
pipe('04', '运维产线', '面向 FOMS 的运维自动化:告警处置、巡检报告、变更编排。', False),
|
||
pipe('05', '营销产线', '内容生产与投放自动化:素材生成、渠道分发、效果回收。', False),
|
||
]},
|
||
],
|
||
}
|
||
|
||
# ---------- flow ----------
|
||
def step(no, title, desc):
|
||
return {'widgettype': 'VBox',
|
||
'options': {'css': 'pl-card', 'flex': '1 1 190px', 'gap': '8px', 'alignItems': 'flex-start',
|
||
'minHeight': '170px'},
|
||
'subwidgets': [TN(no, 'pl-step-n'), T(title, 'pl-card-t'), T(desc, 'pl-card-d', wrap=True)]}
|
||
|
||
flow = {
|
||
'id': 'sec_flow',
|
||
'widgettype': 'VBox',
|
||
'options': {'css': 'pl-sec', 'width': '100%', 'gap': '16px', 'alignItems': 'flex-start'},
|
||
'subwidgets': [
|
||
TN('HOW IT WORKS', 'pl-kicker'),
|
||
T('五步跑通一条产线', 'pl-h2'),
|
||
{'widgettype': 'HBox',
|
||
'options': {'width': '100%', 'flexWrap': 'wrap', 'gap': '14px', 'padding': '18px 0 0 0',
|
||
'alignItems': 'stretch'},
|
||
'subwidgets': [
|
||
step('01', '订阅产线', '在产品商城选购产线订阅,账号权益即时生效。'),
|
||
step('02', '发起会话', '进入产线工作台,用一句话下达任务。'),
|
||
step('03', 'Agent 自主执行', '任务拆解、模型调用、交付件产出全程自动推进,可随时插话纠偏。'),
|
||
step('04', '人类门禁', '关键节点冒泡为平台待办,人看过正文再放行。'),
|
||
step('05', '交付与记账', '产出归档、按量出账,消费明细随时可查。'),
|
||
]},
|
||
],
|
||
}
|
||
|
||
# ---------- CTA + footer ----------
|
||
cta = {
|
||
'widgettype': 'VBox',
|
||
'options': {'css': 'pl-cta', 'width': 'auto', 'gap': '16px', 'alignItems': 'center'},
|
||
'subwidgets': [
|
||
T('准备好把业务装上产线了吗?', 'pl-cta-t'),
|
||
T('注册账号,订阅第一条产线,让 Agent 今天就开始干活。', 'pl-cta-d'),
|
||
btn('立即注册', 'pl-btn', bind=login_bind()),
|
||
],
|
||
}
|
||
footer = {
|
||
'widgettype': 'HBox',
|
||
'options': {'css': 'pl-footer', 'width': '100%', 'justifyContent': 'space-between',
|
||
'alignItems': 'center', 'flexWrap': 'wrap', 'gap': '8px'},
|
||
'subwidgets': [
|
||
T('© 2026 开元云(北京)科技有限公司', 'pl-footer-t'),
|
||
TN('PIPELINE PLATFORM · BUILT WITH BRICKS', 'pl-footer-t'),
|
||
],
|
||
}
|
||
|
||
landing = {
|
||
'id': 'app',
|
||
'widgettype': 'VBox',
|
||
'options': {'css': 'pl-page', 'width': '100%', 'height': '100%', 'padding': '0',
|
||
'overflow': 'hidden'},
|
||
'subwidgets': [
|
||
{'widgettype': 'VBox',
|
||
'options': {'css': 'pl-scroll', 'width': '100%', 'flex': '1 1 auto', 'minHeight': '0',
|
||
'overflowY': 'auto', 'alignItems': 'flex-start'},
|
||
'subwidgets': [header, hero, marquee, caps, pipes, flow, cta, footer]},
|
||
],
|
||
# 注册成功(register.dspy dispatch user_logined)后整页刷新 → 服务端渲染工作台分支。
|
||
# 密码登录(up_login.dspy manual)走自身的 dismissed→in-place 渲染 /index.ui,不经此事件,无双重刷新。
|
||
'binds': [
|
||
{'wid': 'app', 'event': 'user_logined', 'actiontype': 'script', 'target': 'self',
|
||
'script': 'window.location.reload();'},
|
||
],
|
||
}
|
||
|
||
landing_txt = json.dumps(landing, ensure_ascii=False, indent=1)
|
||
# 防 .ui 内嵌 <script> 提前闭合(本页面没有 Html 控件,保险检查)
|
||
assert '<script' not in landing_txt
|
||
|
||
new = ('{% if get_user() %}\n' + old + '\n{% else %}\n' + landing_txt + '\n{% endif %}\n')
|
||
with io.open(IDX, 'w', encoding='utf-8') as f:
|
||
f.write(new)
|
||
print('index.ui written, %d bytes' % len(new.encode('utf-8')))
|
||
|
||
# 收集需要 i18n 的 key(otext 全集 + Button label)
|
||
keys = []
|
||
def walk(w):
|
||
if isinstance(w, dict):
|
||
o = w.get('options', {})
|
||
if isinstance(o, dict):
|
||
if o.get('i18n') and o.get('otext'):
|
||
keys.append(o['otext'])
|
||
if w.get('widgettype') == 'Button' and o.get('label') and not o['label'].startswith('🌙'):
|
||
keys.append(o['label'])
|
||
for v in w.values():
|
||
walk(v)
|
||
elif isinstance(w, list):
|
||
for v in w:
|
||
walk(v)
|
||
walk(landing)
|
||
seen = set()
|
||
uniq = [k for k in keys if not (k in seen or seen.add(k))]
|
||
with io.open('/tmp/home_i18n_keys.txt', 'w', encoding='utf-8') as f:
|
||
f.write('\n'.join(uniq))
|
||
print('%d unique i18n keys -> /tmp/home_i18n_keys.txt' % len(uniq))
|
||
for k in uniq:
|
||
print(' -', k)
|