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.

638 lines
16 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" :style="{ paddingBottom: paddingBottom + 'px' }">
<page-meta
:page-style="'overflow:' + (show ? 'hidden' : 'visible')"
></page-meta>
<top @clickLeft="openDrawer"></top>
<scroll-view
class="content"
:scroll-y="true"
show-scrollbar="false"
scroll-with-animation
ref="scrollView"
>
<front @onSuggestionTap="onSuggestionTap" />
<chat :messages="messages" @playVoice="playVoice" />
<view style="height: 12px" />
</scroll-view>
<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>
<leftDrawer
:historyGroups="historyGroups"
ref="popup"
@changeShow="changeShow"
@onHistoryItemTap="onHistoryItemTap"
@removeFromHistory="removeFromHistory"
@clearAllHistory="clearAllHistory"
/>
<!-- 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";
import top from "./top/index.vue";
import front from "./front/index.vue";
import chat from "./chat/index.vue";
import leftDrawer from "./leftDrawer/index.vue";
import { recognizeAudio } from "@/utils/uploadVoice.js";
export default {
components: {
top,
front,
chat,
leftDrawer,
},
data() {
return {
inputText: "",
messages: [],
historyGroups: [],
isRecording: false,
isLoading: false,
willCancel: false,
recorder: null,
recordStartY: 0,
recordStartTs: 0,
recordSimTimer: null,
innerAudioContext: null,
typewriterTimers: {},
quickAskList: [
{
id: 1,
label: "自我介绍",
quickAskText: "你是谁?",
},
{
id: 2,
label: "快捷提问",
quickAskText: "今日任务有哪些?",
},
{
id: 3,
label: "快捷提问",
quickAskText: "展示一份报表示例",
},
{
id: 4,
label: "快捷提问",
quickAskText: "生成日报模版",
},
],
show: false,
paddingBottom: 0,
};
},
mounted() {
this.loadChatHistory();
this.scrollToBottom();
let self = this;
uni.onKeyboardHeightChange((res) => {
self.paddingBottom = res.height;
});
},
beforeDestroy() {
// 清理所有打字机定时器
Object.values(this.typewriterTimers).forEach((timer) => {
if (timer) clearInterval(timer);
});
this.typewriterTimers = {};
// 清理录音定时器
if (this.recordSimTimer) {
clearTimeout(this.recordSimTimer);
}
},
methods: {
// top 打开历史记录
openDrawer() {
this.$refs.popup.open();
},
// front 猜你想问
onSuggestionTap(text) {
this.inputText = text;
this.onSend();
},
// leftDrawer 点击历史记录搜索
onHistoryItemTap(text) {
this.inputText = text;
this.onSend();
this.$refs.popup.close();
},
// leftDrawer 打开历史记录,聊天页面禁止滚动
changeShow(e) {
this.show = e;
},
// leftDrawer 删除历史记录
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(),
});
},
// leftDrawer 清除全部历史记录
clearAllHistory() {
uni.showModal({
title: "清除全部",
content: "将删除所有对话记录,此操作不可恢复",
success: (res) => {
if (res.confirm) {
uni.removeStorageSync(HISTORY_KEY);
this.historyGroups = [];
uni.showToast({
title: "已清除",
icon: "success",
});
}
},
});
},
// ==================== 历史记录管理 ====================
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(),
});
},
// 快速提问
onQuickAsk(text) {
this.inputText = text;
this.onSend();
},
// 模型切换
onSwitchModel() {
uni.showToast({
title: "已切换为通用模型",
icon: "none",
});
},
// onInput(e) {
// this.inputText = e.detail.value;
// },
// ===== 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(async (res) => {
const duration = Date.now() - this.recordStartTs;
if (this.willCancel || duration < 700) {
uni.showToast({
title: duration < 700 ? "说话时间太短" : "已取消",
icon: "none",
});
return;
}
uni.showLoading({
title: "识别中...",
});
const text = await recognizeAudio(res.tempFilePath);
uni.hideLoading();
this.inputText = text;
// TODO: 上传 res.tempFilePath 做识别;现用 mock
// this.handleRecognizedText(this.mockSpeechToText(duration), res.tempFilePath, Math.ceil(
// duration / 100) / 10)
this.onSend();
});
}
},
// 按下录音
onPressMic(e) {
if (this.isLoading)
return uni.showToast({
title: "AI正在回复中",
icon: "none",
});
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: "amr",
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)];
// },
// 发送消息
async onSend(inputType = "text", inputContent = "", duration = undefined) {
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();
});
},
},
};
</script>
<style scoped>
::v-deep .uni-nav-bar-text {
font-size: 18px !important;
}
::v-deeo .uni-navbar--border {
border-bottom: 1px solid #fff !important;
}
.ai-page {
display: flex;
flex-direction: column;
background: #f7f8fc;
}
.content {
flex: 1;
padding: 16px 12px 74px 12px;
background-color: #f7f8fc;
width: 100%;
box-sizing: border-box;
}
/* 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, 0.25) inset;
}
.send {
height: 36px;
line-height: 36px;
padding: 0 14px;
border-radius: 18px;
background: #4e7bff;
color: #fff;
font-size: 14px;
}
/* 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, 0.75);
color: #fff;
padding: 16px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
min-width: 220rpx;
}
.record-text {
font-size: 14px;
}
.mask-layer {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.1);
}
.history-popup {
z-index: 99999;
}
::v-deep .drawer-scroll {
padding-top: 44px;
}
</style>