first commit

This commit is contained in:
2025-11-01 09:24:26 +08:00
commit 6516d8dc78
87 changed files with 7558 additions and 0 deletions

19
apps/frontend/src/App.vue Normal file
View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, RouterView } from 'vue-router';
import BottomTabBar from './components/navigation/BottomTabBar.vue';
import QuickAddButton from './components/actions/QuickAddButton.vue';
const route = useRoute();
const showAppShell = computed(() => !route.path.startsWith('/auth'));
</script>
<template>
<div class="min-h-screen bg-gray-100 text-gray-900">
<div class="max-w-xl mx-auto min-h-screen relative pb-24">
<RouterView />
<QuickAddButton v-if="showAppShell" />
</div>
<BottomTabBar v-if="showAppShell" />
</div>
</template>

View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
body {
@apply bg-gray-100 text-gray-900 font-sans antialiased;
}
a {
@apply text-indigo-500 hover:text-indigo-600;
}
#app {
@apply min-h-screen;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import LucideIcon from '../common/LucideIcon.vue';
const props = defineProps<{
icon: string;
label: string;
}>();
</script>
<template>
<button
type="button"
class="bg-gray-100 p-4 rounded-2xl flex items-center space-x-3 hover:bg-gray-200 transition"
>
<LucideIcon :name="props.icon" class="text-indigo-500" :size="22" />
<span class="font-medium text-gray-800">{{ props.label }}</span>
</button>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue';
import LucideIcon from '../common/LucideIcon.vue';
const isOpen = ref(false);
const toggle = () => {
isOpen.value = !isOpen.value;
};
</script>
<template>
<div class="fixed right-6 bottom-28 z-30">
<button
type="button"
class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center shadow-xl hover:bg-blue-600 transition-transform"
@click="toggle"
>
<LucideIcon
name="plus"
class="text-white transition-transform"
:class="isOpen ? 'rotate-45' : ''"
:size="32"
/>
</button>
<div
v-if="isOpen"
class="absolute bottom-20 right-1 flex flex-col items-end space-y-3"
>
<button class="w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg hover:shadow-xl">
<LucideIcon name="camera" class="text-gray-700" :size="20" />
</button>
<button class="w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg hover:shadow-xl">
<LucideIcon name="edit-3" class="text-gray-700" :size="20" />
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Budget } from '../../types/budget';
const props = defineProps<{ budget: Budget }>();
const usagePercent = computed(() => {
if (!props.budget.amount) return 0;
return Math.min((props.budget.usage / props.budget.amount) * 100, 100);
});
const thresholdPercent = computed(() => props.budget.threshold * 100);
const usageVariant = computed(() => {
if (usagePercent.value >= thresholdPercent.value) return 'text-red-500';
if (usagePercent.value >= thresholdPercent.value - 10) return 'text-amber-500';
return 'text-emerald-500';
});
</script>
<template>
<div class="border border-gray-100 rounded-2xl p-5 space-y-3 bg-white shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">{{ budget.period === 'monthly' ? '月度预算' : '周预算' }}</p>
<h3 class="text-lg font-semibold text-gray-900">{{ budget.category }}</h3>
</div>
<div class="text-right">
<p class="text-sm text-gray-500">预算上限</p>
<p class="text-xl font-bold text-gray-900">¥ {{ budget.amount.toFixed(0) }}</p>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>已使用 ¥ {{ budget.usage.toFixed(0) }}</span>
<span>阈值 {{ thresholdPercent.toFixed(0) }}%</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div
class="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-blue-500"
:style="{ width: `${usagePercent}%` }"
></div>
</div>
</div>
<p class="text-sm" :class="usageVariant">
{{ usagePercent.toFixed(0) }}% 已使用
</p>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
title: string;
amount: string;
subtitle: string;
progressLabel?: string;
progressValue?: number;
footer?: string;
}>();
const progressStyle = computed(() =>
props.progressValue !== undefined
? { width: `${Math.min(props.progressValue, 100)}%` }
: undefined
);
</script>
<template>
<section class="bg-gradient-to-br from-indigo-500 to-blue-500 text-white p-6 rounded-3xl shadow-card">
<p class="text-sm text-indigo-100/90">{{ title }}</p>
<p class="text-4xl font-bold mt-2">{{ amount }}</p>
<div v-if="progressValue !== undefined" class="mt-6 space-y-2">
<p class="text-sm text-indigo-100/90">{{ progressLabel }}</p>
<div class="w-full bg-indigo-400/50 rounded-full h-2.5">
<div class="bg-white h-2.5 rounded-full" :style="progressStyle"></div>
</div>
<p class="text-right text-sm font-medium">{{ footer }}</p>
</div>
<p v-if="subtitle" class="text-sm text-indigo-100/90 mt-4">{{ subtitle }}</p>
</section>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue';
import { icons } from 'lucide-vue-next';
const props = defineProps<{
name: string;
size?: number;
class?: string;
}>();
const normalizeName = (value: string) =>
value
.split(/[-_ ]+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join('');
const iconComponent = computed(() => {
const normalized = normalizeName(props.name);
// @ts-expect-error dynamic lookup
return icons[normalized] ?? icons.CircleHelp;
});
</script>
<template>
<component :is="iconComponent" :size="props.size ?? 24" :class="props.class" />
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import LucideIcon from '../common/LucideIcon.vue';
const route = useRoute();
const tabs = [
{ name: 'dashboard', label: '首页', icon: 'layout-dashboard', path: '/' },
{ name: 'transactions', label: '记账', icon: 'list-checks', path: '/transactions' },
{ name: 'analysis', label: 'AI', icon: 'sparkles', path: '/analysis' },
{ name: 'settings', label: '设置', icon: 'settings', path: '/settings' }
];
</script>
<template>
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
<div class="max-w-xl mx-auto flex justify-around items-center h-16 px-4">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="tab.path"
class="flex flex-col items-center space-y-1 text-xs font-medium"
:class="route.path === tab.path ? 'text-indigo-500' : 'text-gray-400'"
>
<LucideIcon :name="tab.icon" :size="22" />
<span>{{ tab.label }}</span>
</RouterLink>
</div>
</nav>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue';
import LucideIcon from '../common/LucideIcon.vue';
import type { Transaction } from '../../types/transaction';
const props = defineProps<{ transaction: Transaction }>();
const isIncome = computed(() => props.transaction.type === 'income');
const amountClass = computed(() => (isIncome.value ? 'text-emerald-500' : 'text-red-600'));
const formattedAmount = computed(() => {
const prefix = isIncome.value ? '+' : '-';
return `${prefix}¥ ${props.transaction.amount.toFixed(2)}`;
});
const iconName = computed(() => {
if (props.transaction.icon) return props.transaction.icon;
if (props.transaction.source === 'notification') return 'bell-ring';
if (props.transaction.source === 'ocr') return 'scan-text';
if (props.transaction.source === 'ai') return 'sparkles';
return isIncome.value ? 'arrow-down-right' : 'arrow-up-right';
});
const statusColor = computed(() => {
switch (props.transaction.status) {
case 'pending':
return 'bg-amber-100 text-amber-600';
case 'rejected':
return 'bg-red-100 text-red-600';
default:
return 'bg-emerald-100 text-emerald-600';
}
});
const packageName = computed(() => props.transaction.metadata?.packageName as string | undefined);
const sourceLabel = computed(() => {
switch (props.transaction.source) {
case 'notification':
return packageName.value ? `通知 · ${packageName.value}` : '自动通知';
case 'ocr':
return '票据识别';
case 'ai':
return 'AI 推荐';
default:
return '手动';
}
});
</script>
<template>
<div class="flex items-center space-x-4 p-4 bg-white border border-gray-100 rounded-xl">
<div class="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
<LucideIcon :name="iconName" class="text-indigo-500" :size="24" />
</div>
<div class="flex-1 space-y-1">
<div class="flex items-center justify-between">
<p class="font-semibold text-gray-800">{{ transaction.title }}</p>
<span class="text-[10px] uppercase tracking-wide text-gray-400">{{ transaction.currency }}</span>
</div>
<p class="text-sm text-gray-500">{{ transaction.category }} · {{ sourceLabel }}</p>
<p v-if="transaction.notes" class="text-xs text-gray-400 truncate">{{ transaction.notes }}</p>
<div class="mt-1">
<span class="text-xs px-2 py-0.5 rounded-full" :class="statusColor">{{ transaction.status }}</span>
</div>
</div>
<div class="text-right">
<p class="font-semibold" :class="amountClass">{{ formattedAmount }}</p>
<p class="text-sm text-gray-400">
{{ new Date(transaction.occurredAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,32 @@
import { useMutation, useQuery } from '@tanstack/vue-query';
import { computed, isRef, ref, type Ref } from 'vue';
import { apiClient } from '../lib/api/client';
import { sampleSpendingInsight } from '../mocks/analysis';
import type { CalorieResponse, SpendingInsight } from '../types/analysis';
export function useSpendingInsightQuery(input: Ref<'30d' | '90d'> | '30d' | '90d' = '30d') {
const range = isRef(input) ? input : ref(input);
return useQuery<SpendingInsight>({
queryKey: computed(() => ['analysis', range.value]),
queryFn: async () => {
try {
const { data } = await apiClient.get('/analysis/habits', { params: { range: range.value } });
return data as SpendingInsight;
} catch (error) {
console.warn('[analysis] fallback to sample data', error);
return sampleSpendingInsight;
}
},
initialData: sampleSpendingInsight
});
}
export function useCalorieEstimationMutation() {
return useMutation<CalorieResponse, unknown, string>({
mutationFn: async (query: string) => {
const { data } = await apiClient.post('/analysis/calories', { query });
return data as CalorieResponse;
}
});
}

View File

@@ -0,0 +1,79 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { apiClient } from '../lib/api/client';
import { sampleBudgets } from '../mocks/budgets';
import type { Budget, BudgetPayload } from '../types/budget';
const queryKey = ['budgets'];
const mapBudget = (raw: any): Budget => ({
id: raw.id,
category: raw.category,
amount: Number(raw.amount ?? 0),
currency: raw.currency ?? 'CNY',
period: raw.period ?? 'monthly',
threshold: raw.threshold ?? 0.8,
usage: raw.usage ?? raw.used ?? 0,
userId: raw.userId ?? undefined,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt
});
export function useBudgetsQuery() {
return useQuery<Budget[]>({
queryKey,
queryFn: async () => {
try {
const { data } = await apiClient.get('/budgets');
return (data.data as Budget[]).map(mapBudget);
} catch (error) {
console.warn('[budgets] fallback to sample data', error);
return sampleBudgets;
}
},
initialData: sampleBudgets
});
}
export function useCreateBudgetMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: BudgetPayload) => {
const { data } = await apiClient.post('/budgets', payload);
return mapBudget(data.data);
},
onSuccess: (budget) => {
queryClient.setQueryData<Budget[]>(queryKey, (existing = []) => [...existing, budget]);
}
});
}
export function useUpdateBudgetMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: Partial<BudgetPayload> }) => {
const { data } = await apiClient.patch(`/budgets/${id}`, payload);
return mapBudget(data.data);
},
onSuccess: (budget) => {
queryClient.setQueryData<Budget[]>(queryKey, (existing = []) =>
existing.map((item) => (item.id === budget.id ? budget : item))
);
}
});
}
export function useDeleteBudgetMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/budgets/${id}`);
return id;
},
onSuccess: (id) => {
queryClient.setQueryData<Budget[]>(queryKey, (existing = []) => existing.filter((item) => item.id !== id));
}
});
}

View File

@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/vue-query';
import { apiClient } from '../lib/api/client';
export interface NotificationStatus {
secretConfigured: boolean;
secretHint: string | null;
webhookSecret: string | null;
packageWhitelist: string[];
ingestedCount: number;
lastNotificationAt: string | null;
ingestEndpoint: string;
}
const initialStatus: NotificationStatus = {
secretConfigured: false,
secretHint: null,
webhookSecret: null,
packageWhitelist: [],
ingestedCount: 0,
lastNotificationAt: null,
ingestEndpoint: 'http://localhost:4000/api/transactions/notification'
};
export function useNotificationStatusQuery() {
return useQuery<NotificationStatus>({
queryKey: ['notification-status'],
queryFn: async () => {
try {
const { data } = await apiClient.get('/notifications/status');
return data as NotificationStatus;
} catch (error) {
console.warn('[notifications] using fallback status', error);
return initialStatus;
}
},
initialData: initialStatus,
staleTime: 30_000
});
}

View File

@@ -0,0 +1,85 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { apiClient } from '../lib/api/client';
import { sampleTransactions } from '../mocks/transactions';
import type { Transaction, TransactionPayload } from '../types/transaction';
const queryKey = ['transactions'];
const mapTransaction = (raw: any): Transaction => ({
id: raw.id,
title: raw.title,
description: raw.description ?? raw.notes ?? undefined,
icon: raw.icon,
amount: Number(raw.amount ?? 0),
currency: raw.currency ?? 'CNY',
category: raw.category ?? '未分类',
type: raw.type ?? 'expense',
source: raw.source ?? 'manual',
occurredAt: raw.occurredAt,
status: raw.status ?? 'pending',
notes: raw.notes,
metadata: raw.metadata ?? undefined,
userId: raw.userId ?? undefined,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt
});
export function useTransactionsQuery() {
return useQuery<Transaction[]>({
queryKey,
queryFn: async () => {
try {
const { data } = await apiClient.get('/transactions');
return (data.data as Transaction[]).map(mapTransaction);
} catch (error) {
console.warn('[transactions] fallback to sample data', error);
return sampleTransactions;
}
},
initialData: sampleTransactions
});
}
export function useCreateTransactionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: TransactionPayload) => {
const { data } = await apiClient.post('/transactions', payload);
return mapTransaction(data.data);
},
onSuccess: (transaction) => {
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [transaction, ...existing]);
}
});
}
export function useUpdateTransactionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: Partial<TransactionPayload> }) => {
const { data } = await apiClient.patch(`/transactions/${id}`, payload);
return mapTransaction(data.data);
},
onSuccess: (transaction) => {
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) =>
existing?.map((item) => (item.id === transaction.id ? transaction : item)) ?? [transaction]
);
}
});
}
export function useDeleteTransactionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/transactions/${id}`);
return id;
},
onSuccess: (id) => {
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => existing.filter((item) => item.id !== id));
}
});
}

