添加用户消息记录接口;
This commit is contained in:
@@ -1,2 +1,9 @@
|
||||
# API 基础地址
|
||||
VITE_API_BASE_URL=http://chat.nexjs.com/api
|
||||
VITE_API_BASE_URL=http://mp.yizhezhe.com/api
|
||||
|
||||
# 企业微信配置
|
||||
VITE_WECOM_CORP_ID=your_corp_id_here
|
||||
VITE_WECOM_AGENT_ID=your_agent_id_here
|
||||
|
||||
# 用户标签属性接口基础地址
|
||||
VITE_CUSTOMER_API_BASE=https://admin.doouba.cn/api
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>兜爸心选 - 企业微信智能客服</title>
|
||||
<meta name="description" content="兜爸心选企业微信智能客服系统,集成企业微信JSSDK,提供智能客服和消息发送功能" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wecom/jssdk": "^2.3.3",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"daisyui": "^5.5.5",
|
||||
|
||||
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@wecom/jssdk':
|
||||
specifier: ^2.3.3
|
||||
version: 2.3.3
|
||||
autoprefixer:
|
||||
specifier: ^10.4.22
|
||||
version: 10.4.22(postcss@8.5.6)
|
||||
@@ -541,6 +544,9 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@wecom/jssdk@2.3.3':
|
||||
resolution: {integrity: sha512-bazYSD39MoiaUV1xMVxrOPsTFa37ARY0xXAqDgth4kU/xk8n61o1YiAcdn4dPM+kBnJKOfukFYPjOPQv8K5l+w==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -1805,6 +1811,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@wecom/jssdk@2.3.3': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
@@ -1,301 +1,598 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://chat.nexjs.com/api'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useWeCom } from './hooks/useWeCom';
|
||||
import { workflowService, customerService } from './services/wecomService';
|
||||
import WorkflowProgress from './components/WorkflowProgress';
|
||||
import { getStepNames } from './constants/workflowSteps';
|
||||
import { getSceneDescription } from './constants/wecomScenes';
|
||||
|
||||
function App() {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [inputMessage, setInputMessage] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState(null)
|
||||
const [roleConfig, setRoleConfig] = useState({
|
||||
name: "兜兜",
|
||||
description: "你是兜兜,1989年出生,有一个5岁的女儿(2018年出生)。你是一位对用户负责、选品严格的宝妈客服。"
|
||||
})
|
||||
const [isEditingRole, setIsEditingRole] = useState(false)
|
||||
const messagesEndRef = useRef(null)
|
||||
// 企业微信 Hook
|
||||
const weCom = useWeCom();
|
||||
|
||||
// 聊天状态
|
||||
const [messages, setMessages] = useState([]);
|
||||
|
||||
// 工作流状态
|
||||
const [workflowStatus, setWorkflowStatus] = useState({
|
||||
isRunning: false,
|
||||
currentStep: null,
|
||||
progress: {},
|
||||
result: null,
|
||||
error: null
|
||||
});
|
||||
|
||||
// 执行计数
|
||||
const [executionCount, setExecutionCount] = useState(0);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const hasAutoExecutedRef = useRef(false);
|
||||
|
||||
// 自动滚动到最新消息
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 获取当前角色配置
|
||||
useEffect(() => {
|
||||
fetchRoleConfig()
|
||||
}, [])
|
||||
|
||||
const fetchRoleConfig = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/role`)
|
||||
setRoleConfig(response.data)
|
||||
} catch (error) {
|
||||
console.error('获取角色配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const updateRoleConfig = async (newConfig) => {
|
||||
try {
|
||||
const response = await axios.put(`${API_BASE_URL}/role`, newConfig)
|
||||
setRoleConfig(response.data.role_config)
|
||||
setIsEditingRole(false)
|
||||
} catch (error) {
|
||||
console.error('更新角色配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputMessage.trim() || isLoading) return
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: inputMessage,
|
||||
// 添加系统消息
|
||||
const addSystemMessage = useCallback((content) => {
|
||||
const systemMessage = {
|
||||
role: 'system',
|
||||
content,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
};
|
||||
setMessages(prev => [...prev, systemMessage]);
|
||||
}, []);
|
||||
|
||||
// 添加助手消息
|
||||
const addAssistantMessage = useCallback((content) => {
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
}, []);
|
||||
|
||||
// 处理工作流进度更新
|
||||
const handleWorkflowProgress = useCallback((stepId, status, data) => {
|
||||
// 添加进度消息到聊天
|
||||
const stepNames = getStepNames();
|
||||
|
||||
if (status === 'loading') {
|
||||
if (stepId !== 'auth' && stepId !== 'register' && stepId !== 'customer') { //初始化步骤不显示加载消息
|
||||
addSystemMessage(`📌 正在${stepNames[stepId]}...`);
|
||||
}
|
||||
|
||||
} else if (status === 'success') {
|
||||
addSystemMessage(`✅ ${stepNames[stepId]}成功!`);
|
||||
|
||||
// 如果是标签步骤成功,单独显示标签消息
|
||||
if (stepId === 'tags' && data && data.tags && data.tags.length > 0) {
|
||||
const tagNames = data.tags.map(tag => tag.TagName).join(', ');
|
||||
addAssistantMessage(`📋 客户标签: ${tagNames}`);
|
||||
}
|
||||
|
||||
// 如果是历史步骤成功,显示历史会话内容
|
||||
if (stepId === 'history') {
|
||||
// 格式化并显示历史会话
|
||||
if (data && data.messages && data.messages.length > 0) {
|
||||
// 获取客户ID(从之前的步骤中)
|
||||
const customerId = workflowStatus.progress.customer?.data?.externalUserId;
|
||||
// 格式化历史会话显示
|
||||
const formattedHistory = `📜 最近对话记录(${Math.min(data.messages.length, 5)}条):\n\n` +
|
||||
data.messages
|
||||
.slice(0, 5) // 只显示最近5条
|
||||
.map((msg, index) => {
|
||||
// 确定发送者角色
|
||||
const role = msg.from_user === customerId ? '客户' : '客服';
|
||||
// 获取消息内容
|
||||
const content = msg.text?.content || msg.content || '无文本内容';
|
||||
// 简化内容,避免过长
|
||||
const simplifiedContent = content.length > 80
|
||||
? content.substring(0, 80) + '...'
|
||||
: content;
|
||||
return `• ${role}:${simplifiedContent}`;
|
||||
})
|
||||
.join('\n');
|
||||
addAssistantMessage(formattedHistory);
|
||||
} else {
|
||||
addAssistantMessage('📜 暂无历史会话记录');
|
||||
}
|
||||
|
||||
// 更新工作流状态
|
||||
setWorkflowStatus(prev => ({
|
||||
...prev,
|
||||
currentStep: stepId,
|
||||
progress: {
|
||||
...prev.progress,
|
||||
[stepId]: {
|
||||
status,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}));
|
||||
return; // 提前返回
|
||||
}
|
||||
} else if (status === 'error') {
|
||||
addSystemMessage(`❌ ${stepNames[stepId]}失败: ${data?.message || '未知错误'}`);
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
// 使用函数式更新,避免依赖 workflowStatus.progress
|
||||
setWorkflowStatus(prev => ({
|
||||
...prev,
|
||||
currentStep: stepId,
|
||||
progress: {
|
||||
...prev.progress,
|
||||
[stepId]: {
|
||||
status,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}));
|
||||
}, [addSystemMessage, addAssistantMessage, workflowStatus.progress.customer?.data?.externalUserId]);
|
||||
|
||||
// 执行智能回复工作流(不发送消息)
|
||||
const executeSmartReplyWorkflow = useCallback(async () => {
|
||||
setMessages([]); // 清空消息
|
||||
// 使用函数式更新检查是否正在运行
|
||||
setWorkflowStatus(prev => {
|
||||
if (prev.isRunning) return prev;
|
||||
return {
|
||||
isRunning: true,
|
||||
currentStep: null,
|
||||
progress: {},
|
||||
result: null,
|
||||
error: null
|
||||
};
|
||||
});
|
||||
|
||||
setExecutionCount(prev => prev + 1);
|
||||
addSystemMessage('📌 开始执行智能回复工作流...');
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/chat`, {
|
||||
message: inputMessage,
|
||||
conversation_id: conversationId
|
||||
})
|
||||
// 使用不发送消息的工作流版本
|
||||
const result = await workflowService.executeWorkflowWithoutSend(weCom, handleWorkflowProgress);
|
||||
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
content: response.data.response,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
setWorkflowStatus(prev => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
result,
|
||||
error: null
|
||||
}));
|
||||
|
||||
// 显示生成的回复
|
||||
if (result.generatedMessage) {
|
||||
// 处理 generatedMessage 可能是对象的情况
|
||||
let messageContent = result.generatedMessage;
|
||||
if (typeof messageContent === 'object') {
|
||||
// 如果是发送步骤返回的对象,提取 message 字段
|
||||
if (messageContent.message) {
|
||||
messageContent = messageContent.message;
|
||||
} else if (messageContent.errMsg) {
|
||||
messageContent = `发送消息失败: ${messageContent.errMsg}`;
|
||||
} else {
|
||||
// 转换为字符串作为后备方案
|
||||
messageContent = JSON.stringify(messageContent);
|
||||
}
|
||||
}
|
||||
addAssistantMessage(`${messageContent}`);
|
||||
}
|
||||
|
||||
// 根据工作流结果和JSSDK状态显示不同的提示语
|
||||
if (result.success && weCom.sdkReady && !weCom.sdkError) {
|
||||
// 工作流成功且JSSDK正常
|
||||
addSystemMessage('✅ 工作流执行完成!已生成智能回复,请点击下方按钮发送到企业微信。');
|
||||
} else if (result.success && (weCom.sdkError || !weCom.sdkReady)) {
|
||||
// 工作流成功但JSSDK有问题
|
||||
addSystemMessage('⚠️ 工作流执行完成,但企业微信JSSDK状态异常。生成的回复可能无法发送到企业微信。');
|
||||
} else {
|
||||
// 工作流失败
|
||||
addSystemMessage('❌ 工作流执行完成,但遇到问题无法生成个性化回复。');
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage])
|
||||
setConversationId(response.data.conversation_id)
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
const errorMessage = {
|
||||
role: 'assistant',
|
||||
content: '抱歉,兜兜暂时无法回复,请稍后再试~ 😔',
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
console.error('工作流执行失败:', error);
|
||||
|
||||
// 检查是否是获取客户ID相关的错误
|
||||
let errorMessage = error.message;
|
||||
let userFriendlyMessage = error.message;
|
||||
|
||||
if (errorMessage.includes('getCurExternalContact') || errorMessage.includes('entry 值为') || errorMessage.includes('不支持获取客户ID')) {
|
||||
userFriendlyMessage = `获取客户ID失败: 当前页面不支持获取客户信息\n\n💡 请确保在企业微信的以下页面中打开此应用:\n• 外部联系人聊天页面\n• 客户详情页面\n• 单聊工具栏页面`;
|
||||
} else if (errorMessage.includes('JSSDK') || errorMessage.includes('register') || errorMessage.includes('SDK')) {
|
||||
userFriendlyMessage = `企业微信JSSDK错误: ${errorMessage}\n\n请检查企业微信配置或刷新页面重试。`;
|
||||
} else if (errorMessage.includes('超时')) {
|
||||
userFriendlyMessage = `操作超时: ${errorMessage}\n\n可能是网络问题或企业微信JSSDK未正确响应。`;
|
||||
}
|
||||
setMessages(prev => [...prev, errorMessage])
|
||||
|
||||
setWorkflowStatus(prev => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
error: errorMessage
|
||||
}));
|
||||
|
||||
addSystemMessage(`❌工作流执行失败: ${userFriendlyMessage}`);
|
||||
}
|
||||
}, [weCom, handleWorkflowProgress]);
|
||||
|
||||
// 发送智能回复到企业微信
|
||||
const sendSmartReply = useCallback(async () => {
|
||||
if (workflowStatus.isRunning || !weCom.sdkReady) return;
|
||||
|
||||
setWorkflowStatus(prev => ({
|
||||
...prev,
|
||||
isRunning: true,
|
||||
error: null
|
||||
}));
|
||||
|
||||
addSystemMessage('📌开始发送智能回复到企业微信...');
|
||||
|
||||
try {
|
||||
// 这里应该调用发送消息到企业微信的API
|
||||
// 暂时使用模拟发送
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 如果有生成的回复,发送它
|
||||
let replyContent = '您好!我是兜爸心选的智能客服,有什么可以帮您的吗?';
|
||||
if (workflowStatus.result?.generatedMessage) {
|
||||
const generatedMessage = workflowStatus.result.generatedMessage;
|
||||
if (typeof generatedMessage === 'object' && generatedMessage.message) {
|
||||
replyContent = generatedMessage.message;
|
||||
} else if (typeof generatedMessage === 'string') {
|
||||
replyContent = generatedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// 调用企业微信发送消息
|
||||
const sendResult = await weCom.sendChatMessage(replyContent);
|
||||
|
||||
addSystemMessage('✅ 智能回复已成功发送到企业微信!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送智能回复失败:', error);
|
||||
addSystemMessage(`❌发送智能回复失败: ${error.message}`);
|
||||
|
||||
setWorkflowStatus(prev => ({
|
||||
...prev,
|
||||
error: error.message
|
||||
}));
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setWorkflowStatus(prev => ({
|
||||
...prev,
|
||||
isRunning: false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [workflowStatus.isRunning, workflowStatus.result, weCom]);
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
|
||||
// 检查企业微信配置和首次自动执行
|
||||
useEffect(() => {
|
||||
if (!weCom.isConfigured) {
|
||||
addSystemMessage('⚠️ 企业微信配置缺失,请检查 .env 文件中的 VITE_WECOM_CORP_ID 和 VITE_WECOM_AGENT_ID');
|
||||
} else if (weCom.sdkError) {
|
||||
addSystemMessage(`⚠️ 企业微信 SDK 错误: ${weCom.sdkError.message}`);
|
||||
} else if (weCom.sdkReady && !hasAutoExecutedRef.current) {
|
||||
addSystemMessage(`✅ 企业微信 SDK 已就绪`);
|
||||
|
||||
// 首次自动执行工作流(仅一次)
|
||||
hasAutoExecutedRef.current = true;
|
||||
executeSmartReplyWorkflow();
|
||||
}
|
||||
}
|
||||
}, [weCom.isConfigured, weCom.sdkReady, weCom.sdkError]);
|
||||
|
||||
const clearConversation = () => {
|
||||
setMessages([])
|
||||
setConversationId(null)
|
||||
}
|
||||
// 移动端侧边栏状态
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
// 页面场景状态
|
||||
const [pageContext, setPageContext] = useState(null);
|
||||
|
||||
// 自动获取页面场景(不显示在聊天中)
|
||||
useEffect(() => {
|
||||
const fetchPageContext = async () => {
|
||||
if (weCom.sdkReady) {
|
||||
try {
|
||||
console.log('开始自动获取页面场景...');
|
||||
const context = await weCom.getContext();
|
||||
console.log('获取到的页面场景:', context);
|
||||
setPageContext(context);
|
||||
// 不显示在聊天中,只在工作流执行时显示
|
||||
} catch (error) {
|
||||
console.error('自动获取页面场景失败:', error);
|
||||
// 不显示错误在聊天中
|
||||
}
|
||||
} else {
|
||||
console.log('企业微信SDK未就绪,无法获取页面场景');
|
||||
}
|
||||
};
|
||||
|
||||
fetchPageContext();
|
||||
}, [weCom.sdkReady, weCom.getContext, weCom]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-blue-600 text-white shadow-lg">
|
||||
<header className="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
className="lg:hidden w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/20"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 text-xl">🤖</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">兜爸 AI 聊天助手</h1>
|
||||
<p className="text-sm opacity-90">我是{roleConfig.name},很高兴为您服务</p>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-xl font-bold">兜爸心选 - 企业微信智能客服</h1>
|
||||
<p className="text-sm opacity-90">
|
||||
自动获取客户消息会话与标签,生成个性化智能回复
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<h1 className="text-lg font-bold">智能客服</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="px-3 py-1 bg-white/20 rounded text-sm hover:bg-white/30 transition-colors"
|
||||
onClick={() => setIsEditingRole(true)}
|
||||
className="hidden sm:inline px-3 py-1 bg-white/20 rounded text-sm hover:bg-white/30 transition-colors"
|
||||
onClick={executeSmartReplyWorkflow}
|
||||
disabled={workflowStatus.isRunning || !weCom.sdkReady}
|
||||
>
|
||||
编辑角色
|
||||
{workflowStatus.isRunning ? '执行中...' : '执行工作流'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="px-3 py-1 bg-white/20 rounded text-sm hover:bg-white/30 transition-colors"
|
||||
onClick={clearConversation}
|
||||
>
|
||||
新对话
|
||||
</button>
|
||||
{/* 移动端操作按钮 */}
|
||||
<div className="sm:hidden flex space-x-1">
|
||||
<button
|
||||
className="w-8 h-8 flex items-center justify-center bg-white/20 rounded hover:bg-white/30 transition-colors"
|
||||
onClick={executeSmartReplyWorkflow}
|
||||
disabled={workflowStatus.isRunning || !weCom.sdkReady}
|
||||
title="执行工作流"
|
||||
>
|
||||
{workflowStatus.isRunning ? '⏳' : '🔄️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 聊天区域 */}
|
||||
<main className="flex-1" style={{ height: 'calc(100vh - 80px - 120px)' }}>
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
{messages.length === 0 ? (
|
||||
// 欢迎页面
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white text-3xl">👋</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-3">欢迎使用, 兜爸 AI 聊天助手</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
我是{roleConfig.name},一位亲切温暖的宝妈客服。有任何关于育儿、选品、团购的问题,都可以随时问我哦~
|
||||
</p>
|
||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
💡 提示:开始对话吧,我会以亲切温暖的风格为您解答问题
|
||||
</p>
|
||||
</div>
|
||||
{/* 状态栏 */}
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="container mx-auto px-4 py-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between text-sm space-y-2 sm:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${weCom.sdkReady ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-gray-700">
|
||||
{weCom.sdkReady ? '企业微信 SDK 已就绪' : '企业微信 SDK 未就绪'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${workflowStatus.isRunning ? 'bg-yellow-500' : workflowStatus.result ? 'bg-green-500' : 'bg-gray-300'}`}></div>
|
||||
<span className="text-gray-700">
|
||||
{workflowStatus.isRunning ? '工作流执行中...' : workflowStatus.result ? '工作流已完成' : '工作流待执行'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 聊天消息列表
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[70%] rounded-2xl px-4 py-3 ${
|
||||
message.role === 'user'
|
||||
? 'bg-blue-600 text-white rounded-br-none'
|
||||
: 'bg-white border border-gray-200 rounded-bl-none shadow-sm'
|
||||
}`}>
|
||||
<div className="text-sm leading-relaxed">
|
||||
{message.content}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 ${
|
||||
message.role === 'user' ? 'text-blue-100' : 'text-gray-500'
|
||||
}`}>
|
||||
{message.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl rounded-bl-none px-4 py-3 shadow-sm">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-500">
|
||||
执行次数: {executionCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<footer className="border-t border-gray-200 bg-white p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-stretch border border-gray-300 rounded-lg bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-200 transition-colors overflow-hidden">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
className="w-full h-full px-4 py-3 resize-none border-none focus:outline-none focus:ring-0 bg-transparent"
|
||||
placeholder={`向${roleConfig.name}发送消息... (按 Enter 发送)`}
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{/* 移动端侧边栏遮罩 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex h-[calc(100vh-240px)]">
|
||||
{/* 聊天区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto p-2 sm:p-4">
|
||||
{messages.length === 0 ? (
|
||||
// 欢迎页面
|
||||
<div className="flex items-center justify-center h-full px-2">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white text-2xl sm:text-3xl">👋</span>
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-3">欢迎使用兜爸心选智能客服</h2>
|
||||
<p className="text-sm sm:text-base text-gray-600 mb-4">
|
||||
本系统集成了企业微信智能回复系统,可以自动获取客户历史会话、标签属性,并生成个性化的智能回复。
|
||||
</p>
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 border border-gray-200">
|
||||
<p className="text-xs sm:text-sm text-gray-500">
|
||||
💡 系统将自动执行以下流程:
|
||||
<ol className="mt-2 space-y-1 text-left pl-4">
|
||||
<li className="text-xs sm:text-sm">1. 获取企业微信认证信息</li>
|
||||
<li className="text-xs sm:text-sm">2. 初始化智能回复系统</li>
|
||||
<li className="text-xs sm:text-sm">3. 获取当前客户ID</li>
|
||||
<li className="text-xs sm:text-sm">4. 查询客户标签属性</li>
|
||||
<li className="text-xs sm:text-sm">5. 生成个性化回复</li>
|
||||
</ol>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 聊天消息列表
|
||||
<div className="max-w-4xl mx-auto space-y-3 sm:space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} ${
|
||||
message.role === 'system' ? 'justify-center' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`max-w-[85%] sm:max-w-[70%] rounded-2xl px-3 py-2 sm:px-4 sm:py-3 ${
|
||||
message.role === 'user'
|
||||
? 'bg-blue-600 text-white rounded-br-none'
|
||||
: message.role === 'system'
|
||||
? 'bg-gray-100 text-gray-700 border border-gray-200'
|
||||
: 'bg-white border border-gray-200 rounded-bl-none shadow-sm'
|
||||
}`}>
|
||||
<div className="text-sm leading-relaxed break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
{/* <div className={`text-xs mt-1 ${
|
||||
message.role === 'user' ? 'text-blue-100' :
|
||||
message.role === 'system' ? 'text-gray-500' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{message.timestamp}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{workflowStatus.isRunning && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl rounded-bl-none px-4 py-3 shadow-sm">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工作流状态面板 - 桌面端和移动端抽屉 */}
|
||||
<div className={`hidden lg:block w-80 border-l border-gray-200 bg-white overflow-y-auto ${
|
||||
sidebarOpen ? '!block fixed right-0 top-0 h-full w-80 z-50 shadow-2xl' : ''
|
||||
}`}>
|
||||
<div className="p-4 h-full overflow-y-auto">
|
||||
{/* 移动端侧边栏头部 */}
|
||||
{sidebarOpen && (
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
|
||||
<h3 className="font-bold text-gray-800">工作流状态</h3>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorkflowProgress
|
||||
progress={workflowStatus.progress}
|
||||
includeSend={false}
|
||||
title={sidebarOpen ? '' : '工作流状态'}
|
||||
/>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{workflowStatus.error && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="font-medium text-red-800 mb-1">工作流错误</div>
|
||||
<div className="text-sm text-red-600">{workflowStatus.error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 企业微信状态 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<h4 className="font-medium text-gray-700 mb-2">企业微信状态</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">SDK 状态:</span>
|
||||
<span className={weCom.sdkReady ? 'text-green-600' : 'text-red-600'}>
|
||||
{weCom.sdkReady ? '已就绪' : '未就绪'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 直接显示页面场景 */}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">页面场景:</span>
|
||||
<span className="text-blue-600">
|
||||
{pageContext ? (
|
||||
pageContext.entry ? getSceneDescription(pageContext.entry) : '未知场景'
|
||||
) : (
|
||||
weCom.sdkReady ? '获取中...' : 'SDK未就绪'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{weCom.sdkError && (
|
||||
<div className="text-red-600 text-xs break-words">
|
||||
错误: {weCom.sdkError.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l border-gray-200 flex items-center">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 智能回复按钮区域 */}
|
||||
{workflowStatus.result && (
|
||||
<footer className="border-t border-gray-200 bg-white p-3 sm:p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex flex-col items-center space-y-2 sm:space-y-3">
|
||||
<div className="text-xs sm:text-sm text-gray-600 text-center px-2">
|
||||
{workflowStatus.result?.generatedMessage ? (
|
||||
<span>已生成智能回复,点击下方按钮发送到企业微信</span>
|
||||
) : (
|
||||
<span>执行工作流以生成智能回复</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="bg-blue-600 text-white px-6 h-full font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors min-w-[6rem]"
|
||||
onClick={sendMessage}
|
||||
disabled={!inputMessage.trim() || isLoading}
|
||||
className="w-full max-w-sm bg-gradient-to-r from-green-500 to-emerald-600 text-white px-4 sm:px-8 py-2 sm:py-3 rounded-lg font-medium hover:from-green-600 hover:to-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
||||
onClick={sendSmartReply}
|
||||
disabled={workflowStatus.isRunning || !weCom.sdkReady || !workflowStatus.result?.generatedMessage}
|
||||
>
|
||||
{isLoading ? (
|
||||
{workflowStatus.isRunning ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
|
||||
发送中
|
||||
<span className="w-3 h-3 sm:w-4 sm:h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
|
||||
发送中...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center space-x-2">
|
||||
<span>发送</span>
|
||||
<span className="text-xs opacity-70">↵</span>
|
||||
<span className="flex items-center justify-center space-x-1 sm:space-x-2">
|
||||
<span className="text-sm sm:text-base">📤 发送智能回复到企业微信</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* 角色编辑模态框 */}
|
||||
{isEditingRole && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-800">编辑角色配置</h3>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setIsEditingRole(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
角色名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
value={roleConfig.name}
|
||||
onChange={(e) => setRoleConfig(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
角色描述
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 h-32 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
value={roleConfig.description}
|
||||
onChange={(e) => setRoleConfig(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
<div className="text-xs text-gray-500 text-center px-2">
|
||||
点击按钮将生成的智能回复发送到当前企业微信会话
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setIsEditingRole(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
onClick={() => updateRoleConfig(roleConfig)}
|
||||
>
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
77
frontend/src/components/WorkflowProgress.jsx
Normal file
77
frontend/src/components/WorkflowProgress.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { getStepIds, getStepNames, getStepStatusConfig } from '../constants/workflowSteps';
|
||||
|
||||
/**
|
||||
* 工作流进度组件
|
||||
* 显示工作流各个步骤的状态
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.progress 进度数据对象
|
||||
* @param {boolean} [props.includeSend=false] 是否包含发送步骤
|
||||
* @param {string} [props.title='工作流状态'] 组件标题
|
||||
* @param {string} [props.className=''] 额外的CSS类名
|
||||
*/
|
||||
const WorkflowProgress = ({
|
||||
progress = {},
|
||||
includeSend = false,
|
||||
title = '工作流状态',
|
||||
className = ''
|
||||
}) => {
|
||||
const stepIds = getStepIds(includeSend);
|
||||
const stepNames = getStepNames();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{title && <h3 className="font-bold text-gray-800 mb-4">{title}</h3>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{stepIds.map(stepId => {
|
||||
const stepInfo = progress[stepId];
|
||||
const statusConfig = stepInfo ? getStepStatusConfig(stepInfo.status) : getStepStatusConfig();
|
||||
|
||||
return (
|
||||
<div key={stepId} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-sm text-gray-700">{stepNames[stepId]}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusConfig.className}`}>
|
||||
{statusConfig.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stepInfo && stepInfo.data && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{stepId === 'context' && stepInfo.data.scene && (
|
||||
<div>场景: {stepInfo.data.scene}</div>
|
||||
)}
|
||||
{stepId === 'customer' && stepInfo.data.externalUserId && (
|
||||
<div>客户ID: {stepInfo.data.externalUserId}</div>
|
||||
)}
|
||||
{stepId === 'tags' && stepInfo.data.tags && (
|
||||
<div>标签数: {stepInfo.data.tags.length}</div>
|
||||
)}
|
||||
{stepId === 'history' && stepInfo.data && (
|
||||
<div>消息数: {stepInfo.data.messages?.length || 0}</div>
|
||||
)}
|
||||
{stepId === 'generate' && stepInfo.data && (
|
||||
<div className="truncate">回复已生成</div>
|
||||
)}
|
||||
{stepId === 'send' && stepInfo.data && (
|
||||
<div className="truncate">
|
||||
发送结果: {stepInfo.data.errCode === 0 ? '成功' : '失败'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepInfo && stepInfo.error && (
|
||||
<div className="text-xs text-red-600 mt-1">{stepInfo.error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowProgress;
|
||||
76
frontend/src/constants/wecomScenes.js
Normal file
76
frontend/src/constants/wecomScenes.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 企业微信页面场景类型映射
|
||||
* 将场景代码转换为友好的中文描述
|
||||
*/
|
||||
export const WECOM_SCENE_TYPES = {
|
||||
// 从联系人详情进入
|
||||
'contact_profile': '联系人详情',
|
||||
|
||||
// 从单聊会话的工具栏进入
|
||||
'single_chat_tools': '单聊工具栏',
|
||||
|
||||
// 从群聊会话的工具栏进入
|
||||
'group_chat_tools': '群聊工具栏',
|
||||
|
||||
// 从会话的聊天附件栏进入
|
||||
'chat_attachment': '聊天附件栏',
|
||||
|
||||
// 从微信客服的工具栏进入
|
||||
'single_kf_tools': '微信客服工具栏',
|
||||
|
||||
// 上下游单聊会话的工具栏
|
||||
'chain_single_chat_tools': '上下游单聊工具栏',
|
||||
|
||||
// 上下游群聊会话的工具栏
|
||||
'chain_group_chat_tools': '上下游群聊工具栏',
|
||||
|
||||
// 从内部群群看板进入
|
||||
'internal_group_chat_board': '内部群看板'
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取场景类型的中文描述
|
||||
* @param {string} sceneCode 场景代码
|
||||
* @returns {string} 中文描述,如果未知则返回原始代码
|
||||
*/
|
||||
export const getSceneDescription = (sceneCode) => {
|
||||
return WECOM_SCENE_TYPES[sceneCode] || sceneCode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取入口类型的中文描述
|
||||
* @param {string} entryCode 入口代码
|
||||
* @returns {string} 中文描述,如果未知则返回原始代码
|
||||
*/
|
||||
export const getEntryDescription = (entryCode) => {
|
||||
const entryMap = {
|
||||
'contact': '联系人',
|
||||
'single_chat': '单聊',
|
||||
'group_chat': '群聊',
|
||||
'chat': '聊天',
|
||||
'single_kf': '微信客服',
|
||||
'chain_single_chat': '上下游单聊',
|
||||
'chain_group_chat': '上下游群聊',
|
||||
'internal_group': '内部群'
|
||||
};
|
||||
|
||||
return entryMap[entryCode] || entryCode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化页面场景信息
|
||||
* @param {Object} context 页面场景对象
|
||||
* @returns {Object} 格式化后的场景信息
|
||||
*/
|
||||
export const formatPageContext = (context) => {
|
||||
if (!context) return null;
|
||||
|
||||
// 使用 scene 字段,如果不存在则使用 entry 字段作为后备
|
||||
const sceneCode = context.scene || context.entry;
|
||||
|
||||
return {
|
||||
...context,
|
||||
entryDisplay: getEntryDescription(context.entry),
|
||||
sceneDisplay: getSceneDescription(sceneCode)
|
||||
};
|
||||
};
|
||||
97
frontend/src/constants/workflowSteps.js
Normal file
97
frontend/src/constants/workflowSteps.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 工作流步骤常量
|
||||
* 用于统一管理工作流步骤的名称和配置
|
||||
*/
|
||||
|
||||
export const WORKFLOW_STEPS = {
|
||||
auth: {
|
||||
id: 'auth',
|
||||
name: '获取认证信息',
|
||||
description: '获取企业微信 Access Token 和 JSAPI Ticket'
|
||||
},
|
||||
register: {
|
||||
id: 'register',
|
||||
name: '注册企业微信SDK',
|
||||
description: '初始化智能回复SDK,准备生成个性化回复'
|
||||
},
|
||||
customer: {
|
||||
id: 'customer',
|
||||
name: '获取客户ID',
|
||||
description: '获取当前企业微信会话的客户ID'
|
||||
},
|
||||
tags: {
|
||||
id: 'tags',
|
||||
name: '获取客户标签',
|
||||
description: '根据客户ID查询客户标签属性'
|
||||
},
|
||||
history: {
|
||||
id: 'history',
|
||||
name: '获取会话历史',
|
||||
description: '获取客户的历史会话记录'
|
||||
},
|
||||
generate: {
|
||||
id: 'generate',
|
||||
name: '生成智能回复',
|
||||
description: '基于客户标签和历史会话生成个性化的智能回复'
|
||||
},
|
||||
send: {
|
||||
id: 'send',
|
||||
name: '发送消息到企业微信',
|
||||
description: '将生成的回复发送到当前企业微信会话'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取步骤名称映射
|
||||
* @returns {Object} 步骤ID到名称的映射
|
||||
*/
|
||||
export const getStepNames = () => {
|
||||
const names = {};
|
||||
Object.values(WORKFLOW_STEPS).forEach(step => {
|
||||
names[step.id] = step.name;
|
||||
});
|
||||
return names;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取步骤状态样式
|
||||
* @param {string} status 步骤状态
|
||||
* @returns {Object} 包含样式类和显示文本的对象
|
||||
*/
|
||||
export const getStepStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return {
|
||||
className: 'bg-green-100 text-green-800',
|
||||
text: '成功'
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
className: 'bg-red-100 text-red-800',
|
||||
text: '失败'
|
||||
};
|
||||
case 'loading':
|
||||
return {
|
||||
className: 'bg-blue-100 text-blue-800',
|
||||
text: '进行中'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
className: 'bg-gray-100 text-gray-800',
|
||||
text: '待执行'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有步骤ID的数组
|
||||
* @param {boolean} includeSend 是否包含发送步骤
|
||||
* @returns {Array} 步骤ID数组
|
||||
*/
|
||||
export const getStepIds = (includeSend = false) => {
|
||||
const steps = ['auth', 'register', 'customer', 'tags', 'history', 'generate'];
|
||||
if (includeSend) {
|
||||
steps.push('send');
|
||||
}
|
||||
return steps;
|
||||
};
|
||||
375
frontend/src/hooks/useWeCom.js
Normal file
375
frontend/src/hooks/useWeCom.js
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as ww from '@wecom/jssdk';
|
||||
|
||||
/**
|
||||
* 企业微信 JSSDK Hook
|
||||
* 提供企业微信 JSSDK 的初始化、注册和 API 调用功能
|
||||
*/
|
||||
export const useWeCom = () => {
|
||||
const [sdkReady, setSdkReady] = useState(false);
|
||||
const [sdkError, setSdkError] = useState(null);
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
// 从环境变量获取配置
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID;
|
||||
const agentId = parseInt(import.meta.env.VITE_WECOM_AGENT_ID, 10);
|
||||
|
||||
// 检查环境变量
|
||||
useEffect(() => {
|
||||
if (!corpId || !agentId) {
|
||||
setSdkError(new Error('企业微信配置缺失,请检查 .env 文件中的 VITE_WECOM_CORP_ID 和 VITE_WECOM_AGENT_ID'));
|
||||
}
|
||||
}, [corpId, agentId]);
|
||||
|
||||
// 初始化 JSSDK
|
||||
const initializeSdk = useCallback(async () => {
|
||||
if (sdkReady || sdkError) return;
|
||||
|
||||
try {
|
||||
console.log('初始化企业微信 JSSDK...');
|
||||
console.log('SDK 版本:', ww.SDK_VERSION);
|
||||
console.log('企业ID:', corpId);
|
||||
console.log('应用ID:', agentId);
|
||||
|
||||
// 检查 SDK 是否可用
|
||||
if (typeof ww === 'undefined' || ww.SDK_VERSION == null) {
|
||||
throw new Error('企业微信 JSSDK 未加载成功');
|
||||
}
|
||||
|
||||
setSdkReady(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('初始化企业微信 JSSDK 失败:', error);
|
||||
setSdkError(error);
|
||||
return false;
|
||||
}
|
||||
}, [corpId, agentId, sdkReady, sdkError]);
|
||||
|
||||
// 注册 JSSDK
|
||||
const registerSdk = useCallback(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!sdkReady || isRegistering) return false;
|
||||
|
||||
setIsRegistering(true);
|
||||
setSdkError(null);
|
||||
|
||||
console.log('开始注册企业微信 JSSDK...');
|
||||
|
||||
// 清理 URL,移除 # 及其后面的部分
|
||||
const url = window.location.href;
|
||||
const cleanUrl = url.split('#')[0];
|
||||
console.log('注册 URL:', cleanUrl);
|
||||
|
||||
// 生成签名的函数
|
||||
const getConfigSignature = async () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonceStr = Math.random().toString(36).substring(2, 15);
|
||||
return { timestamp, nonceStr, signature: 'temp_corp_signature' };
|
||||
};
|
||||
|
||||
const getAgentConfigSignature = async (url) => {
|
||||
try {
|
||||
const cleanUrl = url.split('#')[0];
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://mp.yizhezhe.com/api';
|
||||
const apiUrl = `${apiBaseUrl}/auth/signature`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: cleanUrl,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
nonceStr: Math.random().toString(36).substring(2, 15)
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调用签名 API 失败:', error);
|
||||
// 返回测试签名
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonceStr = Math.random().toString(36).substring(2, 15);
|
||||
return { timestamp, nonceStr, signature: 'test_signature' };
|
||||
}
|
||||
};
|
||||
|
||||
// 注册 JSSDK
|
||||
|
||||
try {
|
||||
ww.register({
|
||||
corpId,
|
||||
agentId,
|
||||
jsApiList: ['getContext', 'getCurExternalContact', 'sendChatMessage'],
|
||||
getConfigSignature,
|
||||
getAgentConfigSignature,
|
||||
onConfigSuccess: (res) => {
|
||||
console.log('JSSDK 注册成功:', res);
|
||||
setSdkError(null);
|
||||
setIsRegistering(false);
|
||||
},
|
||||
onConfigFail: (err) => {
|
||||
console.error('JSSDK 注册失败:', err);
|
||||
let registrationError = new Error(`JSSDK 注册失败: ${err.errMsg || '未知错误'}`);
|
||||
setSdkError(registrationError);
|
||||
setIsRegistering(false);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('企业微信 JSSDK 注册调用完成');
|
||||
resolve(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('注册企业微信 JSSDK 时发生错误:', error);
|
||||
setSdkError(error);
|
||||
setIsRegistering(false);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}, [sdkReady, isRegistering, corpId, agentId]);
|
||||
|
||||
// 获取页面场景
|
||||
const getContext = useCallback(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!sdkReady) {
|
||||
reject(new Error('JSSDK 未就绪'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('开始调用 ww.getContext()...');
|
||||
console.log('ww 对象:', ww);
|
||||
console.log('ww.getContext 类型:', typeof ww.getContext);
|
||||
|
||||
// 添加超时机制
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn('获取页面场景超时(3秒),可能JSSDK未正确注册或回调未触发');
|
||||
reject(new Error('获取页面场景超时:可能JSSDK未正确注册或回调未触发'));
|
||||
}, 3000);
|
||||
|
||||
try {
|
||||
ww.getContext({
|
||||
success: (res) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.log('获取页面场景成功 - 原始数据:', res);
|
||||
console.log('获取页面场景成功 - 数据类型:', typeof res);
|
||||
|
||||
if (res === undefined || res === null) {
|
||||
console.warn('获取页面场景返回空数据,可能不在企业微信环境中');
|
||||
const formattedContext = {
|
||||
entry: 'unknown',
|
||||
isValidEntry: false,
|
||||
validEntries: ['contact_profile', 'single_chat_tools', 'single_kf_tools'],
|
||||
rawData: res,
|
||||
message: '获取页面场景返回空数据,可能不在企业微信环境中'
|
||||
};
|
||||
resolve(formattedContext);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('获取页面场景成功 - 数据键名:', Object.keys(res || {}));
|
||||
|
||||
// 企业微信 getContext 返回的数据结构可能是:
|
||||
// 1. 直接包含 entry 字段的对象
|
||||
// 2. 包含在 data 字段中的对象
|
||||
// 3. 其他格式
|
||||
|
||||
let contextData = res;
|
||||
|
||||
// 如果返回的数据有 data 字段,使用 data 字段
|
||||
if (res && res.data) {
|
||||
console.log('检测到 data 字段,使用 data 字段:', res.data);
|
||||
contextData = res.data;
|
||||
}
|
||||
|
||||
// 根据企业微信官方文档,getContext 只返回 entry 参数
|
||||
// entry: 进入页面的场景类型
|
||||
const entry = contextData?.entry || contextData?.entryId || 'unknown';
|
||||
|
||||
// 检查 entry 是否有效
|
||||
const validEntries = ['contact_profile', 'single_chat_tools', 'single_kf_tools'];
|
||||
const isValidEntry = validEntries.includes(entry);
|
||||
|
||||
const formattedContext = {
|
||||
entry: entry,
|
||||
isValidEntry: isValidEntry,
|
||||
validEntries: validEntries,
|
||||
rawData: res // 保留原始数据用于调试
|
||||
};
|
||||
|
||||
console.log('企业微信 getContext 返回:', {
|
||||
原始数据: res,
|
||||
解析后的entry: entry,
|
||||
是否有效: isValidEntry ? '是' : '否',
|
||||
有效entry列表: validEntries
|
||||
});
|
||||
|
||||
console.log('格式化后的页面场景数据:', formattedContext);
|
||||
resolve(formattedContext);
|
||||
},
|
||||
fail: (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.error('获取页面场景失败:', err);
|
||||
console.error('失败详情 - errCode:', err?.errCode);
|
||||
console.error('失败详情 - errMsg:', err?.errMsg);
|
||||
reject(new Error(`获取页面场景失败: ${err?.errMsg || '未知错误'}`));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
console.error('调用 ww.getContext 时发生异常:', error);
|
||||
reject(new Error(`调用 getContext 时发生异常: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}, [sdkReady]);
|
||||
|
||||
// 获取当前客户ID
|
||||
const getCurExternalContact = useCallback(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!sdkReady) {
|
||||
reject(new Error('JSSDK 未就绪'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('开始获取当前客户ID...');
|
||||
|
||||
// 添加超时机制
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn('获取当前客户ID超时(3秒),可能不在外部联系人页面');
|
||||
reject(new Error('获取当前客户ID超时:可能不在外部联系人聊天页面或JSSDK回调未触发'));
|
||||
}, 3000);
|
||||
|
||||
ww.getCurExternalContact({
|
||||
success: (res) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.log('获取当前客户ID成功:', res);
|
||||
|
||||
// 检查返回的数据结构
|
||||
if (res && res.externalUserId) {
|
||||
resolve(res);
|
||||
} else {
|
||||
console.warn('获取当前客户ID返回的数据格式异常:', res);
|
||||
// 即使数据格式异常,也返回数据,让上层处理
|
||||
resolve(res || { externalUserId: 'unknown', data: res });
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.error('获取当前客户ID失败:', err);
|
||||
|
||||
// 根据错误类型提供更详细的错误信息
|
||||
let errorMessage = `获取当前客户ID失败: ${err.errMsg || '未知错误'}`;
|
||||
if (err.errCode === 10001) {
|
||||
errorMessage = '获取当前客户ID失败:不在外部联系人聊天页面';
|
||||
} else if (err.errCode === 10002) {
|
||||
errorMessage = '获取当前客户ID失败:用户取消了操作';
|
||||
}
|
||||
|
||||
reject(new Error(errorMessage));
|
||||
},
|
||||
cancel: () => {
|
||||
clearTimeout(timeoutId);
|
||||
console.warn('用户取消了获取当前客户ID操作');
|
||||
reject(new Error('用户取消了获取当前客户ID操作'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [sdkReady]);
|
||||
|
||||
// 发送消息到当前会话
|
||||
const sendChatMessage = useCallback((content) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!sdkReady) {
|
||||
reject(new Error('JSSDK 未就绪'));
|
||||
return;
|
||||
}
|
||||
|
||||
ww.sendChatMessage({
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content
|
||||
},
|
||||
success: (res) => {
|
||||
console.log('发送消息成功:', res);
|
||||
resolve(res);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('发送消息失败:', err);
|
||||
reject(new Error(`发送消息失败: ${err.errMsg || '未知错误'}`));
|
||||
},
|
||||
cancel: () => {
|
||||
reject(new Error('用户取消了发送消息操作'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [sdkReady]);
|
||||
|
||||
// 自动初始化
|
||||
useEffect(() => {
|
||||
if (corpId && agentId && !sdkReady && !sdkError) {
|
||||
initializeSdk();
|
||||
}
|
||||
}, [corpId, agentId, sdkReady, sdkError, initializeSdk]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
sdkReady,
|
||||
sdkError,
|
||||
isRegistering,
|
||||
corpId,
|
||||
agentId,
|
||||
|
||||
// 方法
|
||||
initializeSdk,
|
||||
registerSdk,
|
||||
getContext,
|
||||
getCurExternalContact,
|
||||
sendChatMessage,
|
||||
|
||||
// 工具方法
|
||||
isConfigured: !!corpId && !!agentId
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 带重试的执行函数
|
||||
* @param {Function} action 要执行的函数
|
||||
* @param {number} maxRetries 最大重试次数,默认3次
|
||||
* @param {Function} onRetry 重试时的回调
|
||||
* @returns 执行结果
|
||||
*/
|
||||
export const executeWithRetry = async (action, maxRetries = 3, onRetry = null) => {
|
||||
let retryCount = 0;
|
||||
let lastError = null;
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
retryCount++;
|
||||
|
||||
if (retryCount > maxRetries) {
|
||||
throw new Error(`操作失败,已重试${maxRetries}次: ${error.message}`);
|
||||
}
|
||||
|
||||
// 指数退避等待
|
||||
const delay = Math.pow(2, retryCount - 1) * 1000;
|
||||
console.log(`第${retryCount}次重试,等待${delay}ms后重试...`);
|
||||
|
||||
if (onRetry) {
|
||||
onRetry(retryCount, error, delay);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
@@ -33,3 +33,59 @@
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸友好的按钮 */
|
||||
@layer components {
|
||||
.touch-button {
|
||||
@apply active:scale-95 transition-transform duration-150;
|
||||
}
|
||||
|
||||
/* 移动端优化输入框 */
|
||||
.mobile-input {
|
||||
@apply text-base;
|
||||
font-size: 16px !important; /* 防止iOS缩放 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 防止移动端长按菜单 */
|
||||
.no-context-menu {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 移动端优化滚动 */
|
||||
.mobile-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* 防止文本溢出 */
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 移动端优化模态框 */
|
||||
.mobile-modal {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
590
frontend/src/services/wecomService.js
Normal file
590
frontend/src/services/wecomService.js
Normal file
@@ -0,0 +1,590 @@
|
||||
import axios from 'axios';
|
||||
import { executeWithRetry } from '../hooks/useWeCom';
|
||||
|
||||
// API 基础配置
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://mp.yizhezhe.com/api';
|
||||
const CUSTOMER_API_BASE = import.meta.env.VITE_CUSTOMER_API_BASE || 'https://admin.doouba.cn/api';
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const customerApiClient = axios.create({
|
||||
baseURL: CUSTOMER_API_BASE,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* 获取 Access Token
|
||||
* @returns {Promise} Access Token 数据
|
||||
*/
|
||||
getAccessToken: () => executeWithRetry(async () => {
|
||||
console.log('获取 Access Token...');
|
||||
const response = await apiClient.get('/auth/token');
|
||||
|
||||
if (response.data.success !== true) {
|
||||
throw new Error(`获取 Access Token 失败: ${response.data.message}`);
|
||||
}
|
||||
|
||||
console.log('Access Token 获取成功:', response.data);
|
||||
return response.data;
|
||||
}, 3, (retryCount) => {
|
||||
console.log(`第${retryCount}次重试获取 Access Token...`);
|
||||
}),
|
||||
|
||||
/**
|
||||
* 获取 JSAPI Ticket
|
||||
* @returns {Promise} JSAPI Ticket 数据
|
||||
*/
|
||||
getJsapiTicket: () => executeWithRetry(async () => {
|
||||
console.log('获取 JSAPI Ticket...');
|
||||
const response = await apiClient.get('/auth/ticket');
|
||||
|
||||
if (response.data.success !== true) {
|
||||
throw new Error(`获取 JSAPI Ticket 失败: ${response.data.message}`);
|
||||
}
|
||||
|
||||
console.log('JSAPI Ticket 获取成功:', response.data);
|
||||
return response.data;
|
||||
}, 3, (retryCount) => {
|
||||
console.log(`第${retryCount}次重试获取 JSAPI Ticket...`);
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* 客户服务
|
||||
*/
|
||||
export const customerService = {
|
||||
/**
|
||||
* 获取客户标签属性
|
||||
* @param {string} userId 客户ID
|
||||
* @returns {Promise} 客户信息(包含标签)
|
||||
*/
|
||||
getCustomerTags: (userId) => executeWithRetry(async () => {
|
||||
console.log(`获取客户 ${userId} 的标签属性...`);
|
||||
|
||||
const response = await customerApiClient.get(`/WechatCustomers/${userId}`, {
|
||||
params: {
|
||||
includeWechatInfo: true
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.code !== 200) {
|
||||
throw new Error(`获取客户标签失败: ${response.data.msg}`);
|
||||
}
|
||||
|
||||
console.log('客户标签获取成功:', response.data.data);
|
||||
return response.data.data;
|
||||
}, 3, (retryCount) => {
|
||||
console.log(`第${retryCount}次重试获取客户标签...`);
|
||||
}),
|
||||
|
||||
/**
|
||||
* 根据标签和历史会话生成提示词
|
||||
* @param {Object} customerInfo 客户信息
|
||||
* @param {Array} messageHistory 消息历史数据
|
||||
* @returns {string} 生成的提示词
|
||||
*/
|
||||
generatePromptFromTagsAndHistory: (customerInfo, messageHistory = []) => {
|
||||
if (!customerInfo || !customerInfo.tags) {
|
||||
return '你好!我是兜爸心选的客服,有什么可以帮您的吗?';
|
||||
}
|
||||
|
||||
const { name, tags, externalUserId } = customerInfo;
|
||||
const tagNames = tags.map(t => t.TagName).join('、');
|
||||
const tagCategories = [...new Set(tags.map(t => t.TagCategory))].join('、');
|
||||
|
||||
// 1. 基础客户信息
|
||||
let prompt = `【客户基本信息】\n`;
|
||||
prompt += `- 姓名:${name || '未知客户'}\n`;
|
||||
|
||||
if (tagNames) {
|
||||
prompt += `- 标签:${tagNames}\n`;
|
||||
}
|
||||
|
||||
if (tagCategories) {
|
||||
prompt += `- 标签分类:${tagCategories}\n`;
|
||||
}
|
||||
|
||||
// 添加基于标签的个性化内容
|
||||
const hasXiaohongshu = tags.some(t => t.TagName.includes('小红书'));
|
||||
const hasYunying = tags.some(t => t.TagCategory === '运营人');
|
||||
|
||||
if (hasXiaohongshu) {
|
||||
prompt += `- 渠道:小红书用户,对性价比比较关注\n`;
|
||||
}
|
||||
|
||||
if (hasYunying) {
|
||||
const yunyingTags = tags.filter(t => t.TagCategory === '运营人');
|
||||
if (yunyingTags.length > 0) {
|
||||
prompt += `- 运营人:${yunyingTags[0].TagName}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 历史会话摘要
|
||||
prompt += `\n【历史会话摘要】(最近${messageHistory.length}条)\n`;
|
||||
|
||||
if (messageHistory.length > 0) {
|
||||
// 按时间倒序排列(最新的在前)
|
||||
const sortedHistory = [...messageHistory].sort((a, b) =>
|
||||
new Date(b.msgtime) - new Date(a.msgtime)
|
||||
).slice(0, 20); // 确保最多20条
|
||||
|
||||
sortedHistory.forEach((msg, index) => {
|
||||
const time = new Date(msg.msgtime).toLocaleString('zh-CN');
|
||||
const role = msg.from_user === externalUserId ? '客户' : '客服';
|
||||
const content = msg.text?.content || msg.content || '无文本内容';
|
||||
|
||||
// 简化内容,避免过长
|
||||
const simplifiedContent = content.length > 50
|
||||
? content.substring(0, 50) + '...'
|
||||
: content;
|
||||
|
||||
prompt += `${index + 1}. 时间:${time},角色:${role},内容:${simplifiedContent}\n`;
|
||||
});
|
||||
|
||||
// 3. 对话上下文分析
|
||||
prompt += `\n【对话上下文分析】\n`;
|
||||
|
||||
// 分析客户消息
|
||||
const customerMessages = sortedHistory.filter(msg =>
|
||||
msg.from_user === externalUserId
|
||||
);
|
||||
|
||||
if (customerMessages.length > 0) {
|
||||
// 提取关键词
|
||||
const allContent = customerMessages.map(msg =>
|
||||
msg.text?.content || msg.content || ''
|
||||
).join(' ');
|
||||
|
||||
// 简单分析(实际可以更复杂)
|
||||
prompt += `- 客户主要关注:${customerService.analyzeMainTopics(allContent)}\n`;
|
||||
prompt += `- 消息频率:${customerMessages.length}条历史消息\n`;
|
||||
|
||||
// 检查是否有未解决的问题
|
||||
const hasQuestions = customerMessages.some(msg =>
|
||||
(msg.text?.content || '').includes('?') ||
|
||||
(msg.text?.content || '').includes('?')
|
||||
);
|
||||
prompt += `- 未解决问题:${hasQuestions ? '有' : '暂无明确未解决问题'}\n`;
|
||||
}
|
||||
} else {
|
||||
prompt += `暂无历史会话记录\n`;
|
||||
}
|
||||
|
||||
// 4. 生成要求
|
||||
prompt += `\n【生成要求】\n`;
|
||||
prompt += `基于以上客户信息和历史会话,生成一段亲切、专业的回复。要求:\n`;
|
||||
prompt += `1. 如果历史会话中有未解决的问题,优先回应\n`;
|
||||
prompt += `2. 保持对话的连贯性和上下文一致性\n`;
|
||||
prompt += `3. 体现对客户历史需求的了解和关注\n`;
|
||||
prompt += `4. 根据客户标签提供个性化服务建议\n`;
|
||||
prompt += `5. 语气亲切但不失专业,体现兜爸心选的品牌形象\n`;
|
||||
|
||||
return prompt;
|
||||
},
|
||||
|
||||
/**
|
||||
* 简单的话题分析函数
|
||||
* @param {string} text 文本内容
|
||||
* @returns {string} 分析出的主要话题
|
||||
*/
|
||||
analyzeMainTopics: (text) => {
|
||||
const topics = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
if (lowerText.includes('价格') || lowerText.includes('多少钱')) {
|
||||
topics.push('价格咨询');
|
||||
}
|
||||
if (lowerText.includes('怎么用') || lowerText.includes('使用方法')) {
|
||||
topics.push('使用指导');
|
||||
}
|
||||
if (lowerText.includes('发货') || lowerText.includes('物流')) {
|
||||
topics.push('物流查询');
|
||||
}
|
||||
if (lowerText.includes('质量') || lowerText.includes('品质')) {
|
||||
topics.push('质量咨询');
|
||||
}
|
||||
if (lowerText.includes('售后') || lowerText.includes('退换')) {
|
||||
topics.push('售后服务');
|
||||
}
|
||||
|
||||
return topics.length > 0 ? topics.join('、') : '一般咨询';
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化历史会话用于显示
|
||||
* @param {Array} messageHistory 消息历史数据
|
||||
* @param {string} externalUserId 客户ID(用于识别客户消息)
|
||||
* @param {number} maxMessages 最大显示消息数
|
||||
* @returns {string} 格式化后的历史会话文本
|
||||
*/
|
||||
formatHistoryForDisplay: (messageHistory = [], externalUserId = '', maxMessages = 5) => {
|
||||
if (!messageHistory || messageHistory.length === 0) {
|
||||
return '📜 暂无历史会话记录';
|
||||
}
|
||||
|
||||
// 按时间倒序排列(最新的在前)
|
||||
const sortedHistory = [...messageHistory].sort((a, b) =>
|
||||
new Date(b.msgtime) - new Date(a.msgtime)
|
||||
).slice(0, maxMessages);
|
||||
|
||||
let formattedText = `📜 最近对话记录(${sortedHistory.length}条):\n\n`;
|
||||
|
||||
sortedHistory.forEach((msg) => {
|
||||
// 确定发送者角色
|
||||
const role = msg.from_user === externalUserId ? '客户' : '客服';
|
||||
|
||||
// 获取消息内容
|
||||
const content = msg.text?.content || msg.content || '无文本内容';
|
||||
|
||||
// 简化内容,避免过长
|
||||
const simplifiedContent = content.length > 80
|
||||
? content.substring(0, 80) + '...'
|
||||
: content;
|
||||
|
||||
// 使用点状列表格式,弱化时间显示
|
||||
formattedText += `• ${role}:${simplifiedContent}\n`;
|
||||
});
|
||||
|
||||
return formattedText;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息服务
|
||||
*/
|
||||
export const messageService = {
|
||||
/**
|
||||
* 获取用户消息历史
|
||||
* @param {string} userId 客户ID
|
||||
* @param {number} page 页码
|
||||
* @param {number} pageSize 每页数量
|
||||
* @returns {Promise} 消息历史数据
|
||||
*/
|
||||
getUserMessageHistory: (userId, page = 1, pageSize = 20) => executeWithRetry(async () => {
|
||||
console.log(`获取用户 ${userId} 的消息历史,页码: ${page}, 每页: ${pageSize}...`);
|
||||
|
||||
const response = await apiClient.get('/messages', {
|
||||
params: {
|
||||
from_user: userId,
|
||||
page: page,
|
||||
page_size: pageSize
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(`获取消息历史失败: ${response.data.message || '未知错误'}`);
|
||||
}
|
||||
|
||||
console.log('消息历史获取成功:', {
|
||||
total: response.data.data?.total || 0,
|
||||
messagesCount: response.data.data?.messages?.length || 0
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
}, 3, (retryCount) => {
|
||||
console.log(`第${retryCount}次重试获取消息历史...`);
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* 大语言模型服务
|
||||
*/
|
||||
export const glmService = {
|
||||
/**
|
||||
* 发送消息到大语言模型
|
||||
* @param {string} message 消息内容
|
||||
* @param {string} conversationId 会话ID(可选)
|
||||
* @returns {Promise} 模型回复
|
||||
*/
|
||||
sendMessage: (message, conversationId = null) => executeWithRetry(async () => {
|
||||
console.log('发送消息到大语言模型...', { message, conversationId });
|
||||
|
||||
const payload = {
|
||||
message,
|
||||
conversation_id: conversationId
|
||||
};
|
||||
|
||||
const response = await apiClient.post('/glm/chat', payload);
|
||||
|
||||
if (!response.data || !response.data.response) {
|
||||
throw new Error('大语言模型返回格式错误');
|
||||
}
|
||||
|
||||
console.log('大语言模型回复成功:', response.data);
|
||||
return response.data;
|
||||
}, 3, (retryCount) => {
|
||||
console.log(`第${retryCount}次重试发送消息到大语言模型...`);
|
||||
}),
|
||||
|
||||
/**
|
||||
* 基于客户标签生成智能回复
|
||||
* @param {Object} customerInfo 客户信息(包含标签)
|
||||
* @returns {Promise} 生成的回复
|
||||
*/
|
||||
generateReplyFromTags: async (customerInfo) => {
|
||||
const prompt = customerService.generatePromptFromTags(customerInfo);
|
||||
console.log('生成的提示词:', prompt);
|
||||
|
||||
const result = await glmService.sendMessage(prompt);
|
||||
return result.response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 基于客户标签和历史会话生成智能回复
|
||||
* @param {Object} customerInfo 客户信息(包含标签)
|
||||
* @param {Array} messageHistory 消息历史数据
|
||||
* @returns {Promise} 生成的回复
|
||||
*/
|
||||
generateReplyFromTagsAndHistory: async (customerInfo, messageHistory = []) => {
|
||||
const prompt = customerService.generatePromptFromTagsAndHistory(customerInfo, messageHistory);
|
||||
console.log('生成的提示词(包含历史):', prompt);
|
||||
|
||||
const result = await glmService.sendMessage(prompt);
|
||||
return result.response;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 工作流服务 - 整合所有步骤
|
||||
*/
|
||||
export const workflowService = {
|
||||
/**
|
||||
* 执行工作流但不发送消息(只生成回复)
|
||||
* @param {Object} weComHook 企业微信 Hook 实例
|
||||
* @param {Function} onProgress 进度回调
|
||||
* @returns {Promise} 工作流结果(包含生成的回复)
|
||||
*/
|
||||
executeWorkflowWithoutSend: async (weComHook, onProgress = null) => {
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
// 1. 获取认证信息
|
||||
if (onProgress) onProgress('auth', 'loading');
|
||||
const [tokenResult, ticketResult] = await Promise.all([
|
||||
authService.getAccessToken(),
|
||||
authService.getJsapiTicket()
|
||||
]);
|
||||
results.auth = { token: tokenResult, ticket: ticketResult };
|
||||
if (onProgress) onProgress('auth', 'success', results.auth);
|
||||
|
||||
// 2. 注册企业微信 JSSDK(同步调用)
|
||||
if (onProgress) onProgress('register', 'loading');
|
||||
let registerResult;
|
||||
try {
|
||||
const registered = weComHook.registerSdk();
|
||||
registerResult = {
|
||||
registered,
|
||||
message: registered ? 'JSSDK注册调用成功' : 'JSSDK注册调用失败'
|
||||
};
|
||||
|
||||
// 如果注册失败,抛出错误停止工作流
|
||||
if (!registered) {
|
||||
throw new Error('JSSDK注册失败,无法继续执行需要JSSDK的步骤');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('JSSDK注册调用时发生错误:', error);
|
||||
registerResult = {
|
||||
registered: false,
|
||||
error: error.message,
|
||||
message: `注册调用失败: ${error.message}`
|
||||
};
|
||||
// 抛出错误停止工作流
|
||||
throw new Error(`JSSDK注册失败: ${error.message}`);
|
||||
}
|
||||
results.register = registerResult;
|
||||
if (onProgress) onProgress('register', 'success', registerResult);
|
||||
|
||||
// 3. 获取客户ID
|
||||
if (onProgress) onProgress('customer', 'loading');
|
||||
|
||||
console.log('开始获取客户ID步骤...');
|
||||
|
||||
let customerResult;
|
||||
try {
|
||||
// 首先获取页面场景,检查是否在支持获取客户ID的页面
|
||||
let contextInfo = null;
|
||||
try {
|
||||
contextInfo = await weComHook.getContext();
|
||||
console.log('当前页面场景:', contextInfo);
|
||||
|
||||
// 检查是否获取到有效的页面场景数据
|
||||
if (!contextInfo || contextInfo.entry === 'unknown') {
|
||||
const errorMessage = `无法获取有效的页面场景信息。可能的原因:\n1. 不在企业微信环境中运行\n2. JSSDK未正确注册\n3. 当前页面不支持获取场景信息\n\n请确保在企业微信中打开此应用,并刷新页面重试。`;
|
||||
console.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 检查 entry 是否有效
|
||||
if (!contextInfo.isValidEntry) {
|
||||
const errorMessage = `当前页面不支持获取客户ID。getCurExternalContact() 接口只在以下页面可用:\n• 外部联系人详情页面 (contact_profile)\n• 单聊工具栏页面 (single_chat_tools)\n• 单聊客服工具栏页面 (single_kf_tools)\n\n当前页面: ${contextInfo.entry}\n请在企业微信外部联系人聊天页面中打开此应用。`;
|
||||
console.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
console.log('当前页面支持获取客户ID,entry:', contextInfo.entry);
|
||||
|
||||
// 在支持获取客户ID的页面,尝试获取真实客户ID
|
||||
customerResult = await weComHook.getCurExternalContact()
|
||||
.then(customerInfo => {
|
||||
console.log('成功获取到客户ID:', customerInfo);
|
||||
return {
|
||||
...customerInfo,
|
||||
context: contextInfo,
|
||||
mock: false
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取客户ID失败:', error);
|
||||
throw error; // 重新抛出错误,停止工作流
|
||||
});
|
||||
|
||||
} catch (contextError) {
|
||||
console.error('获取页面场景失败或页面不支持获取客户ID:', contextError);
|
||||
throw contextError; // 重新抛出错误,停止工作流
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取客户ID步骤失败:', error);
|
||||
// 不再返回模拟数据,而是抛出错误停止工作流
|
||||
throw new Error(`获取客户ID失败: ${error.message}`);
|
||||
}
|
||||
|
||||
results.customer = customerResult;
|
||||
if (onProgress) onProgress('customer', 'success', customerResult);
|
||||
|
||||
// 4. 获取客户标签
|
||||
if (onProgress) onProgress('tags', 'loading');
|
||||
let tagsResult;
|
||||
if (!customerResult.externalUserId || customerResult.mock) {
|
||||
console.warn('使用模拟标签数据');
|
||||
tagsResult = {
|
||||
name: '模拟客户',
|
||||
tags: [
|
||||
{ TagName: '小红书用户', TagCategory: '渠道' },
|
||||
{ TagName: '高意向客户', TagCategory: '意向度' },
|
||||
{ TagName: '已购买用户', TagCategory: '客户状态' }
|
||||
],
|
||||
mock: true,
|
||||
message: '使用模拟标签数据'
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
tagsResult = await customerService.getCustomerTags(customerResult.externalUserId);
|
||||
} catch (error) {
|
||||
console.warn('获取客户标签失败,使用模拟数据:', error);
|
||||
tagsResult = {
|
||||
name: '客户(标签获取失败)',
|
||||
tags: [{ TagName: '兜爸心选客户', TagCategory: '默认' }],
|
||||
mock: true,
|
||||
error: error.message,
|
||||
message: '标签获取失败,使用模拟数据'
|
||||
};
|
||||
}
|
||||
}
|
||||
results.tags = tagsResult;
|
||||
if (onProgress) onProgress('tags', 'success', tagsResult);
|
||||
|
||||
// 5. 获取会话历史
|
||||
if (onProgress) onProgress('history', 'loading');
|
||||
let historyResult;
|
||||
if (!customerResult.externalUserId || customerResult.mock) {
|
||||
console.warn('使用模拟历史数据');
|
||||
historyResult = {
|
||||
messages: [
|
||||
{
|
||||
msgid: 'mock_msg_001',
|
||||
from_user: customerResult.externalUserId || 'mock_customer',
|
||||
text: { content: '你好,我想了解一下你们的产品' },
|
||||
msgtime: new Date(Date.now() - 86400000).toISOString(),
|
||||
msgtype: 'text'
|
||||
},
|
||||
{
|
||||
msgid: 'mock_msg_002',
|
||||
from_user: '客服',
|
||||
text: { content: '您好!欢迎咨询兜爸心选产品,请问您想了解哪方面的信息?' },
|
||||
msgtime: new Date(Date.now() - 86300000).toISOString(),
|
||||
msgtype: 'text'
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
mock: true,
|
||||
message: '使用模拟历史数据'
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
historyResult = await messageService.getUserMessageHistory(customerResult.externalUserId, 1, 20);
|
||||
} catch (error) {
|
||||
console.warn('获取会话历史失败,使用模拟数据:', error);
|
||||
historyResult = {
|
||||
messages: [],
|
||||
total: 0,
|
||||
mock: true,
|
||||
error: error.message,
|
||||
message: '历史获取失败,使用模拟数据'
|
||||
};
|
||||
}
|
||||
}
|
||||
results.history = historyResult;
|
||||
if (onProgress) onProgress('history', 'success', historyResult);
|
||||
|
||||
// 6. 生成智能回复
|
||||
if (onProgress) onProgress('generate', 'loading');
|
||||
let generatedReply;
|
||||
try {
|
||||
// 合并客户信息和标签信息
|
||||
const customerInfo = {
|
||||
...customerResult,
|
||||
...tagsResult,
|
||||
externalUserId: customerResult.externalUserId
|
||||
};
|
||||
|
||||
// 使用新的函数,包含历史数据
|
||||
generatedReply = await glmService.generateReplyFromTagsAndHistory(
|
||||
customerInfo,
|
||||
historyResult.messages || []
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('生成智能回复失败,使用默认回复:', error);
|
||||
generatedReply = `您好!我是兜爸心选的智能客服。抱歉,生成个性化回复时遇到问题:${error.message}。请问您有什么需要帮助的吗?`;
|
||||
}
|
||||
results.generate = generatedReply;
|
||||
if (onProgress) onProgress('generate', 'success', generatedReply);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
steps: results,
|
||||
generatedMessage: generatedReply,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('工作流执行失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
steps: results,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
authService,
|
||||
customerService,
|
||||
glmService,
|
||||
workflowService
|
||||
};
|
||||
@@ -4,5 +4,10 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/chat_fe'
|
||||
base: '/chat_fe',
|
||||
server: {
|
||||
port: 3010,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['mp.yizhezhe.com']
|
||||
}
|
||||
})
|
||||
|
||||
546
frontend/web/index.html
Normal file
546
frontend/web/index.html
Normal file
@@ -0,0 +1,546 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>企业微信 JSSDK 测试</title>
|
||||
<!-- 引入企业微信 JSSDK -->
|
||||
<script src="https://wwcdn.weixin.qq.com/node/open/js/wecom-jssdk-2.3.3.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>企业微信 JSSDK 测试页面</h1>
|
||||
<p>此页面用于测试企业微信 JSSDK 是否加载成功。</p>
|
||||
<p>如果 JSSDK 加载成功,将会显示 SDK 版本号。</p>
|
||||
|
||||
<h2>Access Token(自动获取)</h2>
|
||||
<div>
|
||||
<p style="color: #666; font-style: italic;">页面加载时自动获取Access Token...</p>
|
||||
<div id="tokenResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9;">
|
||||
<h3>Access Token 结果:</h3>
|
||||
<pre id="tokenData">获取中...</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>JSAPI Ticket(自动获取)</h2>
|
||||
<div>
|
||||
<p style="color: #666; font-style: italic;">页面加载时自动获取JSAPI Ticket...</p>
|
||||
<div id="ticketResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9;">
|
||||
<h3>JSAPI Ticket 结果:</h3>
|
||||
<pre id="ticketData">获取中...</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>获取页面场景</h2>
|
||||
<div>
|
||||
<button id="getContextBtn">获取页面场景</button>
|
||||
<div id="contextResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; display: none;">
|
||||
<h3>页面场景结果:</h3>
|
||||
<pre id="contextData"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>获取当前客户ID</h2>
|
||||
<div>
|
||||
<button id="getCustomerBtn">获取当前客户ID</button>
|
||||
<div id="customerResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; display: none;">
|
||||
<h3>当前客户ID结果:</h3>
|
||||
<pre id="customerData"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>发送消息到当前会话</h2>
|
||||
<div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="messageContent" style="display: block; margin-bottom: 5px; font-weight: bold;">消息内容:</label>
|
||||
<textarea id="messageContent" rows="4" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 5px; font-family: monospace;" placeholder="请输入要发送的消息内容...">你好,这是一条测试消息!</textarea>
|
||||
</div>
|
||||
<button id="sendMessageBtn">发送消息到当前会话</button>
|
||||
<div id="messageResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; display: none;">
|
||||
<h3>消息发送结果:</h3>
|
||||
<pre id="messageData"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试代码 -->
|
||||
<script>
|
||||
// 函数定义
|
||||
async function getConfigSignature(url) {
|
||||
// 根据 url 生成企业签名
|
||||
// 生成方法参考 https://developer.work.weixin.qq.com/document/14924
|
||||
// 暂时返回简单实现,主要关注应用签名
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonceStr = Math.random().toString(36).substring(2, 15);
|
||||
return { timestamp, nonceStr, signature: 'temp_signature' };
|
||||
}
|
||||
|
||||
async function getAgentConfigSignature(url) {
|
||||
// 根据 url 生成应用签名
|
||||
// 注意:企业微信要求url必须是调用JSAPI页面的完整URL,不包含#及其后面部分
|
||||
try {
|
||||
// 清理URL,移除#及其后面的部分
|
||||
const cleanUrl = url.split('#')[0];
|
||||
console.log('生成签名,原始URL:', url, '清理后URL:', cleanUrl);
|
||||
|
||||
// 使用线上地址
|
||||
const apiBaseUrl = 'http://mp.yizhezhe.com';
|
||||
const apiUrl = `${apiBaseUrl}/api/auth/signature`;
|
||||
console.log('调用签名API:', apiUrl);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: cleanUrl,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
nonceStr: Math.random().toString(36).substring(2, 15)
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
console.log('签名生成成功:', result.data);
|
||||
console.log('签名详情:', {
|
||||
url: cleanUrl,
|
||||
timestamp: result.data.timestamp,
|
||||
nonceStr: result.data.nonceStr,
|
||||
signature: result.data.signature
|
||||
});
|
||||
return result.data;
|
||||
} else {
|
||||
console.error('签名生成失败:', result.message);
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调用签名API失败:', error);
|
||||
// 返回一个简单的签名用于测试
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonceStr = Math.random().toString(36).substring(2, 15);
|
||||
return { timestamp, nonceStr, signature: 'error_signature' };
|
||||
}
|
||||
}
|
||||
|
||||
// 自动获取 Access Token 函数
|
||||
async function autoFetchAccessToken() {
|
||||
console.log('开始自动获取 Access Token...');
|
||||
const tokenDataElement = document.getElementById('tokenData');
|
||||
const tokenResultElement = document.getElementById('tokenResult');
|
||||
|
||||
try {
|
||||
// 更新状态
|
||||
tokenDataElement.textContent = '正在获取...';
|
||||
tokenDataElement.style.color = '#666';
|
||||
|
||||
// 获取 access_token 的 API 端点
|
||||
const apiUrl = 'http://mp.yizhezhe.com/api/auth/token';
|
||||
|
||||
console.log('正在调用 API:', apiUrl);
|
||||
|
||||
// 使用 fetch 调用 API
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP 错误: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Access Token 获取成功:', data);
|
||||
|
||||
// 更新页面显示
|
||||
if (data.success === true) {
|
||||
tokenDataElement.textContent = JSON.stringify(data, null, 2);
|
||||
tokenDataElement.style.color = '#333';
|
||||
console.log(`Access Token 获取成功!${data.message}`);
|
||||
console.log('Access Token 数据:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 添加成功样式
|
||||
tokenResultElement.style.borderColor = '#4CAF50';
|
||||
tokenResultElement.style.backgroundColor = '#f8fff8';
|
||||
} else {
|
||||
tokenDataElement.textContent = `获取失败: ${data.message}\n\n返回数据: ${JSON.stringify(data, null, 2)}`;
|
||||
tokenDataElement.style.color = '#d32f2f';
|
||||
console.error(`获取 Access Token 失败: ${data.message}`);
|
||||
|
||||
// 添加错误样式
|
||||
tokenResultElement.style.borderColor = '#f44336';
|
||||
tokenResultElement.style.backgroundColor = '#fff8f8';
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('获取 Access Token 失败:', error);
|
||||
console.error('错误详情:', error.message);
|
||||
|
||||
// 更新页面显示错误
|
||||
tokenDataElement.textContent = `错误: ${error.message}\n\n请检查网络连接或后端服务状态。`;
|
||||
tokenDataElement.style.color = '#d32f2f';
|
||||
|
||||
// 添加错误样式
|
||||
tokenResultElement.style.borderColor = '#f44336';
|
||||
tokenResultElement.style.backgroundColor = '#fff8f8';
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 自动获取 JSAPI Ticket 函数
|
||||
async function autoFetchJsapiTicket() {
|
||||
console.log('开始自动获取 JSAPI Ticket...');
|
||||
const ticketDataElement = document.getElementById('ticketData');
|
||||
const ticketResultElement = document.getElementById('ticketResult');
|
||||
|
||||
try {
|
||||
// 更新状态
|
||||
ticketDataElement.textContent = '正在获取...';
|
||||
ticketDataElement.style.color = '#666';
|
||||
|
||||
// 获取 jsapi_ticket 的 API 端点 - 使用线上地址
|
||||
const apiUrl = 'http://mp.yizhezhe.com/api/auth/ticket';
|
||||
|
||||
console.log('正在调用 API:', apiUrl);
|
||||
|
||||
// 使用 fetch 调用 API
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP 错误: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log('JSAPI Ticket 获取成功:', data);
|
||||
|
||||
// 更新页面显示
|
||||
if (data.success === true) {
|
||||
ticketDataElement.textContent = JSON.stringify(data, null, 2);
|
||||
ticketDataElement.style.color = '#333';
|
||||
console.log(`JSAPI Ticket 获取成功!${data.message}`);
|
||||
console.log('JSAPI Ticket 数据:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 添加成功样式
|
||||
ticketResultElement.style.borderColor = '#4CAF50';
|
||||
ticketResultElement.style.backgroundColor = '#f8fff8';
|
||||
} else {
|
||||
ticketDataElement.textContent = `获取失败: ${data.message}\n\n返回数据: ${JSON.stringify(data, null, 2)}`;
|
||||
ticketDataElement.style.color = '#d32f2f';
|
||||
console.error(`获取 JSAPI Ticket 失败: ${data.message}`);
|
||||
|
||||
// 添加错误样式
|
||||
ticketResultElement.style.borderColor = '#f44336';
|
||||
ticketResultElement.style.backgroundColor = '#fff8f8';
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('获取 JSAPI Ticket 失败:', error);
|
||||
console.error('错误详情:', error.message);
|
||||
|
||||
// 更新页面显示错误
|
||||
ticketDataElement.textContent = `错误: ${error.message}\n\n请检查网络连接或后端服务状态。`;
|
||||
ticketDataElement.style.color = '#d32f2f';
|
||||
|
||||
// 添加错误样式
|
||||
ticketResultElement.style.borderColor = '#f44336';
|
||||
ticketResultElement.style.backgroundColor = '#fff8f8';
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// DOMContentLoaded 事件处理
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOMContentLoaded: 开始初始化页面...');
|
||||
|
||||
// 1. 检查并注册 JSSDK
|
||||
console.log('检查 JSSDK...');
|
||||
if (typeof ww !== 'undefined') {
|
||||
console.log('企业微信 JSSDK 版本:', ww.SDK_VERSION);
|
||||
console.log('JSSDK 对象已加载:', ww);
|
||||
|
||||
// 注册 JSSDK(不等待回调,让 JSSDK 自行处理)
|
||||
console.log('开始注册 JSSDK...');
|
||||
ww.register({
|
||||
corpId: 'ww45bb49a9d651b2bb', // 必填,当前用户企业所属企业ID,从.env文件获取
|
||||
agentId: 1000061, // 必填,当前应用的AgentID,从.env文件获取
|
||||
jsApiList: ['getContext', 'getCurExternalContact', 'sendChatMessage'], // 必填,需要使用的JSAPI列表
|
||||
getConfigSignature, // 必填,根据url生成企业签名的回调函数
|
||||
getAgentConfigSignature // 必填,根据url生成应用签名的回调函数
|
||||
// 不设置 success 和 fail 回调,让按钮直接调用接口
|
||||
});
|
||||
|
||||
console.log('JSSDK 注册调用完成');
|
||||
|
||||
// 2. 自动获取 Access Token 和 JSAPI Ticket
|
||||
console.log('开始自动获取认证信息...');
|
||||
autoFetchAccessToken();
|
||||
autoFetchJsapiTicket();
|
||||
} else {
|
||||
console.error('错误: 企业微信 JSSDK 未加载成功!');
|
||||
console.error('请检查网络或刷新页面。');
|
||||
}
|
||||
|
||||
// 获取页面场景功能
|
||||
const getContextBtn = document.getElementById('getContextBtn');
|
||||
const contextResult = document.getElementById('contextResult');
|
||||
const contextData = document.getElementById('contextData');
|
||||
|
||||
if (getContextBtn) {
|
||||
console.log('找到获取页面场景按钮,绑定点击事件');
|
||||
// 初始时直接启用按钮
|
||||
getContextBtn.disabled = false;
|
||||
getContextBtn.textContent = '获取页面场景';
|
||||
|
||||
getContextBtn.addEventListener('click', function() {
|
||||
console.log('点击获取页面场景按钮');
|
||||
|
||||
// 直接调用,不检查注册状态
|
||||
getContextBtn.disabled = true;
|
||||
getContextBtn.textContent = '获取中...';
|
||||
|
||||
console.log('直接调用 ww.getContext()...');
|
||||
console.log('检查 ww 对象:', typeof ww);
|
||||
console.log('检查 ww.getContext:', typeof ww.getContext);
|
||||
|
||||
if (typeof ww === 'undefined') {
|
||||
alert('错误: ww 对象未定义,JSSDK 未加载成功!');
|
||||
getContextBtn.disabled = false;
|
||||
getContextBtn.textContent = '获取页面场景';
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof ww.getContext !== 'function') {
|
||||
alert('错误: ww.getContext 不是函数,请检查 jsApiList 是否包含 getContext');
|
||||
getContextBtn.disabled = false;
|
||||
getContextBtn.textContent = '获取页面场景';
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用企业微信 JSSDK 的 getContext 方法
|
||||
ww.getContext({
|
||||
success: function(res) {
|
||||
console.log('获取页面场景成功:', res);
|
||||
|
||||
// 显示结果
|
||||
contextData.textContent = JSON.stringify(res, null, 2);
|
||||
contextResult.style.display = 'block';
|
||||
|
||||
// 解析场景信息
|
||||
let sceneInfo = '未知场景';
|
||||
if (res.entry) {
|
||||
sceneInfo = `入口: ${res.entry}`;
|
||||
}
|
||||
if (res.scene) {
|
||||
sceneInfo += `, 场景: ${res.scene}`;
|
||||
}
|
||||
if (res.chatId) {
|
||||
sceneInfo += `, 聊天ID: ${res.chatId}`;
|
||||
}
|
||||
|
||||
alert(`获取页面场景成功!\n${sceneInfo}`);
|
||||
},
|
||||
fail: function(err) {
|
||||
console.error('获取页面场景失败:', err);
|
||||
contextData.textContent = `错误: ${JSON.stringify(err, null, 2)}`;
|
||||
contextResult.style.display = 'block';
|
||||
alert('获取页面场景失败: ' + (err.errMsg || '未知错误'));
|
||||
},
|
||||
complete: function() {
|
||||
console.log('getContext 调用完成');
|
||||
getContextBtn.disabled = false;
|
||||
getContextBtn.textContent = '获取页面场景';
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error('未找到获取页面场景按钮!');
|
||||
}
|
||||
|
||||
// 获取当前客户ID功能
|
||||
const getCustomerBtn = document.getElementById('getCustomerBtn');
|
||||
const customerResult = document.getElementById('customerResult');
|
||||
const customerData = document.getElementById('customerData');
|
||||
|
||||
if (getCustomerBtn) {
|
||||
console.log('找到获取当前客户ID按钮,绑定点击事件');
|
||||
// 初始时直接启用按钮
|
||||
getCustomerBtn.disabled = false;
|
||||
getCustomerBtn.textContent = '获取当前客户ID';
|
||||
|
||||
getCustomerBtn.addEventListener('click', function() {
|
||||
console.log('点击获取当前客户ID按钮');
|
||||
|
||||
// 直接调用,不检查注册状态
|
||||
getCustomerBtn.disabled = true;
|
||||
getCustomerBtn.textContent = '获取中...';
|
||||
|
||||
console.log('准备调用 ww.getCurExternalContact()...');
|
||||
console.log('检查 ww 对象:', typeof ww);
|
||||
console.log('检查 ww.getCurExternalContact:', typeof ww.getCurExternalContact);
|
||||
|
||||
if (typeof ww === 'undefined') {
|
||||
alert('错误: ww 对象未定义,JSSDK 未加载成功!');
|
||||
getCustomerBtn.disabled = false;
|
||||
getCustomerBtn.textContent = '获取当前客户ID';
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof ww.getCurExternalContact !== 'function') {
|
||||
alert('错误: ww.getCurExternalContact 不是函数,请检查 jsApiList 是否包含 getCurExternalContact');
|
||||
getCustomerBtn.disabled = false;
|
||||
getCustomerBtn.textContent = '获取当前客户ID';
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用企业微信 JSSDK 的 getCurExternalContact 方法
|
||||
ww.getCurExternalContact({
|
||||
success: function(res) {
|
||||
console.log('获取当前客户ID成功:', res);
|
||||
|
||||
// 显示结果
|
||||
customerData.textContent = JSON.stringify(res, null, 2);
|
||||
customerResult.style.display = 'block';
|
||||
|
||||
// 解析客户信息
|
||||
let customerInfo = '未知客户';
|
||||
if (res.externalUserId) {
|
||||
customerInfo = `客户ID: ${res.externalUserId}`;
|
||||
}
|
||||
if (res.nickname) {
|
||||
customerInfo += `, 昵称: ${res.nickname}`;
|
||||
}
|
||||
if (res.avatar) {
|
||||
customerInfo += `, 有头像`;
|
||||
}
|
||||
if (res.corpName) {
|
||||
customerInfo += `, 企业: ${res.corpName}`;
|
||||
}
|
||||
|
||||
alert(`获取当前客户ID成功!\n${customerInfo}`);
|
||||
},
|
||||
fail: function(err) {
|
||||
console.error('获取当前客户ID失败:', err);
|
||||
customerData.textContent = `错误: ${JSON.stringify(err, null, 2)}`;
|
||||
customerResult.style.display = 'block';
|
||||
alert('获取当前客户ID失败: ' + (err.errMsg || '未知错误'));
|
||||
},
|
||||
cancel: function() {
|
||||
console.log('用户取消了获取当前客户ID操作');
|
||||
alert('用户取消了获取当前客户ID操作');
|
||||
getCustomerBtn.disabled = false;
|
||||
getCustomerBtn.textContent = '获取当前客户ID';
|
||||
},
|
||||
complete: function() {
|
||||
console.log('getCurExternalContact 调用完成');
|
||||
getCustomerBtn.disabled = false;
|
||||
getCustomerBtn.textContent = '获取当前客户ID';
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error('未找到获取当前客户ID按钮!');
|
||||
}
|
||||
|
||||
// 发送消息到当前会话功能
|
||||
const sendMessageBtn = document.getElementById('sendMessageBtn');
|
||||
const messageResult = document.getElementById('messageResult');
|
||||
const messageData = document.getElementById('messageData');
|
||||
const messageContent = document.getElementById('messageContent');
|
||||
|
||||
if (sendMessageBtn) {
|
||||
console.log('找到发送消息按钮,绑定点击事件');
|
||||
// 初始时直接启用按钮
|
||||
sendMessageBtn.disabled = false;
|
||||
sendMessageBtn.textContent = '发送消息到当前会话';
|
||||
|
||||
sendMessageBtn.addEventListener('click', function() {
|
||||
console.log('点击发送消息按钮');
|
||||
|
||||
// 获取消息内容
|
||||
const content = messageContent.value.trim();
|
||||
if (!content) {
|
||||
alert('错误: 消息内容不能为空!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接调用,不检查注册状态
|
||||
sendMessageBtn.disabled = true;
|
||||
sendMessageBtn.textContent = '发送中...';
|
||||
|
||||
console.log('准备调用 ww.sendChatMessage()...');
|
||||
console.log('检查 ww 对象:', typeof ww);
|
||||
console.log('检查 ww.sendChatMessage:', typeof ww.sendChatMessage);
|
||||
console.log('消息内容:', content);
|
||||
|
||||
if (typeof ww === 'undefined') {
|
||||
alert('错误: ww 对象未定义,JSSDK 未加载成功!');
|
||||
sendMessageBtn.disabled = false;
|
||||
sendMessageBtn.textContent = '发送消息到当前会话';
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof ww.sendChatMessage !== 'function') {
|
||||
alert('错误: ww.sendChatMessage 不是函数,请检查 jsApiList 是否包含 sendChatMessage');
|
||||
sendMessageBtn.disabled = false;
|
||||
sendMessageBtn.textContent = '发送消息到当前会话';
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用企业微信 JSSDK 的 sendChatMessage 方法
|
||||
ww.sendChatMessage({
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: content
|
||||
},
|
||||
success: function(res) {
|
||||
console.log('发送消息成功:', res);
|
||||
|
||||
// 显示结果
|
||||
messageData.textContent = JSON.stringify(res, null, 2);
|
||||
messageResult.style.display = 'block';
|
||||
|
||||
// 解析发送结果
|
||||
let resultInfo = '消息发送成功!';
|
||||
if (res.errCode === 0) {
|
||||
resultInfo = `消息发送成功!\n错误码: ${res.errCode}, 错误信息: ${res.errMsg || '无'}`;
|
||||
} else {
|
||||
resultInfo = `消息发送失败!\n错误码: ${res.errCode}, 错误信息: ${res.errMsg || '未知错误'}`;
|
||||
}
|
||||
|
||||
alert(resultInfo);
|
||||
},
|
||||
fail: function(err) {
|
||||
console.error('发送消息失败:', err);
|
||||
messageData.textContent = `错误: ${JSON.stringify(err, null, 2)}`;
|
||||
messageResult.style.display = 'block';
|
||||
alert('发送消息失败: ' + (err.errMsg || '未知错误'));
|
||||
},
|
||||
cancel: function() {
|
||||
console.log('用户取消了发送消息操作');
|
||||
alert('用户取消了发送消息操作');
|
||||
sendMessageBtn.disabled = false;
|
||||
sendMessageBtn.textContent = '发送消息到当前会话';
|
||||
},
|
||||
complete: function() {
|
||||
console.log('sendChatMessage 调用完成');
|
||||
sendMessageBtn.disabled = false;
|
||||
sendMessageBtn.textContent = '发送消息到当前会话';
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error('未找到发送消息按钮!');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<p>企业微信 JSSDK 测试 - 开发环境</p>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user