Files
AI-Bill/apps/frontend/src/features/analysis/pages/AnalysisPage.vue

150 lines
5.5 KiB
Vue
Raw Normal View History

2025-11-01 09:24:26 +08:00
<script setup lang="ts">
import { reactive, ref } from 'vue';
import LucideIcon from '../../../components/common/LucideIcon.vue';
import { useSpendingInsightQuery, useCalorieEstimationMutation } from '../../../composables/useAnalysis';
const range = ref<'30d' | '90d'>('30d');
const { data: insight } = useSpendingInsightQuery(range);
const calorieQuery = useCalorieEstimationMutation();
const calorieForm = reactive({ query: '' });
interface ChatMessage {
role: 'assistant' | 'user';
text: string;
}
const messages = ref<ChatMessage[]>([
{
role: 'assistant',
text: '你好我分析了你近30天的消费。你似乎在“餐饮”上的支出较多特别是工作日午餐和咖啡。这里有几条建议\n- 尝试每周带 1-2 次午餐。\n- 将每天喝咖啡的习惯改为每周 3 次。\n- 设置一个“餐饮”类的单日预算。'
}
]);
const submitCalorieQuery = async () => {
if (!calorieForm.query.trim()) return;
const userMessage: ChatMessage = { role: 'user', text: calorieForm.query };
messages.value.push(userMessage);
try {
const result = await calorieQuery.mutateAsync(calorieForm.query);
const response: ChatMessage = {
role: 'assistant',
text: `热量估算:${result.calories} kcal\n${result.insights?.join('\n') ?? ''}`
};
messages.value.push(response);
} catch (error) {
messages.value.push({
role: 'assistant',
text: 'AI 服务暂时不可用,请稍后重试。'
});
console.error(error);
} finally {
calorieForm.query = '';
}
};
</script>
<template>
<div class="p-6 pt-10 pb-24 space-y-8">
<header class="space-y-2">
<p class="text-sm text-gray-500">AI 助手随时待命</p>
<h1 class="text-3xl font-bold text-gray-900">AI 智能分析</h1>
</header>
<section class="bg-white rounded-3xl p-6 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">消费洞察</h2>
<div class="bg-gray-100 rounded-full p-1 flex space-x-1">
<button
v-for="option in ['30d', '90d']"
:key="option"
class="px-3 py-1 text-sm rounded-full"
:class="range === option ? 'bg-white text-indigo-500 shadow-sm' : 'text-gray-500'"
@click="range = option as '30d' | '90d'"
>
{{ option === '30d' ? '近30天' : '近90天' }}
</button>
</div>
</div>
<p class="text-sm text-gray-600 whitespace-pre-line">{{ insight?.summary }}</p>
<ul class="space-y-2">
<li
v-for="recommendation in insight?.recommendations ?? []"
:key="recommendation"
class="flex items-start space-x-2 text-sm text-gray-700"
>
<LucideIcon name="sparkles" class="mt-1 text-indigo-500" :size="16" />
<span>{{ recommendation }}</span>
</li>
</ul>
<div class="grid grid-cols-3 gap-4 pt-2">
<div
v-for="category in insight?.categories ?? []"
:key="category.name"
class="bg-gray-50 rounded-2xl p-4"
>
<p class="text-sm text-gray-500">{{ category.name }}</p>
<p class="text-lg font-semibold text-gray-900 mt-1">¥ {{ category.amount.toFixed(2) }}</p>
<span
class="text-xs font-medium"
:class="{
'text-red-500': category.trend === 'up',
'text-emerald-500': category.trend === 'down',
'text-gray-500': category.trend === 'flat'
}"
>
{{ category.trend === 'up' ? '上升' : category.trend === 'down' ? '下降' : '持平' }}
</span>
</div>
</div>
</section>
<section class="bg-white rounded-3xl p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">卡路里估算</h2>
<div class="flex space-x-3">
<input
v-model="calorieForm.query"
type="text"
placeholder="例如:一份麦辣鸡腿堡"
class="flex-1 px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
class="w-14 h-14 rounded-2xl bg-indigo-500 text-white flex items-center justify-center hover:bg-indigo-600 disabled:opacity-60"
:disabled="calorieQuery.isPending.value"
@click="submitCalorieQuery"
>
<LucideIcon v-if="!calorieQuery.isPending.value" name="search" :size="22" />
<svg
v-else
class="animate-spin w-5 h-5"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
</button>
</div>
<div class="bg-gray-50 rounded-2xl p-4 space-y-4 h-72 overflow-y-auto">
<div
v-for="(message, index) in messages"
:key="index"
class="flex"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[70%] px-4 py-3 text-sm whitespace-pre-line"
:class="message.role === 'user'
? 'bg-gray-200 text-gray-800 rounded-2xl rounded-br-none'
: 'bg-indigo-500 text-white rounded-2xl rounded-bl-none'"
>
{{ message.text }}
</div>
</div>
</div>
</section>
</div>
</template>