feat:增加美食分析助手

This commit is contained in:
2026-07-04 20:25:51 +08:00
parent 0ec0bba2fa
commit 3535d33c4b
7 changed files with 254 additions and 83 deletions

View File

@@ -31,6 +31,10 @@ server {
proxy_pass http://host.docker.internal:3000/; proxy_pass http://host.docker.internal:3000/;
} }
location /agent-api/ {
proxy_pass http://host.docker.internal:8000/;
}
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }

View File

@@ -10,10 +10,10 @@
<i class="fa-solid fa-chart-pie"></i> <i class="fa-solid fa-chart-pie"></i>
<span>统计</span> <span>统计</span>
</router-link> </router-link>
<router-link v-if="path.includes('bill')" to="/bill/search" <router-link v-if="path.includes('bill')" to="/bill/agent"
@click="routerClick()" class="item"> @click="routerClick()" class="item">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
<span>搜索</span> <span>分析</span>
</router-link> </router-link>
<router-link v-if="path.includes('food')" to="/food/recipe" <router-link v-if="path.includes('food')" to="/food/recipe"

16
src/types/agent.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface IChatMessage {
role: 'user' | 'ai';
content: string;
}
export interface IAgentMessage {
username: string;
message: string;
thread_id: string;
}
export interface ISessionMessage {
username: string;
title: string;
thread_id: string;
}

225
src/views/bill/agent.vue Normal file
View File

