feat: phase 3 - ledger and asset management
Add group expense tracking (ledger) and public asset inventory (asset) features. Ledger supports income/expense recording with monthly summary. Asset tracks group equipment with free-form holder transfer. Both are independent pages accessible from GroupView navigation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, 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()
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user