流程表单字段显隐(disable/enable),流程监管对办理记录的顺序调整,资金预算字段null校验,后端校验表单必填

master
weizong song 2 months ago
parent 52bf79d603
commit 7e965b4ff4

@ -279,6 +279,34 @@ export function updateFlowLogMeta(data, isLoading = true) {
})
}
// 流程监管:排序草稿、时序校验和一次性提交展示顺序
export function flowLogReorderMeta(data, isLoading = true) {
return request({
method: 'post',
url: '/api/oa/flow/log-reorder-meta',
data,
isLoading,
})
}
export function validateFlowLogOrder(data, isLoading = true) {
return request({
method: 'post',
url: '/api/oa/flow/validate-log-order',
data,
isLoading,
})
}
export function reorderFlowLogs(data, isLoading = true) {
return request({
method: 'post',
url: '/api/oa/flow/reorder-logs',
data,
isLoading,
})
}
export function deleteFlowLog(data, isLoading = true) {
return request({
method: 'post',

@ -0,0 +1,73 @@
const CONTROL_TYPES = new Set(['text', 'select', 'radio'])
const TARGET_TYPES = new Set(['text', 'textarea', 'date', 'datetime', 'select', 'radio'])
function normalizeValue(value) {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function isSimpleRadio(field) {
if (field.type !== 'radio') return true
return !String(field.stub || '').split(/\r?\n/).some((option) => /(?:\[[\s\S]*\]|\{[\s\S]*\})\s*$/.test(option))
}
function isSupportedControl(field) {
if (!field || !field.name || !CONTROL_TYPES.has(field.type)) return false
if (['select', 'radio'].includes(field.type) && field.selection_model) return false
if (field.type === 'select' && Number(field.multiple)) return false
return isSimpleRadio(field)
}
function isSupportedTarget(field) {
return !!field && !!field.name && TARGET_TYPES.has(field.type)
}
/**
* Evaluate persisted field linkage rules for a single main form.
*
* The function deliberately does not mutate form data or field definitions.
* A bad or unsupported rule is ignored (fail open), while a valid unmatched
* rule disables only fields that are writable at the current flow node.
*/
export function evaluateFieldLinkage({ fields, form, writeable }) {
const list = Array.isArray(fields) ? fields : []
const writableIds = new Set((Array.isArray(writeable) ? writeable : []).map((id) => Number(id)))
const fieldsById = new Map(list.map((field) => [Number(field.id), field]))
const disabledFieldIds = new Set()
const disabledFieldNames = new Set()
const states = {}
list.forEach((field) => {
const rule = field && field.visibility_rule
if (!rule) return
const state = { active: false, disabled: false, reason: 'invalid_rule' }
states[field.id] = state
if (!isSupportedTarget(field) || rule.mode !== 'show_when' || !Array.isArray(rule.values)) return
const values = rule.values.map(normalizeValue).filter(Boolean)
const controlField = fieldsById.get(Number(rule.control_field_id))
if (!values.length || !isSupportedControl(controlField)) return
if (!writableIds.has(Number(field.id)) || !writableIds.has(Number(controlField.id))) {
state.reason = 'not_both_writable'
return
}
state.active = true
const controlValue = normalizeValue((form || {})[controlField.name])
if (values.includes(controlValue)) {
state.reason = 'value_matched'
return
}
state.disabled = true
state.reason = 'value_not_matched'
disabledFieldIds.add(Number(field.id))
disabledFieldNames.add(field.name)
})
return { disabledFieldIds, disabledFieldNames, states }
}
export default evaluateFieldLinkage

@ -0,0 +1,32 @@
/**
* 将流转记录转为排序模式可拖拽的单元同一会签组只能整体移动
* 默认草稿按开始 -> 历史记录的实际流转时间 -> 当前办理组织
* 开始节点作为固定锚点展示用户可看到但不能拖拽调整
*/
export function buildFlowLogReorderUnits(logs = [], normalize = true) {
const groups = new Map()
;(logs || []).forEach((log) => {
const key = log.jointly_sign_id ? `joint-${log.jointly_sign_id}` : `log-${log.id}`
if (!groups.has(key)) groups.set(key, { key, logs: [] })
groups.get(key).logs.push(log)
})
const units = Array.from(groups.values()).map((unit) => {
unit.logs.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')) || Number(a.id) - Number(b.id))
const isStart = unit.logs.some((log) => log.node?.category === 'start')
const isHandling = unit.logs.some((log) => Number(log.status) === 0)
unit.locked = isStart
unit.lockReason = isStart ? '开始节点固定展示,不参与拖拽排序' : ''
unit.bucket = isStart ? 0 : (isHandling ? 2 : 1)
unit.time = unit.logs.map((log) => log.created_at || '').sort()[0] || ''
return unit
})
return normalize
? units.sort((a, b) => a.bucket - b.bucket || String(a.time).localeCompare(String(b.time)) || a.key.localeCompare(b.key))
: units
}
export function flattenFlowLogReorderUnits(units = []) {
return (units || []).reduce((ids, unit) => ids.concat(unit.logs.map((log) => Number(log.id))), [])
}

@ -188,6 +188,7 @@ export default function formBuilder(
) {
let target = row ? row : this.form;
let formItem;
const linkageDisabled = !!info._linkageDisabled;
//下拉选项
let options = [];
if (info?.selection_model) {
@ -207,6 +208,7 @@ export default function formBuilder(
formItem = h("el-input", {
props: {
value: target[info.name],
disabled: linkageDisabled,
clearable: true,
placeholder: info.help_text,
},
@ -228,6 +230,7 @@ export default function formBuilder(
minRows: 2,
},
value: target[info.name],
disabled: linkageDisabled,
clearable: true,
placeholder: info.help_text,
},
@ -248,6 +251,7 @@ export default function formBuilder(
"value-format": "yyyy-MM-dd",
format: "yyyy年MM月dd日",
value: target[info.name],
disabled: linkageDisabled,
clearable: true,
editable:false,
placeholder: info.help_text,
@ -275,6 +279,7 @@ export default function formBuilder(
"value-format": "yyyy-MM-dd HH:mm:ss",
format: "yyyy-MM-dd HH:mm",
value: target[info.name],
disabled: linkageDisabled,
clearable: true,
editable:false,
placeholder: info.help_text,
@ -318,6 +323,7 @@ export default function formBuilder(
{
props: {
value: getSelectValue(),
disabled: linkageDisabled,
clearable: true,
placeholder: info.help_text,
multiple: !!info.multiple,
@ -387,6 +393,7 @@ export default function formBuilder(
{
props: {
value: radioParent,
disabled: linkageDisabled,
},
attrs: {
placeholder: info.help_text,
@ -1613,6 +1620,9 @@ export default function formBuilder(
prop: info.name,
label: info.label_show ? info.label : "",
"label-width": !info.label_show ? "0" : "",
// 仅让当前被联动禁用的字段跳过校验;不要改动 el-form
// 的整套 rules以免触发其它字段的全量校验。
rules: linkageDisabled ? [] : undefined,
},
style: {
// +1为了工作标题往下顺延
@ -1761,6 +1771,7 @@ export default function formBuilder(
props: {
name: info.name,
value: target[info.name],
disabled: linkageDisabled,
clearable: true,
placeholder: info.help_text || info.label,
},
@ -1778,6 +1789,7 @@ export default function formBuilder(
name: info.name,
rows: 2,
type: "textarea",
disabled: linkageDisabled,
value: target[info.name],
clearable: true,
placeholder: info.help_text || info.label,
@ -1826,6 +1838,7 @@ export default function formBuilder(
{
props: {
value: mRadioParent,
disabled: linkageDisabled,
},
on: {
input: onMParentChange,
@ -1903,6 +1916,7 @@ export default function formBuilder(
"value-format": "yyyy-MM-dd",
format: "yyyy年MM月dd日",
value: target[info.name],
disabled: linkageDisabled,
clearable: true,
editable:false,
placeholder: info.help_text || info.label,
@ -1930,6 +1944,7 @@ export default function formBuilder(
"value-format": "yyyy-MM-dd HH:mm:ss",
format: "yyyy-MM-dd HH:mm",
value: target[info.name],
disabled: linkageDisabled,
clearable: true,
editable:false,
placeholder: info.help_text || info.label,
@ -1992,6 +2007,7 @@ export default function formBuilder(
h('van-field', {
props: {
value: getDetailMobileSelectValue(),
disabled: linkageDisabled,
clickable: true,
readonly: true,
'label-width': 0,
@ -1999,6 +2015,7 @@ export default function formBuilder(
},
on: {
click: _ => {
if (linkageDisabled) return;
this.$refs[`popup-${info.name}-${row ? 'c' : 'b'}`].show()
}
}

@ -6,6 +6,7 @@ import request from '@/utils/request'
import moment from "moment/moment";
import {defaultModalSize} from "@/settings";
import { getToken } from '@/utils/auth'
import { evaluateFieldLinkage } from "@/utils/fieldLinkage";
export default {
props: {
config: {
@ -295,7 +296,18 @@ export default {
}
},
},
computed: {},
computed: {
linkage() {
return evaluateFieldLinkage({
fields: this.fields,
form: this.form,
writeable: this.writeable,
});
},
linkageDisabledFieldNames() {
return Array.from(this.linkage.disabledFieldNames);
},
},
watch: {
originalForm(newVal) {
this.form = deepCopy(newVal)
@ -318,6 +330,12 @@ export default {
},
deep: true
},
linkageDisabledFieldNames(newNames, oldNames) {
const previous = new Set(oldNames || []);
const newlyDisabled = (newNames || []).filter((name) => !previous.has(name));
if (!newlyDisabled.length) return;
this.$nextTick(() => this.$refs.elForm?.clearValidate(newlyDisabled));
},
scriptContent(newVal) {
if (newVal) {
try {
@ -367,6 +385,7 @@ export default {
_readable:
this.readable.indexOf(field.id) !== -1 || field.type === "label",
_writeable: this.writeable.indexOf(field.id) !== -1,
_linkageDisabled: this.linkage.disabledFieldIds.has(Number(field.id)),
}));
console.log("authFields",authFields)
if (this.needFlowTitle) {
@ -396,7 +415,10 @@ export default {
model: this.form,
"label-position": "right",
"label-width": this.device === 'mobile' ? "80px" : "130px",
// disabled
// rules Element UI validate-on-rule-change
rules: this.rules,
"validate-on-rule-change": false,
"inline-message": true,
},
},

@ -6,6 +6,7 @@ import {deepCopy} from "@/utils";
import {PopupManager} from "element-ui/lib/utils/popup";
import request from '@/utils/request'
import {getToken} from '@/utils/auth'
import { evaluateFieldLinkage } from "@/utils/fieldLinkage";
export default {
components: {
MobileMultipleSelect
@ -235,7 +236,15 @@ export default {
}
},
},
computed: {},
computed: {
linkage() {
return evaluateFieldLinkage({
fields: this.fields,
form: this.form,
writeable: this.writeable,
});
},
},
watch: {
info(newVal) {
let keys = newVal.map(i => i.name)
@ -298,7 +307,8 @@ export default {
const authFields = this.fields.map(field => ({
...field,
_readable: this.readable.indexOf(field.id) !== -1,
_writeable: this.writeable.indexOf(field.id) !== -1
_writeable: this.writeable.indexOf(field.id) !== -1,
_linkageDisabled: this.linkage.disabledFieldIds.has(Number(field.id)),
}))
const subFields = Array.from(this.subForm).map(i => i[1]?.customModel?.fields).filter(i => i).flat()
if (this.needFlowTitle) {

@ -0,0 +1,300 @@
<template>
<el-dialog
title="调整流转记录展示顺序"
:visible="visible"
:close-on-click-modal="false"
width="860px"
@close="$emit('update:visible', false)"
@closed="reset"
>
<div class="reorder-intro">
<i class="el-icon-info" />
此处仅调整流程图和流转记录的展示顺序不修改实际审批时间当前待办或流程连线
</div>
<div class="reorder-toolbar">
<span>开始节点固定展示不可拖拽拖拽手柄调整其余记录</span>
</div>
<el-alert v-if="autoNormalized" title="草稿已按开始节点、实际流转时间和当前办理节点预整理,仅供复核参考。" type="info" :closable="false" show-icon />
<div v-if="issues.length" class="reorder-issues">
<div v-for="(issue, index) in issues" :key="`${issue.code}-${index}`" :class="['reorder-issue', `is-${issue.level}`]">
<i :class="issue.level === 'error' ? 'el-icon-circle-close' : 'el-icon-warning-outline'" />
{{ issue.message }}
</div>
</div>
<div v-if="units.length" class="reorder-list">
<div
v-for="(unit, index) in units"
:key="unit.key"
:class="['reorder-card', { 'is-locked': unit.locked, 'is-dragging': dragIndex === index }]"
:draggable="!unit.locked"
@dragstart="startDrag(index, $event)"
@dragover.prevent
@drop.prevent="dropAt(index)"
>
<div class="reorder-drag-handle" :title="unit.lockReason || '拖拽调整顺序'">
<i :class="unit.locked ? 'el-icon-lock' : 'el-icon-rank'" />
</div>
<div :class="['reorder-validation-status', `is-${unitLevel(unit)}`]" :title="unitValidationText(unit)">
<i :class="statusIcon(unit)" />
</div>
<div class="reorder-sequence">{{ index + 1 }}</div>
<div class="reorder-card-content">
<div v-for="log in unit.logs" :key="log.id" class="reorder-log-line">
<strong>{{ log.node ? log.node.name : '节点已调整' }}</strong>
<span>{{ log.user ? log.user.name : '未知人员' }} · {{ statusText(log.status) }}</span>
<small>流转{{ formatDate(log.created_at) }} 办理{{ formatDate(log.updated_at) }}</small>
</div>
</div>
<div v-if="unit.logs.length > 1" class="reorder-group-tag"> {{ unit.logs.length }} </div>
</div>
</div>
<div v-else class="reorder-empty">暂无可调整的流转记录</div>
<div v-if="warningCount" class="reorder-reason">
<el-alert title="存在时序提示,可填写调整原因后保存。" type="warning" :closable="false" show-icon />
<el-input v-model="reason" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="可选:填写调整原因" />
</div>
<div slot="footer" class="reorder-footer">
<span>已调整 {{ changedCount }} 个位置</span>
<div>
<el-button @click="restore"></el-button>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="errorCount > 0" @click="save"></el-button>
</div>
</div>
</el-dialog>
</template>
<script>
import { flowLogReorderMeta, validateFlowLogOrder, reorderFlowLogs } from '@/api/flow'
import { buildFlowLogReorderUnits, flattenFlowLogReorderUnits } from '@/utils/flowLogReorder'
export default {
name: 'FlowLogReorderDialog',
props: {
visible: { type: Boolean, default: false },
flowId: { type: Number, required: true },
},
data() {
return {
revision: '',
originalUnits: [],
units: [],
autoNormalized: false,
issues: [],
reason: '',
dragIndex: null,
validating: false,
submitting: false,
}
},
computed: {
errorCount() { return this.issues.filter((issue) => issue.level === 'error').length },
warningCount() { return this.issues.filter((issue) => issue.level === 'warning').length },
changedCount() {
const original = flattenFlowLogReorderUnits(this.originalUnits)
const current = this.orderedLogIds()
return current.reduce((count, id, index) => count + (original[index] !== id ? 1 : 0), 0)
},
},
watch: {
visible(value) {
if (value) this.open()
},
},
methods: {
statusText(status) {
return new Map([[1, '已完成'], [0, '办理中'], [-1, '退回'], [-2, '会签回退']]).get(Number(status)) || '未知'
},
formatDate(value) { return value ? this.$moment(value).format('YYYY-MM-DD HH:mm:ss') : '—' },
orderedLogIds() { return flattenFlowLogReorderUnits(this.units) },
async open() {
try {
const result = await flowLogReorderMeta({ flow_id: this.flowId })
const persisted = buildFlowLogReorderUnits(result.logs || [], false)
this.originalUnits = JSON.parse(JSON.stringify(persisted))
const draft = buildFlowLogReorderUnits(result.logs || [])
this.units = JSON.parse(JSON.stringify(draft))
this.autoNormalized = JSON.stringify(this.originalUnits.map((unit) => unit.key)) !== JSON.stringify(this.units.map((unit) => unit.key))
this.revision = result.revision || ''
this.issues = []
this.reason = ''
await this.validateOrder()
} catch (error) {
console.error(error)
}
},
reset() {
this.revision = ''
this.originalUnits = []
this.units = []
this.autoNormalized = false
this.issues = []
this.reason = ''
this.dragIndex = null
this.validating = false
this.submitting = false
},
restore() {
this.units = JSON.parse(JSON.stringify(this.originalUnits))
this.autoNormalized = false
this.issues = []
this.validateOrder()
},
startDrag(index, event) {
if (this.units[index]?.locked) return event.preventDefault()
this.dragIndex = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(index))
},
dropAt(targetIndex) {
const sourceIndex = this.dragIndex
this.dragIndex = null
if (sourceIndex === null || sourceIndex === targetIndex || this.units[targetIndex]?.locked) return
const [unit] = this.units.splice(sourceIndex, 1)
this.units.splice(sourceIndex < targetIndex ? targetIndex - 1 : targetIndex, 0, unit)
this.issues = []
},
async validateOrder() {
if (!this.revision || !this.units.length) return
this.validating = true
try {
const result = await validateFlowLogOrder({ flow_id: this.flowId, ordered_log_ids: this.orderedLogIds(), revision: this.revision })
this.issues = result.issues || []
if (result.revision) this.revision = result.revision
} catch (error) {
console.error(error)
} finally {
this.validating = false
}
},
showValidationResult(issues = []) {
const errors = issues.filter((issue) => issue.level === 'error')
const warnings = issues.filter((issue) => issue.level === 'warning')
if (!errors.length && !warnings.length) {
this.$message.success('时序校验通过')
return
}
const h = this.$createElement
const renderIssueGroup = (title, list, color) => list.length
? h('div', { style: { marginTop: '12px' } }, [
h('div', { style: { fontWeight: 600, color, marginBottom: '8px' } }, title),
h('ol', { style: { margin: 0, paddingLeft: '22px' } }, list.map((issue) => h('li', {
style: {
marginBottom: '8px',
lineHeight: '22px',
color: '#606266',
},
}, issue.message))),
])
: null
const content = h('div', { style: { maxWidth: '680px' } }, [
h('div', {
style: {
marginBottom: '4px',
lineHeight: '22px',
color: '#606266',
},
}, errors.length ? '存在需要处理的错误,请调整后再保存。' : '未发现阻断错误,以下提示仅供复核。'),
renderIssueGroup(`错误 ${errors.length}`, errors, '#f56c6c'),
renderIssueGroup(`提示 ${warnings.length}`, warnings, '#e6a23c'),
].filter(Boolean))
this.$alert(content, `时序校验:${errors.length}项错误,${warnings.length}项提示`, {
confirmButtonText: '知道了',
closeOnClickModal: false,
dangerouslyUseHTMLString: false,
})
},
async validatePersistedOrder(options = {}) {
if (!this.flowId) return null
this.validating = true
try {
const meta = await flowLogReorderMeta({ flow_id: this.flowId })
const units = buildFlowLogReorderUnits(meta.logs || [], false)
const result = await validateFlowLogOrder({
flow_id: this.flowId,
ordered_log_ids: flattenFlowLogReorderUnits(units),
revision: meta.revision || '',
})
if (!options.silent) this.showValidationResult(result.issues || [])
return result
} catch (error) {
console.error(error)
return null
} finally {
this.validating = false
}
},
unitIssues(unit) {
const ids = new Set(unit.logs.map((log) => Number(log.id)))
return this.issues.filter((issue) => (issue.log_ids || []).some((id) => ids.has(Number(id))))
},
unitLevel(unit) {
const issues = this.unitIssues(unit)
if (issues.some((issue) => issue.level === 'error')) return 'error'
return issues.some((issue) => issue.level === 'warning') ? 'warning' : 'success'
},
unitValidationText(unit) {
const issues = this.unitIssues(unit)
return issues.length ? issues.map((issue) => issue.message).join('\n') : '时序校验通过'
},
statusIcon(unit) {
const level = this.unitLevel(unit)
return level === 'error' ? 'el-icon-circle-close' : (level === 'warning' ? 'el-icon-warning-outline' : 'el-icon-circle-check')
},
async save() {
await this.validateOrder()
if (this.errorCount) return
try {
await this.$confirm(`本次将调整 ${this.changedCount} 个位置;排序只影响展示顺序,不改变实际审批时间和当前待办。`, '确认保存排序', { type: 'warning', closeOnClickModal: false })
} catch (error) {
return
}
this.submitting = true
try {
await reorderFlowLogs({ flow_id: this.flowId, ordered_log_ids: this.orderedLogIds(), revision: this.revision, reason: this.reason.trim() })
this.$message.success('展示顺序已保存')
await this.validatePersistedOrder()
this.$emit('saved')
this.$emit('update:visible', false)
} catch (error) {
console.error(error)
} finally {
this.submitting = false
}
},
},
}
</script>
<style scoped lang="scss">
.reorder-intro { margin-bottom: 12px; padding: 10px 12px; color: #606266; background: #f4f7fb; border-radius: 4px; line-height: 20px; }
.reorder-intro i { color: #409eff; margin-right: 6px; }
.reorder-toolbar, .reorder-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.reorder-toolbar { margin-bottom: 12px; color: #909399; font-size: 13px; }
.reorder-list { max-height: 460px; overflow-y: auto; padding: 2px; }
.reorder-card { display: flex; align-items: stretch; min-height: 74px; margin-bottom: 8px; border: 1px solid #dcdfe6; border-radius: 6px; background: #fff; }
.reorder-card:not(.is-locked) { cursor: grab; }
.reorder-card:not(.is-locked):hover { border-color: #409eff; box-shadow: 0 2px 8px rgba(64, 158, 255, .12); }
.reorder-card.is-dragging { opacity: .45; }
.reorder-card.is-locked { background: #fafafa; color: #909399; }
.reorder-drag-handle, .reorder-validation-status, .reorder-sequence { display: flex; align-items: center; justify-content: center; flex: 0 0 auto; }
.reorder-drag-handle { width: 38px; color: #909399; }
.reorder-validation-status { width: 28px; }
.reorder-validation-status.is-success { color: #67c23a; }
.reorder-validation-status.is-warning { color: #e6a23c; }
.reorder-validation-status.is-error { color: #f56c6c; }
.reorder-sequence { width: 34px; color: #409eff; font-weight: 600; border-right: 1px solid #ebeef5; }
.reorder-card-content { flex: 1; min-width: 0; padding: 10px 14px; }
.reorder-log-line { display: flex; align-items: center; flex-wrap: wrap; gap: 0 8px; line-height: 22px; }
.reorder-log-line strong { color: #303133; }
.reorder-log-line span { color: #606266; }
.reorder-log-line small { width: 100%; color: #909399; }
.reorder-group-tag { align-self: center; margin-right: 14px; padding: 2px 7px; color: #409eff; background: #ecf5ff; border-radius: 10px; font-size: 12px; }
.reorder-issues { margin: 12px 0; }
.reorder-issue { margin: 5px 0; padding: 7px 10px; border-radius: 4px; font-size: 13px; }
.reorder-issue.is-warning { color: #8a6d1f; background: #fdf6ec; }
.reorder-issue.is-error { color: #a94442; background: #fef0f0; }
.reorder-reason { margin-top: 14px; }
.reorder-reason .el-input { margin-top: 8px; }
.reorder-empty { padding: 36px; text-align: center; color: #909399; }
</style>

@ -553,6 +553,19 @@
size="small"
@click="openInsertHistoryLogDialog()"
>新增节点</el-button>
<el-button
v-if="canManageFlowLog && $route.query.flow_id"
icon="el-icon-rank"
size="small"
@click="openReorderFlowLogDialog"
>调整顺序</el-button>
<el-button
v-if="canManageFlowLog && $route.query.flow_id"
icon="el-icon-circle-check"
size="small"
:loading="reorderValidating"
@click="validateCurrentFlowLogOrder"
>校验时序合理性</el-button>
</div>
<vxe-table
style="margin-top: 10px;"
@ -746,6 +759,72 @@
<el-backtop></el-backtop>
<!-- 编辑流转记录 -->
<el-dialog
title="调整流转记录展示顺序"
:visible.sync="isShowReorderFlowLog"
:close-on-click-modal="false"
width="860px"
class="flow-log-reorder-dialog"
@closed="resetReorderFlowLogDialog"
>
<div class="reorder-intro">
<i class="el-icon-info"></i>
此处仅调整流程图和流转记录的展示顺序不修改实际审批时间当前待办或流程连线
</div>
<div class="reorder-toolbar">
<span>开始节点固定展示不可拖拽拖拽手柄调整其余记录</span>
</div>
<el-alert v-if="reorderAutoNormalized" title="草稿已按开始节点、实际流转时间和当前办理节点预整理,仅供复核参考。" type="info" :closable="false" show-icon />
<div class="reorder-issues" v-if="reorderIssues.length">
<div v-for="(issue, index) in reorderIssues" :key="`${issue.code}-${index}`" :class="['reorder-issue', `is-${issue.level}`]">
<i :class="issue.level === 'error' ? 'el-icon-circle-close' : 'el-icon-warning-outline'"></i>
{{ issue.message }}
</div>
</div>
<div class="reorder-list" v-if="reorderUnits.length">
<div
v-for="(unit, index) in reorderUnits"
:key="unit.key"
:class="['reorder-card', { 'is-locked': unit.locked, 'is-dragging': reorderDragIndex === index }]"
:draggable="!unit.locked"
@dragstart="handleReorderDragStart(index, $event)"
@dragover.prevent
@drop.prevent="handleReorderDrop(index)"
>
<div class="reorder-drag-handle" :title="unit.lockReason || '拖拽调整顺序'">
<i :class="unit.locked ? 'el-icon-lock' : 'el-icon-rank'"></i>
</div>
<div :class="['reorder-validation-status', `is-${reorderUnitLevel(unit)}`]" :title="reorderUnitValidationText(unit)">
<i :class="reorderUnitLevel(unit) === 'error' ? 'el-icon-circle-close' : (reorderUnitLevel(unit) === 'warning' ? 'el-icon-warning-outline' : 'el-icon-circle-check')"></i>
</div>
<div class="reorder-sequence">{{ index + 1 }}</div>
<div class="reorder-card-content">
<template v-for="log in unit.logs">
<div :key="log.id" class="reorder-log-line">
<strong>{{ log.node ? log.node.name : '节点已调整' }}</strong>
<span>{{ log.user ? log.user.name : '未知人员' }} · {{ myStatus.get(log.status) }}</span>
<small>流转{{ formatFlowLogDate(log.created_at) }} 办理{{ formatFlowLogDate(log.updated_at) }}</small>
</div>
</template>
</div>
<div v-if="unit.logs.length > 1" class="reorder-group-tag"> {{ unit.logs.length }} </div>
</div>
</div>
<div v-else class="reorder-empty">暂无可调整的流转记录</div>
<div class="reorder-reason" v-if="reorderWarningCount">
<el-alert title="存在时序提示,可填写调整原因后保存。" type="warning" :closable="false" show-icon />
<el-input v-model="reorderReason" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="可选:填写调整原因" />
</div>
<div slot="footer" class="dialog-footer reorder-footer">
<span>已调整 {{ reorderChangedCount }} 个位置</span>
<div>
<el-button @click="restoreReorderFlowLog"></el-button>
<el-button @click="isShowReorderFlowLog = false">取消</el-button>
<el-button type="primary" :loading="reorderSubmitting" :disabled="reorderErrorCount > 0" @click="submitReorderFlowLog"></el-button>
</div>
</div>
</el-dialog>
<el-dialog
title="编辑流转记录"
:visible.sync="isShowTime"
@ -831,15 +910,6 @@
placeholder="退回状态时请输入原因"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="editFlowLogForm.sort"
:min="1"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="耗时">
<el-input :value="editFlowLogUseTime" disabled />
</el-form-item>
@ -1221,16 +1291,20 @@ import {
fieldConfig, flowList,
deleteFlowLog,
freeAssignPre,
flowLogReorderMeta,
insertHistoryLog,
preConfig,
preDeal,
updateNodeTime,
updateFlowLogMeta,
validateFlowLogOrder,
reorderFlowLogs,
view,
} from "@/api/flow";
import { getPaymentsByFlowId, getPaymentsByContractId, getBudgetContractDetail, getPlannedExpenditure, getPlannedExpenditureTemplatesByCategory, getPaymentCategoryTemplateElements, getDetailTableFields, getOaFlowDetails, getTemplateElementDetail } from "@/api/payment";
import { show as meetingMinutesShow } from "@/api/meetingMinutes";
import { deepCopy } from "@/utils";
import { buildFlowLogReorderUnits, flattenFlowLogReorderUnits } from '@/utils/flowLogReorder'
import { validation, validationName } from "@/utils/validate";
import { print } from "@/utils/print";
import JSONBigint from 'json-bigint'
@ -1268,9 +1342,18 @@ export default {
status: 1,
created_at: '',
updated_at: '',
sort: 1,
reason: '',
},
isShowReorderFlowLog: false,
reorderSubmitting: false,
reorderValidating: false,
reorderRevision: '',
reorderOriginalUnits: [],
reorderUnits: [],
reorderAutoNormalized: false,
reorderIssues: [],
reorderReason: '',
reorderDragIndex: null,
isShowUserDialog: false,
selectedUserId: '',
userList: [],
@ -1291,6 +1374,7 @@ export default {
info: [],
config: {},
writeableFields: [],
nodeRequiredFields: [],
readableFields: [],
subConfig: new Map(),
myStatus: new Map([
@ -1760,8 +1844,12 @@ export default {
generateForm(object, fields, relation = false, pname) {
fields.forEach((field) => {
if (field.rules && field.rules.length > 0 && this.writeableFields.find(i => i === field.id) && !relation) {
this.rules[field.name] = field.rules.map((rule) => {
const fieldRules = Array.isArray(field.rules) ? [...field.rules] : []
if (this.nodeRequiredFields.includes(field.id) && !fieldRules.includes('required')) {
fieldRules.push('required')
}
if (fieldRules.length > 0 && this.writeableFields.find(i => i === field.id) && !relation) {
this.rules[field.name] = fieldRules.map((rule) => {
switch (rule) {
case "required":
if (field.type === 'relation') {
@ -2691,6 +2779,7 @@ export default {
? this.fields?.map((i) => i.id)
: this.config?.currentNode?.readable || [];
this.writeableFields = this.config?.currentNode?.writeable || [];
this.nodeRequiredFields = this.config?.currentNode?.required || [];
// form
this.generateForm(this.form, fields);
// ID
@ -2783,6 +2872,7 @@ export default {
? this.fields?.map((i) => i.id)
: this.config?.currentNode?.readable || [];
this.writeableFields = this.config?.currentNode?.writeable || [];
this.nodeRequiredFields = this.config?.currentNode?.required || [];
this.generateForm(this.form, fields);
this.handleDefaultJSON();
this.form = Object.assign({}, this.form);
@ -2820,6 +2910,7 @@ export default {
? this.fields?.map((i) => i.id)
: this.config?.currentNode?.readable || [];
this.writeableFields = this.config?.currentNode?.writeable || [];
this.nodeRequiredFields = this.config?.currentNode?.required || [];
this.generateForm(this.form, fields);
// ID
if (res?.flow?.related_flows && res.flow.related_flows.length > 0) {
@ -3033,6 +3124,184 @@ export default {
this.openEditLogDialog(row)
}
},
formatFlowLogDate(value) {
return value ? this.$moment(value).format('YYYY-MM-DD HH:mm:ss') : '—'
},
reorderOrderedLogIds() {
return flattenFlowLogReorderUnits(this.reorderUnits)
},
async openReorderFlowLogDialog() {
try {
const result = await flowLogReorderMeta({ flow_id: Number(this.$route.query.flow_id) })
const persisted = buildFlowLogReorderUnits(result.logs || [], false)
const draft = buildFlowLogReorderUnits(result.logs || [])
this.reorderRevision = result.revision || ''
this.reorderOriginalUnits = deepCopy(persisted)
this.reorderUnits = deepCopy(draft)
this.reorderAutoNormalized = JSON.stringify(persisted.map((unit) => unit.key)) !== JSON.stringify(draft.map((unit) => unit.key))
this.reorderIssues = []
this.reorderReason = ''
this.isShowReorderFlowLog = true
await this.validateReorderFlowLog()
} catch (err) {
console.error(err)
}
},
resetReorderFlowLogDialog() {
this.reorderSubmitting = false
this.reorderValidating = false
this.reorderRevision = ''
this.reorderOriginalUnits = []
this.reorderUnits = []
this.reorderAutoNormalized = false
this.reorderIssues = []
this.reorderReason = ''
this.reorderDragIndex = null
},
restoreReorderFlowLog() {
this.reorderUnits = deepCopy(this.reorderOriginalUnits)
this.reorderAutoNormalized = false
this.reorderIssues = []
this.validateReorderFlowLog()
},
handleReorderDragStart(index, event) {
if (this.reorderUnits[index]?.locked) {
event.preventDefault()
return
}
this.reorderDragIndex = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(index))
},
handleReorderDrop(targetIndex) {
const sourceIndex = this.reorderDragIndex
this.reorderDragIndex = null
if (sourceIndex === null || sourceIndex === targetIndex || this.reorderUnits[targetIndex]?.locked) return
const [unit] = this.reorderUnits.splice(sourceIndex, 1)
const adjustedTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
this.reorderUnits.splice(adjustedTarget, 0, unit)
this.reorderIssues = []
},
showReorderValidationResult(issues = []) {
const errors = issues.filter((issue) => issue.level === 'error')
const warnings = issues.filter((issue) => issue.level === 'warning')
if (!errors.length && !warnings.length) {
this.$message.success('时序校验通过')
return
}
const h = this.$createElement
const renderIssueGroup = (title, list, color) => list.length
? h('div', { style: { marginTop: '12px' } }, [
h('div', { style: { fontWeight: 600, color, marginBottom: '8px' } }, title),
h('ol', { style: { margin: 0, paddingLeft: '22px' } }, list.map((issue) => h('li', {
style: {
marginBottom: '8px',
lineHeight: '22px',
color: '#606266',
},
}, issue.message))),
])
: null
const content = h('div', { style: { maxWidth: '680px' } }, [
h('div', {
style: {
marginBottom: '4px',
lineHeight: '22px',
color: '#606266',
},
}, errors.length ? '存在需要处理的错误,请调整后再保存。' : '未发现阻断错误,以下提示仅供复核。'),
renderIssueGroup(`错误 ${errors.length}`, errors, '#f56c6c'),
renderIssueGroup(`提示 ${warnings.length}`, warnings, '#e6a23c'),
].filter(Boolean))
this.$alert(content, `时序校验:${errors.length}项错误,${warnings.length}项提示`, {
confirmButtonText: '知道了',
closeOnClickModal: false,
dangerouslyUseHTMLString: false,
})
},
async validateCurrentFlowLogOrder(options = {}) {
const flowId = Number(this.$route.query.flow_id)
if (!flowId) return null
this.reorderValidating = true
try {
let orderedLogIds = []
let revision = ''
const useDraft = this.isShowReorderFlowLog && this.reorderUnits.length && this.reorderRevision
if (useDraft) {
orderedLogIds = this.reorderOrderedLogIds()
revision = this.reorderRevision
} else {
const meta = await flowLogReorderMeta({ flow_id: flowId })
orderedLogIds = flattenFlowLogReorderUnits(buildFlowLogReorderUnits(meta.logs || [], false))
revision = meta.revision || ''
}
if (!orderedLogIds.length || !revision) return null
const result = await validateFlowLogOrder({
flow_id: flowId,
ordered_log_ids: orderedLogIds,
revision,
})
if (useDraft) {
this.reorderIssues = result.issues || []
if (result.revision) this.reorderRevision = result.revision
}
if (!options.silent) this.showReorderValidationResult(result.issues || [])
return result
} catch (err) {
console.error(err)
return null
} finally {
this.reorderValidating = false
}
},
async validateReorderFlowLog(options = {}) {
return this.validateCurrentFlowLogOrder({ ...options, silent: options.silent !== undefined ? options.silent : true })
},
reorderUnitIssues(unit) {
const ids = new Set(unit.logs.map((log) => Number(log.id)))
return this.reorderIssues.filter((issue) => (issue.log_ids || []).some((id) => ids.has(Number(id))))
},
reorderUnitLevel(unit) {
const issues = this.reorderUnitIssues(unit)
if (issues.some((issue) => issue.level === 'error')) return 'error'
if (issues.some((issue) => issue.level === 'warning')) return 'warning'
return 'success'
},
reorderUnitValidationText(unit) {
const issues = this.reorderUnitIssues(unit)
return issues.length ? issues.map((issue) => issue.message).join('\n') : '时序校验通过'
},
async submitReorderFlowLog() {
await this.validateReorderFlowLog()
if (this.reorderErrorCount > 0) return
try {
await this.$confirm(
`本次将调整 ${this.reorderChangedCount} 个位置;排序只影响展示顺序,不改变实际审批时间和当前待办。`,
'确认保存排序',
{ type: 'warning', closeOnClickModal: false }
)
} catch (err) {
return
}
this.reorderSubmitting = true
try {
const result = await reorderFlowLogs({
flow_id: Number(this.$route.query.flow_id),
ordered_log_ids: this.reorderOrderedLogIds(),
revision: this.reorderRevision,
reason: this.reorderReason.trim(),
})
this.$message.success('展示顺序已保存')
this.isShowReorderFlowLog = false
if (Array.isArray(result.logs)) this.config.logs = result.logs
await this.getConfig()
await this.validateCurrentFlowLogOrder()
} catch (err) {
console.error(err)
} finally {
this.reorderSubmitting = false
}
},
resetInsertHistoryLogForm(defaults = {}) {
const normalizedDefaults = {
...defaults,
@ -3082,7 +3351,6 @@ export default {
flow_node_id: defaults.flow_node_id === '' || defaults.flow_node_id === undefined || defaults.flow_node_id === null ? '' : Number(defaults.flow_node_id),
user_id: defaults.user_id === '' || defaults.user_id === undefined || defaults.user_id === null ? '' : Number(defaults.user_id),
status: defaults.status === '' || defaults.status === undefined || defaults.status === null ? 1 : Number(defaults.status),
sort: defaults.sort === '' || defaults.sort === undefined || defaults.sort === null ? 1 : Number(defaults.sort),
};
this.editFlowLogSubmitting = false;
this.editFlowLogForm = {
@ -3093,7 +3361,6 @@ export default {
status: 1,
created_at: '',
updated_at: '',
sort: 1,
reason: '',
...normalizedDefaults,
};
@ -3114,7 +3381,6 @@ export default {
status: latestRow.status,
created_at: latestRow.created_at,
updated_at: latestRow.updated_at,
sort: latestRow.sort || 1,
reason: latestRow.reason || '',
});
this.isShowTime = true;
@ -3161,7 +3427,6 @@ export default {
status: Number(this.editFlowLogForm.status),
created_at: this.editFlowLogForm.created_at,
updated_at: this.editFlowLogForm.updated_at,
sort: Number(this.editFlowLogForm.sort),
reason: this.editFlowLogForm.status < 0 ? (this.editFlowLogForm.reason || '') : '',
});
if (Array.isArray(res?.logs)) {
@ -3263,13 +3528,22 @@ export default {
},
},
computed: {
reorderErrorCount() {
return this.reorderIssues.filter((issue) => issue.level === 'error').length
},
reorderWarningCount() {
return this.reorderIssues.filter((issue) => issue.level === 'warning').length
},
reorderChangedCount() {
const original = flattenFlowLogReorderUnits(this.reorderOriginalUnits)
const current = flattenFlowLogReorderUnits(this.reorderUnits)
return current.reduce((count, id, index) => count + (original[index] !== id ? 1 : 0), 0)
},
canManageFlowLog() {
const globalRoles = this.$store.state.user.roles || [];
const moduleRoles = this.$store.state.user.moduleRoles?.oa || [];
const adminId = Number(this.$store.state.user.adminId);
const username = this.$store.state.user.username;
return adminId === 1
|| username === 'admin'
|| globalRoles.includes('全局流程监管')
|| moduleRoles.includes('全局流程监管');
},
@ -3411,7 +3685,6 @@ export default {
trigger: 'change',
},
],
sort: [{ required: true, message: '请输入排序', trigger: 'change' }],
reason: [{
validator: (rule, value, callback) => {
if (this.editFlowLogForm.status >= 0 || (value && String(value).trim())) {
@ -3679,6 +3952,66 @@ export default {
::v-deep .flow-detail-drawer > .el-drawer__body {
overflow-y: auto;
}
.reorder-intro {
margin-bottom: 12px;
padding: 10px 12px;
color: #606266;
background: #f4f7fb;
border-radius: 4px;
line-height: 20px;
}
.reorder-intro i { color: #409eff; margin-right: 6px; }
.reorder-toolbar,
.reorder-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.reorder-toolbar { margin-bottom: 12px; color: #909399; font-size: 13px; }
.reorder-list { max-height: 460px; overflow-y: auto; padding: 2px; }
.reorder-card {
display: flex;
align-items: stretch;
min-height: 74px;
margin-bottom: 8px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #fff;
transition: border-color .2s, box-shadow .2s;
}
.reorder-card:not(.is-locked) { cursor: grab; }
.reorder-card:not(.is-locked):hover { border-color: #409eff; box-shadow: 0 2px 8px rgba(64, 158, 255, .12); }
.reorder-card.is-dragging { opacity: .45; }
.reorder-card.is-locked { background: #fafafa; color: #909399; }
.reorder-drag-handle,
.reorder-validation-status,
.reorder-sequence {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.reorder-drag-handle { width: 38px; color: #909399; }
.reorder-validation-status { width: 28px; }
.reorder-validation-status.is-success { color: #67c23a; }
.reorder-validation-status.is-warning { color: #e6a23c; }
.reorder-validation-status.is-error { color: #f56c6c; }
.reorder-sequence { width: 34px; color: #409eff; font-weight: 600; border-right: 1px solid #ebeef5; }
.reorder-card-content { flex: 1; min-width: 0; padding: 10px 14px; }
.reorder-log-line { display: flex; align-items: center; flex-wrap: wrap; gap: 0 8px; line-height: 22px; }
.reorder-log-line strong { color: #303133; }
.reorder-log-line span { color: #606266; }
.reorder-log-line small { width: 100%; color: #909399; }
.reorder-group-tag { align-self: center; margin-right: 14px; padding: 2px 7px; color: #409eff; background: #ecf5ff; border-radius: 10px; font-size: 12px; }
.reorder-issues { margin-bottom: 12px; }
.reorder-issue { margin: 5px 0; padding: 7px 10px; border-radius: 4px; font-size: 13px; }
.reorder-issue.is-warning { color: #8a6d1f; background: #fdf6ec; }
.reorder-issue.is-error { color: #a94442; background: #fef0f0; }
.reorder-reason { margin-top: 14px; }
.reorder-reason .el-input { margin-top: 8px; }
.reorder-empty { padding: 36px; text-align: center; color: #909399; }
</style>
<style lang="scss">
.log-table-scroll {

@ -212,6 +212,18 @@
@click="openInsertHistoryLogDialog"
>插入流转记录</el-button
>
<el-button
v-if="canManageFlowLog && $route.query.flow_id"
icon="el-icon-rank"
size="small"
@click="isShowReorderFlowLog = true"
>调整顺序</el-button>
<el-button
v-if="canManageFlowLog && $route.query.flow_id"
icon="el-icon-circle-check"
size="small"
@click="$refs.flowLogReorderDialog && $refs.flowLogReorderDialog.validatePersistedOrder()"
>校验时序合理性</el-button>
<el-button plain size="small" @click="$router.go(-1)"></el-button>
<el-button plain size="small" @click="print(false)"></el-button>
<!-- <el-button plain size="small" @click="print(true)">()</el-button> -->
@ -328,6 +340,12 @@
</el-dialog>
<el-backtop></el-backtop>
<flow-log-reorder-dialog
ref="flowLogReorderDialog"
:visible.sync="isShowReorderFlowLog"
:flow-id="Number($route.query.flow_id)"
@saved="getConfig"
/>
<!-- 更改时间 -->
<el-dialog
:title="selectedDateType === 'updated_at' ? '编辑办理时间' : '编辑流转时间'"
@ -361,6 +379,7 @@ import MobileForm from "./MobileForm.vue";
import assign from "./components/assign.vue";
import forward from "./components/forward.vue";
import rollback from "./components/rollback.vue";
import FlowLogReorderDialog from './components/FlowLogReorderDialog.vue'
import { generateRandomString } from '@/utils'
import {
create,
@ -385,6 +404,7 @@ export default {
assign,
forward,
rollback,
FlowLogReorderDialog,
},
data() {
return {
@ -392,6 +412,7 @@ export default {
isShowTime:false,
selectedDateTime:'',
selectedDateType:'',
isShowReorderFlowLog: false,
isShowInsertHistoryLog: false,
insertHistoryLogSubmitting: false,
insertHistoryLogForm: {
@ -411,6 +432,7 @@ export default {
info: [],
config: {},
writeableFields: [],
nodeRequiredFields: [],
readableFields: [],
subConfig: new Map(),
myStatus: new Map([
@ -633,8 +655,12 @@ export default {
generateForm(object, fields, relation = false, pname) {
fields.forEach((field) => {
if (field.rules && field.rules.length > 0 && this.writeableFields.find(i => i === field.id) && !relation) {
this.rules[field.name] = field.rules.map((rule) => {
const fieldRules = Array.isArray(field.rules) ? [...field.rules] : []
if (this.nodeRequiredFields.includes(field.id) && !fieldRules.includes('required')) {
fieldRules.push('required')
}
if (fieldRules.length > 0 && this.writeableFields.find(i => i === field.id) && !relation) {
this.rules[field.name] = fieldRules.map((rule) => {
switch (rule) {
case "required":
if (field.type === 'relation') {
@ -786,6 +812,7 @@ export default {
? this.fields?.map((i) => i.id)
: this.config?.currentNode?.readable || [];
this.writeableFields = this.config?.currentNode?.writeable || [];
this.nodeRequiredFields = this.config?.currentNode?.required || [];
// form
this.generateForm(this.form, fields);
// form
@ -863,6 +890,7 @@ export default {
? this.fields?.map((i) => i.id)
: this.config?.currentNode?.readable || [];
this.writeableFields = this.config?.currentNode?.writeable || [];
this.nodeRequiredFields = this.config?.currentNode?.required || [];
this.generateForm(this.form, fields);
this.handleDefaultJSON();
this.form = Object.assign({}, this.form);
@ -897,6 +925,7 @@ export default {
? this.fields?.map((i) => i.id)
: this.config?.currentNode?.readable || [];
this.writeableFields = this.config?.currentNode?.writeable || [];
this.nodeRequiredFields = this.config?.currentNode?.required || [];
this.generateForm(this.form, fields);
this.handleDefaultJSON();
const { data } = res?.flow;
@ -1091,9 +1120,7 @@ export default {
const globalRoles = this.$store.state.user.roles || [];
const moduleRoles = this.$store.state.user.moduleRoles?.oa || [];
const adminId = Number(this.$store.state.user.adminId);
const username = this.$store.state.user.username;
return adminId === 1
|| username === 'admin'
|| globalRoles.includes('全局流程监管')
|| moduleRoles.includes('全局流程监管');
},

@ -0,0 +1,45 @@
import { evaluateFieldLinkage } from '@/utils/fieldLinkage'
const fields = [
{ id: 1, name: 'need_invoice', type: 'select', multiple: 0 },
{
id: 2,
name: 'invoice_address',
type: 'text',
visibility_rule: { mode: 'show_when', control_field_id: 1, values: ['是'] }
}
]
describe('evaluateFieldLinkage', () => {
it('disables a writable controlled field when its control value does not match', () => {
const result = evaluateFieldLinkage({ fields, form: { need_invoice: '否' }, writeable: [1, 2] })
expect(result.disabledFieldIds.has(2)).toBe(true)
expect(result.disabledFieldNames.has('invoice_address')).toBe(true)
})
it('keeps the field enabled when the control value matches', () => {
const result = evaluateFieldLinkage({ fields, form: { need_invoice: '是' }, writeable: [1, 2] })
expect(result.disabledFieldIds.has(2)).toBe(false)
expect(result.states[2].reason).toBe('value_matched')
})
it('does not activate the rule unless both fields are writable', () => {
const result = evaluateFieldLinkage({ fields, form: { need_invoice: '否' }, writeable: [2] })
expect(result.disabledFieldIds.size).toBe(0)
expect(result.states[2].reason).toBe('not_both_writable')
})
it('fails open for unsupported multi-select controls', () => {
const multiFields = [
{ ...fields[0], multiple: 1 },
fields[1]
]
const result = evaluateFieldLinkage({ fields: multiFields, form: { need_invoice: '否' }, writeable: [1, 2] })
expect(result.disabledFieldIds.size).toBe(0)
expect(result.states[2].reason).toBe('invalid_rule')
})
})

@ -0,0 +1,30 @@
import { buildFlowLogReorderUnits, flattenFlowLogReorderUnits } from '@/utils/flowLogReorder'
describe('flowLogReorder', () => {
const logs = [
{ id: 4, jointly_sign_id: 4, status: 0, created_at: '2026-07-15 10:00:00', node: { category: 'process' } },
{ id: 2, jointly_sign_id: 2, status: 1, created_at: '2026-07-15 08:10:00', node: { category: 'process' } },
{ id: 1, jointly_sign_id: 1, status: 1, created_at: '2026-07-15 08:00:00', node: { category: 'start' } },
{ id: 3, jointly_sign_id: 2, status: 1, created_at: '2026-07-15 08:11:00', node: { category: 'process' } },
]
it('shows start first as locked, suggests handling last, and keeps jointly signed records together', () => {
const units = buildFlowLogReorderUnits(logs)
expect(units).toHaveLength(3)
expect(units[0].logs.map((log) => log.id)).toEqual([1])
expect(units[0].locked).toBe(true)
expect(units[0].lockReason).toBe('开始节点固定展示,不参与拖拽排序')
expect(units[1].logs.map((log) => log.id)).toEqual([2, 3])
expect(units[1].locked).toBe(false)
expect(units[2].logs.map((log) => log.id)).toEqual([4])
expect(units[2].locked).toBe(false)
expect(flattenFlowLogReorderUnits(units)).toEqual([1, 2, 3, 4])
})
it('preserves persisted unit order when normalization is disabled', () => {
const units = buildFlowLogReorderUnits(logs, false)
expect(flattenFlowLogReorderUnits(units)).toEqual([4, 2, 3, 1])
})
})

@ -0,0 +1,40 @@
import FlowCreate from '@/views/flow/create.vue'
import FlowDetail from '@/views/flow/detailCommon.vue'
import FlowLogReorderDialog from '@/views/flow/components/FlowLogReorderDialog.vue'
describe('flow record reorder UI', () => {
it('keeps sorting out of the record edit form and exposes the independent reorder flow', () => {
const state = FlowCreate.data()
expect(state.editFlowLogForm.sort).toBeUndefined()
expect(state.isShowReorderFlowLog).toBe(false)
expect(typeof FlowCreate.methods.openReorderFlowLogDialog).toBe('function')
expect(typeof FlowCreate.methods.submitReorderFlowLog).toBe('function')
expect(typeof FlowCreate.methods.validateCurrentFlowLogOrder).toBe('function')
})
it('exposes the same restricted reorder dialog on the flow detail page', () => {
const state = FlowDetail.data()
expect(state.isShowReorderFlowLog).toBe(false)
expect(FlowDetail.components.FlowLogReorderDialog).toBe(FlowLogReorderDialog)
expect(typeof FlowLogReorderDialog.methods.validatePersistedOrder).toBe('function')
})
it('allows warning-only reorder submissions without a required reason', async () => {
const confirm = jest.fn(() => Promise.reject(new Error('stop after confirm')))
const context = {
validateOrder: jest.fn(() => Promise.resolve()),
errorCount: 0,
warningCount: 1,
reason: '',
changedCount: 1,
$confirm: confirm,
}
await FlowLogReorderDialog.methods.save.call(context)
expect(context.validateOrder).toHaveBeenCalled()
expect(confirm).toHaveBeenCalled()
})
})
Loading…
Cancel
Save