shop-web/src/views/chat/index.vue

306 lines
6.8 KiB
Vue
Raw Normal View History

<template>
<div class="chat-page">
<div ref="scrollRef" class="chat-messages" @scroll="onScroll">
<div v-if="loadingHistory" class="loading-history">
<van-loading size="16px">加载中...</van-loading>
</div>
<template v-for="msg in messages" :key="msg.id">
<div v-if="shouldShowTime(msg.timestamp)" class="message-time">
{{ formatTime(msg.timestamp) }}
</div>
<ChatBubble
:message="msg"
@retry="onRetry"
@delete="onDelete(msg)"
/>
</template>
</div>
<div class="clear-float" @click="onMoreAction">
<van-icon name="delete-o" />
</div>
<div class="chat-input-area">
<div class="input-row">
<van-field
v-model="inputValue"
type="textarea"
autosize
placeholder="请输入消息..."
maxlength="500"
show-word-limit
class="message-input"
@keydown.enter.exact.prevent="onSend"
/>
<van-button
type="primary"
size="small"
:loading="sending"
:disabled="!inputValue.trim() || isThinking"
@click="onSend"
>
发送
</van-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch, onMounted, onUnmounted } from "vue"
import { useRouter } from "vue-router"
import { showToast, showFailToast, showConfirmDialog } from "vant"
import ChatBubble from "./components/ChatBubble.vue"
import type { ChatMessage } from "@/common/types/chat"
import { chatStorage, intro } from "@/common/utils/storage"
import { streamApi } from "@/common/apis/agent"
const router = useRouter()
const abortController = ref<AbortController>()
const agentName = ref("智能助手")
const inputValue = ref("")
const messages = ref<ChatMessage[]>([])
const isThinking = ref(false)
const sending = ref(false)
const loadingHistory = ref(false)
const scrollRef = ref<HTMLElement>()
const currentAiMsgId = ref<string>("")
onMounted(() => {
messages.value = chatStorage.load()
// 新对话时直接输出intro
if (messages.value.length === 0) {
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: intro,
timestamp: Date.now(),
status: "sent"
}
messages.value.push(aiMsg)
}
nextTick(() => {
scrollToBottom()
})
})
watch(messages, () => {
chatStorage.save(messages.value)
}, { deep: true })
const loadHistory = async () => {
if (loadingHistory.value) return
loadingHistory.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
}
finally {
loadingHistory.value = false
}
}
const onSend = async () => {
const content = inputValue.value.trim()
if (!content || isThinking.value) return
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: "user",
content,
timestamp: Date.now(),
status: "sent"
}
messages.value.push(userMsg)
inputValue.value = ""
sending.value = true
await scrollToBottom()
isThinking.value = true
sending.value = false
// 创建AI消息占位
const aiMsgId = (Date.now() + 1).toString()
currentAiMsgId.value = aiMsgId
const aiMsg: ChatMessage = {
id: aiMsgId,
role: "assistant",
content: "",
timestamp: Date.now(),
status: "pending"
}
messages.value.push(aiMsg)
// 创建 AbortController
abortController.value = new AbortController()
const thread = chatStorage.getThread()
const resource = chatStorage.getResource()
streamApi(
content,
{
onmessage(data) {
try {
const parsed = JSON.parse(data)
if (parsed.text) {
const msg = messages.value.find(m => m.id === aiMsgId)
if (msg) {
msg.status = "sent"
msg.content += parsed.text
scrollToBottom()
}
}
}
catch (e) {
console.warn("解析SSE消息失败:", e)
}
},
onclose() {
const msg = messages.value.find(m => m.id === aiMsgId)
if (msg) {
msg.status = "sent"
}
isThinking.value = false
abortController.value = undefined
},
onerror(error) {
const msg = messages.value.find(m => m.id === aiMsgId)
if (msg) {
msg.status = "error"
}
showFailToast("连接失败,请重试")
isThinking.value = false
abortController.value = undefined
}
},
{ signal: abortController.value.signal, thread, resource }
)
}
const scrollToBottom = async () => {
await nextTick()
if (scrollRef.value) {
scrollRef.value.scrollTop = scrollRef.value.scrollHeight
}
}
const onScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target.scrollTop < 50 && !loadingHistory.value) {
loadHistory()
}
}
const shouldShowTime = (timestamp: number) => {
if (messages.value.length === 0) return true
const lastMsg = messages.value[messages.value.length - 1]
return timestamp - lastMsg.timestamp > 300000
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`
}
const onMoreAction = () => {
showConfirmDialog({
message: "确定清空对话历史?",
confirmButtonColor: "#f44"
}).then(() => {
messages.value = []
chatStorage.clear()
chatStorage.clearThread()
})
}
const onBack = () => {
router.back()
}
const onRetry = () => {
const lastUserMsg = messages.value.filter(m => m.role === "user").pop()
if (lastUserMsg) {
inputValue.value = lastUserMsg.content
onSend()
}
}
// 页面销毁时取消请求
onUnmounted(() => {
abortController.value?.abort()
})
const onDelete = (msg: ChatMessage) => {
const index = messages.value.findIndex(m => m.id === msg.id)
if (index > -1) {
messages.value.splice(index, 1)
}
}
</script>
<style scoped lang="scss">
.chat-page {
display: flex;
flex-direction: column;
height: calc(100vh - 50px);
background: #f7f8fa;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.message-time {
text-align: center;
color: #999;
font-size: 12px;
margin: 16px 0;
}
.loading-history {
text-align: center;
padding: 16px;
color: #999;
}
.chat-input-area {
background: #fff;
border-top: 1px solid #eee;
}
.input-row {
display: flex;
align-items: flex-end;
padding: 12px;
gap: 8px;
}
.message-input {
flex: 1;
background: #f7f8fa;
border-radius: 20px;
padding: 8px 12px;
}
.clear-float {
position: fixed;
right: 20px;
bottom: 290px;
width: 40px;
height: 40px;
background: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: #666;
font-size: 20px;
z-index: 100;
}
</style>