feat: 实现心理健康评估系统核心功能
添加心理健康评估系统前端核心组件和功能,包括: 1. 评估表单发送与展示功能 2. 客户信息分析页面 3. 智能回复系统集成 4. 企业微信SDK集成 5. 响应式设计和移动端适配 实现与后端API的交互逻辑,包括客户信息获取、表单提交和智能回复生成
This commit is contained in:
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coding agents
|
||||
.iflow/
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# sendForm
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
20
eslint.config.ts
Normal file
20
eslint.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
)
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5383
package-lock.json
generated
Normal file
5383
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "sendform",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wecom/jssdk": "^2.3.1",
|
||||
"axios": "^1.12.2",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^22.16.5",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-vue": "~10.3.0",
|
||||
"jiti": "^2.4.2",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-vue-devtools": "^8.0.0",
|
||||
"vue-tsc": "^3.0.4"
|
||||
}
|
||||
}
|
||||
3311
pnpm-lock.yaml
generated
Normal file
3311
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
146
src/App.vue
Normal file
146
src/App.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 根据当前路由路径计算高亮的 Tab
|
||||
const activeTab = computed(() => {
|
||||
const path = route.path
|
||||
if (path.includes('/analysis')) return 'analysis'
|
||||
if (path.includes('/send-archive')) return 'archive'
|
||||
// 默认为 form (首页/发送评估表)
|
||||
return 'form'
|
||||
})
|
||||
|
||||
// Tab 切换处理函数
|
||||
const handleTabChange = (tab: string) => {
|
||||
if (tab === 'form') {
|
||||
router.push('/') // 假设首页路由是 '/'
|
||||
} else if (tab === 'analysis') {
|
||||
// 这里简单跳转,具体逻辑(如检查是否有数据)通常在组件内的 onMounted 处理,或者通过路由守卫处理
|
||||
router.push('/analysis')
|
||||
} else if (tab === 'archive') {
|
||||
router.push('/send-archive') // 确保你在 router/index.ts 中配置了这个路由
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app-container">
|
||||
<!-- 全局 Tab 导航栏 -->
|
||||
<div class="tab-header-wrapper">
|
||||
<div class="tab-header">
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'form' }"
|
||||
@click="handleTabChange('form')"
|
||||
>
|
||||
发送评估表
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'analysis' }"
|
||||
@click="handleTabChange('analysis')"
|
||||
>
|
||||
客户信息
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'archive' }"
|
||||
@click="handleTabChange('archive')"
|
||||
>
|
||||
发送档案表
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 路由视图:不同页面的内容将在这里渲染 -->
|
||||
<div class="page-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<!--以此保持组件状态,可选,如果不需要缓存可去掉 keep-alive -->
|
||||
<keep-alive include="HomePage">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 全局基础样式 */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
#app-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* Tab 栏样式 */
|
||||
.tab-header-wrapper {
|
||||
position: sticky; /* 可选:让 Tab 吸顶 */
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background-color: #f5f5f5; /* 与背景色一致,避免透明穿透 */
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 3px solid transparent;
|
||||
background-color: #fafafa;
|
||||
/* 禁止文字被选中,提升APP质感 */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #4ecdc4;
|
||||
background-color: #fff;
|
||||
border-bottom-color: #4ecdc4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.tab-item {
|
||||
padding: 12px 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18
src/assets/main.css
Normal file
18
src/assets/main.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* Vue应用的全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: white;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
728
src/components/AnalysisPage.vue
Normal file
728
src/components/AnalysisPage.vue
Normal file
@@ -0,0 +1,728 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SimpleChatService, type ChatResponse } from '@/utils/ChatService.js';
|
||||
import axios from 'axios';
|
||||
import * as ww from '@wecom/jssdk'
|
||||
|
||||
// 初始化ChatService实例
|
||||
const chatService_01 = new SimpleChatService('app-h4uBo5kOGoiYhjuBF1AHZi8b'); //基础信息分析
|
||||
// const chatService_02 = new SimpleChatService('app-IyAtULLEnSXdQrDzp4STn8EE');//sop分析简要版
|
||||
|
||||
// 定义客户信息的数据结构类型
|
||||
interface CustomerInfo {
|
||||
name: string;
|
||||
child_name: string;
|
||||
child_gender: string;
|
||||
occupation: string;
|
||||
child_education: string;
|
||||
child_relation: string;
|
||||
mobile: string;
|
||||
territory: string;
|
||||
additional_info: { topic: string; answer: string }[];
|
||||
}
|
||||
|
||||
// 定义聊天记录的数据结构类型
|
||||
interface ChatRecordItem {
|
||||
sender_role?: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// 定义通话记录的数据结构类型
|
||||
interface CallRecordItem {
|
||||
timestamp?: string;
|
||||
time?: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter()
|
||||
|
||||
// 客户信息状态
|
||||
const customerInfo = ref<CustomerInfo | null>(null)
|
||||
|
||||
// 分析结果状态
|
||||
const analysisResult = ref<string>('')
|
||||
const isLoading = ref(true)
|
||||
// SOP分析的加载状态
|
||||
const isSopAnalysisLoading = ref(false)
|
||||
// 是否存在SOP分析报告
|
||||
const hasSopReport = ref(false)
|
||||
|
||||
// 将Markdown格式的分析结果转换为HTML
|
||||
const formattedAnalysis = computed(() => {
|
||||
if (!analysisResult.value) return ''
|
||||
|
||||
// 替换Markdown标记为HTML标签
|
||||
return analysisResult.value
|
||||
.replace(/^# (.*$)/gim, '<strong>$1</strong>')
|
||||
.replace(/^## (.*$)/gim, '<strong>$1</strong>')
|
||||
.replace(/^### (.*$)/gim, '<strong>$1</strong>')
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||
.replace(/\n\n/g, '<br>')
|
||||
.replace(/\n/g, '<br>')
|
||||
})
|
||||
|
||||
// 获取持久化存储的数据
|
||||
const ChatRecord = ref<ChatRecordItem[]>([])
|
||||
const CallRecord = ref<CallRecordItem[]>([])
|
||||
const wechatId=ref<string>(localStorage.getItem('external_user_id') || '')
|
||||
const wecomeId=ref<string>(localStorage.getItem('wecome_id') || '')
|
||||
// 检查是否存在SOP分析报告
|
||||
const checkSopReportExists = async () => {
|
||||
if (!wechatId.value) return;
|
||||
try {
|
||||
const res = await axios.get('https://analysis.api.nycjy.cn/api/v1/call', {
|
||||
params: {
|
||||
wechat_id: wechatId.value
|
||||
}
|
||||
});
|
||||
if (res.data && res.data.report_content) {
|
||||
hasSopReport.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('检查SOP报告存在性失败(可能不存在):', error);
|
||||
hasSopReport.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 发送客户信息到API的函数
|
||||
const sendCustomerInfoToAPI = async () => {
|
||||
try {
|
||||
// 从localStorage获取持久化的externalUserId
|
||||
const externalUserId = localStorage.getItem('external_user_id')
|
||||
|
||||
const requestData = {
|
||||
wechat_enterprise_id: externalUserId
|
||||
}
|
||||
const response = await axios.post('https://sidebar.wx.nycjy.cn/api/v1/wecom/get_wechat_customers_info', requestData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
const responseData = response.data.data;
|
||||
ChatRecord.value = responseData.chat_history;
|
||||
CallRecord.value = responseData.call_history;
|
||||
wecomeId.value = responseData.wechat_id;
|
||||
|
||||
const customerInfoMap: Partial<CustomerInfo> = {};
|
||||
const additionalInfoList: CustomerInfo['additional_info'] = [];
|
||||
|
||||
// 定义问题标签到 CustomerInfo 属性的映射
|
||||
const fieldMap: { [key: string]: keyof CustomerInfo } = {
|
||||
'您的姓名:': 'name',
|
||||
'孩子姓名(小名):': 'child_name',
|
||||
'孩子性别(单选):': 'child_gender',
|
||||
'您的职业:': 'occupation',
|
||||
'孩子的年级:': 'child_education',
|
||||
'您是孩子的什么人?': 'child_relation',
|
||||
'报课手机号:': 'mobile',
|
||||
'您的所在地区:': 'territory',
|
||||
};
|
||||
|
||||
// **↓↓↓ 修改后的核心解析逻辑:统一处理 customer_form 数组中的所有表单项 ↓↓↓**
|
||||
const formsToProcess = responseData.customer_form || [];
|
||||
|
||||
for (const item of formsToProcess) {
|
||||
// 优先尝试处理包含 answers 数组的复杂结构
|
||||
if (Array.isArray(item.answers)) {
|
||||
item.answers.forEach((subItem: { question_label: string; answer: string }) => {
|
||||
const subLabel = (subItem.question_label || '').trim();
|
||||
let subAnswer = (subItem.answer || '').trim();
|
||||
|
||||
if (subLabel && subAnswer) {
|
||||
// 尝试清理数组字符串格式的答案,如 ['选项1', '选项2']
|
||||
try {
|
||||
if (subAnswer.startsWith("['") && subAnswer.endsWith("']")) {
|
||||
subAnswer = subAnswer.replace(/^\[\'|\'\]$/g, '').replace(/', '/g, ',').replace(/、\s*/g, ',').trim();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cleaning sub form answer:', e);
|
||||
}
|
||||
|
||||
if (subLabel in fieldMap) {
|
||||
(customerInfoMap[fieldMap[subLabel]] as string) = subAnswer;
|
||||
} else {
|
||||
// 补充信息
|
||||
additionalInfoList.push({
|
||||
topic: subLabel,
|
||||
answer: subAnswer,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
continue; // 处理完子数组后跳过当前 item 的后续处理
|
||||
}
|
||||
|
||||
// 处理扁平结构 (question_label/question + answer)
|
||||
const label = (item.question_label || item.question || '').trim();
|
||||
let answer = (item.answer || '').trim();
|
||||
|
||||
if (!label || !answer) {
|
||||
continue; // 跳过没有标签或答案的项
|
||||
}
|
||||
|
||||
// 尝试清理数组字符串格式的答案,如 ['选项1', '选项2']
|
||||
try {
|
||||
if (answer.startsWith("['") && answer.endsWith("']")) {
|
||||
// 尝试去除包裹的 [' 和 '],并替换分隔符 '、 ' 和 ', ' 为中文逗号,以便阅读
|
||||
answer = answer.replace(/^\[\'|\'\]$/g, '').replace(/', '/g, ',').replace(/、\s*/g, ',').trim();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cleaning form answer:', e);
|
||||
// 保留原始 answer
|
||||
}
|
||||
|
||||
if (label in fieldMap) {
|
||||
// 映射到主要的 CustomerInfo 属性
|
||||
(customerInfoMap[fieldMap[label]] as string) = answer;
|
||||
} else {
|
||||
// 添加到补充信息
|
||||
additionalInfoList.push({
|
||||
topic: label,
|
||||
answer: answer,
|
||||
});
|
||||
}
|
||||
}
|
||||
// **↑↑↑ 修改后的核心解析逻辑 ↑↑↑**
|
||||
|
||||
// 组装最终的 customerInfo 对象
|
||||
customerInfo.value = {
|
||||
name: customerInfoMap.name || '',
|
||||
child_name: customerInfoMap.child_name || '',
|
||||
child_gender: customerInfoMap.child_gender || '',
|
||||
occupation: customerInfoMap.occupation || '',
|
||||
child_education: customerInfoMap.child_education || '',
|
||||
child_relation: customerInfoMap.child_relation || '',
|
||||
mobile: customerInfoMap.mobile || '',
|
||||
territory: customerInfoMap.territory || '',
|
||||
additional_info: additionalInfoList, // 包含所有非核心字段的补充信息列表
|
||||
};
|
||||
|
||||
await generateAnalysis()
|
||||
} catch (error) {
|
||||
console.error('发送客户信息到API失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// SOP通话分析
|
||||
const startSopAnalysis = async () => {
|
||||
// 先获取客户信息和wecome_id
|
||||
await sendCustomerInfoToAPI();
|
||||
|
||||
if (!wecomeId.value) {
|
||||
console.error('无法获取 wecome_id,无法进行SOP分析');
|
||||
analysisResult.value = "分析失败: 缺少用户信息,无法开始SOP分析。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSopAnalysisLoading.value = true;
|
||||
analysisResult.value = ''; // 清空之前的分析结果
|
||||
|
||||
try {
|
||||
const res = await axios.get('https://analysis.api.nycjy.cn/api/v1/call', {
|
||||
params: {
|
||||
wechat_id: wechatId.value
|
||||
}
|
||||
});
|
||||
// 将SOP分析报告内容放入分析结果中以在页面显示
|
||||
analysisResult.value = res.data.report_content;
|
||||
} catch (error) {
|
||||
console.error('SOP通话分析失败:', error);
|
||||
// 检查 error 是否为 Error 的实例
|
||||
if (error instanceof Error) {
|
||||
analysisResult.value = `分析失败: ${error.message}`;
|
||||
} else {
|
||||
// 如果不是 Error 实例,则进行通用处理
|
||||
analysisResult.value = `分析失败: 发生未知错误`;
|
||||
}
|
||||
isSopAnalysisLoading.value = false;
|
||||
} finally {
|
||||
isSopAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fullAnalysis = ref('')
|
||||
// 使用ChatService生成分析报告
|
||||
const generateAnalysis = async () => {
|
||||
if (!customerInfo.value) {
|
||||
console.warn('没有客户信息,无法生成分析')
|
||||
return ''
|
||||
}
|
||||
|
||||
// 格式化聊天记录
|
||||
const chatRecords = ChatRecord.value && ChatRecord.value.length > 0
|
||||
? ChatRecord.value.map(record => `${record.sender_role || '用户'}: ${record.content || record.message || ''}`).join('\n')
|
||||
: '暂无聊天记录'
|
||||
|
||||
// 格式化通话记录
|
||||
const callRecords = CallRecord.value && CallRecord.value.length > 0
|
||||
? CallRecord.value.map(record => `时间:${record.timestamp || record.time || '未知'},内容:${record.content || record.summary || ''}`).join('\n')
|
||||
: '暂无通话记录'
|
||||
|
||||
// **新增:格式化补充信息** (所有表单非核心字段都汇聚于此)
|
||||
const additionalInfo = customerInfo.value.additional_info && customerInfo.value.additional_info.length > 0
|
||||
? customerInfo.value.additional_info.map(item => `${item.topic}: ${item.answer}`).join('\n')
|
||||
: '暂无补充信息'
|
||||
|
||||
|
||||
// 构建分析提示词,包含客户信息、补充信息和沟通记录
|
||||
const analysisPrompt = `请基于以下客户信息和沟通记录进行基础信息分析:
|
||||
|
||||
客户信息:
|
||||
姓名:${customerInfo.value.name || '未提供'}
|
||||
孩子姓名:${customerInfo.value.child_name || '未提供'}
|
||||
孩子性别:${customerInfo.value.child_gender || '未提供'}
|
||||
职业:${customerInfo.value.occupation || '未提供'}
|
||||
孩子教育阶段:${customerInfo.value.child_education || '未提供'}
|
||||
与孩子关系:${customerInfo.value.child_relation || '未提供'}
|
||||
手机号:${customerInfo.value.mobile || '未提供'}
|
||||
地区:${customerInfo.value.territory || '未提供'}
|
||||
|
||||
补充信息:
|
||||
${additionalInfo}
|
||||
|
||||
聊天记录:
|
||||
${chatRecords}
|
||||
|
||||
通话记录:
|
||||
${callRecords}
|
||||
|
||||
请提供详细的客户基础信息分析报告,包括客户需求分析、沟通偏好、潜在关注点等。`
|
||||
|
||||
console.log('发送给AI的提示词:', analysisPrompt)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
|
||||
chatService_01.sendMessage(
|
||||
analysisPrompt,
|
||||
(response: ChatResponse) => {
|
||||
// 实时更新分析结果到页面
|
||||
analysisResult.value = response.content
|
||||
fullAnalysis.value = response.content
|
||||
// console.log('当前analysisResult.value:', analysisResult.value)
|
||||
},
|
||||
() => {
|
||||
// console.log('最终analysisResult.value:', analysisResult.value)
|
||||
isLoading.value = false
|
||||
resolve(fullAnalysis)
|
||||
}
|
||||
).catch((error) => {
|
||||
console.error('ChatService调用失败:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时获取客户信息并生成分析
|
||||
onMounted(async () => {
|
||||
// 从localStorage获取客户信息(与HomePage.vue保持一致)
|
||||
const storedInfo = localStorage.getItem('customer_info')
|
||||
// console.log('从localStorage获取的客户信息:', storedInfo)
|
||||
if (storedInfo && storedInfo !== 'undefined') {
|
||||
customerInfo.value = JSON.parse(storedInfo)
|
||||
// await sendCustomerInfoToAPI()
|
||||
await checkSopReportExists()
|
||||
} else {
|
||||
// await sendCustomerInfoToAPI()
|
||||
await checkSopReportExists()
|
||||
}
|
||||
})
|
||||
// 客户分析建议书
|
||||
const AnalyzeReport= async ()=>{
|
||||
const report = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/get-form-report-by-wecom-id',{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wecom_id: wechatId.value
|
||||
})
|
||||
})
|
||||
const reportData = await report.json()
|
||||
analysisResult.value = reportData.data.form_report
|
||||
console.log('客户分析建议书:', reportData.data.form_report)
|
||||
}
|
||||
// 通话指引
|
||||
const BeginSopAnalysis= async ()=>{
|
||||
const report = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/get-customers-guide-by-wecom-id',{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wecom_id: wechatId.value
|
||||
})
|
||||
})
|
||||
const reportData = await report.json()
|
||||
analysisResult.value = reportData.data.customers_guide
|
||||
console.log('客户通话指引:', reportData.data.customers_guide)
|
||||
}
|
||||
const SendAnalysis= async ()=>{
|
||||
await ww.sendChatMessage({
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: analysisResult.value
|
||||
}
|
||||
})
|
||||
}
|
||||
// 返回上一页
|
||||
const handleBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
// 重新生成分析报告
|
||||
// const regenerateAnalysis = async () => {
|
||||
// if (!customerInfo.value || (!ChatRecord.value.length && !CallRecord.value.length)) {
|
||||
// console.warn('没有足够的数据(客户信息或记录)来生成分析')
|
||||
// return
|
||||
// }
|
||||
|
||||
// isLoading.value = true
|
||||
// analysisResult.value = ''
|
||||
|
||||
// try {
|
||||
// await generateAnalysis()
|
||||
// } catch (error) {
|
||||
// console.error('重新生成分析失败:', error)
|
||||
// analysisResult.value = '分析生成失败,请稍后重试。'
|
||||
// } finally {
|
||||
// isLoading.value = false
|
||||
// }
|
||||
// }
|
||||
|
||||
// 打开外部管理页面
|
||||
// const openExternalPage = () => {
|
||||
// window.open('http://localhost:8888', '_blank')
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="analysis-container">
|
||||
<div class="analysis-header">
|
||||
<div class="left-section">
|
||||
<button class="back-button" @click="handleBack">返回</button>
|
||||
<!-- <span>客户信息分析</span> -->
|
||||
<!-- </div>
|
||||
<div> -->
|
||||
<!-- <button class="regenerate-button" @click="regenerateAnalysis" :disabled="isLoading || isSopAnalysisLoading">{{ isLoading ? '生成中...' : '重新分析' }}</button>
|
||||
<button class="regenerate-button" @click="startSopAnalysis" :disabled="isLoading || isSopAnalysisLoading">{{ isSopAnalysisLoading ? 'SOP分析中...' : 'SOP分析' }}</button>
|
||||
<button class="external-link-button" @click="openExternalPage">打开管理页面</button> -->
|
||||
<button class="regenerate-button" @click="AnalyzeReport" >{{ '客户分析建议书' }}</button>
|
||||
<button v-if="hasSopReport" class="regenerate-button" @click="startSopAnalysis" >{{ 'SOP分析' }}</button>
|
||||
<button v-else class="regenerate-button" @click="BeginSopAnalysis" >{{ '通话指引' }}</button>
|
||||
<!-- <button class="external-link-button" @click="openExternalPage">打开管理页面</button> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="analysis-content">
|
||||
<!-- 数据状态信息 -->
|
||||
<!-- <div class="data-status">
|
||||
<h3>数据状态</h3>
|
||||
<p><strong>客户信息:</strong> {{ customerInfo ? '已加载' : '未加载' }}</p>
|
||||
<p><strong>聊天记录:</strong> {{ ChatRecord.length }} 条</p>
|
||||
<p><strong>通话记录:</strong> {{ CallRecord.length }} 条</p>
|
||||
<p><strong>分析结果:</strong> {{ analysisResult ? '已生成' : '未生成' }} (长度: {{ analysisResult ? analysisResult.length : 0 }})</p>
|
||||
</div> -->
|
||||
<!-- 分析结果 -->
|
||||
<div class="analysis-text">
|
||||
<div v-if="!analysisResult && isLoading" class="loading-section">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在努力生成客户分析报告,请稍候...</p>
|
||||
</div>
|
||||
<div v-else-if="!analysisResult" class="no-analysis">
|
||||
暂无分析结果
|
||||
</div>
|
||||
<div v-else v-html="formattedAnalysis"></div>
|
||||
</div>
|
||||
<div class="send-analysis-container">
|
||||
<button class="regenerate-button" @click="SendAnalysis" >{{ '发送分析' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analysis-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.analysis-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.regenerate-button {
|
||||
background: #4ecdc4;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.regenerate-button:hover:not(:disabled) {
|
||||
background: #45b7aa;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.regenerate-button:disabled {
|
||||
background: #95a5a6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.external-link-button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.external-link-button:hover {
|
||||
background: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.analysis-header h1 {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-section {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #4ecdc4;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-section p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.data-status {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.data-status h3 {
|
||||
color: #495057;
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 15px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.data-status p {
|
||||
margin: 8px 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.data-status strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.no-analysis {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.analysis-text {
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.analysis-text h1 {
|
||||
color: #2c3e50;
|
||||
font-size: 1.8rem;
|
||||
margin: 30px 0 20px 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #4ecdc4;
|
||||
}
|
||||
|
||||
.analysis-text h2 {
|
||||
color: #34495e;
|
||||
font-size: 1.4rem;
|
||||
margin: 25px 0 15px 0;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #4ecdc4;
|
||||
}
|
||||
|
||||
.analysis-text h3 {
|
||||
color: #555;
|
||||
font-size: 1.2rem;
|
||||
margin: 20px 0 10px 0;
|
||||
}
|
||||
|
||||
.analysis-text strong {
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.analysis-text em {
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.analysis-text ul {
|
||||
padding-left: 20px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.analysis-text li {
|
||||
margin: 8px 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.analysis-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.analysis-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.left-section {
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.regenerate-button,
|
||||
.external-link-button {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 80px;
|
||||
max-width: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.analysis-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.analysis-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.analysis-text h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.analysis-text h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.analysis-text h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.send-analysis-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 30px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.left-section {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.regenerate-button,
|
||||
.external-link-button {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.75rem;
|
||||
min-width: 70px;
|
||||
max-width: 100%;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
src/components/FormPage.vue
Normal file
310
src/components/FormPage.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-content">
|
||||
<div class="form-card">
|
||||
<div class="form-header">
|
||||
<div class="form-icon">📋</div>
|
||||
<h3>心理健康评估表</h3>
|
||||
<div class="form-date">填写时间: {{ formatDate(formData.created_at) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-details">
|
||||
<div class="basic-info">
|
||||
<h4>基本信息</h4>
|
||||
<div class="detail-item">
|
||||
<label>姓名:</label>
|
||||
<span>{{ formData.wechat_form.name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>孩子姓名:</label>
|
||||
<span>{{ formData.wechat_form.child_name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>孩子性别:</label>
|
||||
<span>{{ formData.wechat_form.child_gender }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>职业:</label>
|
||||
<span>{{ formData.wechat_form.occupation }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>孩子教育阶段:</label>
|
||||
<span>{{ formData.wechat_form.child_education }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>与孩子关系:</label>
|
||||
<span>{{ formData.wechat_form.child_relation }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>联系电话:</label>
|
||||
<span>{{ formData.wechat_form.mobile }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>所在地区:</label>
|
||||
<span>{{ formData.wechat_form.territory }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-info">
|
||||
<h4>详细评估信息</h4>
|
||||
<div
|
||||
v-for="(item, index) in formData.wechat_form.additional_info"
|
||||
:key="index"
|
||||
class="question-item"
|
||||
>
|
||||
<div class="question">
|
||||
<strong>{{ item.topic }}</strong>
|
||||
</div>
|
||||
<div class="answer">
|
||||
{{ item.answer }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" @click="handleViewFullForm">
|
||||
📄 查看完整表单
|
||||
</button>
|
||||
<button class="btn-secondary" @click="handleRefresh">
|
||||
🔄 刷新状态
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface FormData {
|
||||
wechat_form: {
|
||||
name: string
|
||||
child_name: string
|
||||
child_gender: string
|
||||
occupation: string
|
||||
child_education: string
|
||||
child_relation: string
|
||||
mobile: string
|
||||
territory: string
|
||||
additional_info: Array<{
|
||||
topic: string
|
||||
answer: string
|
||||
}>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
formData: FormData
|
||||
onViewFullForm: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return '未知时间'
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewFullForm = () => {
|
||||
props.onViewFullForm()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
props.onRefresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-header h3 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-date {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-details {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.basic-info, .additional-info {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.basic-info h4, .additional-info h4 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
border-left: 4px solid #4CAF50;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: 600;
|
||||
color: #34495e;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
color: #2c3e50;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.question-item {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.question {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.question strong {
|
||||
color: #2c3e50;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.answer {
|
||||
color: #34495e;
|
||||
line-height: 1.6;
|
||||
padding-left: 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #45a049, #3d8b40);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #ecf0f1;
|
||||
color: #34495e;
|
||||
border: 1px solid #bdc3c7;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d5dbdb;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.form-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
text-align: left;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
732
src/components/HomePage.vue
Normal file
732
src/components/HomePage.vue
Normal file
@@ -0,0 +1,732 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as ww from '@wecom/jssdk'
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface CustomerInfo {
|
||||
name: string;
|
||||
child_name: string;
|
||||
child_gender: string;
|
||||
occupation: string;
|
||||
child_education: string;
|
||||
child_relation: string;
|
||||
mobile: string;
|
||||
territory: string;
|
||||
additional_info: { topic: string; answer: string }[];
|
||||
}
|
||||
|
||||
interface RawAnswerItem {
|
||||
question_label: string;
|
||||
answer: string;
|
||||
question_id: string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 0. 数据转换函数
|
||||
// -------------------------------------------------------------------
|
||||
function mapAnswersToCustomerInfo(answers: RawAnswerItem[]): CustomerInfo {
|
||||
const mainFieldsMap: { [key: string]: keyof CustomerInfo } = {
|
||||
'您的姓名:': 'name',
|
||||
'孩子姓名(小名):': 'child_name',
|
||||
'孩子性别(单选):': 'child_gender',
|
||||
'您的职业:': 'occupation',
|
||||
'孩子的年级:': 'child_education',
|
||||
'您是孩子的什么人?': 'child_relation',
|
||||
'报课手机号:': 'mobile',
|
||||
'您的所在地区:': 'territory',
|
||||
};
|
||||
|
||||
const info: Partial<CustomerInfo> = { additional_info: [] };
|
||||
|
||||
answers.forEach(item => {
|
||||
const questionLabel = item.question_label.trim();
|
||||
const answer = item.answer;
|
||||
const fieldName = mainFieldsMap[questionLabel];
|
||||
if (fieldName) {
|
||||
(info as any)[fieldName] = answer;
|
||||
}
|
||||
// 无论是否匹配到主字段,都放入 additional_info 以便在列表中展示
|
||||
info.additional_info!.push({ topic: questionLabel, answer: answer });
|
||||
});
|
||||
|
||||
return info as CustomerInfo;
|
||||
}
|
||||
// -------------------------------------------------------------------
|
||||
// 1. 响应式状态
|
||||
// -------------------------------------------------------------------
|
||||
const router = useRouter()
|
||||
const isWWReady = ref(false)
|
||||
const externalUserId = ref<string | null>(localStorage.getItem('external_user_id'))
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const showPhoneInput = ref(false)
|
||||
const phoneNumber = ref('')
|
||||
|
||||
const customerInfo = ref<CustomerInfo | null>((() => {
|
||||
const stored = localStorage.getItem('customer_info')
|
||||
if (stored && stored !== 'undefined') {
|
||||
try {
|
||||
return JSON.parse(stored) as CustomerInfo
|
||||
} catch (error) {
|
||||
console.error('解析客户信息失败:', error)
|
||||
localStorage.removeItem('customer_info')
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
})())
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 2. JS-SDK 初始化 和 签名获取
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async function getConfigSignature(url: string) {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/config-signature', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
if (!response.ok) throw new Error('获取企业签名失败')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('[getConfigSignature]', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getAgentConfigSignature(url: string) {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/agent-config-signature', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
if (!response.ok) throw new Error('获取应用签名失败')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('[getAgentConfigSignature]', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const initWeWork = async () => {
|
||||
try {
|
||||
await ww.register({
|
||||
corpId: 'wwf72acc5a681dca93',
|
||||
agentId: 1000105,
|
||||
jsApiList: ['sendChatMessage', 'getCurExternalContact', 'getContext'],
|
||||
getConfigSignature,
|
||||
getAgentConfigSignature
|
||||
})
|
||||
isWWReady.value = true
|
||||
} catch (error) {
|
||||
isWWReady.value = false
|
||||
errorMessage.value = '企业微信JS-SDK初始化失败,请刷新页面重试。'
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. API调用函数
|
||||
// -------------------------------------------------------------------
|
||||
const checkFormExists = async (wechatEnterpriseId: string) => {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/check-form', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wechat_enterprise_id: wechatEnterpriseId })
|
||||
})
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('检查表单失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const checkPhoneExists = async (wechatEnterpriseId: string) => {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/check-phone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wechat_enterprise_id: wechatEnterpriseId })
|
||||
})
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('检查手机号失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const updatePhone = async (wechatEnterpriseId: string, phone: string) => {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/update-phone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wechat_enterprise_id: wechatEnterpriseId, phone })
|
||||
})
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('更新手机号失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 4. 核心业务逻辑
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const sendFormLink = async () => {
|
||||
if (!isWWReady.value) {
|
||||
alert('企业微信功能尚未准备好,请稍等片刻...')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ww.sendChatMessage({
|
||||
msgtype: 'news',
|
||||
news: {
|
||||
title: '青少年成长伙伴计划评估表',
|
||||
desc: '通过专业的评估工具,了解孩子的成长需求,为每个孩子制定个性化的成长方案',
|
||||
imgUrl: 'https://forms.nycjy.cn/favicon.ico',
|
||||
link: 'https://forms.nycjy.cn?wecom_id=' + externalUserId.value
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
alert('发送失败,详情请查看控制台日志。')
|
||||
}
|
||||
}
|
||||
|
||||
const getUserIn = async () => {
|
||||
try {
|
||||
const contact = await ww.getCurExternalContact()
|
||||
return contact
|
||||
} catch (error) {
|
||||
console.error('[getCurExternalContact]', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleButtonClick = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
if (!externalUserId.value) {
|
||||
alert('无法获取用户信息,请重新进入页面')
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
errorMessage.value = null
|
||||
try {
|
||||
// 获取手机号
|
||||
const phoneResponse = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/get-phone-by-wecom-id',{
|
||||
method:'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wecom_id: externalUserId.value })
|
||||
})
|
||||
const phoneData = await phoneResponse.json()
|
||||
console.log('获取手机号响应:', phoneData)
|
||||
if (phoneData.code === 0 && phoneData.data && phoneData.data.phone) {
|
||||
phoneNumber.value = phoneData.data.phone
|
||||
}
|
||||
console.log('手机号:', phoneNumber.value)
|
||||
await ww.sendChatMessage({
|
||||
msgtype: 'news',
|
||||
news: {
|
||||
title: '青少年成长伙伴计划评估表',
|
||||
desc: '通过专业的评估工具,了解孩子的成长需求,为每个孩子制定个性化的成长方案',
|
||||
imgUrl: 'https://forms.nycjy.cn/favicon.ico',
|
||||
link: 'https://forms.nycjy.cn?wecom_id=' + externalUserId.value
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('处理发送请求失败:', error)
|
||||
errorMessage.value = '处理请求失败,请重试'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitPhone = async () => {
|
||||
if (!phoneNumber.value.trim()) {
|
||||
alert('请输入手机号')
|
||||
return
|
||||
}
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(phoneNumber.value)) {
|
||||
alert('请输入正确的手机号格式')
|
||||
return
|
||||
}
|
||||
if (!externalUserId.value) {
|
||||
alert('无法获取用户信息,请重新进入页面')
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
errorMessage.value = null
|
||||
try {
|
||||
await updatePhone(externalUserId.value, phoneNumber.value)
|
||||
await sendFormLink()
|
||||
showPhoneInput.value = false
|
||||
phoneNumber.value = ''
|
||||
} catch (error) {
|
||||
console.error('提交手机号失败:', error)
|
||||
errorMessage.value = '提交手机号失败,请重试'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPhoneInput = () => {
|
||||
showPhoneInput.value = false
|
||||
phoneNumber.value = ''
|
||||
}
|
||||
|
||||
const fetchAndCheckExternalContact = async () => {
|
||||
if (!isWWReady.value) {
|
||||
console.warn('JSSDK未就绪,无法获取外部联系人')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const context = await ww.getContext()
|
||||
const allowedEntries = ['contact_profile', 'single_chat_tools']
|
||||
if (!allowedEntries.includes(context.entry)) {
|
||||
errorMessage.value = `当前入口 "${context.entry}" 不支持此功能。请从客户详情页或聊天工具栏进入。`
|
||||
return
|
||||
}
|
||||
const contact = await ww.getCurExternalContact()
|
||||
externalUserId.value = contact.userId
|
||||
localStorage.setItem('external_user_id', contact.userId)
|
||||
|
||||
if (externalUserId.value) {
|
||||
const result = await checkFormExists(externalUserId.value)
|
||||
if (result.data.code && result.data.form_content && result.data.form_content.length > 0) {
|
||||
const rawAnswers = result.data.form_content[0].answers as RawAnswerItem[];
|
||||
console.log('原始答案:', rawAnswers);
|
||||
const mappedCustomerInfo = mapAnswersToCustomerInfo(rawAnswers);
|
||||
customerInfo.value = mappedCustomerInfo;
|
||||
localStorage.setItem('customer_info', JSON.stringify(mappedCustomerInfo));
|
||||
} else {
|
||||
customerInfo.value = null
|
||||
localStorage.removeItem('customer_info')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取外部联系人信息流程失败:', error)
|
||||
errorMessage.value = `获取客户信息失败: ${error.errMsg || '未知错误'}`
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 5. 辅助功能
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
await initWeWork()
|
||||
await getUserIn()
|
||||
if (isWWReady.value) {
|
||||
await fetchAndCheckExternalContact()
|
||||
}
|
||||
})
|
||||
|
||||
const handleCardHover = (event: Event, isEnter: boolean) => {
|
||||
const card = event.currentTarget as HTMLElement
|
||||
card.style.transform = isEnter ? 'translateY(-10px) scale(1.02)' : 'translateY(0) scale(1)'
|
||||
}
|
||||
|
||||
// 按钮跳转逻辑
|
||||
const goToAnalysis = () => {
|
||||
if (customerInfo.value) {
|
||||
sessionStorage.setItem('customerInfo', JSON.stringify(customerInfo.value))
|
||||
router.push('/analysis')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSmartReply = () => {
|
||||
router.push('/smart-reply')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="errorMessage" class="error-banner">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 场景一:已获取到客户信息,显示详情卡片 -->
|
||||
<div v-if="customerInfo" class="customer-details-card fade-in">
|
||||
<h2><span class="card-icon green">📋</span> 客户评估信息</h2>
|
||||
<div class="qa-section">
|
||||
<ul>
|
||||
<li v-for="(item, index) in customerInfo.additional_info" :key="index" class="qa-item">
|
||||
<span class="question">{{ index + 1 }}. {{ item.topic }} :</span>
|
||||
<span class="answer">{{ item.answer }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<div class="button-group">
|
||||
<!-- 快捷跳转按钮:虽然顶部有Tab,但这里的按钮也是很好的引导 -->
|
||||
<button class="card-button smart-reply-button" @click="handleSmartReply">
|
||||
🤖 智能回复
|
||||
</button>
|
||||
<button class="card-button" @click="handleButtonClick" :disabled="isLoading">
|
||||
<span v-if="isLoading">处理中...</span>
|
||||
<span v-else>📧 发送二阶评估表</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 场景二:手机号输入界面 -->
|
||||
<div v-else-if="showPhoneInput" class="phone-input-card fade-in">
|
||||
<h2><span class="card-icon green">📱</span> 请输入您的手机号</h2>
|
||||
<p class="phone-input-desc">为了更好地为您提供服务,请输入您的手机号码</p>
|
||||
|
||||
<div class="phone-input-form">
|
||||
<input v-model="phoneNumber" type="tel" placeholder="请输入11位手机号" class="phone-input" maxlength="11"
|
||||
@keyup.enter="submitPhone" />
|
||||
|
||||
<div class="phone-button-group">
|
||||
<button class="card-button cancel-button" @click="cancelPhoneInput" :disabled="isLoading">
|
||||
取消
|
||||
</button>
|
||||
<button class="card-button smart-reply-button" @click="handleSmartReply">
|
||||
🤖 智能回复
|
||||
</button>
|
||||
<button class="card-button submit-button" @click="submitPhone" :disabled="isLoading || !phoneNumber.trim()">
|
||||
<span v-if="isLoading">提交中...</span>
|
||||
<span v-else>确认提交</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 场景三:未获取到客户信息,显示发送表单卡片 -->
|
||||
<div v-else class="card-grid">
|
||||
<div class="card fade-in" style="animation-delay: 0.1s" @mouseenter="(e) => handleCardHover(e, true)"
|
||||
@mouseleave="(e) => handleCardHover(e, false)">
|
||||
<div class="card-icon green">🌱</div>
|
||||
<h3>青少年成长伙伴计划评估表</h3>
|
||||
<p>通过专业的评估工具,了解孩子的成长需求,为每个孩子制定个性化的成长方案,陪伴他们健康快乐地成长。</p>
|
||||
<div class="card-meta">
|
||||
<span>暖洋葱家庭教育</span>
|
||||
<div class="button-group">
|
||||
<button class="card-button smart-reply-button" @click="handleSmartReply">
|
||||
🤖 智能回复
|
||||
</button>
|
||||
<button class="card-button" @click="sendFormLink" :disabled="isLoading">
|
||||
<span v-if="isLoading">处理中...</span>
|
||||
<span v-else>📧 发送评估表</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 这里只保留卡片和内容的样式,不需要 tab 相关的样式了 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* container 样式由 App.vue 统一管理了,这里只需要负责内部布局 */
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 25px;
|
||||
padding: 10px 0 20px 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #4ecdc4, #44a08d);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
background: #333;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-button:hover {
|
||||
background: #555;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.analysis-button {
|
||||
background: #4ecdc4;
|
||||
}
|
||||
|
||||
.analysis-button:hover {
|
||||
background: #44a08d;
|
||||
}
|
||||
|
||||
.smart-reply-button {
|
||||
background: #9c27b0;
|
||||
}
|
||||
|
||||
.smart-reply-button:hover {
|
||||
background: #7b1fa2;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.6s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
margin: 0 auto 20px auto;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ef9a9a;
|
||||
}
|
||||
|
||||
.customer-details-card {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e0e0e0;
|
||||
max-width: 900px;
|
||||
margin: 0 auto 15px auto;
|
||||
}
|
||||
|
||||
.customer-details-card h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.customer-details-card h2 .card-icon {
|
||||
margin-bottom: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.qa-section h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qa-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.qa-item {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qa-item .question {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qa-item .answer {
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 手机号输入界面样式 */
|
||||
.phone-input-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e0e0e0;
|
||||
max-width: 500px;
|
||||
margin: 20px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.phone-input-card h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phone-input-desc {
|
||||
color: #666;
|
||||
margin-bottom: 25px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.phone-input-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.phone-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.phone-input:focus {
|
||||
outline: none;
|
||||
border-color: #4ecdc4;
|
||||
}
|
||||
|
||||
.phone-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.phone-button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancel-button:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: #4ecdc4;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: #44a08d;
|
||||
}
|
||||
|
||||
.card-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.customer-details-card {
|
||||
padding: 12px 15px;
|
||||
margin: 0 auto 10px auto;
|
||||
}
|
||||
|
||||
.qa-item {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.qa-item .question {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.qa-item .answer {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.phone-input-card {
|
||||
padding: 20px;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
.phone-button-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
src/components/LoadingPage.vue
Normal file
47
src/components/LoadingPage.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<h3>正在加载...</h3>
|
||||
<p>正在获取外部联系人信息,请稍候</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 加载页面组件
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #4CAF50;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-state h3 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
334
src/components/SendPage.vue
Normal file
334
src/components/SendPage.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🌟 青少年心理健康评估</h1>
|
||||
<p class="subtitle">专业、科学、个性化的心理健康分析</p>
|
||||
</div>
|
||||
|
||||
<div class="cta-section">
|
||||
<h2>开始您的专业评估之旅</h2>
|
||||
<p class="cta-description">
|
||||
只需5-10分钟,即可获得专业的家庭教育档案信息报告
|
||||
</p>
|
||||
|
||||
<!-- 修改部分:按钮增加了 disabled 属性、动态 class 和动态文本 -->
|
||||
<button
|
||||
class="cta-button"
|
||||
:class="{ 'disabled': isCoolingDown }"
|
||||
@click="handleSendForm"
|
||||
:disabled="isCoolingDown"
|
||||
>
|
||||
{{ isCoolingDown ? `📝 请等待 ${countdown} 秒...` : '📝 发送评估表' }}
|
||||
</button>
|
||||
|
||||
<p class="note">
|
||||
💡 评估完全免费,结果仅供参考,如需专业帮助请咨询心理医生
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as ww from '@wecom/jssdk'
|
||||
|
||||
const isWWReady = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
// --- 防重复点击逻辑 ---
|
||||
const isCoolingDown = ref(false) // 是否处于冷却期
|
||||
const countdown = ref(0) // 倒计时秒数
|
||||
|
||||
const handleSendForm = () => {
|
||||
// 如果正在冷却中,直接阻断
|
||||
if (isCoolingDown.value) return
|
||||
|
||||
// 启动10秒倒计时
|
||||
startCooldown(10)
|
||||
|
||||
// 执行业务逻辑
|
||||
GetFormUrl()
|
||||
}
|
||||
|
||||
// 倒计时工具函数
|
||||
const startCooldown = (seconds: number) => {
|
||||
isCoolingDown.value = true
|
||||
countdown.value = seconds
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
isCoolingDown.value = false // 倒计时结束,恢复按钮可用
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
// --------------------
|
||||
|
||||
async function getConfigSignature(url: string) {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/config-signature', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
if (!response.ok) throw new Error('获取企业签名失败')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('[getConfigSignature]', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getAgentConfigSignature(url: string) {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/agent-config-signature', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
if (!response.ok) throw new Error('获取应用签名失败')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('[getAgentConfigSignature]', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const initWeWork = async () => {
|
||||
try {
|
||||
await ww.register({
|
||||
corpId: 'wwf72acc5a681dca93',
|
||||
agentId: 1000105,
|
||||
jsApiList: ['sendChatMessage', 'getCurExternalContact', 'getContext'],
|
||||
getConfigSignature,
|
||||
getAgentConfigSignature
|
||||
})
|
||||
isWWReady.value = true
|
||||
} catch (error) {
|
||||
isWWReady.value = false
|
||||
errorMessage.value = '企业微信JS-SDK初始化失败,请刷新页面重试。'
|
||||
}
|
||||
}
|
||||
|
||||
const getUserInfo = async () => {
|
||||
try {
|
||||
const response = await ww.getCurExternalContact()
|
||||
return response.userId
|
||||
} catch (error) {
|
||||
console.error('[getCurExternalContact] 失败', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const GetFormUrl = async () => {
|
||||
try {
|
||||
const userId = await getUserInfo()
|
||||
const response = await fetch(`https://liaison.nycjy.cn/api/v1/archive/form-url?user_id=${userId}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
const formUrl = await response.json()
|
||||
console.log('[GetFormUrl]', formUrl.data)
|
||||
sendFormLink(formUrl.data.form_url)
|
||||
if (!response.ok) throw new Error('获取表单URL失败')
|
||||
} catch (error) {
|
||||
console.error('[GetFormUrl]', error)
|
||||
// 如果希望出错时允许立即重试,可以在这里取消冷却:
|
||||
// isCoolingDown.value = false
|
||||
alert('请求失败,请检查网络或稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
const sendFormLink = async (formUrl: string) => {
|
||||
if (!isWWReady.value) {
|
||||
alert('企业微信功能尚未准备好,请稍等片刻...')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ww.sendChatMessage({
|
||||
msgtype: 'news',
|
||||
news: {
|
||||
title: '家庭教育档案信息表',
|
||||
desc: '通过专业的评估工具,了解孩子的成长需求,为每个孩子制定个性化的成长方案',
|
||||
imgUrl: 'https://forms.nycjy.cn/favicon.ico',
|
||||
link: formUrl
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
alert('发送失败,详情请查看控制台日志。')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initWeWork()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card.highlight {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: inherit;
|
||||
opacity: 0.9;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border-radius: 20px;
|
||||
padding: 3rem 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cta-section h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cta-description {
|
||||
font-size: 1.1rem;
|
||||
color: #5a6c7d;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
background: linear-gradient(135deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2.5rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: linear-gradient(135deg, #45a049, #3d8b40);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
/* 禁用状态样式 */
|
||||
.cta-button.disabled {
|
||||
background: #bdc3c7; /* 灰色背景 */
|
||||
cursor: not-allowed;
|
||||
transform: none; /* 移除悬浮效果 */
|
||||
box-shadow: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cta-button.disabled:hover {
|
||||
background: #bdc3c7;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
973
src/components/SmartReplyPage.vue
Normal file
973
src/components/SmartReplyPage.vue
Normal file
@@ -0,0 +1,973 @@
|
||||
<template>
|
||||
<div class="smart-reply-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<button class="back-button" @click="goBack">
|
||||
← 返回
|
||||
</button>
|
||||
<h1>🤖 智能回复助手</h1>
|
||||
<p>基于AI的智能回复建议,提供沟通辅助</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key 配置区域 -->
|
||||
<div v-if="!isApiKeyConfigured" class="api-config-card">
|
||||
<h3>🔑 配置API Key</h3>
|
||||
<p>请输入您的Dify API Key以使用智能回复功能:</p>
|
||||
<div class="api-input-group">
|
||||
<input v-model="apiKeyInput" type="password" placeholder="请输入Dify API Key" class="api-input"
|
||||
@keyup.enter="configureApiKey" />
|
||||
<button @click="configureApiKey" class="config-button">
|
||||
配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要功能区域 -->
|
||||
<div v-else class="main-content">
|
||||
<!-- 智能回复建议区域 -->
|
||||
<div class="smart-reply-section">
|
||||
|
||||
<!-- 重新生成按钮 -->
|
||||
<div class="regenerate-button-container">
|
||||
<button @click="refreshAndGenerateReply" :disabled="isGeneratingReply" class="regenerate-button">
|
||||
{{ isGeneratingReply ? '生成中...' : '🔄 生成智能建议' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 自动回复进度条 -->
|
||||
<div v-if="showAutoReplyProgress" class="auto-reply-progress">
|
||||
<div class="progress-info">
|
||||
<span>⏱️ 自动生成智能回复中...</span>
|
||||
<span>{{ Math.round(autoReplyProgress) }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: autoReplyProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isGeneratingReply" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在生成智能回复建议...</span>
|
||||
</div>
|
||||
|
||||
<!-- 新版建议卡片 -->
|
||||
<div v-else-if="replyData && replyData.suggestions && replyData.suggestions.length > 0"
|
||||
class="new-reply-container">
|
||||
<!-- 当前阶段显示 -->
|
||||
<div class="current-stage">
|
||||
当前沟通阶段: {{ replyData.analysis?.stage || '信息收集期' }}
|
||||
</div>
|
||||
|
||||
<div class="stage-title">智能回复建议:</div>
|
||||
|
||||
<!-- 建议卡片列表 -->
|
||||
<div class="suggestions-list">
|
||||
<div v-for="(suggestion, index) in replyData.suggestions" :key="index" class="suggestion-card"
|
||||
:class="{ selected: selectedSuggestion?.priority === suggestion.priority }">
|
||||
<div class="suggestion-content">
|
||||
<div class="suggestion-header">
|
||||
{{ suggestion.priority }}. {{ suggestion.type }}:
|
||||
</div>
|
||||
<div class="suggestion-detail">
|
||||
{{ suggestion.content }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="selectSuggestion(suggestion)" class="select-button"
|
||||
:class="{ selected: selectedSuggestion?.priority === suggestion.priority }">
|
||||
选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认发送按钮 -->
|
||||
<div class="send-button-container">
|
||||
<button @click="confirmSendReply" class="main-send-button" :disabled="!selectedSuggestion || !isWWReady">
|
||||
确认发送到聊天
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<div class="usage-notice">
|
||||
<div class="notice-icon">ⓘ</div>
|
||||
<div class="notice-content">
|
||||
<div class="notice-title">使用说明:</div>
|
||||
<div class="notice-text">
|
||||
请根据分析结果,选择最合适的回复建议,适当编辑后发送给家长。所有建议都是为了最终促成20分钟电话沟通。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 兼容旧版格式 -->
|
||||
<div v-else-if="smartReplyOptions.length > 0 || smartReplyContent" class="legacy-reply">
|
||||
<!-- 选项式回复 -->
|
||||
<div v-if="smartReplyOptions.length > 0" class="reply-options">
|
||||
<p class="options-title">点击选择合适的回复:</p>
|
||||
<div class="options-grid">
|
||||
<button v-for="(option, index) in smartReplyOptions" :key="index" @click="() => selectReply(option)"
|
||||
class="option-button" :class="{ selected: selectedReply === option }" title="点击选择此回复">
|
||||
{{ option }}
|
||||
<span class="select-icon">{{ selectedReply === option ? '✅' : '📝' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本式回复 -->
|
||||
<div v-else-if="smartReplyContent" class="reply-content">
|
||||
<p class="content-title">AI建议回复:</p>
|
||||
<div class="content-text">
|
||||
{{ smartReplyContent }}
|
||||
</div>
|
||||
<button @click="() => selectReply(smartReplyContent)" class="use-suggestion-button"
|
||||
:class="{ selected: selectedReply === smartReplyContent }" title="点击选择此回复">
|
||||
{{ selectedReply === smartReplyContent ? '已选择' : '选择此回复' }} <span class="select-icon">{{ selectedReply ===
|
||||
smartReplyContent ? '✅' : '📝' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 确认发送按钮 -->
|
||||
<div v-if="selectedReply" class="confirm-send-inline">
|
||||
<button @click="confirmSendReply" class="confirm-send-button" :disabled="!isWWReady">
|
||||
<span class="button-icon">📤</span>
|
||||
{{ isWWReady ? '确认发送到聊天' : '企业微信未就绪' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-state">
|
||||
<p>暂无智能回复建议,请点击"生成智能建议"按钮</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSmartReply } from '@/composables/useSmartReply.js'
|
||||
import * as ww from '@wecom/jssdk'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 使用智能回复composable
|
||||
const {
|
||||
isGeneratingReply,
|
||||
smartReplyContent,
|
||||
smartReplyOptions,
|
||||
replyData, // 导入新版结构化数据
|
||||
showAutoReplyProgress,
|
||||
autoReplyProgress,
|
||||
initChatService,
|
||||
getSmartReplyFromDify,
|
||||
selectReplyOption
|
||||
} = useSmartReply()
|
||||
|
||||
// API Key 配置
|
||||
const apiKeyInput = ref('app-pK17zkMC2Fvzp3oa6rl1zFZn') // 默认API密钥
|
||||
const isApiKeyConfigured = ref(false)
|
||||
|
||||
// 企业微信相关状态
|
||||
const isWWReady = ref(false)
|
||||
|
||||
// 选中的回复内容(兼容旧版)
|
||||
const selectedReply = ref<string>('')
|
||||
|
||||
// 新版:建议类型定义
|
||||
interface Suggestion {
|
||||
priority: number
|
||||
type: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// 选中的建议
|
||||
const selectedSuggestion = ref<Suggestion | null>(null)
|
||||
|
||||
// 配置API Key
|
||||
const configureApiKey = () => {
|
||||
if (apiKeyInput.value.trim()) {
|
||||
initChatService(apiKeyInput.value.trim())
|
||||
isApiKeyConfigured.value = true
|
||||
// 可以将API Key保存到localStorage(注意安全性)
|
||||
localStorage.setItem('dify_api_key', apiKeyInput.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// 企业微信JSSDK相关函数
|
||||
// 企业签名函数
|
||||
async function getConfigSignature(url: string) {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/config-signature', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
if (!response.ok) throw new Error('获取企业签名失败')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('[getConfigSignature]', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 应用签名函数
|
||||
async function getAgentConfigSignature(url: string) {
|
||||
try {
|
||||
const response = await fetch('https://sidebar.wx.nycjy.cn/api/v1/wecom/agent-config-signature', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
if (!response.ok) throw new Error('获取应用签名失败')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('[getAgentConfigSignature]', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化企业微信JS-SDK
|
||||
const initWeWork = async () => {
|
||||
try {
|
||||
// console.log('开始初始化企业微信JS-SDK...')
|
||||
await ww.register({
|
||||
corpId: 'wwf72acc5a681dca93',
|
||||
agentId: 1000105,
|
||||
jsApiList: ['sendChatMessage'],
|
||||
getConfigSignature,
|
||||
getAgentConfigSignature
|
||||
})
|
||||
isWWReady.value = true
|
||||
// console.log('企业微信JS-SDK初始化成功!isWWReady:', isWWReady.value)
|
||||
} catch (error) {
|
||||
isWWReady.value = false
|
||||
console.error('企业微信JS-SDK初始化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息到聊天区域
|
||||
const sendMessageToChat = async (message: string) => {
|
||||
if (!isWWReady.value) {
|
||||
// console.warn('企业微信功能尚未准备好,isWWReady:', isWWReady.value)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log('发送消息到聊天区域:', message)
|
||||
// console.log('使用ww.sendChatMessage...')
|
||||
await ww.sendChatMessage({
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: message
|
||||
}
|
||||
})
|
||||
console.log('消息已成功发送到聊天区域!')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 选择回复内容(兼容旧版)
|
||||
const selectReply = (option: string) => {
|
||||
console.log('选择回复:', option)
|
||||
selectedReply.value = option
|
||||
// 同时调用原始的selectReplyOption方法(保持兼容性)
|
||||
selectReplyOption(option)
|
||||
}
|
||||
|
||||
// 新版:选择建议
|
||||
const selectSuggestion = (suggestion: Suggestion) => {
|
||||
console.log('选择建议:', suggestion)
|
||||
selectedSuggestion.value = suggestion
|
||||
}
|
||||
|
||||
// 确认发送选中的回复到聊天区域
|
||||
const confirmSendReply = async () => {
|
||||
// 新版格式:发送选中的建议
|
||||
if (selectedSuggestion.value) {
|
||||
if (!isWWReady.value) {
|
||||
alert('企业微信功能尚未准备好,请稍等片刻...')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await sendMessageToChat(selectedSuggestion.value.content)
|
||||
|
||||
if (success) {
|
||||
// 清空选中状态
|
||||
selectedSuggestion.value = null
|
||||
} else {
|
||||
console.log('❌ 发送智能回复失败')
|
||||
alert('发送失败,请重试')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送回复时出错:', error)
|
||||
alert('发送失败,请重试')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 旧版格式:发送选中的文本回复
|
||||
if (!selectedReply.value) {
|
||||
alert('请先选择一个回复内容')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isWWReady.value) {
|
||||
alert('企业微信功能尚未准备好,请稍等片刻...')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await sendMessageToChat(selectedReply.value)
|
||||
|
||||
if (success) { // 清空选中状态
|
||||
selectedReply.value = ''
|
||||
} else {
|
||||
console.log('❌ 发送智能回复失败')
|
||||
alert('发送失败,请重试')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送回复时出错:', error)
|
||||
alert('发送失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 刷新并准备生成回复
|
||||
const refreshAndGenerateReply = () => {
|
||||
// 设置一个标志位,告诉页面刷新后需要执行某个动作
|
||||
sessionStorage.setItem('shouldGenerateReplyAfterRefresh', 'true')
|
||||
|
||||
// 强制刷新页面
|
||||
location.reload()
|
||||
}
|
||||
|
||||
|
||||
// 页面初始化
|
||||
onMounted(async () => {
|
||||
// 尝试从localStorage恢复API Key,如果没有则使用默认值
|
||||
const savedApiKey = localStorage.getItem('dify_api_key')
|
||||
if (savedApiKey) {
|
||||
apiKeyInput.value = savedApiKey
|
||||
}
|
||||
|
||||
// 如果有API Key(默认或保存的),自动配置
|
||||
if (apiKeyInput.value.trim()) {
|
||||
configureApiKey()
|
||||
}
|
||||
|
||||
// 初始化企业微信JSSDK
|
||||
console.log('开始初始化企业微信JSSDK...')
|
||||
try {
|
||||
await initWeWork()
|
||||
} catch (error) {
|
||||
console.error('企业微信JSSDK初始化失败:', error)
|
||||
isWWReady.value = false
|
||||
}
|
||||
|
||||
// ***** 修改点 3: 检查刷新后的标志位 *****
|
||||
// 检查sessionStorage中是否有需要执行的标志
|
||||
if (sessionStorage.getItem('shouldGenerateReplyAfterRefresh') === 'true') {
|
||||
// 3. 清除标志,防止下次手动刷新时再次触发
|
||||
sessionStorage.removeItem('shouldGenerateReplyAfterRefresh');
|
||||
|
||||
// 4. 执行原有的操作
|
||||
// 加一个小的延迟确保页面元素都准备好了
|
||||
setTimeout(() => {
|
||||
getSmartReplyFromDify();
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-reply-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* API配置卡片 */
|
||||
.api-config-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.api-config-card h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.api-input-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 500px;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.api-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.api-input:focus {
|
||||
outline: none;
|
||||
border-color: #9c27b0;
|
||||
}
|
||||
|
||||
.config-button {
|
||||
background: #9c27b0;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.config-button:hover {
|
||||
background: #7b1fa2;
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
/* 控制面板 */
|
||||
.control-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feature-toggle {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-button.active {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.regenerate-button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.regenerate-button {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.regenerate-button:hover:not(:disabled) {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.regenerate-button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 自动回复进度条 */
|
||||
.auto-reply-progress {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #8bc34a);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 智能回复区域 */
|
||||
.smart-reply-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.smart-reply-section h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.suggestion-info {
|
||||
background: #e8f5e8;
|
||||
border: 1px solid #c8e6c9;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.suggestion-info p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #2e7d32;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-top: 2px solid #9c27b0;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 回复选项 */
|
||||
.reply-options {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.options-title {
|
||||
margin-bottom: 15px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.option-button {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e0e0e0;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-button:hover {
|
||||
background: #e8f4fd;
|
||||
border-color: #1890ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.option-button .select-icon {
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-button.selected {
|
||||
background: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.option-button.selected .select-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 文本回复 */
|
||||
.reply-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
margin-bottom: 15px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.use-suggestion-button {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.use-suggestion-button:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.use-suggestion-button.selected {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.use-suggestion-button.selected:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 内联确认发送按钮 */
|
||||
.confirm-send-inline {
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-send-inline .confirm-send-button {
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.confirm-send-inline .confirm-send-button:hover:not(:disabled) {
|
||||
background: #73d13d;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.confirm-send-inline .confirm-send-button:disabled {
|
||||
background: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.feature-toggle {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.api-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 新版建议卡片样式 */
|
||||
.new-reply-container {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.current-stage {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stage-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
background: white;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.suggestion-card:hover {
|
||||
border-color: #d0d0d0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.suggestion-card.selected {
|
||||
border-color: #5b5fc7;
|
||||
background: #f8f8ff;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.suggestion-header {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.suggestion-detail {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.select-button {
|
||||
background: white;
|
||||
border: 2px solid #5b5fc7;
|
||||
color: #5b5fc7;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.select-button:hover {
|
||||
background: #5b5fc7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.select-button.selected {
|
||||
background: #5b5fc7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.send-button-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.main-send-button {
|
||||
width: 100%;
|
||||
background: #5b5fc7;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.main-send-button:hover:not(:disabled) {
|
||||
background: #4a4fb5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(91, 95, 199, 0.3);
|
||||
}
|
||||
|
||||
.main-send-button:disabled {
|
||||
background: #d0d0d0;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.usage-notice {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.notice-icon {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 兼容旧版格式样式 */
|
||||
.legacy-reply {
|
||||
padding: 15px 0;
|
||||
}
|
||||
</style>
|
||||
63
src/composables/useSmartReply.d.ts
vendored
Normal file
63
src/composables/useSmartReply.d.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Ref } from 'vue'
|
||||
|
||||
export interface SmartReplyFeatures {
|
||||
smart: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
text: string
|
||||
is_self_msg: boolean
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SmartReplyOption {
|
||||
text: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface GeneratedReplyData {
|
||||
id: string
|
||||
content: string
|
||||
confidence?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ReplyData {
|
||||
analysis?: {
|
||||
stage?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
suggestions?: Array<{
|
||||
priority: number
|
||||
type: string
|
||||
content: string
|
||||
}>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UseSmartReplyReturn {
|
||||
// 状态
|
||||
features: SmartReplyFeatures
|
||||
isGeneratingReply: Ref<boolean>
|
||||
smartReplyContent: Ref<string>
|
||||
smartReplyOptions: Ref<string[]>
|
||||
generatedReplyData: Ref<GeneratedReplyData>
|
||||
replyData: Ref<ReplyData | null>
|
||||
showAutoReplyProgress: Ref<boolean>
|
||||
autoReplyProgress: Ref<number>
|
||||
messages: Ref<Message[]>
|
||||
newMessage: Ref<string>
|
||||
|
||||
// 方法
|
||||
initChatService: (apiKey: string) => void
|
||||
toggleFeature: (feature: keyof SmartReplyFeatures) => void
|
||||
getSmartReplyFromDify: () => Promise<void>
|
||||
selectReplyOption: (option: string | SmartReplyOption) => void
|
||||
handleReceivedMessage: (message: Message) => void
|
||||
sendMessage: () => void
|
||||
simulateReceivedMessage: (text: string) => void
|
||||
clearAutoReplyTimer: () => void
|
||||
startAutoReplyTimer: () => void
|
||||
}
|
||||
|
||||
export declare function useSmartReply(): UseSmartReplyReturn
|
||||
415
src/composables/useSmartReply.js
Normal file
415
src/composables/useSmartReply.js
Normal file
@@ -0,0 +1,415 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
import { SimpleChatService } from '@/utils/ChatService.js'
|
||||
import axios from 'axios'
|
||||
|
||||
// 全局状态,可以在多个组件间共享
|
||||
const globalState = {
|
||||
// 功能开关(默认开启智能回复功能)
|
||||
features: reactive({ smart: true }),
|
||||
|
||||
// 加载状态,用于显示"正在生成..."的UI提示
|
||||
isGeneratingReply: ref(false),
|
||||
|
||||
// 存储AI返回结果的两个容器
|
||||
// 1. 用于存储纯文本格式的建议
|
||||
smartReplyContent: ref(''),
|
||||
// 2. 用于存储选项按钮格式的建议
|
||||
smartReplyOptions: ref([]),
|
||||
// 3. 用于存储新版结构化数据
|
||||
replyData: ref(null),
|
||||
|
||||
// 自动触发机制的状态变量
|
||||
autoReplyTimer: null, // 15秒延时定时器ID
|
||||
progressTimer: null, // 进度条更新定时器ID
|
||||
showAutoReplyProgress: ref(false), // 控制进度条的显示
|
||||
autoReplyProgress: ref(0), // 进度条的百分比 (0-100)
|
||||
|
||||
// 聊天消息历史
|
||||
messages: ref([]),
|
||||
|
||||
// 当前输入的消息
|
||||
newMessage: ref(''),
|
||||
|
||||
// 客户信息和记录数据
|
||||
customerInfo: ref(null),
|
||||
chatRecord: ref([]),
|
||||
callRecord: ref([]),
|
||||
|
||||
// 构建的完整查询内容
|
||||
queryContent: '',
|
||||
|
||||
// ChatService实例
|
||||
chatService: null
|
||||
}
|
||||
|
||||
export function useSmartReply() {
|
||||
// 获取客户信息和记录数据
|
||||
const fetchCustomerData = async () => {
|
||||
try {
|
||||
// 从localStorage获取持久化的externalUserId
|
||||
const externalUserId = localStorage.getItem('external_user_id')
|
||||
|
||||
if (!externalUserId) {
|
||||
console.warn('未找到 external_user_id,无法获取客户信息')
|
||||
return
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
wechat_enterprise_id: externalUserId
|
||||
}
|
||||
|
||||
const response = await axios.post('https://sidebar.wx.nycjy.cn/api/v1/wecom/get_wechat_customers_info', requestData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 存储客户信息
|
||||
globalState.customerInfo.value = response.data.data.customer_form
|
||||
|
||||
// 将additional_info数组转换为以topic为键、answer为值的对象
|
||||
const additionalInfoObject = {}
|
||||
if (globalState.customerInfo.value?.additional_info && Array.isArray(globalState.customerInfo.value.additional_info)) {
|
||||
globalState.customerInfo.value.additional_info.forEach(item => {
|
||||
if (item.topic && item.answer) {
|
||||
// 移除topic末尾的冒号和空格(如果有的话)
|
||||
const cleanTopic = item.topic.replace(/[::]$/, '').trim()
|
||||
additionalInfoObject[cleanTopic] = item.answer
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 只存储最新的30条消息,并过滤掉链接和图片,同时格式化为查询字符串
|
||||
const allChatHistory = response.data.data.chat_history || []
|
||||
const filteredChatHistory = allChatHistory
|
||||
.filter(message => {
|
||||
// 过滤掉包含链接的消息
|
||||
const content = message.content || message.message || ''
|
||||
const hasLink = /https?:\/\/[^\s]+/i.test(content)
|
||||
|
||||
// 过滤掉图片消息(通常包含 [图片] 或 image 等标识)
|
||||
const hasImage = /\[图片\]|\[image\]|<img|\.jpg|\.jpeg|\.png|\.gif|\.webp/i.test(content)
|
||||
|
||||
// 只保留纯文本消息
|
||||
return !hasLink && !hasImage && content.trim().length > 0
|
||||
})
|
||||
.slice(-30)
|
||||
|
||||
globalState.chatRecord.value = filteredChatHistory
|
||||
|
||||
// 直接在过滤后格式化聊天记录为查询字符串
|
||||
const formattedChatRecords = filteredChatHistory.length > 0
|
||||
? filteredChatHistory.map(record => `${record.sender_role || '用户'}: ${record.content || record.message || ''}`).join('\n')
|
||||
: '暂无聊天记录'
|
||||
|
||||
// 存储通话记录
|
||||
globalState.callRecord.value = response.data.data.call_history
|
||||
|
||||
// 构建包含完整客户信息的查询内容并存储到全局状态
|
||||
// 按照新格式重新组织客户信息
|
||||
const parentBasicInfo = `## 一、家长基本信息
|
||||
姓名:${globalState.customerInfo.value?.name || '未提供'}
|
||||
家长关系:${globalState.customerInfo.value?.child_relation || '未提供'}
|
||||
职业:${globalState.customerInfo.value?.occupation || '未提供'}
|
||||
家长学历:${additionalInfoObject['家长学历:'] || additionalInfoObject['家长学历'] || '未提供'}
|
||||
地区:${globalState.customerInfo.value?.territory || '未提供'}
|
||||
手机号:${globalState.customerInfo.value?.mobile || '未提供'}`;
|
||||
|
||||
|
||||
const childBasicInfo = `## 二、孩子基本信息
|
||||
孩子姓名:${globalState.customerInfo.value?.child_name || '未提供'}
|
||||
孩子性别:${globalState.customerInfo.value?.child_gender || '未提供'}
|
||||
孩子教育阶段:${globalState.customerInfo.value?.child_education || '未提供'}
|
||||
咱们家几个孩子?:${additionalInfoObject['咱们家几个孩子?'] || '未提供'}`;
|
||||
|
||||
const childBehaviorInfo = `## 三、孩子学习与行为表现
|
||||
孩子的成绩一般是?:${additionalInfoObject['孩子的成绩一般是?'] || '未提供'}
|
||||
上学时孩子学习状态:${additionalInfoObject['上学时孩子学习状态:'] || additionalInfoObject['上学时孩子学习状态'] || '未提供'}
|
||||
手机使用情况:${additionalInfoObject['手机使用情况:'] || additionalInfoObject['手机使用情况'] || '未提供'}`;
|
||||
|
||||
const parentChildInfo = `## 四、亲子互动情况
|
||||
孩子跟您的关系如何?:${additionalInfoObject['孩子跟您的关系如何?'] || '未提供'}
|
||||
跟孩子相处时间:${additionalInfoObject['跟孩子相处时间:'] || additionalInfoObject['跟孩子相处时间'] || '未提供'}`;
|
||||
|
||||
const coreRequest = `## 五、本次核心诉求
|
||||
本次学习,你最想解决那个问题?:${additionalInfoObject['本次学习,你最想解决那个问题?'] || '未提供'}`;
|
||||
|
||||
globalState.queryContent = `# 客户信息:
|
||||
${parentBasicInfo}
|
||||
|
||||
${childBasicInfo}
|
||||
|
||||
${childBehaviorInfo}
|
||||
|
||||
${parentChildInfo}
|
||||
|
||||
${coreRequest}
|
||||
|
||||
# 聊天记录:
|
||||
${formattedChatRecords}
|
||||
`;
|
||||
/**
|
||||
* 构建的查询内容: 客户信息:
|
||||
姓名:222
|
||||
孩子姓名:11
|
||||
孩子性别:男孩
|
||||
职业:公务员
|
||||
孩子教育阶段:五年级
|
||||
与孩子关系:父亲
|
||||
手机号:18203843742
|
||||
地区:河南省 郑州市 中原区
|
||||
基本信息:
|
||||
家长学历:高中
|
||||
咱们家几个孩子?:1个
|
||||
上学时孩子学习状态:不去上学:休学,不去上学。哪怕跟家长说好了去上学,到时间又不去。
|
||||
孩子的成绩一般是?:非常差
|
||||
手机使用情况:手机成瘾,黑白颠倒
|
||||
孩子跟您的关系如何?:亲密无间,无话不说
|
||||
跟孩子相处时间:几乎每天都在一起
|
||||
本次学习,你最想解决那个问题?:成绩提升
|
||||
*/
|
||||
|
||||
console.log('客户数据获取成功:', response.data)
|
||||
console.log('构建的查询内容:', globalState.queryContent)
|
||||
} catch (error) {
|
||||
console.error('获取客户数据失败:', error)
|
||||
}
|
||||
}
|
||||
// 初始化ChatService(需要API Key)
|
||||
const initChatService = (apiKey) => {
|
||||
if (!globalState.chatService && apiKey) {
|
||||
globalState.chatService = new SimpleChatService(apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除自动回复定时器
|
||||
const clearAutoReplyTimer = () => {
|
||||
if (globalState.autoReplyTimer) {
|
||||
clearTimeout(globalState.autoReplyTimer)
|
||||
globalState.autoReplyTimer = null
|
||||
}
|
||||
if (globalState.progressTimer) {
|
||||
clearInterval(globalState.progressTimer)
|
||||
globalState.progressTimer = null
|
||||
}
|
||||
globalState.showAutoReplyProgress.value = false
|
||||
globalState.autoReplyProgress.value = 0
|
||||
}
|
||||
|
||||
// 启动自动回复计时器
|
||||
const startAutoReplyTimer = () => {
|
||||
// 清除之前的定时器
|
||||
clearAutoReplyTimer()
|
||||
|
||||
// 显示进度条
|
||||
globalState.showAutoReplyProgress.value = true
|
||||
globalState.autoReplyProgress.value = 0
|
||||
|
||||
// 启动进度条更新定时器(每150ms更新一次,15秒内从0到100)
|
||||
const progressInterval = 150 // ms
|
||||
const totalTime = 15000 // 15秒
|
||||
const progressStep = (progressInterval / totalTime) * 100
|
||||
|
||||
globalState.progressTimer = setInterval(() => {
|
||||
globalState.autoReplyProgress.value += progressStep
|
||||
if (globalState.autoReplyProgress.value >= 100) {
|
||||
globalState.autoReplyProgress.value = 100
|
||||
clearInterval(globalState.progressTimer)
|
||||
globalState.progressTimer = null
|
||||
}
|
||||
}, progressInterval)
|
||||
|
||||
// 启动15秒延时定时器
|
||||
globalState.autoReplyTimer = setTimeout(() => {
|
||||
// 15秒后自动生成智能回复
|
||||
getSmartReplyFromDify()
|
||||
globalState.showAutoReplyProgress.value = false
|
||||
}, totalTime)
|
||||
}
|
||||
|
||||
// 核心AI请求与处理函数
|
||||
const getSmartReplyFromDify = async () => {
|
||||
if (!globalState.chatService) {
|
||||
console.error('ChatService未初始化,请先调用initChatService')
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 准备工作:清除定时器,设置加载状态
|
||||
clearAutoReplyTimer()
|
||||
globalState.isGeneratingReply.value = true
|
||||
globalState.smartReplyContent.value = ''
|
||||
globalState.smartReplyOptions.value = []
|
||||
globalState.replyData.value = null
|
||||
|
||||
try {
|
||||
// 2. 获取客户信息和记录数据(在fetchCustomerData中已经构建了完整的查询内容)
|
||||
await fetchCustomerData()
|
||||
|
||||
// 3. 使用预构建的查询内容
|
||||
const query = globalState.queryContent
|
||||
|
||||
// 4. 发送请求(流式):调用封装好的 ChatService
|
||||
await globalState.chatService.sendMessage(
|
||||
query,
|
||||
// onMessageUpdate 回调:处理AI返回的每一个数据块
|
||||
(messageUpdate) => {
|
||||
const content = messageUpdate.content
|
||||
|
||||
// 5. 智能解析:判断返回的是JSON选项还是纯文本
|
||||
// 先暂存内容,避免频繁解析不完整的 JSON
|
||||
globalState.smartReplyContent.value = content
|
||||
|
||||
// 检查 JSON 是否可能完整(简单判断:大括号数量匹配)
|
||||
const openBraces = (content.match(/\{/g) || []).length
|
||||
const closeBraces = (content.match(/\}/g) || []).length
|
||||
|
||||
// 只有当大括号匹配时才尝试解析,避免解析不完整的 JSON
|
||||
if (openBraces > 0 && openBraces === closeBraces) {
|
||||
try {
|
||||
// 尝试提取完整的 JSON 对象(支持嵌套)
|
||||
let parsedData = null
|
||||
|
||||
// 方法1: 尝试直接解析整个内容
|
||||
try {
|
||||
parsedData = JSON.parse(content)
|
||||
} catch (e) {
|
||||
// 方法2: 尝试提取 JSON 部分(查找第一个 { 到最后一个 })
|
||||
const firstBrace = content.indexOf('{')
|
||||
const lastBrace = content.lastIndexOf('}')
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const jsonStr = content.substring(firstBrace, lastBrace + 1)
|
||||
try {
|
||||
parsedData = JSON.parse(jsonStr)
|
||||
} catch (e2) {
|
||||
// 静默失败,等待更多数据
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedData) {
|
||||
// 检查是否为新版格式(包含 analysis 和 suggestions 字段)
|
||||
if (parsedData.analysis && parsedData.suggestions && Array.isArray(parsedData.suggestions)) {
|
||||
// 新版格式:存储到 replyData
|
||||
globalState.replyData.value = parsedData
|
||||
globalState.smartReplyContent.value = '' // 清空文本内容
|
||||
console.log('✅ 解析到新版格式数据:', parsedData)
|
||||
}
|
||||
// 检查旧版格式(包含"选项"字段)
|
||||
else if (JSON.stringify(parsedData).includes('"选项')) {
|
||||
// 旧版选项格式:填充 smartReplyOptions 数组
|
||||
globalState.smartReplyOptions.value = Object.values(parsedData)
|
||||
globalState.smartReplyContent.value = '' // 清空文本内容
|
||||
console.log('✅ 解析到旧版选项格式:', parsedData)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,继续显示文本内容
|
||||
}
|
||||
}
|
||||
},
|
||||
// onFinish 回调:流式响应结束
|
||||
() => {
|
||||
// 6. 结束工作:关闭加载状态
|
||||
globalState.isGeneratingReply.value = false
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('获取智能回复失败:', error)
|
||||
globalState.isGeneratingReply.value = false
|
||||
globalState.smartReplyContent.value = '抱歉,获取智能回复失败,请稍后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
// 切换功能开关
|
||||
const toggleFeature = () => {
|
||||
globalState.features.smart = !globalState.features.smart
|
||||
|
||||
if (globalState.features.smart) {
|
||||
// 开启功能:清空旧数据并立即获取第一次建议
|
||||
globalState.smartReplyContent.value = ''
|
||||
globalState.smartReplyOptions.value = []
|
||||
getSmartReplyFromDify()
|
||||
} else {
|
||||
// 关闭功能:清空所有数据并取消所有定时器
|
||||
clearAutoReplyTimer()
|
||||
globalState.smartReplyContent.value = ''
|
||||
globalState.smartReplyOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 选择回复选项
|
||||
const selectReplyOption = (option) => {
|
||||
// 将建议内容直接赋值给与 v-textarea 双向绑定的 newMessage
|
||||
globalState.newMessage.value = option
|
||||
}
|
||||
|
||||
// 处理收到的消息(用于自动触发)
|
||||
const handleReceivedMessage = (message) => {
|
||||
// 添加消息到历史记录
|
||||
globalState.messages.value.push(message)
|
||||
|
||||
// 如果是客户消息且智能回复功能已开启,启动自动回复计时器
|
||||
if (!message.is_self_msg && globalState.features.smart) {
|
||||
startAutoReplyTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = () => {
|
||||
if (!globalState.newMessage.value.trim()) return
|
||||
|
||||
const message = {
|
||||
text: globalState.newMessage.value,
|
||||
is_self_msg: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 添加到消息历史
|
||||
globalState.messages.value.push(message)
|
||||
|
||||
// 清空输入框
|
||||
globalState.newMessage.value = ''
|
||||
|
||||
// 如果有自动回复计时器在运行,取消它(因为用户已经手动回复了)
|
||||
clearAutoReplyTimer()
|
||||
}
|
||||
|
||||
// 模拟接收消息(用于测试)
|
||||
const simulateReceivedMessage = (text) => {
|
||||
const message = {
|
||||
text: text,
|
||||
is_self_msg: false,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
handleReceivedMessage(message)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
features: globalState.features,
|
||||
isGeneratingReply: globalState.isGeneratingReply,
|
||||
smartReplyContent: globalState.smartReplyContent,
|
||||
smartReplyOptions: globalState.smartReplyOptions,
|
||||
replyData: globalState.replyData, // 新增:导出新版结构化数据
|
||||
showAutoReplyProgress: globalState.showAutoReplyProgress,
|
||||
autoReplyProgress: globalState.autoReplyProgress,
|
||||
messages: globalState.messages,
|
||||
newMessage: globalState.newMessage,
|
||||
customerInfo: globalState.customerInfo,
|
||||
chatRecord: globalState.chatRecord,
|
||||
callRecord: globalState.callRecord,
|
||||
|
||||
// 方法
|
||||
initChatService,
|
||||
fetchCustomerData,
|
||||
toggleFeature,
|
||||
getSmartReplyFromDify,
|
||||
selectReplyOption,
|
||||
handleReceivedMessage,
|
||||
sendMessage,
|
||||
simulateReceivedMessage,
|
||||
clearAutoReplyTimer,
|
||||
startAutoReplyTimer
|
||||
}
|
||||
}
|
||||
13
src/main.ts
Normal file
13
src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
40
src/router/index.ts
Normal file
40
src/router/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomePage from '../components/HomePage.vue'
|
||||
import AnalysisPage from '../components/AnalysisPage.vue'
|
||||
import SmartReplyPage from '../components/SmartReplyPage.vue'
|
||||
// 引入你的发送档案表组件 (根据你的截图文件名)
|
||||
import SendPage from '../components/SendPage.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: HomePage,
|
||||
meta: { title: '发送评估表' }
|
||||
},
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'Analysis',
|
||||
component: AnalysisPage,
|
||||
meta: { title: '客户信息分析' }
|
||||
},
|
||||
{
|
||||
path: '/send-archive',
|
||||
name: 'SendArchive',
|
||||
component: SendPage,
|
||||
meta: { title: '发送档案表' }
|
||||
},
|
||||
{
|
||||
path: '/smart-reply',
|
||||
name: 'SmartReply',
|
||||
component: SmartReplyPage,
|
||||
meta: { title: '智能回复' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
12
src/stores/counter.ts
Normal file
12
src/stores/counter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
23
src/utils/ChatService.d.ts
vendored
Normal file
23
src/utils/ChatService.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface ChatResponse {
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export interface MessageUpdateCallback {
|
||||
(response: ChatResponse): void;
|
||||
}
|
||||
|
||||
export interface StreamEndCallback {
|
||||
(): void;
|
||||
}
|
||||
|
||||
export class SimpleChatService {
|
||||
constructor(apiKey: string, baseUrl?: string);
|
||||
|
||||
sendMessage(
|
||||
userMessage: string,
|
||||
onMessageUpdate: MessageUpdateCallback,
|
||||
onStreamEnd: StreamEndCallback,
|
||||
abortSignal?: AbortSignal | null
|
||||
): Promise<void>;
|
||||
}
|
||||
168
src/utils/ChatService.js
Normal file
168
src/utils/ChatService.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Dify API的基础URL,可以根据你的实际部署地址进行修改
|
||||
const BaseUrl = 'https://ai.nycjy1.cn/v1'; // 示例: 替换成你的 Dify API 地址
|
||||
|
||||
/**
|
||||
* 处理从Dify API返回的流式数据块。
|
||||
* @param chunk - 从服务器接收到的数据块字符串。
|
||||
* @param options - 包含当前状态和回调函数的对象。
|
||||
* @returns 更新后的状态对象。
|
||||
*/
|
||||
function handleStreamResponse(chunk, options) {
|
||||
try {
|
||||
// 忽略空的数据块
|
||||
if (!chunk.trim()) {
|
||||
return options;
|
||||
}
|
||||
|
||||
// 流式数据可能包含多个 "data: {...}" 块,它们由两个换行符分隔
|
||||
const lines = chunk.split('\n\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// 跳过无效行或非数据行
|
||||
if (!line.trim() || !line.startsWith('data:')) continue;
|
||||
|
||||
// 提取 "data:" 后面的JSON字符串
|
||||
const jsonStr = line.slice(5).trim();
|
||||
if (!jsonStr) continue;
|
||||
|
||||
try {
|
||||
// 确保是完整的JSON对象
|
||||
if (!jsonStr.startsWith('{') || !jsonStr.endsWith('}')) {
|
||||
console.warn('接收到不完整的JSON对象:', jsonStr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
// 事件类型为 'message',表示正在接收AI的回答
|
||||
if (data.event === 'message') {
|
||||
const newContent = data.answer || '';
|
||||
options.fullResponse += newContent; // 将新的内容追加到完整响应中
|
||||
|
||||
// 调用实时更新回调函数
|
||||
options.onMessageUpdate({
|
||||
content: options.fullResponse,
|
||||
isStreaming: true
|
||||
});
|
||||
|
||||
// 事件类型为 'message_end',表示消息流结束
|
||||
} else if (data.event === 'message_end') {
|
||||
options.isStreamEnded = true;
|
||||
|
||||
// 调用最终更新回调函数
|
||||
options.onMessageUpdate({
|
||||
content: options.fullResponse,
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
// 调用流结束的回调
|
||||
options.onStreamEnd();
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('JSON解析错误:', {
|
||||
error: parseError,
|
||||
rawData: jsonStr,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('流处理时发生错误:', e);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个简单的Dify聊天服务类。
|
||||
*/
|
||||
export class SimpleChatService {
|
||||
constructor(apiKey, baseUrl = BaseUrl) {
|
||||
if (!apiKey) {
|
||||
throw new Error("API Key是必须的。");
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到Dify API并处理流式响应。
|
||||
* @param userMessage - 用户发送的消息。
|
||||
* @param onMessageUpdate - 当收到新数据块时调用的回调函数。
|
||||
* @param onStreamEnd - 当流结束时调用的回调函数。
|
||||
* @param abortSignal - 用于取消请求的AbortSignal。
|
||||
*/
|
||||
async sendMessage(
|
||||
userMessage,
|
||||
onMessageUpdate,
|
||||
onStreamEnd,
|
||||
abortSignal = null
|
||||
) {
|
||||
const params = {
|
||||
inputs: {},
|
||||
query: userMessage,
|
||||
response_mode: 'streaming', // 声明需要流式响应
|
||||
conversation_id: '', // 如果需要继续之前的对话,请传入对应的ID
|
||||
user: 'web-user-axios-simple',
|
||||
auto_generate_name: true
|
||||
};
|
||||
|
||||
let state = {
|
||||
fullResponse: '',
|
||||
isStreamEnded: false,
|
||||
};
|
||||
|
||||
// 用于跟踪已处理的响应文本长度
|
||||
let processedLength = 0;
|
||||
|
||||
try {
|
||||
await axios({
|
||||
method: 'POST',
|
||||
url: `${this.baseUrl}/chat-messages`,
|
||||
data: params,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
responseType: 'text', // 必须设置为 'text' 以便处理流式响应
|
||||
signal: abortSignal, // 关联取消信号
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
// 获取自上次回调以来新增的文本部分
|
||||
const newChunk = progressEvent.event.target.responseText.substring(processedLength);
|
||||
processedLength = progressEvent.event.target.responseText.length;
|
||||
|
||||
// 将新的数据块传递给处理函数
|
||||
state = handleStreamResponse(newChunk, {
|
||||
...state,
|
||||
onMessageUpdate,
|
||||
onStreamEnd,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 检查请求完成后,流是否已正常结束
|
||||
if (!state.isStreamEnded) {
|
||||
console.warn("请求已完成,但未收到 'message_end' 事件。");
|
||||
onMessageUpdate({
|
||||
content: state.fullResponse,
|
||||
isStreaming: false
|
||||
});
|
||||
onStreamEnd();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
console.log('请求已被用户取消。');
|
||||
} else {
|
||||
console.error('发送消息失败:', error);
|
||||
onMessageUpdate({
|
||||
content: '抱歉,发送消息失败,请检查API Key或网络连接后重试。',
|
||||
isStreaming: false
|
||||
});
|
||||
}
|
||||
// 确保在出错或取消时也调用 onStreamEnd
|
||||
onStreamEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
tsconfig.app.json
Normal file
12
tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./node_modules/@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user