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:
congsh
2026-04-18 19:42:04 +08:00
parent 625d0baf7d
commit c5413644f9
21 changed files with 4407 additions and 1 deletions
@@ -0,0 +1,334 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useLedgerStore } from '@/stores/ledger'
import { useGroupStore } from '@/stores/group'
import type { Ledger, LedgerType, LedgerCategory } from '@/types'
import { LedgerCategoryMap } from '@/types'
import { displayName } from '@/types'
const props = defineProps<{
groupId: string
editLedger?: Ledger
}>()
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
saved: []
}>()
const ledgerStore = useLedgerStore()
const groupStore = useGroupStore()
const loading = ref(false)
const form = ref({
type: 'expense' as LedgerType,
amount: 0,
category: 'other' as LedgerCategory,
description: '',
relatedMembers: [] as string[],
occurredAt: '' as string | Date,
})
const isEditing = computed(() => !!props.editLedger)
const dialogTitle = computed(() => (isEditing.value ? '编辑账目' : '新建账目'))
// 分类选项
const categoryOptions = Object.entries(LedgerCategoryMap).map(([value, label]) => ({
label,
value,
}))
// 群组成员选项
const memberOptions = computed(() => {
return groupStore.currentMembers.map((m) => ({
label: displayName(m),
value: m.id,
}))
})
// 格式化今天日期
function getTodayStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// 重置表单
function resetForm() {
form.value = {
type: 'expense',
amount: 0,
category: 'other',
description: '',
relatedMembers: [],
occurredAt: getTodayStr(),
}
}
// 填充编辑数据
function fillForm(ledger: Ledger) {
form.value = {
type: ledger.type,
amount: ledger.amount,
category: ledger.category,
description: ledger.description || '',
relatedMembers: [...(ledger.relatedMembers || [])],
occurredAt: ledger.occurredAt?.slice(0, 10) || getTodayStr(),
}
}
// 对话框打开
function handleOpen() {
if (props.editLedger) {
fillForm(props.editLedger)
} else {
resetForm()
}
}
// 提交
async function handleSubmit() {
if (!props.groupId) {
ElMessage.error('缺少群组信息')
return
}
if (form.value.amount <= 0) {
ElMessage.warning('金额必须大于0')
return
}
loading.value = true
try {
const occurredAt = form.value.occurredAt
? new Date(String(form.value.occurredAt)).toISOString()
: new Date().toISOString()
if (isEditing.value && props.editLedger) {
await ledgerStore.editLedger(props.editLedger.id, {
type: form.value.type,
amount: form.value.amount,
category: form.value.category,
description: form.value.description,
relatedMembers: form.value.relatedMembers,
occurredAt,
})
ElMessage.success('账目更新成功')
} else {
await ledgerStore.addLedger({
group: props.groupId,
type: form.value.type,
amount: form.value.amount,
category: form.value.category,
description: form.value.description,
relatedMembers: form.value.relatedMembers,
occurredAt,
})
ElMessage.success('账目创建成功')
}
visible.value = false
emit('saved')
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="480px"
@open="handleOpen"
>
<div class="create-form">
<!-- 类型 -->
<div class="form-field">
<label>类型 <span class="required">*</span></label>
<div class="type-switch">
<button
:class="['type-btn', { active: form.type === 'income' }]"
@click="form.type = 'income'"
>
收入
</button>
<button
:class="['type-btn', { active: form.type === 'expense' }]"
@click="form.type = 'expense'"
>
支出
</button>
</div>
</div>
<!-- 金额 -->
<div class="form-field">
<label>金额 <span class="required">*</span></label>
<el-input
v-model.number="form.amount"
type="number"
:min="0.01"
:step="0.01"
placeholder="请输入金额"
>
<template #prefix>
<span class="amount-prefix">¥</span>
</template>
</el-input>
</div>
<!-- 分类 -->
<div class="form-field">
<label>分类 <span class="required">*</span></label>
<el-select
v-model="form.category"
placeholder="请选择分类"
style="width: 100%"
>
<el-option
v-for="opt in categoryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<!-- 描述 -->
<div class="form-field">
<label>描述</label>
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入账目描述(可选)"
maxlength="200"
show-word-limit
/>
</div>
<!-- 关联成员 -->
<div class="form-field">
<label>关联成员</label>
<el-select
v-model="form.relatedMembers"
multiple
placeholder="选择关联成员(可选)"
style="width: 100%"
collapse-tags
collapse-tags-tooltip
>
<el-option
v-for="opt in memberOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<!-- 发生日期 -->
<div class="form-field">
<label>发生日期</label>
<el-date-picker
v-model="form.occurredAt"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? '保存中...' : '保存' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.type-switch {
display: flex;
gap: 8px;
}
.type-btn {
flex: 1;
padding: 8px 16px;
border: 1px solid var(--gg-border, #dcdfe6);
border-radius: 6px;
background: var(--gg-bg, #fff);
color: var(--gg-text-secondary, #909399);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.type-btn:hover {
border-color: var(--gg-primary, #67c23a);
color: var(--gg-primary, #67c23a);
}
.type-btn.active {
background: var(--gg-primary, #67c23a);
border-color: var(--gg-primary, #67c23a);
color: #fff;
}
.amount-prefix {
color: var(--gg-text-secondary);
font-weight: 500;
}
.submit-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover {
opacity: 0.85;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Edit, Delete } from '@element-plus/icons-vue'
import type { Ledger } from '@/types'
import { LedgerTypeMap, LedgerCategoryMap, displayName } from '@/types'
import { pb } from '@/api/pocketbase'
import { useGroupStore } from '@/stores/group'
const props = defineProps<{
ledger: Ledger
}>()
const emit = defineEmits<{
edit: [ledgerId: string]
delete: [ledgerId: string]
}>()
const groupStore = useGroupStore()
const isCreator = computed(() => {
return props.ledger.creator === pb.authStore.model?.id
})
const isGroupOwner = computed(() => {
return pb.authStore.model?.id === groupStore.currentGroup?.owner
})
const typeLabel = computed(() => LedgerTypeMap[props.ledger.type])
const categoryLabel = computed(() => LedgerCategoryMap[props.ledger.category])
const isIncome = computed(() => props.ledger.type === 'income')
const formattedAmount = computed(() => {
const prefix = isIncome.value ? '+' : '-'
return `${prefix}¥${props.ledger.amount.toFixed(2)}`
})
const formattedDate = computed(() => {
if (!props.ledger.occurredAt) return ''
return props.ledger.occurredAt.slice(0, 10)
})
const creatorName = computed(() => {
return displayName(props.ledger.expand?.creator)
})
const relatedMemberNames = computed(() => {
const members = props.ledger.expand?.relatedMembers
if (!members || members.length === 0) return []
return members.map((m) => displayName(m))
})
</script>
<template>
<div class="ledger-card">
<div class="ledger-card__main">
<!-- 左侧类型标签 + 金额 -->
<div class="ledger-card__left">
<span
class="ledger-card__type-tag"
:class="isIncome ? 'ledger-card__type-tag--income' : 'ledger-card__type-tag--expense'"
>
{{ typeLabel }}
</span>
<span
class="ledger-card__amount"
:class="isIncome ? 'ledger-card__amount--income' : 'ledger-card__amount--expense'"
>
{{ formattedAmount }}
</span>
</div>
<!-- 中间描述分类日期 -->
<div class="ledger-card__center">
<div class="ledger-card__desc">{{ ledger.description || '无备注' }}</div>
<div class="ledger-card__meta">
<span class="ledger-card__category-tag">{{ categoryLabel }}</span>
<span class="ledger-card__date">{{ formattedDate }}</span>
</div>
</div>
<!-- 右侧关联成员 + 创建者 -->
<div class="ledger-card__right">
<div v-if="relatedMemberNames.length > 0" class="ledger-card__members">
<span
v-for="(name, index) in relatedMemberNames.slice(0, 3)"
:key="index"
class="ledger-card__member"
>
{{ name }}
</span>
<span v-if="relatedMemberNames.length > 3" class="ledger-card__member ledger-card__member--more">
+{{ relatedMemberNames.length - 3 }}
</span>
</div>
<div class="ledger-card__creator">
{{ creatorName }}
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="isCreator || isGroupOwner" class="ledger-card__actions">
<button v-if="isCreator" class="ledger-card__action-btn" @click.stop="emit('edit', ledger.id)">
<el-icon><Edit /></el-icon>
编辑
</button>
<button class="ledger-card__action-btn ledger-card__action-btn--danger" @click.stop="emit('delete', ledger.id)">
<el-icon><Delete /></el-icon>
删除
</button>
</div>
</div>
</template>
<style scoped>
.ledger-card {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md, 8px);
padding: 14px 18px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.ledger-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
}
.ledger-card__main {
display: flex;
align-items: flex-start;
gap: 16px;
}
/* 左侧 */
.ledger-card__left {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 80px;
}
.ledger-card__type-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.ledger-card__type-tag--income {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.ledger-card__type-tag--expense {
background: rgba(239, 68, 68, 0.1);
color: var(--gg-danger);
}
.ledger-card__amount {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
}
.ledger-card__amount--income {
color: var(--gg-success);
}
.ledger-card__amount--expense {
color: var(--gg-danger);
}
/* 中间 */
.ledger-card__center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ledger-card__desc {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ledger-card__meta {
display: flex;
align-items: center;
gap: 8px;
}
.ledger-card__category-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
background: rgba(5, 150, 105, 0.08);
color: var(--gg-primary);
}
.ledger-card__date {
font-size: 12px;
color: var(--gg-text-muted);
}
/* 右侧 */
.ledger-card__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
min-width: 0;
}
.ledger-card__members {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
.ledger-card__member {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
background: var(--gg-bg-elevated);
color: var(--gg-text-secondary);
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ledger-card__member--more {
background: var(--gg-bg-elevated);
color: var(--gg-text-muted);
max-width: none;
}
.ledger-card__creator {
font-size: 12px;
color: var(--gg-text-muted);
}
/* 操作按钮 */
.ledger-card__actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--gg-border);
}
.ledger-card__action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gg-text-secondary);
font-size: 12px;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.ledger-card__action-btn:hover {
color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
}
.ledger-card__action-btn--danger:hover {
color: var(--gg-danger);
background: rgba(239, 68, 68, 0.08);
}
@media (max-width: 640px) {
.ledger-card__main {
flex-direction: column;
gap: 10px;
}
.ledger-card__left {
flex-direction: row;
align-items: center;
justify-content: space-between;
min-width: auto;
width: 100%;
}
.ledger-card__right {
align-items: flex-start;
}
}
</style>
@@ -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>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLedgerStore } from '@/stores/ledger'
const ledgerStore = useLedgerStore()
const totalIncome = computed(() => ledgerStore.summary.totalIncome)
const totalExpense = computed(() => ledgerStore.summary.totalExpense)
const balance = computed(() => ledgerStore.summary.balance)
function formatAmount(value: number): string {
return '¥' + value.toFixed(2)
}
</script>
<template>
<div class="ledger-summary">
<div class="ledger-summary__stat ledger-summary__stat--income">
<span class="ledger-summary__label">总收入</span>
<span class="ledger-summary__amount ledger-summary__amount--income">
{{ formatAmount(totalIncome) }}
</span>
</div>
<div class="ledger-summary__stat ledger-summary__stat--expense">
<span class="ledger-summary__label">总支出</span>
<span class="ledger-summary__amount ledger-summary__amount--expense">
{{ formatAmount(totalExpense) }}
</span>
</div>
<div class="ledger-summary__stat ledger-summary__stat--balance">
<span class="ledger-summary__label">余额</span>
<span
class="ledger-summary__amount"
:class="balance >= 0 ? 'ledger-summary__amount--income' : 'ledger-summary__amount--expense'"
>
{{ formatAmount(balance) }}
</span>
</div>
</div>
</template>
<style scoped>
.ledger-summary {
display: flex;
gap: 12px;
}
.ledger-summary__stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 12px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-lg, 12px);
}
.ledger-summary__label {
font-size: 12px;
font-weight: 500;
color: var(--gg-text-secondary);
}
.ledger-summary__amount {
font-size: 20px;
font-weight: 700;
}
.ledger-summary__amount--income {
color: var(--gg-success);
}
.ledger-summary__amount--expense {
color: var(--gg-danger);
}
@media (max-width: 640px) {
.ledger-summary {
flex-direction: column;
gap: 8px;
}
.ledger-summary__stat {
flex-direction: row;
justify-content: space-between;
padding: 12px 16px;
}
}
</style>