feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复

== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
+169 -9
View File
@@ -36,10 +36,12 @@
# =============================================================================
import logging
import time
import secrets as secrets_mod
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Path, Query
from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -145,6 +147,7 @@ async def poll_qrcode(
# --------------------------------------------------------------------------
@router.api_route("/scan", methods=["GET", "POST"], response_model=None)
async def scan_qrcode(
request: Request,
body: Optional[QrcodeScanRequest] = None,
ticket: Optional[str] = Query(None, description="扫码登录票据(兼容旧参数名)"),
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
@@ -240,6 +243,51 @@ async def scan_qrcode(
from fastapi.responses import HTMLResponse
if final_code is not None and final_ticket is not None and body is None:
user_name = result.get('name', '')
# ============================================================
# 生成企微 JS-SDK 签名(用于 wx.closeWindow() 关闭当前页面)
# ============================================================
# 签名算法: sha1(jsapi_ticket=X&noncestr=X&timestamp=X&url=X)
# url 必须是企微 webview 中当前页面的完整 URL(含 query,不含 #hash
jsapi_signature = ""
jsapi_timestamp = 0
jsapi_nonce = ""
jsapi_appid = getattr(settings, "wecom_corp_id", "")
current_url = "" # 用于调试显示和 JSAPI 签名 URL
try:
from app.services.wecom_service import WecomService
wecom_svc = WecomService(redis_client)
jsapi_ticket = await wecom_svc.get_jsapi_ticket()
# 当前页面 URL(不含 # 后面部分)
# ⚠️ 关键:request.url 默认用内部 HTTP 协议(Nginx→后端是 HTTP
# 但企微 WebView 看到的是 HTTPS,协议不一致会导致签名校验失败
# 必须从 X-Forwarded-Proto 头重建正确的 HTTPS URL
scheme = request.headers.get("x-forwarded-proto", "https")
host = request.headers.get("host", "")
path = request.url.path
query = str(request.url.query) if request.url.query else ""
current_url = f"{scheme}://{host}{path}"
if query:
current_url += f"?{query}"
jsapi_timestamp = int(time.time())
jsapi_nonce = secrets_mod.token_hex(8)
jsapi_signature = wecom_svc.generate_jsapi_signature(
ticket=jsapi_ticket,
nonce_str=jsapi_nonce,
timestamp=jsapi_timestamp,
url=current_url,
)
logger.info(
f"JS-SDK 签名生成成功: url={current_url}, "
f"timestamp={jsapi_timestamp}, appid={jsapi_appid}"
)
try:
await wecom_svc.close()
except Exception:
pass
except Exception as e:
logger.warning(f"JS-SDK 签名生成失败(降级为 WeixinJSBridge: {e}")
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
@@ -249,12 +297,15 @@ async def scan_qrcode(
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #07C160 0%, #06AD56 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }}
.card {{ background: rgba(255,255,255,0.95); border-radius: 20px; padding: 48px 40px; max-width: 360px; width: 100%; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
.card {{ background: rgba(255,255,255,0.95); border-radius: 20px; padding: 48px 32px; max-width: 360px; width: 100%; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
.check {{ width: 64px; height: 64px; margin: 0 auto 16px; }}
.title {{ color: #1f2937; font-size: 24px; font-weight: 600; margin-bottom: 8px; }}
.subtitle {{ color: #6b7280; font-size: 14px; margin-bottom: 24px; }}
.status {{ display: inline-flex; align-items: center; gap: 6px; background: #dcfce7; color: #166534; padding: 10px 20px; border-radius: 50px; font-size: 14px; font-weight: 500; }}
.footer {{ margin-top: 24px; color: #9ca3af; font-size: 12px; }}
.footer {{ margin-top: 20px; color: #9ca3af; font-size: 12px; }}
.back-btn {{ display: none; margin-top: 20px; padding: 12px 32px; background: #07C160; color: white; border: none; border-radius: 50px; font-size: 16px; font-weight: 500; cursor: pointer; }}
.debug {{ margin-top: 16px; color: #6b7280; font-size: 11px; line-height: 1.6; word-break: break-all; text-align: left; background: #f3f4f6; padding: 10px 12px; border-radius: 8px; }}
.debug b {{ color: #07C160; }}
</style>
</head>
<body>
@@ -266,15 +317,124 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
<h1 class="title">登录成功</h1>
<div class="status">已自动确认登录</div>
<p class="subtitle">你好,{user_name}<br>请返回电脑端查看</p>
<div class="footer">页面可安全关闭 · 税友集团</div>
<button class="back-btn" id="backBtn" onclick="manualClose()">点击返回企微</button>
<div class="footer">页面即将自动关闭 · 税友集团</div>
<div class="debug" id="debugInfo">
<b>签名:</b> {'成功' if jsapi_signature else '未生成'}<br>
<b>URL:</b> {current_url}<br>
<b>状态:</b> <span id="jsStatus">JS加载中...</span>
</div>
</div>
<script>
// 尝试关闭企微 WebView(如果支持的话)
setTimeout(function() {{
if (typeof wx !== 'undefined' && wx.closeWindow) {{
wx.closeWindow();
(function() {{
var statusEl = document.getElementById('jsStatus');
var btnEl = document.getElementById('backBtn');
var startTime = Date.now();
var tried = {{}};
function setStatus(msg) {{
if (statusEl) statusEl.textContent = msg + ' (' + (Date.now() - startTime) + 'ms)';
}}
}}, 1500);
function showBtn() {{
if (btnEl) btnEl.style.display = 'inline-block';
}}
// 尝试关闭当前 WebView
function tryClose(forceShowBtn) {{
setStatus('尝试关闭');
// 1. 企微/微信 JS-SDK wx.closeWindow
if (!tried.wxClose && typeof wx !== 'undefined' && wx.closeWindow) {{
tried.wxClose = true;
try {{
setStatus('wx.closeWindow');
wx.closeWindow();
return true;
}} catch(e) {{ setStatus('wx.closeWindow失败:' + (e.message || e)); }}
}}
// 2. wx.invoke closeWindow
if (!tried.wxInvoke && typeof wx !== 'undefined' && wx.invoke) {{
tried.wxInvoke = true;
try {{
setStatus('wx.invoke closeWindow');
wx.invoke('closeWindow', {{}}, function(){{}});
return true;
}} catch(e) {{ setStatus('wx.invoke失败:' + (e.message || e)); }}
}}
// 3. 内置 WeixinJSBridge
if (!tried.jsBridge && typeof WeixinJSBridge !== 'undefined' && WeixinJSBridge.call) {{
tried.jsBridge = true;
try {{
setStatus('WeixinJSBridge.closeWindow');
WeixinJSBridge.call('closeWindow');
return true;
}} catch(e) {{ setStatus('JSBridge失败:' + (e.message || e)); }}
}}
// 4. window.close
if (!tried.windowClose) {{
tried.windowClose = true;
try {{
setStatus('window.close');
window.close();
return true;
}} catch(e) {{}}
}}
// 5. history.back
if (!tried.historyBack) {{
tried.historyBack = true;
try {{
setStatus('history.back');
history.back();
return true;
}} catch(e) {{}}
}}
if (forceShowBtn) {{
setStatus('无法自动关闭,请手动返回');
showBtn();
}}
return false;
}}
function manualClose() {{
tryClose(true);
}}
// 轮询检测 WeixinJSBridge / wx,最多 5 秒
var checkCount = 0;
var maxChecks = 50;
var interval = setInterval(function() {{
checkCount++;
var hasWx = typeof wx !== 'undefined';
var hasBridge = typeof WeixinJSBridge !== 'undefined';
setStatus('检测中 wx=' + hasWx + ' bridge=' + hasBridge + ' count=' + checkCount);
if (hasWx || hasBridge) {{
clearInterval(interval);
setStatus('已检测到关闭API1秒后尝试关闭');
setTimeout(function() {{
tryClose(true);
}}, 1000);
return;
}}
if (checkCount >= maxChecks) {{
clearInterval(interval);
setStatus('未检测到API,直接尝试关闭');
tryClose(true);
}}
}}, 100);
// 3 秒后无论结果都显示手动按钮兜底
setTimeout(function() {{
showBtn();
}}, 3000);
}})();
</script>
</body>
</html>"""