feat(mobile): stage 8 - ledger + assets + blacklist

- migrate LedgerMobile.vue (monthly summary + list + add + swipe-delete)
- migrate AssetMobile.vue (list + add with image + transfer + delete)
- migrate BlacklistMobile.vue (game/player dual tabs + add + delete)
- router: wire LedgerView/AssetView/BlacklistView mobile views
- verified: ledger/asset stores + assets/gameBlacklist/playerBlacklist APIs + types maps all match uat

build verified: vue-tsc + vite build pass
This commit is contained in:
锦麟 王
2026-06-18 11:20:52 +08:00
parent 6ba671d2c3
commit 13e87110ae
4 changed files with 771 additions and 3 deletions
+3 -3
View File
@@ -89,7 +89,7 @@ const routes: RouteRecordRaw[] = [
name: 'LedgerView', name: 'LedgerView',
component: view( component: view(
() => import('@/views/LedgerView.vue'), () => import('@/views/LedgerView.vue'),
mobilePlaceholder () => import('@/views-mobile/LedgerMobile.vue')
), ),
props: true, props: true,
meta: { requiresAuth: true } meta: { requiresAuth: true }
@@ -99,7 +99,7 @@ const routes: RouteRecordRaw[] = [
name: 'AssetView', name: 'AssetView',
component: view( component: view(
() => import('@/views/AssetView.vue'), () => import('@/views/AssetView.vue'),
mobilePlaceholder () => import('@/views-mobile/AssetMobile.vue')
), ),
props: true, props: true,
meta: { requiresAuth: true } meta: { requiresAuth: true }
@@ -109,7 +109,7 @@ const routes: RouteRecordRaw[] = [
name: 'BlacklistView', name: 'BlacklistView',
component: view( component: view(
() => import('@/views/BlacklistView.vue'), () => import('@/views/BlacklistView.vue'),
mobilePlaceholder () => import('@/views-mobile/BlacklistMobile.vue')
), ),
props: true, props: true,
meta: { requiresAuth: true } meta: { requiresAuth: true }
+230
View File
@@ -0,0 +1,230 @@
<!-- src/views-mobile/AssetMobile.vue -->
<!-- 手机端资产列表 + 添加 + 转移 + 删除 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAssetStore } from '@/stores/asset'
import { useGroupStore } from '@/stores/group'
import { getAssetImageUrl } from '@/api/assets'
import { AssetTypeMap } from '@/types'
import type { AssetType } from '@/types'
import { displayName } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const route = useRoute()
const groupId = route.params.groupId as string
const assetStore = useAssetStore()
const groupStore = useGroupStore()
const assets = computed(() => assetStore.assets)
const members = computed(() => groupStore.currentMembers)
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
await assetStore.loadAssets(groupId)
await assetStore.startSubscription(groupId)
})
onUnmounted(() => {
assetStore.stopSubscription()
})
function imageUrl(assetId: string, filename: string): string {
if (!filename) return ''
return getAssetImageUrl(assetId, filename, '200x200')
}
// 添加
const showAdd = ref(false)
const addForm = ref({ name: '', type: 'other' as AssetType, description: '' })
const addImage = ref<File | null>(null)
const addLoading = ref(false)
function onImageSelect(items: any) {
const arr = Array.isArray(items) ? items : [items]
addImage.value = arr[0]?.file || null
}
async function handleAdd() {
if (!addForm.value.name.trim()) {
showFailToast('请输入名称')
return
}
addLoading.value = true
try {
await assetStore.addAsset({
group: groupId,
name: addForm.value.name.trim(),
type: addForm.value.type,
description: addForm.value.description.trim(),
image: addImage.value || undefined
})
showSuccessToast('添加成功')
showAdd.value = false
addForm.value = { name: '', type: 'other', description: '' }
addImage.value = null
} catch (e: any) {
showFailToast(e.message || '添加失败')
} finally {
addLoading.value = false
}
}
// 转移
const showTransfer = ref(false)
const transferAssetId = ref('')
const transferUserId = ref('')
function openTransfer(assetId: string) {
transferAssetId.value = assetId
transferUserId.value = ''
showTransfer.value = true
}
async function handleTransfer() {
if (!transferUserId.value) {
showFailToast('请选择成员')
return
}
try {
await assetStore.transfer(transferAssetId.value, transferUserId.value)
showSuccessToast('已转移')
showTransfer.value = false
} catch (e: any) {
showFailToast(e.message || '转移失败')
}
}
// 删除
async function handleDelete(assetId: string) {
showConfirmDialog({ title: '删除', message: '确定删除这个资产吗?' })
.then(async () => {
try {
await assetStore.removeAsset(assetId)
showSuccessToast('已删除')
} catch (e: any) {
showFailToast('删除失败')
}
}).catch(() => {})
}
const assetTypes = Object.keys(AssetTypeMap) as AssetType[]
</script>
<template>
<div class="asset-mobile">
<div v-if="assets.length === 0" class="empty">
<van-empty description="暂无资产" image-size="100" />
</div>
<div v-else class="asset-list">
<div v-for="a in assets" :key="a.id" class="asset-card">
<div class="asset-main">
<img
v-if="a.image"
:src="imageUrl(a.id, a.image)"
class="asset-img"
alt=""
/>
<div v-else class="asset-img-placeholder">
<van-icon name="gift-o" size="28" />
</div>
<div class="asset-info">
<div class="asset-name">{{ a.name }}</div>
<div class="asset-meta">
<van-tag plain size="medium">{{ AssetTypeMap[a.type] }}</van-tag>
</div>
<div v-if="a.expand?.currentHolder" class="asset-holder">
持有: {{ displayName(a.expand.currentHolder) }}
</div>
</div>
</div>
<div class="asset-actions">
<van-button size="mini" plain round @click="openTransfer(a.id)">转移</van-button>
<van-button size="mini" plain type="danger" round @click="handleDelete(a.id)">删除</van-button>
</div>
</div>
</div>
<!-- 浮动添加 -->
<div class="fab" @click="showAdd = true">
<van-icon name="plus" size="24" />
</div>
<!-- 添加弹层 -->
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
<div class="popup-content">
<div class="popup-title">添加资产</div>
<van-cell-group inset>
<van-field v-model="addForm.name" label="名称" placeholder="资产名称" required />
<van-field name="select" label="类型">
<template #input>
<select v-model="addForm.type" class="type-select">
<option v-for="t in assetTypes" :key="t" :value="t">{{ AssetTypeMap[t] }}</option>
</select>
</template>
</van-field>
<van-field v-model="addForm.description" label="描述" placeholder="可选" />
</van-cell-group>
<div class="upload-area">
<van-uploader :after-read="onImageSelect" :max-count="1" accept="image/*" />
</div>
<div class="popup-actions">
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
</div>
</div>
</van-popup>
<!-- 转移弹层 -->
<van-action-sheet v-model:show="showTransfer" title="转移给">
<div class="transfer-list">
<div
v-for="m in members"
:key="m.id"
class="transfer-item"
:class="{ active: transferUserId === m.id }"
@click="transferUserId = m.id"
>
<img :src="m.avatar || '/default-avatar.svg'" class="transfer-avatar" alt="" />
<span>{{ displayName(m) }}</span>
<van-icon v-if="transferUserId === m.id" name="success" class="transfer-check" />
</div>
<div class="transfer-actions">
<van-button type="primary" block round @click="handleTransfer">确认转移</van-button>
</div>
</div>
</van-action-sheet>
</div>
</template>
<style scoped>
.asset-mobile { padding: 12px; min-height: 60vh; }
.empty { padding: 30px 0; }
.asset-list { display: flex; flex-direction: column; gap: 10px; }
.asset-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
.asset-main { display: flex; gap: 12px; }
.asset-img { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); object-fit: cover; flex-shrink: 0; }
.asset-img-placeholder { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); background: var(--gg-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--gg-text-muted); flex-shrink: 0; }
.asset-info { flex: 1; min-width: 0; }
.asset-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
.asset-meta { margin-top: 6px; }
.asset-holder { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
.asset-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.type-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
.upload-area { padding: 16px; }
.popup-actions { padding: 16px; }
.transfer-list { padding: 8px 0; }
.transfer-item { display: flex; align-items: center; gap: 10px; padding: 12px 24px; }
.transfer-item.active { background: rgba(5,150,105,0.06); color: var(--gg-primary); }
.transfer-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
.transfer-check { margin-left: auto; color: var(--gg-primary); }
.transfer-actions { padding: 16px 24px; }
</style>
@@ -0,0 +1,278 @@
<!-- src/views-mobile/BlacklistMobile.vue -->
<!-- 手机端黑名单游戏/玩家双 Tab + 添加 + 删除 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { listBlacklist, createBlacklistEntry, deleteBlacklistEntry } from '@/api/gameBlacklist'
import { listPlayerBlacklist, createPlayerBlacklistEntry, deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
import { BlacklistReasonMap, BlacklistSeverityMap, PlayerTagMap } from '@/types'
import type { BlacklistReason, BlacklistSeverity, PlayerTag, BlacklistEntry, PlayerBlacklistEntry } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const route = useRoute()
const groupId = route.params.groupId as string
const activeTab = ref<'game' | 'player'>('game')
const gameList = ref<BlacklistEntry[]>([])
const playerList = ref<PlayerBlacklistEntry[]>([])
const loading = ref(false)
async function loadAll() {
loading.value = true
try {
const [games, players] = await Promise.all([
listBlacklist(groupId),
listPlayerBlacklist(groupId)
])
gameList.value = games
playerList.value = players
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(loadAll)
const showAddGame = ref(false)
const gameForm = ref({
gameName: '',
reason: 'behavior' as BlacklistReason,
severity: 'medium' as BlacklistSeverity,
description: ''
})
async function handleAddGame() {
if (!gameForm.value.gameName.trim()) {
showFailToast('请输入游戏名称')
return
}
try {
await createBlacklistEntry({
group: groupId,
gameName: gameForm.value.gameName.trim(),
reason: gameForm.value.reason,
severity: gameForm.value.severity,
description: gameForm.value.description.trim()
})
showSuccessToast('已添加')
showAddGame.value = false
gameForm.value = { gameName: '', reason: 'behavior', severity: 'medium', description: '' }
await loadAll()
} catch (e: any) {
showFailToast(e.message || '添加失败')
}
}
const showAddPlayer = ref(false)
const playerForm = ref({
playerId: '',
platform: '',
tags: [] as PlayerTag[],
severity: 'medium' as BlacklistSeverity,
description: ''
})
function toggleTag(tag: PlayerTag) {
const idx = playerForm.value.tags.indexOf(tag)
if (idx === -1) playerForm.value.tags.push(tag)
else playerForm.value.tags.splice(idx, 1)
}
async function handleAddPlayer() {
if (!playerForm.value.playerId.trim()) {
showFailToast('请输入玩家 ID')
return
}
if (playerForm.value.tags.length === 0) {
showFailToast('请至少选一个标签')
return
}
try {
await createPlayerBlacklistEntry({
group: groupId,
playerId: playerForm.value.playerId.trim(),
platform: playerForm.value.platform.trim(),
tags: playerForm.value.tags,
severity: playerForm.value.severity,
description: playerForm.value.description.trim()
})
showSuccessToast('已添加')
showAddPlayer.value = false
playerForm.value = { playerId: '', platform: '', tags: [], severity: 'medium', description: '' }
await loadAll()
} catch (e: any) {
showFailToast(e.message || '添加失败')
}
}
async function deleteGame(id: string) {
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
.then(async () => {
try {
await deleteBlacklistEntry(id)
showSuccessToast('已删除')
await loadAll()
} catch { showFailToast('删除失败') }
}).catch(() => {})
}
async function deletePlayer(id: string) {
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
.then(async () => {
try {
await deletePlayerBlacklistEntry(id)
showSuccessToast('已删除')
await loadAll()
} catch { showFailToast('删除失败') }
}).catch(() => {})
}
const reasonKeys = Object.keys(BlacklistReasonMap) as BlacklistReason[]
const severityKeys = Object.keys(BlacklistSeverityMap) as BlacklistSeverity[]
const tagKeys = Object.keys(PlayerTagMap) as PlayerTag[]
const severityType = (s: string): any => s === 'severe' ? 'danger' : s === 'medium' ? 'warning' : 'default'
</script>
<template>
<div class="blacklist-mobile">
<van-tabs v-model:active="activeTab" sticky>
<van-tab title="游戏黑名单" name="game">
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showAddGame = true">添加</van-button>
</div>
<div v-if="gameList.length === 0" class="empty">
<van-empty description="暂无游戏黑名单" image-size="100" />
</div>
<div v-else class="entry-list">
<van-swipe-cell v-for="g in gameList" :key="g.id">
<div class="entry-card">
<div class="entry-top">
<span class="entry-name">{{ g.gameName }}</span>
<van-tag :type="severityType(g.severity)" size="medium">{{ BlacklistSeverityMap[g.severity] }}</van-tag>
</div>
<van-tag plain size="medium">{{ BlacklistReasonMap[g.reason] }}</van-tag>
<p v-if="g.description" class="entry-desc">{{ g.description }}</p>
</div>
<template #right>
<van-button square type="danger" text="删除" class="delete-btn" @click="deleteGame(g.id)" />
</template>
</van-swipe-cell>
</div>
</van-tab>
<van-tab title="玩家黑名单" name="player">
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showAddPlayer = true">添加</van-button>
</div>
<div v-if="playerList.length === 0" class="empty">
<van-empty description="暂无玩家黑名单" image-size="100" />
</div>
<div v-else class="entry-list">
<van-swipe-cell v-for="p in playerList" :key="p.id">
<div class="entry-card">
<div class="entry-top">
<span class="entry-name">{{ p.playerId }}</span>
<van-tag :type="severityType(p.severity)" size="medium">{{ BlacklistSeverityMap[p.severity] }}</van-tag>
</div>
<div class="tag-row">
<van-tag v-for="t in p.tags" :key="t" type="danger" plain size="medium">{{ PlayerTagMap[t] }}</van-tag>
<van-tag v-if="p.customTag" type="danger" plain size="medium">{{ p.customTag }}</van-tag>
</div>
<p v-if="p.description" class="entry-desc">{{ p.description }}</p>
<div v-if="p.platform" class="entry-platform">平台: {{ p.platform }}</div>
</div>
<template #right>
<van-button square type="danger" text="删除" class="delete-btn" @click="deletePlayer(p.id)" />
</template>
</van-swipe-cell>
</div>
</van-tab>
</van-tabs>
<van-popup v-model:show="showAddGame" position="bottom" round closeable :style="{ height: '70%' }">
<div class="popup-content">
<div class="popup-title">添加游戏黑名单</div>
<van-cell-group inset>
<van-field v-model="gameForm.gameName" label="游戏名" placeholder="游戏名称" required />
<van-field name="select" label="原因">
<template #input>
<select v-model="gameForm.reason" class="sel">
<option v-for="r in reasonKeys" :key="r" :value="r">{{ BlacklistReasonMap[r] }}</option>
</select>
</template>
</van-field>
<van-field name="select" label="严重度">
<template #input>
<select v-model="gameForm.severity" class="sel">
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
</select>
</template>
</van-field>
<van-field v-model="gameForm.description" type="textarea" label="说明" placeholder="详细描述" rows="2" />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round @click="handleAddGame">添加</van-button>
</div>
</div>
</van-popup>
<van-popup v-model:show="showAddPlayer" position="bottom" round closeable :style="{ height: '75%' }">
<div class="popup-content">
<div class="popup-title">添加玩家黑名单</div>
<van-cell-group inset>
<van-field v-model="playerForm.playerId" label="玩家 ID" placeholder="游戏内 ID" required />
<van-field v-model="playerForm.platform" label="平台" placeholder="如 Steam/PSN" />
<van-field name="select" label="严重度">
<template #input>
<select v-model="playerForm.severity" class="sel">
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
</select>
</template>
</van-field>
</van-cell-group>
<div class="tag-select-title">标签多选</div>
<div class="tag-select-area">
<van-tag
v-for="t in tagKeys"
:key="t"
:type="playerForm.tags.includes(t) ? 'primary' : 'default'"
size="large"
round
class="tag-opt"
@click="toggleTag(t)"
>{{ PlayerTagMap[t] }}</van-tag>
</div>
<van-cell-group inset>
<van-field v-model="playerForm.description" type="textarea" label="说明" placeholder="可选" rows="2" />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round @click="handleAddPlayer">添加</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.blacklist-mobile { min-height: 60vh; }
.list-header { display: flex; justify-content: flex-end; padding: 12px; }
.empty { padding: 30px 0; }
.entry-list { padding: 0 12px; display: flex; flex-direction: column; gap: 1px; }
.entry-card { background: var(--gg-bg-card); padding: 12px 14px; }
.entry-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.entry-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
.tag-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.entry-desc { font-size: 13px; color: var(--gg-text-secondary); margin: 6px 0 0; line-height: 1.5; }
.entry-platform { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
.delete-btn { height: 100%; }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.sel { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
.tag-select-title { padding: 16px 16px 8px; font-size: 14px; font-weight: 600; }
.tag-select-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 16px 16px; }
.tag-opt { user-select: none; }
.popup-actions { padding: 20px 16px 0; }
</style>
+260
View File
@@ -0,0 +1,260 @@
<!-- src/views-mobile/LedgerMobile.vue -->
<!-- 手机端账本月度汇总 + 列表 + 添加 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useLedgerStore } from '@/stores/ledger'
import { useGroupStore } from '@/stores/group'
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
import type { LedgerType, LedgerCategory } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const route = useRoute()
const groupId = route.params.groupId as string
const ledgerStore = useLedgerStore()
const groupStore = useGroupStore()
const ledgers = computed(() => ledgerStore.ledgers)
const summary = computed(() => ledgerStore.summary)
const showMonthPicker = ref(false)
const currentMonth = ref(ledgerStore.currentMonth || new Date().toISOString().slice(0, 7))
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
await ledgerStore.loadLedgers(groupId, currentMonth.value)
await ledgerStore.startSubscription(groupId)
})
onUnmounted(() => {
ledgerStore.stopSubscription()
})
async function changeMonth(month: string) {
currentMonth.value = month
showMonthPicker.value = false
await ledgerStore.loadLedgers(groupId, month)
}
// 月份选择器
const months = computed(() => {
const now = new Date()
const list = []
for (let i = 0; i < 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
list.push({
value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`,
label: `${d.getFullYear()}${d.getMonth() + 1}`
})
}
return list
})
// 添加
const showAdd = ref(false)
const addForm = ref({
type: 'expense' as LedgerType,
amount: 0,
category: 'gaming' as LedgerCategory,
description: '',
occurredAt: new Date().toISOString().slice(0, 10)
})
const addLoading = ref(false)
async function handleAdd() {
if (addForm.value.amount <= 0) {
showFailToast('请输入金额')
return
}
addLoading.value = true
try {
await ledgerStore.addLedger({
group: groupId,
type: addForm.value.type,
amount: addForm.value.amount,
category: addForm.value.category,
description: addForm.value.description.trim(),
occurredAt: addForm.value.occurredAt
})
await ledgerStore.loadLedgers(groupId, currentMonth.value)
showSuccessToast('添加成功')
showAdd.value = false
addForm.value = { type: 'expense', amount: 0, category: 'gaming', description: '', occurredAt: new Date().toISOString().slice(0, 10) }
} catch (e: any) {
showFailToast(e.message || '添加失败')
} finally {
addLoading.value = false
}
}
// 删除
async function handleDelete(id: string) {
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
.then(async () => {
try {
await ledgerStore.removeLedger(id)
await ledgerStore.loadLedgers(groupId, currentMonth.value)
showSuccessToast('已删除')
} catch (e: any) {
showFailToast('删除失败')
}
}).catch(() => {})
}
const typeColors: Record<string, 'success' | 'danger' | 'warning' | 'primary' | 'default'> = { income: 'success', expense: 'danger' }
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
}
</script>
<template>
<div class="ledger-mobile">
<!-- 月度汇总 -->
<div class="summary-card">
<div class="month-bar" @click="showMonthPicker = true">
<span class="month-label">{{ months.find(m => m.value === currentMonth)?.label || currentMonth }}</span>
<van-icon name="arrow-down" />
</div>
<div class="balance-row">
<div class="balance-item">
<div class="balance-label">收入</div>
<div class="balance-num income">{{ summary.totalIncome.toFixed(2) }}</div>
</div>
<div class="balance-divider" />
<div class="balance-item">
<div class="balance-label">支出</div>
<div class="balance-num expense">{{ summary.totalExpense.toFixed(2) }}</div>
</div>
<div class="balance-divider" />
<div class="balance-item">
<div class="balance-label">结余</div>
<div class="balance-num" :class="summary.balance >= 0 ? 'income' : 'expense'">
{{ summary.balance.toFixed(2) }}
</div>
</div>
</div>
</div>
<!-- 列表 -->
<div v-if="ledgers.length === 0" class="empty">
<van-empty description="本月暂无记录" image-size="100" />
</div>
<div v-else class="ledger-list">
<van-swipe-cell v-for="l in ledgers" :key="l.id">
<div class="ledger-item">
<div class="ledger-left">
<van-tag :type="typeColors[l.type]" size="medium">{{ LedgerTypeMap[l.type] }}</van-tag>
<div class="ledger-info">
<div class="ledger-desc">{{ l.description || LedgerCategoryMap[l.category] }}</div>
<div class="ledger-meta">
{{ LedgerCategoryMap[l.category] }} · {{ formatDate(l.occurredAt) }}
</div>
</div>
</div>
<div class="ledger-amount" :class="l.type">
{{ l.type === 'income' ? '+' : '-' }}{{ l.amount.toFixed(2) }}
</div>
</div>
<template #right>
<van-button square type="danger" text="删除" class="delete-btn" @click="handleDelete(l.id)" />
</template>
</van-swipe-cell>
</div>
<!-- 浮动添加按钮 -->
<div class="fab" @click="showAdd = true">
<van-icon name="plus" size="24" />
</div>
<!-- 月份选择 -->
<van-action-sheet v-model:show="showMonthPicker" title="选择月份">
<div class="month-list">
<div
v-for="m in months"
:key="m.value"
class="month-option"
:class="{ active: m.value === currentMonth }"
@click="changeMonth(m.value)"
>
{{ m.label }}
</div>
</div>
</van-action-sheet>
<!-- 添加弹层 -->
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
<div class="popup-content">
<div class="popup-title">添加账目</div>
<van-cell-group inset>
<van-field name="radio" label="类型">
<template #input>
<van-radio-group v-model="addForm.type" direction="horizontal">
<van-radio name="expense">支出</van-radio>
<van-radio name="income">收入</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field name="stepper" label="金额">
<template #input>
<van-stepper v-model="addForm.amount" min="0" step="0.01" allow-empty />
</template>
</van-field>
<van-field name="radio" label="分类">
<template #input>
<select v-model="addForm.category" class="category-select">
<option v-for="(label, key) in LedgerCategoryMap" :key="key" :value="key">{{ label }}</option>
</select>
</template>
</van-field>
<van-field v-model="addForm.description" label="备注" placeholder="可选" />
<van-field v-model="addForm.occurredAt" label="日期" placeholder="YYYY-MM-DD" />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.ledger-mobile { padding: 12px; min-height: 60vh; }
.summary-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-lg); padding: 16px; box-shadow: var(--gg-shadow); margin-bottom: 16px; }
.month-bar { display: flex; align-items: center; gap: 4px; font-size: 15px; font-weight: 600; color: var(--gg-text); margin-bottom: 14px; }
.balance-row { display: flex; align-items: center; }
.balance-item { flex: 1; text-align: center; }
.balance-label { font-size: 12px; color: var(--gg-text-muted); }
.balance-num { font-size: 18px; font-weight: 700; margin-top: 4px; }
.balance-num.income { color: var(--gg-success); }
.balance-num.expense { color: var(--gg-danger); }
.balance-divider { width: 1px; height: 32px; background: var(--gg-border); }
.empty { padding: 30px 0; }
.ledger-list { display: flex; flex-direction: column; gap: 1px; }
.ledger-item { display: flex; align-items: center; justify-content: space-between; background: var(--gg-bg-card); padding: 12px 14px; }
.ledger-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.ledger-info { min-width: 0; }
.ledger-desc { font-size: 14px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
.ledger-amount { font-size: 15px; font-weight: 600; flex-shrink: 0; }
.ledger-amount.income { color: var(--gg-success); }
.ledger-amount.expense { color: var(--gg-danger); }
.delete-btn { height: 100%; }
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
.fab:active { transform: scale(0.92); }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.category-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
.popup-actions { padding: 20px 16px 0; }
.month-list { padding: 8px 0; }
.month-option { padding: 14px 24px; font-size: 15px; color: var(--gg-text); }
.month-option.active { color: var(--gg-primary); font-weight: 600; background: rgba(5,150,105,0.06); }
</style>