View 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>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-indigo-500/20 to-blue-500/10 flex items-center justify-center px-6 py-12">
<div class="w-full max-w-md bg-white rounded-3xl shadow-xl p-8">
<RouterView />
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { apiClient } from '../../../lib/api/client';
const form = reactive({ email: '' });
const isLoading = ref(false);
const isCompleted = ref(false);
const errorMessage = ref('');
const submit = async () => {
isLoading.value = true;
errorMessage.value = '';
try {
await apiClient.post('/auth/forgot-password', form);
isCompleted.value = true;
} catch (error) {
console.warn('forgot password fallback', error);
isCompleted.value = true;
} finally {
isLoading.value = false;
}
};
</script>
<template>
<div class="space-y-6">
<header class="space-y-2 text-center">
<h1 class="text-2xl font-bold text-gray-900">找回密码</h1>
<p class="text-sm text-gray-500">填写注册邮箱我们将发送重置链接</p>
</header>
<div v-if="!isCompleted" class="space-y-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
邮箱
<input v-model="form.email" type="email" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
<button
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
:disabled="isLoading"
@click="submit"
>
{{ isLoading ? '发送中...' : '发送验证码' }}
</button>
</div>
<div v-else class="bg-gray-50 rounded-2xl p-6 text-sm text-gray-600 space-y-3 text-center">
<p class="font-semibold text-gray-900">邮件已发送</p>
<p>请前往邮箱查收验证码并在 10 分钟内完成密码重置</p>
<RouterLink to="/auth/login" class="text-indigo-500 font-medium">返回登录</RouterLink>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { apiClient } from '../../../lib/api/client';
import { useAuthStore } from '../../../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const form = reactive({
email: 'user@example.com',
password: 'Password123!'
});
const errorMessage = ref('');
const isLoading = ref(false);
const submit = async () => {
isLoading.value = true;
errorMessage.value = '';
try {
const { data } = await apiClient.post('/auth/login', form);
authStore.setSession(data.tokens, data.user);
router.push('/');
} catch (error) {
console.warn('login failed', error);
errorMessage.value = '登录失败,请检查邮箱或密码。';
} finally {
isLoading.value = false;
}
};
const enterDemoMode = () => {
authStore.setSession(
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
{
id: 'demo-user',
email: 'demo@ai-bill.app',
displayName: '体验用户',
preferredCurrency: 'CNY'
}
);
router.push('/');
};
</script>
<template>
<div class="space-y-8">
<header class="space-y-2 text-center">
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
<p class="text-sm text-gray-500">使用 AI 记账自动同步你的收支</p>
</header>
<div class="space-y-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
邮箱
<input v-model="form.email" type="email" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
密码
<input v-model="form.password" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
<button
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
:disabled="isLoading"
@click="submit"
>
{{ isLoading ? '登录中...' : '登录' }}
</button>
<button class="w-full py-3 border border-gray-200 text-gray-600 rounded-2xl text-sm" @click="enterDemoMode">
体验模式进入
</button>
</div>
<div class="flex justify-between text-sm text-gray-600">
<RouterLink to="/auth/forgot-password" class="text-indigo-500">忘记密码</RouterLink>
<RouterLink to="/auth/register" class="text-indigo-500">新用户注册</RouterLink>
</div>
</div>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { apiClient } from '../../../lib/api/client';
import { useAuthStore } from '../../../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const form = reactive({
displayName: '',
email: '',
password: '',
confirmPassword: ''
});
const isLoading = ref(false);
const errorMessage = ref('');
const submit = async () => {
if (form.password !== form.confirmPassword) {
errorMessage.value = '两次输入的密码不一致';
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
const payload = {
email: form.email,
password: form.password,
confirmPassword: form.confirmPassword,
displayName: form.displayName,
preferredCurrency: 'CNY'
};
const { data } = await apiClient.post('/auth/register', payload);
authStore.setSession(data.tokens, data.user);
router.push('/');
} catch (error) {
console.warn('register failed', error);
errorMessage.value = '注册失败,请稍后再试或联系支持。';
} finally {
isLoading.value = false;
}
};
const enterDemoMode = () => {
authStore.setSession(
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
{
id: 'demo-user',
email: 'demo@ai-bill.app',
displayName: form.displayName || '体验用户',
preferredCurrency: 'CNY'
}
);
router.push('/');
};
</script>
<template>
<div class="space-y-6">
<header class="space-y-2 text-center">
<h1 class="text-2xl font-bold text-gray-900">创建你的账户</h1>
<p class="text-sm text-gray-500">开启 AI 自动记账之旅</p>
</header>
<div class="space-y-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
昵称
<input v-model="form.displayName" type="text" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
邮箱
<input v-model="form.email" type="email" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
密码
<input v-model="form.password" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
确认密码
<input v-model="form.confirmPassword" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
<button
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
:disabled="isLoading"
@click="submit"
>
{{ isLoading ? '注册中...' : '注册' }}
</button>
<button class="w-full py-3 border border-gray-200 text-gray-600 rounded-2xl text-sm" @click="enterDemoMode">
体验模式进入
</button>
</div>
<p class="text-sm text-gray-600 text-center">
已有账户<RouterLink to="/auth/login" class="text-indigo-500">立即登录</RouterLink>
</p>
</div>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue';
import OverviewCard from '../../../components/cards/OverviewCard.vue';
import QuickActionButton from '../../../components/actions/QuickActionButton.vue';
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
import LucideIcon from '../../../components/common/LucideIcon.vue';
import { useAuthStore } from '../../../stores/auth';
import { useTransactionsQuery } from '../../../composables/useTransactions';
import { useBudgetsQuery } from '../../../composables/useBudgets';
import { useRouter } from 'vue-router';
const authStore = useAuthStore();
const router = useRouter();
const { data: transactions } = useTransactionsQuery();
const { data: budgets } = useBudgetsQuery();
const greetingName = computed(() => authStore.profile?.displayName ?? '用户');
const monthlyStats = computed(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const monthTransactions = (transactions.value ?? []).filter(
(txn) => new Date(txn.occurredAt) >= startOfMonth
);
const expense = monthTransactions
.filter((txn) => txn.type === 'expense')
.reduce((total, txn) => total + txn.amount, 0);
const income = monthTransactions
.filter((txn) => txn.type === 'income')
.reduce((total, txn) => total + txn.amount, 0);
return {
expense,
income,
net: income - expense
};
});
const activeBudget = computed(() => budgets.value?.[0]);
const budgetUsagePercentage = computed(() => {
if (!activeBudget.value || !activeBudget.value.amount) return 0;
return Math.min((activeBudget.value.usage / activeBudget.value.amount) * 100, 100);
});
const budgetFooter = computed(() => {
if (!activeBudget.value) return '';
const remaining = Math.max(activeBudget.value.amount - activeBudget.value.usage, 0);
return `剩余 ¥ ${remaining.toFixed(2)} / ¥ ${activeBudget.value.amount.toFixed(2)}`;
});
const navigateToAnalysis = () => router.push('/analysis');
const navigateToBudgets = () => router.push('/settings');
</script>
<template>
<div class="bg-white">
<header class="p-6 pt-10 bg-white sticky top-0 z-10">
<div class="flex justify-between items-center">
<div>
<p class="text-sm text-gray-500">欢迎回来,</p>
<h1 class="text-3xl font-bold text-gray-900">{{ greetingName }}</h1>
</div>
<div class="w-12 h-12 rounded-full bg-indigo-100 flex items-center justify-center">
<LucideIcon name="user" class="text-indigo-600" :size="24" />
</div>
</div>
</header>
<main class="px-6 pb-24 space-y-8">
<OverviewCard
title="本月总支出"
:amount="`¥ ${monthlyStats.expense.toFixed(2)}`"
subtitle="净收益"
:progress-label="'预算剩余'"
:progress-value="budgetUsagePercentage"
:footer="budgetFooter"
>
</OverviewCard>
<section class="grid grid-cols-2 gap-4">
<div class="bg-white border border-gray-100 rounded-2xl p-4">
<p class="text-sm text-gray-500">本月收入</p>
<p class="text-2xl font-semibold text-emerald-500 mt-1">¥ {{ monthlyStats.income.toFixed(2) }}</p>
</div>
<div class="bg-white border border-gray-100 rounded-2xl p-4">
<p class="text-sm text-gray-500">净收益</p>
<p :class="['text-2xl font-semibold', monthlyStats.net >= 0 ? 'text-emerald-500' : 'text-red-500']">
¥ {{ monthlyStats.net.toFixed(2) }}
</p>
</div>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900">快捷操作</h2>
<div class="mt-4 grid grid-cols-2 gap-4">
<QuickActionButton icon="sparkles" label="消费分析" @click="navigateToAnalysis" />
<QuickActionButton icon="wallet-cards" label="预算设置" @click="navigateToBudgets" />
</div>
</section>
<section>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">近期交易</h2>
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
</div>
<div class="space-y-4 mt-4 pb-10">
<TransactionItem v-for="transaction in transactions" :key="transaction.id" :transaction="transaction" />
</div>
</section>
</main>
</div>
</template>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import BudgetCard from '../../../components/budgets/BudgetCard.vue';
import LucideIcon from '../../../components/common/LucideIcon.vue';
import { useCreateBudgetMutation, useDeleteBudgetMutation, useBudgetsQuery } from '../../../composables/useBudgets';
import { useNotificationStatusQuery } from '../../../composables/useNotifications';
import { useAuthStore } from '../../../stores/auth';
const authStore = useAuthStore();
const preferences = reactive({
notifications: true,
aiSuggestions: true,
experimentalLab: false
});
const { data: budgets } = useBudgetsQuery();
const createBudget = useCreateBudgetMutation();
const deleteBudget = useDeleteBudgetMutation();
const notificationStatusQuery = useNotificationStatusQuery();
const notificationStatus = computed(() => notificationStatusQuery.data.value);
const budgetSheetOpen = ref(false);
const budgetForm = reactive({
category: '',
amount: '',
period: 'monthly',
threshold: 0.8
});
const isCreatingBudget = computed(() => createBudget.isPending.value);
const canCopySecret = computed(() => Boolean(notificationStatus.value?.webhookSecret));
const secretDisplay = computed(() => {
const status = notificationStatus.value;
if (!status) return '未配置';
if (status.webhookSecret) return status.webhookSecret;
if (status.secretHint) return status.secretHint;
return '未配置';
});
const formattedLastNotification = computed(() => {
const status = notificationStatus.value;
if (!status?.lastNotificationAt) return '暂无记录';
return new Date(status.lastNotificationAt).toLocaleString('zh-CN', { hour12: false });
});
const copyFeedback = ref<string | null>(null);
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
const showCopyFeedback = (message: string) => {
copyFeedback.value = message;
if (copyTimeout) {
clearTimeout(copyTimeout);
}
copyTimeout = setTimeout(() => {
copyFeedback.value = null;
}, 2000);
};
const copyText = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
showCopyFeedback(`${label} 已复制`);
} catch (error) {
console.warn('Clipboard copy failed', error);
showCopyFeedback('复制失败,请手动复制');
}
};
onBeforeUnmount(() => {
if (copyTimeout) {
clearTimeout(copyTimeout);
}
});
const resetBudgetForm = () => {
budgetForm.category = '';
budgetForm.amount = '';
budgetForm.period = 'monthly';
budgetForm.threshold = 0.8;
};
const submitBudget = async () => {
if (!budgetForm.category || !budgetForm.amount) return;
await createBudget.mutateAsync({
category: budgetForm.category,
amount: Number(budgetForm.amount),
period: budgetForm.period as 'monthly' | 'weekly',
threshold: budgetForm.threshold,
currency: 'CNY'
});
resetBudgetForm();
budgetSheetOpen.value = false;
};
const removeBudget = async (id: string) => {
await deleteBudget.mutateAsync(id);
};
const handleLogout = () => {
authStore.clearSession();
};
</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">掌控你的记账体验</p>
<h1 class="text-3xl font-bold text-gray-900">设置中心</h1>
</header>
<section class="bg-white rounded-3xl p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">账户信息</h2>
<div class="flex items-center space-x-4">
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center">
<LucideIcon name="user" class="text-indigo-600" :size="28" />
</div>
<div>
<p class="text-base font-semibold text-gray-900">{{ authStore.profile?.displayName ?? '示例用户' }}</p>
<p class="text-sm text-gray-500">{{ authStore.profile?.email ?? 'user@example.com' }}</p>
</div>
</div>
<button class="text-sm text-indigo-500 font-medium">编辑资料</button>
</section>
<section class="bg-white rounded-3xl p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">预算管理</h2>
<p class="text-sm text-gray-500">为高频分类设置预算阈值接近时自动提醒</p>
<div class="space-y-4">
<BudgetCard v-for="budget in budgets" :key="budget.id" :budget="budget" />
<button
class="w-full flex items-center justify-center space-x-2 border border-dashed border-indigo-400 rounded-2xl py-3 text-indigo-500 font-semibold"
@click="budgetSheetOpen = true"
>
<LucideIcon name="plus" :size="20" />
<span>新增预算</span>
</button>
</div>
</section>
<section class="bg-white rounded-3xl p-6 space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">通知监听</h2>
<p class="text-sm text-gray-500">配置原生插件的 Webhook 地址与安全密钥</p>
</div>
<button
class="text-sm text-indigo-500 font-medium disabled:opacity-60"
:disabled="notificationStatusQuery.isFetching.value"
@click="notificationStatusQuery.refetch()"
>
{{ notificationStatusQuery.isFetching.value ? '刷新中...' : '刷新' }}
</button>
</div>
<div class="space-y-3">
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">Webhook Endpoint</span>
<button
class="text-xs text-indigo-500 font-medium"
@click="notificationStatus?.ingestEndpoint && copyText(notificationStatus.ingestEndpoint, 'Webhook 地址')"
>
复制
</button>
</div>
<p class="text-sm font-mono break-all text-gray-800">
{{ notificationStatus?.ingestEndpoint ?? 'http://localhost:4000/api/transactions/notification' }}
</p>
</div>
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">HMAC 密钥</span>
<button
class="text-xs font-medium"
:class="canCopySecret ? 'text-indigo-500' : 'text-gray-400 cursor-not-allowed'
"
:disabled="!canCopySecret"
@click="notificationStatus?.webhookSecret && copyText(notificationStatus.webhookSecret, 'HMAC 密钥')"
>
复制
</button>
</div>
<p class="text-sm font-mono break-all text-gray-800">
{{ secretDisplay }}
</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="border border-gray-100 rounded-2xl p-4">
<p class="text-xs text-gray-500">通知入库</p>
<p class="text-xl font-semibold text-gray-900">{{ notificationStatus?.ingestedCount ?? 0 }}</p>
</div>
<div class="border border-gray-100 rounded-2xl p-4">
<p class="text-xs text-gray-500">最新入库</p>
<p class="text-sm text-gray-900">{{ formattedLastNotification }}</p>
</div>
</div>
<div>
<p class="text-xs text-gray-500 mb-2">包名白名单</p>
<div class="flex flex-wrap gap-2">
<span
v-for="pkg in notificationStatus?.packageWhitelist ?? []"
:key="pkg"
class="px-3 py-1 text-xs rounded-full bg-gray-100 text-gray-700"
>
{{ pkg }}
</span>
<span v-if="(notificationStatus?.packageWhitelist?.length ?? 0) === 0" class="text-xs text-gray-400">
暂无包名配置
</span>
</div>
</div>
<p v-if="copyFeedback" class="text-xs text-emerald-500">{{ copyFeedback }}</p>
</section>
<section class="bg-white rounded-3xl p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">个性化偏好</h2>
<div class="space-y-4">
<label class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-800">通知提醒</p>
<p class="text-sm text-gray-500">开启后自动检测通知并辅助记账</p>
</div>
<input v-model="preferences.notifications" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
</label>
<label class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-800">AI 理财建议</p>
<p class="text-sm text-gray-500">根据消费习惯提供个性化提示</p>
</div>
<input v-model="preferences.aiSuggestions" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
</label>
<label class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-800">实验室功能</p>
<p class="text-sm text-gray-500">抢先体验新功能可能不稳定</p>
</div>
<input v-model="preferences.experimentalLab" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
</label>
</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="space-y-3 text-sm text-gray-600">
<p>· 支持密码找回邮箱验证码</p>
<p>· 可申请导出全部数据</p>
<p>· 支持平台内删除账户</p>
</div>
<button class="w-full py-3 bg-red-50 text-red-600 font-semibold rounded-2xl" @click="handleLogout">退出登录</button>
</section>
<div
v-if="budgetSheetOpen"
class="fixed inset-0 bg-black/40 flex items-end justify-center"
@click.self="budgetSheetOpen = false"
>
<div class="w-full max-w-xl bg-white rounded-t-3xl p-6 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">新增预算</h3>
<button @click="budgetSheetOpen = false">
<LucideIcon name="x" :size="22" />
</button>
</div>
<div class="space-y-3">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
分类
<input v-model="budgetForm.category" type="text" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
金额 (¥)
<input v-model="budgetForm.amount" type="number" min="0" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
周期
<select v-model="budgetForm.period" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="monthly">月度</option>
<option value="weekly">每周</option>
</select>
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
阈值 ({{ Math.round(budgetForm.threshold * 100) }}%)
<input v-model.number="budgetForm.threshold" type="range" min="0.4" max="1" step="0.05" />
</label>
<div class="flex justify-end space-x-3 pt-2">
<button class="px-4 py-2 text-sm text-gray-500" @click="budgetSheetOpen = false">取消</button>
<button
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
:disabled="isCreatingBudget"
@click="submitBudget"
>
{{ isCreatingBudget ? '保存中...' : '保存预算' }}
</button>
</div>
</div>
<div v-if="budgets && budgets.length" class="border-t border-gray-100 pt-4 space-y-2">
<p class="text-xs text-gray-500">长按预算卡片可在未来版本中编辑阈值当前可在此快速删除</p>
<div class="space-y-2">
<button
v-for="budget in budgets"
:key="budget.id"
class="w-full text-left text-sm text-red-500 border border-red-200 rounded-xl px-4 py-2"
@click="removeBudget(budget.id)"
>
删除{{ budget.category }}预算
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import LucideIcon from '../../../components/common/LucideIcon.vue';
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
import {
useTransactionsQuery,
useCreateTransactionMutation,
useDeleteTransactionMutation
} from '../../../composables/useTransactions';
import type { TransactionPayload } from '../../../types/transaction';
type FilterOption = 'all' | 'expense' | 'income';
const filter = ref<FilterOption>('all');
const showSheet = ref(false);
const form = reactive({
title: '',
amount: '',
category: '',
type: 'expense',
notes: '',
source: 'manual'
});
const { data: transactions } = useTransactionsQuery();
const createTransaction = useCreateTransactionMutation();
const deleteTransaction = useDeleteTransactionMutation();
const filters: Array<{ label: string; value: FilterOption }> = [
{ label: '全部', value: 'all' },
{ label: '支出', value: 'expense' },
{ label: '收入', value: 'income' }
];
const filteredTransactions = computed(() => {
if (!transactions.value) return [];
if (filter.value === 'all') return transactions.value;
return transactions.value.filter((txn) => txn.type === filter.value);
});
const isSaving = computed(() => createTransaction.isPending.value);
const setFilter = (value: FilterOption) => {
filter.value = value;
};
const resetForm = () => {
form.title = '';
form.amount = '';
form.category = '';
form.type = 'expense';
form.source = 'manual';
form.notes = '';
};
const submit = async () => {
if (!form.title || !form.amount) return;
const payload: TransactionPayload = {
title: form.title,
amount: Math.abs(Number(form.amount)),
category: form.category || '未分类',
type: form.type as TransactionPayload['type'],
source: form.source as TransactionPayload['source'],
status: 'pending',
currency: 'CNY',
notes: form.notes || undefined,
occurredAt: new Date().toISOString()
};
await createTransaction.mutateAsync(payload);
showSheet.value = false;
resetForm();
};
const removeTransaction = async (id: string) => {
await deleteTransaction.mutateAsync(id);
};
</script>
<template>
<div class="p-6 pt-10 pb-24 space-y-6">
<header class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">轻松管理你的收支</p>
<h1 class="text-3xl font-bold text-gray-900">交易记录</h1>
</div>
<button
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
@click="showSheet = true"
>
新增
</button>
</header>
<div class="bg-white rounded-2xl p-2 flex space-x-2 border border-gray-100">
<button
v-for="item in filters"
:key="item.value"
class="flex-1 py-2 rounded-lg text-sm font-medium"
:class="filter === item.value ? 'bg-indigo-500 text-white' : 'text-gray-500'"
@click="setFilter(item.value)"
>
{{ item.label }}
</button>
</div>
<div class="space-y-4">
<div
v-for="transaction in filteredTransactions"
:key="transaction.id"
class="relative group"
>
<TransactionItem :transaction="transaction" />
<button
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500"
@click="removeTransaction(transaction.id)"
>
<LucideIcon name="trash-2" :size="18" />
</button>
</div>
</div>
<div
v-if="showSheet"
class="fixed inset-0 bg-black/40 flex items-end justify-center"
@click.self="showSheet = false"
>
<div class="w-full max-w-xl bg-white rounded-t-3xl p-6 space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-900">快速记一笔</h2>
<button @click="showSheet = false">
<LucideIcon name="x" :size="24" />
</button>
</div>
<div class="space-y-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
标题
<input v-model="form.title" type="text" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<div class="grid grid-cols-2 gap-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
金额
<input v-model="form.amount" type="number" min="0" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
类型
<select v-model="form.type" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="expense">支出</option>
<option value="income">收入</option>
</select>
</label>
</div>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
分类
<input v-model="form.category" type="text" placeholder="如:餐饮" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
来源
<select v-model="form.source" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="manual">手动</option>
<option value="notification">通知自动</option>
<option value="ocr">票据识别</option>
<option value="ai">AI 推荐</option>
</select>
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
备注
<textarea v-model="form.notes" rows="2" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<button
class="w-full bg-indigo-500 text-white py-3 rounded-xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
:disabled="isSaving"
@click="submit"
>
{{ isSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,21 @@
import axios from 'axios';
const baseURL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4000/api';
export const apiClient = axios.create({
baseURL,
timeout: 10_000
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
console.error('API error', error.response.status, error.response.data);
} else {
console.error('Network error', error.message);
}
return Promise.reject(error);
}
);

23
apps/frontend/src/main.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import App from './App.vue';
import router from './router';
import './assets/main.css';
const app = createApp(App);
const pinia = createPinia();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false
}
}
});
app.use(pinia);
app.use(router);
app.use(VueQueryPlugin, { queryClient });
app.mount('#app');