@@ -0,0 +1,225 @@
<template>
<div class="common-mobile-view">
<main class="message-container" ref="msgContainer">
<div v-if="agentInfo.chatMessages.length === 0" class="empty-state">
<h2>您好我是你的智能账单分析助手</h2>
<p>请问有什么可以帮你的</p>
<div class="quick-actions">
<el-button v-for="q in quickQuestions" :key="q" type="primary" round
@click="askQuick(q)">
{{ q }}
</el-button>
</div>
</div>
<div v-for="(item, index) in agentInfo.chatMessages" :key="index"
class="msg-row" :class="item.role">
<div class="bubble">
<MdPreview v-if="item.role === 'ai'" :modelValue="item.content" />
<span v-else>{{ item.content }}</span>
</div>
</div>
<div v-if="agentInfo.loading" class="msg-row ai">
<div class="bubble typing">
<span /><span /><span />
</div>
</div>
</main>
<footer class="input-bar">
<el-input
v-model="agentInfo.inputMessage"
type="textarea" autosize
placeholder="给智能助手发送消息"
@keyup.enter="sendMessage"
:disabled="agentInfo.loading"
/>
<el-button type="primary" circle :loading="agentInfo.loading"
:disabled="!agentInfo.inputMessage.trim()" @click="sendMessage">
<template #icon>
<el-icon><Promotion /></el-icon>
</template>
</el-button>
</footer>
</div>
</template>
<script lang="ts" setup>
import { Promotion } from '@element-plus/icons-vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import { nextTick, reactive, ref } from 'vue'
import { IChatMessage } from '@/types/agent.ts'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const msgContainer = ref<HTMLElement>()
const agentInfo = reactive({
inputMessage: '',
loading: false,
chatMessages: [] as IChatMessage[]
})
const quickQuestions = [
'帮我分析下这个月的账单',
'帮我查询下最近花费最大的一笔是什么?',
'帮我查询下今年存了多少钱?'
]
const scrollToBottom = () => {
nextTick(() => {
const el = msgContainer.value
if (el) el.scrollTop = el.scrollHeight
})
}
const sendMessage = async () => {
if (!agentInfo.inputMessage.trim() || agentInfo.loading) return
const userMsg = agentInfo.inputMessage
agentInfo.inputMessage = ''
agentInfo.chatMessages.push({ role: 'user', content: userMsg })
agentInfo.loading = true
scrollToBottom()
try {
const response = await fetch('/agent-api/query/bill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userMsg, platform: isMobile() ? 'mobile' : 'pc' })
})
agentInfo.chatMessages.push({ role: 'ai', content: '' })
const reader = response.body!.getReader()
const decoder = new TextDecoder('utf-8')
let aiText = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
aiText += decoder.decode(value, { stream: true })
agentInfo.chatMessages[agentInfo.chatMessages.length - 1].content = aiText
scrollToBottom()
}
} finally {
agentInfo.loading = false
scrollToBottom()
}
}
const askQuick = (q: string) => {
agentInfo.inputMessage = q
sendMessage()
}
</script>
<style lang="scss">
.message-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
h2 {
font-size: 18px;
color: #333;
margin-bottom: 4px;
}
p {
font-size: 13px;
}
.quick-actions {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
}
.msg-row {
display: flex;
&.ai {
justify-content: flex-start;
}
&.user {
justify-content: flex-end;
}
.bubble {
max-width: 78%;
line-height: 1.6;
border-radius: 16px;
word-break: break-word;
}
&.ai .bubble {
max-width: 90%;
}
&.user .bubble {
padding: 10px;
background: var(--el-color-primary);
color: #fff;
}
.typing {
display: flex;
gap: 5px;
padding: 12px 14px;
span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #bbb;
animation: blink 1.2s infinite both;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
.input-bar {
display: flex;
gap: 8px;
padding: 10px 12px;
background: #fff;
border-radius: 12px;
}
</style>

View File

@@ -1,79 +0,0 @@
<template>
<div class="common-mobile-view">
<bill-search />
<div v-if="billStore.billRecordList.length === 0">
<el-empty description="暂无数据"/>
</div>
<div v-else class="bill-search-list card-item">
<div class="search-result-title">
<span>收入{{ formatAmount(getTotalAmount(billStore.billRecordList, 'income')) }}</span>
<span>支出{{ formatAmount(getTotalAmount(billStore.billRecordList, 'expense')) }}</span>
</div>
<daily-card v-for="(item, index) in billStore.billRecordList"
:key="index" @click="selectBillRecordClick(item)"
:title="item.category" :content="item.content">
<template #icon>
<i :class="getCategoryByName(billStore.billCategoryList, item.category).icon"
:style="{color: getCategoryByName(billStore.billCategoryList, item.category).color}">
</i>
</template>
<template #content>
<span v-if="item.location" class="location">
<i class="fa-solid fa-location-dot"></i>
{{ item.location }}
</span>
</template>
<template #extra>
<span>{{ formatAmount(item.amount, true) }}</span>
<span>{{ item.payAccount }}</span>
</template>
</daily-card>
</div>
</div>
</template>
<script lang="ts" setup>
import BillSearch from '@/components/Bill/BillSearch.vue'
import DailyCard from '@/components/DailyCard.vue'
import { onMounted } from 'vue'
import { useBillStore } from '@/store/bill.ts'
import { getCategoryByName, getTotalAmount } from '@/utils/home/billUtil'
import { formatAmount } from 'vue3-common/utils/numberUtil'
import { IBillRecord } from '@/types/bill'
import { useRouter } from 'vue-router'
const billStore = useBillStore()
const router = useRouter()
onMounted(async () => {
await billStore.searchInfo()
})
const selectBillRecordClick = async (billRecord: IBillRecord) => {
await billStore.queryBillRecord(billRecord.id as number)
billStore.billApiType = 'UPDATE'
await router.push({
name: 'BillDetailId',
params: { id: billStore.currentBillRecord.id }
})
}
</script>
<style lang="scss">
.bill-search-list {
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
.search-result-title {
align-self: flex-end;
display: flex;
gap: 10px;
}
}</style>

View File

@@ -15,8 +15,8 @@ export const mobileHomeMeta: IRouteMetaConfig = {
title: '账单图表', title: '账单图表',
icon: '' icon: ''
}, },
'/bill/search': { '/bill/agent': {
title: '账单搜索', title: '分析',
icon: '' icon: ''
}, },
'/bill/detail/:id': { '/bill/detail/:id': {

View File

@@ -59,6 +59,11 @@ export default defineConfig({
target: 'http://192.168.1.7:3000', target: 'http://192.168.1.7:3000',
changeOrigin: true, changeOrigin: true,
rewrite: (item) => item.replace(/^\/image-api/, '') rewrite: (item) => item.replace(/^\/image-api/, '')
},
'/agent-api': {
target: `http://${API_HOST}:8000`,
changeOrigin: true,
rewrite: (item) => item.replace(/^\/agent-api/, '')
} }
} }
} }