Files
ks-app-employment-service/pages/chat/components/ai-paging.vue
2025-03-28 18:19:05 +08:00

840 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="chat-container">
<FadeView :show="!messages.length" :duration="600">
<view class="chat-background">
<image class="backlogo" src="/static/icon/backAI.png"></image>
<view class="back-rowTitle">欢迎使用青岛AI智能求职</view>
<view class="back-rowText">
我可以根据您的简历和求职需求帮你精准匹配青岛市互联网招聘信息对比招聘信息的优缺点提供面试指导等请把你的任务交给我吧~
</view>
<view class="back-rowh3">猜你所想</view>
<view class="back-rowmsg">我希望找青岛的IT行业岗位薪资能否在12000以上</view>
<view class="back-rowmsg">我有三年的工作经验能否推荐一些适合我的青岛的国企 岗位</view>
</view>
</FadeView>
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
<FadeView :show="messages.length" :duration="600">
<view class="chat-list list-content">
<view
v-for="(msg, index) in messages"
:key="index"
:id="'msg-' + index"
class="chat-item"
:class="{ self: msg.self }"
>
<text class="message" v-if="msg.self">
<view class="msg-filecontent" v-if="msg.files.length">
<view
class="msg-files"
v-for="(file, vInex) in msg.files"
:key="vInex"
@click="jumpUrl(file)"
>
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
<text class="msg-file-text">{{ file.name || '附件' }}</text>
</view>
</view>
{{ msg.displayText }}
</text>
<text class="message" :class="{ messageNull: !msg.displayText }" v-else>
<!-- {{ msg.displayText }} -->
<md-render :content="msg.displayText"></md-render>
<!-- guess -->
<view
class="guess"
v-if="showGuess && !msg.self && messages.length - 1 === index && msg.displayText"
>
<view class="guess-top">
<image class="guess-icon" src="/static/icon/tips2.png" mode=""></image>
猜你所想
</view>
<view class="gulist">
<view
class="guess-list"
@click="sendMessageGuess(item)"
v-for="(item, index) in guessList"
:key="index"
>
{{ item }}
</view>
</view>
</view>
</text>
</view>
<view v-if="isTyping" :class="{ self: true }">
<text class="message msg-loading">
<span class="ai-loading"></span>
</text>
</view>
</view>
</FadeView>
</scroll-view>
<view class="vio_container" :class="status" v-if="status !== 'idle'">
<view class="record-tip">{{ statusText }}</view>
<AudioWave :background="audiowaveStyle" />
</view>
<view class="input-area" v-else>
<view class="areatext">
<input
v-model="textInput"
placeholder-class="inputplaceholder"
class="input"
@confirm="sendMessage"
:disabled="isTyping"
:adjust-position="false"
placeholder="请输入您的职位名称、薪资要求、岗位地址"
v-if="!isVoice"
/>
<view
class="input_vio"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
type="default"
v-else
>
按住说话
</view>
<!-- upload -->
<view class="btn-box" @click="changeShowFile">
<image
class="send-btn"
:class="{ 'add-file-btn': showfile }"
src="/static/icon/addGroup.png"
></image>
</view>
<!-- sendmessgae Button-->
<view class="btn-box purple" v-if="textInput && !isTyping" @click="sendMessage">
<image class="send-btn" src="/static/icon/send3.png"></image>
</view>
<view class="btn-box" v-else-if="!isTyping && !isVoice" @click="changeVoice">
<image class="send-btn" src="/static/icon/send2x.png"></image>
</view>
<view class="btn-box" v-else-if="!isTyping" @click="changeVoice">
<image class="send-btn" src="/static/icon/send4.png"></image>
</view>
<view class="btn-box" v-else>
<image class="send-btn" src="/static/icon/send2xx.png"></image>
</view>
</view>
<!-- btn -->
<CollapseTransition :show="showfile">
<view class="area-file">
<view class="file-card" @click="uploadCamera">
<image class="card-img" src="/static/icon/file1.png"></image>
<text>拍照上传</text>
</view>
<view class="file-card" @click="uploadCamera('album')">
<image class="card-img" src="/static/icon/file2.png"></image>
<text>相册上传</text>
</view>
<view class="file-card" @click="getUploadFile">
<image class="card-img" src="/static/icon/file3.png"></image>
<text>文件上传</text>
</view>
</view>
</CollapseTransition>
<!-- filelist -->
<view class="area-uploadfiles" v-if="filesList.length">
<scroll-view class="uploadfiles-scroll" scroll-x="true">
<view class="uploadfiles-list">
<view
class="file-uploadsend"
:class="{ 'file-border': isImage(file.type) }"
v-for="(file, index) in filesList"
:key="index"
>
<image
class="file-iconImg"
@click="jumpUrl(file)"
v-if="isImage(file.type)"
:src="file.url"
></image>
<view class="file-doc" @click="jumpUrl(file)" v-else>
<view style="width: 100%">
<view class="filename-text">{{ file.name }}</view>
<view class="filename-size">{{ file.size }}</view>
</view>
<FileIcon :type="file.type"></FileIcon>
<!-- <image class="file-icon" @click="jumpUrl(file)" :src=""></image> -->
</view>
<view class="file-del" catchtouchmove="true" @click="delfile(file)">
<uni-icons type="closeempty" color="#4B4B4B" size="10"></uni-icons>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, inject, nextTick, defineProps, defineEmits, onMounted, toRaw, reactive, computed } from 'vue';
import { storeToRefs } from 'pinia';
import config from '@/config.js';
import useChatGroupDBStore from '@/stores/userChatGroupStore';
const { $api, navTo, throttle } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
import MdRender from '@/components/md-render/md-render.vue';
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
import CollapseTransition from '@/components/CollapseTransition/CollapseTransition.vue';
import FadeView from '@/components/FadeView/FadeView.vue';
import AudioWave from './AudioWave.vue';
import FileIcon from './fileIcon.vue';
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
const { isRecording, recognizedText, startRecording, stopRecording, cancelRecording } = useAudioRecorder(
config.vioceBaseURl
);
const guessList = ref([]);
const scrollTop = ref(0);
const showGuess = ref(false);
const showfile = ref(false);
const filesList = ref([]);
const bgText = ref(false);
const isVoice = ref(false);
const status = ref('idle'); // idle | recording | cancel
const startY = ref(0);
const cancelThreshold = 100;
let recordingTimer = null;
const state = reactive({
uploadFileTips: '请根据以上附件,帮我推荐岗位。',
});
onMounted(() => {
scrollToBottom();
});
const sendMessage = () => {
const values = textInput.value;
showfile.value = false;
showGuess.value = false;
if (values.trim()) {
// 判断是否有对话ID // 没有则创建对话列表
const callback = () => {
const normalArr = toRaw(filesList.value); // 转换为普通数组
filesList.value = [];
const newMsg = { text: values, self: true, displayText: values, files: normalArr };
useChatGroupDBStore().addMessage(newMsg);
useChatGroupDBStore()
.getStearm(values, normalArr, scrollToBottom)
.then(() => {
console.log(messages);
getGuess();
scrollToBottom();
});
emit('onConfirm', values);
textInput.value = '';
scrollToBottom();
};
// 没有对话列表则创建
if (!chatSessionID.value) {
useChatGroupDBStore()
.addTabel(values)
.then((res) => {
callback();
});
} else {
callback();
}
} else {
if (filesList.value.length) {
$api.msg('上传文件请输入想问的问题描述');
} else {
$api.msg('请输入职位信息或描述');
}
}
};
const sendMessageGuess = (item) => {
showGuess.value = false;
textInput.value = item;
sendMessage(item);
};
const delfile = (file) => {
uni.showModal({
content: '确认删除文件?',
success(res) {
if (res.confirm) {
filesList.value = filesList.value.filter((item) => item.url !== file.url);
if (!filesList.value.length) {
if (textInput.value === state.uploadFileTips) {
textInput.value = '';
}
}
$api.msg('附件删除成功');
}
},
});
};
const scrollToBottom = throttle(function () {
nextTick(() => {
try {
setTimeout(() => {
const query = uni.createSelectorQuery();
query.select('.scrollView').boundingClientRect();
query.select('.list-content').boundingClientRect();
query.exec((res) => {
const scrollViewHeight = res[0].height;
const scrollContentHeight = res[1].height;
if (scrollContentHeight > scrollViewHeight) {
const scrolldistance = scrollContentHeight - scrollViewHeight;
scrollTop.value = scrolldistance;
}
});
}, 100);
} catch (err) {
console.warn(err);
}
});
}, 500);
function getGuess() {
$api.chatRequest('/guest', { sessionId: chatSessionID.value }, 'POST').then((res) => {
guessList.value = res.data;
showGuess.value = true;
nextTick(() => {
scrollToBottom();
});
});
}
function isImage(type) {
return new RegExp('image').test(type);
}
function isFile(type) {
const allowedTypes = config.allowedFileTypes || [];
if (!allowedTypes.includes(type)) {
return false;
}
return true;
}
function jumpUrl(file) {
if (file.url) {
window.open(file.url);
} else {
$api.msg('文件地址丢失');
}
}
function VerifyNumberFiles(num) {
if (filesList.value.length >= config.allowedFileNumber) {
$api.msg(`最大上传文件数量 ${config.allowedFileNumber}`);
return true;
} else {
return false;
}
}
function uploadCamera(type = 'camera') {
if (VerifyNumberFiles()) return;
uni.chooseImage({
count: 1, //默认9
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
sourceType: [type], //从相册选择
success: function (res) {
const tempFilePaths = res.tempFilePaths;
const file = res.tempFiles[0];
// 继续上传
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
if (isImage(file.type)) {
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
});
textInput.value = state.uploadFileTips;
}
});
},
});
}
function getUploadFile(type = 'camera') {
if (VerifyNumberFiles()) return;
uni.chooseFile({
count: 1,
success: (res) => {
const tempFilePaths = res.tempFilePaths;
const file = res.tempFiles[0];
const allowedTypes = config.allowedFileTypes || [];
const size = $api.formatFileSize(file.size);
if (!allowedTypes.includes(file.type)) {
return $api.msg('仅支持 txt md html word pdf ppt csv excel 格式类型');
}
// 继续上传
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
size: size,
});
textInput.value = state.uploadFileTips;
});
},
});
}
const handleTouchStart = (e) => {
startY.value = e.touches[0].clientY;
status.value = 'recording';
showfile.value = false;
startRecording();
};
const handleTouchMove = (e) => {
const moveY = e.touches[0].clientY;
if (startY.value - moveY > cancelThreshold) {
status.value = 'cancel';
} else {
status.value = 'recording';
}
};
const handleTouchEnd = () => {
if (status.value === 'cancel') {
console.log('取消发送');
cancelRecording();
} else {
stopRecording();
console.log('发送语音');
}
status.value = 'idle';
};
const handleTouchCancel = () => {
stopRecording();
status.value = 'idle';
};
const statusText = computed(() => {
switch (status.value) {
case 'recording':
return '松手发送,上划取消';
case 'cancel':
return '松手取消';
default:
return '按住说话';
}
});
const audiowaveStyle = computed(() => {
return status.value === 'cancel'
? '#f54545'
: status.value === 'recording'
? 'linear-gradient(to right, #377dff, #9a60ff)'
: '#f1f1f1';
});
function closeGuess() {
showGuess.value = false;
}
function changeVoice() {
isVoice.value = !isVoice.value;
}
function changeShowFile() {
showfile.value = !showfile.value;
}
function colseFile() {
showfile.value = false;
}
defineExpose({ scrollToBottom, closeGuess, colseFile });
</script>
<style lang="stylus" scoped>
/* 过渡样式 */
.collapse-enter-active,
.collapse-leave-active {
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.collapse-enter-from,
.collapse-leave-to {
max-height: 0;
opacity: 0;
}
.collapse-enter-to,
.collapse-leave-from {
max-height: 400rpx; /* 根据你内容最大高度设定 */
opacity: 1;
}
.msg-filecontent
display: flex
flex-wrap: wrap
.msg-files
overflow: hidden
margin-right: 10rpx
height: 30rpx
max-width: 201rpx
background: #FFFFFF
border-radius: 10rpx
display: flex
align-items: center
justify-content: flex-start
padding: 10rpx
color: #000000
margin-bottom: 10rpx
.msg-file-icon
width: 29rpx
height: 26rpx
padding-right: 10rpx
.msg-file-text
flex: 1
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
color: rgba(96, 96, 96, 1)
font-size: 24rpx
.msg-files:active
background: #e9e9e9
.guess
border-top: 2rpx solid #8c8c8c
padding: 20rpx 0 10rpx 0
.guess-top
padding: 0 0 10rpx 0
display: flex
align-items: center
color: rgba(255, 173, 71, 1)
font-size: 28rpx
.guess-icon
width: 43rpx
height: 43rpx
.guess-list
border: 2rpx solid #8c8c8c
padding: 6rpx 12rpx
border-radius: 10rpx;
width: fit-content
margin: 0 10rpx 10rpx 0
font-size: 24rpx
color: #8c8c8c
.gulist
display: flex
flex-wrap: wrap
position: relative
image-margin-top = 40rpx
.chat-container
display: flex;
flex-direction: column;
height: calc(100% - var(--window-top) - var(--status-bar-height) - var(--window-bottom));
position: relative
z-index: 1
background: #FFFFFF
.chat-background
position: absolute
padding: 44rpx;
display: flex
flex-direction: column
justify-content: flex-start
align-items: center
.backlogo
width: 313rpx;
height: 190rpx;
.back-rowTitle
width: 100%;
height: 56rpx;
font-weight: bold;
font-size: 40rpx;
color: #333333;
line-height: 47rpx;
margin-top: 40rpx
.back-rowText
margin-top: 28rpx
width: 100%;
height: 144rpx;
font-weight: 400;
font-size: 28rpx;
color: #333333;
border-bottom: 2rpx dashed rgba(0, 0, 0, 0.2);
.back-rowh3
width: 100%;
height: 30rpx;
font-weight: 500;
font-size: 22rpx;
color: #000000;
margin-top: 24rpx
.back-rowmsg
width: 630rpx
margin-top: 20rpx
font-weight: 500;
font-size: 24rpx;
color: #333333;
line-height: 28rpx;
font-size: 24rpx
background: #F6F6F6;
border-radius: 8rpx 8rpx 8rpx 8rpx;
padding: 32rpx 18rpx;
.chat-list {
flex: 1;
overflow-y: auto;
white-space: pre-wrap;
}
.list-content {
padding: 0 44rpx 44rpx 44rpx;
}
.chat-item {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx;
}
.chat-item.self {
justify-content: flex-end;
}
.message {
margin-top: 40rpx
padding: 20rpx 20rpx 0 20rpx;
border-radius: 0 20rpx 20rpx 20rpx;
background: #F6F6F6;
// max-width: 80%;
word-break: break-word;
color: #333333;
user-select: text;
-webkit-user-select: text;
}
.messageNull
background: transparent;
.msg-loading{
background: transparent;
font-size: 24rpx;
color: #8f8d8e;
display: flex;
align-items: flex-end;
justify-content: flex-start;
}
.loaded{
padding-left: 20rpx
}
.chat-item.self .message {
background: linear-gradient( 225deg, #DAE2FE 0%, #E9E3FF 100%);
border-radius: 20rpx 0 20rpx 20rpx;
padding: 20rpx;
}
.input-area {
padding: 32rpx 28rpx 24rpx 28rpx;
position: relative;
background: #FFFFFF;
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
transition: height 2s ease-in-out;
}
.input-area::after
position: absolute
content: ''
top: 0
left: 0
width: 100%
z-index: 1
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
.areatext{
display: flex;
}
.input {
flex: 1;
border-radius: 5rpx;
min-height: 63rpx;
line-height: 63rpx;
padding: 4rpx 24rpx;
position: relative
background: #F5F5F5;
border-radius: 60rpx 60rpx 60rpx 60rpx;
}
.input_vio
flex: 1;
border-radius: 5rpx;
min-height: 63rpx;
line-height: 63rpx;
padding: 4rpx 24rpx;
// position: relative
border-radius: 60rpx 60rpx 60rpx 60rpx;
font-size: 28rpx
color: #333333
background: #F5F5F5;
text-align: center
font-size: 28rpx
font-weight: 500
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
user-select:none;
.input_vio:active
background: #e8e8e8
.vio_container
background: transparent
padding: 28rpx
text-align: center
.record-tip
font-weight: 400;
color: #909090;
text-align: center;
padding-bottom: 16rpx
.inputplaceholder {
font-weight: 500;
font-size: 24rpx;
color: #000000;
line-height: 28rpx;
opacity: 0.4
}
.btn-box
margin-left: 12rpx;
width: 70rpx;
height: 70rpx;
border-radius: 50%
background: #F5F5F5;
display: flex
align-items: center
justify-content: center
.send-btn,
.receive-btn
transition: transform 0.5s ease;
width: 38rpx;
height: 38rpx;
.purple
background: linear-gradient( 225deg, #9E74FD 0%, #256BFA 100%);
.add-file-btn{
transform: rotate(45deg)
transition: transform 0.5s ease;
}
.area-file
display: grid
width: 100%
grid-template-columns: repeat(3, 1fr)
grid-gap: 20rpx
padding: 32rpx 0 0 0;
.file-card
display: flex
flex-direction: column
align-items: center
padding: 24rpx 0
background: #F5F5F5;
border-radius: 20rpx 20rpx 20rpx 20rpx;
text
font-size: 24rpx
font-weight: 500
color: #000000
padding-top: 8rpx
.card-img
height: 56rpx
width: 56rpx
.file-card:active
background: #e8e8e8
.area-uploadfiles
position: absolute
top: -180rpx
width: calc(100% - 40rpx)
background: #FFFFFF
left: 0
padding: 10rpx 20rpx
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
.uploadfiles-scroll
height: 100%
.uploadfiles-list
height: 100%
display: flex
flex-wrap: nowrap
.file-doc
display: flex
flex-direction: column
align-items: flex-start
justify-content: space-between
padding: 16rpx 20rpx 18rpx 20rpx
height: calc(100% - 40rpx)
.file-uploadsend
margin: 10rpx 18rpx 0 10rpx;
height: 100%
border-radius: 30rpx
font-size: 24rpx
position: relative
width: 360rpx;
height: 160rpx;
border-radius: 12rpx 12rpx 12rpx 12rpx;
border: 2rpx solid #E2E2E2;
.file-del
position: absolute
right: 0
top: 0
z-index: 9
border-radius: 50%
display: flex
align-items: center
justify-content: center
transform: translate(50%, -10rpx)
height: 40rpx
width: 23rpx;
height: 23rpx;
background: #FFFFFF;
border: 2rpx solid #E2E2E2;
.file-del:active
background: #e8e8e8
.filename-text
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
color: #333333
font-size: 24rpx
flex: 1
font-weight: 500
max-width: 100%
.filename-size
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
color: #7B7B7B;
flex: 1
max-width: 100%
.file-icon
height: 40rpx
width: 40rpx
.file-iconImg
height: 100%
width: 100%
border-radius: 15rpx
.file-border
width: 160rpx;
border: 0
@keyframes ai-circle {
0% {
-webkit-transform: rotate(0);
transform: rotate(0);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.ai-loading
display: inline-flex;
vertical-align: middle;
width: 28rpx;
height: 28rpx;
background: 0 0;
border-radius: 50%;
border: 4rpx solid;
border-color: #e5e5e5 #e5e5e5 #e5e5e5 #8f8d8e;
-webkit-animation: ai-circle 1s linear infinite;
animation: ai-circle 1s linear infinite;
</style>