256 lines
9.4 KiB
Vue
256 lines
9.4 KiB
Vue
<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>
|