feat:添加账单详情,优化页面
This commit is contained in:
@@ -11,7 +11,7 @@ const showAppShell = computed(() => !route.path.startsWith('/auth'));
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
<div class="min-h-screen bg-gray-100 text-gray-900">
|
||||||
<div
|
<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'"
|
:class="showAppShell ? 'pb-24' : 'pb-0'"
|
||||||
>
|
>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const tabs = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
|
<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
|
<RouterLink
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.name"
|
: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">
|
<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" />
|
<LucideIcon :name="iconName" class="text-indigo-500" :size="24" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 space-y-1">
|
<div class="flex-1 min-w-0 space-y-1">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between space-x-2">
|
||||||
<p class="font-semibold text-gray-800">{{ transaction.title }}</p>
|
<p class="font-semibold text-gray-800 truncate">{{ transaction.title }}</p>
|
||||||
<span class="text-[10px] uppercase tracking-wide text-gray-400">{{ transaction.currency }}</span>
|
<span class="shrink-0 text-[10px] uppercase tracking-wide text-gray-400">{{ transaction.currency }}</span>
|
||||||
</div>
|
</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>
|
<p v-if="transaction.notes" class="text-xs text-gray-400 truncate">{{ transaction.notes }}</p>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<span class="text-xs px-2 py-0.5 rounded-full" :class="statusColor">{{ transaction.status }}</span>
|
<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"
|
:key="transaction.id"
|
||||||
class="relative group"
|
class="relative group"
|
||||||
>
|
>
|
||||||
<TransactionItem :transaction="transaction" />
|
<RouterLink
|
||||||
|
class="block"
|
||||||
|
:to="{ name: 'transaction-detail', params: { id: transaction.id } }"
|
||||||
|
>
|
||||||
|
<TransactionItem :transaction="transaction" />
|
||||||
|
</RouterLink>
|
||||||
<button
|
<button
|
||||||
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500"
|
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 z-10"
|
||||||
@click="removeTransaction(transaction.id)"
|
@click.stop="removeTransaction(transaction.id)"
|
||||||
|
aria-label="删除"
|
||||||
>
|
>
|
||||||
<LucideIcon name="trash-2" :size="18" />
|
<LucideIcon name="trash-2" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
|
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
|
||||||
meta: { title: '交易记录', requiresAuth: true }
|
meta: { title: '交易记录', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/transactions/:id',
|
||||||
|
name: 'transaction-detail',
|
||||||
|
component: () => import('../features/transactions/pages/TransactionDetailPage.vue'),
|
||||||
|
meta: { title: '账单详情', requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/analysis',
|
path: '/analysis',
|
||||||
name: 'analysis',
|
name: 'analysis',
|
||||||
|
|||||||
Reference in New Issue
Block a user