Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@
"pinia": "^3.0.4",
"prettier": "3.8.4",
"screenfull": "^6.0.2",
"sortablejs": "^1.15.7",
"vue": "^3.5.38",
"vue-codemirror": "^6.1.1",
"vue-demi": "^0.14.10",
"vue-draggable-plus": "^0.6.1",
"vue-router": "^5.1.0"
},
"devDependencies": {
Expand All @@ -59,7 +59,6 @@
"@types/lodash": "^4.17.25",
"@types/node": "^24.13.2",
"@types/nprogress": "^0.2.3",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^6.0.7",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.8.0",
Expand Down
2 changes: 1 addition & 1 deletion ui/src/assets/iconfont.js

Large diffs are not rendered by default.

37 changes: 36 additions & 1 deletion ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,23 @@ Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启
传入 `size="small"` 时,组件会为内部 `el-table` 添加 `small` class;组件仅提供该样式钩子,
不内置对应样式。

行拖拽排序通过 `sortable` 按需开启,开启后可从整行任意位置开始拖动,不添加独立的排序图标列。
使用 `v-model:data` 接收新顺序;也可使用 `:data` 和 `@update:data` 自行写回。
`row-key` 支持字段路径或函数,默认为 `id`,排序要求每行键值唯一且
为字符串或数字。

```vue
<MkTable v-model:data="fieldRows" sortable row-key="field" @sort-change="saveFieldOrder">
<el-table-column prop="label" label="字段名称" />
</MkTable>
```

`sort-change` 在实际顺序变化后返回 `{ oldIndex, newIndex, data }`,不要在回调里再次移动数组。
分页场景只调整当前传入页的数据,索引从 `0` 开始;跨页位置、接口保存和失败回滚由业务负责。
有活动列排序、表头筛选、展开行或树形数据时暂停拖拽,避免显示行与数据索引不一致。
虚拟表格、跨页和跨列表拖拽不属于该接口。排序工具不会深拷贝普通表格的行对象;工作流使用方应在
数据写回边界自行 `cloneDeep`,保持 LogicFlow 的可观察树约束。

表头需要多选筛选时使用 `MkTableFilter`。`label` 设置表头文案,`options` 接收
`OptionItem<string>[]`,`v-model` 绑定已选值;初始不选择任何选项,确认或重置后通过 `change`
返回筛选值。过长的选项文案会显示省略号,悬停时可查看完整文案。
Expand Down Expand Up @@ -872,7 +889,7 @@ import MkSourceCard from '@/components/mk-source-card/index.vue'

### MkFormList

用于多个业务字段组成的动态表单行,只负责重复行布局、添加和删除,不管理业务字段、校验规则或
用于多个业务字段组成的动态表单行,负责重复行布局、添加、删除和可选排序,不管理业务字段、校验规则或
选项请求。通过 `v-model` 传入行数据,`defaultItem` 创建新行,列表始终至少保留一行。默认插槽
提供 `item`、`index`,业务组件在插槽中继续声明
`el-form-item`、字段路径和校验规则。
Expand Down Expand Up @@ -901,6 +918,24 @@ const roleSettings = defineModel<{ roleId: string; workspaceIds: string[] }[]>({
`:show-add-button="false"`。`firstRowHasLabel` 默认为 `true`:第一行删除按钮使用 `mt-8`,后续行
使用 `mt-0.5`;并列表单项没有 label 时传入 `:firstRowHasLabel="false"`。删除成功后通过
`remove(item, index)` 返回被删除的行数据和原索引,业务组件可处理关联状态,不需要再次修改列表。
增删时使用 Lodash `cloneDeep` 回写独立的行数据,新增行也独立克隆 `defaultItem`,避免共享嵌套
引用及重复挂载 LogicFlow 的 MobX 可观察对象。调用方应使用业务 ID 识别行,不依赖对象引用保持不变。

表单行排序通过 `sortable` 开启,同时必须提供稳定且唯一的 `item-key`(字段名或取键函数)。
组件自带拖拽手柄,添加按钮位于排序容器之外,不需要业务再包拖拽指令或放置手柄。
不足两行或键值无效时禁用拖拽。排序与增删一样深拷贝写回,
`sort-change` 返回 `{ oldIndex, newIndex, data }`。校验规则和字段路径继续由默认插槽提供。

新行包含业务 ID 时,`default-item` 应传工厂函数,在每次点击添加时生成新 ID;普通无 ID 数据
仍可传默认对象。

```vue
<MkFormList v-model="group.variable_list" :default-item="createVariable" :first-row-has-label="false" sortable item-key="v_id">
<template #default="{ item, index }">
<!-- 业务字段与 el-form-item -->
</template>
</MkFormList>
```

### MkSearchList

Expand Down
39 changes: 35 additions & 4 deletions ui/src/components/global/mk-table/index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<script setup lang="ts">
<script setup lang="ts" generic="T">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { TableInstance } from 'element-plus'
import LayoutBatchFooter from '../mk-view-layout/layout-batch-footer.vue'
import { useSortable, type SortableChange } from '@/utils/use-sortable'
import { get } from 'lodash'

defineOptions({ name: 'MkTable', inheritAttrs: false })

Expand All @@ -16,24 +18,53 @@ interface PaginationConfig {

const props = withDefaults(
defineProps<{
data?: unknown[]
data?: T[]
maxTableHeight?: number
paginationConfig?: PaginationConfig
resizable?: boolean // 非指定表格禁止开启
size?: 'small'
rowKey?: string | ((row: T) => string | number)
sortable?: boolean
}>(),
{ data: () => [], maxTableHeight: 250, resizable: false },
{ data: () => [], maxTableHeight: 250, resizable: false, rowKey: 'id', sortable: false },
)

const emit = defineEmits<{
'current-change': [currentPage: number]
'selection-change': [selection: unknown[]]
'size-change': [pageSize: number]
'update:paginationConfig': [paginationConfig: PaginationConfig]
'update:data': [data: T[]]
'sort-change': [change: SortableChange<T>]
}>()

const tableRef = ref<TableInstance>()

/** 行排序:只处理当前传入的平面数据,保持显示顺序与数组索引一致。 */
const sortableRows = computed({
get: () => props.data,
set: (data: T[]) => emit('update:data', data),
})
const canSortRows = computed(() => {
if (!props.sortable || props.data.length < 2) return false
const states = tableRef.value?.store.states
if (!states || states.sortOrder.value || states.expandRows.value.length || Object.keys(states.treeData.value).length) return false
if (Object.values(states.filters.value).some((values) => values.length > 0)) return false
const keys = props.data.map((row) => (typeof props.rowKey === 'function' ? props.rowKey(row) : get(row, props.rowKey)))
if (keys.some((key) => typeof key !== 'string' && typeof key !== 'number') || new Set(keys).size !== keys.length) return false
const displayedRows = states.data.value as T[]
return displayedRows.length === props.data.length && displayedRows.every((row, index) => row === props.data[index])
})
useSortable(
() => (props.sortable ? (tableRef.value?.$el.querySelector('.el-table__body-wrapper tbody') as HTMLElement | null) : null),
sortableRows,
() => ({
disabled: !canSortRows.value,
draggable: '> tr.el-table__row',
onReorder: (change) => emit('sort-change', change),
}),
)

const paginationPageSizes = computed(() => props.paginationConfig?.pageSizes ?? DEFAULT_PAGE_SIZES)

/** 表格高度 */
Expand Down Expand Up @@ -98,7 +129,7 @@ defineExpose({ clearSelection, tableRef })
:class="{ 'mk-table__resizable--borderless': props.resizable, small: props.size === 'small' }"
:data="props.data"
:max-height="tableHeight"
row-key="id"
:row-key="props.rowKey"
v-bind="$attrs"
:border="props.resizable"
@selection-change="handleSelectionChange"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ onMounted(() => {
</el-radio-group>
</el-form-item>
<el-form-item v-if="formValue.default_value_assignment_method === 'ref_variables'" :required="true" prop="default_value" :rules="[referenceVariableRule]">
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" class="w-full" placeholder="请选择变量" v-model="formValue.option_list" />
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" placeholder="请选择变量" v-model="formValue.option_list" />
</el-form-item>

<el-form-item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ onMounted(() => {
</el-radio-group>
</el-form-item>
<el-form-item v-if="formValue.assignment_method === 'ref_variables'" :required="true" prop="option_list" :rules="[referenceVariableRule]">
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" class="w-full" placeholder="请选择变量" v-model="formValue.option_list" />
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" placeholder="请选择变量" v-model="formValue.option_list" />
</el-form-item>
<div v-if="formValue.assignment_method === 'custom'" class="mb-4">
<div class="flex-between mb-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ onMounted(() => {
</el-radio-group>
</el-form-item>
<el-form-item v-if="formValue.assignment_method === 'ref_variables'" :required="true" prop="option_list" :rules="[referenceVariableRule]">
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" class="w-full" placeholder="请选择变量" v-model="formValue.option_list" />
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" placeholder="请选择变量" v-model="formValue.option_list" />
</el-form-item>

<div v-if="formValue.assignment_method === 'custom'" class="mb-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ onMounted(() => {
</el-radio-group>
</el-form-item>
<el-form-item v-if="formValue.assignment_method === 'ref_variables'" :required="true" prop="option_list" :rules="[referenceVariableRule]">
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" class="w-full" placeholder="请选择变量" v-model="formValue.option_list" />
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" placeholder="请选择变量" v-model="formValue.option_list" />
</el-form-item>
<div v-if="formValue.assignment_method === 'custom'" class="mb-4">
<div class="flex-between mb-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ onMounted(() => {
</el-radio-group>
</el-form-item>
<el-form-item v-if="formValue.assignment_method === 'ref_variables'" :required="true" prop="option_list" :rules="[referenceVariableRule]">
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" class="w-full" placeholder="请选择变量" v-model="formValue.option_list" />
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" placeholder="请选择变量" v-model="formValue.option_list" />
</el-form-item>
<div v-if="formValue.assignment_method === 'custom'" class="mb-4">
<div class="flex-between mb-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ onMounted(() => {
</el-radio-group>
</el-form-item>
<el-form-item v-if="formValue.assignment_method === 'ref_variables'" :required="true" prop="option_list" :rules="[referenceVariableRule]">
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" class="w-full" placeholder="请选择变量" v-model="formValue.option_list" />
<NodeCascader ref="nodeCascaderRef" :nodeModel="model" placeholder="请选择变量" v-model="formValue.option_list" />
</el-form-item>
<div v-if="formValue.assignment_method === 'custom'" class="mb-4">
<div class="flex-between mb-2">
Expand Down
83 changes: 69 additions & 14 deletions ui/src/components/mk-form-list/index.vue
Original file line number Diff line number Diff line change
@@ -1,37 +1,92 @@
<script setup lang="ts" generic="T">
import { cloneDeep } from 'lodash'
import { computed, useTemplateRef } from 'vue'
import { useSortable, type SortableChange } from '@/utils/use-sortable'

defineOptions({ name: 'MkFormList' })

const props = withDefaults(defineProps<{ addText?: string; defaultItem: T; firstRowHasLabel?: boolean; showAddButton?: boolean }>(), {
addText: '添加',
firstRowHasLabel: true,
showAddButton: true,
})
const props = withDefaults(
defineProps<{
addText?: string
defaultItem: T | (() => T)
firstRowHasLabel?: boolean
showAddButton?: boolean
sortable?: boolean
itemKey?: keyof T | ((item: T) => string | number)
}>(),
{
addText: '添加',
firstRowHasLabel: true,
showAddButton: true,
sortable: false,
},
)
const formRows = defineModel<T[]>({ required: true })

const emit = defineEmits<{ remove: [item: T, index: number] }>()
const emit = defineEmits<{ remove: [item: T, index: number]; 'sort-change': [change: SortableChange<T>] }>()

defineSlots<{ default(props: { index: number; item: T }): unknown }>()

// 只绑定表单行容器,添加按钮不参与排序。
const rowsRef = useTemplateRef<HTMLElement>('rowsRef')
function resolveRowKey(item: T) {
return typeof props.itemKey === 'function' ? props.itemKey(item) : props.itemKey === undefined ? undefined : item[props.itemKey]
}
function getRowKey(item: T, index: number): string | number {
const key = resolveRowKey(item)
return typeof key === 'string' || typeof key === 'number' ? key : index
}
const sortDisabled = computed(() => {
if (props.itemKey === undefined || formRows.value.length < 2) return true
const keys = formRows.value.map(resolveRowKey)
return keys.some((key) => typeof key !== 'string' && typeof key !== 'number') || new Set(keys).size !== keys.length
})
useSortable(
() => (props.sortable ? rowsRef.value : null),
formRows,
() => ({
disabled: sortDisabled.value,
handle: '.mk-form-list__handle',
draggable: '> .mk-form-list__row',
cloneOnUpdate: true,
onReorder: (change) => emit('sort-change', change),
}),
)

function addRow() {
formRows.value = [...formRows.value, props.defaultItem]
// 回写独立数据,避免 LogicFlow 历史记录重复观察同一个对象。
const newRow = typeof props.defaultItem === 'function' ? (props.defaultItem as () => T)() : props.defaultItem
formRows.value = [...cloneDeep(formRows.value), cloneDeep(newRow)]
}

function removeRow(index: number) {
if (formRows.value.length === 1) return
const removedItem = formRows.value[index] as T
formRows.value = formRows.value.filter((_, rowIndex) => rowIndex !== index)
formRows.value = cloneDeep(formRows.value.filter((_, rowIndex) => rowIndex !== index))
emit('remove', removedItem, index)
}
</script>

<template>
<div v-for="(item, index) in formRows" :key="index" class="flex w-full gap-2">
<slot :index="index" :item="item" />
<el-form-item class="shrink-0" :class="firstRowHasLabel ? (index === 0 ? 'mt-8' : 'mt-0.5') : ''">
<el-button :disabled="formRows.length === 1" text @click="removeRow(index)">
<MkIcon name="icon_delete-trash_outlined" class="text-N600" />
<div ref="rowsRef" class="w-full">
<div v-for="(item, index) in formRows" :key="getRowKey(item, index)" class="mk-form-list__row flex w-full gap-2">
<el-button
v-if="sortable"
link
class="mk-form-list__handle shrink-0 text-N600 -mr-2 h-8!"
:class="{ 'mt-8': firstRowHasLabel && index === 0 }"
:disabled="sortDisabled"
aria-label="拖拽排序"
>
<MkIcon name="icon_drag_outlined" />
</el-button>
</el-form-item>
<slot :index="index" :item="item" />
<el-form-item class="shrink-0" :class="firstRowHasLabel ? (index === 0 ? 'mt-8' : 'mt-0.5') : 'mt-1'">
<el-button :disabled="formRows.length === 1" text @click="removeRow(index)">
<MkIcon name="icon_delete-trash_outlined" class="text-N600" />
</el-button>
</el-form-item>
</div>
</div>

<el-button v-if="showAddButton" class="-mt-1" link type="primary" @click="addRow">
Expand Down
43 changes: 43 additions & 0 deletions ui/src/utils/UTILS_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ src/utils/
├── resource-context.ts # 当前路由的资源范围和工作空间上下文判断
├── time.ts # 跨页面复用的日期时间计算与格式化函数
├── use-responsive.ts # 需要同步组件状态时使用的响应式屏幕判断
├── use-sortable.ts # 列表排序、动态容器绑定和可选的独立数据写回
└── vnode.ts # Vue 插槽 VNode 的可渲染内容判断
```

Expand All @@ -31,6 +32,48 @@ src/utils/
- 依赖 Vue 响应式状态或生命周期的通用组合式函数可以使用 `use-*.ts` 命名。
- 仅改变样式的响应式需求优先使用 Tailwind;只有需要改变组件状态时才使用响应式工具函数。

## 拖拽排序

同一列表内的拖拽排序统一使用 `use-sortable.ts` 的 `useSortable`,底层为 VueDraggablePlus;
不要在业务代码中直接初始化 SortableJS 或重复实现 DOM 还原和数组移动。目录树继续使用
`@he-tree/vue` 的树形拖拽协议。

```ts
import { ref } from 'vue'
import { useSortable } from '@/utils/use-sortable'

const rowContainer = ref<HTMLElement>()
const taskRows = ref([
{ id: 'a', name: '任务 A' },
{ id: 'b', name: '任务 B' },
])
const sortDisabled = ref(false)

useSortable(rowContainer, taskRows, () => ({
handle: '.task-drag-handle',
draggable: '> .task-row',
disabled: sortDisabled.value,
onReorder: ({ oldIndex, newIndex, data }) => {
// data 已经完成排序,可在这里保存新顺序,不要再次 splice。
},
}))
```

容器的直属可拖动元素应与数组逐项对应,Vue 渲染使用稳定业务 ID 作为 `key`;添加按钮等
操作元素放在行容器之外。容器参数支持模板 Ref 或返回 HTMLElement 的 getter,适配 `v-if`
和表格内部 `tbody`;组件更新时检查是否需要重新绑定,卸载时自动销毁。外部代码重建内部 DOM
时可调用返回的 `refresh()`,也提供 `pause()`、`resume()`;响应式禁用优先通过 `disabled` 配置。
配置可传对象、Ref 或 getter,默认动画 `150ms`,拖拽占位使用 `opacity-40`。

普通 Vue 数组排序保留行对象引用;LogicFlow/MobX 可观察树的数据需要开启
`cloneOnUpdate: true`,或在业务 computed setter 中使用 Lodash `cloneDeep` 后写回。
库的 `clone` 选项不等同于普通排序写回时深拷贝整个数组。`onReorder` 只在实际顺序变化时触发,
返回 `{ oldIndex, newIndex, data }`,索引基于当前列表,从 `0` 开始。

MkTable、MkFormList 的排序优先使用其公开属性;普通列表再直接接入该工具。本工具只处理
同一列表排序,不开放跨列表 `group` 和绕过默认同步的 `customUpdate`;特殊业务置换规则应另行
评估,不直接套用普通移动语义。

## 资源上下文

`application`、`knowledge`、`model`、`tool` 是使用资源上下文的四类特殊资源。工作空间 id 和这
Expand Down
Loading
Loading