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
11 changes: 11 additions & 0 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,17 @@ JSON 专用输入框,通过 `v-model` 接收并回传解析后的 JSON 值,
表单配置加载、字段联动请求与 Tree 懒加载的 loading 由组件在请求前开启,并在 `finally` 中释放;
不向 API 请求方法传递 loading,动态请求脚本也不再通过 `extra.loading` 获取状态。

动态表单 `Tree` 字段使用 `MkTable` 展示文档与文件夹,复用原生复选列、全选和父子选择联动。
保留 `text_field`、`value_field`、`childrenField` 映射,选中值仍为原始节点对象数组;
已导入节点自动选中且不可取消,`disabled` 节点不可选择。懒加载先查询根目录,展开时通过
原有工具数据源接口提交 `current_node` 查询子节点;非懒加载使用 `option_list`。

动态表单 `LocalFileUpload` 字段复用 `MkDragUpload` 的多文件模式,将
`file_type_list`、`file_size_limit`、`file_count_limit` 映射为格式、大小和数量限制。
上传适配兼容上层返回的 Promise 或 `{ request, abort }`,响应支持地址字符串或 `{ data: 地址 }`;
文件夹选择、进度、失败重试、取消和成功文件删除由 `MkDragUpload` 维护,文件值采用
`DragUploadFile[]`,失败状态为 `fail`。

