Files
AI-Bill/apps/frontend/src/features/transactions/pages/TransactionsPage.vue
2025-11-10 13:58:06 +08:00

256 lines
9.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onBeforeUnmount, 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 transactionsQuery = useTransactionsQuery();
const createTransaction = useCreateTransactionMutation();
const deleteTransaction = useDeleteTransactionMutation();
const transactions = computed(() => transactionsQuery.data.value ?? []);
const filters: Array<{ label: string; value: FilterOption }> = [
{ label: '全部', value: 'all' },
{ label: '支出', value: 'expense' },
{ label: '收入', value: 'income' }
];
const filteredTransactions = computed(() => {
if (!transactions.value.length) return [];
if (filter.value === 'all') return transactions.value;
return transactions.value.filter((txn) => txn.type === filter.value);
});
const isSaving = computed(() => createTransaction.isPending.value);
const isInitialLoading = computed(() => transactionsQuery.isLoading.value);
const isRefreshing = computed(() => transactionsQuery.isFetching.value && !transactionsQuery.isLoading.value);
const feedbackMessage = ref('');
let feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
const showFeedback = (message: string) => {
feedbackMessage.value = message;
if (feedbackTimeout) {
clearTimeout(feedbackTimeout);
}
feedbackTimeout = setTimeout(() => {
feedbackMessage.value = '';
}, 2400);
};
onBeforeUnmount(() => {
if (feedbackTimeout) {
clearTimeout(feedbackTimeout);
}
});
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()
};
try {
await createTransaction.mutateAsync(payload);
showFeedback('新增交易已提交');
showSheet.value = false;
resetForm();
} catch (error) {
console.warn('Failed to create transaction', error);
showFeedback('保存失败,请稍后重试');
}
};
const removeTransaction = async (id: string) => {
try {
await deleteTransaction.mutateAsync(id);
showFeedback('交易已删除');
} catch (error) {
console.warn('Failed to delete transaction', error);
showFeedback('删除失败,请稍后重试');
}
};
const refreshTransactions = async () => {
await transactionsQuery.refetch();
showFeedback('列表已更新');
};
</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>
<div class="flex items-center space-x-2">
<button
class="px-4 py-2 border border-gray-200 text-sm rounded-xl text-gray-600 hover:bg-gray-50 disabled:opacity-60"
:disabled="isRefreshing"
@click="refreshTransactions"
>
<span v-if="isRefreshing" class="flex items-center space-x-2">
<svg class="w-4 h-4 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span>刷新中</span>
</span>
<span v-else class="flex items-center space-x-2">
<LucideIcon name="refresh-cw" :size="16" />
<span>刷新</span>
</span>
</button>
<button
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
@click="showSheet = true"
>
新增
</button>
</div>
</header>
<p v-if="feedbackMessage" class="text-xs text-emerald-500 text-right">{{ feedbackMessage }}</p>
<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 min-h-[140px]">
<div v-if="isInitialLoading" class="flex justify-center py-16">
<svg class="w-8 h-8 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
<div
v-else-if="filteredTransactions.length === 0"
class="bg-white border border-dashed border-gray-200 rounded-2xl py-12 text-center text-sm text-gray-400"
>
暂无交易记录点击新增开始记账
</div>
<div v-else>
<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>
<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-sm 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>