You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1030 lines
23 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<view class="ai-page">
<uni-nav-bar left-icon="left" @clickLeft="openDrawer" title="AI对话">
<template v-slot:left>
<view class="hamburger">
<view class="line" />
<view class="line" />
<view class="line" />
</view>
</template>
<template v-slot:right>
<view class="nav-right">
<image src="../../static/set.png" mode="widthFix" @tap="onSettingTap" style="width: 18px;"></image>
</view>
</template>
</uni-nav-bar>
<!-- scrollable content -->
<scroll-view class="content" :scroll-y="true" show-scrollbar="false" scroll-with-animation ref="scrollView">
<!-- greeting card -->
<view class="greet-card">
<image src="../../static/ai.webp" mode="widthFix" style="width: 60px;margin-right: 10px;"></image>
<view class="greet-text">
<view class="hi">HI{{ timeOfDayText }}</view>
<view class="sub">我是萃星科技智能体</view>
</view>
</view>
<!-- welcome sentence -->
<view class="welcome">
您好非常高兴与您交流今天有什么可以帮到您
</view>
<!-- suggestions -->
<view class="guess-panel">
<view class="guess-title">猜你想问</view>
<view class="guess-list">
<view class="guess-item" @tap="onSuggestionTap(item.label)" v-for="item in guessData" :key="item.id">
<text>{{item.label}}</text>
<text class="arrow"></text>
</view>
</view>
</view>
<!-- conversation -->
<view class="chat">
<view v-for="m in messages" :key="m.id" :id="'msg-' + m.id" :class="['msg', m.role]">
<view v-if="m.role === 'user'" class="bubble user-bubble">
<text v-if="m.inputType === 'text'">{{ m.content }}</text>
<view class="text-voice" v-if="m.inputType === 'voice'" @tap="playVoice(m.inputContent,m.id)">
<text>{{m.duration }}</text>
<image class="voice-play" src="../../static/voice-play.png" mode="widthFix"></image>
</view>
</view>
<view v-else class="bubble ai-bubble">
<view v-if="m.type === 'card'" class="ai-card">
<view class="ai-card-title">{{ m.title }}</view>
<view class="ai-card-body">{{ m.content }}</view>
</view>
<view v-else-if="m.loading" class="ai-loading">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
<view v-else>
<text>{{ m.displayText !== undefined ? m.displayText : m.content }}</text>
</view>
</view>
</view>
</view>
<view style="height: 12px;" />
</scroll-view>
<!-- bottom dock: quick actions + input bar -->
<view class="dock">
<scroll-view class="quick-actions horizontal" scroll-x show-scrollbar="false">
<view class="qa-btn minor" @tap="onSwitchModel">切换模型</view>
<view class="qa-btn" @tap="onQuickAsk(item.quickAskText)" v-for="item in quickAskList" :key="item.id">{{item.label}}</view>
</scroll-view>
<view class="input-bar">
<input class="input" confirm-type="send" :value="inputText" @input="onInput" @confirm="onSend()"
placeholder="你可以说…" placeholder-class="ph" />
<view :class="['mic', { recording: isRecording }]" @touchstart.stop="onPressMic"
@touchmove.stop="onMoveMic" @touchend.stop="onReleaseMic">🎙️</view>
<button class="send" type="primary" @tap="onSend">发送</button>
</view>
</view>
<!-- left drawer -->、
<uni-popup ref="popup" background-color="#fff" type="left" :z-index="10090" @change="onPopupChange"
style="z-index: 99999;width: 100vw">
<view class="drawer-mask">
<view class="drawer">
<scroll-view class="drawer-scroll" scroll-y show-scrollbar="false">
<view v-for="g in historyGroups" :key="g.date" class="drawer-group">
<view class="drawer-date">{{ g.date }}</view>
<view v-for="(t, idx) in g.items" :key="idx" class="drawer-item" @tap="onHistoryItemTap(t)"
@longpress="onLongPressHistory(t)">
{{ t }}
</view>
<view class="drawer-divider" />
</view>
</scroll-view>
<view class="drawer-footer">
<view class="user-icon">👤</view>
<text class="user-name">用户名</text>
<view class="footer-gear" @tap="clearAllHistory">⚙️</view>
</view>
</view>
</view>
</uni-popup>
<!-- Voice recording overlay -->
<view v-if="isRecording" class="record-mask">
<view class="record-box" :class="{ cancel: willCancel }">
<view class="record-icon">🎙️</view>
<view class="record-text">{{ willCancel ? '' : '' }}</view>
</view>
</view>
<view v-if="isRecording" class="mask-layer" @touchmove.stop.prevent>
</view>
</view>
</template>
<script>
const HISTORY_KEY = 'chat_history_groups'
import {getAIResponse} from '@/api/index.js'
export default {
data() {
return {
inputText: '',
messages: [],
scrollInto: '',
drawerOpen: false,
historyGroups: [],
isRecording: false,
isLoading: false,
willCancel: false,
recorder: null,
recordStartY: 0,
recordStartTs: 0,
recordSimTimer: null,
innerAudioContext: null,
popupVisible: false,
typewriterTimers: {},
guessData : [
{
id : 1,
label : '今日出入库数据'
},
{
id : 2,
label : '今日销售数据'
},
{
id : 3,
label : '今日生产数据'
}
],
quickAskList : [
{
id : 1,
label : '自我介绍',
quickAskText : '你是谁?'
},
{
id : 2,
label : '快捷提问',
quickAskText : '今日任务有哪些?'
},
{
id : 3,
label : '快捷提问',
quickAskText : '展示一份报表示例'
},
{
id : 4,
label : '快捷提问',
quickAskText : '生成日报模版'
}
]
}
},
computed: {
timeOfDayText() {
const h = new Date().getHours()
if (h < 6) return '凌晨好'
if (h < 12) return '上午好'
if (h < 18) return '下午好'
return '晚上好'
}
},
mounted() {
this.loadChatHistory();
this.scrollToBottom();
},
beforeDestroy() {
// 清理所有打字机定时器
Object.values(this.typewriterTimers).forEach(timer => {
if (timer) clearInterval(timer)
})
this.typewriterTimers = {}
// 清理录音定时器
if (this.recordSimTimer) {
clearTimeout(this.recordSimTimer)
}
},
methods: {
// ==================== 历史记录管理 ====================
formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
},
loadChatHistory() {
try {
const data = uni.getStorageSync(HISTORY_KEY)
if (data && Array.isArray(data.groups)) {
this.historyGroups = data.groups
} else {
this.historyGroups = []
}
} catch (e) {
this.historyGroups = []
}
},
addToHistory(text) {
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || []
const today = this.formatDate(new Date())
let todayGroup = groups.find(g => g.date === today)
if (!todayGroup) {
todayGroup = {
date: today,
items: []
}
groups.unshift(todayGroup)
}
if (!todayGroup.items.includes(text)) {
todayGroup.items.unshift(text)
}
// 限制大小
if (todayGroup.items.length > 50) todayGroup.items = todayGroup.items.slice(0, 50)
if (groups.length > 30) groups = groups.slice(0, 30)
this.historyGroups = groups
uni.setStorageSync(HISTORY_KEY, {
groups,
updatedAt: Date.now()
})
},
removeFromHistory(text) {
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || []
groups.forEach(group => {
group.items = group.items.filter(item => item !== text)
})
groups = groups.filter(g => g.items.length > 0)
this.historyGroups = groups
uni.setStorageSync(HISTORY_KEY, {
groups,
updatedAt: Date.now()
})
},
clearAllHistory() {
uni.showModal({
title: '清除全部',
content: '将删除所有对话记录,此操作不可恢复',
success: (res) => {
if (res.confirm) {
uni.removeStorageSync(HISTORY_KEY)
this.historyGroups = []
uni.showToast({
title: '已清除',
icon: 'success'
})
}
}
})
},
onLongPressHistory(text) {
uni.showModal({
title: '删除记录',
content: '确定删除这条对话记录?',
success: (res) => {
if (res.confirm) {
this.removeFromHistory(text)
}
}
})
},
// 工具
removeMessage(id) {
const idx = this.messages.findIndex(m => m.id === id)
if (idx > -1) this.messages.splice(idx, 1)
},
addAssistantMessage(id, content) {
this.messages.push({
id,
role: 'assistant',
type: 'text',
content,
displayText: ''
})
},
playVoice(voicePath) {
if (!voicePath) {
uni.showToast({
title: '无可播放的语音',
icon: 'none'
})
return
}
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext()
this.innerAudioContext.autoplay = false
this.innerAudioContext.onError(() => {
uni.showToast({
title: '播放失败',
icon: 'none'
})
});
}
try {
this.innerAudioContext.stop()
} catch (e) {}
console.log(voicePath)
this.innerAudioContext.src = voicePath
this.innerAudioContext.play()
},
onSettingTap() {
uni.navigateTo({
url: '/pages/setting/index'
})
},
onSuggestionTap(text) {
this.inputText = text
this.onSend();
},
onQuickAsk(text) {
this.inputText = text
this.onSend()
},
onSwitchModel() {
uni.showToast({
title: '已切换为通用模型',
icon: 'none'
})
},
onInput(e) {
this.inputText = e.detail.value
},
openDrawer() {
this.$refs.popup.open()
},
onPopupChange(e) {
this.popupVisible = !!(e && (e.show === true))
},
// ===== Voice input (WeChat-like) =====
ensureRecorder() {
if (this.recorder) return
try {
this.recorder = uni.getRecorderManager && uni.getRecorderManager()
} catch (e) {
this.recorder = null
}
if (this.recorder) {
this.recorder.onStart()
this.recorder.onStop((res) => {
const duration = Date.now() - this.recordStartTs;
if (this.willCancel || duration < 700) {
uni.showToast({
title: duration < 700 ? '说话时间太短' : '已取消',
icon: 'none'
})
return
}
// TODO: 上传 res.tempFilePath 做识别;现用 mock
this.handleRecognizedText(this.mockSpeechToText(duration), res.tempFilePath, Math.ceil(
duration / 100) / 10)
})
}
},
onPressMic(e) {
if(this.isLoading) return
this.ensureRecorder()
this.isRecording = true
this.willCancel = false
this.recordStartTs = Date.now()
this.recordStartY = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientY : 0
if (this.recorder) {
try {
this.recorder.start({
format: 'mp3',
sampleRate: 16000,
numberOfChannels: 1,
duration: 60000
})
} catch (err) {}
} else {
if (this.recordSimTimer) clearTimeout(this.recordSimTimer)
this.recordSimTimer = setTimeout(() => {}, 60000)
}
},
onMoveMic(e) {
if (!this.isRecording) return;
const y = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientY : 0;
this.willCancel = (this.recordStartY - y) > 60
},
onReleaseMic() {
if (!this.isRecording) return
this.isRecording = false;
this.show = false
if (this.recorder) {
try {
this.recorder.stop()
} catch (err) {
console.log('err', err);
}
}
},
handleRecognizedText(text, tempFilePath, duration) {
if (!text) return
this.inputText = text
this.onSend('voice', tempFilePath, duration) // 传 'voice'
},
mockSpeechToText(ms) {
const sec = Math.ceil(ms / 100) / 10
const pool = [
`语音输入 ${sec}s模拟识别帮我统计今天销售额`,
`语音输入 ${sec}s模拟识别查询订单20388993483`,
`语音输入 ${sec}s模拟识别生成日报`
]
return pool[Math.floor(Math.random() * pool.length)]
},
onHistoryItemTap(text) {
this.inputText = text
this.onSend();
this.$refs.popup.close()
},
async onSend(inputType = 'text', inputContent = '', duration = undefined) {
console.log('inputType',inputType);
const text = (this.inputText || '').trim()
if (!text || this.isLoading) return
const baseId = Date.now()
// 1. 用户消息
this.messages.push({
id: baseId,
role: 'user',
type: 'text',
content: text,
inputType : typeof inputType === 'string' ? inputType : 'text',
inputContent,
duration
})
// 2. loading 消息
const loadingId = baseId + 0.5
this.messages.push({
id: loadingId,
role: 'assistant',
loading: true
})
this.scrollToBottom()
this.inputText = ''
this.isLoading = true
this.addToHistory(text)
try {
// 3. 真正等待 AI 回复
const reply = await getAIResponse({message : text})
// 4. 移除 loading
const loadingIdx = this.messages.findIndex(m => m.id === loadingId)
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1)
// 5. 添加回复 + 打字机
const replyId = baseId + 1
this.messages.push({
id: replyId,
role: 'assistant',
type: 'text',
content: reply,
displayText: ''
})
this.typewriter(replyId, reply)
} catch (e) {
// 出错时也展示
const loadingIdx = this.messages.findIndex(m => m.id === loadingId)
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1)
this.messages.push({
id: baseId + 1,
role: 'assistant',
content: `请求出错:${e.message || e}`
})
} finally {
console.log('finally');
this.isLoading = false
this.$nextTick(() => this.scrollToBottom())
}
},
typewriter(messageId, fullText) {
const msg = this.messages.find(m => m.id === messageId)
if (!msg) return
// 清理之前的定时器(如果存在)
if (this.typewriterTimers[messageId]) {
clearInterval(this.typewriterTimers[messageId])
}
let index = 0
msg.displayText = fullText.substring(0, index + 1);
index += 1;
const speed = 50 // 每个字符间隔50ms
const timer = setInterval(() => {
if (index < fullText.length) {
msg.displayText = fullText.substring(0, index + 1)
index++
this.scrollToBottom()
} else {
clearInterval(timer)
delete this.typewriterTimers[messageId]
// 完成后使用完整文本
msg.displayText = fullText
}
}, speed)
this.typewriterTimers[messageId] = timer
},
scrollToBottom() {
let self = this;
this.$nextTick(() => {
uni.createSelectorQuery().select('.content').boundingClientRect((rect) => {
if(self.height !== rect.height){
self.height = rect.height;
uni.pageScrollTo({
scrollTop: rect.height,
duration: 300,
class: '.content'
});
}
}).exec();
})
},
mockReply(text) {
const candidates = [
'好的,我已经为您处理。',
'收到请求,以下是结果的概览。',
'我理解了,这是一个示例回复。',
'已记录,稍后将完善报表。'
]
const pick = candidates[Math.floor(Math.random() * candidates.length)]
return pick + '(已收到:“' + text + '”)'
},
onListen(text) {
try {
// H5: Web Speech API
if (typeof window !== 'undefined' && window.speechSynthesis) {
const u = new SpeechSynthesisUtterance(String(text))
u.lang = 'zh-CN'
u.rate = 1
u.pitch = 1
window.speechSynthesis.cancel()
window.speechSynthesis.speak(u)
return
}
} catch (e) {}
uni.showToast({
title: '当前端不支持语音播放',
icon: 'none'
})
}
}
}
</script>
<style scoped>
::v-deep .uni-nav-bar-text {
font-size: 18px !important;
}
::v-deeo .uni-navbar--border {
/* border-bottom: 0px !important; */
border-bottom: 1px solid #fff !important;
}
.ai-page {
display: flex;
flex-direction: column;
background: #f7f8fc;
}
.nav {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 9;
}
.nav-title {
font-size: 16px;
font-weight: 600;
}
.hamburger {
width: 18px;
}
.hamburger .line {
height: 2px;
background: #333;
margin: 3px 0;
border-radius: 2px;
}
.gear {
width: 18px;
height: 18px;
position: relative;
color: #000;
}
.content {
flex: 1;
padding: 16px 12px 68px 12px;
background-color: #f7f8fc;
width: 100%;
box-sizing: border-box;
}
.greet-card {
display: flex;
align-items: center;
background: #fff;
border-radius: 14px;
padding: 12px;
margin-bottom: 10px;
}
.avatar-inner {
font-size: 26px;
}
.greet-text .hi {
font-size: 16px;
font-weight: 700;
color: #0b56ff;
}
.greet-text .sub {
font-size: 12px;
color: #4a76b1;
margin-top: 4px;
}
.welcome {
font-size: 13px;
color: #333;
background: #fff;
border-radius: 12px;
padding: 10px 12px;
margin: 12px 0;
}
.guess-panel {
background: #fff;
border-radius: 14px;
padding: 10px;
margin-bottom: 16px;
}
.guess-title {
color: #5f6fff;
font-size: 14px;
margin-bottom: 8px;
}
.guess-list {
display: flex;
flex-direction: column;
}
.guess-item {
background: #f7f8fc;
border-radius: 10px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
box-sizing: border-box;
}
.guess-item:last-child {
margin-bottom: 0;
}
.guess-item .arrow {
color: #9aa3b2;
font-size: 18px;
}
.chat {
margin: 6px 0 12px;
}
.msg {
margin: 10px 0;
display: flex;
}
.msg.user {
justify-content: flex-end;
}
.bubble {
max-width: 80%;
padding: 10px 12px;
border-radius: 14px;
font-size: 14px;
line-height: 1.5;
}
.user-bubble {
background: #4e7bff;
color: #fff;
border-bottom-right-radius: 4px;
margin-right: 6px;
}
.ai-bubble {
background: #fff;
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.listen-btn {
margin-left: 8px;
color: #6b7280;
font-size: 14px;
}
.ai-card-title {
color: #5f6fff;
font-weight: 600;
margin-bottom: 6px;
}
.ai-card-body {
color: #666;
}
/* loading animation */
.ai-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
}
.loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
animation: loading-bounce 1.5s ease-in-out infinite both;
}
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loading-bounce {
0%,
80%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1.2);
opacity: 1;
}
}
/* bottom dock */
.dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #f7f8fc;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.quick-actions {
padding: 6px 10px 4px;
}
.quick-actions.horizontal {
white-space: nowrap;
width: 95%;
}
.qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96px;
text-align: center;
background: #fff;
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
color: #3b3f45;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-right: 10px;
}
.qa-btn.minor {
background: #eff1ff;
color: #4e7bff;
}
.qa-btn:last-child {
margin-right: 0;
}
.input-bar {
display: flex;
align-items: center;
padding: 8px 10px 12px;
gap: 8px;
background: #f7f8fc;
}
.input {
flex: 1;
background: #fff;
border-radius: 24px;
padding: 10px 14px;
font-size: 14px;
}
.ph {
color: #9aa3b2;
}
.mic {
width: 36px;
height: 36px;
border-radius: 18px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.mic.recording {
background: #fffbf0;
box-shadow: 0 0 0 2px rgba(255, 193, 7, .25) inset;
}
.send {
height: 36px;
line-height: 36px;
padding: 0 14px;
border-radius: 18px;
background: #4e7bff;
color: #fff;
font-size: 14px;
}
/* drawer */
.drawer-mask {
width: 70vw;
height: 100vh;
}
.drawer {
width: 100%;
height: 100vh;
background: #fff;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
display: flex;
flex-direction: column;
}
.drawer.show {
transform: translateX(0);
}
.drawer-scroll {
height: calc(100vh - 64px);
padding: 12px;
box-sizing: border-box;
}
.drawer-group {
padding: 10px 8px 0;
}
.drawer-date {
color: #9aa3b2;
font-size: 12px;
margin-bottom: 8px;
}
.drawer-item {
color: #333;
font-size: 13px;
line-height: 20px;
margin: 6px 0;
}
.drawer-divider {
height: 1px;
background: #eeeeee;
margin: 12px 0;
}
.drawer-footer {
padding: 12px;
border-top: 1px solid #eeeeee;
display: flex;
align-items: center;
}
.drawer-footer {
/* fixed height for calc above */
height: 64px;
}
.user-icon {
width: 24px;
text-align: center;
}
.user-name {
flex: 1;
font-size: 14px;
color: #333;
}
.footer-gear {
width: 24px;
text-align: center;
}
/* voice overlay */
.record-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.record-box {
background: rgba(0, 0, 0, .75);
color: #fff;
padding: 16px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
min-width: 220rpx;
}
.record-box.cancel {
background: rgba(221, 44, 0, .85);
}
.record-icon {
font-size: 20px;
}
.record-text {
font-size: 14px;
}
.text-voice {
display: flex;
align-items: center;
}
.voice-play {
width: 20px;
margin-left: 5px;
}
.mask-layer {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, .1);
}
</style>