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
8 changes: 7 additions & 1 deletion ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ Esc 或取消清理草稿;`loading` 默认为 false,为 true 时禁止创建

`maxTableHeight` 表示窗口中除表格外需要扣除的高度,默认为 `250`;组件会在窗口尺寸变化时
重新计算 `max-height`。传入 `resizable` 后启用列宽拖拽,并隐藏为拖拽借用的原生边框视觉。
仅开启 `resizable` 时,表头保持单行,拖拽下限按当前标题文字、排序/筛选图标、间距及内边距
计算;拖拽线与最终列宽均不能小于该宽度,原生 `header-dragend` 继续透传。
`resizable` 采用白名单式启用:只有需求明确指定的页面级表格才能开启;未明确指定的表格,以及
Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启。

Expand All @@ -304,7 +306,11 @@ Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启
`row-key` 支持字段路径或函数,默认为 `id`,排序要求每行键值唯一且
为字符串或数字。

`sort-change` 在实际顺序变化后返回 `{ oldIndex, newIndex, data }`,不要在回调里再次移动数组。
`row-sort-change` 在实际行顺序变化后返回 `{ oldIndex, newIndex, data }`,不要在回调里再次移动数组。
`sort-change` 通过 `$attrs` 透传 Element Plus 原生表头排序参数 `{ column, prop, order }`。
列只需声明 `sortable`,MkTable 在渲染列时统一转换为 `sortable="custom"`,不执行当前页排序;
支持 Fragment、条件/循环列和原生分组列,保留列的插槽、键与引用。页面通过 `toOrderBy` 转换请求字段。
该约定独立于列宽拖拽开关 `resizable`,未开启排序的列保持原样。
分页场景只调整当前传入页的数据,索引从 `0` 开始;跨页位置、接口保存和失败回滚由业务负责。
有活动列排序、表头筛选、展开行或树形数据时暂停拖拽,避免显示行与数据索引不一致。
虚拟表格、跨页和跨列表拖拽不属于该接口。排序工具不会深拷贝普通表格的行对象;工作流使用方应在
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/business/folder-tree/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import type { FolderSource, FolderItem } from '@/api/types'
import { FOLDER_SORT, type FolderSort } from './types'
import { FOLDER_ENTRIES, FOLDER_ENTRY_ID } from '@/constants'
import { MsgSuccess, MsgConfirm } from '@/utils/message'
import { getWorkspaceId } from '@/utils/resource-context'
import VirtualizedTree from './VirtualizedTree.vue'
import FolderFormDialog from './FolderFormDialog.vue'
import ResourceAuthorizationDrawer from '@/components/business/resource-authorization-drawer/index.vue'
import { useStore } from '@/stores'
import { getWorkspaceId } from '@/utils/resource-context'

const MoveToDialog = defineAsyncComponent(() => import('./MoveToDialog.vue'))

Expand Down
97 changes: 91 additions & 6 deletions ui/src/components/global/mk-table/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts" generic="T extends object">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, type VNode } from 'vue'
import type { TableColumnCtx, TableInstance } from 'element-plus'
import { cloneVNode, computed, Fragment, h, isVNode, nextTick, onBeforeUnmount, onMounted, ref, type VNode } from 'vue'
import { ElTableColumn, type TableColumnCtx, type TableInstance } from 'element-plus'
import LayoutBatchFooter from '../mk-view-layout/LayoutBatchFooter.vue'
import { useSortable, type SortableChange } from '@/utils/use-sortable'
import { get } from 'lodash'
Expand Down Expand Up @@ -38,11 +38,64 @@ const emit = defineEmits<{
'size-change': [pageSize: number]
'update:paginationConfig': [paginationConfig: PaginationConfig]
'update:data': [data: T[]]
'sort-change': [change: SortableChange<T>]
'row-sort-change': [change: SortableChange<T>]
}>()

const tableRef = ref<TableInstance>()

/** 列宽拖拽:按当前表头的单行内容限制拖拽线,保留原生拖拽与事件。 */
let columnResize: { proxy: HTMLElement; minLeft: number } | undefined

function clampColumnResize() {
if (!props.resizable || !columnResize) return
const { proxy, minLeft } = columnResize
proxy.style.left = `${Math.max(minLeft, Number.parseFloat(proxy.style.left))}px`
}

function clearColumnResize() {
columnResize = undefined
document.removeEventListener('mousemove', clampColumnResize)
document.removeEventListener('mouseup', finishColumnResize, true)
}

function finishColumnResize() {
// 在 Element Plus 读取拖拽线并发出 header-dragend 前完成最后一次限制。
clampColumnResize()
clearColumnResize()
}

