This commit is contained in:
2025-10-24 09:34:42 +08:00
commit c0e46d1ae7
282 changed files with 33820 additions and 0 deletions

329
pages/chat/chat.vue Normal file
View File

@@ -0,0 +1,329 @@
<template>
<view class="container">
<!-- 抽屉遮罩层 -->
<view v-if="isDrawerOpen" class="overlay" @click="toggleDrawer"></view>
<!-- 抽屉窗口 -->
<view class="drawer" :class="{ open: isDrawerOpen }">
<view class="drawer-content">
<view class="drawer-title">AI+</view>
<view class="drawer-input-content">
<input
class="drawer-input"
type="text"
v-model="searchText"
placeholder-class="input-placeholder"
placeholder="请输入搜索历史对话"
/>
<uni-icons class="input-search" type="search" size="20"></uni-icons>
</view>
<scroll-view scroll-y :show-scrollbar="false" class="chat-scroll">
<view
class="drawer-rows"
@click="changeDialogue(item)"
v-for="(item, index) in filteredList"
:key="item.id"
>
<view
v-if="!item.isTitle"
class="drawer-row-list"
:class="{ 'drawer-row-active': item.sessionId === chatSessionID }"
>
{{ item.title }}
</view>
<view class="drawer-row-title" v-else>
{{ item.title }}
</view>
</view>
</scroll-view>
<view class="drawer-user">
<image class="drawer-user-img" v-if="userInfo.age === '0'" src="/static/icon/boy.png"></image>
<image class="drawer-user-img" v-else src="/static/icon/girl.png"></image>
<text>{{ userInfo.name || '暂无用户名' }}</text>
<!-- <image
class="drawer-user-setting button-click"
src="/static/icon/setting.png"
@click="updateSetting"
></image> -->
</view>
</view>
</view>
<!-- 主要内容挤压效果 -->
<view class="main-content" :class="{ shift: isDrawerOpen }">
<!-- header -->
<header class="head">
<view class="main-header">
<image src="/static/icon/Hamburger-button.png" @click="toggleDrawer"></image>
<view class="title">青岛市岗位推荐</view>
<image src="/static/icon/Comment-one.png" @click="addNewDialogue"></image>
</view>
</header>
<view class="chatmain-warpper">
<ai-paging ref="paging"></ai-paging>
</view>
<!-- 自定义tabbar -->
<view class="chatmain-footer" v-show="!isDrawerOpen">
<Tabbar :currentpage="2"></Tabbar>
</view>
</view>
</view>
</template>
<script setup>
import { ref, inject, nextTick, computed } from 'vue';
const { $api, navTo, insertSortData } = inject('globalFunction');
import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
import Tabbar from '@/components/tabbar/midell-box.vue';
import useChatGroupDBStore from '@/stores/userChatGroupStore';
import useUserStore from '@/stores/useUserStore';
import aiPaging from './components/ai-paging.vue';
import { storeToRefs } from 'pinia';
const { isTyping, tabeList, chatSessionID } = storeToRefs(useChatGroupDBStore());
const { userInfo } = storeToRefs(useUserStore());
const isDrawerOpen = ref(false);
const scrollIntoView = ref(false);
import config from '@/config';
const searchText = ref('');
const paging = ref(null);
// 实时过滤
const filteredList = computed(() => {
// console.log(tabeList.value);
if (!searchText.value) return tabeList.value;
const list = tabeList.value.filter((item) => !item.isTitle && item.title.includes(searchText.value));
const [result, lastData] = $api.insertSortData(list);
return result;
});
onLoad(() => {
// useChatGroupDBStore().getHistory();
});
onShow(() => {
nextTick(() => {
paging.value?.colseFile();
});
});
onHide(() => {
paging.value?.handleTouchCancel();
if (isDrawerOpen.value) {
isDrawerOpen.value = false;
// uni.showTabBar();
}
});
const toggleDrawer = () => {
isDrawerOpen.value = !isDrawerOpen.value;
if (isDrawerOpen.value) {
// uni.hideTabBar();
} else {
// uni.showTabBar();
}
};
const addNewDialogue = () => {
$api.msg('新对话');
paging.value?.changeQueries();
useChatGroupDBStore().addNewDialogue();
};
const changeDialogue = (item) => {
if (item.sessionId) {
paging.value?.closeGuess();
useChatGroupDBStore().changeDialogue(item);
toggleDrawer();
nextTick(() => {
paging.value?.scrollToBottom();
});
}
};
function updateSetting() {
$api.msg('该功能正在开发中,敬请期待后续更新!');
}
</script>
<style lang="stylus" scoped>
header-height = 88rpx
footer-height = 98rpx
/* 页面容器 */
.container {
position: fixed;
z-index: 1000;
width: 100vw;
height: calc(100% - var(--window-bottom));
overflow: hidden;
}
/* 遮罩层 */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
z-index: 999;
}
/* 抽屉窗口 */
.drawer {
position: fixed;
top: 0;
left: 0; /* 如果要右侧弹出改为 right: 0; */
width: 523rpx;
height: 100%;
background: #e7e7e6;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
z-index: 1000;
}
/* 抽屉展开 */
.drawer.open {
box-shadow: 4rpx 0 20rpx rgba(0, 0, 0, 0.2);
transform: translateX(0);
}
.drawer-input-content
margin: 0 28rpx
padding: 0 24rpx
height: 72rpx;
background: #F6F6F6;
border-radius: 16rpx 16rpx 16rpx 16rpx;
display: flex
align-items: center
justify-content: center
.drawer-input
font-size: 28rpx
width: 100%
.input-placeholder
font-weight: 500;
font-size: 28rpx;
color: #A6A6A6;
.input-search
margin-left: 20rpx
/* 抽屉内容 */
.drawer-content
height: 100%
background: #FFFFFF;
display: flex
flex-direction: column
.drawer-user
border-top: 1rpx solid rgba(0,0,0,.1);
padding: 20rpx 28rpx
display: flex
font-weight: 500;
align-items: center
position: relative
margin-bottom: calc( 32rpx + var(--window-bottom)); /*兼容 IOS<11.2*/
margin-bottom: calc( 32rpx +var(--window-bottom)); /*兼容 IOS>11.2*/
color: #000000
.drawer-user-img
width: 57.2rpx;
height: 57.2rpx
margin-right: 20rpx
.drawer-user-setting
width: 48rpx
height: 48rpx
position: absolute
top: 50%
right: 28rpx
transform: translate(0,-50%)
.drawer-title
height: header-height;
line-height: header-height;
padding: 0 52rpx;
color: #333333;
font-size: 32rpx
font-weight: bold
.chat-scroll
flex: 1
overflow: hidden
.drawer-rows
padding: 0 28rpx;
// border-bottom: 2rpx dashed #e8e8e8
overflow:hidden; //超出的文本隐藏
text-overflow:ellipsis; //溢出用省略号显示
white-space:nowrap; //溢出不换行
.drawer-row-title
color: #A6A6A6;
font-weight: 500;
font-weight: bold;
font-size: 28rpx
padding: 0 24rpx
margin-top: 50rpx
margin-bottom: 16rpx
.drawer-row-list
height: 66rpx;
line-height: 66rpx
font-size: 28rpx
overflow: hidden
text-overflow: ellipsis
font-weight: 500;
color: #595959;
padding: 0 24rpx
.drawer-row-active
.drawer-row-list:active
color: #333333;
background: #F6F6F6;
border-radius: 16rpx 16rpx 16rpx 16rpx;
/* 主要内容区域 */
.main-content
width: 100%;
height: 100%;
transition: margin-left 0.3s ease-in-out;
position: relative
background: #FFFFFF
display: flex
flex-direction: column
.head
display: block;
box-sizing: border-box;
height: header-height;
user-select: none;
.main-header
position: fixed;
left: var(--window-left);
right: var(--window-right);
height:header-height;
padding-top: calc(14rpx);
background: #FFFFFF
z-index: 1;
transition-property: all;
display: flex;
align-items: center;
justify-content: space-between
font-size: 32rpx
color: #000000
padding: 0 30rpx;
font-weight: bold
text-align: center
image
width: 36rpx;
height: 37rpx;
.chatmain-warpper
height: 'calc(100% - %s)' %( header-height + footer-height)
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
border-top: 2rpx solid #F4F4F4;
flex: 1
/* 页面被挤压时向右移动 */
.main-content.shift {
margin-left: 500rpx;
}
.chatmain-footer{
height: footer-height;
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<view class="wave-container" :style="{ background }">
<view v-for="(bar, index) in bars" :key="index" class="bar" :style="getBarStyle(index)" />
</view>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
background: {
type: String,
default: 'linear-gradient(to right, #377dff, #9a60ff)',
},
});
// 默认参数(不暴露)
const barCount = 20;
const barWidth = 4;
const barHeight = 40;
const barRadius = 2;
const duration = 1200;
const gap = 4;
const bars = computed(() => new Array(barCount).fill(0));
const getBarStyle = (index) => {
const delay = (index * (duration / barCount)) % duration;
return {
width: `${barWidth}rpx`,
height: `${barHeight}rpx`,
background: '#fff',
borderRadius: `${barRadius}rpx`,
animation: `waveAnim ${duration}ms ease-in-out ${delay}ms infinite`,
transformOrigin: 'bottom center',
};
};
</script>
<style scoped>
.wave-container {
display: flex;
justify-content: center;
align-items: center;
padding: 16rpx;
border-radius: 36rpx;
height: calc(102rpx - 40rpx);
gap: 4rpx;
/* background: linear-gradient(90deg, #9e74fd 0%, #256bfa 100%); */
box-shadow: 0rpx 8rpx 40rpx 0rpx rgba(0, 54, 170, 0.15);
}
@keyframes waveAnim {
0%,
100% {
transform: scaleY(0.4);
}
50% {
transform: scaleY(1);
}
}
.bar {
transform-origin: bottom center;
}
</style>

