320 lines
9.7 KiB
Python
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 ({{"&":"&","<":"<",">":">","\\"":""","'":"'"}})[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)
|