落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 17:25:38 +08:00
co-authored by Cursor
parent 8219dce84b
commit 65ef75f1ef
19 changed files with 2420 additions and 8 deletions
+46 -1
View File
@@ -10,14 +10,20 @@ from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from agent.channel.clarification import validate_token
from agent.channel.h5.copy_page import expired_copy_page, render_copy_page
from agent.channel.h5.copy_store import lookup_copy_token
from agent.channel.h5.upload_page import (
expired_upload_page,
render_upload_message,
render_upload_page,
)
from agent.handlers.card_action import handle_card_action
from agent.policy import inquiry_copy as copy
logger = logging.getLogger(__name__)
@@ -67,6 +73,45 @@ def h5_jsapi_signature(
raise HTTPException(status_code=503, detail="jsapi_unavailable") from exc
@router.get("/upload/{token}", response_class=HTMLResponse)
def h5_upload_open(token: str) -> HTMLResponse:
"""
打开附件上传页。身份只认 token;过期给 410,不吐内部栈。
"""
from agent.handlers.attachment_inquiry import lookup_upload_sender
if lookup_upload_sender(token) is None:
return HTMLResponse(expired_upload_page(), status_code=410)
return HTMLResponse(render_upload_page())
@router.post("/upload/{token}", response_class=HTMLResponse)
async def h5_upload_submit(
token: str,
files: Optional[list[UploadFile]] = File(default=None),
) -> HTMLResponse:
"""
接收多个 Excel/PDF。请求线程只验 token、读字节、入队,不等千问。
"""
from agent.handlers.attachment_inquiry import accept_h5_uploads, lookup_upload_sender
sender_id = lookup_upload_sender(token)
if sender_id is None:
return HTMLResponse(expired_upload_page(), status_code=410)
incoming: list[tuple[str, bytes]] = []
for item in files or []:
name = item.filename or ""
raw = await item.read()
incoming.append((name, raw))
phase, hint = accept_h5_uploads(sender_id=sender_id, files=incoming, process_now=False)
logger.info("h5.upload phase=%s sender=%s n=%s", phase, sender_id, len(incoming))
if phase == "expired":
return HTMLResponse(expired_upload_page(), status_code=410)
if phase in {"reject_type", "need_file"}:
return HTMLResponse(render_upload_message("无法上传", hint), status_code=400)
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
@router.get("/{token}")
def h5_open(token: str) -> dict[str, Any]:
"""
@@ -46,6 +46,23 @@ def issue_copy_token(work_order_no: str, *, ttl_seconds: int = COPY_TTL_SECONDS)
return token
def inquiry_form_base() -> str:
"""H5 公网根路径(含 /inquiry-form),不含 userid。"""
return _form_base()
def build_upload_url(token: str) -> str:
"""
拼附件上传页:{inquiry-form}/upload/{token}。
token 为空则返回空串,调用方不要出跳转按钮。
"""
raw = (token or "").strip()
if not raw:
return ""
return f"{_form_base()}/upload/{raw}"
def lookup_copy_token(token: str) -> Optional[str]:
"""校验 token 未过期;返回工单号或 None。不消费,可重复打开。"""
raw = (token or "").strip()
@@ -0,0 +1,319 @@
"""
附件询价 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)