e4b730c8db
- Ledger store: add stopSubscription() to properly clean up realtime subscriptions, matching asset store pattern - LedgerList: call stopSubscription on unmount - Assets migration: restrict image upload to image/* mime types (C3 updateRule is a known tradeoff — PocketBase lacks field-level permissions, frontend enforces edit restrictions instead) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
403 lines
9.1 KiB
Vue
403 lines
9.1 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { Plus } from '@element-plus/icons-vue'
|
|
import { useLedgerStore } from '@/stores/ledger'
|
|
import { useGroupStore } from '@/stores/group'
|
|
import type { LedgerType, LedgerCategory } from '@/types'
|
|
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
|
|
import LedgerSummary from './LedgerSummary.vue'
|
|
import LedgerCard from './LedgerCard.vue'
|
|
import CreateLedgerDialog from './CreateLedgerDialog.vue'
|
|
|
|
const ledgerStore = useLedgerStore()
|
|
const groupStore = useGroupStore()
|
|
|
|
const showCreate = ref(false)
|
|
const editingLedger = ref<any>(undefined)
|
|
const filterType = ref<LedgerType | ''>('')
|
|
const filterCategory = ref<LedgerCategory | ''>('')
|
|
|
|
// 月份选择器值
|
|
const selectedMonth = ref(new Date())
|
|
|
|
// 类型筛选选项
|
|
const typeOptions = computed(() => [
|
|
{ label: '全部', value: '' },
|
|
...Object.entries(LedgerTypeMap).map(([value, label]) => ({ label, value }))
|
|
])
|
|
|
|
// 分类筛选选项
|
|
const categoryOptions = computed(() => [
|
|
{ label: '全部', value: '' },
|
|
...Object.entries(LedgerCategoryMap).map(([value, label]) => ({ label, value }))
|
|
])
|
|
|
|
// 获取月份字符串
|
|
function getMonthString(date: Date): string {
|
|
const year = date.getFullYear()
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
return `${year}-${month}`
|
|
}
|
|
|
|
// 加载数据
|
|
async function loadData() {
|
|
const groupId = groupStore.currentGroupId
|
|
if (!groupId) return
|
|
|
|
const month = getMonthString(selectedMonth.value)
|
|
await ledgerStore.loadLedgers(groupId, month)
|
|
}
|
|
|
|
// 筛选后的账目列表
|
|
const filteredLedgers = computed(() => {
|
|
let list = [...ledgerStore.ledgers]
|
|
if (filterType.value) {
|
|
list = list.filter((l) => l.type === filterType.value)
|
|
}
|
|
if (filterCategory.value) {
|
|
list = list.filter((l) => l.category === filterCategory.value)
|
|
}
|
|
return list
|
|
})
|
|
|
|
// 按日期分组
|
|
const groupedByDate = computed(() => {
|
|
const groups: Record<string, typeof filteredLedgers.value> = {}
|
|
for (const ledger of filteredLedgers.value) {
|
|
const date = ledger.occurredAt?.slice(0, 10) || '未知日期'
|
|
if (!groups[date]) {
|
|
groups[date] = []
|
|
}
|
|
groups[date].push(ledger)
|
|
}
|
|
// 按日期降序排列
|
|
const sortedKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a))
|
|
return sortedKeys.map((date) => ({ date, items: groups[date] }))
|
|
})
|
|
|
|
// 月份变更
|
|
function handleMonthChange(val: Date) {
|
|
selectedMonth.value = val
|
|
loadData()
|
|
}
|
|
|
|
// 筛选变更
|
|
function handleFilterChange() {
|
|
// 筛选在前端完成,无需重新加载
|
|
}
|
|
|
|
// 新建
|
|
function handleCreate() {
|
|
editingLedger.value = undefined
|
|
showCreate.value = true
|
|
}
|
|
|
|
// 编辑
|
|
function handleEdit(ledgerId: string) {
|
|
const ledger = ledgerStore.ledgers.find((l) => l.id === ledgerId)
|
|
if (ledger) {
|
|
editingLedger.value = ledger
|
|
showCreate.value = true
|
|
}
|
|
}
|
|
|
|
// 删除
|
|
async function handleDelete(ledgerId: string) {
|
|
try {
|
|
await ElMessageBox.confirm('确定删除这条账目记录吗?删除后不可恢复。', '删除确认', {
|
|
confirmButtonText: '确定',
|
|
cancelButtonText: '取消',
|
|
type: 'warning',
|
|
})
|
|
await ledgerStore.removeLedger(ledgerId)
|
|
ElMessage.success('删除成功')
|
|
loadData()
|
|
} catch {
|
|
// 用户取消
|
|
}
|
|
}
|
|
|
|
// 保存成功
|
|
function handleSaved() {
|
|
showCreate.value = false
|
|
editingLedger.value = undefined
|
|
loadData()
|
|
}
|
|
|
|
// 实时订阅(store 内部管理生命周期)
|
|
async function startSubscription() {
|
|
const groupId = groupStore.currentGroupId
|
|
if (!groupId) return
|
|
await ledgerStore.startSubscription(groupId)
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (groupStore.currentGroupId) {
|
|
loadData()
|
|
startSubscription()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
ledgerStore.stopSubscription()
|
|
})
|
|
|
|
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
|
if (newId && newId !== oldId) {
|
|
loadData()
|
|
startSubscription()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="ledger-list">
|
|
<!-- 顶部操作栏 -->
|
|
<div class="ledger-list__header">
|
|
<h3 class="ledger-list__title">账本</h3>
|
|
<button class="ledger-list__create-btn" @click="handleCreate">
|
|
<el-icon><Plus /></el-icon>
|
|
新建账目
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 筛选栏 -->
|
|
<div class="ledger-list__filters">
|
|
<el-date-picker
|
|
:model-value="selectedMonth"
|
|
type="month"
|
|
placeholder="选择月份"
|
|
format="YYYY年MM月"
|
|
value-format="YYYY-MM"
|
|
@update:model-value="handleMonthChange"
|
|
class="ledger-list__month-picker"
|
|
/>
|
|
<el-select
|
|
v-model="filterType"
|
|
placeholder="类型"
|
|
clearable
|
|
@change="handleFilterChange"
|
|
class="ledger-list__filter-select"
|
|
>
|
|
<el-option
|
|
v-for="opt in typeOptions"
|
|
:key="opt.value"
|
|
:label="opt.label"
|
|
:value="opt.value"
|
|
/>
|
|
</el-select>
|
|
<el-select
|
|
v-model="filterCategory"
|
|
placeholder="分类"
|
|
clearable
|
|
@change="handleFilterChange"
|
|
class="ledger-list__filter-select"
|
|
>
|
|
<el-option
|
|
v-for="opt in categoryOptions"
|
|
:key="opt.value"
|
|
:label="opt.label"
|
|
:value="opt.value"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
|
|
<!-- 汇总 -->
|
|
<LedgerSummary />
|
|
|
|
<!-- 账目列表 -->
|
|
<div v-loading="ledgerStore.loading" class="ledger-list__content">
|
|
<section
|
|
v-for="group in groupedByDate"
|
|
:key="group.date"
|
|
class="ledger-list__date-group"
|
|
>
|
|
<div class="ledger-list__date-header">
|
|
<span class="ledger-list__date-dot"></span>
|
|
<span class="ledger-list__date-label">{{ group.date }}</span>
|
|
<span class="ledger-list__date-count">{{ group.items.length }} 条</span>
|
|
</div>
|
|
<div class="ledger-list__items">
|
|
<LedgerCard
|
|
v-for="ledger in group.items"
|
|
:key="ledger.id"
|
|
:ledger="ledger"
|
|
@edit="handleEdit"
|
|
@delete="handleDelete"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 空状态 -->
|
|
<div
|
|
v-if="groupedByDate.length === 0 && !ledgerStore.loading"
|
|
class="ledger-list__empty"
|
|
>
|
|
<svg class="ledger-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
|
|
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
|
|
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z" />
|
|
</svg>
|
|
<p class="ledger-list__empty-text">暂无账目</p>
|
|
<p class="ledger-list__empty-hint">该月份还没有账目记录</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新建/编辑对话框 -->
|
|
<CreateLedgerDialog
|
|
v-model="showCreate"
|
|
:group-id="groupStore.currentGroupId"
|
|
:edit-ledger="editingLedger"
|
|
@saved="handleSaved"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ledger-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
/* 顶部操作栏 */
|
|
.ledger-list__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.ledger-list__title {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: var(--gg-text);
|
|
margin: 0;
|
|
}
|
|
|
|
.ledger-list__create-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 7px 16px;
|
|
border: none;
|
|
border-radius: var(--gg-radius-sm);
|
|
background: var(--gg-gradient-green);
|
|
color: #ffffff;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.ledger-list__create-btn:hover {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* 筛选栏 */
|
|
.ledger-list__filters {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.ledger-list__month-picker {
|
|
width: 160px;
|
|
}
|
|
|
|
.ledger-list__filter-select {
|
|
width: 110px;
|
|
}
|
|
|
|
/* 内容区 */
|
|
.ledger-list__content {
|
|
min-height: 200px;
|
|
}
|
|
|
|
/* 日期分组 */
|
|
.ledger-list__date-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.ledger-list__date-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
.ledger-list__date-dot {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--gg-primary);
|
|
}
|
|
|
|
.ledger-list__date-label {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--gg-text-secondary);
|
|
}
|
|
|
|
.ledger-list__date-count {
|
|
font-size: 12px;
|
|
color: var(--gg-text-muted);
|
|
background: var(--gg-bg-elevated);
|
|
padding: 1px 7px;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.ledger-list__items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* 空状态 */
|
|
.ledger-list__empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 48px 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.ledger-list__empty-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
color: var(--gg-text-muted);
|
|
margin-bottom: 12px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.ledger-list__empty-text {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--gg-text-secondary);
|
|
margin: 0 0 4px;
|
|
}
|
|
|
|
.ledger-list__empty-hint {
|
|
font-size: 13px;
|
|
color: var(--gg-text-muted);
|
|
margin: 0;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.ledger-list__filters {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.ledger-list__month-picker,
|
|
.ledger-list__filter-select {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|