diff --git a/src/components/BudgetSourcePickerField.vue b/src/components/BudgetSourcePickerField.vue index ef34910..b4de9cc 100644 --- a/src/components/BudgetSourcePickerField.vue +++ b/src/components/BudgetSourcePickerField.vue @@ -985,27 +985,56 @@ export default { return; } - const rows = []; + const authGroups = []; for (const node of selectedNodes) { const auths = await this.getDepartmentAuthorizations(node.id); - if (!auths || auths.length !== 1) { - this.selectionError = "多条预算来源时,每条预算必须且仅能授权给一个部门"; + if (!auths || auths.length === 0) { + this.selectionError = `预算“${node.name || node.id}”未配置部门授权`; this.$message.warning(this.selectionError); this.selectedBudgetIds = [...this.lastValidBudgetIds]; this.setCheckedIds(this.lastValidBudgetIds); this.selectionError = ""; 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 || "", - allocated_amount: existingMap[key] ?? 0, + authGroups.push({ + node, + auths, + authIdSet: new Set( + auths + .map((a) => String(a.department_id || "")) + .filter(Boolean) + ), }); } + + const commonDepartmentIds = [...authGroups[0].authIdSet].filter((departmentId) => + authGroups.every((group) => group.authIdSet.has(departmentId)) + ); + + if (commonDepartmentIds.length === 0) { + this.selectionError = "多条预算来源不存在交叉授权科室"; + this.$message.warning(this.selectionError); + this.selectedBudgetIds = [...this.lastValidBudgetIds]; + this.setCheckedIds(this.lastValidBudgetIds); + this.selectionError = ""; + return; + } + + const rows = []; + authGroups.forEach(({ node, auths }) => { + auths.forEach((a) => { + const departmentId = String(a.department_id || ""); + if (!commonDepartmentIds.includes(departmentId)) return; + const key = `${String(node.id)}:${departmentId}`; + rows.push({ + budget_data_id: node.id, + budget_name: node.name || "", + department_id: a.department_id, + department_name: a.department?.name || "", + allocated_amount: existingMap[key] ?? 0, + }); + }); + }); this.pendingAllocations = rows; this.lastValidBudgetIds = ids; }, diff --git a/src/main.js b/src/main.js index a2b3583..6c29f4a 100644 --- a/src/main.js +++ b/src/main.js @@ -23,10 +23,30 @@ Vue.use(ElementUI) Vue.config.productionTip = false // vant: only register components used as global tags -import { Popup, Picker, Uploader } from 'vant' +import { + Popup, + Picker, + Uploader, + Radio, + RadioGroup, + Form, + Field, + Calendar, + DatetimePicker, + Cell, + Button +} from 'vant' import 'vant/lib/popup/style' import 'vant/lib/picker/style' import 'vant/lib/uploader/style' +import 'vant/lib/radio/style' +import 'vant/lib/radio-group/style' +import 'vant/lib/form/style' +import 'vant/lib/field/style' +import 'vant/lib/calendar/style' +import 'vant/lib/datetime-picker/style' +import 'vant/lib/cell/style' +import 'vant/lib/button/style' import 'vant/lib/checkbox/style' import 'vant/lib/checkbox-group/style' import 'vant/lib/dropdown-menu/style' @@ -34,6 +54,14 @@ import 'vant/lib/dropdown-item/style' Vue.use(Popup) Vue.use(Picker) Vue.use(Uploader) +Vue.use(Radio) +Vue.use(RadioGroup) +Vue.use(Form) +Vue.use(Field) +Vue.use(Calendar) +Vue.use(DatetimePicker) +Vue.use(Cell) +Vue.use(Button) import domZIndex from 'dom-zindex' domZIndex.setCurrent(2000) diff --git a/src/utils/formBuilder.js b/src/utils/formBuilder.js index 3f18981..020a92a 100644 --- a/src/utils/formBuilder.js +++ b/src/utils/formBuilder.js @@ -21,6 +21,96 @@ function isJSON(str) { } } +function normalizeBudgetSourceValue(value, maxDepth = 8) { + let current = value; + + for (let i = 0; i < maxDepth; i++) { + if (current === null || current === undefined || current === "") return current; + if (typeof current === "object") return current; + if (typeof current !== "string") return current; + + const trimmed = current.trim(); + if (!trimmed) return ""; + + try { + const parsed = JSON.parse(trimmed); + if (parsed === current) return parsed; + current = parsed; + continue; + } catch (e) { + // keep normalizing below + } + + let next = trimmed; + if ( + (next.startsWith('"') && next.endsWith('"')) || + (next.startsWith("'") && next.endsWith("'")) + ) { + next = next.slice(1, -1); + } + + next = next + .replace(/\\\\/g, "\\") + .replace(/\\"/g, '"') + .replace(/\\'/g, "'"); + + if (next === trimmed) return trimmed; + current = next; + } + + return current; +} + +function formatMoney(amount) { + const numeric = Number(amount || 0); + return `¥${numeric.toLocaleString("zh-CN", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +function formatBudgetSourceDisplay(value) { + const normalized = normalizeBudgetSourceValue(value); + if (!normalized || typeof normalized !== "object") return ""; + + const rows = []; + const appendAllocations = (budgetLabel, allocations) => { + if (!Array.isArray(allocations)) return; + allocations.forEach((alloc) => { + if (!alloc || typeof alloc !== "object") return; + const amount = Number(alloc.allocated_amount || 0); + if (Math.abs(amount) < 0.00001) return; + const deptName = alloc.department_name || (alloc.department_id ? `科室ID:${alloc.department_id}` : "-"); + const prefix = budgetLabel ? `${budgetLabel} - ` : ""; + rows.push(`${prefix}${deptName} - ${formatMoney(amount)}`); + }); + }; + + if (Array.isArray(normalized.budget_items)) { + normalized.budget_items.forEach((item) => { + if (!item || typeof item !== "object") return; + const budgetLabel = item.budget_name || (item.budget_data_id ? `预算ID:${item.budget_data_id}` : ""); + appendAllocations(budgetLabel, item.allocations); + if (!Array.isArray(item.allocations) || item.allocations.length === 0) { + if (budgetLabel) rows.push(budgetLabel); + } + }); + } else { + const budgetLabel = normalized.budget_name || (normalized.budget_data_id ? `预算ID:${normalized.budget_data_id}` : ""); + appendAllocations(budgetLabel, normalized.allocations); + if (rows.length === 0 && budgetLabel) rows.push(budgetLabel); + } + + if (rows.length > 1) { + return rows.map((row, index) => `${index + 1}、${row}`).join(";"); + } + return rows.join(";"); +} + +function shouldRenderJointlySignContent(info) { + return info && (info.is_sign === 1 || info.is_sign === true || info.is_sign === "1"); +} + // radio 统一按 string 做存储与比较,避免 "1" vs 1 导致回显不勾选 function normalizeRadioValue(v) { if (v === null || v === undefined) return ""; @@ -1390,15 +1480,7 @@ export default function formBuilder( // 如果 _display 为空,尝试从原始值生成显示文本(避免显示JSON) if (!displayValue && target[info.name]) { - const rawValue = target[info.name]; - // 如果是字符串且看起来像JSON,不直接显示 - if (typeof rawValue === 'string' && (rawValue.trim().startsWith('{') || rawValue.trim().startsWith('['))) { - displayValue = ''; // 不显示原始JSON - } else if (typeof rawValue === 'object' || Array.isArray(rawValue)) { - displayValue = ''; // 不显示对象或数组 - } else { - displayValue = String(rawValue); - } + displayValue = formatBudgetSourceDisplay(target[info.name]); } console.log('[budget-source] 只读模式渲染', { @@ -1452,7 +1534,7 @@ export default function formBuilder( if (formItem) { let jointlySignContent = []; let isJointly = false; - this.jointlySignLog.forEach((log) => { + if (shouldRenderJointlySignContent(info)) this.jointlySignLog.forEach((log) => { const data = JSON.parse(log.data); Object.entries(data)?.forEach(([key, value]) => { if ( @@ -1464,47 +1546,14 @@ export default function formBuilder( // 对于 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 = ''; // 不显示对象或数组 - } - } + displayValue = target[displayFieldName] || formatBudgetSourceDisplay(target[info.name]) || ''; } jointlySignContent.push( h("div", [ h("span", displayValue), h("br"), - info.is_sign + shouldRenderJointlySignContent(info) ? log.user?.sign_file?.url ? h("div", { style: { @@ -1986,141 +2035,121 @@ export default function formBuilder( break; case "file": - formItem = h( - "el-upload", - { - props: { - action: process.env.VUE_APP_UPLOAD_API, + const normalizeMobileUploadList = (list) => { + return (list instanceof Array ? list : []).map((file) => { + file.name = file.original_name || file.name || file.file?.name; + file.url = file.url || file.response?.url; + file.status = file.status === "success" ? "done" : (file.status || "done"); + return file; + }); + }; + const setMobileUploadList = (list) => { + this.$set(target, info.name, normalizeMobileUploadList(list)); + }; + const uploadMobileFile = (fileItem) => { + const rawFile = fileItem?.file || fileItem; + if (!rawFile) return; + fileItem.status = "uploading"; + fileItem.message = "上传中..."; + window.$_uploading = true; + + const currentList = normalizeMobileUploadList(target[info.name]); + if (currentList.indexOf(fileItem) === -1) { + currentList.push(fileItem); + } + setMobileUploadList(currentList.slice(-(info.multiple ? 20 : 1))); + + const formData = new FormData(); + formData.append("file", rawFile); + axios + .post(process.env.VUE_APP_UPLOAD_API, formData, { headers: { Authorization: `Bearer ${getToken()}`, }, - accept: - ".cebx,application/msword,image/jpeg,application/pdf,image/png,application/vnd.ms-powerpoint,text/plain,application/x-zip-compressed,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document", - multiple: !!info.multiple, - limit: info.multiple ? 20 : 1, - fileList: - this.form[info.name] instanceof Array - ? this.form[info.name]?.map((i) => { - if (i.hasOwnProperty("original_name")) { - i.name = i.original_name; - } - return i; - }) - : [], - beforeUpload: (file) => { - if (file.size > uploadSize) { - this.$message({ - type: "warning", - message: `上传图片大小超过${formatFileSize( - uploadSize - )}!`, - }); - return false; - } - window.$_uploading = true; - }, - onSuccess: (response, file, fileList) => { - window.$_uploading = false; - console.log("response",response,file,fileList) - if (response.code) { - fileList.splice(fileList.indexOf(file), 1) - this.$message.warning(response.msg) - } - fileList.forEach((file) => { - if (file.response?.data && !file.response?.code) { - file.response = file.response?.data; - file.url = file.response?.url; - } - }); - target[info.name] = fileList; - }, - onRemove: (file, fileList) => { - target[info.name] = fileList; - }, - onError: (err, file, fileList) => { - console.log("err",err,file,fileList) - window.$_uploading = false; - if (err.code) { - fileList.splice(fileList.indexOf(file), 1) - this.$message.warning(err.msg) - } - target[info.name] = fileList; - this.$message({ - type: "warning", - message: err, + }) + .then((response) => { + window.$_uploading = false; + if (response.status === 200 && !response.data.code) { + const uploaded = response.data.data; + Object.assign(fileItem, { + status: "done", + message: "", + response: uploaded, + id: uploaded.id, + name: uploaded.original_name, + original_name: uploaded.original_name, + url: uploaded.url, }); + } else { + fileItem.status = "failed"; + fileItem.message = response.data?.msg || "上传失败"; + this.$message.warning(fileItem.message); + } + setMobileUploadList(target[info.name]); + }) + .catch((err) => { + window.$_uploading = false; + fileItem.status = "failed"; + fileItem.message = "上传失败"; + setMobileUploadList(target[info.name]); + this.$message.warning(err?.message || "上传失败"); + }); + }; + formItem = h("div", [ + h( + "van-uploader", + { + props: { + fileList: normalizeMobileUploadList(target[info.name]), + multiple: !!info.multiple, + maxCount: info.multiple ? 20 : 1, + maxSize: uploadSize, + accept: + ".cebx,application/msword,image/jpeg,application/pdf,image/png,application/vnd.ms-powerpoint,text/plain,application/x-zip-compressed,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document", + beforeRead: (file) => { + const files = file instanceof Array ? file : [file]; + const oversized = files.find((item) => item.size > uploadSize); + if (oversized) { + this.$message.warning(`上传文件大小超过${formatFileSize(uploadSize)}!`); + return false; + } + return true; + }, + afterRead: (file) => { + const files = file instanceof Array ? file : [file]; + files.forEach(uploadMobileFile); + }, }, - }, - scopedSlots: { - file: (scope) => { - let { file } = scope; - return [ - h("div", {}, [ - h("i", { - class: { - "el-icon-circle-check": file.status === "success", - "el-icon-loading": file.status === "uploading", - }, - style: { - color: file.status === "success" ? "green" : "", - }, - }), - h( - "a", - { - class: { - "uploaded-a": file.status === "success", - }, - style: { - padding: "0 4px", - }, - on: { - click: (_) => { - this.$bus.$emit("online-file", file.url); - }, - }, - }, - file.original_name || file.name - ), - ]), - h("i", { - class: "el-icon-close", - on: { - ["click"]: () => { - if (file.status === "uploading") return; - target[info.name].splice( - target[info.name].indexOf(file), - 1 - ); - }, - }, - }), - ]; + on: { + input: (fileList) => { + setMobileUploadList(fileList); + }, }, }, - }, - [ - h( - "el-button", - { - slot: "trigger", - props: { - size: "mini", - type: "primary", + [ + h( + "van-button", + { + props: { + type: "info", + size: "small", + }, }, + "选取文件" + ), + ] + ), + h( + "div", + { + class: "el-upload__tip", + style: { + "margin-top": "8px", }, - "选取文件" - ), - h( - "div", - { - class: "el-upload__tip", - slot: "tip", - }, - `文件不超过${formatFileSize(uploadSize)}` - ), - ] - ); + }, + `文件不超过${formatFileSize(uploadSize)}` + ), + ]); break; case "relation-flow": if (!this.flows[info.name]) { @@ -2716,7 +2745,7 @@ export default function formBuilder( if (formItem) { let jointlySignContent = []; let isJointly = false; - this.jointlySignLog.forEach((log) => { + if (shouldRenderJointlySignContent(info)) this.jointlySignLog.forEach((log) => { const data = JSON.parse(log.data); Object.entries(data)?.forEach(([key, value]) => { if ( @@ -2728,47 +2757,14 @@ export default function formBuilder( // 对于 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 = ''; // 不显示对象或数组 - } - } + displayValue = target[displayFieldName] || formatBudgetSourceDisplay(target[info.name]) || ''; } jointlySignContent.push( h("div", [ h("span", displayValue), h("br"), - info.is_sign + shouldRenderJointlySignContent(info) ? log.user?.sign_file?.url ? h("div", { style: {