View File

@@ -0,0 +1,266 @@
<template>
<view class="wave-container active" :style="{ background }">
<!-- 波形显示区域 -->
<view class="wave">
<view
v-for="(bar, index) in waveBars"
:key="index"
class="wave-bar"
:style="{
height: `${bar.height}px`,
backgroundColor: bar.color,
borderRadius: `${bar.borderRadius}px`,
}"
></view>
</view>
</view>
</template>
<script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
// 是否激活显示
isActive: {
type: Boolean,
default: false,
},
// 音频数据数组 (0-1之间的值)
audioData: {
type: Array,
default: () => [],
},
// 是否显示录音信息
showInfo: {
type: Boolean,
default: false,
},
// 录音时间 (秒)
recordingTime: {
type: Number,
default: 0,
},
// 是否显示取消提示
showCancelTip: {
type: Boolean,
default: false,
},
// 是否处于取消状态
isCanceling: {
type: Boolean,
default: false,
},
background: {
type: String,
default: 'linear-gradient(to right, #377dff, #9a60ff)',
},
});
const emit = defineEmits(['update:audioData']);
// 波形条数据
const waveBars = ref([]);
// 中心条索引
const centerIndex = ref(0);
// 动画帧ID
let animationId = null;
// 格式化显示时间
const formattedTime = computed(() => {
const mins = Math.floor(props.recordingTime / 60)
.toString()
.padStart(2, '0');
const secs = (props.recordingTime % 60).toString().padStart(2, '0');
return `${mins}:${secs}`;
});
// 录音提示文本
const recordingTip = computed(() => {
return props.isCanceling ? '松开取消' : '松手发送';
});
// 初始化波形条
const initWaveBars = () => {
waveBars.value = [];
// 创建31个波形条奇数个确保有真正的中心点
const barCount = 31;
centerIndex.value = Math.floor(barCount / 2);
for (let i = 0; i < barCount; i++) {
// 设置初始状态(中心条最高,向两侧递减)
const distanceFromCenter = Math.abs(i - centerIndex.value);
const initialHeight = Math.max(2, 20 - distanceFromCenter * 3);
waveBars.value.push({
height: initialHeight,
color: '#FFFFFF',
borderRadius: 2,
});
}
};
// 更新波形显示
const updateWaveform = () => {
if (!props.isActive) return;
const AMPLIFY = 1.6; // 振幅放大
const center = centerIndex.value;
// 如果没有传入音频数据,则使用模拟数据(加强振幅)
const audioData =
props.audioData.length > 0
? props.audioData.map((v) => Math.min(v * AMPLIFY, 1))
: Array(center + 1)
.fill(0)
.map(() => Math.random() * 0.7 + 0.3); // 模拟值更明显
for (let i = 0; i <= center; i++) {
const leftIndex = center - i;
const rightIndex = center + i;
const value = audioData[i] || 0;
if (leftIndex >= 0) updateWaveBar(leftIndex, value);
if (rightIndex < waveBars.value.length && rightIndex !== leftIndex) {
updateWaveBar(rightIndex, value);
}
}
animationId = requestAnimationFrame(updateWaveform);
};
// 更新单个波形条
const updateWaveBar = (index, value) => {
// 动态高度 (4rpx到42rpx之间)
const height = 2 + value * 38;
// // 动态颜色
// let color;
// if (props.isCanceling) {
// color = '#F44336'; // 取消状态显示红色
// } else {
// const intensity = Math.min(1, value * 1.5);
// const r = Math.floor(7 + intensity * 200);
// const g = Math.floor(193 - intensity * 50);
// const b = Math.floor(96 - intensity * 30);
// color = `rgb(${r}, ${g}, ${b})`;
// }
let color = '#FFFFFF';
// 动态圆角
const borderRadius = Math.min(4, height * 0.4);
waveBars.value[index] = {
height,
color,
borderRadius,
};
};
// 开始动画
const startAnimation = () => {
if (!animationId) {
animationId = requestAnimationFrame(updateWaveform);
}
};
// 停止动画
const stopAnimation = () => {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
};
// 监听激活状态变化
watch(
() => props.isActive,
(newVal) => {
if (newVal) {
startAnimation();
} else {
stopAnimation();
}
}
);
// 组件挂载时初始化
onMounted(() => {
initWaveBars();
if (props.isActive) {
startAnimation();
}
});
// 组件卸载时清理
onUnmounted(() => {
stopAnimation();
});
</script>
<style lang="scss">
.wave-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
padding: 16rpx;
border-radius: 36rpx;
height: calc(102rpx - 40rpx);
gap: 4rpx;
box-shadow: 0rpx 8rpx 40rpx 0rpx rgba(0, 54, 170, 0.15);
overflow: hidden;
&.active {
opacity: 1;
}
}
.wave {
width: 100%;
height: 200rpx;
display: flex;
justify-content: center;
align-items: center;
}
.wave-bar {
width: 6rpx;
min-height: 4rpx;
border-radius: 4rpx;
margin: 0 3rpx;
transition: height 0.3s ease-out, background-color 0.2s;
transform-origin: center bottom;
}
.wave-bar:nth-child(3n) {
opacity: 0.7;
}
.recording-info {
position: absolute;
bottom: -80rpx;
width: 100%;
text-align: center;
font-size: 32rpx;
color: #666;
}
.recording-tip {
font-size: 28rpx;
color: #07c160;
}
.recording-time {
font-size: 28rpx;
color: #07c160;
margin-top: 10rpx;
}
.cancel-tip {
position: absolute;
bottom: -120rpx;
width: 100%;
text-align: center;
font-size: 28rpx;
color: #f44336;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
<template>
<image v-if="type === 'application/pdf'" :src="pdfIcon" class="file-icon" />
<image
v-else-if="
type === 'application/msword' ||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
"
:src="docIcon"
class="file-icon"
/>
<image
v-else-if="
type === 'application/vnd.ms-powerpoint' ||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
"
:src="pptIcon"
class="file-icon"
/>
<image v-else-if="type === 'text/markdown'" :src="mdIcon" class="file-icon" />
<image v-else-if="type === 'text/plain'" :src="txtIcon" class="file-icon" />
<image v-else-if="type === 'text/html'" :src="htmlIcon" class="file-icon" />
<image
v-else-if="
type === 'application/vnd.ms-excel' ||
type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
"
:src="excelIcon"
class="file-icon"
/>
<image v-else :src="otherIcon" class="file-icon" />
</template>
<script setup>
import pdfIcon from '@/static/file/pdf.png';
import docIcon from '@/static/file/doc.png';
import pptIcon from '@/static/file/ppt.png';
import mdIcon from '@/static/file/md.png';
import txtIcon from '@/static/file/txt.png';
import htmlIcon from '@/static/file/html.png';
import excelIcon from '@/static/file/excel.png';
import otherIcon from '@/static/file/other.png';
const props = defineProps({
type: String,
});
const type = props.type;
</script>

View File

@@ -0,0 +1,38 @@
<template>
<view class="file-type-box">
{{ fileAbbreviation }}
</view>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
type: String,
});
const fileAbbreviation = computed(() => {
const typeMap = {
'application/pdf': 'PDF',
'application/msword': 'DOC',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
'application/vnd.ms-powerpoint': 'PPT',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',
'application/vnd.ms-excel': 'XLS',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
'text/markdown': 'MD',
'text/plain': 'TXT',
'text/html': 'HTML',
};
return typeMap[props.type] || 'OTHER';
});
</script>
<style scoped>
.file-type-box {
font-weight: 400;
font-size: 24rpx;
color: #7b7b7b;
line-height: 28rpx;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<uni-popup ref="popup" type="bottom" borderRadius="12px 12px 0 0" background-color="#F6F6F6">
<view class="feeback">
<view class="titile">反馈</view>
<view class="pop-h3">针对问题</view>
<view class="pop-content">
<view
class="item"
:class="{ active: item.check }"
@click="toggleCheck(item.id)"
v-for="item in wt"
:key="item.id"
>
{{ item.label }}
</view>
</view>
<view class="pop-h3">针对回答</view>
<view class="pop-content">
<view
class="item"
:class="{ active: item.check }"
@click="toggleCheck(item.id)"
v-for="item in hd"
:key="item.id"
>
{{ item.label }}
</view>
</view>
<view class="pop-h3">我要补充</view>
<view class="supplement">
<textarea v-model="inputText" cols="30" rows="10"></textarea>
</view>
<view class="btn button-click" @click="send">提交</view>
<view class="close-btn" @click="close"></view>
</view>
</uni-popup>
</template>
<script setup>
import { ref, inject, defineEmits } from 'vue';
const emit = defineEmits(['onSend']);
const { $api } = inject('globalFunction');
const popup = ref(null);
const inputText = ref('');
const wt = ref([
{ label: '上下文错误', id: 1, check: false },
{ label: '理解错误', id: 2, check: false },
{ label: '未识别问题中的错误', id: 3, check: false },
]);
const hd = ref([
{ label: '违法有害', id: 11, check: false },
{ label: '内容不专业', id: 12, check: false },
{ label: '推理错误', id: 13, check: false },
{ label: '计算错误', id: 14, check: false },
{ label: '内容不完整', id: 15, check: false },
{ label: '事实错误', id: 16, check: false },
]);
function open() {
resetCheck();
inputText.value = '';
popup.value.open();
}
function close() {
popup.value.close();
}
function send() {
const text = getLabel();
if (text) {
emit('onSend', text);
// close();
} else {
$api.msg('清输入反馈内容');
}
}
function getLabel() {
const wtArr = wt.value.filter((item) => item.check).map((item) => item.label);
const hdArr = hd.value.filter((item) => item.check).map((item) => item.label);
let str = '';
wtArr.length ? (str += `问题:${wtArr.join(',')}. `) : '';
hdArr.length ? (str += `回答:${hdArr.join(',')}. `) : '';
inputText.value ? (str += `描述:${inputText.value}. `) : '';
return str;
}
function resetCheck() {
wt.value = wt.value.map((item) => {
return { ...item, check: false }; // 创建新对象,确保更新响应式
});
hd.value = hd.value.map((item) => {
return { ...item, check: false }; // 创建新对象,确保更新响应式
});
}
function toggleCheck(id) {
if (id < 10) {
const newWt = wt.value.map((item) => {
if (item.id === id) {
return { ...item, check: !item.check };
}
return item;
});
wt.value = newWt;
} else {
const newHd = hd.value.map((item) => {
if (item.id === id) {
return { ...item, check: !item.check };
}
return item;
});
hd.value = newHd;
}
}
defineExpose({ open, close });
</script>
<style lang="stylus" scoped>
.feeback
padding: 38rpx 32rpx;
.titile
font-weight: 500;
font-size: 36rpx;
color: #333333;
line-height: 42rpx;
text-align: center;
margin-bottom: 20rpx;
.pop-h3
font-weight: 600;
font-size: 32rpx;
color: #000000;
line-height: 38rpx;
text-align: left;
padding: 8rpx 0
margin-top: 32rpx
.pop-content
.item
width: fit-content;
height: 80rpx;
background: #E8EAEE;
border-radius: 12rpx 12rpx 12rpx 12rpx;
text-align: center;
line-height: 80rpx;
padding: 0 36rpx
border: 2rpx solid transparent;
display: inline-block
margin-right: 28rpx
margin-top: 28rpx
.active
border: 2rpx solid #256BFA;
color: #256BFA;
.supplement
height: 200rpx;
background: #FFFFFF;
border-radius: 20rpx 20rpx 20rpx 20rpx;
margin-top: 28rpx
padding: 20rpx 24rpx
.btn
height: 90rpx;
background: #256BFA;
border-radius: 12rpx 12rpx 12rpx 12rpx;
font-weight: 500;
font-size: 32rpx;
color: #FFFFFF;
line-height: 90rpx;
text-align: center;
margin-top: 62rpx
.close-btn
position: absolute;
right: 32rpx;
top: 32rpx;
width: 56rpx;
height: 56rpx;
.close-btn::before
position: absolute;
left: 50%;
top: 50%;
content: '';
width: 4rpx;
height: 28rpx;
border-radius: 2rpx
background: #5A5A68;
transform: translate(50%, -50%) rotate(-45deg) ;
.close-btn::after
position: absolute;
left: 50%;
top: 50%;
content: '';
width: 4rpx;
height: 28rpx;
border-radius: 2rpx
background: #5A5A68;
transform: translate(50%, -50%) rotate(45deg)
</style>