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

740 lines
24 KiB

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