|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="budget-source-field">
|
|
|
|
|
|
<!-- 提交用隐藏字段 -->
|
|
|
|
|
|
<input :name="fieldName" type="hidden" :value="normalizedValue" />
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 当前展示文本(有值时展示) -->
|
|
|
|
|
|
<span v-if="hasValue && displayText" class="budget-source-label" :title="displayText">
|
|
|
|
|
|
{{ displayText }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-else-if="hasValue && budgetDataId"
|
|
|
|
|
|
class="budget-source-label budget-source-label--muted"
|
|
|
|
|
|
:title="`预算ID: ${budgetDataId}`"
|
|
|
|
|
|
>
|
|
|
|
|
|
预算ID: {{ budgetDataId }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-else-if="hasValue"
|
|
|
|
|
|
class="budget-source-label budget-source-label--muted"
|
|
|
|
|
|
:title="normalizedValue"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ normalizedValue }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
<el-button type="primary" size="small" @click="openDialog">选取</el-button>
|
|
|
|
|
|
<el-button v-if="hasValue" size="small" @click="clearValue">清除</el-button>
|
|
|
|
|
|
|
|
|
|
|
|
<el-dialog
|
|
|
|
|
|
title="选择预算来源"
|
|
|
|
|
|
:visible.sync="dialogVisible"
|
|
|
|
|
|
:width="pendingAuthorizations.length > 1 ? '1000px' : '720px'"
|
|
|
|
|
|
:close-on-click-modal="false"
|
|
|
|
|
|
append-to-body
|
|
|
|
|
|
@open="onDialogOpen"
|
|
|
|
|
|
@close="onDialogClose"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style="margin-bottom: 12px">
|
|
|
|
|
|
<el-select
|
|
|
|
|
|
v-model="yearId"
|
|
|
|
|
|
placeholder="请选择年份"
|
|
|
|
|
|
filterable
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
:loading="yearLoading"
|
|
|
|
|
|
@change="onYearChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-option
|
|
|
|
|
|
v-for="y in yearOptions"
|
|
|
|
|
|
:key="String(y.value)"
|
|
|
|
|
|
:label="y.label"
|
|
|
|
|
|
:value="y.value"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style="display: flex; gap: 16px">
|
|
|
|
|
|
<!-- 左侧:预算树 -->
|
|
|
|
|
|
<div v-loading="treeLoading" style="flex: 1; max-height: 420px; overflow: auto; min-width: 0">
|
|
|
|
|
|
<div v-if="departmentTree.length" class="tree-block">
|
|
|
|
|
|
<div class="tree-title">部门预算</div>
|
|
|
|
|
|
<el-tree
|
|
|
|
|
|
:data="departmentTree"
|
|
|
|
|
|
node-key="id"
|
|
|
|
|
|
:props="treeProps"
|
|
|
|
|
|
highlight-current
|
|
|
|
|
|
default-expand-all
|
|
|
|
|
|
:expand-on-click-node="false"
|
|
|
|
|
|
:current-node-key="pendingId"
|
|
|
|
|
|
@node-click="onNodeClick"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
|
|
|
<span class="node-label">{{ node.label }}</span>
|
|
|
|
|
|
<span v-if="data.is_leaf" class="node-usage-info">
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">预算:</span>
|
|
|
|
|
|
<span class="usage-value">{{ formatAmount(data.budget_amount) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">已用:</span>
|
|
|
|
|
|
<span class="usage-value usage-value--used">{{ formatAmount(data.used_amount || 0) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">执行率:</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="usage-value usage-value--rate"
|
|
|
|
|
|
:class="getExecutionRateClass(data.execution_rate || 0)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatExecutionRate(data.execution_rate || 0) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</el-tree>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="projectTree.length" class="tree-block" style="margin-top: 16px">
|
|
|
|
|
|
<div class="tree-title">自有账户</div>
|
|
|
|
|
|
<el-tree
|
|
|
|
|
|
:data="projectTree"
|
|
|
|
|
|
node-key="id"
|
|
|
|
|
|
:props="treeProps"
|
|
|
|
|
|
highlight-current
|
|
|
|
|
|
default-expand-all
|
|
|
|
|
|
:expand-on-click-node="false"
|
|
|
|
|
|
:current-node-key="pendingId"
|
|
|
|
|
|
@node-click="onNodeClick"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
|
|
|
<span class="node-label">{{ node.label }}</span>
|
|
|
|
|
|
<span v-if="data.is_leaf" class="node-usage-info">
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">预算:</span>
|
|
|
|
|
|
<span class="usage-value">{{ formatAmount(data.budget_amount) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">已用:</span>
|
|
|
|
|
|
<span class="usage-value usage-value--used">{{ formatAmount(data.used_amount || 0) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">执行率:</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="usage-value usage-value--rate"
|
|
|
|
|
|
:class="getExecutionRateClass(data.execution_rate || 0)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatExecutionRate(data.execution_rate || 0) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</el-tree>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="specialFundTree.length" class="tree-block" style="margin-top: 16px">
|
|
|
|
|
|
<div class="tree-title">专项资金</div>
|
|
|
|
|
|
<el-tree
|
|
|
|
|
|
:data="specialFundTree"
|
|
|
|
|
|
node-key="id"
|
|
|
|
|
|
:props="treeProps"
|
|
|
|
|
|
highlight-current
|
|
|
|
|
|
default-expand-all
|
|
|
|
|
|
:expand-on-click-node="false"
|
|
|
|
|
|
:current-node-key="pendingId"
|
|
|
|
|
|
@node-click="onNodeClick"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
|
|
|
<span class="node-label">{{ node.label }}</span>
|
|
|
|
|
|
<span v-if="data.is_leaf" class="node-usage-info">
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">预算:</span>
|
|
|
|
|
|
<span class="usage-value">{{ formatAmount(data.budget_amount) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">已用:</span>
|
|
|
|
|
|
<span class="usage-value usage-value--used">{{ formatAmount(data.used_amount || 0) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="usage-item">
|
|
|
|
|
|
<span class="usage-label">执行率:</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="usage-value usage-value--rate"
|
|
|
|
|
|
:class="getExecutionRateClass(data.execution_rate || 0)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatExecutionRate(data.execution_rate || 0) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</el-tree>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="!treeLoading && departmentTree.length === 0 && projectTree.length === 0 && specialFundTree.length === 0"
|
|
|
|
|
|
style="text-align: center; color: #909399; padding: 32px 0"
|
|
|
|
|
|
>
|
|
|
|
|
|
暂无数据
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 右侧:部门分配界面(仅多部门时显示) -->
|
|
|
|
|
|
<div v-if="pendingAuthorizations.length > 1" style="flex: 0 0 400px; border-left: 1px solid #e4e7ed; padding-left: 16px">
|
|
|
|
|
|
<div style="margin-bottom: 12px; font-weight: 500; color: #303133">
|
|
|
|
|
|
部门金额分配
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="margin-bottom: 12px; color: #606266; font-size: 12px">
|
|
|
|
|
|
该预算来源授权给多个部门,请将支付总额分配到各个部门(分配金额可以为0):
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-table :data="pendingAllocations" border size="small" style="width: 100%">
|
|
|
|
|
|
<el-table-column prop="department_name" label="部门" width="180"></el-table-column>
|
|
|
|
|
|
<el-table-column label="分配金额" width="180">
|
|
|
|
|
|
<template slot-scope="scope">
|
|
|
|
|
|
<el-input-number
|
|
|
|
|
|
v-model="scope.row.allocated_amount"
|
|
|
|
|
|
:min="0"
|
|
|
|
|
|
:precision="2"
|
|
|
|
|
|
:controls="false"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
@change="onAllocationChange"
|
|
|
|
|
|
></el-input-number>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
<div style="margin-top: 12px; padding: 12px; background-color: #f5f7fa; border-radius: 4px">
|
|
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
|
|
|
|
|
|
<span style="font-size: 12px; color: #606266">已分配总额:</span>
|
|
|
|
|
|
<span :style="{ color: allocationTotal === totalAmount ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '14px' }">
|
|
|
|
|
|
{{ formatAmount(allocationTotal) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="totalAmount > 0" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
|
|
|
|
|
|
<span style="font-size: 12px; color: #606266">支付总额:</span>
|
|
|
|
|
|
<span style="font-weight: 500; font-size: 14px">{{ formatAmount(totalAmount) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="totalAmount > 0" style="display: flex; justify-content: space-between; align-items: center">
|
|
|
|
|
|
<span style="font-size: 12px; color: #606266">剩余金额:</span>
|
|
|
|
|
|
<span :style="{ color: remainingAmount >= 0 ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '14px' }">
|
|
|
|
|
|
{{ formatAmount(remainingAmount) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="totalAmount === 0" style="margin-top: 8px; color: #909399; font-size: 11px; line-height: 1.5">
|
|
|
|
|
|
提示:当前支付总额为0,您可以在财务确认时填写实际金额
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
:disabled="!pendingId || (pendingAuthorizations.length > 1 && totalAmount > 0 && allocationTotal !== totalAmount)"
|
|
|
|
|
|
@click="confirmPick"
|
|
|
|
|
|
>
|
|
|
|
|
|
确定
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
import request from "@/utils/request";
|
|
|
|
|
|
import { getBudgetYearOptions, getBudgetDataTree } from "@/api/flow";
|
|
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
|
name: "BudgetSourcePickerField",
|
|
|
|
|
|
props: {
|
|
|
|
|
|
fieldName: { type: String, required: true },
|
|
|
|
|
|
value: { type: [String, Number, Object, Array], default: "" },
|
|
|
|
|
|
display: { type: String, default: "" },
|
|
|
|
|
|
},
|
|
|
|
|
|
data() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
dialogVisible: false,
|
|
|
|
|
|
yearLoading: false,
|
|
|
|
|
|
treeLoading: false,
|
|
|
|
|
|
yearOptions: [],
|
|
|
|
|
|
yearId: null,
|
|
|
|
|
|
treeData: [],
|
|
|
|
|
|
pendingId: null,
|
|
|
|
|
|
// 分配相关
|
|
|
|
|
|
pendingAuthorizations: [],
|
|
|
|
|
|
pendingAllocations: [],
|
|
|
|
|
|
totalAmount: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
computed: {
|
|
|
|
|
|
// 解析JSON格式的value,兼容旧格式
|
|
|
|
|
|
parsedValue() {
|
|
|
|
|
|
const value = this.value;
|
|
|
|
|
|
if (!value || value === null || value === undefined) {
|
|
|
|
|
|
return { budget_data_id: null, allocations: null };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 已经是对象(推荐的新格式:直接对象落库/回传)
|
|
|
|
|
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
budget_data_id: value?.budget_data_id ?? null,
|
|
|
|
|
|
allocations: value?.allocations ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 兼容:如果是数组(极端旧数据/异常数据),当作 allocations
|
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
|
return { budget_data_id: null, allocations: value };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 字符串:可能是
|
|
|
|
|
|
// 1) 旧格式: "123"
|
|
|
|
|
|
// 2) 新格式: '{"budget_data_id":"2","allocations":[...]}'
|
|
|
|
|
|
// 3) 双重编码: '"{\"budget_data_id\":\"2\",\"allocations\":[...]}"'
|
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
|
const raw = value.trim();
|
|
|
|
|
|
if (!raw) return { budget_data_id: null, allocations: null };
|
|
|
|
|
|
|
|
|
|
|
|
const parseMaybeJson = (s) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return { ok: true, data: JSON.parse(s) };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, data: null };
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const p1 = parseMaybeJson(raw);
|
|
|
|
|
|
if (p1.ok) {
|
|
|
|
|
|
// 解析结果是对象:标准新格式
|
|
|
|
|
|
if (p1.data && typeof p1.data === "object" && !Array.isArray(p1.data)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
budget_data_id: p1.data?.budget_data_id ?? null,
|
|
|
|
|
|
allocations: p1.data?.allocations ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
// 解析结果是数组:当作 allocations(仍可能缺 budget_data_id)
|
|
|
|
|
|
if (Array.isArray(p1.data)) {
|
|
|
|
|
|
return { budget_data_id: null, allocations: p1.data };
|
|
|
|
|
|
}
|
|
|
|
|
|
// 解析结果是字符串:双重编码常见形态
|
|
|
|
|
|
if (typeof p1.data === "string") {
|
|
|
|
|
|
const inner = p1.data.trim();
|
|
|
|
|
|
// inner 可能是 '{...}' 或者 '2'
|
|
|
|
|
|
const p2 = parseMaybeJson(inner);
|
|
|
|
|
|
if (p2.ok && p2.data && typeof p2.data === "object" && !Array.isArray(p2.data)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
budget_data_id: p2.data?.budget_data_id ?? null,
|
|
|
|
|
|
allocations: p2.data?.allocations ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (p2.ok && Array.isArray(p2.data)) {
|
|
|
|
|
|
return { budget_data_id: null, allocations: p2.data };
|
|
|
|
|
|
}
|
|
|
|
|
|
// inner 可能是数字字符串
|
|
|
|
|
|
return {
|
|
|
|
|
|
budget_data_id: isNaN(inner) ? null : String(inner),
|
|
|
|
|
|
allocations: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 非JSON:当作旧格式 ID
|
|
|
|
|
|
return { budget_data_id: isNaN(raw) ? null : String(raw), allocations: null };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { budget_data_id: null, allocations: null };
|
|
|
|
|
|
},
|
|
|
|
|
|
budgetDataId() {
|
|
|
|
|
|
return this.parsedValue.budget_data_id;
|
|
|
|
|
|
},
|
|
|
|
|
|
allocations() {
|
|
|
|
|
|
return this.parsedValue.allocations;
|
|
|
|
|
|
},
|
|
|
|
|
|
normalizedValue() {
|
|
|
|
|
|
// 返回JSON格式的字符串(用于表单提交/兜底)
|
|
|
|
|
|
if (!this.budgetDataId) return "";
|
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
|
budget_data_id: this.budgetDataId,
|
|
|
|
|
|
allocations: this.allocations,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
hasValue() {
|
|
|
|
|
|
return this.budgetDataId !== null && this.budgetDataId !== "";
|
|
|
|
|
|
},
|
|
|
|
|
|
displayText() {
|
|
|
|
|
|
// 优先使用后端返回的 `{字段名}_display`
|
|
|
|
|
|
if (this.display && this.display.trim()) {
|
|
|
|
|
|
return this.display;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果display为空,但有值,尝试从JSON中解析并生成显示文本
|
|
|
|
|
|
if (this.hasValue && this.budgetDataId) {
|
|
|
|
|
|
// 如果value是JSON格式但display为空,显示budget_data_id作为fallback
|
|
|
|
|
|
// 注意:这里不显示原始JSON,而是显示解析出的ID
|
|
|
|
|
|
return `预算ID: ${this.budgetDataId}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果value存在但不是JSON格式(旧格式),也不显示原始值
|
|
|
|
|
|
// 避免显示原始JSON字符串
|
|
|
|
|
|
return "";
|
|
|
|
|
|
},
|
|
|
|
|
|
treeProps() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
children: "children",
|
|
|
|
|
|
label: "name",
|
|
|
|
|
|
disabled: (data) => !data?.is_leaf,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
departmentTree() {
|
|
|
|
|
|
return (this.treeData || []).filter((n) => n?.budget_type === "department");
|
|
|
|
|
|
},
|
|
|
|
|
|
projectTree() {
|
|
|
|
|
|
return (this.treeData || []).filter((n) => n?.budget_type === "project");
|
|
|
|
|
|
},
|
|
|
|
|
|
specialFundTree() {
|
|
|
|
|
|
return (this.treeData || []).filter((n) => n?.budget_type === "special_fund");
|
|
|
|
|
|
},
|
|
|
|
|
|
budgetTypeMap() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
department: "部门预算",
|
|
|
|
|
|
project: "自有账户",
|
|
|
|
|
|
special_fund: "专项资金",
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
// 分配总额
|
|
|
|
|
|
allocationTotal() {
|
|
|
|
|
|
return (this.pendingAllocations || []).reduce((sum, item) => {
|
|
|
|
|
|
return sum + (parseFloat(item.allocated_amount) || 0);
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
},
|
|
|
|
|
|
// 剩余金额
|
|
|
|
|
|
remainingAmount() {
|
|
|
|
|
|
return this.totalAmount - this.allocationTotal;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
methods: {
|
|
|
|
|
|
openDialog() {
|
|
|
|
|
|
this.dialogVisible = true;
|
|
|
|
|
|
},
|
|
|
|
|
|
onDialogOpen() {
|
|
|
|
|
|
this.pendingId = null;
|
|
|
|
|
|
this.ensureInit();
|
|
|
|
|
|
},
|
|
|
|
|
|
onDialogClose() {
|
|
|
|
|
|
this.pendingId = null;
|
|
|
|
|
|
},
|
|
|
|
|
|
async ensureInit() {
|
|
|
|
|
|
if (!this.yearOptions || this.yearOptions.length === 0) {
|
|
|
|
|
|
await this.loadYearOptions();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!this.yearId && this.yearOptions.length > 0) {
|
|
|
|
|
|
// 尝试根据当前 value 反查 year_id(可选),否则默认第一项
|
|
|
|
|
|
const fromValue = await this.tryResolveYearIdByValue();
|
|
|
|
|
|
this.yearId = fromValue || this.yearOptions[0].value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.yearId) {
|
|
|
|
|
|
await this.loadTree(this.yearId);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
async loadYearOptions() {
|
|
|
|
|
|
this.yearLoading = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getBudgetYearOptions(false);
|
|
|
|
|
|
const arr = Array.isArray(res) ? res : [];
|
|
|
|
|
|
this.yearOptions = arr.map((y) => ({
|
|
|
|
|
|
value: y.value,
|
|
|
|
|
|
label: y.label || (y.year ? `${y.year}年` : `${y.value}年`),
|
|
|
|
|
|
year: y.year,
|
|
|
|
|
|
}));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.yearLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
async tryResolveYearIdByValue() {
|
|
|
|
|
|
if (!this.hasValue || !this.budgetDataId) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await request({
|
|
|
|
|
|
method: "get",
|
|
|
|
|
|
url: `/api/budget/budget-data/${this.budgetDataId}`,
|
|
|
|
|
|
isLoading: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
const yearId = detail?.year_id ?? null;
|
|
|
|
|
|
return yearId;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
async getDepartmentAuthorizations(budgetDataId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await request({
|
|
|
|
|
|
method: "get",
|
|
|
|
|
|
url: `/api/budget/budget-data/${budgetDataId}/department-authorizations`,
|
|
|
|
|
|
isLoading: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
return Array.isArray(res) ? res : [];
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
async loadTree(yearId) {
|
|
|
|
|
|
if (!yearId) return;
|
|
|
|
|
|
this.treeLoading = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getBudgetDataTree({ year_id: yearId }, false);
|
|
|
|
|
|
this.treeData = Array.isArray(res) ? res : [];
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.treeLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
async onYearChange(val) {
|
|
|
|
|
|
this.pendingId = null;
|
|
|
|
|
|
this.treeData = [];
|
|
|
|
|
|
await this.loadTree(val);
|
|
|
|
|
|
},
|
|
|
|
|
|
async onNodeClick(node) {
|
|
|
|
|
|
if (!node || !node.is_leaf) return;
|
|
|
|
|
|
this.pendingId = node.id;
|
|
|
|
|
|
|
|
|
|
|
|
// 选择叶子节点时,检查部门授权并显示分配界面
|
|
|
|
|
|
const authorizations = await this.getDepartmentAuthorizations(node.id);
|
|
|
|
|
|
if (authorizations.length > 1) {
|
|
|
|
|
|
// 多部门:显示分配界面
|
|
|
|
|
|
this.pendingAuthorizations = authorizations;
|
|
|
|
|
|
// 恢复之前的分配方案(如果有)
|
|
|
|
|
|
const existingAllocations = this.allocations || [];
|
|
|
|
|
|
if (existingAllocations.length > 0) {
|
|
|
|
|
|
// 合并:保留已有的分配,新增的部门设为0
|
|
|
|
|
|
this.pendingAllocations = authorizations.map(auth => {
|
|
|
|
|
|
const existing = existingAllocations.find(
|
|
|
|
|
|
a => String(a.department_id) === String(auth.department_id)
|
|
|
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
|
|
|
department_id: auth.department_id,
|
|
|
|
|
|
department_name: auth.department?.name || '',
|
|
|
|
|
|
allocated_amount: existing ? (parseFloat(existing.allocated_amount) || 0) : 0
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 没有之前的分配方案,创建新的
|
|
|
|
|
|
this.pendingAllocations = authorizations.map(auth => ({
|
|
|
|
|
|
department_id: auth.department_id,
|
|
|
|
|
|
department_name: auth.department?.name || '',
|
|
|
|
|
|
allocated_amount: 0
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 单部门:清空分配界面
|
|
|
|
|
|
this.pendingAuthorizations = [];
|
|
|
|
|
|
this.pendingAllocations = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
findPathNames(tree, id, path = []) {
|
|
|
|
|
|
if (!Array.isArray(tree)) return null;
|
|
|
|
|
|
for (const n of tree) {
|
|
|
|
|
|
const nextPath = [...path, n];
|
|
|
|
|
|
if (String(n.id) === String(id)) return nextPath;
|
|
|
|
|
|
if (n.children && n.children.length) {
|
|
|
|
|
|
const found = this.findPathNames(n.children, id, nextPath);
|
|
|
|
|
|
if (found) return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
},
|
|
|
|
|
|
getYearLabel() {
|
|
|
|
|
|
const opt = (this.yearOptions || []).find(
|
|
|
|
|
|
(y) => String(y.value) === String(this.yearId)
|
|
|
|
|
|
);
|
|
|
|
|
|
return opt?.label || (this.yearId ? `${this.yearId}年` : "");
|
|
|
|
|
|
},
|
|
|
|
|
|
async confirmPick() {
|
|
|
|
|
|
if (!this.pendingId) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 验证多部门分配总额(如果totalAmount > 0)
|
|
|
|
|
|
if (this.pendingAuthorizations.length > 1 && this.totalAmount > 0) {
|
|
|
|
|
|
if (this.allocationTotal !== this.totalAmount) {
|
|
|
|
|
|
this.$message.warning(`分配总额必须等于支付总额 ${this.formatAmount(this.totalAmount)}`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取部门授权列表(用于确认)
|
|
|
|
|
|
const authorizations = await this.getDepartmentAuthorizations(this.pendingId);
|
|
|
|
|
|
|
|
|
|
|
|
let allocations = [];
|
|
|
|
|
|
if (authorizations.length === 1) {
|
|
|
|
|
|
// 单部门:自动创建分配方案
|
|
|
|
|
|
allocations = [{
|
|
|
|
|
|
department_id: authorizations[0].department_id,
|
|
|
|
|
|
department_name: authorizations[0].department?.name || '',
|
|
|
|
|
|
allocated_amount: 0 // 占位,实际金额在financialConfirm时确定
|
|
|
|
|
|
}];
|
|
|
|
|
|
} else if (authorizations.length > 1) {
|
|
|
|
|
|
// 多部门:使用已填写的分配方案
|
|
|
|
|
|
allocations = this.pendingAllocations || [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.doConfirmPick(allocations);
|
|
|
|
|
|
},
|
|
|
|
|
|
doConfirmPick(allocations) {
|
|
|
|
|
|
const nodes = this.findPathNames(this.treeData, this.pendingId) || [];
|
|
|
|
|
|
const names = nodes.map((n) => n?.name).filter(Boolean);
|
|
|
|
|
|
const leaf = nodes[nodes.length - 1];
|
|
|
|
|
|
const typeText = this.budgetTypeMap[leaf?.budget_type] || leaf?.budget_type || "";
|
|
|
|
|
|
const yearLabel = this.getYearLabel();
|
|
|
|
|
|
const display = [yearLabel, typeText, ...names].filter(Boolean).join(" / ");
|
|
|
|
|
|
|
|
|
|
|
|
// 关键:不要再把JSON对象当“字符串”提交给后端 json 字段,否则后端会再次 json_encode 导致双重编码
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
budget_data_id: String(this.pendingId),
|
|
|
|
|
|
allocations: allocations.length > 0 ? allocations : null,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 更新主值与展示值(展示值用于只读/回显)
|
|
|
|
|
|
this.$emit("input", payload);
|
|
|
|
|
|
this.$emit("update:display", display);
|
|
|
|
|
|
|
|
|
|
|
|
this.dialogVisible = false;
|
|
|
|
|
|
this.pendingId = null;
|
|
|
|
|
|
// 清空分配相关数据
|
|
|
|
|
|
this.pendingAuthorizations = [];
|
|
|
|
|
|
this.pendingAllocations = [];
|
|
|
|
|
|
},
|
|
|
|
|
|
onAllocationChange() {
|
|
|
|
|
|
// 分配金额变化时的处理(实时验证已在按钮disabled中处理)
|
|
|
|
|
|
},
|
|
|
|
|
|
onDialogClose() {
|
|
|
|
|
|
this.pendingId = null;
|
|
|
|
|
|
// 清空分配相关数据
|
|
|
|
|
|
this.pendingAuthorizations = [];
|
|
|
|
|
|
this.pendingAllocations = [];
|
|
|
|
|
|
},
|
|
|
|
|
|
formatAmount(amount) {
|
|
|
|
|
|
if (amount === null || amount === undefined) return "0.00";
|
|
|
|
|
|
const num = parseFloat(amount);
|
|
|
|
|
|
if (isNaN(num)) return "0.00";
|
|
|
|
|
|
return num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
|
|
},
|
|
|
|
|
|
formatExecutionRate(rate) {
|
|
|
|
|
|
if (rate === null || rate === undefined) return "0.00%";
|
|
|
|
|
|
const num = parseFloat(rate);
|
|
|
|
|
|
if (isNaN(num)) return "0.00%";
|
|
|
|
|
|
return num.toFixed(2) + "%";
|
|
|
|
|
|
},
|
|
|
|
|
|
getExecutionRateClass(rate) {
|
|
|
|
|
|
const num = parseFloat(rate);
|
|
|
|
|
|
if (isNaN(num)) return "";
|
|
|
|
|
|
if (num >= 100) return "rate-high";
|
|
|
|
|
|
if (num >= 80) return "rate-medium";
|
|
|
|
|
|
if (num >= 50) return "rate-normal";
|
|
|
|
|
|
return "rate-low";
|
|
|
|
|
|
},
|
|
|
|
|
|
clearValue() {
|
|
|
|
|
|
this.$emit("input", "");
|
|
|
|
|
|
this.$emit("update:display", "");
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.budget-source-field {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.budget-source-label {
|
|
|
|
|
|
color: #606266;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 160px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.budget-source-label--muted {
|
|
|
|
|
|
color: #909399;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tree-title {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: #606266;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
padding-bottom: 8px;
|
|
|
|
|
|
border-bottom: 1px solid #e4e7ed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.custom-tree-node {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
padding-right: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.node-label {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.node-usage-info {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
margin-left: 12px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-label {
|
|
|
|
|
|
color: #909399;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-value {
|
|
|
|
|
|
color: #606266;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-value--used {
|
|
|
|
|
|
color: #409eff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-value--rate {
|
|
|
|
|
|
min-width: 50px;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-value--rate.rate-high {
|
|
|
|
|
|
color: #f56c6c;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-value--rate.rate-medium {
|
|
|
|
|
|
color: #e6a23c;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-value--rate.rate-normal {
|
|
|
|
|
|
color: #409eff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-value--rate.rate-low {
|
|
|
|
|
|
color: #909399;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|