feat: 实现心理健康评估系统核心功能

添加心理健康评估系统前端核心组件和功能,包括:
1. 评估表单发送与展示功能
2. 客户信息分析页面
3. 智能回复系统集成
4. 企业微信SDK集成
5. 响应式设计和移动端适配

实现与后端API的交互逻辑,包括客户信息获取、表单提交和智能回复生成
This commit is contained in:
2026-01-19 16:28:28 +08:00
commit c715b24f04
32 changed files with 12937 additions and 0 deletions

8
.editorconfig Normal file
View 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
View File

@@ -0,0 +1 @@
* text=auto eol=lf

33
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig"
]
}

0
1.md Normal file
View File

39
README.md Normal file
View 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
```

1
env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
eslint.config.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

146
src/App.vue Normal file
View 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
View 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;
}

View 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
View 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
View 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>

View 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
View 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>

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View 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
View 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))
},
},
})