落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 ({{"&":"&","<":"<",">":">","\\"":""","'":"'"}})[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)
|
||||
Reference in New Issue
Block a user