150 lines
5.5 KiB
Vue
150 lines
5.5 KiB
Vue
<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>
|