|
|
@@ -114,6 +114,10 @@
|
|
|
size="mini"
|
|
|
:disabled="isViewMode"
|
|
|
:maxlength="computeNumberMaxlength(column)"
|
|
|
+ :class="getCellErrorClass(scope.row, column.prop)"
|
|
|
+ :title="getCellError(scope.row, column.prop)"
|
|
|
+ :data-rowid="scope.row.rowid || scope.row.id"
|
|
|
+ :data-prop="column.prop"
|
|
|
@input="sanitizeNumberInput(scope.row, column)"
|
|
|
@keypress.native="
|
|
|
onNumberKeypressNumeric($event, scope.row, column)
|
|
|
@@ -133,6 +137,11 @@
|
|
|
:placeholder="column.placeholder || '请输入' + column.label"
|
|
|
size="mini"
|
|
|
:disabled="isViewMode"
|
|
|
+ :maxlength="column.maxlength"
|
|
|
+ :class="getCellErrorClass(scope.row, column.prop)"
|
|
|
+ :title="getCellError(scope.row, column.prop)"
|
|
|
+ :data-rowid="scope.row.rowid || scope.row.id"
|
|
|
+ :data-prop="column.prop"
|
|
|
@blur="handleCellBlur(scope.row, column.prop)"
|
|
|
/>
|
|
|
</template>
|
|
|
@@ -264,6 +273,8 @@
|
|
|
fixedAssetsData: [],
|
|
|
// 验证错误
|
|
|
validationErrors: [],
|
|
|
+ // 字段级错误映射:key = rowid::prop -> message
|
|
|
+ fieldErrors: {},
|
|
|
// 记录被删除的行rowid/id,用于保存时过滤
|
|
|
deletedRowids: [],
|
|
|
// 扁平化的表格数据(响应式)
|
|
|
@@ -473,6 +484,18 @@
|
|
|
if (f.format) col.format = f.format
|
|
|
if (f.valueFormat) col.valueFormat = f.valueFormat
|
|
|
}
|
|
|
+ // 纯数字格式表示最大长度,且仅对非日期/数字类字段生效
|
|
|
+ const fmtStr = (meta.format && String(meta.format).trim()) || ''
|
|
|
+ if (
|
|
|
+ /^\d+$/.test(fmtStr) &&
|
|
|
+ !['date', 'datetime', 'year', 'number'].includes(col.type)
|
|
|
+ ) {
|
|
|
+ col.maxlength = Number(fmtStr)
|
|
|
+ }
|
|
|
+ // 数字字段:若 format 为纯数字,按“总数字位数上限”处理
|
|
|
+ if (/^\d+$/.test(fmtStr) && ['number'].includes(col.type)) {
|
|
|
+ col.maxDigits = Number(fmtStr)
|
|
|
+ }
|
|
|
// 元数据上的长度规则(兼容多种命名)
|
|
|
const rawType = String(
|
|
|
meta.type || meta.fieldType || ''
|
|
|
@@ -621,6 +644,7 @@
|
|
|
// 限制整数/小数位
|
|
|
const intLimit = Number(column.totalLength)
|
|
|
const decLimit = Number(column.decimalLength)
|
|
|
+ const maxDigits = Number(column.maxDigits)
|
|
|
const sign = v.startsWith('-') || v.startsWith('+') ? v[0] : ''
|
|
|
const unsigned = sign ? v.slice(1) : v
|
|
|
const [iRaw, dRaw = ''] = unsigned.split('.')
|
|
|
@@ -632,6 +656,21 @@
|
|
|
if (!isNaN(decLimit) && decLimit >= 0 && d.length > decLimit) {
|
|
|
d = d.slice(0, decLimit)
|
|
|
}
|
|
|
+ // 若定义了总数字位数上限(来自 format 纯数字),优先再截断到总位数
|
|
|
+ if (!isNaN(maxDigits) && maxDigits > 0) {
|
|
|
+ let total = i.length + d.length
|
|
|
+ if (total > maxDigits) {
|
|
|
+ // 先截断小数部分,再截断整数部分
|
|
|
+ let allow = maxDigits
|
|
|
+ if (i.length > allow) {
|
|
|
+ i = i.slice(0, allow)
|
|
|
+ d = ''
|
|
|
+ } else {
|
|
|
+ allow -= i.length
|
|
|
+ d = d.slice(0, allow)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
if (!isNaN(decLimit) && decLimit === 0) {
|
|
|
// 整数:不保留小数点
|
|
|
v = sign + i
|
|
|
@@ -1458,6 +1497,74 @@
|
|
|
Message.warning(`${this.getFieldLabel(field)}必须是正整数`)
|
|
|
}
|
|
|
}
|
|
|
+ // 失焦进行字段级校验以清除或设置错误
|
|
|
+ this.validateField(row, field)
|
|
|
+ },
|
|
|
+ // 获取单元格错误class
|
|
|
+ getCellErrorClass(row, prop) {
|
|
|
+ const key = `${row.rowid || row.id}::${prop}`
|
|
|
+ return this.fieldErrors && this.fieldErrors[key] ? 'input-error' : ''
|
|
|
+ },
|
|
|
+ // 获取单元格错误消息(可用于title/提示)
|
|
|
+ getCellError(row, prop) {
|
|
|
+ const key = `${row.rowid || row.id}::${prop}`
|
|
|
+ return (this.fieldErrors && this.fieldErrors[key]) || ''
|
|
|
+ },
|
|
|
+ // 单字段校验:基于 columnsMeta 的 isRequired 与 format(数字=最大长度)
|
|
|
+ validateField(row, prop) {
|
|
|
+ if (!row || !prop) return
|
|
|
+ const col = (this.dynamicColumns || []).find(
|
|
|
+ (c) => c && c.prop === prop
|
|
|
+ )
|
|
|
+ const metas = Array.isArray(this.columnsMeta) ? this.columnsMeta : []
|
|
|
+ const byLabel = new Map()
|
|
|
+ const byId = new Map()
|
|
|
+ metas.forEach((m) => {
|
|
|
+ if (!m) return
|
|
|
+ if (m.label) byLabel.set(String(m.label), m)
|
|
|
+ if (m.fieldId) byId.set(String(m.fieldId), m)
|
|
|
+ })
|
|
|
+ const meta =
|
|
|
+ (col && col.label && byLabel.get(String(col.label))) ||
|
|
|
+ (col && col.fieldId && byId.get(String(col.fieldId))) ||
|
|
|
+ (prop && byLabel.get(String(prop))) ||
|
|
|
+ null
|
|
|
+ const value = row[prop]
|
|
|
+ const isEmpty = (v) => v === undefined || v === null || v === ''
|
|
|
+ const fmt = meta && meta.format ? String(meta.format).trim() : ''
|
|
|
+ const toBool = (v) => {
|
|
|
+ if (v === true) return true
|
|
|
+ if (typeof v === 'number') return v === 1
|
|
|
+ if (typeof v === 'string') {
|
|
|
+ const s = v.trim().toLowerCase()
|
|
|
+ return s === 'true' || s === '1'
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ const requiredFlag = toBool(meta && (meta.required || meta.isRequired))
|
|
|
+ const key = `${row.rowid || row.id}::${prop}`
|
|
|
+ // 清理
|
|
|
+ if (this.fieldErrors && this.fieldErrors[key])
|
|
|
+ this.$delete(this.fieldErrors, key)
|
|
|
+ if (requiredFlag && isEmpty(value)) {
|
|
|
+ this.$set(
|
|
|
+ this.fieldErrors,
|
|
|
+ key,
|
|
|
+ `${col && col.label ? col.label : prop}不能为空`
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!isEmpty(value) && /^\d+$/.test(fmt)) {
|
|
|
+ const expect = Number(fmt)
|
|
|
+ const actual = String(value).length
|
|
|
+ if (actual > expect) {
|
|
|
+ this.$set(
|
|
|
+ this.fieldErrors,
|
|
|
+ key,
|
|
|
+ `${col && col.label ? col.label : prop}长度不能超过${expect}位`
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
},
|
|
|
// 获取字段标签
|
|
|
getFieldLabel(field) {
|
|
|
@@ -1637,14 +1744,58 @@
|
|
|
|
|
|
return syncDataToNested(this.fixedAssetsData)
|
|
|
},
|
|
|
+ // 聚焦第一个错误单元格
|
|
|
+ focusFirstError() {
|
|
|
+ try {
|
|
|
+ // 优先使用 fieldErrors 顺序中的第一个
|
|
|
+ const keys = Object.keys(this.fieldErrors || {})
|
|
|
+ if (!keys.length) return
|
|
|
+ const [first] = keys
|
|
|
+ const [rowKey, prop] = first.split('::')
|
|
|
+ if (!rowKey || !prop) return
|
|
|
+ this.$nextTick(() => {
|
|
|
+ // 查找带有 data-rowid 和 data-prop 的输入
|
|
|
+ const cssEscape = (s) => {
|
|
|
+ try {
|
|
|
+ return window.CSS && CSS.escape
|
|
|
+ ? CSS.escape(s)
|
|
|
+ : String(s).replace(/"/g, '\\"')
|
|
|
+ } catch (e) {
|
|
|
+ return String(s)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const selector = `[data-rowid="${cssEscape(
|
|
|
+ rowKey
|
|
|
+ )}"][data-prop="${cssEscape(prop)}"] input`
|
|
|
+ let el = this.$el && this.$el.querySelector(selector)
|
|
|
+ if (!el) {
|
|
|
+ // 兜底:第一个标红输入
|
|
|
+ el =
|
|
|
+ this.$el &&
|
|
|
+ this.$el.querySelector(
|
|
|
+ '.input-error input, .input-error .el-input__inner'
|
|
|
+ )
|
|
|
+ }
|
|
|
+ if (el) {
|
|
|
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
+ el.focus()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } catch (e) {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+ },
|
|
|
// 保存数据(调用 saveSingleRecordSurvey 接口)
|
|
|
async saveData() {
|
|
|
// 保存前校验
|
|
|
const validationErrors = this.validateBeforeSave()
|
|
|
- // if (validationErrors && validationErrors.length > 0) {
|
|
|
- // Message.error('表单验证失败,请检查后再保存')
|
|
|
- // return false
|
|
|
- // }
|
|
|
+ if (validationErrors && validationErrors.length > 0) {
|
|
|
+ // 聚焦并提示第一个错误
|
|
|
+ this.focusFirstError()
|
|
|
+ const firstMsg = validationErrors[0]
|
|
|
+ Message.error(firstMsg || '表单验证失败,请检查标红字段后再保存')
|
|
|
+ return false
|
|
|
+ }
|
|
|
try {
|
|
|
// 格式化保存数据为接口需要的格式
|
|
|
const saveData = []
|
|
|
@@ -1754,27 +1905,29 @@
|
|
|
|
|
|
const metaByLabel = new Map()
|
|
|
const metaByFieldId = new Map()
|
|
|
+ const metaByFieldName = new Map()
|
|
|
+ const metaByName = new Map()
|
|
|
metaArr.forEach((m) => {
|
|
|
if (!m) return
|
|
|
- if (m.label) metaByLabel.set(String(m.label), m)
|
|
|
- if (m.fieldId) metaByFieldId.set(String(m.fieldId), m)
|
|
|
+ if (m.label) metaByLabel.set(String(m.label).trim(), m)
|
|
|
+ if (m.fieldId) metaByFieldId.set(String(m.fieldId).trim(), m)
|
|
|
+ if (m.fieldName) metaByFieldName.set(String(m.fieldName).trim(), m)
|
|
|
+ if (m.name) metaByName.set(String(m.name).trim(), m)
|
|
|
})
|
|
|
|
|
|
const getMetaForProp = (prop, column) => {
|
|
|
- // column.label 优先,其次 fieldId
|
|
|
- if (column && column.label && metaByLabel.has(String(column.label))) {
|
|
|
- return metaByLabel.get(String(column.label))
|
|
|
+ const tryKeys = []
|
|
|
+ if (column) {
|
|
|
+ if (column.label) tryKeys.push(String(column.label).trim())
|
|
|
+ if (column.fieldId) tryKeys.push(String(column.fieldId).trim())
|
|
|
}
|
|
|
- if (
|
|
|
- column &&
|
|
|
- column.fieldId &&
|
|
|
- metaByFieldId.has(String(column.fieldId))
|
|
|
- ) {
|
|
|
- return metaByFieldId.get(String(column.fieldId))
|
|
|
+ if (prop) tryKeys.push(String(prop).trim())
|
|
|
+ for (const k of tryKeys) {
|
|
|
+ if (metaByLabel.has(k)) return metaByLabel.get(k)
|
|
|
+ if (metaByFieldId.has(k)) return metaByFieldId.get(k)
|
|
|
+ if (metaByFieldName.has(k)) return metaByFieldName.get(k)
|
|
|
+ if (metaByName.has(k)) return metaByName.get(k)
|
|
|
}
|
|
|
- // 最后尝试用 prop 当作 label 匹配
|
|
|
- if (prop && metaByLabel.has(String(prop)))
|
|
|
- return metaByLabel.get(String(prop))
|
|
|
return null
|
|
|
}
|
|
|
|
|
|
@@ -1800,26 +1953,33 @@
|
|
|
}
|
|
|
|
|
|
const errors = []
|
|
|
+ const fieldErrMap = {}
|
|
|
const flat = Array.isArray(this.flattenedData) ? this.flattenedData : []
|
|
|
flat.forEach((row, rowIndex) => {
|
|
|
- // 跳过分类行
|
|
|
- if (
|
|
|
- row.parentid === '-1' ||
|
|
|
- row.parentid === -1 ||
|
|
|
- row.parentId === '-1' ||
|
|
|
- row.parentId === -1 ||
|
|
|
- row.isCategory
|
|
|
- ) {
|
|
|
- return
|
|
|
- }
|
|
|
this.dynamicColumns.forEach((col) => {
|
|
|
const prop = col.prop
|
|
|
const value = row[prop]
|
|
|
const meta = getMetaForProp(prop, col)
|
|
|
if (!meta) return
|
|
|
|
|
|
- // 必填
|
|
|
- if (meta.required && isEmpty(value)) {
|
|
|
+ // 必填:兼容 meta.required 以及 meta.isRequired 为 'true'/'1'
|
|
|
+ const requiredFlag = (() => {
|
|
|
+ const r = meta.required
|
|
|
+ const ir = meta.isRequired
|
|
|
+ const toBool = (v) => {
|
|
|
+ if (v === true) return true
|
|
|
+ if (typeof v === 'number') return v === 1
|
|
|
+ if (typeof v === 'string') {
|
|
|
+ const s = v.trim().toLowerCase()
|
|
|
+ return s === 'true' || s === '1'
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return toBool(r) || toBool(ir)
|
|
|
+ })()
|
|
|
+ if (requiredFlag && isEmpty(value)) {
|
|
|
+ const key = `${row.rowid || row.id}::${prop}`
|
|
|
+ fieldErrMap[key] = `第${rowIndex + 1}行【${col.label}】不能为空`
|
|
|
errors.push(`第${rowIndex + 1}行【${col.label}】不能为空`)
|
|
|
return
|
|
|
}
|
|
|
@@ -1891,6 +2051,22 @@
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
+ // 数字字段:若 format 为纯数字,校验“总数字位数”不超过上限
|
|
|
+ if (/^\d+$/.test(fmt)) {
|
|
|
+ const maxDigits = Number(fmt)
|
|
|
+ const digits = String(value).replace(/[^0-9]/g, '')
|
|
|
+ if (digits.length > maxDigits) {
|
|
|
+ const key = `${row.rowid || row.id}::${prop}`
|
|
|
+ fieldErrMap[key] = `第${rowIndex + 1}行【${
|
|
|
+ col.label
|
|
|
+ }】长度不能超过${maxDigits}位`
|
|
|
+ errors.push(
|
|
|
+ `第${rowIndex + 1}行【${
|
|
|
+ col.label
|
|
|
+ }】长度不能超过${maxDigits}位`
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
} else if (t === 'date' || t === 'datetime' || t === 'year') {
|
|
|
if (t === 'year') {
|
|
|
if (!isValidYear(value)) {
|
|
|
@@ -1907,6 +2083,23 @@
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
+ } else {
|
|
|
+ // 非日期/数字:当 meta.format 为纯数字(如 '5')时,按“最大长度”校验
|
|
|
+ if (/^\d+$/.test(fmt)) {
|
|
|
+ const expectLen = Number(fmt)
|
|
|
+ const actualLen = String(value).length
|
|
|
+ if (actualLen > expectLen) {
|
|
|
+ const key = `${row.rowid || row.id}::${prop}`
|
|
|
+ fieldErrMap[key] = `第${rowIndex + 1}行【${
|
|
|
+ col.label
|
|
|
+ }】长度不能超过${expectLen}位`
|
|
|
+ errors.push(
|
|
|
+ `第${rowIndex + 1}行【${
|
|
|
+ col.label
|
|
|
+ }】长度不能超过${expectLen}位`
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 字典/枚举:优先使用 dictCode 的选项校验;否则退回 meta.options
|
|
|
@@ -1952,8 +2145,8 @@
|
|
|
}
|
|
|
})
|
|
|
})
|
|
|
-
|
|
|
this.validationErrors = errors
|
|
|
+ this.fieldErrors = fieldErrMap
|
|
|
return errors
|
|
|
},
|
|
|
isCategoryItem(item) {
|
|
|
@@ -2277,5 +2470,10 @@
|
|
|
::v-deep .el-date-editor {
|
|
|
width: 100%;
|
|
|
}
|
|
|
+
|
|
|
+ // 错误高亮
|
|
|
+ ::v-deep .input-error .el-input__inner {
|
|
|
+ border-color: #f56c6c !important;
|
|
|
+ }
|
|
|
}
|
|
|
</style>
|