378 lines
9.0 KiB
Vue
378 lines
9.0 KiB
Vue
![]() |
<template>
|
|||
|
<div class="ai-app-chat">
|
|||
|
<div class="chat-container">
|
|||
|
<div class="chat-header">
|
|||
|
<div class="app-info">
|
|||
|
<n-avatar
|
|||
|
:size="32"
|
|||
|
:src="appInfo?.icon ? getAppIcon(appInfo.icon) : '/default-app-icon.png'"
|
|||
|
round
|
|||
|
/>
|
|||
|
<div class="app-details">
|
|||
|
<h3 class="app-name">{{ appInfo?.name || 'AI应用' }}</h3>
|
|||
|
<p class="app-desc">{{ appInfo?.descr || '智能对话助手' }}</p>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
<n-button @click="handleBack" quaternary>
|
|||
|
<template #icon>
|
|||
|
<n-icon><ArrowLeftOutlined /></n-icon>
|
|||
|
</template>
|
|||
|
返回
|
|||
|
</n-button>
|
|||
|
</div>
|
|||
|
|
|||
|
<div class="chat-content" ref="chatContentRef">
|
|||
|
<div class="message-list">
|
|||
|
<!-- 欢迎消息 -->
|
|||
|
<div v-if="messages.length === 0 && appInfo?.prologue" class="message bot-message">
|
|||
|
<n-avatar size="small" src="/ai-avatar.png" round />
|
|||
|
<div class="message-content">
|
|||
|
<div class="message-text">{{ appInfo.prologue }}</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 消息列表 -->
|
|||
|
<div
|
|||
|
v-for="(message, index) in messages"
|
|||
|
:key="index"
|
|||
|
class="message"
|
|||
|
:class="message.type === 'user' ? 'user-message' : 'bot-message'"
|
|||
|
>
|
|||
|
<n-avatar
|
|||
|
size="small"
|
|||
|
:src="message.type === 'user' ? '/user-avatar.png' : '/ai-avatar.png'"
|
|||
|
round
|
|||
|
/>
|
|||
|
<div class="message-content">
|
|||
|
<div class="message-text" v-html="formatMessage(message.content)"></div>
|
|||
|
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 加载中 -->
|
|||
|
<div v-if="isLoading" class="message bot-message">
|
|||
|
<n-avatar size="small" src="/ai-avatar.png" round />
|
|||
|
<div class="message-content">
|
|||
|
<div class="message-text">
|
|||
|
<n-spin size="small" />
|
|||
|
<span style="margin-left: 8px">正在思考中...</span>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<div class="chat-input">
|
|||
|
<!-- 预设问题 -->
|
|||
|
<div v-if="presetQuestions.length > 0 && messages.length === 0" class="preset-questions">
|
|||
|
<n-space>
|
|||
|
<n-button
|
|||
|
v-for="question in presetQuestions"
|
|||
|
:key="question"
|
|||
|
size="small"
|
|||
|
@click="handlePresetQuestion(question)"
|
|||
|
>
|
|||
|
{{ question }}
|
|||
|
</n-button>
|
|||
|
</n-space>
|
|||
|
</div>
|
|||
|
|
|||
|
<div class="input-area">
|
|||
|
<n-input
|
|||
|
v-model:value="inputMessage"
|
|||
|
type="textarea"
|
|||
|
placeholder="请输入您的问题..."
|
|||
|
:rows="3"
|
|||
|
:maxlength="1000"
|
|||
|
show-count
|
|||
|
@keydown.enter.prevent="handleSend"
|
|||
|
/>
|
|||
|
<n-button
|
|||
|
type="primary"
|
|||
|
:disabled="!inputMessage.trim() || isLoading"
|
|||
|
:loading="isLoading"
|
|||
|
@click="handleSend"
|
|||
|
>
|
|||
|
发送
|
|||
|
</n-button>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</template>
|
|||
|
|
|||
|
<script setup lang="ts">
|
|||
|
import { ref, reactive, onMounted, nextTick } from 'vue'
|
|||
|
import { useRoute, useRouter } from 'vue-router'
|
|||
|
import { useMessage } from 'naive-ui'
|
|||
|
import { ArrowLeftOutlined } from '@vicons/antd'
|
|||
|
import { aiAppApi } from './aiApp'
|
|||
|
import type { AiApp } from './type/aiApp'
|
|||
|
|
|||
|
interface ChatMessage {
|
|||
|
type: 'user' | 'bot'
|
|||
|
content: string
|
|||
|
timestamp: number
|
|||
|
}
|
|||
|
|
|||
|
const route = useRoute()
|
|||
|
const router = useRouter()
|
|||
|
const message = useMessage()
|
|||
|
|
|||
|
const appId = route.params.id as string
|
|||
|
const appInfo = ref<AiApp | null>(null)
|
|||
|
const messages = ref<ChatMessage[]>([])
|
|||
|
const inputMessage = ref('')
|
|||
|
const isLoading = ref(false)
|
|||
|
const chatContentRef = ref<HTMLElement>()
|
|||
|
|
|||
|
const presetQuestions = ref<string[]>([])
|
|||
|
|
|||
|
// 获取应用信息
|
|||
|
const loadAppInfo = async () => {
|
|||
|
try {
|
|||
|
const response = await aiAppApi.getAppById({ id: appId })
|
|||
|
if (response.success) {
|
|||
|
appInfo.value = response.result
|
|||
|
// 解析预设问题
|
|||
|
if (appInfo.value.presetQuestion) {
|
|||
|
presetQuestions.value = appInfo.value.presetQuestion.split('\n').filter(q => q.trim())
|
|||
|
}
|
|||
|
}
|
|||
|
} catch (error) {
|
|||
|
message.error('加载应用信息失败')
|
|||
|
console.error('Load app info error:', error)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 获取应用图标
|
|||
|
const getAppIcon = (icon: string) => {
|
|||
|
return icon ? `/api/sys/common/static/${icon}` : '/default-app-icon.png'
|
|||
|
}
|
|||
|
|
|||
|
// 发送消息
|
|||
|
const handleSend = async () => {
|
|||
|
if (!inputMessage.value.trim() || isLoading.value) return
|
|||
|
|
|||
|
const userMessage: ChatMessage = {
|
|||
|
type: 'user',
|
|||
|
content: inputMessage.value.trim(),
|
|||
|
timestamp: Date.now()
|
|||
|
}
|
|||
|
|
|||
|
messages.value.push(userMessage)
|
|||
|
const currentInput = inputMessage.value.trim()
|
|||
|
inputMessage.value = ''
|
|||
|
isLoading.value = true
|
|||
|
|
|||
|
// 滚动到底部
|
|||
|
await nextTick()
|
|||
|
scrollToBottom()
|
|||
|
|
|||
|
try {
|
|||
|
// 这里应该调用实际的AI对话接口
|
|||
|
// 暂时使用模拟响应
|
|||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|||
|
|
|||
|
const botMessage: ChatMessage = {
|
|||
|
type: 'bot',
|
|||
|
content: `您好!我收到了您的问题:"${currentInput}"。这是一个模拟回复,实际的AI对话功能需要连接到具体的AI服务。`,
|
|||
|
timestamp: Date.now()
|
|||
|
}
|
|||
|
|
|||
|
messages.value.push(botMessage)
|
|||
|
await nextTick()
|
|||
|
scrollToBottom()
|
|||
|
} catch (error) {
|
|||
|
message.error('发送消息失败')
|
|||
|
console.error('Send message error:', error)
|
|||
|
} finally {
|
|||
|
isLoading.value = false
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 处理预设问题
|
|||
|
const handlePresetQuestion = (question: string) => {
|
|||
|
inputMessage.value = question
|
|||
|
handleSend()
|
|||
|
}
|
|||
|
|
|||
|
// 返回
|
|||
|
const handleBack = () => {
|
|||
|
router.back()
|
|||
|
}
|
|||
|
|
|||
|
// 格式化消息
|
|||
|
const formatMessage = (content: string) => {
|
|||
|
return content.replace(/\n/g, '<br>')
|
|||
|
}
|
|||
|
|
|||
|
// 格式化时间
|
|||
|
const formatTime = (timestamp: number) => {
|
|||
|
const date = new Date(timestamp)
|
|||
|
return date.toLocaleTimeString('zh-CN', {
|
|||
|
hour: '2-digit',
|
|||
|
minute: '2-digit'
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
// 滚动到底部
|
|||
|
const scrollToBottom = () => {
|
|||
|
if (chatContentRef.value) {
|
|||
|
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
onMounted(() => {
|
|||
|
loadAppInfo()
|
|||
|
})
|
|||
|
</script>
|
|||
|
|
|||
|
<style scoped lang="scss">
|
|||
|
.ai-app-chat {
|
|||
|
height: 100vh;
|
|||
|
display: flex;
|
|||
|
flex-direction: column;
|
|||
|
background: #f5f5f5;
|
|||
|
|
|||
|
.chat-container {
|
|||
|
flex: 1;
|
|||
|
display: flex;
|
|||
|
flex-direction: column;
|
|||
|
max-width: 800px;
|
|||
|
margin: 0 auto;
|
|||
|
background: white;
|
|||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|||
|
|
|||
|
.chat-header {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
justify-content: space-between;
|
|||
|
padding: 16px 24px;
|
|||
|
border-bottom: 1px solid #f0f0f0;
|
|||
|
background: white;
|
|||
|
|
|||
|
.app-info {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
gap: 12px;
|
|||
|
|
|||
|
.app-details {
|
|||
|
.app-name {
|
|||
|
margin: 0;
|
|||
|
font-size: 16px;
|
|||
|
font-weight: 600;
|
|||
|
color: #333;
|
|||
|
}
|
|||
|
|
|||
|
.app-desc {
|
|||
|
margin: 4px 0 0 0;
|
|||
|
font-size: 12px;
|
|||
|
color: #666;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
.chat-content {
|
|||
|
flex: 1;
|
|||
|
overflow-y: auto;
|
|||
|
padding: 16px 24px;
|
|||
|
|
|||
|
.message-list {
|
|||
|
display: flex;
|
|||
|
flex-direction: column;
|
|||
|
gap: 16px;
|
|||
|
|
|||
|
.message {
|
|||
|
display: flex;
|
|||
|
gap: 12px;
|
|||
|
|
|||
|
&.user-message {
|
|||
|
flex-direction: row-reverse;
|
|||
|
|
|||
|
.message-content {
|
|||
|
background: #1890ff;
|
|||
|
color: white;
|
|||
|
border-radius: 18px 18px 4px 18px;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
&.bot-message {
|
|||
|
.message-content {
|
|||
|
background: #f6f6f6;
|
|||
|
color: #333;
|
|||
|
border-radius: 18px 18px 18px 4px;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
.message-content {
|
|||
|
max-width: 70%;
|
|||
|
padding: 12px 16px;
|
|||
|
|
|||
|
.message-text {
|
|||
|
line-height: 1.5;
|
|||
|
word-break: break-word;
|
|||
|
}
|
|||
|
|
|||
|
.message-time {
|
|||
|
font-size: 11px;
|
|||
|
opacity: 0.7;
|
|||
|
margin-top: 4px;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
.chat-input {
|
|||
|
padding: 16px 24px;
|
|||
|
border-top: 1px solid #f0f0f0;
|
|||
|
background: white;
|
|||
|
|
|||
|
.preset-questions {
|
|||
|
margin-bottom: 12px;
|
|||
|
}
|
|||
|
|
|||
|
.input-area {
|
|||
|
display: flex;
|
|||
|
gap: 12px;
|
|||
|
align-items: flex-end;
|
|||
|
|
|||
|
.n-input {
|
|||
|
flex: 1;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 响应式设计
|
|||
|
@media (max-width: 768px) {
|
|||
|
.ai-app-chat {
|
|||
|
.chat-container {
|
|||
|
margin: 0;
|
|||
|
border-radius: 0;
|
|||
|
box-shadow: none;
|
|||
|
|
|||
|
.chat-header,
|
|||
|
.chat-content,
|
|||
|
.chat-input {
|
|||
|
padding-left: 16px;
|
|||
|
padding-right: 16px;
|
|||
|
}
|
|||
|
|
|||
|
.chat-content {
|
|||
|
.message-list {
|
|||
|
.message {
|
|||
|
.message-content {
|
|||
|
max-width: 85%;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
</style>
|