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

5 months ago
<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>
4 months ago
<span
v-else-if="hasValue && budgetDataId"
class="budget-source-label budget-source-label--muted"
:title="`预算ID: ${budgetDataId}`"
>
预算ID: {{ budgetDataId }}
</span>
5 months ago
<span
v-else-if="hasValue"
class="budget-source-label budget-source-label--muted"
:title="normalizedValue"
>
4 months ago
{{ normalizedValue }}
5 months ago
</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"
4 months ago
width="1300px"
5 months ago
: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>
4 months ago
<div style="display: flex; gap: 20px">
4 months ago
<!-- 左侧预算树 -->
4 months ago
<div v-loading="treeLoading" style="flex: 1; max-width: 780px; max-height: 500px; overflow: auto; min-width: 0">
5 months ago
<div v-if="departmentTree.length" class="tree-block">
<div class="tree-title">部门预算</div>
<el-tree
4 months ago
ref="departmentTreeRef"
5 months ago
:data="departmentTree"
node-key="id"
:props="treeProps"
highlight-current
default-expand-all
:expand-on-click-node="false"
4 months ago
show-checkbox
check-strictly
:check-on-click-node="false"
@check-change="onTreeCheckChange"
@node-click="onNodeClickToggle"
4 months ago
>
<span class="custom-tree-node" slot-scope="{ node, data }">
4 months ago
<span class="node-label" :class="{ 'node-label--clickable': data.is_leaf }" @click.stop="onLabelClick(data, node, $refs.departmentTreeRef)">{{ node.label }}</span>
4 months ago
<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>
5 months ago
</div>
<div v-if="projectTree.length" class="tree-block" style="margin-top: 16px">
<div class="tree-title">自有账户</div>
<el-tree
4 months ago
ref="projectTreeRef"
5 months ago
:data="projectTree"
node-key="id"
:props="treeProps"
highlight-current
default-expand-all
:expand-on-click-node="false"
4 months ago
show-checkbox
check-strictly
:check-on-click-node="false"
@check-change="onTreeCheckChange"
@node-click="onNodeClickToggle"
4 months ago
>
<span class="custom-tree-node" slot-scope="{ node, data }">
4 months ago
<span class="node-label" :class="{ 'node-label--clickable': data.is_leaf }" @click.stop="onLabelClick(data, node, $refs.projectTreeRef)">{{ node.label }}</span>
4 months ago
<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
4 months ago
ref="specialFundTreeRef"
4 months ago
:data="specialFundTree"
node-key="id"
:props="treeProps"
highlight-current
default-expand-all
:expand-on-click-node="false"
4 months ago
show-checkbox
check-strictly
:check-on-click-node="false"
@check-change="onTreeCheckChange"
@node-click="onNodeClickToggle"
4 months ago
>
<span class="custom-tree-node" slot-scope="{ node, data }">
4 months ago
<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>
4 months ago
<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>
5 months ago
</div>
4 months ago
<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>
5 months ago
<div
4 months ago
v-if="!treeLoading && departmentTree.length === 0 && projectTree.length === 0 && specialFundTree.length === 0 && lastYearCarryoverTree.length === 0 && offsetPrepaidTree.length === 0"
5 months ago
style="text-align: center; color: #909399; padding: 32px 0"
>
暂无数据
</div>
4 months ago
</div>
4 months ago
<!-- 右侧部门金额分配始终展示支持单预算/多部门与多预算 -->
<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">
4 months ago
部门金额分配
</div>
4 months ago
<div style="margin-bottom: 16px; color: #303133; font-size: 14px; line-height: 1.6">
请选择预算来源可多选并将本次付款金额分配到对应部门与预算分配金额可以为0但分配总额必须匹配本次付款金额
4 months ago
</div>
4 months ago
<el-table :data="pendingAllocations" border style="width: 100%">
<el-table-column prop="budget_name" label="预算" min-width="180"></el-table-column>
4 months ago
<el-table-column prop="department_name" label="部门" width="180"></el-table-column>
4 months ago
<el-table-column label="分配金额" width="200">
4 months ago
<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>
4 months ago
<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' }">
4 months ago
{{ formatAmount(allocationTotal) }}
</span>
</div>
4 months ago
<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>
4 months ago
</div>
<div v-if="totalAmount > 0" style="display: flex; justify-content: space-between; align-items: center">
4 months ago
<span style="font-size: 14px; color: #303133; font-weight: 500">剩余金额</span>
<span :style="{ color: remainingAmount >= 0 ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '16px' }">
4 months ago
{{ formatAmount(remainingAmount) }}
</span>
</div>
4 months ago
<div v-if="totalAmount === 0" style="margin-top: 12px; color: #606266; font-size: 13px; line-height: 1.6">
提示未识别到"本次付款金额"当前按 0 校验若需要校验请确保流程表单存在该字段
4 months ago
</div>
</div>
</div>
5 months ago
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
4 months ago
<el-button
type="primary"
4 months ago
:disabled="!canConfirm"
4 months ago
@click="confirmPick"
>
5 months ago
确定
</el-button>
</template>
</el-dialog>
4 months ago
5 months ago
</div>
</template>
<script>
import request from "@/utils/request";
import { getBudgetYearOptions, getBudgetDataTree } from "@/api/flow";
export default {
name: "BudgetSourcePickerField",
props: {
fieldName: { type: String, required: true },
4 months ago
value: { type: [String, Number, Object, Array], default: "" },
5 months ago
display: { type: String, default: "" },
},
data() {
return {
dialogVisible: false,
yearLoading: false,
treeLoading: false,
yearOptions: [],
yearId: null,
treeData: [],
4 months ago
// 选择相关(支持多选)
selectedBudgetIds: [],
lastValidBudgetIds: [],
selectionError: "",
// 授权缓存budget_data_id -> authorizations[]
authCache: {},
// 分配相关(右侧始终展示)
4 months ago
pendingAllocations: [],
4 months ago
// “本次付款金额(元)”——从流程自定义模型字段中按 label 模糊匹配“本次付款金额”解析
4 months ago
totalAmount: 0,
4 months ago
// 生成的显示文本(用于展示区)
generatedDisplayText: "",
5 months ago
};
},
computed: {
4 months ago
// 解析JSON格式的value兼容旧格式
parsedValue() {
const value = this.value;
if (!value || value === null || value === undefined) {
4 months ago
return { budget_data_id: null, allocations: null, budget_items: null };
4 months ago
}
// 已经是对象(推荐的新格式:直接对象落库/回传)
if (typeof value === "object" && !Array.isArray(value)) {
return {
budget_data_id: value?.budget_data_id ?? null,
allocations: value?.allocations ?? null,
4 months ago
budget_items: value?.budget_items ?? null,
4 months ago
};
}
// 兼容:如果是数组(极端旧数据/异常数据),当作 allocations
if (Array.isArray(value)) {
4 months ago
return { budget_data_id: null, allocations: value, budget_items: null };
4 months ago
}
// 字符串:可能是
// 1) 旧格式: "123"
// 2) 新格式: '{"budget_data_id":"2","allocations":[...]}'
// 3) 双重编码: '"{\"budget_data_id\":\"2\",\"allocations\":[...]}"'
if (typeof value === "string") {
const raw = value.trim();
4 months ago
if (!raw) return { budget_data_id: null, allocations: null, budget_items: null };
4 months ago
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,
4 months ago
budget_items: p1.data?.budget_items ?? null,
4 months ago
};
}
// 解析结果是数组:当作 allocations仍可能缺 budget_data_id
if (Array.isArray(p1.data)) {
4 months ago
return { budget_data_id: null, allocations: p1.data, budget_items: null };
4 months ago
}
// 解析结果是字符串:双重编码常见形态
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,
4 months ago
budget_items: p2.data?.budget_items ?? null,
4 months ago
};
}
if (p2.ok && Array.isArray(p2.data)) {
4 months ago
return { budget_data_id: null, allocations: p2.data, budget_items: null };
4 months ago
}
// inner 可能是数字字符串
return {
budget_data_id: isNaN(inner) ? null : String(inner),
allocations: null,
4 months ago
budget_items: null,
4 months ago
};
}
}
// 非JSON当作旧格式 ID
4 months ago
return { budget_data_id: isNaN(raw) ? null : String(raw), allocations: null, budget_items: null };
4 months ago
}
4 months ago
return { budget_data_id: null, allocations: null, budget_items: null };
4 months ago
},
budgetDataId() {
return this.parsedValue.budget_data_id;
},
allocations() {
return this.parsedValue.allocations;
},
4 months ago
budgetItems() {
const items = this.parsedValue.budget_items;
return Array.isArray(items) ? items : [];
},
5 months ago
normalizedValue() {
4 months ago
// 返回JSON格式的字符串用于表单提交/兜底)
4 months ago
if (this.budgetItems.length > 0) {
return JSON.stringify({ budget_items: this.budgetItems });
}
4 months ago
if (!this.budgetDataId) return "";
return JSON.stringify({
budget_data_id: this.budgetDataId,
allocations: this.allocations,
});
5 months ago
},
hasValue() {
4 months ago
return (this.budgetDataId !== null && this.budgetDataId !== "") || this.budgetItems.length > 0;
5 months ago
},
displayText() {
4 months ago
// 优先使用后端返回的 `{字段名}_display`
if (this.display && this.display.trim()) {
return this.display;
}
4 months ago
// 使用生成的显示文本
if (this.generatedDisplayText && this.generatedDisplayText.trim()) {
return this.generatedDisplayText;
4 months ago
}
4 months ago
// 如果都没有返回空字符串避免显示JSON或ID
4 months ago
return "";
5 months ago
},
treeProps() {
return {
children: "children",
label: "name",
disabled: (data) => !data?.is_leaf,
};
},
departmentTree() {
4 months ago
return (this.treeData || []).filter((n) => n?.budget_type === "department");
5 months ago
},
projectTree() {
return (this.treeData || []).filter((n) => n?.budget_type === "project");
},
4 months ago
specialFundTree() {
return (this.treeData || []).filter((n) => n?.budget_type === "special_fund");
},
4 months ago
lastYearCarryoverTree() {
return (this.treeData || []).filter((n) => n?.budget_type === "last_year_carryover");
},
4 months ago
offsetPrepaidTree() {
return (this.treeData || []).filter((n) => n?.budget_type === "offset_prepaid");
},
4 months ago
budgetTypeMap() {
return {
department: "部门预算",
project: "自有账户",
special_fund: "专项资金",
4 months ago
last_year_carryover: "上一年结转资金",
4 months ago
offset_prepaid: "抵消预付账款",
4 months ago
};
},
// 分配总额
allocationTotal() {
return (this.pendingAllocations || []).reduce((sum, item) => {
return sum + (parseFloat(item.allocated_amount) || 0);
}, 0);
},
// 剩余金额
remainingAmount() {
return this.totalAmount - this.allocationTotal;
},
4 months ago
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);
},
5 months ago
},
methods: {
openDialog() {
this.dialogVisible = true;
},
onDialogOpen() {
4 months ago
this.selectionError = "";
this.selectedBudgetIds = [];
4 months ago
// 读取“本次付款金额”(延迟查找,确保 DOM 已渲染)
this.$nextTick(() => {
// 第一次尝试
this.totalAmount = this.extractPaymentAmountFromForm();
// 如果没找到,再延迟一次尝试(可能 DOM 还在渲染中)
if (this.totalAmount === 0) {
setTimeout(() => {
this.totalAmount = this.extractPaymentAmountFromForm();
}, 100);
}
});
5 months ago
this.ensureInit();
},
onDialogClose() {
4 months ago
this.selectionError = "";
5 months ago
},
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() {
4 months ago
if (!this.hasValue) return null;
const idFromValue = this.budgetDataId || (this.budgetItems[0] && this.budgetItems[0].budget_data_id);
if (!idFromValue) return null;
5 months ago
try {
const detail = await request({
method: "get",
4 months ago
url: `/api/budget/budget-data/${idFromValue}`,
5 months ago
isLoading: false,
});
const yearId = detail?.year_id ?? null;
return yearId;
} catch (e) {
return null;
}
},
4 months ago
async getDepartmentAuthorizations(budgetDataId) {
4 months ago
const key = String(budgetDataId || "");
if (!key) return [];
if (this.authCache[key]) return this.authCache[key];
4 months ago
try {
const res = await request({
method: "get",
url: `/api/budget/budget-data/${budgetDataId}/department-authorizations`,
isLoading: false,
});
4 months ago
const list = Array.isArray(res) ? res : [];
this.$set(this.authCache, key, list);
return list;
4 months ago
} catch (e) {
4 months ago
this.$set(this.authCache, key, []);
4 months ago
return [];
}
},
5 months ago
async loadTree(yearId) {
if (!yearId) return;
this.treeLoading = true;
try {
const res = await getBudgetDataTree({ year_id: yearId }, false);
this.treeData = Array.isArray(res) ? res : [];
4 months ago
// 回显已有选择
this.$nextTick(async () => {
await this.restoreSelectionFromValue();
await this.rebuildAllocationsFromCurrentSelection();
// 树数据加载完成后,重新生成显示文本
if (this.hasValue && !this.display) {
await this.generateDisplayText();
}
});
5 months ago
} finally {
this.treeLoading = false;
}
},
async onYearChange(val) {
4 months ago
this.selectionError = "";
this.selectedBudgetIds = [];
this.pendingAllocations = [];
5 months ago
this.treeData = [];
await this.loadTree(val);
},
4 months ago
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,
4 months ago
this.$refs.offsetPrepaidTreeRef,
4 months ago
].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,
4 months ago
this.$refs.offsetPrepaidTreeRef,
4 months ago
].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 “本次付款金额”,解析金额(兼容单位后缀)
4 months ago
// 支持多种查找方式label、placeholder、字段名等
4 months ago
try {
4 months ago
// 方法1通过 label 查找
4 months ago
const labels = Array.from(document.querySelectorAll(".el-form-item__label"));
4 months ago
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;
4 months ago
} catch (e) {
4 months ago
console.warn("[BudgetSourcePickerField] 提取本次付款金额失败:", e);
4 months ago
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)}`;
4 months ago
return {
4 months ago
budget_data_id: node.id,
budget_name: node.name || "",
department_id: a.department_id,
department_name: a.department?.name || "",
allocated_amount: existingMap[key] ?? 0,
4 months ago
};
});
}
4 months ago
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;
4 months ago
}
4 months ago
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;
5 months ago
},
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}` : "");
},
4 months ago
async confirmPick() {
4 months ago
if (!this.canConfirm) {
this.$message.warning("请先选择预算来源,并确保分配总额等于本次付款金额");
return;
4 months ago
}
4 months ago
// 基于 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]),
};
4 months ago
}
5 months ago
4 months ago
// 生成展示文本(多条用 “;” 分隔)
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("");
4 months ago
this.$emit("input", payload);
5 months ago
this.$emit("update:display", display);
this.dialogVisible = false;
4 months ago
},
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";
5 months ago
},
clearValue() {
this.$emit("input", "");
this.$emit("update:display", "");
4 months ago
this.generatedDisplayText = "";
5 months ago
},
4 months ago
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();
}
5 months ago
},
};
</script>
<style scoped>
.budget-source-field {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.budget-source-label {
4 months ago
color: #303133;
5 months ago
font-size: 14px;
4 months ago
font-weight: 500;
5 months ago
flex: 1;
min-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.budget-source-label--muted {
4 months ago
color: #606266;
5 months ago
}
.tree-title {
4 months ago
font-size: 16px;
5 months ago
font-weight: 600;
4 months ago
color: #303133;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 2px solid #e4e7ed;
5 months ago
}
4 months ago
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
4 months ago
padding-right: 12px;
4 months ago
}
.node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
4 months ago
color: #303133;
font-weight: 500;
}
.node-label--clickable {
cursor: pointer;
transition: color 0.2s;
}
.node-label--clickable:hover {
color: #409eff;
4 months ago
}
.node-usage-info {
display: flex;
align-items: center;
4 months ago
gap: 16px;
margin-left: 16px;
4 months ago
flex-shrink: 0;
4 months ago
font-size: 14px;
4 months ago
}
.usage-item {
display: flex;
align-items: center;
4 months ago
gap: 6px;
4 months ago
}
.usage-label {
4 months ago
color: #606266;
font-size: 14px;
font-weight: 500;
4 months ago
}
.usage-value {
4 months ago
color: #303133;
font-weight: 600;
font-size: 14px;
4 months ago
}
.usage-value--used {
color: #409eff;
4 months ago
font-weight: 600;
4 months ago
}
.usage-value--rate {
4 months ago
min-width: 60px;
4 months ago
text-align: right;
}
.usage-value--rate.rate-high {
color: #f56c6c;
4 months ago
font-weight: 700;
4 months ago
}
.usage-value--rate.rate-medium {
color: #e6a23c;
4 months ago
font-weight: 600;
4 months ago
}
.usage-value--rate.rate-normal {
color: #409eff;
4 months ago
font-weight: 600;
4 months ago
}
.usage-value--rate.rate-low {
color: #909399;
4 months ago
font-weight: 500;
4 months ago
}
5 months ago
</style>