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,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>
|
||||
Reference in New Issue
Block a user