View File

@@ -0,0 +1,16 @@
import type { SpendingInsight } from '../types/analysis';
export const sampleSpendingInsight: SpendingInsight = {
range: '30d',
summary: '本期餐饮支出占比 42%,建议每周至少 2 天自带午餐。',
recommendations: [
'为餐饮类别设置每日 80 元的预算提醒。',
'将咖啡消费减半,节省 200 元/月。',
'尝试将高频支出标记标签,便于 AI 学习偏好。'
],
categories: [
{ name: '餐饮', amount: 1250.4, trend: 'up' },
{ name: '交通', amount: 420.0, trend: 'flat' },
{ name: '生活服务', amount: 389.5, trend: 'down' }
]
};

View File

@@ -0,0 +1,24 @@
import type { Budget } from '../types/budget';
export const sampleBudgets: Budget[] = [
{
id: 'budget-001',
category: '餐饮',
amount: 2000,
currency: 'CNY',
period: 'monthly',
usage: 1250,
threshold: 0.8,
userId: 'demo-user'
},
{
id: 'budget-002',
category: '交通',
amount: 600,
currency: 'CNY',
period: 'monthly',
usage: 320,
threshold: 0.75,
userId: 'demo-user'
}
];

View File

@@ -0,0 +1,57 @@
import type { Transaction } from '../types/transaction';
export const sampleTransactions: Transaction[] = [
{
id: 'txn-001',
title: '星巴克咖啡',
description: '餐饮 | 早餐',
icon: 'coffee',
amount: 32.5,
currency: 'CNY',
category: '餐饮',
type: 'expense',
source: 'notification',
occurredAt: '2024-04-03T09:15:00.000Z',
status: 'confirmed',
metadata: { packageName: 'com.eg.android.AlipayGphone' }
},
{
id: 'txn-002',
title: '地铁充值',
description: '交通 | 交通卡充值',
icon: 'tram-front',
amount: 100,
currency: 'CNY',
category: '交通',
type: 'expense',
source: 'manual',
occurredAt: '2024-04-02T18:40:00.000Z',
status: 'confirmed'
},
{
id: 'txn-003',
title: '午餐报销',
description: '收入 | 公司报销',
icon: 'wallet',
amount: 58,
currency: 'CNY',
category: '报销',
type: 'income',
source: 'ocr',
occurredAt: '2024-04-01T12:00:00.000Z',
status: 'pending'
},
{
id: 'txn-004',
title: '超市购物 (OCR)',
description: '购物 | 票据识别',
icon: 'shopping-bag',
amount: 128.5,
currency: 'CNY',
category: '购物',
type: 'expense',
source: 'ocr',
occurredAt: '2024-03-31T16:20:00.000Z',
status: 'confirmed'
}
];

