diff --git a/rebuild.sh b/rebuild.sh
index 957041b..16b2131 100644
--- a/rebuild.sh
+++ b/rebuild.sh
@@ -117,3 +117,9 @@ echo ""
+
+
+
+
+
+
diff --git a/src/components/BudgetSourcePickerField.vue b/src/components/BudgetSourcePickerField.vue
index e53858a..87e9fab 100644
--- a/src/components/BudgetSourcePickerField.vue
+++ b/src/components/BudgetSourcePickerField.vue
@@ -494,8 +494,17 @@ export default {
onDialogOpen() {
this.selectionError = "";
this.selectedBudgetIds = [];
- // 读取“本次付款金额”
- this.totalAmount = this.extractPaymentAmountFromForm();
+ // 读取“本次付款金额”(延迟查找,确保 DOM 已渲染)
+ this.$nextTick(() => {
+ // 第一次尝试
+ this.totalAmount = this.extractPaymentAmountFromForm();
+ // 如果没找到,再延迟一次尝试(可能 DOM 还在渲染中)
+ if (this.totalAmount === 0) {
+ setTimeout(() => {
+ this.totalAmount = this.extractPaymentAmountFromForm();
+ }, 100);
+ }
+ });
this.ensureInit();
},
onDialogClose() {
@@ -658,20 +667,145 @@ export default {
},
extractPaymentAmountFromForm() {
// 从页面 DOM 中模糊匹配 label “本次付款金额”,解析金额(兼容单位后缀)
+ // 支持多种查找方式:label、placeholder、字段名等
try {
+ // 方法1:通过 label 查找
const labels = Array.from(document.querySelectorAll(".el-form-item__label"));
- const labelEl = labels.find((el) => (el.innerText || "").includes("本次付款金额"));
- if (!labelEl) return 0;
- const formItem = labelEl.closest(".el-form-item");
- const content = formItem ? formItem.querySelector(".el-form-item__content") : null;
- const input =
- (content && content.querySelector("input")) ||
- (content && content.querySelector("textarea")) ||
- null;
- const raw = input ? String(input.value || "") : "";
- const num = parseFloat(raw.replace(/,/g, "").replace(/[^\d.-]/g, ""));
- return isNaN(num) ? 0 : num;
+ 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;
}
},
diff --git a/src/components/ContractSignField.vue b/src/components/ContractSignField.vue
index fc9b37f..1977b80 100644
--- a/src/components/ContractSignField.vue
+++ b/src/components/ContractSignField.vue
@@ -38,55 +38,52 @@
-
+
+ CZHT
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -94,23 +91,26 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
-
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -152,13 +188,14 @@
-
-
-
-
-
-
-
+
+
+
@@ -170,39 +207,41 @@
-
+
-
-
-
-
-
-
+
+
+
-
+
-
+
-
+
-
+
@@ -231,10 +270,11 @@
付款计划
-
- 新增计划
-
-
+
+
+ 新增计划
+
+
@@ -267,7 +307,8 @@
移除
-
+
+
@@ -287,6 +328,7 @@ import {
getContractSettings,
getContractByFlowId,
createContract,
+ getBudgetYearOptions,
} from "@/api/flow";
import { userListNoAuth, departmentListNoAuth } from "@/api/common";
@@ -307,8 +349,14 @@ export default {
userList: [],
departmentList: [],
attachmentFileList: [],
+ budgetYearOptions: [],
+ fundSourceOptions: [],
+ purchaseCategoryOptions: [],
+ contractTypeOptions: [],
+ purchaseMethodOptions: [],
form: {
contract_no: "",
+ contract_no_without_prefix: "",
title: "",
main_content: "",
party_a: "",
@@ -323,11 +371,15 @@ export default {
perform_period: "",
pay_method: "",
fund_source: "",
+ fund_source_year_id: null,
+ fund_source_budget_data_id: null,
+ is_government_purchase: false,
tender_agent: "",
purchase_method: "",
perform_status: "",
is_accepted: false,
purchase_category: "",
+ purchase_category_id: null,
handler_admin_ids: "",
handler_admin_ids_array: [],
apply_handler_id: "",
@@ -339,11 +391,8 @@ export default {
remark: "",
attachment_id: null,
pay_plans: [],
- savedAmountTotal: null,
- },
- rules: {
- contract_no: [{ required: true, message: "请填写合同编号", trigger: "blur" }],
},
+ rules: {},
};
},
computed: {
@@ -374,34 +423,301 @@ export default {
},
},
watch: {
- 'form.amount_type'(newVal, oldVal) {
- if (newVal === 'open') {
- if (oldVal === 'fixed' && this.form.amount_total) {
- this.form.savedAmountTotal = this.form.amount_total;
- }
- this.form.amount_total = null;
- } else if (newVal === 'fixed') {
- if (this.form.savedAmountTotal !== null) {
- this.form.amount_total = this.form.savedAmountTotal;
- this.form.savedAmountTotal = null;
- }
+ 'form.fund_source_year_id'(newVal, oldVal) {
+ // 当预算年度变化时,自动加载项目经费来源选项
+ if (newVal) {
+ this.fetchFundSourceOptions(newVal);
+ } else {
+ this.fundSourceOptions = [];
+ this.form.fund_source_budget_data_id = null;
+ }
+ // 如果年度变化了,清空之前选择的经费来源
+ if (newVal !== oldVal) {
+ this.form.fund_source_budget_data_id = null;
+ }
+ },
+ // 监听多选下拉变化,触发校验
+ 'form.owner_department_ids_array'() {
+ if (this.$refs.formRef) {
+ this.$refs.formRef.validateField('owner_department_ids', () => {});
+ }
+ },
+ 'form.handler_admin_ids_array'() {
+ if (this.$refs.formRef) {
+ this.$refs.formRef.validateField('handler_admin_ids', () => {});
}
- }
+ },
+ 'form.apply_handler_id_array'() {
+ if (this.$refs.formRef) {
+ this.$refs.formRef.validateField('apply_handler_id', () => {});
+ }
+ },
+ 'form.pay_plans'() {
+ if (this.$refs.formRef) {
+ this.$refs.formRef.validateField('pay_plans', () => {});
+ }
+ },
+ // 监听金额类型变化,重新校验合同金额和金额说明
+ 'form.amount_type'() {
+ if (this.$refs.formRef) {
+ this.$refs.formRef.validateField('amount_total', () => {});
+ this.$refs.formRef.validateField('amount_description', () => {});
+ }
+ },
},
methods: {
openDialog() {
this.dialogVisible = true;
},
- handleAmountInputKeydown(event) {
- if (this.form.amount_type === 'open') {
- event.preventDefault();
- event.stopPropagation();
- return false;
+ // 判断是否为编辑模式(已有合同ID)
+ isEditMode() {
+ return this.hasValue && this.normalizedValue && parseInt(this.normalizedValue);
+ },
+ // 初始化校验规则
+ initRules() {
+ // 新建时:只校验到付款方式(包含付款方式)
+ const baseRules = {
+ contract_no: [{ required: true, message: "请填写合同编号", trigger: "blur" }],
+ title: [{ required: true, message: "请填写合同名称", trigger: "blur" }],
+ main_content: [{ required: true, message: "请填写合同主要内容", trigger: "blur" }],
+ party_a: [{ required: true, message: "请填写甲方", trigger: "blur" }],
+ party_b: [{ required: true, message: "请填写乙方", trigger: "blur" }],
+ amount_total: [
+ {
+ validator: (rule, value, callback) => {
+ // 开口合同时,合同金额可以为空
+ if (this.form.amount_type === 'open') {
+ callback();
+ } else if (value === null || value === undefined || value === '') {
+ callback(new Error('请填写合同金额'));
+ } else {
+ callback();
+ }
+ },
+ trigger: ['blur', 'change']
+ }
+ ],
+ amount_type: [{ required: true, message: "请选择金额类型", trigger: "change" }],
+ amount_description: [
+ {
+ validator: (rule, value, callback) => {
+ if (this.form.amount_type === 'open' && !value) {
+ callback(new Error('开口合同时,金额说明不能为空'));
+ } else {
+ callback();
+ }
+ },
+ trigger: 'blur'
+ }
+ ],
+ budget_amount: [{ required: true, message: "请填写项目预算金额", trigger: "blur" }],
+ contract_type_id: [{ required: true, message: "请选择合同类型", trigger: "change" }],
+ sign_date: [{ required: true, message: "请选择签订日期", trigger: "change" }],
+ apply_date: [{ required: true, message: "请选择申请日期", trigger: "change" }],
+ perform_period: [{ required: true, message: "请填写合同履行期", trigger: "blur" }],
+ pay_method: [{ required: true, message: "请填写付款方式", trigger: "blur" }],
+ };
+
+ // 新建时只校验到付款方式(包含付款方式)
+ if (!this.isEditMode()) {
+ this.rules = baseRules;
+ return;
+ }
+
+ // 编辑时全量校验(与合同编辑页面对齐)
+ this.rules = {
+ ...baseRules,
+ fund_source_year_id: [{ required: true, message: "请选择预算年度", trigger: "change" }],
+ fund_source_budget_data_id: [{ required: true, message: "请选择项目经费来源", trigger: "change" }],
+ is_government_purchase: [{ required: true, message: "请选择是否为政府采购", trigger: "change" }],
+ purchase_category_id: [{ required: true, message: "请选择采购类别", trigger: "change" }],
+ purchase_method_id: [{ required: true, message: "请选择采购方式", trigger: "change" }],
+ is_accepted: [{ required: true, message: "请选择是否验收", trigger: "change" }],
+ handler_admin_ids: [
+ {
+ validator: (rule, value, callback) => {
+ const ids = this.form.handler_admin_ids_array;
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
+ callback(new Error('请选择合同签订/变更经办人'));
+ } else {
+ callback();
+ }
+ },
+ trigger: ['change', 'blur']
+ }
+ ],
+ apply_handler_id: [
+ {
+ validator: (rule, value, callback) => {
+ const ids = this.form.apply_handler_id_array;
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
+ callback(new Error('请选择申请科室经办人'));
+ } else {
+ callback();
+ }
+ },
+ trigger: ['change', 'blur']
+ }
+ ],
+ owner_department_ids: [
+ {
+ validator: (rule, value, callback) => {
+ const ids = this.form.owner_department_ids_array;
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
+ callback(new Error('请选择科室'));
+ } else {
+ callback();
+ }
+ },
+ trigger: ['change', 'blur']
+ }
+ ],
+ purchase_handler_id: [{ required: true, message: "请选择采购科室经办人", trigger: "change" }],
+ pay_plans: [
+ {
+ validator: (rule, value, callback) => {
+ if (!value || !Array.isArray(value) || value.length === 0) {
+ callback(new Error('至少需要一条付款计划'));
+ return;
+ }
+ const errors = [];
+ value.forEach((plan, index) => {
+ const planErrors = [];
+ if (!plan.phase_no || plan.phase_no <= 0) {
+ planErrors.push('期次');
+ }
+ if (!plan.due_date) {
+ planErrors.push('到期日期');
+ }
+ if (!plan.amount_plan || plan.amount_plan <= 0) {
+ planErrors.push('金额');
+ }
+ if (planErrors.length > 0) {
+ errors.push(`第${index + 1}条付款计划的${planErrors.join('、')}不能为空`);
+ }
+ });
+ if (errors.length > 0) {
+ callback(new Error(errors[0]));
+ } else {
+ callback();
+ }
+ },
+ trigger: ['change', 'blur']
+ }
+ ]
+ };
+ },
+ handleFundSourceYearChange() {
+ this.form.fund_source_budget_data_id = null;
+ if (this.form.fund_source_year_id) {
+ this.fetchFundSourceOptions(this.form.fund_source_year_id);
+ }
+ },
+ async fetchBudgetYearOptions() {
+ try {
+ const response = await getBudgetYearOptions(false);
+ const arr = Array.isArray(response) ? response : (response?.data || []);
+ this.budgetYearOptions = arr.map((y) => ({
+ value: y.value,
+ label: y.label || (y.year ? `${y.year}年` : `${y.value}年`),
+ status: y.status, // 保留状态信息
+ }));
+ // 默认选择 ACTIVE 状态或最新的年度(与合同编辑页面保持一致)
+ // 注意:只在新建时默认选择,编辑时应该保持原有选择
+ if (this.budgetYearOptions.length > 0 && !this.form.fund_source_year_id && !this.isEditMode()) {
+ const activeYear = this.budgetYearOptions.find(y => y.status === 'ACTIVE');
+ if (activeYear) {
+ this.form.fund_source_year_id = activeYear.value;
+ await this.fetchFundSourceOptions(activeYear.value);
+ } else {
+ this.form.fund_source_year_id = this.budgetYearOptions[0].value;
+ await this.fetchFundSourceOptions(this.budgetYearOptions[0].value);
+ }
+ }
+ } catch (e) {
+ console.error('获取预算年度列表失败', e);
+ Message.error('获取预算年度列表失败:' + (e.message || '未知错误'));
+ this.budgetYearOptions = [];
}
},
- handleAmountInputFocus(event) {
- if (this.form.amount_type === 'open') {
- event.target.blur();
+ async fetchFundSourceOptions(yearId) {
+ if (!yearId) {
+ this.fundSourceOptions = [];
+ return;
+ }
+ try {
+ // 注意:响应拦截器已经返回了 res.data,所以 response 直接就是数据数组
+ const response = await request.get('/api/budget/contracts/fund-source-options', { params: { year_id: yearId } });
+ // 检查是否是数组格式(正常的响应)
+ if (Array.isArray(response)) {
+ this.fundSourceOptions = response;
+ } else if (response && response.code === 0 && Array.isArray(response.data)) {
+ // 兼容处理:如果响应拦截器没有处理,fallback 到原始格式
+ this.fundSourceOptions = response.data;
+ } else {
+ this.fundSourceOptions = [];
+ }
+ } catch (e) {
+ console.error('获取项目经费来源列表失败', e);
+ Message.error('获取项目经费来源列表失败:' + (e.message || '未知错误'));
+ this.fundSourceOptions = [];
+ }
+ },
+ async fetchPurchaseCategoryOptions() {
+ try {
+ // 注意:响应拦截器已经返回了 res.data,所以 response 直接就是数据
+ const response = await request.get('/api/budget/purchase-categories');
+ // 与合同编辑页面保持一致:只获取启用的采购类别
+ if (Array.isArray(response)) {
+ this.purchaseCategoryOptions = response;
+ } else if (response && response.code === 0 && Array.isArray(response.data)) {
+ // 兼容处理:如果响应拦截器没有处理,fallback 到原始格式
+ this.purchaseCategoryOptions = response.data;
+ } else {
+ this.purchaseCategoryOptions = [];
+ }
+ } catch (e) {
+ console.error('获取采购类别列表失败', e);
+ Message.error('获取采购类别列表失败:' + (e.message || '未知错误'));
+ this.purchaseCategoryOptions = [];
+ }
+ },
+ async fetchContractTypeOptions() {
+ try {
+ // 注意:响应拦截器已经返回了 res.data,所以 response 直接就是数据
+ // 与合同编辑页面保持一致:只获取启用的合同类型(使用 /api/budget/contract-types 而不是 /all)
+ const response = await request.get('/api/budget/contract-types');
+ if (Array.isArray(response)) {
+ this.contractTypeOptions = response;
+ } else if (response && response.code === 0 && Array.isArray(response.data)) {
+ // 兼容处理:如果响应拦截器没有处理,fallback 到原始格式
+ this.contractTypeOptions = response.data;
+ } else {
+ this.contractTypeOptions = [];
+ }
+ } catch (e) {
+ console.error('获取合同类型列表失败', e);
+ Message.error('获取合同类型列表失败:' + (e.message || '未知错误'));
+ this.contractTypeOptions = [];
+ }
+ },
+ async fetchPurchaseMethodOptions() {
+ try {
+ // 注意:响应拦截器已经返回了 res.data,所以 response 直接就是数据
+ // 与合同编辑页面保持一致:只获取启用的采购方式(使用 /api/budget/purchase-methods 而不是 /all)
+ const response = await request.get('/api/budget/purchase-methods');
+ if (Array.isArray(response)) {
+ this.purchaseMethodOptions = response;
+ } else if (response && response.code === 0 && Array.isArray(response.data)) {
+ // 兼容处理:如果响应拦截器没有处理,fallback 到原始格式
+ this.purchaseMethodOptions = response.data;
+ } else {
+ this.purchaseMethodOptions = [];
+ }
+ } catch (e) {
+ console.error('获取采购方式列表失败', e);
+ Message.error('获取采购方式列表失败:' + (e.message || '未知错误'));
+ this.purchaseMethodOptions = [];
}
},
beforeUpload(file) {
@@ -483,12 +799,20 @@ export default {
this.resetForm();
await this.loadUserList();
await this.loadDepartmentList();
+ await this.fetchBudgetYearOptions();
+ await this.fetchPurchaseCategoryOptions();
+ await this.fetchContractTypeOptions();
+ await this.fetchPurchaseMethodOptions();
+ // 初始化校验规则
+ this.initRules();
// 优先尝试加载已有合同(通过合同ID或flowId)
- const contractExists = await this.loadExistingContract();
+ const contractExists = await this.loadExistingContract();
// 如果找不到已有合同,且提供了flowId,则从流程数据预填
if (!contractExists && this.flowId) {
- this.loadFlowDataAndPrefill();
+ this.loadFlowDataAndPrefill();
}
+ // 重新初始化校验规则(因为可能已经加载了合同数据,需要区分新建/编辑)
+ this.initRules();
},
onDialogClose() {
// 关闭时不做处理
@@ -496,6 +820,7 @@ export default {
resetForm() {
this.form = {
contract_no: "",
+ contract_no_without_prefix: "",
title: "",
main_content: "",
party_a: "",
@@ -504,17 +829,23 @@ export default {
amount_type: "fixed",
amount_description: "",
budget_amount: 0,
- contract_type: "",
+ contract_type_id: null,
+ contract_type: "", // 保留旧字段,用于兼容
sign_date: "",
apply_date: "",
perform_period: "",
pay_method: "",
fund_source: "",
+ fund_source_year_id: null,
+ fund_source_budget_data_id: null,
+ is_government_purchase: false,
tender_agent: "",
- purchase_method: "",
+ purchase_method_id: null,
+ purchase_method: "", // 保留旧字段,用于兼容
perform_status: "",
is_accepted: false,
purchase_category: "",
+ purchase_category_id: null,
handler_admin_ids: "",
handler_admin_ids_array: [],
apply_handler_id: "",
@@ -526,9 +857,9 @@ export default {
remark: "",
attachment_id: null,
pay_plans: [],
- savedAmountTotal: null,
};
this.attachmentFileList = [];
+ this.fundSourceOptions = [];
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate();
}
@@ -562,6 +893,14 @@ export default {
}
if (contractData) {
+ // 处理合同编号:拆分CZHT前缀
+ if (contractData.contract_no) {
+ if (contractData.contract_no.startsWith('CZHT')) {
+ this.form.contract_no_without_prefix = contractData.contract_no.substring(4);
+ } else {
+ this.form.contract_no_without_prefix = contractData.contract_no;
+ }
+ }
this.form.contract_no = contractData.contract_no || "";
this.form.title = contractData.title || "";
this.form.main_content = contractData.main_content || "";
@@ -571,23 +910,36 @@ export default {
this.form.amount_type = contractData.amount_type || "fixed";
this.form.amount_description = contractData.amount_description || "";
this.form.budget_amount = contractData.budget_amount || 0;
- this.form.contract_type = contractData.contract_type || "";
+ // 优先使用ID字段,如果不存在则使用旧字段(兼容历史数据)
+ this.form.contract_type_id = contractData.contract_type_id || null;
+ this.form.contract_type = contractData.contract_type || ""; // 保留旧字段,用于兼容
this.form.sign_date = contractData.sign_date || "";
this.form.apply_date = contractData.apply_date || "";
this.form.perform_period = contractData.perform_period || "";
this.form.pay_method = contractData.pay_method || "";
this.form.fund_source = contractData.fund_source || "";
+ this.form.fund_source_year_id = contractData.fund_source_year_id || null;
+ this.form.fund_source_budget_data_id = contractData.fund_source_budget_data_id || null;
+ this.form.is_government_purchase = contractData.is_government_purchase || false;
this.form.tender_agent = contractData.tender_agent || "";
- this.form.purchase_method = contractData.purchase_method || "";
+ // 优先使用ID字段,如果不存在则使用旧字段(兼容历史数据)
+ this.form.purchase_method_id = contractData.purchase_method_id || null;
+ this.form.purchase_method = contractData.purchase_method || ""; // 保留旧字段,用于兼容
this.form.perform_status = contractData.perform_status || "";
this.form.is_accepted = contractData.is_accepted || false;
this.form.purchase_category = contractData.purchase_category || "";
+ this.form.purchase_category_id = contractData.purchase_category_id || null;
this.form.apply_handler_id = contractData.apply_handler_id || "";
this.form.purchase_handler_id = contractData.purchase_handler_id || null;
this.form.owner_department_id = contractData.owner_department_id || null;
this.form.remark = contractData.remark || "";
this.form.attachment_id = contractData.attachment_id || null;
+ // 如果设置了预算年度和经费来源,需要加载经费来源选项
+ if (this.form.fund_source_year_id) {
+ await this.fetchFundSourceOptions(this.form.fund_source_year_id);
+ }
+
// 处理科室多选:转换为字符串数组(因为下拉组件的value是字符串)
if (contractData.owner_department_ids) {
const ids = typeof contractData.owner_department_ids === 'string'
@@ -764,11 +1116,25 @@ export default {
this.saving = true;
try {
+ // 处理合同编号:拼接CZHT前缀
+ let contractNo = this.form.contract_no_without_prefix || '';
+ if (contractNo && !contractNo.startsWith('CZHT')) {
+ contractNo = 'CZHT' + contractNo;
+ }
+
// 处理科室多选:将数组转换为逗号分隔的字符串
const ownerDepartmentIds = Array.isArray(this.form.owner_department_ids_array)
? this.form.owner_department_ids_array.join(',')
: (this.form.owner_department_ids || '');
+ // 如果有科室多选,取第一个作为 owner_department_id(用于兼容)
+ let ownerDepartmentId = null;
+ if (Array.isArray(this.form.owner_department_ids_array) && this.form.owner_department_ids_array.length > 0) {
+ ownerDepartmentId = parseInt(this.form.owner_department_ids_array[0]);
+ } else if (this.form.owner_department_id) {
+ ownerDepartmentId = this.form.owner_department_id;
+ }
+
// 处理经办人多选:将数组转换为逗号分隔的字符串
const handlerAdminIds = Array.isArray(this.form.handler_admin_ids_array)
? this.form.handler_admin_ids_array.join(',')
@@ -778,9 +1144,6 @@ export default {
const applyHandlerIds = Array.isArray(this.form.apply_handler_id_array)
? this.form.apply_handler_id_array.filter(id => id).join(',')
: (this.form.apply_handler_id || '');
-
- // 处理金额:开口合同时,amount_total 应该为 null
- const amountTotal = this.form.amount_type === 'open' ? null : (this.form.amount_total || 0);
// 如果已有合同ID,传递合同ID用于更新
const contractId = this.hasValue && this.normalizedValue ? parseInt(this.normalizedValue) : null;
@@ -789,30 +1152,36 @@ export default {
contract_id: contractId, // 如果提供,则更新该合同
flow_id: this.flowId ? parseInt(this.flowId) : null,
contract: {
- contract_no: this.form.contract_no,
+ contract_no: contractNo,
title: this.form.title,
main_content: this.form.main_content || "",
party_a: this.form.party_a || "",
party_b: this.form.party_b || "",
- amount_total: amountTotal,
+ amount_total: this.form.amount_total || 0,
amount_type: this.form.amount_type,
amount_description: this.form.amount_description || "",
budget_amount: this.form.budget_amount || 0,
- contract_type: this.form.contract_type || "",
+ contract_type_id: this.form.contract_type_id,
+ contract_type: this.form.contract_type || "", // 保留旧字段,用于兼容
sign_date: this.form.sign_date || null,
apply_date: this.form.apply_date || null,
perform_period: this.form.perform_period || "",
pay_method: this.form.pay_method || "",
fund_source: this.form.fund_source || "",
+ fund_source_year_id: this.form.fund_source_year_id,
+ fund_source_budget_data_id: this.form.fund_source_budget_data_id,
+ is_government_purchase: this.form.is_government_purchase || false,
+ purchase_category_id: this.form.purchase_category_id,
tender_agent: this.form.tender_agent || "",
- purchase_method: this.form.purchase_method || "",
+ purchase_method_id: this.form.purchase_method_id,
+ purchase_method: this.form.purchase_method || "", // 保留旧字段,用于兼容
perform_status: this.form.perform_status || "",
is_accepted: this.form.is_accepted || false,
purchase_category: this.form.purchase_category || "",
handler_admin_ids: handlerAdminIds,
apply_handler_id: applyHandlerIds,
purchase_handler_id: this.form.purchase_handler_id,
- owner_department_id: this.form.owner_department_id,
+ owner_department_id: ownerDepartmentId,
owner_department_ids: ownerDepartmentIds,
remark: this.form.remark || "",
attachment_id: this.form.attachment_id,
diff --git a/src/utils/formBuilder.js b/src/utils/formBuilder.js
index 665364a..33c7b00 100644
--- a/src/utils/formBuilder.js
+++ b/src/utils/formBuilder.js
@@ -1444,15 +1444,43 @@ export default function formBuilder(
) {
isJointly = !!log.is_jointly_sign;
if (log.status > 0 && log.user) {
- // 对于 budget-source 类型字段,如果值是 JSON 格式,不显示原始 JSON
+ // 对于 budget-source 类型字段,优先显示后端返回的 _display 值
let displayValue = value.value;
- if (info.type === 'budget-source' && displayValue) {
- const strValue = String(displayValue);
- // 如果是 JSON 字符串,不显示
- if (strValue.trim().startsWith('{') || strValue.trim().startsWith('[')) {
- displayValue = ''; // 不显示原始 JSON
- } else if (typeof displayValue === 'object' || Array.isArray(displayValue)) {
- displayValue = ''; // 不显示对象或数组
+ if (info.type === 'budget-source') {
+ // 优先使用主表字段的 _display(与主表字段展示一致)
+ const displayFieldName = info.name + '_display';
+ const mainDisplayValue = target[displayFieldName] || '';
+ if (mainDisplayValue) {
+ displayValue = mainDisplayValue;
+ } else if (displayValue) {
+ // 如果没有 _display,尝试识别并屏蔽 JSON(包括双重编码)
+ const strValue = String(displayValue);
+ // 尝试解析 JSON(可能是一层或两层编码)
+ let isJson = false;
+ try {
+ const parsed1 = JSON.parse(strValue);
+ if (typeof parsed1 === 'object' || Array.isArray(parsed1)) {
+ isJson = true;
+ } else if (typeof parsed1 === 'string') {
+ // 双重编码:第一层解析出来是字符串,再试一次
+ try {
+ const parsed2 = JSON.parse(parsed1);
+ if (typeof parsed2 === 'object' || Array.isArray(parsed2)) {
+ isJson = true;
+ }
+ } catch (e2) {
+ // 不是双重编码的 JSON
+ }
+ }
+ } catch (e) {
+ // 不是 JSON 字符串
+ }
+ // 如果是 JSON(包括双重编码),不显示
+ if (isJson || strValue.trim().startsWith('{') || strValue.trim().startsWith('[')) {
+ displayValue = ''; // 不显示原始 JSON
+ } else if (typeof displayValue === 'object' || Array.isArray(displayValue)) {
+ displayValue = ''; // 不显示对象或数组
+ }
}
}
jointlySignContent.push(
@@ -2668,15 +2696,43 @@ export default function formBuilder(
) {
isJointly = !!log.is_jointly_sign;
if (log.status > 0 && log.user) {
- // 对于 budget-source 类型字段,如果值是 JSON 格式,不显示原始 JSON
+ // 对于 budget-source 类型字段,优先显示后端返回的 _display 值
let displayValue = value.value;
- if (info.type === 'budget-source' && displayValue) {
- const strValue = String(displayValue);
- // 如果是 JSON 字符串,不显示
- if (strValue.trim().startsWith('{') || strValue.trim().startsWith('[')) {
- displayValue = ''; // 不显示原始 JSON
- } else if (typeof displayValue === 'object' || Array.isArray(displayValue)) {
- displayValue = ''; // 不显示对象或数组
+ if (info.type === 'budget-source') {
+ // 优先使用主表字段的 _display(与主表字段展示一致)
+ const displayFieldName = info.name + '_display';
+ const mainDisplayValue = target[displayFieldName] || '';
+ if (mainDisplayValue) {
+ displayValue = mainDisplayValue;
+ } else if (displayValue) {
+ // 如果没有 _display,尝试识别并屏蔽 JSON(包括双重编码)
+ const strValue = String(displayValue);
+ // 尝试解析 JSON(可能是一层或两层编码)
+ let isJson = false;
+ try {
+ const parsed1 = JSON.parse(strValue);
+ if (typeof parsed1 === 'object' || Array.isArray(parsed1)) {
+ isJson = true;
+ } else if (typeof parsed1 === 'string') {
+ // 双重编码:第一层解析出来是字符串,再试一次
+ try {
+ const parsed2 = JSON.parse(parsed1);
+ if (typeof parsed2 === 'object' || Array.isArray(parsed2)) {
+ isJson = true;
+ }
+ } catch (e2) {
+ // 不是双重编码的 JSON
+ }
+ }
+ } catch (e) {
+ // 不是 JSON 字符串
+ }
+ // 如果是 JSON(包括双重编码),不显示
+ if (isJson || strValue.trim().startsWith('{') || strValue.trim().startsWith('[')) {
+ displayValue = ''; // 不显示原始 JSON
+ } else if (typeof displayValue === 'object' || Array.isArray(displayValue)) {
+ displayValue = ''; // 不显示对象或数组
+ }
}
}
jointlySignContent.push(
diff --git a/src/utils/request.js b/src/utils/request.js
index 8b9f8ef..efbe920 100644
--- a/src/utils/request.js
+++ b/src/utils/request.js
@@ -91,7 +91,7 @@ service.interceptors.response.use(
}
})
}
- if (top) {
+ if (top && typeof top._addError === 'function') {
top._addError(res.msg, `axios-response ${response.config?.url}`, response.config?.params || response.config?.data)
}
return Promise.reject(new Error(res.msg || 'Error'))
@@ -107,8 +107,8 @@ service.interceptors.response.use(
type: 'error',
duration: 5 * 1000
})
- if (top) {
- top._addError(error.toString(), `axios-request ${error.config.url}`, error.config.params || error.config.data)
+ if (top && typeof top._addError === 'function') {
+ top._addError(error.toString(), `axios-request ${error.config?.url}`, error.config?.params || error.config?.data)
}
return Promise.reject(error)
}
diff --git a/src/views/flow/create.vue b/src/views/flow/create.vue
index d86870b..0e94cfe 100644
--- a/src/views/flow/create.vue
+++ b/src/views/flow/create.vue
@@ -2173,7 +2173,8 @@ export default {
this.form[key] = jsonObj;
}
} catch (err) {
- if (this.form.hasOwnProperty(key)) {
+ // 允许所有字段被赋值,包括 _display 字段
+ if (this.form.hasOwnProperty(key) || key.endsWith('_display')) {
if (data[key] instanceof Array) {
if (data[key].length > 0) {
this.form[key] = data[key];
diff --git a/自定义字段渲染整理.md b/自定义字段渲染整理.md
new file mode 100644
index 0000000..ec061bc
--- /dev/null
+++ b/自定义字段渲染整理.md
@@ -0,0 +1,1159 @@
+# 流程详情页面自定义字段渲染整理
+
+## 📋 概述
+
+本文档整理了 `/oa/#/flow/detail?module_id=72&flow_id=13469` 页面上关于自定义字段的渲染机制。
+
+## 🏗️ 组件架构
+
+### 1. 页面组件结构
+
+```
+detailCommon.vue (主页面组件)
+ └── DesktopForm.vue (桌面端表单组件)
+ └── formBuilder.js (字段渲染构建器)
+```
+
+### 2. 核心文件位置
+
+- **主页面**: `cz-hjjc-oa/src/views/flow/detailCommon.vue`
+- **表单组件**: `cz-hjjc-oa/src/views/flow/DesktopForm.vue`
+- **字段构建器**: `cz-hjjc-oa/src/utils/formBuilder.js`
+
+## 🔄 渲染流程
+
+### 1. 数据获取 (`detailCommon.vue`)
+
+```javascript
+// getConfig() 方法获取流程配置
+async getConfig() {
+ // 详情页面:获取流程详情
+ if (/\/detail/.test(this.$route.path) && this.$route.query.flow_id) {
+ const res = await view(this.$route.query.flow_id);
+ // 获取字段配置
+ const { fields } = res?.customModel;
+ // 获取子表单配置
+ fields.forEach((field) => {
+ if (field.sub_custom_model_id) {
+ getSubForm(field.sub_custom_model_id);
+ }
+ });
+ // 设置字段权限
+ this.readableFields = this.fields?.map((i) => i.id);
+ this.writeableFields = this.config?.currentNode?.writeable || [];
+ }
+}
+```
+
+### 2. 字段权限处理 (`DesktopForm.vue`)
+
+```364:385:cz-hjjc-oa/src/views/flow/DesktopForm.vue
+ render(h) {
+ const authFields = this.fields.map((field) => ({
+ ...field,
+ _readable:
+ this.readable.indexOf(field.id) !== -1 || field.type === "label",
+ _writeable: this.writeable.indexOf(field.id) !== -1,
+ }));
+ console.log("authFields",authFields)
+ if (this.needFlowTitle) {
+ authFields.unshift({
+ name: "flow_title",
+ label: "工作名称",
+ type: "text",
+ gs_x: 0,
+ gs_y: 0,
+ gs_width: 12,
+ gs_height: 1,
+ label_show: 1,
+ _readable: !this.isFirstNode,
+ _writeable: this.isFirstNode,
+ });
+ }
+```
+
+### 3. 字段渲染 (`formBuilder.js`)
+
+```90:114:cz-hjjc-oa/src/utils/formBuilder.js
+export default function formBuilder(
+ device,
+ info,
+ h,
+ row,
+ pWrite = false,
+ pReadable = false,
+ pname
+) {
+ let target = row ? row : this.form;
+ let formItem;
+ //下拉选项
+ let options = [];
+ if (info?.selection_model) {
+ options = info.selection_model_items || [];
+ } else if (info?.stub) {
+ options = info?.stub?.split(/\r\n/) || [];
+ }
+ if (device === "desktop") {
+ // 可写并且不为查看和子表单下
+ if (
+ info._writeable ||
+ (info.type === "relation" && info._readable) ||
+ pWrite
+ ) {
+```
+
+## 📝 支持的字段类型
+
+### 1. 基础字段类型
+
+#### text (文本输入)
+- **可写**: `el-input` 组件
+- **只读**: 显示文本值
+
+```116:132:cz-hjjc-oa/src/utils/formBuilder.js
+ case "text":
+ formItem = h("el-input", {
+ props: {
+ value: target[info.name],
+ clearable: true,
+ placeholder: info.help_text,
+ },
+ attrs: {
+ placeholder: info.help_text,
+ },
+ on: {
+ input: (e) => {
+ this.$set(target, info.name, e);
+ },
+ },
+ });
+ break;
+```
+
+#### textarea (多行文本)
+- **可写**: `el-input` type="textarea"
+- **只读**: 显示文本值
+
+```133:153:cz-hjjc-oa/src/utils/formBuilder.js
+ case "textarea":
+ formItem = h("el-input", {
+ props: {
+ type: "textarea",
+ autosize: {
+ minRows: 2,
+ },
+ value: target[info.name],
+ clearable: true,
+ placeholder: info.help_text,
+ },
+ attrs: {
+ placeholder: info.help_text,
+ },
+ on: {
+ input: (e) => {
+ this.$set(target, info.name, e);
+ },
+ },
+ });
+ break;
+```
+
+#### date (日期选择)
+- **可写**: `el-date-picker` type="date"
+- **只读**: 格式化显示日期 (YYYY年MM月DD日)
+
+```154:180:cz-hjjc-oa/src/utils/formBuilder.js
+ case "date":
+ formItem = h("el-date-picker", {
+ props: {
+ type: "date",
+ "value-format": "yyyy-MM-dd",
+ format: "yyyy年MM月dd日",
+ value: target[info.name],
+ clearable: true,
+ editable:false,
+ placeholder: info.help_text,
+ "picker-options": {
+ shortcuts: this.shortcuts,
+ },
+ },
+ attrs: {
+ placeholder: info.help_text,
+ },
+ style: {
+ width: "100%",
+ },
+ on: {
+ input: (e) => {
+ this.$set(target, info.name, e);
+ },
+ },
+ });
+ break;
+```
+
+#### datetime (日期时间选择)
+- **可写**: `el-date-picker` type="datetime"
+- **只读**: 格式化显示日期时间 (YYYY年MM月DD日 HH时mm分)
+
+```181:207:cz-hjjc-oa/src/utils/formBuilder.js
+ case "datetime":
+ formItem = h("el-date-picker", {
+ props: {
+ type: "datetime",
+ "value-format": "yyyy-MM-dd HH:mm:ss",
+ format: "yyyy-MM-dd HH:mm",
+ value: target[info.name],
+ clearable: true,
+ editable:false,
+ placeholder: info.help_text,
+ "picker-options": {
+ shortcuts: this.shortcuts,
+ },
+ },
+ style: {
+ width: "100%",
+ },
+ attrs: {
+ placeholder: info.help_text,
+ },
+ on: {
+ input: (e) => {
+ this.$set(target, info.name, e);
+ },
+ },
+ });
+ break;
+```
+
+### 2. 选择类型字段
+
+#### select/choice (下拉选择)
+- **可写**: `el-select` 组件,支持单选和多选
+- **只读**: 显示选项名称(多个用逗号分隔)
+
+```208:263:cz-hjjc-oa/src/utils/formBuilder.js
+ case "choice":
+ case "select":
+ const getSelectValue = () => {
+ if (!!info.multiple) {
+ return target[info.name]
+ ? target[info.name]
+ .toString()
+ ?.split(/,|\|/)
+ .map((i) => {
+ return ((isNaN(Number(i)) || !i) ? i : Number(i))
+ })
+ : [];
+ } else {
+ return (isNaN(Number(target[info.name])) || !target[info.name])
+ ? target[info.name]
+ : Number(target[info.name]);
+ }
+ };
+ formItem = h(
+ "el-select",
+ {
+ props: {
+ value: getSelectValue(),
+ clearable: true,
+ placeholder: info.help_text,
+ multiple: !!info.multiple,
+ // "multiple-limit": info.multiple,
+ filterable: true,
+ "value-key": "id",
+ "reserve-keyword": true,
+ "allow-create": !!info.is_select2_tag,
+ "default-first-option": true,
+ },
+ style: {
+ width: "100%",
+ },
+ attrs: {
+ placeholder: info.help_text,
+ },
+ on: {
+ input: (e) => {
+ this.$set(target, info.name, e.toString());
+ },
+ },
+ },
+ options.map((option) =>
+ h("el-option", {
+ key: typeof option === "object" ? option.id : option,
+ props: {
+ label: typeof option === "object" ? option.name : option,
+ value: typeof option === "object" ? option.id : option,
+ },
+ })
+ )
+ );
+ break;
+```
+
+#### radio (单选)
+- **可写**: `el-radio-group` 组件,支持父子级联选择
+- **只读**: 用方框+勾号展示所有选项,高亮已选项
+- **存储格式**: JSON字符串 `{"parent":"父选项","children":["子选项"]}`
+
+```264:388:cz-hjjc-oa/src/utils/formBuilder.js
+ case "radio":
+ // 支持父 radio + 子 radio(单选),子项存储为 JSON(方案 c)
+ const { parent: radioParent, child: radioChild } = parseNestedRadioStoredValue(target[info.name]);
+
+ const nestedOptions = options.map((option) => {
+ const parsed = parseNestedRadioOption(option);
+ const label = typeof option === "object" ? option.name : parsed.label;
+ const value = normalizeRadioValue(label);
+ const children = typeof option === "string" ? parsed.children : [];
+ return { option, label, value, children };
+ });
+
+ const onParentChange = (parentValue) => {
+ const nextParent = normalizeRadioValue(parentValue);
+ const hit = nestedOptions.find((i) => i.value === nextParent);
+ if (hit && hit.children && hit.children.length > 0) {
+ const keepChild = hit.children.includes(radioChild) ? radioChild : "";
+ this.$set(target, info.name, buildNestedRadioStoredValue(nextParent, keepChild));
+ } else {
+ this.$set(target, info.name, nextParent);
+ }
+ };
+
+ const onChildChange = (parentValue, childValue) => {
+ this.$set(
+ target,
+ info.name,
+ buildNestedRadioStoredValue(parentValue, normalizeRadioValue(childValue))
+ );
+ };
+
+ formItem = h(
+ "el-radio-group",
+ {
+ props: {
+ value: radioParent,
+ },
+ attrs: {
+ placeholder: info.help_text,
+ },
+ on: {
+ input: onParentChange,
+ },
+ },
+ nestedOptions.map((item) => {
+ const checked = item.value === radioParent;
+ const showChildren = checked && item.children && item.children.length > 0;
+ return h(
+ "el-radio",
+ {
+ key: item.value,
+ props: {
+ label: item.value,
+ },
+ },
+ [
+ h(
+ "span",
+ {
+ style: {
+ color: "rgb(51, 51, 51)",
+ },
+ },
+ item.label
+ ),
+ showChildren
+ ? h(
+ "span",
+ {
+ style: {
+ marginLeft: "6px",
+ color: "rgb(51, 51, 51)",
+ },
+ },
+ "("
+ )
+ : null,
+ showChildren
+ ? h(
+ "el-radio-group",
+ {
+ style: {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "10px",
+ marginLeft: "2px",
+ marginRight: "2px",
+ },
+ props: {
+ value: radioChild,
+ },
+ on: {
+ input: (v) => onChildChange(item.value, v),
+ },
+ },
+ item.children.map((c) =>
+ h(
+ "el-radio",
+ {
+ key: `${item.value}__${c}`,
+ props: {
+ label: normalizeRadioValue(c),
+ },
+ },
+ c
+ )
+ )
+ )
+ : null,
+ showChildren
+ ? h(
+ "span",
+ {
+ style: {
+ color: "rgb(51, 51, 51)",
+ },
+ },
+ ")"
+ )
+ : null,
+ ]
+ );
+ })
+ );
+ break;
+```
+
+### 3. 特殊字段类型
+
+#### budget-source (预算来源)
+- **可写**: 使用 `BudgetSourcePickerField` 组件
+- **只读**: 显示 `_display` 字段的值
+- **存储**: 主字段存储ID,`_display` 字段存储显示文本
+
+```389:407:cz-hjjc-oa/src/utils/formBuilder.js
+ case "budget-source":
+ // 可写:hidden input + "选取"按钮 + 弹窗(在独立组件内实现)
+ formItem = h(BudgetSourcePickerField, {
+ props: {
+ fieldName: info.name,
+ value: target[info.name],
+ display: target[`${info.name}_display`],
+ },
+ on: {
+ input: (val) => {
+ this.$set(target, info.name, val);
+ },
+ "update:display": (txt) => {
+ // 用于回显(只读/展示)
+ this.$set(target, `${info.name}_display`, txt);
+ },
+ },
+ });
+ break;
+```
+
+#### contract-sign (合同签订)
+- **可写**: 使用 `ContractSignField` 组件
+- **只读**: 显示 `_display` 字段的值
+- **功能**: 支持合同签订操作
+
+```408:427:cz-hjjc-oa/src/utils/formBuilder.js
+ case "contract-sign":
+ // 合同签订字段:hidden input + "合同签订"按钮 + 弹窗(在独立组件内实现)
+ formItem = h(ContractSignField, {
+ props: {
+ fieldName: info.name,
+ value: target[info.name],
+ display: target[`${info.name}_display`],
+ flowId: this.$route?.query?.flow_id || "",
+ },
+ on: {
+ input: (val) => {
+ this.$set(target, info.name, val);
+ },
+ "update:display": (txt) => {
+ // 用于回显(只读/展示)
+ this.$set(target, `${info.name}_display`, txt);
+ },
+ },
+ });
+ break;
+```
+
+#### file (文件上传)
+- **可写**: `el-upload` 组件(主表单)或 `vxe-upload`(子表单)
+- **只读**: 显示文件列表,可点击查看
+- **支持**: 单文件/多文件上传
+
+#### relation-flow (关联流程)
+- **可写**: `el-select` 多选,支持搜索和查看关联流程
+- **只读**: 显示关联流程的标题链接列表
+- **功能**: 支持加载所有相关流程、搜索流程、查看流程详情
+
+```648:857:cz-hjjc-oa/src/utils/formBuilder.js
+ case "relation-flow":
+ if (!this.flows[info.name]) {
+ let extraParam = {}
+ if (isJSON(info.stub)) {
+ extraParam = JSON.parse(info.stub)
+ }
+ flowList("all", {
+ page: 1,
+ page_size: 9999,
+ is_simple: 1,
+ custom_model_id: isJSON(info.stub) ? '' : info.stub,
+ // is_auth: 1,
+ ids:target[info.name]?target[info.name]:'',
+ ...extraParam
+ }).then((res) => {
+ this.$set(this.flows, info.name, res.data.data);
+ });
+ }
+```
+
+#### relation (关联表单/子表单)
+- **可写**: `vxe-table` 表格编辑
+- **只读**: 显示子表单数据表格
+- **功能**: 支持动态添加/删除子表单行
+
+```858:1016:cz-hjjc-oa/src/utils/formBuilder.js
+ case "relation":
+ console.log("123",this.form[info.name])
+ formItem = h(
+ "vxe-table",
+ {
+ ref: `subForm-${info.name}`,
+ style: {
+ "margin-top": "10px",
+ },
+ props: {
+ "min-height": "200px",
+ border: true,
+ stripe: true,
+ data:
+ this.form[info.name] &&
+ typeof this.form[info.name] !== "string"
+ ? this.form[info.name]
+ : [],
+ "keep-source": true,
+ "column-config": { resizable: true },
+ "show-overflow": true,
+ "edit-rules": info._writeable
+ ? this.subRules[`${info.name}_rules`]
+ : {},
+ "edit-config": info._writeable
+ ? {
+ trigger: "click",
+ mode: "row",
+ showStatus: false,
+ isHover: true,
+ autoClear: false,
+ }
+ : {},
+ },
+ on: {
+ "edit-closed": ({ row, column }) => {
+ const $table = this.$refs[`subForm-${info.name}`];
+ if ($table) {
+ this.$set(
+ this.form,
+ info.name,
+ this.$refs[`subForm-${info.name}`].tableData
+ );
+ }
+ },
+ },
+ },
+ [
+ info._writeable
+ ? h(
+ "vxe-column",
+ {
+ props: {
+ width: 56,
+ align: "center",
+ },
+ scopedSlots: {
+ default: ({ row }) => {
+ return h("el-button", {
+ slot: "default",
+ style: {
+ padding: "9px",
+ },
+ props: {
+ type: "danger",
+ size: "small",
+ icon: "el-icon-minus",
+ },
+ on: {
+ click: async (_) => {
+ const $table =
+ this.$refs[`subForm-${info.name}`];
+ if ($table) {
+ await $table.remove(row);
+ this.form[info.name] =
+ $table.getTableData()?.tableData;
+ }
+ },
+ },
+ });
+ },
+ },
+ },
+ [
+ h("el-button", {
+ slot: "header",
+ style: {
+ padding: "9px",
+ },
+ props: {
+ type: "primary",
+ size: "small",
+ icon: "el-icon-plus",
+ },
+ on: {
+ click: async (_) => {
+ const $table = this.$refs[`subForm-${info.name}`];
+ if ($table) {
+ const record = {};
+ // 确保子表数据为数组,避免空值时调用 unshift 报错
+ if (!Array.isArray(this.form[info.name])) {
+ this.$set(this.form, info.name, []);
+ }
+ this.form[info.name].unshift(record);
+ // 临时数据不好验证长度
+ // const { row: newRow } = await $table.insert(
+ // record
+ // );
+ await this.$nextTick();
+ await $table.setEditRow(record);
+ }
+ },
+ },
+ }),
+ ]
+ )
+ : "",
+ ...this.subForm
+ .get(info.sub_custom_model_id)
+ ?.customModel?.fields?.map((subField, subIndex) =>
+ h("vxe-column", {
+ props: {
+ field: subField.name,
+ title: subField.label,
+ align: "center",
+ "min-width": "180",
+ "edit-render": {},
+ },
+ scopedSlots: {
+ edit: ({ row: myrow }) => {
+ return formBuilder.bind(this)(
+ device,
+ subField,
+ h,
+ myrow,
+ info._writeable,
+ false
+ );
+ },
+ [["file", "choices", "choice", "select", "radio"].indexOf(
+ subField.type
+ ) !== -1
+ ? "default"
+ : false]: ({ row: myrow }) => {
+ return formBuilder.bind(this)(
+ device,
+ subField,
+ h,
+ myrow,
+ false,
+ true
+ );
+ },
+ },
+ })
+ ),
+ ]
+ );
+ break;
+```
+
+### 4. 展示类型字段
+
+#### label (标签)
+- 仅用于展示文本,不可编辑
+
+```619:629:cz-hjjc-oa/src/utils/formBuilder.js
+ case "label":
+ formItem = h(
+ "div",
+ {
+ props: {
+ type: "primary",
+ },
+ },
+ info.label
+ );
+ break;
+```
+
+#### static (静态链接)
+- 显示为可点击的链接
+
+```630:644:cz-hjjc-oa/src/utils/formBuilder.js
+ case "static":
+ formItem = h(
+ "el-link",
+ {
+ props: {
+ type: "primary",
+ },
+ attrs: {
+ href: target[info.name],
+ target: "_blank",
+ },
+ },
+ info.label
+ );
+ break;
+```
+
+#### hr (分隔线)
+- 显示为分隔线
+
+```645:647:cz-hjjc-oa/src/utils/formBuilder.js
+ case "hr":
+ formItem = h("el-divider", {}, info.label);
+ break;
+```
+
+## 🔐 字段权限控制
+
+### 权限类型
+
+1. **可读字段** (`_readable`): 字段可见,但不可编辑
+2. **可写字段** (`_writeable`): 字段可见且可编辑
+
+### 权限判断逻辑
+
+```364:370:cz-hjjc-oa/src/views/flow/DesktopForm.vue
+ const authFields = this.fields.map((field) => ({
+ ...field,
+ _readable:
+ this.readable.indexOf(field.id) !== -1 || field.type === "label",
+ _writeable: this.writeable.indexOf(field.id) !== -1,
+ }));
+```
+
+- `label` 类型字段默认可读
+- 其他字段根据节点配置的 `readable` 和 `writeable` 字段ID列表判断
+
+### 详情页权限设置
+
+```576:579:cz-hjjc-oa/src/views/flow/detailCommon.vue
+ this.readableFields = /\/detail/.test(this.$route.path)
+ ? this.fields?.map((i) => i.id)
+ : this.config?.currentNode?.readable || [];
+ this.writeableFields = this.config?.currentNode?.writeable || [];
+```
+
+- **详情页**: 所有字段可读,无可写字段
+- **待办页**: 根据当前节点的 `readable` 和 `writeable` 配置
+
+## ✍️ 会签字段显示
+
+### 会签数据获取
+
+```346:362:cz-hjjc-oa/src/views/flow/DesktopForm.vue
+ logs: {
+ handler: function (newVal) {
+ if (newVal && newVal instanceof Array && newVal.length > 0) {
+ this.jointlySignLog = newVal.filter(log => {
+ try {
+ JSON.parse(log.data)
+ return log.is_jointly_sign && /custom_field_id/g.test(log.data)
+ } catch (e) {
+ return false
+ }
+ })
+ } else {
+ this.jointlySignLog = []
+ }
+ },
+ immediate: true
+ }
+```
+
+### 会签字段渲染
+
+```1435:1536:cz-hjjc-oa/src/utils/formBuilder.js
+ if (formItem) {
+ let jointlySignContent = [];
+ let isJointly = false;
+ this.jointlySignLog.forEach((log) => {
+ const data = JSON.parse(log.data);
+ Object.entries(data)?.forEach(([key, value]) => {
+ if (
+ value.hasOwnProperty("custom_field_id") &&
+ value["custom_field_id"] === info.id
+ ) {
+ isJointly = !!log.is_jointly_sign;
+ if (log.status > 0 && log.user) {
+ // 对于 budget-source 类型字段,优先显示后端返回的 _display 值
+ let displayValue = value.value;
+ if (info.type === 'budget-source') {
+ // 优先使用主表字段的 _display(与主表字段展示一致)
+ const displayFieldName = info.name + '_display';
+ const mainDisplayValue = target[displayFieldName] || '';
+ if (mainDisplayValue) {
+ displayValue = mainDisplayValue;
+ } else if (displayValue) {
+ // 如果没有 _display,尝试识别并屏蔽 JSON(包括双重编码)
+ const strValue = String(displayValue);
+ // 尝试解析 JSON(可能是一层或两层编码)
+ let isJson = false;
+ try {
+ const parsed1 = JSON.parse(strValue);
+ if (typeof parsed1 === 'object' || Array.isArray(parsed1)) {
+ isJson = true;
+ } else if (typeof parsed1 === 'string') {
+ // 双重编码:第一层解析出来是字符串,再试一次
+ try {
+ const parsed2 = JSON.parse(parsed1);
+ if (typeof parsed2 === 'object' || Array.isArray(parsed2)) {
+ isJson = true;
+ }
+ } catch (e2) {
+ // 不是双重编码的 JSON
+ }
+ }
+ } catch (e) {
+ // 不是 JSON 字符串
+ }
+ // 如果是 JSON(包括双重编码),不显示
+ if (isJson || strValue.trim().startsWith('{') || strValue.trim().startsWith('[')) {
+ displayValue = ''; // 不显示原始 JSON
+ } else if (typeof displayValue === 'object' || Array.isArray(displayValue)) {
+ displayValue = ''; // 不显示对象或数组
+ }
+ }
+ }
+ jointlySignContent.push(
+ h("div", [
+ h("span", displayValue),
+ h("br"),
+ info.is_sign
+ ? log.user?.sign_file?.url
+ ? h("div", {
+ style: {
+ display: "flex",
+ "align-items": "center",
+ gap: "8px",
+ }
+ }, [
+ h("el-image", {
+ style: {
+ "max-height": "80px",
+ "max-width": "100px",
+ display: "block",
+ },
+ props: {
+ src: log.user.sign_file.url,
+ fit: "contain",
+ alt: log.user?.name || "",
+ "preview-src-list": [log.user.sign_file.url],
+ lazy: false,
+ },
+ attrs: {
+ src: log.user.sign_file.url,
+ },
+ }),
+ h(
+ "span",
+ {
+ style: {
+ "font-size": "16px",
+ color: "#000",
+ }
+ },
+ log.updated_at
+ ? this.$moment(log.updated_at).format("YYYY年MM月DD日")
+ : ""
+ ),
+ ])
+ : h("span", log.user?.name || "")
+ : "",
+ ])
+ );
+ }
+ }
+ });
+ });
+```
+
+### 签名显示逻辑
+
+```1587:1672:cz-hjjc-oa/src/utils/formBuilder.js
+ (() => {
+ // 检查 is_sign 是否为真值(支持 1, true, "1" 等)
+ if (info.is_sign && (info.is_sign === 1 || info.is_sign === true || info.is_sign === "1")) {
+ let log = null;
+
+ // 方法1: 从 jointlySignLog 中查找(检查所有字段)
+ if (this.jointlySignLog && this.jointlySignLog.length > 0) {
+ log = this.jointlySignLog.find((log) => {
+ if (log.status > 0 && log.user && log.user.sign_file && log.user.sign_file.url) {
+ try {
+ const data = JSON.parse(log.data);
+ // 检查 data 中是否有匹配当前字段的条目
+ return Object.values(data).some(
+ (value) =>
+ value &&
+ typeof value === 'object' &&
+ value.custom_field_id === info.id
+ );
+ } catch (e) {
+ return false;
+ }
+ }
+ return false;
+ });
+ }
+
+ // 方法2: 如果没找到,从 this.logs 中查找(必须匹配字段ID和类型)
+ if (!log && this.logs && this.logs.length > 0) {
+ log = this.logs.find(
+ (log) =>
+ log.status > 0 &&
+ log.user &&
+ log.user.sign_file &&
+ log.user.sign_file.url &&
+ log.node?.fields?.findIndex(
+ (field) =>
+ field?.field_id === info.id &&
+ field.type === "write"
+ ) !== -1
+ );
+ }
+
+ // 只有当找到匹配当前字段的log时才显示
+ if (log && log.status > 0 && log.user && log.user.sign_file && log.user.sign_file.url) {
+ return h("div", {
+ style: {
+ "margin-top": "8px",
+ display: "flex",
+ "align-items": "center",
+ gap: "8px",
+ }
+ }, [
+ h("el-image", {
+ style: {
+ "max-height": "80px",
+ "max-width": "100px",
+ display: "block",
+ },
+ props: {
+ src: log.user.sign_file.url,
+ fit: "contain",
+ alt: log.user?.name || "",
+ "preview-src-list": [log.user.sign_file.url],
+ lazy: false,
+ },
+ attrs: {
+ src: log.user.sign_file.url,
+ },
+ }),
+ h(
+ "div",
+ {
+ style: {
+ "font-size": "16px",
+ color: "#000",
+ }
+ },
+ log.updated_at
+ ? this.$moment(log.updated_at).format("YYYY年MM月DD日")
+ : ""
+ ),
+ ]);
+ }
+ }
+ return null;
+ })(),
+```
+
+## 📐 字段布局
+
+### Grid布局系统
+
+字段使用 CSS Grid 布局,通过以下属性控制位置:
+
+- `gs_x`: 列起始位置
+- `gs_y`: 行起始位置
+- `gs_width`: 列跨度
+- `gs_height`: 行跨度
+
+```1551:1562:cz-hjjc-oa/src/utils/formBuilder.js
+ style: {
+ // +1为了工作标题往下顺延
+ "grid-column-start": info.gs_x + 1,
+ "grid-column-end": info.gs_x + 1 + info.gs_width,
+ "grid-row-start":
+ info.gs_y + 1 + (info.name === "flow_title" ? 0 : 1),
+ "grid-row-end":
+ info.gs_y +
+ 1 +
+ (info.name === "flow_title" ? 0 : 1) +
+ info.gs_height,
+ },
+```
+
+## 🔧 自定义脚本支持
+
+### 脚本注入
+
+```321:338:cz-hjjc-oa/src/views/flow/DesktopForm.vue
+ scriptContent(newVal) {
+ if (newVal) {
+ try {
+ // 使用 $nextTick 确保 DOM 已经渲染完成
+ this.$nextTick(() => {
+ try {
+ this.installSafeEventListenerGuard();
+ new Function(newVal).bind(this)();
+ } catch (err) {
+ console.error('脚本执行错误:', err);
+ // 不阻止页面正常使用,只记录错误
+ }
+ });
+ } catch (err) {
+ console.error('脚本编译错误:', err);
+ }
+ }
+ },
+```
+
+- **新建/编辑**: 使用 `js` 脚本
+- **详情查看**: 使用 `view_js` 脚本
+
+### 安全防护
+
+```234:268:cz-hjjc-oa/src/views/flow/DesktopForm.vue
+ installSafeEventListenerGuard() {
+ if (window.__oa_safe_listener_guard_installed) return;
+ window.__oa_safe_listener_guard_installed = true;
+
+ const map = new WeakMap();
+ const origAdd = EventTarget && EventTarget.prototype && EventTarget.prototype.addEventListener;
+ const origRemove = EventTarget && EventTarget.prototype && EventTarget.prototype.removeEventListener;
+ if (!origAdd || !origRemove) return;
+
+ EventTarget.prototype.addEventListener = function (type, listener, options) {
+ if (typeof listener === "function") {
+ let wrapped = map.get(listener);
+ if (!wrapped) {
+ wrapped = function (...args) {
+ try {
+ return listener.apply(this, args);
+ } catch (e) {
+ console.error("[OA Custom Script] event listener error:", e);
+ }
+ };
+ map.set(listener, wrapped);
+ }
+ return origAdd.call(this, type, wrapped, options);
+ }
+ return origAdd.call(this, type, listener, options);
+ };
+
+ EventTarget.prototype.removeEventListener = function (type, listener, options) {
+ if (typeof listener === "function") {
+ const wrapped = map.get(listener);
+ return origRemove.call(this, type, wrapped || listener, options);
+ }
+ return origRemove.call(this, type, listener, options);
+ };
+ },
+```
+
+## 📊 数据流
+
+### 1. 字段配置获取
+
+```
+API调用 (view/preConfig/preDeal)
+ ↓
+获取 customModel.fields
+ ↓
+获取子表单配置 (subForm)
+ ↓
+设置字段权限 (readableFields/writeableFields)
+```
+
+### 2. 字段渲染
+
+```
+DesktopForm 接收 fields 和权限
+ ↓
+遍历 fields,添加 _readable/_writeable
+ ↓
+调用 formBuilder 渲染每个字段
+ ↓
+根据字段类型和权限选择组件
+```
+
+### 3. 数据绑定
+
+```
+form 对象存储字段值
+ ↓
+通过 this.$set 实现响应式更新
+ ↓
+字段值同步到 form[field.name]
+```
+
+## 📝 特殊处理
+
+### 1. budget-source 字段
+
+- 存储主值和显示值分离
+- 主值存储在 `field.name`
+- 显示值存储在 `field.name + '_display'`
+- 会签时优先使用 `_display` 字段
+
+### 2. radio 父子级联
+
+- 支持格式:`父选项[子选项1,子选项2]`
+- 存储格式:`{"parent":"父选项","children":["子选项"]}`
+- 只读模式下用方框+勾号展示
+
+### 3. 关联流程字段
+
+- 支持延迟加载所有流程
+- 支持搜索和过滤
+- 每个选项可跳转到详情页查看
+
+### 4. 文件上传
+
+- 主表单使用 `el-upload`
+- 子表单使用 `vxe-upload`
+- 支持多文件上传(最多20个)
+- 文件大小限制由 `uploadSize` 配置
+
+## 🎯 总结
+
+流程详情页面的自定义字段渲染系统具有以下特点:
+
+1. **类型丰富**: 支持15+种字段类型,覆盖常用表单需求
+2. **权限精细**: 支持字段级别的读写权限控制
+3. **布局灵活**: 基于Grid布局,支持任意字段位置和大小
+4. **功能强大**: 支持会签、签名、关联等复杂场景
+5. **扩展性好**: 支持自定义脚本扩展功能
+
+该系统通过组件化和配置化的方式,实现了高度灵活的表单字段渲染机制。
+
+
+
+
+
diff --git a/错误诊断和解决方案.md b/错误诊断和解决方案.md
index 83d13fc..3a960c3 100644
--- a/错误诊断和解决方案.md
+++ b/错误诊断和解决方案.md
@@ -188,3 +188,9 @@ A: 这是正常的,Vue CLI使用内容哈希来生成文件名。只要HTML文
+
+
+
+
+
+