|
|
<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="1300px"
|
|
|
: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: 20px">
|
|
|
<!-- 左侧:预算树 -->
|
|
|
<div v-loading="treeLoading" style="flex: 1; max-width: 780px; max-height: 500px; overflow: auto; min-width: 0">
|
|
|
<div v-if="departmentTree.length" class="tree-block">
|
|
|
<div class="tree-title">部门预算</div>
|
|
|
<el-tree
|
|
|
ref="departmentTreeRef"
|
|
|
:data="departmentTree"
|
|
|
node-key="id"
|
|
|
:props="treeProps"
|
|
|
highlight-current
|
|
|
default-expand-all
|
|
|
:expand-on-click-node="false"
|
|
|
show-checkbox
|
|
|
check-strictly
|
|
|
:check-on-click-node="false"
|
|
|
@check-change="onTreeCheckChange"
|
|
|
@node-click="onNodeClickToggle"
|
|
|
>
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
<span class="node-label" :class="{ 'node-label--clickable': data.is_leaf }" @click.stop="onLabelClick(data, node, $refs.departmentTreeRef)">{{ 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
|
|
|
ref="projectTreeRef"
|
|
|
:data="projectTree"
|
|
|
node-key="id"
|
|
|
:props="treeProps"
|
|
|
highlight-current
|
|
|
default-expand-all
|
|
|
:expand-on-click-node="false"
|
|
|
show-checkbox
|
|
|
check-strictly
|
|
|
:check-on-click-node="false"
|
|
|
@check-change="onTreeCheckChange"
|
|
|
@node-click="onNodeClickToggle"
|
|
|
>
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
<span class="node-label" :class="{ 'node-label--clickable': data.is_leaf }" @click.stop="onLabelClick(data, node, $refs.projectTreeRef)">{{ 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
|
|
|
ref="specialFundTreeRef"
|
|
|
:data="specialFundTree"
|
|
|
node-key="id"
|
|
|
:props="treeProps"
|
|
|
highlight-current
|
|
|
default-expand-all
|
|
|
:expand-on-click-node="false"
|
|
|
show-checkbox
|
|
|
check-strictly
|
|
|
:check-on-click-node="false"
|
|
|
@check-change="onTreeCheckChange"
|
|
|
@node-click="onNodeClickToggle"
|
|
|
>
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
<span class="node-label" :class="{ 'node-label--clickable': data.is_leaf }" @click.stop="onLabelClick(data, node, $refs.specialFundTreeRef)">{{ 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="lastYearCarryoverTree.length" class="tree-block" style="margin-top: 16px">
|
|
|
<div class="tree-title">上一年结转资金</div>
|
|
|
<el-tree
|
|
|
ref="lastYearCarryoverTreeRef"
|
|
|
:data="lastYearCarryoverTree"
|
|
|
node-key="id"
|
|
|
:props="treeProps"
|
|
|
highlight-current
|
|
|
default-expand-all
|
|
|
:expand-on-click-node="false"
|
|
|
show-checkbox
|
|
|
check-strictly
|
|
|
:check-on-click-node="false"
|
|
|
@check-change="onTreeCheckChange"
|
|
|
@node-click="onNodeClickToggle"
|
|
|
>
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
<span class="node-label" :class="{ 'node-label--clickable': data.is_leaf }" @click.stop="onLabelClick(data, node, $refs.lastYearCarryoverTreeRef)">{{ 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="offsetPrepaidTree.length" class="tree-block" style="margin-top: 16px">
|
|
|
<div class="tree-title">抵消预付账款</div>
|
|
|
<el-tree
|
|
|
ref="offsetPrepaidTreeRef"
|
|
|
:data="offsetPrepaidTree"
|
|
|
node-key="id"
|
|
|
:props="treeProps"
|
|
|
highlight-current
|
|
|
default-expand-all
|
|
|
:expand-on-click-node="false"
|
|
|
show-checkbox
|
|
|
check-strictly
|
|
|
:check-on-click-node="false"
|
|
|
@check-change="onTreeCheckChange"
|
|
|
@node-click="onNodeClickToggle"
|
|
|
>
|
|
|
<span class="custom-tree-node" slot-scope="{ node, data }">
|
|
|
<span class="node-label" :class="{ 'node-label--clickable': data.is_leaf }" @click.stop="onLabelClick(data, node, $refs.offsetPrepaidTreeRef)">{{ 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 && lastYearCarryoverTree.length === 0 && offsetPrepaidTree.length === 0"
|
|
|
style="text-align: center; color: #909399; padding: 32px 0"
|
|
|
>
|
|
|
暂无数据
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 右侧:部门金额分配(始终展示,支持单预算/多部门与多预算) -->
|
|
|
<div style="flex: 0 0 480px; border-left: 1px solid #e4e7ed; padding-left: 20px">
|
|
|
<div style="margin-bottom: 16px; font-weight: 600; color: #303133; font-size: 16px">
|
|
|
部门金额分配
|
|
|
</div>
|
|
|
<div style="margin-bottom: 16px; color: #303133; font-size: 14px; line-height: 1.6">
|
|
|
请选择预算来源(可多选),并将本次付款金额分配到对应部门与预算(分配金额可以为0,但分配总额必须匹配本次付款金额)。
|
|
|
</div>
|
|
|
<el-table :data="pendingAllocations" border style="width: 100%">
|
|
|
<el-table-column prop="budget_name" label="预算" min-width="180"></el-table-column>
|
|
|
<el-table-column prop="department_name" label="部门" width="180"></el-table-column>
|
|
|
<el-table-column label="分配金额" width="200">
|
|
|
<template slot-scope="scope">
|
|
|
<el-input-number
|
|
|
v-model="scope.row.allocated_amount"
|
|
|
:min="0"
|
|
|
:precision="2"
|
|
|
:controls="false"
|
|
|
style="width: 100%"
|
|
|
@change="onAllocationChange"
|
|
|
></el-input-number>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
<div style="margin-top: 16px; padding: 16px; background-color: #f5f7fa; border-radius: 6px">
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
|
|
|
<span style="font-size: 14px; color: #303133; font-weight: 500">已分配总额:</span>
|
|
|
<span :style="{ color: allocationTotal === totalAmount ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '16px' }">
|
|
|
{{ formatAmount(allocationTotal) }}
|
|
|
</span>
|
|
|
</div>
|
|
|
<div v-if="totalAmount > 0" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
|
|
|
<span style="font-size: 14px; color: #303133; font-weight: 500">本次付款金额:</span>
|
|
|
<span style="font-weight: 600; font-size: 16px; color: #303133">{{ formatAmount(totalAmount) }}</span>
|
|
|
</div>
|
|
|
<div v-if="totalAmount > 0" style="display: flex; justify-content: space-between; align-items: center">
|
|
|
<span style="font-size: 14px; color: #303133; font-weight: 500">剩余金额:</span>
|
|
|
<span :style="{ color: remainingAmount >= 0 ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '16px' }">
|
|
|
{{ formatAmount(remainingAmount) }}
|
|
|
</span>
|
|
|
</div>
|
|
|
<div v-if="totalAmount === 0" style="margin-top: 12px; color: #606266; font-size: 13px; line-height: 1.6">
|
|
|
提示:未识别到"本次付款金额",当前按 0 校验;若需要校验,请确保流程表单存在该字段。
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<template #footer>
|
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|
|
<el-button
|
|
|
type="primary"
|
|
|
:disabled="!canConfirm"
|
|
|
@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: [],
|
|
|
// 选择相关(支持多选)
|
|
|
selectedBudgetIds: [],
|
|
|
lastValidBudgetIds: [],
|
|
|
selectionError: "",
|
|
|
// 授权缓存:budget_data_id -> authorizations[]
|
|
|
authCache: {},
|
|
|
// 分配相关(右侧始终展示)
|
|
|
pendingAllocations: [],
|
|
|
// “本次付款金额(元)”——从流程自定义模型字段中按 label 模糊匹配“本次付款金额”解析
|
|
|
totalAmount: 0,
|
|
|
// 生成的显示文本(用于展示区)
|
|
|
generatedDisplayText: "",
|
|
|
};
|
|
|
},
|
|
|
computed: {
|
|
|
// 解析JSON格式的value,兼容旧格式
|
|
|
parsedValue() {
|
|
|
const value = this.value;
|
|
|
if (!value || value === null || value === undefined) {
|
|
|
return { budget_data_id: null, allocations: null, budget_items: null };
|
|
|
}
|
|
|
|
|
|
// 已经是对象(推荐的新格式:直接对象落库/回传)
|
|
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
|
return {
|
|
|
budget_data_id: value?.budget_data_id ?? null,
|
|
|
allocations: value?.allocations ?? null,
|
|
|
budget_items: value?.budget_items ?? null,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 兼容:如果是数组(极端旧数据/异常数据),当作 allocations
|
|
|
if (Array.isArray(value)) {
|
|
|
return { budget_data_id: null, allocations: value, budget_items: null };
|
|
|
}
|
|
|
|
|
|
// 字符串:可能是
|
|
|
// 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, budget_items: 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,
|
|
|
budget_items: p1.data?.budget_items ?? null,
|
|
|
};
|
|
|
}
|
|
|
// 解析结果是数组:当作 allocations(仍可能缺 budget_data_id)
|
|
|
if (Array.isArray(p1.data)) {
|
|
|
return { budget_data_id: null, allocations: p1.data, budget_items: null };
|
|
|
}
|
|
|
// 解析结果是字符串:双重编码常见形态
|
|
|
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,
|
|
|
budget_items: p2.data?.budget_items ?? null,
|
|
|
};
|
|
|
}
|
|
|
if (p2.ok && Array.isArray(p2.data)) {
|
|
|
return { budget_data_id: null, allocations: p2.data, budget_items: null };
|
|
|
}
|
|
|
// inner 可能是数字字符串
|
|
|
return {
|
|
|
budget_data_id: isNaN(inner) ? null : String(inner),
|
|
|
allocations: null,
|
|
|
budget_items: null,
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 非JSON:当作旧格式 ID
|
|
|
return { budget_data_id: isNaN(raw) ? null : String(raw), allocations: null, budget_items: null };
|
|
|
}
|
|
|
|
|
|
return { budget_data_id: null, allocations: null, budget_items: null };
|
|
|
},
|
|
|
budgetDataId() {
|
|
|
return this.parsedValue.budget_data_id;
|
|
|
},
|
|
|
allocations() {
|
|
|
return this.parsedValue.allocations;
|
|
|
},
|
|
|
budgetItems() {
|
|
|
const items = this.parsedValue.budget_items;
|
|
|
return Array.isArray(items) ? items : [];
|
|
|
},
|
|
|
normalizedValue() {
|
|
|
// 返回JSON格式的字符串(用于表单提交/兜底)
|
|
|
if (this.budgetItems.length > 0) {
|
|
|
return JSON.stringify({ budget_items: this.budgetItems });
|
|
|
}
|
|
|
if (!this.budgetDataId) return "";
|
|
|
return JSON.stringify({
|
|
|
budget_data_id: this.budgetDataId,
|
|
|
allocations: this.allocations,
|
|
|
});
|
|
|
},
|
|
|
hasValue() {
|
|
|
return (this.budgetDataId !== null && this.budgetDataId !== "") || this.budgetItems.length > 0;
|
|
|
},
|
|
|
displayText() {
|
|
|
// 优先使用后端返回的 `{字段名}_display`
|
|
|
if (this.display && this.display.trim()) {
|
|
|
return this.display;
|
|
|
}
|
|
|
|
|
|
// 使用生成的显示文本
|
|
|
if (this.generatedDisplayText && this.generatedDisplayText.trim()) {
|
|
|
return this.generatedDisplayText;
|
|
|
}
|
|
|
|
|
|
// 如果都没有,返回空字符串(避免显示JSON或ID)
|
|
|
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");
|
|
|
},
|
|
|
lastYearCarryoverTree() {
|
|
|
return (this.treeData || []).filter((n) => n?.budget_type === "last_year_carryover");
|
|
|
},
|
|
|
offsetPrepaidTree() {
|
|
|
return (this.treeData || []).filter((n) => n?.budget_type === "offset_prepaid");
|
|
|
},
|
|
|
budgetTypeMap() {
|
|
|
return {
|
|
|
department: "部门预算",
|
|
|
project: "自有账户",
|
|
|
special_fund: "专项资金",
|
|
|
last_year_carryover: "上一年结转资金",
|
|
|
offset_prepaid: "抵消预付账款",
|
|
|
};
|
|
|
},
|
|
|
// 分配总额
|
|
|
allocationTotal() {
|
|
|
return (this.pendingAllocations || []).reduce((sum, item) => {
|
|
|
return sum + (parseFloat(item.allocated_amount) || 0);
|
|
|
}, 0);
|
|
|
},
|
|
|
// 剩余金额
|
|
|
remainingAmount() {
|
|
|
return this.totalAmount - this.allocationTotal;
|
|
|
},
|
|
|
canConfirm() {
|
|
|
// 必须已选择至少一个预算来源,且分配总额必须等于本次付款金额(未识别时 totalAmount=0,则要求分配总额也为0)
|
|
|
if (!this.selectedBudgetIds || this.selectedBudgetIds.length === 0) return false;
|
|
|
if (this.selectionError) return false;
|
|
|
return Number(this.allocationTotal) === Number(this.totalAmount);
|
|
|
},
|
|
|
},
|
|
|
methods: {
|
|
|
openDialog() {
|
|
|
this.dialogVisible = true;
|
|
|
},
|
|
|
onDialogOpen() {
|
|
|
this.selectionError = "";
|
|
|
this.selectedBudgetIds = [];
|
|
|
// 读取“本次付款金额”(延迟查找,确保 DOM 已渲染)
|
|
|
this.$nextTick(() => {
|
|
|
// 第一次尝试
|
|
|
this.totalAmount = this.extractPaymentAmountFromForm();
|
|
|
// 如果没找到,再延迟一次尝试(可能 DOM 还在渲染中)
|
|
|
if (this.totalAmount === 0) {
|
|
|
setTimeout(() => {
|
|
|
this.totalAmount = this.extractPaymentAmountFromForm();
|
|
|
}, 100);
|
|
|
}
|
|
|
});
|
|
|
this.ensureInit();
|
|
|
},
|
|
|
onDialogClose() {
|
|
|
this.selectionError = "";
|
|
|
},
|
|
|
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) return null;
|
|
|
const idFromValue = this.budgetDataId || (this.budgetItems[0] && this.budgetItems[0].budget_data_id);
|
|
|
if (!idFromValue) return null;
|
|
|
try {
|
|
|
const detail = await request({
|
|
|
method: "get",
|
|
|
url: `/api/budget/budget-data/${idFromValue}`,
|
|
|
isLoading: false,
|
|
|
});
|
|
|
const yearId = detail?.year_id ?? null;
|
|
|
return yearId;
|
|
|
} catch (e) {
|
|
|
return null;
|
|
|
}
|
|
|
},
|
|
|
async getDepartmentAuthorizations(budgetDataId) {
|
|
|
const key = String(budgetDataId || "");
|
|
|
if (!key) return [];
|
|
|
if (this.authCache[key]) return this.authCache[key];
|
|
|
try {
|
|
|
const res = await request({
|
|
|
method: "get",
|
|
|
url: `/api/budget/budget-data/${budgetDataId}/department-authorizations`,
|
|
|
isLoading: false,
|
|
|
});
|
|
|
const list = Array.isArray(res) ? res : [];
|
|
|
this.$set(this.authCache, key, list);
|
|
|
return list;
|
|
|
} catch (e) {
|
|
|
this.$set(this.authCache, key, []);
|
|
|
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 : [];
|
|
|
// 回显已有选择
|
|
|
this.$nextTick(async () => {
|
|
|
await this.restoreSelectionFromValue();
|
|
|
await this.rebuildAllocationsFromCurrentSelection();
|
|
|
// 树数据加载完成后,重新生成显示文本
|
|
|
if (this.hasValue && !this.display) {
|
|
|
await this.generateDisplayText();
|
|
|
}
|
|
|
});
|
|
|
} finally {
|
|
|
this.treeLoading = false;
|
|
|
}
|
|
|
},
|
|
|
async onYearChange(val) {
|
|
|
this.selectionError = "";
|
|
|
this.selectedBudgetIds = [];
|
|
|
this.pendingAllocations = [];
|
|
|
this.treeData = [];
|
|
|
await this.loadTree(val);
|
|
|
},
|
|
|
onNodeClickToggle(data, node, tree) {
|
|
|
if (!data || !data.is_leaf) return;
|
|
|
// 点击节点也切换勾选
|
|
|
if (tree && typeof tree.setChecked === "function") {
|
|
|
tree.setChecked(data.id, !node.checked, true);
|
|
|
}
|
|
|
},
|
|
|
onLabelClick(data, node, tree) {
|
|
|
// 点击节点名称文字时切换勾选(仅叶子节点)
|
|
|
if (!data || !data.is_leaf) return;
|
|
|
if (tree && typeof tree.setChecked === "function") {
|
|
|
const isChecked = tree.getCheckedKeys().includes(data.id);
|
|
|
tree.setChecked(data.id, !isChecked, true);
|
|
|
}
|
|
|
},
|
|
|
onTreeCheckChange() {
|
|
|
// 任意树勾选变化后统一重算
|
|
|
this.rebuildAllocationsFromCurrentSelection();
|
|
|
},
|
|
|
collectCheckedLeafNodes() {
|
|
|
const refs = [
|
|
|
this.$refs.departmentTreeRef,
|
|
|
this.$refs.projectTreeRef,
|
|
|
this.$refs.specialFundTreeRef,
|
|
|
this.$refs.lastYearCarryoverTreeRef,
|
|
|
this.$refs.offsetPrepaidTreeRef,
|
|
|
].filter(Boolean);
|
|
|
const nodes = [];
|
|
|
refs.forEach((tree) => {
|
|
|
if (typeof tree.getCheckedNodes === "function") {
|
|
|
const checked = tree.getCheckedNodes(true, false) || [];
|
|
|
checked.forEach((n) => {
|
|
|
if (n && n.is_leaf) nodes.push(n);
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
// 去重
|
|
|
const map = {};
|
|
|
nodes.forEach((n) => (map[String(n.id)] = n));
|
|
|
return Object.values(map);
|
|
|
},
|
|
|
setCheckedIds(ids) {
|
|
|
const list = (ids || []).map((x) => String(x));
|
|
|
const refs = [
|
|
|
this.$refs.departmentTreeRef,
|
|
|
this.$refs.projectTreeRef,
|
|
|
this.$refs.specialFundTreeRef,
|
|
|
this.$refs.lastYearCarryoverTreeRef,
|
|
|
this.$refs.offsetPrepaidTreeRef,
|
|
|
].filter(Boolean);
|
|
|
refs.forEach((tree) => {
|
|
|
if (typeof tree.setCheckedKeys === "function") {
|
|
|
tree.setCheckedKeys(list);
|
|
|
}
|
|
|
});
|
|
|
},
|
|
|
async restoreSelectionFromValue() {
|
|
|
// 按 value 回显勾选
|
|
|
let ids = [];
|
|
|
if (this.budgetItems.length > 0) {
|
|
|
ids = this.budgetItems.map((x) => String(x.budget_data_id)).filter(Boolean);
|
|
|
} else if (this.budgetDataId) {
|
|
|
ids = [String(this.budgetDataId)];
|
|
|
}
|
|
|
this.lastValidBudgetIds = ids;
|
|
|
this.selectedBudgetIds = ids;
|
|
|
this.setCheckedIds(ids);
|
|
|
},
|
|
|
extractPaymentAmountFromForm() {
|
|
|
// 从页面 DOM 中模糊匹配 label “本次付款金额”,解析金额(兼容单位后缀)
|
|
|
// 支持多种查找方式:label、placeholder、字段名等
|
|
|
try {
|
|
|
// 方法1:通过 label 查找
|
|
|
const labels = Array.from(document.querySelectorAll(".el-form-item__label"));
|
|
|
let labelEl = labels.find((el) => {
|
|
|
const text = (el.innerText || el.textContent || "").trim();
|
|
|
return text.includes("本次付款金额") || text.includes("付款金额");
|
|
|
});
|
|
|
|
|
|
if (labelEl) {
|
|
|
const formItem = labelEl.closest(".el-form-item");
|
|
|
if (formItem) {
|
|
|
const content = formItem.querySelector(".el-form-item__content");
|
|
|
if (content) {
|
|
|
// 优先查找各种类型的输入框(可编辑状态)
|
|
|
let input = content.querySelector("input") ||
|
|
|
content.querySelector("textarea") ||
|
|
|
content.querySelector(".el-input__inner") ||
|
|
|
content.querySelector("input[type='text']") ||
|
|
|
content.querySelector("input[type='number']");
|
|
|
|
|
|
if (input) {
|
|
|
const raw = String(input.value || input.getAttribute("value") || "");
|
|
|
const num = parseFloat(raw.replace(/,/g, "").replace(/[^\d.-]/g, ""));
|
|
|
if (!isNaN(num) && num > 0) {
|
|
|
return num;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 如果没找到输入框,查找展示元素(只读/view 状态)
|
|
|
// 只读字段通常渲染为 span 或其他展示元素
|
|
|
const displayEl = content.querySelector("span") ||
|
|
|
content.querySelector("div") ||
|
|
|
content;
|
|
|
|
|
|
if (displayEl) {
|
|
|
// 获取元素的文本内容(可能是直接文本或子元素的文本)
|
|
|
const raw = displayEl.innerText || displayEl.textContent || displayEl.getAttribute("value") || "";
|
|
|
const num = parseFloat(raw.replace(/,/g, "").replace(/[^\d.-]/g, ""));
|
|
|
if (!isNaN(num) && num > 0) {
|
|
|
return num;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 方法2:通过 data-field-name 属性查找(如果字段名包含"付款金额"相关关键词)
|
|
|
const formItems = Array.from(document.querySelectorAll(".el-form-item[data-field-name]"));
|
|
|
for (const formItem of formItems) {
|
|
|
const fieldName = formItem.getAttribute("data-field-name") || "";
|
|
|
const label = formItem.querySelector(".el-form-item__label");
|
|
|
const labelText = label ? (label.innerText || label.textContent || "").trim() : "";
|
|
|
|
|
|
if (fieldName.includes("付款金额") || fieldName.includes("payment_amount") ||
|
|
|
labelText.includes("本次付款金额") || labelText.includes("付款金额")) {
|
|
|
const content = formItem.querySelector(".el-form-item__content");
|
|
|
if (content) {
|
|
|
// 优先查找输入框(可编辑状态)
|
|
|
let input = content.querySelector("input") ||
|
|
|
content.querySelector("textarea") ||
|
|
|
content.querySelector(".el-input__inner") ||
|
|
|
content.querySelector("input[type='text']") ||
|
|
|
content.querySelector("input[type='number']");
|
|
|
|
|
|
if (input) {
|
|
|
const raw = String(input.value || input.getAttribute("value") || "");
|
|
|
const num = parseFloat(raw.replace(/,/g, "").replace(/[^\d.-]/g, ""));
|
|
|
if (!isNaN(num) && num > 0) {
|
|
|
return num;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 如果没找到输入框,查找展示元素(只读/view 状态)
|
|
|
const displayEl = content.querySelector("span") ||
|
|
|
content.querySelector("div") ||
|
|
|
content;
|
|
|
|
|
|
if (displayEl) {
|
|
|
const raw = displayEl.innerText || displayEl.textContent || displayEl.getAttribute("value") || "";
|
|
|
const num = parseFloat(raw.replace(/,/g, "").replace(/[^\d.-]/g, ""));
|
|
|
if (!isNaN(num) && num > 0) {
|
|
|
return num;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 方法3:通过 placeholder 查找(可编辑字段)
|
|
|
const inputs = Array.from(document.querySelectorAll("input, textarea, .el-input__inner"));
|
|
|
for (const input of inputs) {
|
|
|
const placeholder = input.getAttribute("placeholder") || "";
|
|
|
if (placeholder.includes("本次付款金额") || placeholder.includes("付款金额")) {
|
|
|
const raw = String(input.value || input.getAttribute("value") || "");
|
|
|
const num = parseFloat(raw.replace(/,/g, "").replace(/[^\d.-]/g, ""));
|
|
|
if (!isNaN(num) && num > 0) {
|
|
|
return num;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 方法4:直接查找所有包含金额数字的 span/div(只读展示字段)
|
|
|
// 查找所有 form-item,检查 label 是否包含"付款金额",然后提取内容
|
|
|
const allFormItems = Array.from(document.querySelectorAll(".el-form-item"));
|
|
|
for (const formItem of allFormItems) {
|
|
|
const label = formItem.querySelector(".el-form-item__label");
|
|
|
const labelText = label ? (label.innerText || label.textContent || "").trim() : "";
|
|
|
|
|
|
if (labelText.includes("本次付款金额") || labelText.includes("付款金额")) {
|
|
|
const content = formItem.querySelector(".el-form-item__content");
|
|
|
if (content) {
|
|
|
// 查找所有可能的展示元素
|
|
|
const displayEls = content.querySelectorAll("span, div, p");
|
|
|
for (const el of displayEls) {
|
|
|
const text = (el.innerText || el.textContent || "").trim();
|
|
|
// 如果文本包含数字(可能是金额)
|
|
|
if (text && /[\d.,]+/.test(text)) {
|
|
|
const num = parseFloat(text.replace(/,/g, "").replace(/[^\d.-]/g, ""));
|
|
|
if (!isNaN(num) && num > 0) {
|
|
|
return num;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
// 如果子元素没找到,直接取 content 的文本
|
|
|
const contentText = (content.innerText || content.textContent || "").trim();
|
|
|
if (contentText && /[\d.,]+/.test(contentText)) {
|
|
|
const num = parseFloat(contentText.replace(/,/g, "").replace(/[^\d.-]/g, ""));
|
|
|
if (!isNaN(num) && num > 0) {
|
|
|
return num;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
} catch (e) {
|
|
|
console.warn("[BudgetSourcePickerField] 提取本次付款金额失败:", e);
|
|
|
return 0;
|
|
|
}
|
|
|
},
|
|
|
getExistingAllocationMap() {
|
|
|
// 兼容旧/新结构:返回 { `${budgetId}:${deptId}`: amount }
|
|
|
const map = {};
|
|
|
// 新:budget_items
|
|
|
if (this.budgetItems.length > 0) {
|
|
|
this.budgetItems.forEach((item) => {
|
|
|
const bid = String(item.budget_data_id || "");
|
|
|
const allocs = Array.isArray(item.allocations) ? item.allocations : [];
|
|
|
allocs.forEach((a) => {
|
|
|
const did = String(a.department_id || "");
|
|
|
if (!bid || !did) return;
|
|
|
map[`${bid}:${did}`] = parseFloat(a.allocated_amount) || 0;
|
|
|
});
|
|
|
});
|
|
|
} else if (this.budgetDataId && Array.isArray(this.allocations)) {
|
|
|
const bid = String(this.budgetDataId);
|
|
|
this.allocations.forEach((a) => {
|
|
|
const did = String(a.department_id || "");
|
|
|
if (!did) return;
|
|
|
map[`${bid}:${did}`] = parseFloat(a.allocated_amount) || 0;
|
|
|
});
|
|
|
}
|
|
|
return map;
|
|
|
},
|
|
|
async rebuildAllocationsFromCurrentSelection() {
|
|
|
this.selectionError = "";
|
|
|
const selectedNodes = this.collectCheckedLeafNodes();
|
|
|
const ids = selectedNodes.map((n) => String(n.id));
|
|
|
this.selectedBudgetIds = ids;
|
|
|
if (ids.length === 0) {
|
|
|
this.pendingAllocations = [];
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 重新读取“本次付款金额”
|
|
|
this.totalAmount = this.extractPaymentAmountFromForm();
|
|
|
|
|
|
const existingMap = this.getExistingAllocationMap();
|
|
|
|
|
|
// 单条预算来源
|
|
|
if (selectedNodes.length === 1) {
|
|
|
const node = selectedNodes[0];
|
|
|
const auths = await this.getDepartmentAuthorizations(node.id);
|
|
|
if (!auths || auths.length === 0) {
|
|
|
this.selectionError = "该预算来源未配置部门授权";
|
|
|
this.$message.warning(this.selectionError);
|
|
|
this.setCheckedIds(this.lastValidBudgetIds);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (auths.length === 1) {
|
|
|
// 情形(1):单预算 + 单部门 -> 自动全额填充
|
|
|
const a = auths[0];
|
|
|
this.pendingAllocations = [
|
|
|
{
|
|
|
budget_data_id: node.id,
|
|
|
budget_name: node.name || "",
|
|
|
department_id: a.department_id,
|
|
|
department_name: a.department?.name || "",
|
|
|
allocated_amount: this.totalAmount,
|
|
|
},
|
|
|
];
|
|
|
} else {
|
|
|
// 情形(2):单预算 + 多部门 -> 与原逻辑一致
|
|
|
this.pendingAllocations = auths.map((a) => {
|
|
|
const key = `${String(node.id)}:${String(a.department_id)}`;
|
|
|
return {
|
|
|
budget_data_id: node.id,
|
|
|
budget_name: node.name || "",
|
|
|
department_id: a.department_id,
|
|
|
department_name: a.department?.name || "",
|
|
|
allocated_amount: existingMap[key] ?? 0,
|
|
|
};
|
|
|
});
|
|
|
}
|
|
|
|
|
|
this.lastValidBudgetIds = ids;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 多条预算来源:同一直接父级 + 每条唯一授权部门
|
|
|
const parentId = selectedNodes[0].parent_id ?? null;
|
|
|
const parentMismatch = selectedNodes.some((n) => (n.parent_id ?? null) !== parentId);
|
|
|
if (parentMismatch) {
|
|
|
this.selectionError = "多条预算来源必须同属一个直接父级";
|
|
|
this.$message.warning(this.selectionError);
|
|
|
this.setCheckedIds(this.lastValidBudgetIds);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const rows = [];
|
|
|
for (const node of selectedNodes) {
|
|
|
const auths = await this.getDepartmentAuthorizations(node.id);
|
|
|
if (!auths || auths.length !== 1) {
|
|
|
this.selectionError = "多条预算来源时,每条预算必须且仅能授权给一个部门";
|
|
|
this.$message.warning(this.selectionError);
|
|
|
this.setCheckedIds(this.lastValidBudgetIds);
|
|
|
return;
|
|
|
}
|
|
|
const a = auths[0];
|
|
|
const key = `${String(node.id)}:${String(a.department_id)}`;
|
|
|
rows.push({
|
|
|
budget_data_id: node.id,
|
|
|
budget_name: node.name || "",
|
|
|
department_id: a.department_id,
|
|
|
department_name: a.department?.name || "",
|
|
|
// 情形(3) 默认全为 0(由用户手动填充)
|
|
|
allocated_amount: existingMap[key] ?? 0,
|
|
|
});
|
|
|
}
|
|
|
this.pendingAllocations = rows;
|
|
|
this.lastValidBudgetIds = ids;
|
|
|
},
|
|
|
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.canConfirm) {
|
|
|
this.$message.warning("请先选择预算来源,并确保分配总额等于本次付款金额");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 基于 pendingAllocations 构建最终 payload(必须包含预算ID与部门ID,支持多条)
|
|
|
const byBudget = {};
|
|
|
(this.pendingAllocations || []).forEach((row) => {
|
|
|
const bid = String(row.budget_data_id || "");
|
|
|
const did = String(row.department_id || "");
|
|
|
if (!bid || !did) return;
|
|
|
if (!byBudget[bid]) byBudget[bid] = { budget_data_id: bid, allocations: [] };
|
|
|
byBudget[bid].allocations.push({
|
|
|
department_id: did,
|
|
|
department_name: row.department_name || "",
|
|
|
allocated_amount: parseFloat(row.allocated_amount) || 0,
|
|
|
});
|
|
|
});
|
|
|
|
|
|
const budgetIds = Object.keys(byBudget);
|
|
|
let payload;
|
|
|
if (budgetIds.length === 1) {
|
|
|
const bid = budgetIds[0];
|
|
|
payload = {
|
|
|
budget_data_id: bid,
|
|
|
allocations: byBudget[bid].allocations,
|
|
|
};
|
|
|
} else {
|
|
|
payload = {
|
|
|
budget_items: budgetIds.map((bid) => byBudget[bid]),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 生成展示文本(多条用 “;” 分隔)
|
|
|
const yearLabel = this.getYearLabel();
|
|
|
const displayParts = budgetIds.map((bid) => {
|
|
|
const nodes = this.findPathNames(this.treeData, bid) || [];
|
|
|
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 || "";
|
|
|
return [yearLabel, typeText, ...names].filter(Boolean).join(" / ");
|
|
|
});
|
|
|
const display = displayParts.filter(Boolean).join(";");
|
|
|
|
|
|
this.$emit("input", payload);
|
|
|
this.$emit("update:display", display);
|
|
|
this.dialogVisible = false;
|
|
|
},
|
|
|
onAllocationChange() {
|
|
|
// 分配金额变化时的处理(实时验证已在按钮disabled中处理)
|
|
|
},
|
|
|
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", "");
|
|
|
this.generatedDisplayText = "";
|
|
|
},
|
|
|
async generateDisplayText() {
|
|
|
// 如果没有值,清空显示文本
|
|
|
if (!this.hasValue) {
|
|
|
this.generatedDisplayText = "";
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 如果已经有后端返回的 display,不需要生成
|
|
|
if (this.display && this.display.trim()) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 需要加载树数据来生成显示文本
|
|
|
if (!this.yearOptions || this.yearOptions.length === 0) {
|
|
|
await this.loadYearOptions();
|
|
|
}
|
|
|
|
|
|
// 尝试获取年份ID
|
|
|
if (!this.yearId) {
|
|
|
const fromValue = await this.tryResolveYearIdByValue();
|
|
|
this.yearId = fromValue || (this.yearOptions.length > 0 ? this.yearOptions[0].value : null);
|
|
|
}
|
|
|
|
|
|
// 如果没有年份ID,无法生成显示文本
|
|
|
if (!this.yearId) {
|
|
|
this.generatedDisplayText = "";
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 加载树数据
|
|
|
if (!this.treeData || this.treeData.length === 0) {
|
|
|
try {
|
|
|
const res = await getBudgetDataTree({ year_id: this.yearId }, false);
|
|
|
this.treeData = Array.isArray(res) ? res : [];
|
|
|
} catch (e) {
|
|
|
console.error("加载树数据失败:", e);
|
|
|
this.generatedDisplayText = "";
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 生成显示文本
|
|
|
const parts = [];
|
|
|
const yearLabel = this.getYearLabel();
|
|
|
|
|
|
// 处理多预算来源的情况
|
|
|
if (this.budgetItems.length > 0) {
|
|
|
const displayParts = [];
|
|
|
for (const item of this.budgetItems) {
|
|
|
const budgetId = String(item.budget_data_id || "");
|
|
|
if (!budgetId) continue;
|
|
|
|
|
|
const nodes = this.findPathNames(this.treeData, budgetId) || [];
|
|
|
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 allocations = Array.isArray(item.allocations) ? item.allocations : [];
|
|
|
const deptNames = allocations
|
|
|
.map((a) => a.department_name)
|
|
|
.filter(Boolean)
|
|
|
.slice(0, 3); // 最多显示3个部门
|
|
|
|
|
|
let partText = [yearLabel, typeText, ...names].filter(Boolean).join(" / ");
|
|
|
if (deptNames.length > 0) {
|
|
|
if (deptNames.length === 1) {
|
|
|
partText += `(${deptNames[0]})`;
|
|
|
} else if (deptNames.length <= 3) {
|
|
|
partText += `(${deptNames.join("、")})`;
|
|
|
} else {
|
|
|
partText += `(${deptNames.slice(0, 3).join("、")}等${allocations.length}个部门)`;
|
|
|
}
|
|
|
}
|
|
|
displayParts.push(partText);
|
|
|
}
|
|
|
this.generatedDisplayText = displayParts.filter(Boolean).join(";");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 处理单预算来源的情况
|
|
|
if (this.budgetDataId) {
|
|
|
const nodes = this.findPathNames(this.treeData, this.budgetDataId) || [];
|
|
|
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 || "";
|
|
|
|
|
|
let displayText = [yearLabel, typeText, ...names].filter(Boolean).join(" / ");
|
|
|
|
|
|
// 获取部门信息
|
|
|
const allocations = Array.isArray(this.allocations) ? this.allocations : [];
|
|
|
if (allocations.length > 0) {
|
|
|
const deptNames = allocations
|
|
|
.map((a) => a.department_name)
|
|
|
.filter(Boolean)
|
|
|
.slice(0, 3); // 最多显示3个部门
|
|
|
|
|
|
if (deptNames.length === 1) {
|
|
|
displayText += `(${deptNames[0]})`;
|
|
|
} else if (deptNames.length <= 3) {
|
|
|
displayText += `(${deptNames.join("、")})`;
|
|
|
} else {
|
|
|
displayText += `(${deptNames.slice(0, 3).join("、")}等${allocations.length}个部门)`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
this.generatedDisplayText = displayText;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 如果都没有,清空显示文本
|
|
|
this.generatedDisplayText = "";
|
|
|
},
|
|
|
},
|
|
|
watch: {
|
|
|
value: {
|
|
|
immediate: true,
|
|
|
handler() {
|
|
|
// 当 value 变化时,重新生成显示文本
|
|
|
this.$nextTick(() => {
|
|
|
this.generateDisplayText();
|
|
|
});
|
|
|
},
|
|
|
},
|
|
|
display(newVal) {
|
|
|
// 如果后端返回了 display,清空生成的显示文本
|
|
|
if (newVal && newVal.trim()) {
|
|
|
this.generatedDisplayText = "";
|
|
|
}
|
|
|
},
|
|
|
},
|
|
|
mounted() {
|
|
|
// 组件挂载时,如果有值,生成显示文本
|
|
|
if (this.hasValue) {
|
|
|
this.generateDisplayText();
|
|
|
}
|
|
|
},
|
|
|
};
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
.budget-source-field {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
flex-wrap: wrap;
|
|
|
}
|
|
|
|
|
|
.budget-source-label {
|
|
|
color: #303133;
|
|
|
font-size: 14px;
|
|
|
font-weight: 500;
|
|
|
flex: 1;
|
|
|
min-width: 160px;
|
|
|
overflow: hidden;
|
|
|
text-overflow: ellipsis;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
.budget-source-label--muted {
|
|
|
color: #606266;
|
|
|
}
|
|
|
|
|
|
.tree-title {
|
|
|
font-size: 16px;
|
|
|
font-weight: 600;
|
|
|
color: #303133;
|
|
|
margin-bottom: 12px;
|
|
|
padding-bottom: 10px;
|
|
|
border-bottom: 2px solid #e4e7ed;
|
|
|
}
|
|
|
|
|
|
.custom-tree-node {
|
|
|
flex: 1;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
font-size: 14px;
|
|
|
padding-right: 12px;
|
|
|
}
|
|
|
|
|
|
.node-label {
|
|
|
flex: 1;
|
|
|
overflow: hidden;
|
|
|
text-overflow: ellipsis;
|
|
|
white-space: nowrap;
|
|
|
color: #303133;
|
|
|
font-weight: 500;
|
|
|
}
|
|
|
|
|
|
.node-label--clickable {
|
|
|
cursor: pointer;
|
|
|
transition: color 0.2s;
|
|
|
}
|
|
|
|
|
|
.node-label--clickable:hover {
|
|
|
color: #409eff;
|
|
|
}
|
|
|
|
|
|
.node-usage-info {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 16px;
|
|
|
margin-left: 16px;
|
|
|
flex-shrink: 0;
|
|
|
font-size: 14px;
|
|
|
}
|
|
|
|
|
|
.usage-item {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 6px;
|
|
|
}
|
|
|
|
|
|
.usage-label {
|
|
|
color: #606266;
|
|
|
font-size: 14px;
|
|
|
font-weight: 500;
|
|
|
}
|
|
|
|
|
|
.usage-value {
|
|
|
color: #303133;
|
|
|
font-weight: 600;
|
|
|
font-size: 14px;
|
|
|
}
|
|
|
|
|
|
.usage-value--used {
|
|
|
color: #409eff;
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
.usage-value--rate {
|
|
|
min-width: 60px;
|
|
|
text-align: right;
|
|
|
}
|
|
|
|
|
|
.usage-value--rate.rate-high {
|
|
|
color: #f56c6c;
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.usage-value--rate.rate-medium {
|
|
|
color: #e6a23c;
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
.usage-value--rate.rate-normal {
|
|
|
color: #409eff;
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
.usage-value--rate.rate-low {
|
|
|
color: #909399;
|
|
|
font-weight: 500;
|
|
|
}
|
|
|
</style>
|
|
|
|
|
|
|