function handleColumnResizeStart(event: MouseEvent) {
const table = tableRef.value?.$el as HTMLElement | undefined
const header = event.target instanceof Element ? event.target.closest<HTMLElement>('th.noclick') : null
if (!props.resizable || !table || !header || header.closest('.el-table') !== table) return
const cell = header.querySelector<HTMLElement>(':scope > .cell')
const proxy = table.querySelector<HTMLElement>(':scope > .el-table__column-resize-proxy')
if (!cell || !proxy) return

// 原位测量保留继承字体、图标、按钮与间距;同步还原,不改变实际表头布局。
const previousStyle = cell.style.cssText
cell.style.width = 'max-content'
cell.style.minWidth = '0'
cell.style.maxWidth = 'none'
const contentWidth = cell.getBoundingClientRect().width
cell.style.cssText = previousStyle
const headerStyle = getComputedStyle(header)
const outerWidth = ['padding-left', 'padding-right', 'border-left-width', 'border-right-width'].reduce(
(width, property) => width + (Number.parseFloat(headerStyle.getPropertyValue(property)) || 0),
0,
)

clearColumnResize()
columnResize = {
proxy,
minLeft: Math.ceil(header.getBoundingClientRect().left - table.getBoundingClientRect().left + Math.max(30, Math.ceil(contentWidth + outerWidth))),
}
clampColumnResize()
// 冒泡到表格后注册,确保每次在 Element Plus 更新拖拽线之后执行。
document.addEventListener('mousemove', clampColumnResize)
document.addEventListener('mouseup', finishColumnResize, true)
}

/** 行内名称编辑:失焦的这次点击不再触发行点击,下一次点击恢复。 */
let nameEditingOnPointerDown = false

Expand All @@ -58,12 +111,38 @@ function handleRowClick(row: T, column: TableColumnCtx<T> | null, event: Pointer
emit('row-click', row, column, event)
}

defineSlots<{
const slots = defineSlots<{
default?: () => VNode[]
'body-prepend'?: () => VNode[]
'footer-batch-actions'?: () => VNode[]
}>()

/** 表头排序统一交给调用方;在 Element Plus 排序前转换,避免重排当前页。 */
function normalizeColumn(column: VNode): VNode {
if (column.type === Fragment && Array.isArray(column.children)) {
return h(
Fragment,
column.props,
column.children.map((child) => (isVNode(child) ? normalizeColumn(child) : child)),
)
}
if (column.type !== ElTableColumn) return column

const sortable = column.props?.sortable
const normalizedColumn = cloneVNode(column, sortable === true || sortable === '' ? { sortable: 'custom' } : undefined)
const columnSlots = column.children
if (columnSlots && !Array.isArray(columnSlots) && typeof columnSlots === 'object' && typeof columnSlots.default === 'function') {
const defaultSlot = columnSlots.default
normalizedColumn.children = {
...columnSlots,
default: (...args: unknown[]) => defaultSlot(...args).map(normalizeColumn),
}
}
return normalizedColumn
}

const TableColumns = () => slots.default?.().map(normalizeColumn)

/** 行排序:只处理当前传入的平面数据,保持显示顺序与数组索引一致。 */
const sortableRows = computed({
get: () => props.data,
Expand All @@ -85,7 +164,7 @@ useSortable(
() => ({
disabled: !canSortRows.value,
draggable: '> tr.el-table__row',
onReorder: (change) => emit('sort-change', change),
onReorder: (change) => emit('row-sort-change', change),
}),
)

Expand Down Expand Up @@ -170,6 +249,7 @@ onMounted(() => {
})

onBeforeUnmount(() => {
clearColumnResize()
window.removeEventListener('resize', updateTableHeight)
})

Expand All @@ -193,9 +273,10 @@ defineExpose({ clearSelection, tableRef })
@select="handleSelect"
@selection-change="handleSelectionChange"
@pointerdown.capture="handlePointerDown"
@mousedown="handleColumnResizeStart"
@row-click="handleRowClick"
>
<slot />
<TableColumns />
<template v-if="$slots['body-prepend']" #append>
<slot name="body-prepend" />
</template>
Expand Down Expand Up @@ -299,6 +380,10 @@ defineExpose({ clearSelection, tableRef })

thead th {
border-bottom: none !important;

> .cell {
white-space: nowrap;
}
}
}
</style>
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { DynamicFormValue } from '../../type'
import { computed, inject, ref, useAttrs } from 'vue'
import { MsgWarning } from '@/utils/message'
import type { FormField } from '@/components/mk-dynamics-form/type'
import { MsgWarning } from '@/utils/message'
import { downloadByURL, getAttrsArray, getFileUrl } from '@/utils/common'
import { getFileExtension, getFileIconUrl } from '@/utils/icon'
import { formatFileSize } from '@/utils/number'
Expand Down
15 changes: 15 additions & 0 deletions ui/src/styles/element-plus.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,21 @@
}
}
}