View File

@@ -0,0 +1,67 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'dashboard',
component: () => import('../features/dashboard/pages/DashboardPage.vue'),
meta: { title: '仪表盘' }
},
{
path: '/transactions',
name: 'transactions',
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
meta: { title: '交易记录' }
},
{
path: '/analysis',
name: 'analysis',
component: () => import('../features/analysis/pages/AnalysisPage.vue'),
meta: { title: 'AI 智能分析' }
},
{
path: '/settings',
name: 'settings',
component: () => import('../features/settings/pages/SettingsPage.vue'),
meta: { title: '设置' }
},
{
path: '/auth',
component: () => import('../features/auth/pages/AuthLayout.vue'),
children: [
{
path: 'login',
name: 'login',
component: () => import('../features/auth/pages/LoginPage.vue'),
meta: { title: '登录' }
},
{
path: 'register',
name: 'register',
component: () => import('../features/auth/pages/RegisterPage.vue'),
meta: { title: '注册' }
},
{
path: 'forgot-password',
name: 'forgot-password',
component: () => import('../features/auth/pages/ForgotPasswordPage.vue'),
meta: { title: '找回密码' }
}
]
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.afterEach((to) => {
if (to.meta.title) {
document.title = `AI 记账 · ${to.meta.title}`;
} else {
document.title = 'AI 记账';
}
});
export default router;

View File

@@ -0,0 +1,41 @@
import { defineStore } from 'pinia';
type AuthStatus = 'authenticated' | 'guest';
interface UserProfile {
id: string;
email: string;
displayName: string;
avatarUrl?: string;
preferredCurrency: string;
}
interface AuthState {
status: AuthStatus;
accessToken?: string;
refreshToken?: string;
profile?: UserProfile;
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
status: 'guest'
}),
getters: {
isAuthenticated: (state) => state.status === 'authenticated'
},
actions: {
setSession(tokens: { accessToken: string; refreshToken: string }, profile: UserProfile) {
this.status = 'authenticated';
this.accessToken = tokens.accessToken;
this.refreshToken = tokens.refreshToken;
this.profile = profile;
},
clearSession() {
this.status = 'guest';
this.accessToken = undefined;
this.refreshToken = undefined;
this.profile = undefined;
}
}
});

