Files
inquiry_robot/inquiry-agent/agent/channel/h5/upload_page.py
T

320 lines
9.7 KiB
Python

"""
附件询价 H5 上传页。
本文件职责:渲染选文件页与过期/成功/拒收提示。URL 不含 userid。
样式按企微内嵌、手机竖屏:卡片、虚线选区、已选文件列表;不改上传规则。
"""
from __future__ import annotations
from html import escape
import json
from agent.policy import inquiry_copy as copy
# 三页共用:浅底、白卡片、企微蓝主按钮。禁止在页面里写 userid。
_PAGE_CSS = """
:root {
--bg: #eef3f9;
--card: #ffffff;
--text: #17233d;
--muted: #5b6b7c;
--line: #c5d4ea;
--brand: #1677ff;
--brand-press: #0f62d9;
--danger: #d83931;
--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: 20px 16px calc(24px + env(safe-area-inset-bottom));
}
.card {
background: var(--card);
border-radius: 16px;
padding: 22px 20px 24px;
box-shadow: var(--shadow);
}
.hero {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.badge {
width: 44px;
height: 44px;
border-radius: 12px;
background: #e8f1ff;
color: var(--brand);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.badge svg { width: 22px; height: 22px; }
h1 { font-size: 20px; line-height: 1.3; margin: 0 0 4px; font-weight: 600; }
.lead { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.hint { margin: 12px 0 0; color: var(--muted); font-size: 13px; line-height: 1.7; }
.pills { display: flex; gap: 8px; margin: 14px 0 0; }
.pill {
font-size: 12px;
color: #2b5aa8;
background: #eef4ff;
border-radius: 999px;
padding: 4px 10px;
}
.drop {
position: relative;
display: block;
margin-top: 18px;
padding: 22px 16px;
border: 1.5px dashed var(--line);
border-radius: 12px;
background: #f7faff;
text-align: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.drop:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16); }
.drop input {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.drop-title { display: block; font-size: 16px; font-weight: 600; }
.drop-sub { display: block; margin-top: 6px; font-size: 13px; color: var(--muted); }
.picked { list-style: none; margin: 14px 0 0; padding: 0; }
.picked li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-top: 8px;
background: #f6f8fb;
border-radius: 10px;
font-size: 14px;
}
.idx {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e8f1ff;
color: var(--brand);
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.name { word-break: break-all; }
button[type="submit"], .btn {
width: 100%;
min-height: 48px;
margin-top: 20px;
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, .btn:active { background: var(--brand-press); }
button[disabled] { background: #94a3b8; cursor: not-allowed; }
.err {
min-height: 1.4em;
margin: 12px 0 0;
color: var(--danger);
font-size: 14px;
line-height: 1.5;
}
.rules { margin: 16px 0 0; padding-left: 18px; color: var(--muted); font-size: 13px; line-height: 1.8; }
.tone-ok .badge { background: #e7f7ef; color: var(--ok); }
.tone-warn .badge { background: #fdecea; color: var(--danger); }
.tone-expired .badge { background: #eef1f5; color: var(--muted); }
.body { margin: 8px 0 0; font-size: 15px; line-height: 1.7; }
.back { margin: 10px 0 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
"""
_ICON_FILE = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" stroke="currentColor" stroke-width="1.7"/>'
'<path d="M14 3v5h5" stroke="currentColor" stroke-width="1.7"/>'
'</svg>'
)
_ICON_OK = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/>'
'<path d="M8 12.5l2.5 2.5L16 9.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>'
'</svg>'
)
_ICON_WARN = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<path d="M12 4l9 16H3L12 4z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/>'
'<path d="M12 10v5M12 17.5h.01" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>'
'</svg>'
)
_ICON_EXPIRED = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/>'
'<path d="M12 7v6l4 2" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>'
'</svg>'
)
def _shell(*, title: str, body: str, tone: str = "ok") -> str:
"""成功 / 拒绝 / 过期共用卡片壳,避免三页三种简陋白底。"""
icons = {"ok": _ICON_OK, "warn": _ICON_WARN, "expired": _ICON_EXPIRED}
icon = icons.get(tone, _ICON_WARN)
extra = '<p class="back">请回企微查看识别结果。</p>' if tone == "ok" else ""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<style>{_PAGE_CSS}</style>
</head>
<body>
<div class="wrap tone-{tone}">
<div class="card">
<div class="hero">
<div class="badge">{icon}</div>
<div>
<h1>{title}</h1>
</div>
</div>
<p class="body">{body}</p>
{extra}
</div>
</div>
</body>
</html>
"""
def render_upload_page() -> str:
"""上传页:一次可选多个 Excel/PDF;图片请发回企微。"""
reject = json.dumps(copy.ATTACH_H5_REJECT_TYPE, ensure_ascii=False)
need = json.dumps(copy.ATTACH_H5_NEED_FILE, ensure_ascii=False)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>上传询价附件</title>
<style>{_PAGE_CSS}</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="hero">
<div class="badge">{_ICON_FILE}</div>
<div>
<h1>上传询价附件</h1>
<p class="lead">这里只传 Excel、PDF。图片请直接发到当前企微会话。</p>
</div>
</div>
<div class="pills"><span class="pill">Excel</span><span class="pill">PDF</span></div>
<p class="hint">一次可选多个文件,顺序按你选中的顺序。提交后请回企微查看识别结果。</p>
<form id="f" method="post" enctype="multipart/form-data">
<label class="drop" for="files">
<input id="files" name="files" type="file" multiple
accept=".xls,.xlsx,.pdf,application/pdf,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"/>
<span class="drop-title">选择 Excel 或 PDF</span>
<span class="drop-sub">点这里选文件,可一次多选</span>
</label>
<ul class="picked" id="picked" hidden></ul>
<button type="submit" id="go">上传</button>
</form>
<p class="err" id="err" role="alert"></p>
<ul class="rules">
<li>只接受 .xls / .xlsx / .pdf</li>
<li>图片请发到当前企微会话,不要在本页上传</li>
</ul>
</div>
</div>
<script>
var REJECT = {reject};
var NEED = {need};
var input = document.getElementById("files");
var picked = document.getElementById("picked");
var err = document.getElementById("err");
var go = document.getElementById("go");
function esc(s) {{
return String(s).replace(/[&<>"']/g, function (c) {{
return ({{"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","'":"&#39;"}})[c];
}});
}}
function renderPicked() {{
var files = input.files || [];
err.textContent = "";
if (!files.length) {{
picked.innerHTML = "";
picked.hidden = true;
return;
}}
var html = "";
for (var i = 0; i < files.length; i++) {{
html += '<li><span class="idx">' + (i + 1) + '</span><span class="name">' + esc(files[i].name) + '</span></li>';
}}
picked.innerHTML = html;
picked.hidden = false;
}}
input.addEventListener("change", renderPicked);
document.getElementById("f").addEventListener("submit", function (ev) {{
var files = input.files;
err.textContent = "";
if (!files || !files.length) {{
ev.preventDefault();
err.textContent = NEED;
return;
}}
var okExt = /\\.(xlsx|xls|pdf)$/i;
for (var i = 0; i < files.length; i++) {{
if (!okExt.test(files[i].name)) {{
ev.preventDefault();
err.textContent = REJECT;
return;
}}
}}
go.disabled = true;
go.textContent = "正在上传…";
}});
</script>
</body>
</html>
"""
def render_upload_message(title: str, body: str, *, expired: bool = False) -> str:
"""成功 / 拒绝 / 过期页。"""
tone = "expired" if expired else ("ok" if title == "上传成功" else "warn")
return _shell(title=escape(title), body=escape(body), tone=tone)
def expired_upload_page() -> str:
return render_upload_message("链接已过期", copy.ATTACH_H5_EXPIRED, expired=True)