diff --git a/ui/src/api/API_README.md b/ui/src/api/API_README.md index cb099642282..31786877383 100644 --- a/ui/src/api/API_README.md +++ b/ui/src/api/API_README.md @@ -134,11 +134,11 @@ API 枚举与类型统一在 `src/api` 范围内管理,相关规则由本文 - Admin Router 与请求客户端直接读取 `window.MaxKB` 运行时路径配置;`Window` 和 `MaxKBRuntimeConfig` 的全局类型统一声明在根目录 `env.d.ts`。 - Admin 普通 JSON 请求使用 Axios;`request.ts` 导出 Axios 实例以及 `promise`、`get`、 - `post`、`put` 和 `del` 请求封装。 + `post`、`put`、`del` 和 Blob 文件 `downloadRequest` 请求封装。 - 正常 JSON 接口返回 `Promise`,请求层负责解包后端 `{ code, message, data }` 响应。 - GET 文件导出使用 `getExportFile`;需要通过 POST 同时传递查询参数和可选请求体的 Excel 导出 - 使用 `postExportExcel`。请求层统一获取 Blob、解析 `Content-Disposition` 文件名并触发浏览器 - 下载;业务 API 只需传入默认文件名、接口地址及业务参数。 + 使用 `postExportExcel`;Skill 压缩包等指定请求方法的文件下载使用 `downloadRequest`。请求层统一 + 获取 Blob、解析 `Content-Disposition` 文件名并触发浏览器下载;业务 API 只需传入接口地址及业务参数。 - 业务代码通过 `api.method().then(...)` 处理接口成功后的状态变化;通用接口错误由请求层统一 提示,不在调用处重复使用 `try/catch` 或 `.catch()` 提示相同错误。只有业务降级、状态恢复等 非提示类失败处理可以按需保留失败分支。 diff --git a/ui/src/api/admin/core/request.ts b/ui/src/api/admin/core/request.ts index c236dc9eaf8..f3f748d7a60 100644 --- a/ui/src/api/admin/core/request.ts +++ b/ui/src/api/admin/core/request.ts @@ -207,6 +207,31 @@ export function get( return promise(request.get>(url, { params, timeout }), loading) } +/** 发送指定方法的 Blob 请求并触发浏览器下载。 */ +export async function downloadRequest( + url: string, + method: string, + data?: unknown, + params?: Dict, + loading?: LoadingTarget, +): Promise { + startLoading(loading) + try { + const response = await request.request({ + url, + method, + data, + params, + responseType: 'blob', + skipGlobalErrorMessage: true, + } as ExportRequestConfig) + + return downloadExportResponse(response, 'download') + } finally { + finishLoading(loading) + } +} + /** 发送 GET 请求并将 Blob 响应下载为文件。 */ export async function getExportFile( fileName: string, diff --git a/ui/src/api/admin/workspace/tool/tool.ts b/ui/src/api/admin/workspace/tool/tool.ts index 837e8a8f7ec..36ab97c064a 100644 --- a/ui/src/api/admin/workspace/tool/tool.ts +++ b/ui/src/api/admin/workspace/tool/tool.ts @@ -1,4 +1,4 @@ -import { del, getExportFile, get, post, put } from '../../core/request' +import { del, downloadRequest, getExportFile, get, post, put } from '../../core/request' import type { ParamsPage, ResponsePage } from '../../core/types' import type { Dict, ToolDebugPayload, ToolItem, ToolPayload, ToolPylintIssue } from '@/api/types' import { getWorkspaceId } from '@/utils/resource-context' @@ -28,6 +28,21 @@ const putTool = (toolId: string, payload: ToolPayload) => { return put(`${getPrefix()}/${toolId}`, payload) } +/** 检查工作空间工具的 Python 代码。 */ +const postToolPylint = (code: string) => { + return post<{ code: string }, ToolPylintIssue[]>(`${getPrefix()}/pylint`, { code }) +} + +// const generateCode = (data: any) => { +// const p = (window.MaxKB?.prefix ? window.MaxKB?.prefix : '/admin') + '/api' +// return postStream(`${p}${getPrefix()}/generate_code`, data) +// } + +/** 调试普通工具代码并返回运行结果。 */ +const postToolDebug = (payload: ToolDebugPayload) => { + return post(`${getPrefix()}/debug`, payload) +} + /** 获取工具详情。 */ const getToolDetail = (toolId: string) => { return get(`${getPrefix()}/${toolId}`) @@ -42,15 +57,15 @@ const postToolImport = (file: File, folderId: string) => { } /** 上传 Skill 压缩包并返回临时文件 ID。 */ -const postSkillFile = (file: File) => { +const putUploadSkillFile = (file: File) => { const payload = new FormData() payload.append('file', file) - return post(`${getPrefix()}/upload_skill_file`, payload) + return put(`${getPrefix()}/upload_skill_file`, payload) } /** 下载 Skill 工具的压缩包。 */ -const downloadSkillFile = (toolId: string, fileName: string) => { - return getExportFile(fileName, `${getPrefix()}/${toolId}/download_skill_file`) +const downloadSkillFile = (toolId: string) => { + return downloadRequest(`${getPrefix()}/${toolId}/download_skill_file`, 'GET') } /** 导出工作空间工具文件。 */ @@ -58,21 +73,6 @@ const exportTool = (toolId: string, toolName: string) => { return getExportFile(`${toolName}.tool`, `${getPrefix()}/${toolId}/export`) } -/** 检查工作空间工具的 Python 代码。 */ -const postToolPylint = (code: string) => { - return post<{ code: string }, ToolPylintIssue[]>(`${getPrefix()}/pylint`, { code }) -} - -// const generateCode = (data: any) => { -// const p = (window.MaxKB?.prefix ? window.MaxKB?.prefix : '/admin') + '/api' -// return postStream(`${p}${getPrefix()}/generate_code`, data) -// } - -/** 调试普通工具代码并返回运行结果。 */ -const postToolDebug = (payload: ToolDebugPayload) => { - return post(`${getPrefix()}/debug`, payload) -} - /** 测试工具配置是否可连接。 */ const postToolTestConnection = (payload: ToolPayload) => { return post(`${getPrefix()}/test_connection`, payload) @@ -103,7 +103,7 @@ export default { postTool, postToolDebug, postToolImport, - postSkillFile, + putUploadSkillFile, postToolPylint, postToolTestConnection, putBatchDeleteTools, diff --git a/ui/src/assets/file-type/csv-icon.svg b/ui/src/assets/file-type/csv-icon.svg new file mode 100644 index 00000000000..85147ccb46c --- /dev/null +++ b/ui/src/assets/file-type/csv-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/src/assets/file-type/doc-icon.svg b/ui/src/assets/file-type/doc-icon.svg new file mode 100644 index 00000000000..899a0086195 --- /dev/null +++ b/ui/src/assets/file-type/doc-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/assets/file-type/docx-icon.svg b/ui/src/assets/file-type/docx-icon.svg new file mode 100644 index 00000000000..899a0086195 --- /dev/null +++ b/ui/src/assets/file-type/docx-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/assets/file-type/file-icon.svg b/ui/src/assets/file-type/file-icon.svg new file mode 100644 index 00000000000..59b1958879c --- /dev/null +++ b/ui/src/assets/file-type/file-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/file-type/html-icon.svg b/ui/src/assets/file-type/html-icon.svg new file mode 100644 index 00000000000..b59a48826e4 --- /dev/null +++ b/ui/src/assets/file-type/html-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/src/assets/file-type/md-icon.svg b/ui/src/assets/file-type/md-icon.svg new file mode 100644 index 00000000000..7b35a9242c8 --- /dev/null +++ b/ui/src/assets/file-type/md-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/assets/file-type/pdf-icon.svg b/ui/src/assets/file-type/pdf-icon.svg new file mode 100644 index 00000000000..17a4be00440 --- /dev/null +++ b/ui/src/assets/file-type/pdf-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/assets/file-type/txt-icon.svg b/ui/src/assets/file-type/txt-icon.svg new file mode 100644 index 00000000000..051ea2bdaa0 --- /dev/null +++ b/ui/src/assets/file-type/txt-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/src/assets/file-type/unknown-icon.svg b/ui/src/assets/file-type/unknown-icon.svg new file mode 100644 index 00000000000..20270ac52c5 --- /dev/null +++ b/ui/src/assets/file-type/unknown-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/assets/file-type/web-link-icon.svg b/ui/src/assets/file-type/web-link-icon.svg new file mode 100644 index 00000000000..f09fab5ade6 --- /dev/null +++ b/ui/src/assets/file-type/web-link-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/file-type/xls-icon.svg b/ui/src/assets/file-type/xls-icon.svg new file mode 100644 index 00000000000..22cb869537f --- /dev/null +++ b/ui/src/assets/file-type/xls-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/assets/file-type/xlsx-icon.svg b/ui/src/assets/file-type/xlsx-icon.svg new file mode 100644 index 00000000000..22cb869537f --- /dev/null +++ b/ui/src/assets/file-type/xlsx-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/assets/file-type/zip-icon.svg b/ui/src/assets/file-type/zip-icon.svg new file mode 100644 index 00000000000..ad5d625fb63 --- /dev/null +++ b/ui/src/assets/file-type/zip-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/src/assets/mk_icon_upload.svg b/ui/src/assets/mk_icon_upload.svg new file mode 100644 index 00000000000..3a2466c2077 --- /dev/null +++ b/ui/src/assets/mk_icon_upload.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/src/components/COMPONENT_README.md b/ui/src/components/COMPONENT_README.md index b3399924be6..b35ec433b53 100644 --- a/ui/src/components/COMPONENT_README.md +++ b/ui/src/components/COMPONENT_README.md @@ -86,6 +86,8 @@ src/components/ ├── mk-date-range/ │ ├── index.vue # 日期预设与自定义日期区间组合筛选器,手动导入 │ └── types.ts # 日期筛选结果类型 +├── mk-drag-upload/ +│ └── index.vue # 拖拽上传与已选文件卡片,手动导入 ├── mk-dynamics-form/ │ ├── index.ts # 动态表单、表单配置器及组件内类型的公开入口 │ ├── index.vue # 根据字段配置渲染和校验动态表单 @@ -117,6 +119,7 @@ Vue 模板中使用,不需要手动导入。其他共享组件必须从具体 import MkSearchList from '@/components/mk-search-list/index.vue' import MkFormList from '@/components/mk-form-list/index.vue' import MkDateRange from '@/components/mk-date-range/index.vue' +import MkDragUpload from '@/components/mk-drag-upload/index.vue' import { MkDynamicsForm, MkDynamicsFormConstructor } from '@/components/mk-dynamics-form' import LogoFull from '@/components/mk-logo/LogoFull.vue' import LogoIcon from '@/components/mk-logo/LogoIcon.vue' @@ -648,6 +651,32 @@ function handleDateRangeChange({ startTime, endTime }: MkDateRangeValue) { ``` +### MkDragUpload + +组合拖拽选择区和已选文件卡片,通过 `v-model` 管理 Element Plus `UploadUserFile[]`。`accept` +直接传给上传控件;`dragText`、`selectText`、`tipText` 和 `replaceText` 可替换展示文案。组件只负责 +文件选择与展示,使用方通过 `change` 执行校验和上传,通过 `remove` 清理业务数据;`download` +作用域插槽提供当前文件,由使用方按业务需要放置下载按钮。组件暴露 `clearFiles()`,用于请求失败 +或表单重置时清空上传控件内部状态。 + +```vue + + + + + +``` + ### PythonCodeEditor 基于 CodeMirror 6 的 Python 代码编辑器,通过 `v-model` 管理代码,并在组件内部调用工具 pylint diff --git a/ui/src/components/global/mk-icon/KnowledgeIcon.vue b/ui/src/components/global/mk-icon/KnowledgeIcon.vue index 76b7bf282f2..26683c97e92 100644 --- a/ui/src/components/global/mk-icon/KnowledgeIcon.vue +++ b/ui/src/components/global/mk-icon/KnowledgeIcon.vue @@ -1,5 +1,5 @@ + + diff --git a/ui/src/components/mk-dynamics-form/items/upload/LocalFileUpload.vue b/ui/src/components/mk-dynamics-form/items/upload/LocalFileUpload.vue index 244f0831ca3..e37a88589b7 100644 --- a/ui/src/components/mk-dynamics-form/items/upload/LocalFileUpload.vue +++ b/ui/src/components/mk-dynamics-form/items/upload/LocalFileUpload.vue @@ -3,6 +3,8 @@ import type { DynamicFormValue } from '../../type' import { Refresh } from '@element-plus/icons-vue' import { computed, useAttrs, nextTick, inject, ref, reactive } from 'vue' import type { FormField } from '@/components/mk-dynamics-form/type' +import { getFileExtension, getFileIconUrl } from '@/utils/icon' +import { formatFileSize } from '@/utils/number' import { MsgError } from '@/utils/message' import type { UploadFiles } from 'element-plus' const upload = inject('upload') as DynamicFormValue @@ -16,34 +18,6 @@ const onExceed = () => { } const emit = defineEmits(['update:modelValue']) -const filesize = (size: number) => { - if (!size) return '' - const num = 1024.0 - if (size < num) return size + 'B' - if (size < Math.pow(num, 2)) return (size / num).toFixed(2) + 'K' //kb - if (size < Math.pow(num, 3)) return (size / Math.pow(num, 2)).toFixed(2) + 'M' //M - if (size < Math.pow(num, 4)) return (size / Math.pow(num, 3)).toFixed(2) + 'G' //G - return (size / Math.pow(num, 4)).toFixed(2) + 'T' //T -} - -const typeList: DynamicFormValue = { - txt: ['txt', 'pdf', 'docx', 'md', 'html', 'zip', 'xlsx', 'xls', 'csv'], - table: ['xlsx', 'xls', 'csv'], - QA: ['xlsx', 'csv', 'xls', 'zip'], -} -const fileType = (name: string) => { - const suffix = name.split('.') - return suffix[suffix.length - 1] || 'DynamicFormValue' -} - -const getImgUrl = (name: string) => { - const list = Object.values(typeList).flat() - const type = list.includes(fileType(name).toLowerCase()) - ? fileType(name).toLowerCase() - : 'DynamicFormValue' - return new URL(`../assets/fileType/${type}-icon.svg`, import.meta.url).href -} - const fileArray = ref([]) const loading = ref(false) @@ -124,7 +98,7 @@ const fileHandleChange = (file: DynamicFormValue, fileList: UploadFiles) => { removeCurrentFile() return false } - if (!allowedFileTypes.value.includes(fileType(file.name).toLocaleUpperCase())) { + if (!allowedFileTypes.value.includes(getFileExtension(file.name).toUpperCase())) { if (file?.name !== '.DS_Store') { MsgError('文件格式不支持') } @@ -237,14 +211,17 @@ const fileCountLimit = computed(() => { :on-change="fileHandleChange" @click.prevent="handlePreview(false)" > - +
+ +
+

将文件拖到此处,或 - 点击上传 - 选择文件夹 + 点击上传 + 选择文件夹

-
+

单次上传最多 {{ fileCountLimit }} 个文件, 每个文件最大 {{ fileSizeLimit }} MB

支持格式:{{ formats }}

@@ -284,15 +261,15 @@ const fileCountLimit = computed(() => { >
- +

{{ item && item?.name }}

- + {{ item.errMsg }} diff --git a/ui/src/components/mk-dynamics-form/items/upload/UploadInput.vue b/ui/src/components/mk-dynamics-form/items/upload/UploadInput.vue index a5e3f5d2723..ff28d406a12 100644 --- a/ui/src/components/mk-dynamics-form/items/upload/UploadInput.vue +++ b/ui/src/components/mk-dynamics-form/items/upload/UploadInput.vue @@ -4,6 +4,9 @@ import { computed, inject, ref, useAttrs } from 'vue' import { ElMessage } from 'element-plus' import type { FormField } from '@/components/mk-dynamics-form/type' import { downloadByURL, getAttrsArray, getFileUrl } from '@/utils/common' +import { getFileExtension, getFileIconUrl } from '@/utils/icon' +import { formatFileSize } from '@/utils/number' + import { useFormDisabled } from 'element-plus' const inputDisabled = useFormDisabled() @@ -14,35 +17,6 @@ const props = withDefaults(defineProps<{ modelValue?: DynamicFormValue; formFiel }) const emit = defineEmits(['update:modelValue']) -const typeList: DynamicFormValue = { - txt: ['txt', 'pdf', 'docx', 'md', 'html', 'zip', 'xlsx', 'xls', 'csv'], - table: ['xlsx', 'xls', 'csv'], - QA: ['xlsx', 'csv', 'xls', 'zip'], -} -const fileType = (name: string) => { - const suffix = name.split('.') - return suffix[suffix.length - 1] || 'DynamicFormValue' -} - -const getImgUrl = (name: string) => { - const list = Object.values(typeList).flat() - const type = list.includes(fileType(name).toLowerCase()) - ? fileType(name).toLowerCase() - : 'DynamicFormValue' - return new URL(`../assets/fileType/${type}-icon.svg`, import.meta.url).href -} -function formatSize(sizeInBytes: number) { - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - let size = sizeInBytes - let unitIndex = 0 - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024 - unitIndex++ - } - - return size.toFixed(2) + ' ' + units[unitIndex] -} const deleteFile = (file: DynamicFormValue) => { if (inputDisabled.value) { @@ -69,7 +43,7 @@ const imageExtensions = ['JPG', 'JPEG', 'PNG', 'GIF', 'BMP'] const videoExtensions = ['MP4', 'AVI', 'MKV', 'MOV', 'FLV', 'WMV'] const audioExtensions = ['MP3', 'WAV', 'OGG', 'AAC', 'M4A'] const ofType = (exts: string[]) => (f: DynamicFormValue) => - exts.includes(fileType(f?.name || '').toUpperCase()) + exts.includes(getFileExtension(f?.name || '').toUpperCase()) const filesWithUrl = computed(() => (modelValueProxy.value || []).map((f: DynamicFormValue) => ({ @@ -143,13 +117,13 @@ const uploadFile = async (file: DynamicFormValue, fileList: DynamicFormValue[]) style="padding: 0 8px 0 8px" >
- + {{ file.name }}
-
{{ formatSize(file.size) }}
+
{{ formatFileSize(file.size) }}
@@ -171,7 +145,7 @@ const uploadFile = async (file: DynamicFormValue, fileList: DynamicFormValue[]) 下载
- +
{{ item && item?.name }}
diff --git a/ui/src/styles/element-plus.scss b/ui/src/styles/element-plus.scss index ec5c9643ad2..58f720fbaed 100644 --- a/ui/src/styles/element-plus.scss +++ b/ui/src/styles/element-plus.scss @@ -442,6 +442,9 @@ width: 100% !important; color: var(--el-text-color-primary); } + &__content { + line-height: 22px; + } } /* input */ @@ -993,3 +996,16 @@ font-size: 14px; } } + +/* upload */ +.el-upload { + --el-upload-dragger-padding-horizontal: 40px; + --el-upload-dragger-padding-vertical: 32px; + &__text { + line-height: 22px; + color: var(--el-text-color-primary) !important; + em:hover { + color: var(--el-color-primary-light-5); + } + } +} diff --git a/ui/src/utils/UTILS_README.md b/ui/src/utils/UTILS_README.md index efa0fd7d10c..7cefe89c56c 100644 --- a/ui/src/utils/UTILS_README.md +++ b/ui/src/utils/UTILS_README.md @@ -10,6 +10,7 @@ src/utils/ ├── UTILS_README.md # utils 目录的放置、拆分、命名和注释规则 ├── array.ts # 跨页面复用的数组转换、筛选、去重等处理函数 ├── clipboard.ts # 剪贴板文本复制和成功反馈 +├── file.ts # 文件后缀识别、类型白名单校验和图标匹配 ├── message.ts # Element Plus 全局消息提示的统一封装 ├── number.ts # 跨页面复用的数字计算、转换和格式化函数 ├── resource-context.ts # 当前路由的资源范围和工作空间上下文判断 diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts index b34ce9496ad..a0f6f162e6f 100644 --- a/ui/src/utils/common.ts +++ b/ui/src/utils/common.ts @@ -6,16 +6,7 @@ export function randomId() { return nanoid() } -/* - icon url -*/ -export const resetUrl = (url?: string | null, useDefault?: boolean) => { - const sourceUrl = url || (useDefault ? './favicon.ico' : '') - if (sourceUrl && sourceUrl.startsWith('./')) { - return `${window.MaxKB?.prefix}/${sourceUrl.substring(2)}` - } - return sourceUrl -} + export const relatedObject = (list: Array>, val: unknown, attr: string) => { const filterData = list.find((item) => item[attr] === val) return filterData || null diff --git a/ui/src/utils/icon.ts b/ui/src/utils/icon.ts new file mode 100644 index 00000000000..94078173b1a --- /dev/null +++ b/ui/src/utils/icon.ts @@ -0,0 +1,47 @@ +/** 提供跨页面复用的文件后缀识别、类型校验和图标匹配函数。 */ + +const FILE_TYPE_GROUPS = { + txt: ['txt', 'pdf', 'doc', 'docx', 'md', 'html', 'zip', 'xlsx', 'xls', 'csv'], + table: ['xlsx', 'xls', 'csv'], + QA: ['xlsx', 'csv', 'xls', 'zip'], +} as const + +const FILE_ICON_EXTENSIONS = new Set(FILE_TYPE_GROUPS.txt) + +export type FileTypeGroup = keyof typeof FILE_TYPE_GROUPS + +/** 获取文件名中不含点号的小写后缀;无有效后缀时返回空字符串。 */ +export function getFileExtension(fileName: string): string { + const baseName = fileName.split(/[\\/]/).pop() ?? '' + const separatorIndex = baseName.lastIndexOf('.') + + if (separatorIndex <= 0 || separatorIndex === baseName.length - 1) return '' + return baseName.slice(separatorIndex + 1).toLowerCase() +} + +/** 根据文件后缀返回对应图标 URL,不支持的后缀使用 unknown 图标。 */ +export function getFileIconUrl(fileName: string): string { + const extension = getFileExtension(fileName) + const iconName = FILE_ICON_EXTENSIONS.has(extension) ? extension : 'unknown' + + return new URL(`../assets/file-type/${iconName}-icon.svg`, import.meta.url).href +} + +/** 判断文件后缀是否属于指定的文件类型白名单。 */ +export function isAllowedFileType(fileName: string, group: FileTypeGroup): boolean { + const fileExtension = getFileExtension(fileName) + return FILE_TYPE_GROUPS[group].some((extension) => extension === fileExtension) +} + +/* + icon url +*/ +export const resetUrl = (url?: string | null, useDefault?: boolean) => { + const sourceUrl = url || (useDefault ? './favicon.ico' : '') + if (sourceUrl && sourceUrl.startsWith('./')) { + return `${window.MaxKB?.prefix}/${sourceUrl.substring(2)}` + } + return sourceUrl +} + + diff --git a/ui/src/utils/number.ts b/ui/src/utils/number.ts index 7f7a4e064b9..28cc5f7fd32 100644 --- a/ui/src/utils/number.ts +++ b/ui/src/utils/number.ts @@ -2,6 +2,25 @@ type NullableNumber = number | null | undefined +const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const + +/** 按 1024 进位格式化文件大小,单位范围为 `B` 至 `TB`。 */ +export function formatFileSize(bytes: NullableNumber): string { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B' + + let normalizedSize = bytes + let unitIndex = 0 + + while (normalizedSize >= 1024 && unitIndex < FILE_SIZE_UNITS.length - 1) { + normalizedSize /= 1024 + unitIndex += 1 + } + + const formattedSize = + unitIndex === 0 ? Math.round(normalizedSize) : Number(normalizedSize.toFixed(1)) + return `${formattedSize} ${FILE_SIZE_UNITS[unitIndex]}` +} + /** 为数字添加千位分隔符,空值按 `0` 处理。 */ export function toThousands(value: NullableNumber | string): string { return String(value ?? 0).replace(/\B(?=(\d{3})+(?!\d))/g, ',') @@ -16,7 +35,6 @@ export function numberFormat(value: NullableNumber): string { : `${toThousands((normalizedValue / 1000).toFixed(1))}k` } - /** 将有限数字缩写为最多带一位小数的 `K`、`M`、`B` 或 `T`,空值和无效数字返回 `-`。 */ const TOKEN_NUMBER_UNITS = ['', 'K', 'M', 'B', 'T'] as const export function formatTokenNumber(value: NullableNumber): string { diff --git a/ui/src/views/VIEW_README.md b/ui/src/views/VIEW_README.md index 95ebb9c46bc..4d78fbd7ef1 100644 --- a/ui/src/views/VIEW_README.md +++ b/ui/src/views/VIEW_README.md @@ -82,19 +82,21 @@ System 共享资源的四类特殊资源。它们统一遵循以下页面组织 ```text src/views/tool/ ├── ToolView.vue +├── McpConfigDialog.vue # MCP 工具配置查看弹窗 ├── components/ # 工具创建与编辑共用的表单片段 │ ├── ToolCreateDropdown.vue # 工具创建入口 │ ├── init-field/ │ ├── input-field/ │ └── python-code/ ├── tool-form/ # 各类型工具创建、编辑表单 -│ ├── tool-custom/ -│ ├── tool-data-source/ -│ ├── tool-mcp/ -│ ├── tool-skills/ -│ └── tool-workflow/ +│ ├── DataSourceFormDrawer.vue +│ ├── McpFormDrawer.vue +│ ├── SkillToolFormDrawer.vue +│ ├── WorkflowFormDialog.vue +│ └── tool-custom/ ├── tool-card/ │ ├── index.vue # 工具卡片展示与操作插槽 +│ ├── InitParamDialog.vue # 启用工具前配置启动参数 │ ├── UpdateVersionButton.vue # 根据页面传入的商店数据检测并更新工具版本 │ └── action-dropdown/ # 工作空间工具菜单 Action │ ├── index.ts @@ -108,10 +110,14 @@ src/views/tool/ 各类型表单只维护本类型特有字段和流程,并统一放在 `tool/tool-form/`;创建入口和编辑 Action 共同复用这些表单。启动参数、输入参数、Python 内容等已有表单片段应从 `tool/components/` 复用, -不在类型目录中重复实现。工具列表页面通过 `ToolCard` 的 `actions` 和 `action-dropdown` 插槽组合 -操作;编辑 Action 负责按 `TOOL_TYPE` 打开对应类型表单,各类型表单完成保存后通过事件通知列表刷新 -或更新,不把不同工具类型的字段重新合并到一个通用表单中。需要请求的 Action 和表单接收页面传入 -的完整 Tool API,不额外维护逐方法接口类型。工具启用状态属于固定的卡片内交互,由 `ToolCard` +不在类型目录中重复实现。`ToolCodeSetting` 的生成入口默认隐藏,只由普通自定义工具表单通过 +`showGenerate` 显式开启。工具列表页面通过 `ToolCard` 的 `actions` 和 `action-dropdown` 插槽组合 +操作;编辑 Action 负责按 `TOOL_TYPE` 打开对应类型表单。各类型表单创建成功后通过 `refresh` 事件 +刷新列表,编辑成功后通过 `update` 事件返回接口响应的完整工具数据,由页面局部更新对应卡片;不要 +把不同工具类型的字段重新合并到一个通用表单中。所有调用 `postTool` 创建工具的流程在接口成功后 +先调用 `auth.loadAuthBaseProfile()` 刷新当前用户基础资料,再执行成功提示和页面刷新;`putTool` 编辑 +流程不触发该刷新。需要请求的 Action 和表单接收页面传入的完整 Tool API,不额外维护逐方法接口 +类型。工具启用状态属于固定的卡片内交互,由 `ToolCard` 使用页面传入的 Tool API 更新,并通过 `update` 事件通知页面替换列表数据。工具批量选择状态和 批量删除流程由 `ToolView` 管理;`ToolCard` 只把选择模式与选中状态传给 `MkSourceCard`。 工具商店列表由 `ToolView` 统一加载并经 `ToolCard` 传给 `UpdateVersionButton`,卡片和更新按钮 diff --git a/ui/src/views/application/components/ApplicationCard.vue b/ui/src/views/application/components/ApplicationCard.vue index 06b46496358..de8484b6cf4 100644 --- a/ui/src/views/application/components/ApplicationCard.vue +++ b/ui/src/views/application/components/ApplicationCard.vue @@ -3,7 +3,7 @@ import { useRouter, useRoute } from 'vue-router' import type { ApplicationDetail } from '@/api/types' import MkSourceCard from '@/components/mk-source-card/index.vue' -import { resetUrl } from '@/utils/common' +import { resetUrl } from '@/utils/icon' import { isWorkFlow } from '@/utils/application' import { dateFormat } from '@/utils/time' diff --git a/ui/src/views/system/identity/resource-authorization/components/PermissionTable.vue b/ui/src/views/system/identity/resource-authorization/components/PermissionTable.vue index 1a37ea05073..a1d1ca79113 100644 --- a/ui/src/views/system/identity/resource-authorization/components/PermissionTable.vue +++ b/ui/src/views/system/identity/resource-authorization/components/PermissionTable.vue @@ -11,7 +11,7 @@ import type { ResourcePermissionItem, ResourcePermissionPayload, } from '@/api/types' -import { resetUrl } from '@/utils/common' +import { resetUrl } from '@/utils/icon' import { getPermissionOptions } from '../constants' import BatchSetPermissionDialog from '../dialog/BatchSetPermissionDialog.vue' diff --git a/ui/src/views/tool/tool-form/tool-mcp/McpToolConfigDialog.vue b/ui/src/views/tool/McpConfigDialog.vue similarity index 93% rename from ui/src/views/tool/tool-form/tool-mcp/McpToolConfigDialog.vue rename to ui/src/views/tool/McpConfigDialog.vue index e8d40d5e0e2..1351056d0e7 100644 --- a/ui/src/views/tool/tool-form/tool-mcp/McpToolConfigDialog.vue +++ b/ui/src/views/tool/McpConfigDialog.vue @@ -3,7 +3,7 @@ import { ref } from 'vue' import type { ToolItem } from '@/api/types' import { copyText } from '@/utils/clipboard' -defineOptions({ name: 'McpToolConfigDialog' }) +defineOptions({ name: 'McpConfigDialog' }) const visible = ref(false) const config = ref('') diff --git a/ui/src/views/tool/ToolView.vue b/ui/src/views/tool/ToolView.vue index 3cacfc0bc05..be571355485 100644 --- a/ui/src/views/tool/ToolView.vue +++ b/ui/src/views/tool/ToolView.vue @@ -248,9 +248,15 @@ onMounted(() => { :api="ToolApi" :store-tools="storeTools" :tool="tool" + @update="handleToolUpdate" + /> + - >('workflowFormDialogRef') const skillToolFormDrawerRef = useTemplateRef>('skillToolFormDrawerRef') -const mcpToolFormDrawerRef = - useTemplateRef>('mcpToolFormDrawerRef') -const dataSourceToolFormDrawerRef = useTemplateRef>( - 'dataSourceToolFormDrawerRef', -) +const mcpFormDrawerRef = useTemplateRef>('mcpFormDrawerRef') +const dataSourceFormDrawerRef = + useTemplateRef>('dataSourceFormDrawerRef') function handleOpenToolForm() { toolFormDrawerRef.value?.open() @@ -53,11 +51,11 @@ function handleOpenSkillForm() { } function handleOpenMcpForm() { - mcpToolFormDrawerRef.value?.open() + mcpFormDrawerRef.value?.open() } function handleOpenDataSourceForm() { - dataSourceToolFormDrawerRef.value?.open() + dataSourceFormDrawerRef.value?.open() } /* 导入创建 */ @@ -155,15 +153,15 @@ function handleRefresh() { :folder-id="folderId" @refresh="handleRefresh" /> - - (), { + showGenerate: false, +}) + const code = defineModel({ required: true }) @@ -14,7 +18,7 @@ const code = defineModel({ required: true }) 使用工具时不显示
- + 生成 @@ -22,7 +26,7 @@ const code = defineModel({ required: true }) - diff --git a/ui/src/views/tool/tool-form/tool-data-source/DataSourceToolFormDrawer.vue b/ui/src/views/tool/tool-form/DataSourceFormDrawer.vue similarity index 82% rename from ui/src/views/tool/tool-form/tool-data-source/DataSourceToolFormDrawer.vue rename to ui/src/views/tool/tool-form/DataSourceFormDrawer.vue index 52a65289b96..b7d76875393 100644 --- a/ui/src/views/tool/tool-form/tool-data-source/DataSourceToolFormDrawer.vue +++ b/ui/src/views/tool/tool-form/DataSourceFormDrawer.vue @@ -5,12 +5,15 @@ import type { FormInstance, FormRules } from 'element-plus' import type ToolApi from '@/api/admin/workspace/tool/tool' import { TOOL_TYPE } from '@/api/enums' import type { DynamicFormField, ToolInputField, ToolItem, ToolPayload } from '@/api/types' +import { useStore } from '@/stores' import { MsgConfirm, MsgSuccess } from '@/utils/message' -import InitFieldTable from '../../components/init-field/InitFieldTable.vue' -import InputFieldTable from '../../components/input-field/InputFieldTable.vue' -import ToolCodeSetting from '../../components/python-code/CodeSetting.vue' +import InitFieldTable from '../components/init-field/InitFieldTable.vue' +import InputFieldTable from '../components/input-field/InputFieldTable.vue' +import ToolCodeSetting from '../components/python-code/CodeSetting.vue' -defineOptions({ name: 'DataSourceToolFormDrawer' }) +defineOptions({ name: 'DataSourceFormDrawer' }) + +const { auth } = useStore() const props = defineProps<{ api: typeof ToolApi @@ -21,6 +24,7 @@ const props = defineProps<{ const emit = defineEmits<{ closed: [] refresh: [] + update: [tool: ToolItem] }>() interface DataSourceFormModel { @@ -74,15 +78,21 @@ function handleSubmit() { tool_type: TOOL_TYPE.DATA_SOURCE, } loading.value = true - const request = editId.value - ? props.api.putTool(editId.value, payload) + const currentEditId = editId.value + const isEdit = Boolean(currentEditId) + const request = currentEditId + ? props.api.putTool(currentEditId, payload) : props.api.postTool({ ...payload, folder_id: props.folderId || null }) request - .then(() => { - MsgSuccess(editId.value ? '保存成功' : '创建成功') - visible.value = false - emit('refresh') + .then((savedTool) => { + const refreshCurrentUser = isEdit ? Promise.resolve() : auth.loadAuthBaseProfile() + return refreshCurrentUser.then(() => { + MsgSuccess(isEdit ? '保存成功' : '创建成功') + visible.value = false + if (isEdit) emit('update', savedTool) + else emit('refresh') + }) }) .finally(() => { loading.value = false @@ -101,26 +111,29 @@ function fillDataSourceForm(tool: ToolItem) { }) } -function open(tool?: ToolItem) { +function open(tool?: ToolItem, asCopy = false) { resetData() visible.value = true originalForm.value = JSON.stringify(dataSourceForm) if (!tool) return + if (asCopy) { + fillDataSourceForm(tool) + originalForm.value = JSON.stringify(dataSourceForm) + return + } + editId.value = tool.id formLoading.value = true props.api .getToolDetail(tool.id) .then((toolDetail) => { - if (editId.value !== tool.id || !visible.value) return fillDataSourceForm(toolDetail) originalForm.value = JSON.stringify(dataSourceForm) }) - .catch(() => { - if (editId.value === tool.id) visible.value = false - }) + .finally(() => { - if (editId.value === tool.id) formLoading.value = false + formLoading.value = false }) } @@ -183,6 +196,7 @@ defineExpose({ open })

基本信息

+ () interface McpFormModel { @@ -29,7 +33,7 @@ interface McpFormModel { const mcpServerExample = `{ "math": { - "url": "https://your-server.example.com/sse", + "url": "your_server", "transport": "sse" } }` @@ -41,7 +45,7 @@ const editId = ref() const originalForm = ref('') const mcpForm = reactive({ code: '', desc: '', icon: '', name: '' }) const formRules: FormRules = { - code: [{ required: true, message: '请输入 MCP Server 配置', trigger: 'blur' }], + code: [{ required: true, message: '请输入 MCP Server Config', trigger: 'blur' }], name: [{ required: true, message: '请输入 MCP 名称', trigger: 'blur' }], } @@ -51,7 +55,7 @@ function isValidConfig() { if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error() return true } catch { - MsgError('请输入正确的 MCP Server JSON 配置') + MsgError('请输入正确的 MCP Server Config') return false } } @@ -65,15 +69,21 @@ function handleSubmit() { tool_type: TOOL_TYPE.MCP, } loading.value = true - const request = editId.value - ? props.api.putTool(editId.value, payload) + const currentEditId = editId.value + const isEdit = Boolean(currentEditId) + const request = currentEditId + ? props.api.putTool(currentEditId, payload) : props.api.postTool({ ...payload, folder_id: props.folderId || null }) request - .then(() => { - MsgSuccess(editId.value ? '保存成功' : '创建成功') - visible.value = false - emit('refresh') + .then((savedTool) => { + const refreshCurrentUser = isEdit ? Promise.resolve() : auth.loadAuthBaseProfile() + return refreshCurrentUser.then(() => { + MsgSuccess(isEdit ? '保存成功' : '创建成功') + visible.value = false + if (isEdit) emit('update', savedTool) + else emit('refresh') + }) }) .finally(() => { loading.value = false @@ -104,26 +114,29 @@ function fillMcpForm(tool: ToolItem) { }) } -function open(tool?: ToolItem) { +function open(tool?: ToolItem, asCopy = false) { resetData() visible.value = true originalForm.value = JSON.stringify(mcpForm) if (!tool) return + if (asCopy) { + fillMcpForm(tool) + originalForm.value = JSON.stringify(mcpForm) + return + } + editId.value = tool.id formLoading.value = true props.api .getToolDetail(tool.id) .then((toolDetail) => { - if (editId.value !== tool.id || !visible.value) return fillMcpForm(toolDetail) originalForm.value = JSON.stringify(mcpForm) }) - .catch(() => { - if (editId.value === tool.id) visible.value = false - }) + .finally(() => { - if (editId.value === tool.id) formLoading.value = false + formLoading.value = false }) } @@ -179,6 +192,7 @@ defineExpose({ open })

基本信息

+ -

MCP Server

- +

MCP 服务

+ +