| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- <template>
- <div class="ai-assist-wrapper">
- <div class="ai-assist-container" v-if="!showDataBoard && !showPerformanceAnalysis && !showUseReports">
- <!-- 消息区域 -->
- <div class="messages-area" ref="messagesRef">
- <div v-if="messages.length === 0" class="welcome-message">
- <h2>您好,今天有什么可以帮到您</h2>
- </div>
- <div v-else class="messages-list">
- <div v-for="msg in messages" :key="msg.id" :class="['message-item', msg.type]">
- <!-- 用户消息 -->
- <div v-if="msg.type === 'user'" class="user-bubble">{{ msg.text }}</div>
- <!-- AI 回复 -->
- <div v-else class="assistant-bubble">
- <!-- 进度步骤 -->
- <div v-if="msg.steps.length" class="progress-steps">
- <div v-for="step in msg.steps" :key="step.step" :class="['step-item', step.status]">
- <span class="step-icon">
- <svg v-if="step.status === 'running'" class="spin-icon" viewBox="0 0 24 24" fill="none">
- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="32"
- stroke-dashoffset="10" />
- </svg>
- <svg v-else viewBox="0 0 24 24" fill="none">
- <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"
- stroke-linejoin="round" />
- </svg>
- </span>
- <span class="step-name">{{ step.name }}</span>
- <span v-if="step.detail" class="step-detail">{{ step.detail }}</span>
- <span v-if="step.duration != null" class="step-duration">{{ step.duration }}s</span>
- </div>
- </div>
- <!-- 结果内容 -->
- <div v-if="msg.result" class="result-content">
- <!-- 知识问答 -->
- <div v-if="msg.result.query_type === 'knowledge'" class="knowledge-answer">
- <div class="answer-text">{{ msg.result.answer }}</div>
- <div v-if="msg.result.sources && msg.result.sources.length" class="sources">
- <span class="sources-label">参考来源:</span>
- <el-tag v-for="s in msg.result.sources" :key="s.filename" size="small" class="source-tag">
- {{ s.filename }}
- </el-tag>
- </div>
- </div>
- <!-- 数据查询 / 混合查询 -->
- <div v-if="msg.result.query_type === 'data' || msg.result.query_type === 'hybrid'">
- <div v-if="msg.result.answer" class="knowledge-answer" style="margin-bottom: 12px;">
- <div class="answer-text">{{ msg.result.answer }}</div>
- </div>
- <div class="result-summary">{{ msg.result.message }}</div>
- <el-table v-if="msg.result.rows && msg.result.rows.length" :data="msg.result.rows" border size="small"
- max-height="360" class="result-table">
- <el-table-column v-for="col in msg.result.columns" :key="col" :prop="col" :label="col"
- min-width="100" />
- </el-table>
- <div v-else-if="msg.result.rows" class="no-data">暂无数据</div>
- </div>
- <!-- 耗时 -->
- <div v-if="msg.result.timing && msg.result.timing.total" class="timing-info">
- 共耗时 {{ msg.result.timing.total }}s
- </div>
- </div>
- <!-- 错误 -->
- <div v-if="msg.status === 'error'" class="error-message">
- <svg viewBox="0 0 24 24" fill="none" width="16" height="16">
- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
- <path d="M12 8v4m0 4h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
- </svg>
- {{ msg.errorText }}
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 底部输入区 -->
- <div class="chat-container">
- <div class="quick-action">
- <el-button class="report-btn" @click="handleReportClick">质控分析报告</el-button>
- <el-button class="report-btn" @click="handlePerformanceClick">绩效分析报告</el-button>
- <el-button class="report-btn" @click="handleUseReportClick">使用报表</el-button>
- </div>
- <!--请输入您的问题,按 Enter 发送...-->
- <div class="input-container">
- <el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="功能开发中,请敬请期待"
- class="chat-input" @keydown.enter.exact.prevent="handleSend" :disabled="true" />
- <el-button :disabled="!inputMessage.trim() || isLoading" circle class="send-btn" @click="handleSend">
- <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
- <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
- </svg>
- </el-button>
- </div>
- </div>
- </div>
- <DataBoard v-if="showDataBoard" @back="handleBack" />
- <PerformanceAnalysis v-if="showPerformanceAnalysis" @back="handleBack" />
- <UseReports v-if="showUseReports" @back="handleBack" />
- </div>
- </template>
- <script setup>
- import { ref, nextTick, onMounted, onActivated, onDeactivated } from 'vue'
- import DataBoard from './components/dataBoard.vue'
- import PerformanceAnalysis from './components/performanceAnalysis.vue'
- import useUserStore from '@/store/modules/user'
- import UseReports from './components/useReports.vue'
- const userStore = useUserStore()
- const AI_SERVICE_URL = import.meta.env.VITE_AI_SERVICE_URL || 'http://localhost:8000'
- const inputMessage = ref('')
- const isLoading = ref(false)
- const messages = ref([])
- const messagesRef = ref(null)
- const showDataBoard = ref(false)
- const showPerformanceAnalysis = ref(false)
- const showUseReports = ref(false)
- onMounted(() => resetState())
- onActivated(() => resetState())
- onDeactivated(() => resetState())
- const resetState = () => {
- showDataBoard.value = false
- showPerformanceAnalysis.value = false
- inputMessage.value = ''
- messages.value = []
- showUseReports.value = false
- isLoading.value = false
- }
- const scrollToBottom = async () => {
- await nextTick()
- if (messagesRef.value) {
- messagesRef.value.scrollTop = messagesRef.value.scrollHeight
- }
- }
- const handleSend = async () => {
- const text = inputMessage.value.trim()
- if (!text || isLoading.value) return
- inputMessage.value = ''
- isLoading.value = true
- // 添加用户消息
- messages.value.push({ id: Date.now(), type: 'user', text })
- await scrollToBottom()
- // 添加 AI 占位消息
- const assistantMsg = {
- id: Date.now() + 1,
- type: 'assistant',
- status: 'loading',
- steps: [],
- result: null,
- errorText: ''
- }
- messages.value.push(assistantMsg)
- await scrollToBottom()
- try {
- const response = await fetch(`${AI_SERVICE_URL}/api/smart-query-stream`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- question: text,
- user_id: String(userStore.id || 'anonymous'),
- llm_type: 'claude'
- })
- })
- if (!response.ok) {
- throw new Error(`服务请求失败 (${response.status})`)
- }
- const reader = response.body.getReader()
- const decoder = new TextDecoder()
- let buffer = ''
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- buffer += decoder.decode(value, { stream: true })
- // SSE 事件以两个换行符分隔
- const blocks = buffer.split('\n\n')
- buffer = blocks.pop() // 保留未完整的块
- for (const block of blocks) {
- if (!block.trim()) continue
- const lines = block.split('\n')
- let eventType = ''
- let dataStr = ''
- for (const line of lines) {
- if (line.startsWith('event: ')) eventType = line.slice(7).trim()
- else if (line.startsWith('data: ')) dataStr = line.slice(6).trim()
- }
- if (!dataStr) continue
- let data
- try { data = JSON.parse(dataStr) } catch { continue }
- if (eventType === 'progress') {
- const existing = assistantMsg.steps.find(s => s.step === data.step)
- if (existing) {
- Object.assign(existing, data)
- } else {
- assistantMsg.steps.push({ ...data })
- }
- // 触发响应式更新
- messages.value = [...messages.value]
- await scrollToBottom()
- } else if (eventType === 'result') {
- // 将所有仍在 running 的步骤标记为 done
- assistantMsg.steps.forEach(s => { if (s.status === 'running') s.status = 'done' })
- assistantMsg.result = data
- assistantMsg.status = 'done'
- messages.value = [...messages.value]
- await scrollToBottom()
- } else if (eventType === 'error') {
- assistantMsg.status = 'error'
- assistantMsg.errorText = data.message || '请求失败,请稍后重试'
- messages.value = [...messages.value]
- }
- }
- }
- // 若未收到 result 事件
- if (assistantMsg.status === 'loading') {
- assistantMsg.status = 'error'
- assistantMsg.errorText = '未收到服务响应,请检查 AI 服务是否正常运行'
- messages.value = [...messages.value]
- }
- } catch (e) {
- assistantMsg.status = 'error'
- assistantMsg.errorText = e.message || '网络错误,请检查 AI 服务连接'
- messages.value = [...messages.value]
- } finally {
- isLoading.value = false
- await scrollToBottom()
- }
- }
- const handleReportClick = () => { showDataBoard.value = true }
- const handlePerformanceClick = () => { showPerformanceAnalysis.value = true }
- const handleUseReportClick = () => { showUseReports.value = true }
- const handleBack = () => {
- showDataBoard.value = false
- showPerformanceAnalysis.value = false
- showUseReports.value = false
- }
- </script>
- <style scoped>
- .ai-assist-wrapper {
- background: #f5f5f5;
- min-height: 100vh;
- }
- .ai-assist-container {
- height: 100vh;
- display: flex;
- flex-direction: column;
- background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
- padding: 0 20px 20px;
- box-sizing: border-box;
- }
- /* ===== 消息区域 ===== */
- .messages-area {
- flex: 1;
- overflow-y: auto;
- padding: 20px 0;
- }
- .welcome-message {
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .welcome-message h2 {
- color: #333;
- font-size: 28px;
- font-weight: 400;
- margin: 0;
- opacity: 0.8;
- }
- .messages-list {
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- .message-item {
- display: flex;
- }
- .message-item.user {
- justify-content: flex-end;
- }
- .message-item.assistant {
- justify-content: flex-start;
- }
- /* 用户气泡 */
- .user-bubble {
- max-width: 70%;
- background: #557DDB;
- color: #fff;
- padding: 10px 16px;
- border-radius: 18px 18px 4px 18px;
- font-size: 14px;
- line-height: 1.6;
- word-break: break-word;
- }
- /* AI 气泡 */
- .assistant-bubble {
- max-width: 80%;
- background: #fff;
- padding: 14px 16px;
- border-radius: 18px 18px 18px 4px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
- font-size: 14px;
- line-height: 1.6;
- }
- /* ===== 进度步骤 ===== */
- .progress-steps {
- display: flex;
- flex-direction: column;
- gap: 6px;
- margin-bottom: 10px;
- }
- .step-item {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 13px;
- color: #888;
- }
- .step-item.done {
- color: #67c23a;
- }
- .step-item.running {
- color: #557DDB;
- }
- .step-icon {
- width: 16px;
- height: 16px;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .spin-icon {
- animation: spin 1s linear infinite;
- }
- @keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
- }
- .step-detail {
- color: #aaa;
- font-size: 12px;
- }
- .step-duration {
- color: #aaa;
- font-size: 12px;
- margin-left: auto;
- }
- /* ===== 结果内容 ===== */
- .result-content {
- margin-top: 4px;
- }
- .knowledge-answer .answer-text {
- color: #333;
- white-space: pre-wrap;
- line-height: 1.8;
- }
- .sources {
- margin-top: 10px;
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- }
- .sources-label {
- font-size: 12px;
- color: #999;
- }
- .source-tag {
- font-size: 12px;
- }
- .result-summary {
- color: #666;
- font-size: 13px;
- margin-bottom: 8px;
- }
- .result-table {
- width: 100%;
- }
- .no-data {
- color: #999;
- font-size: 13px;
- text-align: center;
- padding: 12px 0;
- }
- .timing-info {
- margin-top: 10px;
- font-size: 12px;
- color: #bbb;
- text-align: right;
- }
- .error-message {
- display: flex;
- align-items: center;
- gap: 6px;
- color: #f56c6c;
- font-size: 13px;
- }
- /* ===== 底部输入区 ===== */
- .chat-container {
- flex-shrink: 0;
- }
- .quick-action {
- margin-bottom: 10px;
- display: flex;
- gap: 8px;
- }
- .report-btn {
- color: #557DDB;
- border: 1px solid #557DDB;
- padding: 8px 16px;
- border-radius: 4px;
- font-size: 14px;
- }
- .report-btn:hover {
- background-color: #557DDB;
- color: white;
- }
- .input-container {
- position: relative;
- }
- .chat-input :deep(.el-textarea__inner) {
- border: 1px solid #e0e0e0;
- border-radius: 12px;
- padding: 16px;
- font-size: 14px;
- resize: none;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- padding-right: 60px;
- }
- .send-btn {
- position: absolute;
- right: 8px;
- bottom: 8px;
- background-color: #557DDB;
- color: white;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10;
- }
- .send-btn:hover:not(.is-disabled) {
- background-color: #3a6bd9;
- }
- .send-btn.is-disabled {
- background-color: #c0c4cc;
- cursor: not-allowed;
- }
- @media (max-width: 768px) {
- .ai-assist-container {
- padding: 0 16px 16px;
- }
- .welcome-message h2 {
- font-size: 24px;
- }
- .user-bubble,
- .assistant-bubble {
- max-width: 95%;
- }
- }
- </style>
|