feat: 添加每日总结独立页面与后端接口
1. 新增每日总结前端页面,支持企微授权登录、客户对话总结展示 2. 后端新增查询全量客户总结接口,优化去重与排序逻辑 3. 配置Nginx路由与Vite多入口打包,适配Java版本降级
This commit is contained in:
parent
d55ae221c2
commit
4ba2109f8d
@ -37,4 +37,18 @@ public class DailySummaryController {
|
||||
List<DailySummaryVO> list = dailySummaryService.queryDailySummary(customerId, date);
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询客户所有日期的消息总结,按时间倒序
|
||||
*
|
||||
* @param customerId 客户 ID(企微外部联系人 userid),不传则查该销售所有客户的总结
|
||||
* @param staffId 销售老师企微 userId
|
||||
*/
|
||||
@GetMapping("/daily-summary/all")
|
||||
public Result<List<DailySummaryVO>> allDailySummary(
|
||||
@RequestParam(required = false) String customerId,
|
||||
@RequestParam String staffId) {
|
||||
List<DailySummaryVO> list = dailySummaryService.queryAllDailySummary(customerId, staffId);
|
||||
return Result.success(list);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<DailySummaryVO> 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<DailySummaryVO> 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<DailySummaryVO> 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<DailySummaryVO> deduplicateByMsgTime(List<DailySummaryVO> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return list;
|
||||
}
|
||||
Map<String, DailySummaryVO> 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> T getOrDefault(List<T> list, int index) {
|
||||
if (list == null || index < 0 || index >= list.size()) {
|
||||
return null;
|
||||
|
||||
@ -27,9 +27,9 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<spring-boot.version>2.7.18</spring-boot.version>
|
||||
|
||||
@ -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;
|
||||
|
||||
327
frontend/sidebar/src/SummaryApp.tsx
Normal file
327
frontend/sidebar/src/SummaryApp.tsx
Normal file
@ -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<SummaryItem[]>([])
|
||||
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<boolean> => {
|
||||
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<string> => {
|
||||
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 (
|
||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
||||
<SpinLoading style={{ '--size': '32px' } as any} />
|
||||
<div style={{ marginTop: 16, color: '#999', fontSize: 14 }}>加载每日总结中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (needLogin) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 20 }}>
|
||||
<div style={{ fontSize: 14, color: '#999', marginBottom: 24 }}>{error || '未获取到员工ID,请先登录'}</div>
|
||||
<button
|
||||
onClick={redirectToWeComOAuth}
|
||||
style={{
|
||||
padding: '10px 32px',
|
||||
background: '#1677ff',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontSize: 15,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
企微一键登录
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
||||
<div style={{ color: '#999', fontSize: 14 }}>{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 60 }}>
|
||||
<Empty description="暂无每日总结" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 12,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: '#333' }}>
|
||||
📋 每日总结
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
共 {list.length} 条 · 按时间倒序
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list.map((item, index) => (
|
||||
<div key={item.id || index} style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 12,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 13, color: '#1677ff', fontWeight: 500 }}>
|
||||
{item.msgTime || formatDate(item.createdAt)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
{item.staffName || item.staffId || '未知老师'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{item.summary || item.messageContext || '无内容'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
10
frontend/sidebar/src/summary.tsx
Normal file
10
frontend/sidebar/src/summary.tsx
Normal file
@ -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(
|
||||
<React.StrictMode>
|
||||
<SummaryApp />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
16
frontend/sidebar/summary.html
Normal file
16
frontend/sidebar/summary.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>每日总结</title>
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/summary.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user