feat:添加账单详情,优化页面
This commit is contained in:
@@ -11,7 +11,7 @@ const showAppShell = computed(() => !route.path.startsWith('/auth'));
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
||||
<div
|
||||
class="max-w-sm mx-auto min-h-screen relative w-full"
|
||||
class="mx-auto min-h-screen relative w-full max-w-full"
|
||||
:class="showAppShell ? 'pb-24' : 'pb-0'"
|
||||
>
|
||||
<RouterView />
|
||||
|
||||
@@ -14,7 +14,7 @@ const tabs = [
|
||||
|
||||
<template>
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
|
||||
<div class="max-w-sm mx-auto flex justify-around items-center h-16 px-4 w-full">
|
||||
<div class="mx-auto flex justify-around items-center h-16 px-4 w-full max-w-full">
|
||||
<RouterLink
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
|
||||
@@ -54,12 +54,12 @@ const sourceLabel = computed(() => {
|
||||
<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 class="flex-1 min-w-0 space-y-1">
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<p class="font-semibold text-gray-800 truncate">{{ transaction.title }}</p>
|
||||
<span class="shrink-0 text-[10px] uppercase tracking-wide text-gray-400">{{ transaction.currency }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{{ transaction.category }} · {{ sourceLabel }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ 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>
|
||||
|
||||
43
apps/frontend/src/composables/useTransaction.ts
Normal file
43
apps/frontend/src/composables/useTransaction.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import type { Transaction } from '../types/transaction';
|
||||
import { computed, type ComputedRef } from 'vue';
|
||||
|
||||
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 useTransactionQuery(id: string | ComputedRef<string | undefined>) {
|
||||
const queryClient = useQueryClient();
|
||||
const txId = computed(() => (typeof id === 'string' ? id : id.value) ?? '');
|
||||
|
||||
return useQuery<Transaction | undefined>({
|
||||
queryKey: ['transactions', txId],
|
||||
enabled: computed(() => Boolean(txId.value)).value,
|
||||
initialData: () => {
|
||||
const list = queryClient.getQueryData<Transaction[]>(['transactions']);
|
||||
return list?.find((t) => t.id === txId.value);
|
||||
},
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/transactions/${txId.value}`);
|
||||
return mapTransaction(data.data);
|
||||
},
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import { useTransactionQuery } from '../../../composables/useTransaction';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => route.params.id as string);
|
||||
|
||||
const query = useTransactionQuery(id);
|
||||
const txn = computed(() => query.data.value);
|
||||
|
||||
const goBack = () => router.back();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 pt-10 pb-24 space-y-6">
|
||||
<header class="flex items-center space-x-3">
|
||||
<button class="p-2 rounded-xl bg-white border border-gray-200" @click="goBack">
|
||||
<LucideIcon name="chevron-left" :size="20" />
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">账单详情</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="query.isLoading.value" 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="!txn" class="text-center text-gray-400 py-20">
|
||||
未找到该账单
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="bg-white rounded-2xl p-6 border border-gray-100">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">{{ txn.category }} · {{ txn.currency }}</p>
|
||||
<h2 class="text-3xl font-semibold mt-1" :class="txn.type === 'income' ? 'text-emerald-600' : 'text-red-600'">
|
||||
{{ txn.type === 'income' ? '+' : '-' }}¥ {{ Number(txn.amount).toFixed(2) }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
|
||||
<LucideIcon :name="txn.icon || (txn.type === 'income' ? 'arrow-down-right' : 'arrow-up-right')" class="text-indigo-500" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-lg font-medium text-gray-900 truncate">{{ txn.title }}</p>
|
||||
<p v-if="txn.notes" class="mt-1 text-sm text-gray-500 whitespace-pre-wrap">{{ txn.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl p-6 border border-gray-100">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">详细信息</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">类型</span><span class="text-gray-900">{{ txn.type === 'income' ? '收入' : '支出' }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">来源</span><span class="text-gray-900">{{ txn.source }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">状态</span><span class="text-gray-900 capitalize">{{ txn.status }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">发生时间</span><span class="text-gray-900">{{ new Date(txn.occurredAt).toLocaleString('zh-CN') }}</span></div>
|
||||
<div v-if="txn.createdAt" class="flex justify-between"><span class="text-gray-500">创建时间</span><span class="text-gray-900">{{ new Date(txn.createdAt).toLocaleString('zh-CN') }}</span></div>
|
||||
<div v-if="txn.updatedAt" class="flex justify-between"><span class="text-gray-500">更新时间</span><span class="text-gray-900">{{ new Date(txn.updatedAt).toLocaleString('zh-CN') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="txn.metadata && Object.keys(txn.metadata).length" class="bg-white rounded-2xl p-6 border border-gray-100">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">关联信息</h3>
|
||||
<pre class="text-xs text-gray-600 overflow-x-auto">{{ JSON.stringify(txn.metadata, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -183,10 +183,16 @@ const refreshTransactions = async () => {
|
||||
:key="transaction.id"
|
||||
class="relative group"
|
||||
>
|
||||
<TransactionItem :transaction="transaction" />
|
||||
<RouterLink
|
||||
class="block"
|
||||
:to="{ name: 'transaction-detail', params: { id: transaction.id } }"
|
||||
>
|
||||
<TransactionItem :transaction="transaction" />
|
||||
</RouterLink>
|
||||
<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)"
|
||||
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 z-10"
|
||||
@click.stop="removeTransaction(transaction.id)"
|
||||
aria-label="删除"
|
||||
>
|
||||
<LucideIcon name="trash-2" :size="18" />
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
|
||||
meta: { title: '交易记录', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/transactions/:id',
|
||||
name: 'transaction-detail',
|
||||
component: () => import('../features/transactions/pages/TransactionDetailPage.vue'),
|
||||
meta: { title: '账单详情', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'analysis',
|
||||
|
||||
Reference in New Issue
Block a user