From 4ba2109f8d94437bed60be70c2e1c3a6651f377b Mon Sep 17 00:00:00 2001 From: jqb Date: Thu, 18 Jun 2026 17:49:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E6=80=BB=E7=BB=93=E7=8B=AC=E7=AB=8B=E9=A1=B5=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增每日总结前端页面,支持企微授权登录、客户对话总结展示 2. 后端新增查询全量客户总结接口,优化去重与排序逻辑 3. 配置Nginx路由与Vite多入口打包,适配Java版本降级 --- .../controller/DailySummaryController.java | 14 + .../archive/service/DailySummaryService.java | 78 ++++- backend/pom.xml | 6 +- frontend/sidebar/nginx.conf | 5 + frontend/sidebar/src/SummaryApp.tsx | 327 ++++++++++++++++++ frontend/sidebar/src/summary.tsx | 10 + frontend/sidebar/summary.html | 16 + frontend/sidebar/vite.config.ts | 9 +- 8 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 frontend/sidebar/src/SummaryApp.tsx create mode 100644 frontend/sidebar/src/summary.tsx create mode 100644 frontend/sidebar/summary.html diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java index 6bcecc7..cddb422 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java @@ -37,4 +37,18 @@ public class DailySummaryController { List list = dailySummaryService.queryDailySummary(customerId, date); return Result.success(list); } + + /** + * 查询客户所有日期的消息总结,按时间倒序 + * + * @param customerId 客户 ID(企微外部联系人 userid),不传则查该销售所有客户的总结 + * @param staffId 销售老师企微 userId + */ + @GetMapping("/daily-summary/all") + public Result> allDailySummary( + @RequestParam(required = false) String customerId, + @RequestParam String staffId) { + List list = dailySummaryService.queryAllDailySummary(customerId, staffId); + return Result.success(list); + } } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java index bdc6fc8..eef7fbb 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -74,7 +75,44 @@ public class DailySummaryService { escape(customerId), escape(customerId), escape(queryDate) ); - log.info("Query Milvus daily summary, customerId={}, date={}, expr={}", customerId, queryDate, expr); + return queryByExpr(customerId, expr, true); + } + + /** + * 查询客户所有日期的消息总结 + * + * @param customerId 客户企微外部联系人 userid + * @param staffId 销售老师企微 userId,传入则只返回该销售相关的总结 + * @return 总结列表,按 created_at 倒序 + */ + public List queryAllDailySummary(String customerId, String staffId) { + if (staffId == null || staffId.trim().isEmpty()) { + throw new IllegalArgumentException("staffId 不能为空"); + } + + String s = escape(staffId); + String expr; + if (customerId == null || customerId.trim().isEmpty()) { + // 只按员工查:from_user 是员工,或 to_user 包含员工(可能为 JSON 数组) + expr = String.format("from_user == '%s' || to_user like '%%%s%%'", s, s); + } else { + String c = escape(customerId); + // 双向匹配: + // 1) 员工发、客户收(to_user 可能为单值或 JSON 数组) + // 2) 客户发、员工收(to_user 可能为单值或 JSON 数组) + expr = String.format( + "(from_user == '%s' && (to_user == '%s' || to_user like '%%%s%%')) || " + + "(from_user == '%s' && (to_user == '%s' || to_user like '%%%s%%'))", + s, c, c, + c, s, s + ); + } + + return queryByExpr(customerId, expr, false); + } + + private List queryByExpr(String customerId, String expr, boolean asc) { + log.info("Query Milvus daily summary, customerId={}, expr={}", customerId, expr); QueryParam queryParam = QueryParam.newBuilder() .withCollectionName(milvusProperties.getCollection()) @@ -91,12 +129,14 @@ public class DailySummaryService { } List result = convert(response.getData()); + result = deduplicateByMsgTime(result); fillStaffInfo(result, customerId); + // 统一按 msg_time 倒序排列 result.sort((a, b) -> { - long ca = a.getCreatedAt() != null ? a.getCreatedAt() : 0L; - long cb = b.getCreatedAt() != null ? b.getCreatedAt() : 0L; - return Long.compare(ca, cb); + String ta = a.getMsgTime() != null ? a.getMsgTime() : ""; + String tb = b.getMsgTime() != null ? b.getMsgTime() : ""; + return tb.compareTo(ta); }); log.info("Milvus daily summary found {} records", result.size()); @@ -177,6 +217,9 @@ public class DailySummaryService { * 从 from_user / to_user 中找出销售老师 ID */ private String resolveStaffId(DailySummaryVO vo, String customerId) { + if (customerId == null || customerId.trim().isEmpty()) { + return vo.getFromUser(); + } if (customerId.equals(vo.getFromUser())) { return vo.getToUser(); } @@ -208,6 +251,33 @@ public class DailySummaryService { } } + /** + * 按 msgTime 去重,同一日期保留 createdAt 最新的一条 + */ + private List deduplicateByMsgTime(List list) { + if (list == null || list.isEmpty()) { + return list; + } + Map map = new LinkedHashMap<>(); + for (DailySummaryVO vo : list) { + String key = vo.getMsgTime(); + if (key == null) { + key = ""; + } + DailySummaryVO exist = map.get(key); + if (exist == null || newer(vo, exist)) { + map.put(key, vo); + } + } + return new ArrayList<>(map.values()); + } + + private boolean newer(DailySummaryVO a, DailySummaryVO b) { + if (a.getCreatedAt() == null) return false; + if (b.getCreatedAt() == null) return true; + return a.getCreatedAt() > b.getCreatedAt(); + } + private T getOrDefault(List list, int index) { if (list == null || index < 0 || index >= list.size()) { return null; diff --git a/backend/pom.xml b/backend/pom.xml index 5be3afb..70b7de4 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -27,9 +27,9 @@ - 1.8 - 21 - 21 + 17 + 17 + 17 UTF-8 2.7.18 diff --git a/frontend/sidebar/nginx.conf b/frontend/sidebar/nginx.conf index 3599b59..50602f7 100644 --- a/frontend/sidebar/nginx.conf +++ b/frontend/sidebar/nginx.conf @@ -12,6 +12,11 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # 每日总结独立页面 + location /summary { + try_files /summary.html =404; + } + # 静态资源 location / { try_files $uri $uri/ /index.html; diff --git a/frontend/sidebar/src/SummaryApp.tsx b/frontend/sidebar/src/SummaryApp.tsx new file mode 100644 index 0000000..e3edf9a --- /dev/null +++ b/frontend/sidebar/src/SummaryApp.tsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from 'react' +import { SpinLoading, Empty, Toast } from 'antd-mobile' + +const CORPID = 'wwd483c2fba24ae30a' +const AGENTID = '1000021' +const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary.html') + +interface SummaryItem { + id?: number + msgTime?: string + msgType?: number + fromUser?: string + toUser?: string + roomId?: string + messageContext?: string + summary?: string + createdAt?: number + staffId?: string + staffName?: string +} + +function SummaryApp() { + const [loading, setLoading] = useState(true) + const [list, setList] = useState([]) + const [error, setError] = useState('') + const [needLogin, setNeedLogin] = useState(false) + + useEffect(() => { + const init = async () => { + const urlParams = new URLSearchParams(window.location.search) + const code = urlParams.get('code') + + // 1. URL 中有 code → 先登录 + if (code) { + const cleanUrl = window.location.pathname + window.location.hash + window.history.replaceState({}, document.title, cleanUrl) + const ok = await tryCodeLogin(code) + if (!ok) { + setLoading(false) + setNeedLogin(true) + setError('登录失败,请重试') + return + } + } + + // 2. 检查员工登录态 + const staffId = urlParams.get('staffId') || localStorage.getItem('staff_user_id') || '' + if (!staffId) { + setLoading(false) + setNeedLogin(true) + setError('未获取到员工ID,请先登录') + return + } + + // 3. 加载每日总结 + await loadSummary(staffId) + } + + init() + }, []) + + const noCacheFetch = async (url: string, options?: RequestInit) => { + const sep = url.includes('?') ? '&' : '?' + const nocacheUrl = `${url}${sep}_t=${Date.now()}` + return fetch(nocacheUrl, { + ...options, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + ...options?.headers + } + }) + } + + const tryCodeLogin = async (code: string): Promise => { + try { + const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' }) + const data = await res.json() + if (data.code === 0 && data.data) { + const info = data.data + localStorage.setItem('staff_token', info.token) + localStorage.setItem('staff_user_id', info.userId) + localStorage.setItem('staff_user_name', info.userName || '') + localStorage.setItem('staff_avatar', info.avatar || '') + localStorage.setItem('staff_role', info.role || 'SEAT') + console.log('OAuth 登录成功:', info.userId, info.userName) + return true + } + console.error('OAuth login failed:', data.message) + } catch (e) { + console.error('Code login error:', e) + } + return false + } + + const redirectToWeComOAuth = () => { + const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${CORPID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=snsapi_base&agentid=${AGENTID}&state=summary#wechat_redirect` + console.log('跳转企微 OAuth:', oauthUrl) + window.location.href = oauthUrl + } + + const initWxConfigAndGetCustomerId = (): Promise => { + return new Promise((resolve) => { + const wx = (window as any).wx + if (!wx || !wx.config) { + console.log('非企微环境,跳过 JS-SDK') + resolve('') + return + } + + const currentUrl = window.location.href.split('#')[0] + + noCacheFetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`) + .then(res => res.json()) + .then(corpData => { + if (corpData.code !== 0 || !corpData.data) { + resolve('') + return + } + + const cfg = corpData.data + wx.config({ + beta: true, + debug: false, + appId: cfg.corpId, + timestamp: cfg.timestamp, + nonceStr: cfg.nonceStr, + signature: cfg.signature, + jsApiList: ['agentConfig', 'getContext', 'getCurExternalContact'] + }) + + wx.ready(() => { + noCacheFetch(`/api/v1/auth/signature?type=agent&agentId=${AGENTID}&url=${encodeURIComponent(currentUrl)}`) + .then(res => res.json()) + .then(agentData => { + if (agentData.code !== 0 || !agentData.data) { + resolve('') + return + } + + const ac = agentData.data + wx.agentConfig({ + corpid: ac.corpId, + agentid: ac.agentId, + timestamp: ac.timestamp, + nonceStr: ac.nonceStr, + signature: ac.signature, + jsApiList: ['getContext', 'getCurExternalContact'], + success: () => { + wx.invoke('getCurExternalContact', {}, (res: any) => { + if (res.err_msg === 'getCurExternalContact:ok' && res.userId) { + console.log('JS-SDK 获取客户ID:', res.userId) + resolve(res.userId) + } else { + console.warn('getCurExternalContact failed:', res) + resolve('') + } + }) + }, + fail: (err: any) => { + console.warn('agentConfig fail:', err) + resolve('') + } + }) + }) + .catch((e: any) => { + console.error('agentConfig fetch error:', e) + resolve('') + }) + }) + + wx.error((err: any) => { + console.error('wx.config error:', err) + resolve('') + }) + }) + .catch((e: any) => { + console.error('initWxConfig error:', e) + resolve('') + }) + }) + } + + const loadSummary = async (staffId: string) => { + const urlParams = new URLSearchParams(window.location.search) + let customerId = urlParams.get('customerId') || '' + + // 优先通过企微 JS-SDK 获取当前客户ID + const sdkCustomerId = await initWxConfigAndGetCustomerId() + if (sdkCustomerId) { + customerId = sdkCustomerId + localStorage.setItem('current_customer_id', sdkCustomerId) + } else { + customerId = customerId || localStorage.getItem('current_customer_id') || '' + } + + if (!customerId) { + setLoading(false) + setError('缺少客户ID参数') + Toast.show({ content: '缺少客户ID参数', icon: 'fail' }) + return + } + + try { + const res = await fetch( + `/api/v1/archive/daily-summary/all?customerId=${encodeURIComponent(customerId)}&staffId=${encodeURIComponent(staffId)}&_t=${Date.now()}`, + { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache' + } + } + ) + const data = await res.json() + if (data.code === 0 && Array.isArray(data.data)) { + setList(data.data) + } else { + const msg = data.message || '加载失败' + setError(msg) + Toast.show({ content: msg, icon: 'fail' }) + } + } catch (e) { + console.error('加载每日总结失败:', e) + setError('网络错误') + Toast.show({ content: '网络错误', icon: 'fail' }) + } finally { + setLoading(false) + } + } + + if (loading) { + return ( +
+ +
加载每日总结中...
+
+ ) + } + + if (needLogin) { + return ( +
+
{error || '未获取到员工ID,请先登录'}
+ +
+ ) + } + + if (error) { + return ( +
+
{error}
+
+ ) + } + + if (list.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+ 📋 每日总结 +
+
+ 共 {list.length} 条 · 按时间倒序 +
+
+ + {list.map((item, index) => ( +
+
+
+ {item.msgTime || formatDate(item.createdAt)} +
+
+ {item.staffName || item.staffId || '未知老师'} +
+
+
+ {item.summary || item.messageContext || '无内容'} +
+
+ ))} +
+ ) +} + +function formatDate(timestamp?: number) { + if (!timestamp) return '-' + const date = new Date(timestamp) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +export default SummaryApp diff --git a/frontend/sidebar/src/summary.tsx b/frontend/sidebar/src/summary.tsx new file mode 100644 index 0000000..ab2a026 --- /dev/null +++ b/frontend/sidebar/src/summary.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import SummaryApp from './SummaryApp' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/sidebar/summary.html b/frontend/sidebar/summary.html new file mode 100644 index 0000000..d1f4326 --- /dev/null +++ b/frontend/sidebar/summary.html @@ -0,0 +1,16 @@ + + + + + + + + + 每日总结 + + + +
+ + + diff --git a/frontend/sidebar/vite.config.ts b/frontend/sidebar/vite.config.ts index 28adf62..7d645b4 100644 --- a/frontend/sidebar/vite.config.ts +++ b/frontend/sidebar/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { resolve } from 'path' export default defineConfig({ base: '/sidebar/', @@ -15,6 +16,12 @@ export default defineConfig({ }, build: { outDir: 'dist', - sourcemap: true + sourcemap: true, + rollupOptions: { + input: { + index: resolve(__dirname, 'index.html'), + summary: resolve(__dirname, 'summary.html') + } + } } })