feat:添加账单详情,优化页面

This commit is contained in:
2025-11-12 18:20:19 +08:00
parent 46b329a503
commit 42c80b09e3
7 changed files with 138 additions and 10 deletions

View File

@@ -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 />

View File

@@ -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"

View File

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

View 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
});
}

View File

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

View File

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

View File

@@ -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',