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,303 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useAssetStore } from '@/stores/asset'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { AssetTypeMap } from '@/types'
|
||||
import type { Asset, AssetType } from '@/types'
|
||||
import AssetCard from './AssetCard.vue'
|
||||
import CreateAssetDialog from './CreateAssetDialog.vue'
|
||||
import TransferAssetDialog from './TransferAssetDialog.vue'
|
||||
|
||||
const assetStore = useAssetStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
// 类型过滤
|
||||
const typeFilter = ref<string>('')
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = computed(() => {
|
||||
return [
|
||||
{ value: '', label: '全部' },
|
||||
...(Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
// 过滤后的资产列表
|
||||
const filteredAssets = computed(() => {
|
||||
if (!typeFilter.value) return assetStore.assets
|
||||
return assetStore.assets.filter(a => a.type === typeFilter.value)
|
||||
})
|
||||
|
||||
// 对话框状态
|
||||
const showCreateDialog = ref(false)
|
||||
const showTransferDialog = ref(false)
|
||||
|
||||
// 编辑/转移目标
|
||||
const editingAsset = ref<Asset | undefined>(undefined)
|
||||
const transferringAsset = ref<Asset | null>(null)
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await assetStore.loadAssets(groupId)
|
||||
}
|
||||
|
||||
// 订阅变更
|
||||
async function setupSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await assetStore.startSubscription(groupId)
|
||||
}
|
||||
|
||||
// 登记资产
|
||||
function handleCreate() {
|
||||
editingAsset.value = undefined
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
// 编辑资产
|
||||
function handleEdit(assetId: string) {
|
||||
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
editingAsset.value = asset
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 转移资产
|
||||
function handleTransfer(assetId: string) {
|
||||
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||
if (asset) {
|
||||
transferringAsset.value = asset
|
||||
showTransferDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 资产删除后刷新
|
||||
function handleDelete() {
|
||||
// store 已经从列表中移除了,无需额外操作
|
||||
}
|
||||
|
||||
// 保存成功回调
|
||||
function handleSaved() {
|
||||
showCreateDialog.value = false
|
||||
editingAsset.value = undefined
|
||||
}
|
||||
|
||||
// 转移成功回调
|
||||
function handleTransferred() {
|
||||
showTransferDialog.value = false
|
||||
transferringAsset.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (groupStore.currentGroupId) {
|
||||
await loadData()
|
||||
await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
await loadData()
|
||||
await setupSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
assetStore.stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asset-list">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="asset-list__header">
|
||||
<h3 class="asset-list__title">资产库</h3>
|
||||
<button class="asset-list__create-btn" @click="handleCreate">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="asset-list__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
登记资产
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 类型过滤 -->
|
||||
<div class="asset-list__filter">
|
||||
<el-select
|
||||
v-model="typeFilter"
|
||||
placeholder="筛选类型"
|
||||
size="default"
|
||||
style="width: 160px"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="asset-list__count">共 {{ filteredAssets.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="assetStore.loading" v-loading="true" class="asset-list__loading"></div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="filteredAssets.length === 0"
|
||||
class="asset-list__empty"
|
||||
>
|
||||
<svg class="asset-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
<p class="asset-list__empty-text">暂无资产</p>
|
||||
<p class="asset-list__empty-hint">点击「登记资产」添加第一个资产</p>
|
||||
</div>
|
||||
|
||||
<!-- 资产网格 -->
|
||||
<div v-else class="asset-list__grid">
|
||||
<AssetCard
|
||||
v-for="asset in filteredAssets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
@edit="handleEdit"
|
||||
@transfer="handleTransfer"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑弹窗 -->
|
||||
<CreateAssetDialog
|
||||
v-model="showCreateDialog"
|
||||
:group-id="groupStore.currentGroupId"
|
||||
:edit-asset="editingAsset"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
|
||||
<!-- 转移弹窗 -->
|
||||
<TransferAssetDialog
|
||||
v-model="showTransferDialog"
|
||||
:asset="transferringAsset"
|
||||
:members="groupStore.currentMembers"
|
||||
@transferred="handleTransferred"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.asset-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.asset-list__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asset-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;
|
||||
}
|
||||
|
||||
.asset-list__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.asset-list__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 过滤栏 */
|
||||
.asset-list__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-list__count {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.asset-list__loading {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.asset-list__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.asset-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-list__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.asset-list__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.asset-list__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.asset-list__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user