You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cz-hjjc-oa/src/components/BudgetSourcePickerField.vue

1325 lines
48 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>