first commit
This commit is contained in:
149
apps/frontend/src/features/analysis/pages/AnalysisPage.vue
Normal file
149
apps/frontend/src/features/analysis/pages/AnalysisPage.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user