View File

@@ -0,0 +1,18 @@
export interface SpendingCategoryInsight {
name: string;
amount: number;
trend: 'up' | 'down' | 'flat';
}
export interface SpendingInsight {
range: '30d' | '90d';
summary: string;
recommendations: string[];
categories: SpendingCategoryInsight[];
}
export interface CalorieResponse {
query: string;
calories: number;
insights: string[];
}

View File

@@ -0,0 +1,22 @@
export interface Budget {
id: string;
category: string;
amount: number;
currency: string;
period: 'monthly' | 'weekly';
threshold: number;
usage: number;
userId?: string;
createdAt?: string;
updatedAt?: string;
}
export interface BudgetPayload {
category: string;
amount: number;
currency?: string;
period?: 'monthly' | 'weekly';
threshold?: number;
usage?: number;
userId?: string;
}

View File

@@ -0,0 +1,32 @@
export interface Transaction {
id: string;
title: string;
description?: string;
icon?: string;
amount: number;
currency: string;
category: string;
type: 'expense' | 'income';
source: 'manual' | 'notification' | 'ocr' | 'ai';
occurredAt: string;
status: 'pending' | 'confirmed' | 'rejected';
notes?: string;
metadata?: Record<string, unknown>;
userId?: string;
createdAt?: string;
updatedAt?: string;
}
export interface TransactionPayload {
title: string;
amount: number;
currency?: string;
category: string;
type: Transaction['type'];
source?: Transaction['source'];
status?: Transaction['status'];
occurredAt: string;
notes?: string;
metadata?: Record<string, unknown>;
userId?: string;
}