Files
inquiry_robot/inquiry-agent/agent/channel/h5/air_route_page.py
T
jillion886andCursor 4dfd756bcf 销售侧汇总统一叫费用合计,并认准空运费的金额。
企微跳转改为账号密码登录,报价卡上的费用按原金额改回;航线按单价数量金额报价时不再记成海运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:15:33 +08:00

287 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空运选线路 H5 页。
本文件职责:列出 TMS 线路(航线+费用+总价),提交所选下标。
样式对齐附件询价页:企微内嵌、手机竖屏、卡片点选。
禁止:从 URL 读 userid;禁止本页查 TMS;禁止编造费用行。
"""
from __future__ import annotations
import html
from typing import Any
from agent.channel.h5.wecom_close import wecom_auto_close_script
from agent.schema.air_route_options import (
option_fee_lines,
option_total,
parse_air_options,
route_codes,
route_line,
)
# 与 upload_page 同一套企微蓝、浅底、白卡片。禁止在页面写 userid。
_PAGE_CSS = """
:root {
--bg: #eef3f9;
--card: #ffffff;
--text: #17233d;
--muted: #5b6b7c;
--line: #c5d4ea;
--brand: #1677ff;
--brand-press: #0f62d9;
--ok: #0f8a5f;
--shadow: 0 10px 28px rgba(23, 51, 91, 0.10);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 480px;
margin: 0 auto;
padding: 16px 16px calc(96px + env(safe-area-inset-bottom));
}
.hero { margin: 4px 0 16px; }
.wo {
display: inline-block;
font-size: 12px;
color: #2b5aa8;
background: #eef4ff;
border-radius: 999px;
padding: 4px 10px;
margin: 0 0 8px;
word-break: break-all;
}
h1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; font-weight: 600; }
.lead { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.opt {
display: block;
position: relative;
background: var(--card);
border: 1.5px solid transparent;
border-radius: 16px;
padding: 16px 16px 14px;
margin: 0 0 12px;
box-shadow: var(--shadow);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.opt:has(input:checked), .opt.is-on {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16), var(--shadow);
}
.opt input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.head {
display: flex;
align-items: flex-start;
gap: 12px;
}
.tick {
width: 22px;
height: 22px;
margin-top: 2px;
border: 2px solid var(--line);
border-radius: 50%;
flex-shrink: 0;
background: #fff;
}
.opt:has(input:checked) .tick, .opt.is-on .tick {
border-color: var(--brand);
background: var(--brand);
box-shadow: inset 0 0 0 4px #fff;
}
.route { flex: 1; min-width: 0; }
.route strong {
display: block;
font-size: 17px;
line-height: 1.4;
font-weight: 650;
}
.codes { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 0.04em; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.pill {
font-size: 12px;
color: #2b5aa8;
background: #eef4ff;
border-radius: 999px;
padding: 3px 8px;
}
.pill.via { color: #8a5a00; background: #fff6e0; }
.fees { list-style: none; margin: 12px 0 0; padding: 0; }
.fees li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-top: 1px solid #eef2f6;
font-size: 14px;
line-height: 1.5;
}
.fees .n { color: var(--muted); }
.fees li.leg .n { color: inherit; font-weight: 650; }
.fees .a { font-variant-numeric: tabular-nums; text-align: right; }
.empty {
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid #eef2f6;
color: var(--muted);
font-size: 13px;
}
.total {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px dashed var(--line);
font-size: 15px;
}
.total b { color: var(--brand); font-size: 18px; font-variant-numeric: tabular-nums; }
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: rgba(255,255,255,0.96);
box-shadow: 0 -8px 24px rgba(23, 51, 91, 0.08);
}
.bar-inner { max-width: 480px; margin: 0 auto; }
button[type="submit"] {
width: 100%;
min-height: 48px;
border: 0;
border-radius: 10px;
background: var(--brand);
color: #fff;
font-size: 16px;
font-weight: 700;
cursor: pointer;
touch-action: manipulation;
}
button[type="submit"]:active { background: var(--brand-press); }
.msg {
max-width: 480px;
margin: 48px auto;
padding: 0 16px;
text-align: center;
}
.msg h1 { font-size: 20px; margin: 0 0 8px; }
.msg p { margin: 0; color: var(--muted); font-size: 15px; line-height: 1.6; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
"""
def expired_air_route_page() -> str:
"""token 过期页。"""
return _wrap(
"已过期",
"<div class='msg'><h1>链接已过期</h1><p>请回企微重新打开选线路卡片。</p></div>",
)
def render_air_route_page(payload: dict[str, Any]) -> str:
"""
渲染选线路页。payload 来自 token:quote / facts / work_order_no。
"""
quote = dict(payload.get("quote") or {})
facts = dict(payload.get("facts") or {})
wo = html.escape(str(payload.get("work_order_no") or ""))
options = parse_air_options(quote)
rows: list[str] = []
for i, option in enumerate(options):
rows.append(_option_card(i, option, quote, facts))
count = len(options)
body = (
"<div class='wrap'>"
"<header class='hero'>"
f"<p class='wo'>{wo}</p>"
"<h1>选择线路</h1>"
f"<p class='lead'>共 {count} 条线路,点选一条后确定。费用以 TMS 回包为准。</p>"
"</header>"
"<form method='post'>"
+ "".join(rows)
+ "<div class='bar'><div class='bar-inner'>"
"<button type='submit'>选定这条</button>"
"</div></div></form></div>"
"<script>(function(){var f=document.querySelector('form');if(!f)return;"
"function sync(){f.querySelectorAll('.opt').forEach(function(el){"
"el.classList.toggle('is-on',!!(el.querySelector('input')||{}).checked);});}"
"f.addEventListener('change',sync);})();</script>"
)
return _wrap("选择线路", body)
def render_air_route_ok() -> str:
"""选定成功:短暂展示后关企微 H5。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>正在返回企微,请查看价格明细。</p></div>",
auto_close=True,
)
def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str:
"""一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。"""
src = dict(option or {})
line = html.escape(route_line(src, facts))
codes = html.escape(route_codes(src, facts, quote))
fees = option_fee_lines(src, quote)
if fees:
fee_html = (
"<ul class='fees'>"
+ "".join(
f"<li><span class='n'>{html.escape(name)}</span>"
f"<span class='a'>{html.escape(amount)}</span></li>"
for name, amount in fees
)
+ "</ul>"
)
else:
fee_html = "<p class='empty'>本线路未返回费用明细</p>"
total = html.escape(option_total(src, quote))
transfer = str(src.get("transferAirportCode") or src.get("transfer") or "").strip()
tag = "中转" if transfer else "直达"
tag_cls = "pill via" if transfer else "pill"
flight = html.escape(str(src.get("flightNo") or "").strip())
pills = [f"<span class='{tag_cls}'>{tag}</span>"]
if flight:
pills.append(f"<span class='pill'>航班 {flight}</span>")
codes_html = f"<span class='codes'>{codes}</span>" if codes else ""
return (
"<label class='opt'>"
f"<input type='radio' name='index' value='{index}' required />"
"<div class='head'><span class='tick' aria-hidden='true'></span>"
f"<div class='route'><strong>{line}</strong>{codes_html}"
f"<div class='meta'>{''.join(pills)}</div></div></div>"
f"{fee_html}"
f"<p class='total'><span>总销售价</span><b>{total}</b></p>"
"</label>"
)
def _wrap(title: str, body: str, *, auto_close: bool = False) -> str:
"""整页壳:标题、viewport、共用样式。成功页可挂企微关窗脚本。"""
closer = wecom_auto_close_script() if auto_close else ""
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{html.escape(title)}</title>"
f"<style>{_PAGE_CSS}</style></head><body>"
f"{body}{closer}</body></html>"
)