Files
gamegroup2/frontend/src/components/ledger/LedgerList.vue
T
congsh e4b730c8db fix(phase3): subscription leak, image mime type validation
- 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>
2026-04-18 19:52:34 +08:00

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>