`MkDynamicsForm` 根据字段配置渲染动态表单,统一维护字段值、默认值、显隐规则和表单校验;
`MkDynamicsFormConstructor` 用于新增或编辑单个字段配置。该组件族位于 `components` 直属目录,
使用方必须从 `@/components/mk-dynamics-form` 手动导入,不安装为 Vue 插件,也不全局注册其内部
Expand Down
272 changes: 145 additions & 127 deletions ui/src/components/mk-dynamics-form/items/tree/Tree.vue
Original file line number Diff line number Diff line change
@@ -1,149 +1,167 @@
<script setup lang="ts">
import type { DynamicFormValue } from '../../type'
import { computed, ref, useAttrs, nextTick, inject } from 'vue'
import type { FormField } from '@/components/mk-dynamics-form/type'
import { computed, inject, nextTick, ref, toRaw, useAttrs, useTemplateRef, watch } from 'vue'
import { cloneDeep, isEqual } from 'lodash'
import { formItemContextKey, type TableInstance, type TreeNode } from 'element-plus'
import type { Dict } from '@/api/types'
import { post } from '@/api/admin/core/request'
import { cloneDeep } from 'lodash'
import { formItemContextKey } from 'element-plus'
import type { LoadFunction } from 'element-plus'
const getExtra = inject('get_extra') as DynamicFormValue
const elFormItem = inject(formItemContextKey, void 0)
import { getFileIconUrl } from '@/utils/icon'
import type { DynamicFormValue, FormField } from '../../type'
defineOptions({ name: 'DynamicFormTree' })
defineOptions({ name: 'DynamicFormTree', inheritAttrs: false })
const allCheck = ref<boolean>(false)
const handleAllCheckChange = (checked: boolean) => {
if (checked) {
const nodes = Object.values(treeRef.value?.store.nodesMap || {}) as DynamicFormValue[]
nodes.forEach((node) => {
if (!node.disabled) {
treeRef.value?.setChecked(node.data, true, false)
}
})
} else {
treeRef.value?.setCheckedKeys([])
}
interface DocumentRow extends Dict<DynamicFormValue> {
hasChildren: boolean
}
const textField = computed(() => {
return props.formField.text_field ? props.formField.text_field : 'label'
})
const valueField = computed(() => {
return props.formField.value_field ? props.formField.value_field : 'value'
})
const childrenField = computed(() => {
return props.formField.childrenField ? props.formField.childrenField : 'children'
})
const options = computed(() => {
return props.formField.option_list ? props.formField.option_list : []
})
const propsData = computed(() => {
return {
label: textField,
children: childrenField,
isLeaf: (data: DynamicFormValue) => data.leaf,
disabled: (data: DynamicFormValue) => data.disabled,
}
const props = withDefaults(defineProps<{ modelValue?: DynamicFormValue; formField: FormField; otherParams: DynamicFormValue }>(), {
modelValue: () => [],
})
const emit = defineEmits<{ 'update:modelValue': [value: DynamicFormValue[]]; change: [value: DynamicFormValue[]] }>()
const attrs = useAttrs()
const getExtra = inject<() => Dict<DynamicFormValue>>('get_extra')
const elFormItem = inject(formItemContextKey, undefined)
// 字段映射与表格数据,保留完整节点对象作为选中值。
const textField = computed(() => props.formField.text_field || 'label')
const valueField = computed(() => props.formField.value_field || 'value')
const childrenField = computed(() => props.formField.childrenField || 'children')
const options = computed(() => props.formField.option_list ?? [])
const lazy = computed(() => Boolean(props.formField.attrs?.lazy ?? attrs.lazy))
const documentRows = ref<DocumentRow[]>([])
const documentTableRef = useTemplateRef<{ tableRef?: TableInstance }>('documentTableRef')
const tableKey = ref(0)
const pendingLoads = ref(0)
const documentNodes = new Map<string | number, DocumentRow>()
const originalNodes = new WeakMap<DocumentRow, Dict<DynamicFormValue>>()
let restoringSelection = false
const attrs = useAttrs() as DynamicFormValue
const treeRef = ref<DynamicFormValue>(null)
function renderTemplate(template: string, data: DynamicFormValue) {
return template.replace(/\$\{(\w+)\}/g, (match, key) => {
return data[key] !== undefined ? data[key] : match
function normalizeDocuments(documents: Dict<DynamicFormValue>[]): DocumentRow[] {
return documents.map((document) => {
const row: DocumentRow = {
...document,
hasChildren: lazy.value && (document.leaf === false || (document.leaf !== true && (document.type === 'folder' || !document.type))),
}
originalNodes.set(row, document)
documentNodes.set(row[valueField.value], row)
if (Array.isArray(document[childrenField.value])) {
row[childrenField.value] = normalizeDocuments(document[childrenField.value])
}
return row
})
}
const loadNode: LoadFunction = (node, resolve) => {
const url = renderTemplate(
'/workspace/${current_workspace_id}/knowledge/${current_knowledge_id}/datasource/tool/${current_tool_id}/' + attrs.fetch_list_function,
{
...props.otherParams,
...(getExtra ? getExtra() : {}),
},
)
loading.value = true
return post<{ current_node: DynamicFormValue }, DynamicFormValue[]>(url, { current_node: node.level === 0 ? undefined : node.data })
.then((children) => {
resolve(children)
children.forEach((childNode) => {
if (childNode.is_exist) {
treeRef.value?.setChecked(childNode.token, true, false)
}
})
})
function handleSelectionChange(selection: unknown[]) {
if (restoringSelection) return
const selectedDocuments = cloneDeep((selection as DocumentRow[]).map((row) => originalNodes.get(toRaw(row)) ?? row))
if (isEqual(selectedDocuments, props.modelValue ?? [])) return
emit('update:modelValue', selectedDocuments)
emit('change', selectedDocuments)
nextTick(() => elFormItem?.validate('change').catch(() => {}))
}
function restoreSelection() {
const table = documentTableRef.value?.tableRef
if (!table) return
const selectedKeys = new Set((props.modelValue ?? []).map((document: Dict<DynamicFormValue>) => document[valueField.value]))
restoringSelection = true
table.clearSelection()
documentNodes.forEach((row, key) => {
if (row.is_exist || selectedKeys.has(key)) table.toggleRowSelection(row, true, true)
})
restoringSelection = false
handleSelectionChange(table.getSelectionRows() as DocumentRow[])
}
// 懒加载沿用工具数据源协议,根目录不传 current_node。
function renderTemplate(template: string, data: Dict<DynamicFormValue>) {
return template.replace(/\$\{(\w+)\}/g, (match, key) => (data[key] !== undefined ? String(data[key]) : match))
}
const requestUrl = computed(() =>
renderTemplate(
'/workspace/${current_workspace_id}/knowledge/${current_knowledge_id}/datasource/tool/${current_tool_id}/' +
(props.formField.attrs?.fetch_list_function ?? attrs.fetch_list_function),
{ ...props.otherParams, ...getExtra?.() },
),
)
function loadDocuments(currentNode?: Dict<DynamicFormValue>) {
pendingLoads.value += 1
return post<{ current_node?: Dict<DynamicFormValue> }, Dict<DynamicFormValue>[]>(requestUrl.value, { current_node: currentNode })
.then(normalizeDocuments)
.finally(() => {
loading.value = false
pendingLoads.value -= 1
})
}
const props = withDefaults(defineProps<{ modelValue?: DynamicFormValue; formField: FormField; otherParams: DynamicFormValue }>(), {
modelValue: () => [],
})
const emit = defineEmits(['update:modelValue', 'change'])
function loadNode(document: DocumentRow, treeNode: TreeNode, resolve: (documents: DocumentRow[]) => void) {
return loadDocuments(originalNodes.get(toRaw(document)) ?? document)
.then((documents) => {
resolve(documents)
return nextTick(restoreSelection)
})
.catch(() => {
// Table 无 reject 回调,恢复加载标记以便再次展开重试。
treeNode.loading = false
})
}
const modelValueProxy = computed({
get: () => {
if (!props.modelValue) {
emit('update:modelValue', [])
watch(
[options, lazy, requestUrl],
() => {
documentNodes.clear()
documentRows.value = []
tableKey.value += 1
if (!lazy.value) {
documentRows.value = normalizeDocuments(options.value)
return nextTick(restoreSelection)
}
return props.modelValue
},
set: (v: DynamicFormValue[]) => {
emit('update:modelValue', v)
return loadDocuments()
.then((documents) => {
documentRows.value = documents
return nextTick(restoreSelection)
})
.catch(() => {
// 请求层统一提示错误。
})
},
})
const change = () => {
modelValueProxy.value = cloneDeep(treeRef.value?.getCheckedNodes() || [])
nextTick(() => {
if (elFormItem?.validate) {
elFormItem.validate('change')
}
})
}
const loading = ref<boolean>(false)
{ immediate: true, deep: true },
)
watch(() => props.modelValue, restoreSelection, { deep: true })
</script>

<template>
<div v-loading="loading" class="w-full">
<div class="card-never border-r-6 mb-16">
<el-checkbox v-model="allCheck" label="全选" size="large" class="ml-24" @change="handleAllCheckChange" />
</div>
<div style="height: calc(100vh - 450px)">
<el-scrollbar>
<el-tree
:data="options"
@check-change="change"
v-loading="loading"
style="width: 100%"
:props="propsData"
:load="loadNode"
:lazy="attrs.lazy"
show-checkbox
:node-key="valueField"
ref="treeRef"
>
<template #default="{ node, data }">
<div class="flex align-center lighter">
<img :src="data.icon" alt="" height="20" v-if="data.icon" />
<img src="@/assets/empty/no-data.svg" alt="" height="20" v-else-if="data.type === 'folder'" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.type === 'docx' || data.name.endsWith('.docx')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.type === 'sheet' || data.name.endsWith('.xlsx')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.name.endsWith('xls')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.name.endsWith('csv')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.name.endsWith('.pdf')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.name.endsWith('.html')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.name.endsWith('.txt')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.name.endsWith('.zip')" />
<img src="@/assets/empty/no-data.svg" alt="" height="22" v-else-if="data.name.endsWith('.md')" />

<span class="ml-4">{{ node.label }}</span>
</div>
</template>
</el-tree>
</el-scrollbar>
</div>
</div>
<MkTable
:key="tableKey"
ref="documentTableRef"
v-loading="pendingLoads > 0"
:data="documentRows"
:row-key="valueField"
:tree-props="{ children: childrenField, hasChildren: 'hasChildren', checkStrictly: false }"
:load="loadNode"
:max-table-height="430"
:lazy="lazy"
@selection-change="handleSelectionChange"
>
<!-- 选择文档或文件夹,全选由表格维护 -->
<el-table-column type="selection" width="40" reserve-selection :selectable="(row: DocumentRow) => !row.disabled && !row.is_exist" />
<el-table-column class-name="expand-name-column" label="全部文档" :prop="textField">
<template #default="{ row }: { row: DocumentRow }">
<div class="flex-align-center min-w-0 flex-1 gap-2">
<img v-if="row.icon" :src="row.icon" alt="" class="w-4.5 shrink-0" />
<img v-else-if="row.type === 'folder'" src="@/assets/file-type/file-icon.svg" alt="" class="w-4.5 shrink-0" />
<img
v-else
:src="
getFileIconUrl(
row.type === 'docx' ? `${row.name}.docx` : row.type === 'sheet' ? `${row.name}.xlsx` : String(row.name ?? row[textField] ?? ''),
)
"
alt=""
class="w-4.5 shrink-0"
/>
<span :title="row[textField]" class="min-w-0 truncate">{{ row[textField] }}</span>
</div>
</template>
</el-table-column>
</MkTable>
</template>
Loading
Loading