添加系统管理、登录模块的接口、标准化开发流程

This commit is contained in:
super
2026-01-08 20:49:42 +08:00
parent fef12b01e2
commit 8fa07e4952
40 changed files with 3126 additions and 1701 deletions

View File

@@ -0,0 +1,124 @@
<template>
<a-modal
:open="visible"
title="预算详情"
:footer="null"
width="800px"
@cancel="handleCancel"
>
<template v-if="record">
<a-descriptions bordered :column="2">
<a-descriptions-item label="预算名称" :span="2">{{ record.name }}</a-descriptions-item>
<a-descriptions-item label="预算周期">
{{ BudgetPeriodMap[record.period] }}
</a-descriptions-item>
<a-descriptions-item label="时间">
{{ record.year }}
{{ record.period === 'monthly' ? `${record.month}` :
record.period === 'quarterly' ? `Q${record.quarter}` : '' }}
</a-descriptions-item>
<a-descriptions-item label="所属部门">{{ record.departmentName || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getBudgetStatusColor(record.status)">
{{ BudgetStatusMap[record.status] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="总预算">
<span class="money-primary">¥{{ record.totalBudget.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="执行率">
<a-progress
:percent="record.usageRate"
:stroke-color="getProgressColor(record.usageRate)"
style="width: 150px"
/>
</a-descriptions-item>
</a-descriptions>
<a-divider>预算明细</a-divider>
<a-table
:columns="detailColumns"
:data-source="record.items"
:pagination="false"
row-key="expenseType"
size="small"
>
<template #bodyCell="{ column, record: item }">
<template v-if="column.key === 'expenseType'">
<a-tag :color="ExpenseTypeColorMap[item.expenseType]">
{{ ExpenseTypeMap[item.expenseType] }}
</a-tag>
</template>
<template v-if="column.key === 'budgetAmount'">
¥{{ item.budgetAmount.toLocaleString() }}
</template>
<template v-if="column.key === 'usedAmount'">
<span class="text-warning">¥{{ item.usedAmount.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'remainingAmount'">
<span :class="item.remainingAmount >= 0 ? 'text-success' : 'text-danger'">
¥{{ item.remainingAmount.toLocaleString() }}
</span>
</template>
<template v-if="column.key === 'usage'">
<a-progress
:percent="item.budgetAmount > 0 ? Math.round((item.usedAmount / item.budgetAmount) * 100) : 0"
size="small"
:stroke-color="getProgressColor(item.budgetAmount > 0 ? Math.round((item.usedAmount / item.budgetAmount) * 100) : 0)"
/>
</template>
</template>
</a-table>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BudgetRecord, BudgetStatus } from '@/types/finance/budget'
import { BudgetPeriodMap, BudgetStatusMap } from '@/types/finance/budget'
import { ExpenseTypeMap, ExpenseTypeColorMap } from '@/types/finance/common'
const props = defineProps<{
visible: boolean
record?: BudgetRecord
}>()
const emit = defineEmits(['update:visible'])
const detailColumns = [
{ title: '费用类型', key: 'expenseType', width: 150 },
{ title: '预算金额', key: 'budgetAmount', width: 130, align: 'right' as const },
{ title: '已使用', key: 'usedAmount', width: 130, align: 'right' as const },
{ title: '剩余', key: 'remainingAmount', width: 130, align: 'right' as const },
{ title: '使用率', key: 'usage', width: 150 }
]
function getBudgetStatusColor(status: BudgetStatus): string {
const colors: Record<BudgetStatus, string> = {
draft: 'default',
active: 'green',
completed: 'blue',
cancelled: 'red'
}
return colors[status]
}
function getProgressColor(percent: number): string {
if (percent >= 90) return '#ff4d4f'
if (percent >= 70) return '#faad14'
if (percent >= 50) return '#1890ff'
return '#52c41a'
}
function handleCancel() {
emit('update:visible', false)
}
</script>
<style scoped>
.money-primary { color: #1890ff; font-weight: 600; }
.text-success { color: #52c41a; }
.text-warning { color: #faad14; }
.text-danger { color: #ff4d4f; }
</style>

View File

@@ -0,0 +1,301 @@
<template>
<a-modal
:open="visible"
:title="title"
:confirm-loading="loading"
width="800px"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 19 }"
>
<a-form-item label="预算名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入预算名称" />
</a-form-item>
<a-form-item label="预算周期" name="period">
<a-radio-group v-model:value="formData.period">
<a-radio-button v-for="(label, key) in BudgetPeriodMap" :key="key" :value="key">
{{ label }}
</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="年度" name="year">
<a-select v-model:value="formData.year" style="width: 120px">
<a-select-option :value="2024">2024</a-select-option>
<a-select-option :value="2025">2025</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="月份" v-if="formData.period === 'monthly'">
<a-select v-model:value="formData.month" style="width: 120px">
<a-select-option v-for="m in 12" :key="m" :value="m">{{ m }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="季度" v-if="formData.period === 'quarterly'">
<a-select v-model:value="formData.quarter" style="width: 120px">
<a-select-option v-for="q in 4" :key="q" :value="q">Q{{ q }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="所属部门">
<a-select v-model:value="formData.departmentId" placeholder="选择部门(选填)" allow-clear>
<a-select-option v-for="dept in departments" :key="dept.id" :value="dept.id">
{{ dept.name }}
</a-select-option>
</a-select>
</a-form-item>
<!-- 预算明细 -->
<a-divider>预算明细</a-divider>
<a-form-item label="费用预算" required>
<div class="budget-items">
<a-row :gutter="16" class="budget-item-header">
<a-col :span="8">费用类型</a-col>
<a-col :span="8">预算金额</a-col>
<a-col :span="8">已使用</a-col>
</a-row>
<div v-for="(item, index) in formData.items" :key="index" class="budget-item-row">
<a-row :gutter="16" align="middle">
<a-col :span="8">
<a-tag :color="ExpenseTypeColorMap[item.expenseType]">
{{ ExpenseTypeMap[item.expenseType] }}
</a-tag>
</a-col>
<a-col :span="8">
<a-input-number
v-model:value="item.budgetAmount"
:min="0"
:precision="2"
style="width: 100%"
:formatter="(value: any) => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: any) => value.replace(/¥\s?|(,*)/g, '')"
/>
</a-col>
<a-col :span="8">
<span class="used-amount">¥{{ (item.usedAmount || 0).toLocaleString() }}</span>
</a-col>
</a-row>
</div>
<a-divider dashed />
<a-row :gutter="16" class="budget-total-row">
<a-col :span="8"><strong>合计</strong></a-col>
<a-col :span="8">
<strong class="total-budget">¥{{ calculatedTotal.toLocaleString() }}</strong>
</a-col>
<a-col :span="8">
<strong class="total-used">¥{{ calculatedUsed.toLocaleString() }}</strong>
</a-col>
</a-row>
</div>
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息(选填)" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import type { Rule } from 'ant-design-vue/es/form'
import type { BudgetRecord, BudgetPeriod, BudgetItem } from '@/types/finance/budget'
import { BudgetPeriodMap } from '@/types/finance/budget'
import type { ExpenseType } from '@/types/finance/common'
import { ExpenseTypeMap, ExpenseTypeColorMap } from '@/types/finance/common'
import { createBudget, updateBudget } from '@/api/finance/budget'
const props = defineProps<{
visible: boolean
record?: BudgetRecord
departments: Array<{ id: number; name: string }>
}>()
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const formRef = ref<FormInstance>()
const expenseTypes: ExpenseType[] = ['salary', 'office', 'travel', 'marketing', 'equipment', 'other']
interface FormBudgetItem {
expenseType: ExpenseType
budgetAmount: number
usedAmount: number
remainingAmount: number
}
const formData = reactive({
id: undefined as number | undefined,
name: '',
period: 'monthly' as BudgetPeriod,
year: 2024,
month: new Date().getMonth() + 1,
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
departmentId: undefined as number | undefined,
items: [] as FormBudgetItem[],
remark: ''
})
const title = computed(() => formData.id ? '编辑预算' : '新增预算')
const formRules: Record<string, Rule[]> = {
name: [{ required: true, message: '请输入预算名称', trigger: 'blur' }],
period: [{ required: true, message: '请选择预算周期', trigger: 'change' }],
year: [{ required: true, message: '请选择年度', trigger: 'change' }]
}
// 计算属性
const calculatedTotal = computed(() =>
formData.items.reduce((sum, item) => sum + (item.budgetAmount || 0), 0)
)
const calculatedUsed = computed(() =>
formData.items.reduce((sum, item) => sum + (item.usedAmount || 0), 0)
)
function initFormItems() {
formData.items = expenseTypes.map(type => ({
expenseType: type,
budgetAmount: 0,
usedAmount: 0,
remainingAmount: 0
}))
}
watch(
() => props.visible,
(val) => {
if (val) {
if (props.record) {
const record = props.record
Object.assign(formData, {
id: record.id,
name: record.name,
period: record.period,
year: record.year,
month: record.month || 1,
quarter: record.quarter || 1,
departmentId: record.departmentId,
remark: record.remark || ''
})
// Map items and fill missing
const existingItemsMap = new Map(record.items.map(item => [item.expenseType, item]))
formData.items = expenseTypes.map(type => {
const exist = existingItemsMap.get(type)
return {
expenseType: type,
budgetAmount: exist?.budgetAmount || 0,
usedAmount: exist?.usedAmount || 0,
remainingAmount: exist?.remainingAmount || 0
}
})
} else {
Object.assign(formData, {
id: undefined,
name: '',
period: 'monthly',
year: 2024,
month: new Date().getMonth() + 1,
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
departmentId: undefined,
remark: ''
})
initFormItems()
}
}
}
)
async function handleOk() {
try {
await formRef.value?.validate()
loading.value = true
// Prepare items
const items: BudgetItem[] = formData.items
.filter(item => item.budgetAmount > 0) // Only save non-zero items? Or all? User logic was > 0
.map(item => ({
expenseType: item.expenseType,
budgetAmount: item.budgetAmount,
usedAmount: item.usedAmount,
remainingAmount: item.budgetAmount - item.usedAmount
}))
const dept = props.departments.find(d => d.id === formData.departmentId)
const saveData: Partial<BudgetRecord> = {
id: formData.id,
name: formData.name,
period: formData.period,
year: formData.year,
month: formData.period === 'monthly' ? formData.month : undefined,
quarter: formData.period === 'quarterly' ? formData.quarter : undefined,
departmentId: formData.departmentId,
departmentName: dept?.name,
items,
totalBudget: calculatedTotal.value,
usedAmount: calculatedUsed.value,
remainingAmount: calculatedTotal.value - calculatedUsed.value,
usageRate: calculatedTotal.value > 0 ? Math.round((calculatedUsed.value / calculatedTotal.value) * 100) : 0,
remark: formData.remark,
}
if (saveData.id) {
await updateBudget(saveData)
message.success('编辑成功')
} else {
await createBudget(saveData)
message.success('新增成功')
}
emit('update:visible', false)
emit('success')
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
function handleCancel() {
emit('update:visible', false)
formRef.value?.resetFields()
}
</script>
<style scoped>
.budget-items {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
}
.budget-item-header {
font-weight: 500;
color: #666;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.budget-item-row {
padding: 8px 0;
border-bottom: 1px dashed #f0f0f0;
}
.used-amount {
color: #8c8c8c;
}
.budget-total-row {
padding-top: 12px;
}
.total-budget { color: #1890ff; font-size: 16px; }
.total-used { color: #faad14; font-size: 16px; }
</style>