// 排序图标
.caret-wrapper {
width: 16px;
height: 16px;
}
.sort-caret {
&.ascending {
border-bottom-color: var(--mk-N600);
top: -4px;
}
&.descending {
border-top-color: var(--mk-N600);
}
}
}
.el-table--border {
border: 1px solid var(--el-table-border-color);
Expand Down
15 changes: 15 additions & 0 deletions ui/src/utils/UTILS_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ src/utils/
- 依赖 Vue 响应式状态或生命周期的通用组合式函数可以使用 `use-*.ts` 命名。
- 仅改变样式的响应式需求优先使用 Tailwind;只有需要改变组件状态时才使用响应式工具函数。

## 工作流类型判断

`common.ts` 的 `isWorkFlow(type)` 统一判断智能体的 `APPLICATION_TYPE.WORK_FLOW`、
知识库接口的 `KNOWLEDGE_TYPE.WORKFLOW` 和前端标识 `KNOWLEDGE_TYPE_KEY.WORKFLOW`。
接收字符串、数字或空值,使用枚举值严格比较;空值和其他类型值返回 `false`,不转换接口枚举。

## 表头排序参数

`common.ts` 的 `toOrderBy({ prop, order }, mapField?)` 将表头排序转换为后端正负字段:
升序返回字段名,降序返回 `-字段名`,取消排序或缺少字段时返回 `undefined`。
可选 `mapField` 在添加正负号前转换字段名,例如 `(prop) => 'ett.' + prop`。
页面负责将结果写入 `order_by` 或 `order` 等查询字段,并维护分页、选择和刷新行为;
MkTable 的服务端排序列声明 `sortable` 即可,由组件统一转换为 `custom`;直接使用 Element Plus Table
时仍需声明 `sortable="custom"`。该函数不处理行拖拽排序。

## 相对时间

`time.ts` 的 `relativeTimeFormat(timestamp)` 接收日期、毫秒时间戳或日期字符串。
Expand Down
4 changes: 0 additions & 4 deletions ui/src/utils/application.ts

This file was deleted.

23 changes: 19 additions & 4 deletions ui/src/utils/common.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
/** 公共工具:资源类型判断、标识生成、字段提取、文件地址与表头排序参数转换。 */
import { nanoid } from 'nanoid'
import { APPLICATION_TYPE, KNOWLEDGE_TYPE } from '@/api/enums'
import { KNOWLEDGE_TYPE_KEY } from '@/constants/knowledge'

/** 判断智能体类型、知识库接口类型或前端语义标识是否为工作流;空值返回 false。 */
export function isWorkFlow(type: string | number | null | undefined): boolean {
return type === APPLICATION_TYPE.WORK_FLOW || type === KNOWLEDGE_TYPE.WORKFLOW || type === KNOWLEDGE_TYPE_KEY.WORKFLOW
}

/*
随机id
*/
export function randomId() {
return nanoid()
}

export const relatedObject = (list: Array<Record<string, unknown>>, val: unknown, attr: string) => {
const filterData = list.find((item) => item[attr] === val)
return filterData || null
}
export const getAttrsArray = (array: Array<Record<string, unknown>>, attr: string) => {
return array.map((item) => {
return item[attr]
Expand All @@ -32,3 +37,13 @@ export const getFileUrl = (fileId?: string) => {
}
return ''
}

/** 将表头排序转换为后端正负字段;支持字段映射,取消排序时返回 undefined。 */
export function toOrderBy(
{ prop, order }: { prop?: string; order: 'ascending' | 'descending' | null },
mapField?: (prop: string) => string,
): string | undefined {
if (!prop || !order) return undefined
const field = mapField ? mapField(prop) : prop
return order === 'ascending' ? field : `-${field}`
}
7 changes: 7 additions & 0 deletions ui/src/views/VIEW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ System 资源管理或 System 共享 Knowledge API 的 `postKnowledgeHitTest`;
- 名称使用 `MkEditName`,最长 128,禁止 `: \ / ? * [ ]`;成功按 ID 更新名称与时间。
- 文件状态、启用、命中处理用 `MkTableFilter single`,标签用 `cascader`;改变条件回第一页并清空选择,保留其他条件。
状态查询同步更新 `status` 与 `task_type`;布尔 false 保留,“全部”省略条件;标签传叶子 ID 或 `NO_TAG`。
- 字符数使用服务端表头排序,通过 `toOrderBy` 更新独立的 `orderBy`,请求时作为 `order_by` 传入;切换排序回第一页并清空选择,
取消排序恢复后端默认顺序。搜索、筛选、分页和轮询保留当前排序,导入成功清空搜索及筛选时也保留排序。
- 任务解析及筛选统一在 `document/status.ts`,展示由 `DocumentStatus.vue` 负责;不复用执行记录状态。
汇总优先级为取消中、执行中、排队中、失败、取消完成、成功;忽略未知和 n,保留取消完成显示成功的既有规则。
- 文档 Action 按 embedding、generate-questions、setting、setting-tags、tokenize、export、sync、migrate 等职责分组,
Expand Down Expand Up @@ -309,6 +311,9 @@ View 负责路由、资源与模式、图加载、保存/发布/调试编排
- 返回方法集中在各资源 `navigation.ts`;历史模式先退出预览,编辑态保留保存并退出/直接退出确认。
当前没有通用路由离开或浏览器关闭守卫。
- 工具、知识库执行记录抽屉按需挂载,关闭后卸载;知识库记录每 6 秒刷新,关闭停止。
执行时间列声明 `sortable`,独立 `orderBy` 通过 `toOrderBy` 添加 `ett.` 前缀,以 `order` 查询参数传入。
切换排序回第一页;搜索、筛选、分页与轮询保留排序,重新打开时恢复默认排序。
当前本地后端两类接口仍固定按执行时间降序,需支持 `order` 后才能实际响应升序请求。

## 其他页面约束

Expand All @@ -322,6 +327,8 @@ View 负责路由、资源与模式、图加载、保存/发布/调试编排
排行由概览本地 `components/ApplicationRanking.vue` 调用 Overview API,展示用户 Tokens 消耗与提问次数两类 TOP5。
应用排行复用首页 `RankingCard`,由概览转换展示字段;占比使用当前应用同期完整排行总量。
概览内暂时隐藏卡片详情入口,不接入排行抽屉、搜索、分页或导出。
- 触发器执行记录的执行时间列使用服务端排序,独立 `orderBy` 经 `toOrderBy` 添加 `ett.` 前缀,
通过查询参数 `order` 传入。切换排序回第一页;搜索、分页和详情跨页保留排序,重新打开抽屉恢复默认排序。
- 触发器表单共用创建/编辑,保留任务 ID 和参数;资源模式固定单任务,完整输入定义不足时补查详情。
保存校验全部折叠任务;参数初始化不覆盖已配置值,移除事件引用时恢复自定义默认值。
- 智能体/知识库对话用户页使用双栏布局和当前页授权;智能体从 context 取 Workspace/System 资源管理 ChatUser API,知识库取四范围 API。
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ApplicationDetail } from '@/api/types'
import { isWorkFlow } from '@/utils/application'
import { isWorkFlow } from '@/utils/common'
import { dateFormat } from '@/utils/time'

defineOptions({ name: 'ApplicationCard' })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import type { ApplicationDetail } from '@/api/types'
import { isWorkFlow } from '@/utils/application'
import { isWorkFlow } from '@/utils/common'

defineOptions({ name: 'SettingApplicationAction' })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { FormInstance, FormRules } from 'element-plus'
import type ApplicationApi from '@/api/admin/workspace/application/application'
import type { ApplicationDetail, ApplicationFormPayload } from '@/api/types'
import { useStore } from '@/stores'
import { isWorkFlow } from '@/utils/application'
import { isWorkFlow } from '@/utils/common'
import { MsgSuccess } from '@/utils/message'

defineOptions({ name: 'CopyApplicationDialog' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { useKnowledgeDetailContext } from '@/views/knowledge-detail/context
import { KNOWLEDGE_TYPE_KEY } from '@/constants/knowledge'
import type { DocumentItem } from '@/api/types'
import { MsgConfirm, MsgSuccess } from '@/utils/message'
import { isWorkFlow } from '@/utils/common'

defineOptions({ name: 'SyncDocumentAction' })
const props = defineProps<{
Expand All @@ -23,7 +24,7 @@ function handleSync() {
if (loading.value) return
const document = props.document
if (!document.id) return
const isWorkflow = knowledgeType.value === KNOWLEDGE_TYPE_KEY.WORKFLOW
const isWorkflow = isWorkFlow(String(knowledgeType.value ?? ''))
if ((knowledgeType.value === KNOWLEDGE_TYPE_KEY.WEB || isWorkflow) && !document.meta?.source_url) {
MsgConfirm('提示', '无法同步,请先去设置文档 URL 地址', {
confirmButtonText: '确认',
Expand Down
Loading
Loading