259 lines
9.1 KiB
Vue
259 lines
9.1 KiB
Vue
<script setup>
|
|
import { computed, ref } from 'vue'
|
|
import { storeToRefs } from 'pinia'
|
|
import { getAiStatusMeta } from '../config/aiStatus.js'
|
|
import {
|
|
getEntryTypeLabel,
|
|
getTransferSummary,
|
|
isStoredValueAccountType,
|
|
} from '../config/ledger.js'
|
|
import { CATEGORY_CHIPS, getCategoryLabel } from '../config/transactionCategories.js'
|
|
import { useTransactionStore } from '../stores/transactions'
|
|
import { useUiStore } from '../stores/ui'
|
|
|
|
const transactionStore = useTransactionStore()
|
|
const uiStore = useUiStore()
|
|
const { groupedTransactions, filters, loading } = storeToRefs(transactionStore)
|
|
const deletingId = ref('')
|
|
const categoryChips = CATEGORY_CHIPS
|
|
|
|
const setCategory = (value) => {
|
|
filters.value.category = value
|
|
}
|
|
|
|
const toggleTodayOnly = () => {
|
|
filters.value.showOnlyToday = !filters.value.showOnlyToday
|
|
}
|
|
|
|
const formatAmount = (value) => `${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
|
|
|
|
const formatTime = (value) =>
|
|
new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(
|
|
new Date(value),
|
|
)
|
|
|
|
const resolveLedgerBadge = (transaction) => {
|
|
if (transaction.entryType === 'transfer') {
|
|
return {
|
|
label: getEntryTypeLabel(transaction.entryType),
|
|
icon: 'ph-arrows-left-right',
|
|
className: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
|
}
|
|
}
|
|
if (isStoredValueAccountType(transaction.fundSourceType)) {
|
|
return {
|
|
label: '储值支付',
|
|
icon: 'ph-wallet',
|
|
className: 'bg-teal-50 text-teal-700 border-teal-200',
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const resolveAiBadge = (transaction) => {
|
|
if (!transaction?.aiStatus || ['idle', 'failed'].includes(transaction.aiStatus)) {
|
|
return null
|
|
}
|
|
return getAiStatusMeta(transaction.aiStatus)
|
|
}
|
|
|
|
const formatLedgerHint = (transaction) => {
|
|
if (transaction.entryType === 'transfer') {
|
|
return getTransferSummary(transaction)
|
|
}
|
|
if (isStoredValueAccountType(transaction.fundSourceType)) {
|
|
return `从${transaction.fundSourceName || '储值账户'}扣款`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
const formatAiHint = (transaction) => {
|
|
if (transaction.aiStatus === 'running') {
|
|
return '正在补全分类与标签'
|
|
}
|
|
if (transaction.aiStatus === 'suggested' && transaction.aiCategory) {
|
|
return `建议分类:${getCategoryLabel(transaction.aiCategory)}`
|
|
}
|
|
if (transaction.aiStatus === 'applied') {
|
|
const tags = (transaction.aiTags || []).slice(0, 2).join(' · ')
|
|
return tags ? `已补全:${tags}` : '已完成自动补全'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
const handleEdit = (item) => {
|
|
uiStore.openAddEntry(item.id)
|
|
}
|
|
|
|
const handleDelete = async (item) => {
|
|
if (deletingId.value) return
|
|
const confirmed = window.confirm('确认删除这条流水吗?')
|
|
if (!confirmed) return
|
|
deletingId.value = item.id
|
|
try {
|
|
await transactionStore.removeTransaction(item.id)
|
|
} finally {
|
|
deletingId.value = ''
|
|
}
|
|
}
|
|
|
|
const hasData = computed(() => groupedTransactions.value.length > 0)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-5 pb-10 animate-fade-in">
|
|
<div class="sticky top-0 z-20 bg-warmOffwhite/95 backdrop-blur-sm py-3">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
|
|
<button
|
|
class="flex items-center gap-2 bg-white border border-stone-100 px-3.5 py-2 rounded-full shadow-sm text-xs font-bold text-stone-500"
|
|
@click="uiStore.openAddEntry()"
|
|
>
|
|
<i class="ph-bold ph-plus" /> 新增
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex gap-3 overflow-x-auto pb-3 hide-scrollbar">
|
|
<button
|
|
v-for="chip in categoryChips"
|
|
:key="chip.value"
|
|
class="shrink-0 min-h-[44px] px-4 py-2.5 rounded-[22px] text-sm font-bold flex items-center gap-2 border transition whitespace-nowrap"
|
|
:class="
|
|
filters.category === chip.value
|
|
? 'bg-gradient-warm text-white border-transparent shadow-sm shadow-orange-200/40'
|
|
: 'bg-white text-stone-500 border-stone-100'
|
|
"
|
|
@click="setCategory(chip.value)"
|
|
>
|
|
<i :class="['text-base', chip.icon]" />
|
|
{{ chip.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1">
|
|
<span>点按一条记录可快速编辑</span>
|
|
<button
|
|
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border border-stone-200"
|
|
:class="filters.showOnlyToday ? 'bg-gradient-warm text-white border-transparent' : 'bg-white'"
|
|
@click="toggleTodayOnly"
|
|
>
|
|
<i class="ph-bold ph-sun-dim text-xs" />
|
|
今日
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-center text-stone-400 py-10 text-sm">
|
|
正在同步本地账本...
|
|
</div>
|
|
|
|
<div v-else-if="hasData" class="space-y-6 pb-10">
|
|
<div
|
|
v-for="group in groupedTransactions"
|
|
:key="group.dayKey"
|
|
class="space-y-3"
|
|
>
|
|
<div class="flex justify-between items-center px-1">
|
|
<div>
|
|
<h4 class="text-sm font-bold text-stone-500">{{ group.label }}</h4>
|
|
<p class="text-[10px] text-stone-400">
|
|
支出 ¥{{ group.totalExpense.toFixed(2) }}
|
|
</p>
|
|
</div>
|
|
<span class="text-xs text-stone-300 font-bold">{{ group.dayKey }}</span>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-[28px] p-2 shadow-sm border border-stone-50 divide-y divide-stone-50">
|
|
<div
|
|
v-for="item in group.items"
|
|
:key="item.id"
|
|
class="flex items-start justify-between gap-4 px-4 py-4 hover:bg-stone-50 rounded-[22px] transition group"
|
|
@click="handleEdit(item)"
|
|
>
|
|
<div class="flex min-w-0 flex-1 items-start gap-4">
|
|
<div
|
|
class="shrink-0 w-11 h-11 rounded-[18px] bg-stone-100 flex items-center justify-center text-stone-500 group-active:scale-95 transition"
|
|
>
|
|
<i
|
|
:class="[
|
|
'ph-bold text-lg',
|
|
item.amount < 0 ? 'ph-arrow-up-right' : 'ph-arrow-down-left',
|
|
]"
|
|
/>
|
|
</div>
|
|
<div class="min-w-0 flex-1 space-y-1.5">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="min-w-0 text-[15px] leading-6 font-bold text-stone-800 break-words">
|
|
{{ item.merchant }}
|
|
</p>
|
|
<span
|
|
v-if="resolveLedgerBadge(item)"
|
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
|
|
:class="resolveLedgerBadge(item).className"
|
|
>
|
|
<i :class="['ph-bold text-[10px]', resolveLedgerBadge(item).icon]" />
|
|
{{ resolveLedgerBadge(item).label }}
|
|
</span>
|
|
<span
|
|
v-if="resolveAiBadge(item)"
|
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
|
|
:class="resolveAiBadge(item).className"
|
|
>
|
|
<i
|
|
:class="[
|
|
'ph-bold text-[10px]',
|
|
resolveAiBadge(item).icon,
|
|
resolveAiBadge(item).iconClassName,
|
|
]"
|
|
/>
|
|
{{ resolveAiBadge(item).label }}
|
|
</span>
|
|
</div>
|
|
<p class="text-xs leading-5 text-stone-400">
|
|
{{ formatTime(item.date) }}
|
|
<span v-if="item.category" class="text-stone-300">
|
|
· {{ getCategoryLabel(item.category) }}
|
|
</span>
|
|
<span v-if="item.note" class="text-stone-300 break-words">
|
|
· {{ item.note }}
|
|
</span>
|
|
</p>
|
|
<p v-if="formatLedgerHint(item)" class="text-[11px] leading-5 text-stone-400">
|
|
{{ formatLedgerHint(item) }}
|
|
</p>
|
|
<p v-if="formatAiHint(item)" class="text-[11px] leading-5 text-stone-400">
|
|
{{ formatAiHint(item) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="shrink-0 pl-2 text-right space-y-1.5">
|
|
<p
|
|
:class="[
|
|
'font-bold text-base leading-none',
|
|
item.amount < 0 ? 'text-stone-800' : 'text-emerald-600',
|
|
]"
|
|
>
|
|
{{ formatAmount(item.amount) }}
|
|
</p>
|
|
<button
|
|
class="text-[11px] text-stone-400"
|
|
:disabled="deletingId === item.id"
|
|
@click.stop="handleDelete(item)"
|
|
>
|
|
{{ deletingId === item.id ? '删除中...' : '删除' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="py-12 text-center text-stone-400 text-sm border border-dashed border-stone-100 rounded-3xl"
|
|
>
|
|
暂无符合筛选条件的记录
|
|
</div>
|
|
</div>
|
|
</template>
|