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
31 changes: 31 additions & 0 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,37 @@ Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启

## 手动导入的共享 UI

### MkEditAvatar(修改头像)

手动导入 `@/components/mk-edit-avatar/index.vue`,不参与全局注册。`defaultIcon` 必填,接收默认
图标图片 URL;字符串 `v-model` 为当前自定义头像 URL,空字符串表示使用默认图标。
`size` 默认为 `32`(px),`editable` 默认为 `true`,设为 `false` 时仅展示头像。

默认插槽替换触发区的头像内容,透出 `{ icon, size }`;`icon` 为当前 `v-model` 值,空值可由
`ToolIcon` 等资源组件自行回退。未提供插槽时展示当前头像或 `defaultIcon` 图片。
触发按钮及悬停、聚焦、点击行为由 `MkEditAvatar` 统一维护,插槽内只放展示内容,不嵌套按钮。
`ToolFormDrawer` 通过此插槽渲染 `ToolIcon`,并将头像绑定到 `toolForm.icon`。

悬停、聚焦或点击头像打开 Logo 设置,打开后保持显示以便选择本地文件;取消、点击外部或 Escape
关闭并丢弃草稿,确定后才更新 `v-model` 并触发 `change(icon, file)`。
自定义图片支持 JPG、PNG、GIF,大小不超过 10MB;确认时 `icon` 为本地 Data URL,`file`
为所选 `File`,未重新选文件或使用默认 Logo 时为 `null`。组件不请求上传接口;调用方负责上传
和持久化,并可将返回的图片 URL 写回 `v-model`。

```vue
<script setup lang="ts">
import { ref } from 'vue'
import MkEditAvatar from '@/components/mk-edit-avatar/index.vue'
import defaultIcon from '@/assets/mk_icon_upload.svg'

const avatar = ref('')
</script>

<template>
<MkEditAvatar v-model="avatar" :default-icon="defaultIcon" />
</template>
```

### MkFilterableDropdown

带搜索过滤和滚动列表的下拉选择。组件不限制选项字段,默认使用 `label` 作为展示和搜索字段、
Expand Down
158 changes: 158 additions & 0 deletions ui/src/components/mk-edit-avatar/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
import { ClickOutside as vClickOutside, ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'

defineOptions({ name: 'MkEditAvatar' })

const props = withDefaults(defineProps<{ defaultIcon: string; editable?: boolean; size?: number }>(), {
editable: true,
size: 32,
})
const avatar = defineModel<string>({ default: '' })
const emit = defineEmits<{ change: [icon: string, file: File | null] }>()
defineSlots<{ default?(props: { icon: string; size: number }): unknown }>()

// 头像编辑草稿:确认前不修改外部值。
const visible = ref(false)
const logoMode = ref<'default' | 'custom'>('default')
const draftIcon = ref('')
const draftFile = ref<File | null>(null)
const reading = ref(false)
const referenceRef = useTemplateRef<HTMLElement>('referenceRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const currentIcon = computed(() => avatar.value || props.defaultIcon)
let fileReader: FileReader | undefined

function open() {
if (!props.editable || visible.value) return
logoMode.value = avatar.value && avatar.value !== props.defaultIcon ? 'custom' : 'default'
draftIcon.value = logoMode.value === 'custom' ? avatar.value : ''
draftFile.value = null
visible.value = true
}

function close() {
visible.value = false
fileReader?.abort()
reading.value = false
}

function clearDraft() {
if (visible.value) return
draftIcon.value = ''
draftFile.value = null
if (fileInputRef.value) fileInputRef.value.value = ''
}

function closeOutside(event: MouseEvent) {
if (event.target instanceof Node && referenceRef.value?.contains(event.target)) return
close()
}

function confirm() {
if (reading.value || (logoMode.value === 'custom' && !draftIcon.value)) return
const icon = logoMode.value === 'default' ? '' : draftIcon.value
const file = logoMode.value === 'custom' ? draftFile.value : null
avatar.value = icon
emit('change', icon, file)
close()
}

// 本地图片校验与预览,文件由调用方上传。
function selectImage(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
ElMessage.warning('仅支持 JPG、PNG、GIF 格式的图片')
return
}
if (file.size > 10 * 1024 * 1024) {
ElMessage.warning('文件大小不能超过 10MB')
return
}
fileReader?.abort()
const reader = new FileReader()
fileReader = reader
reading.value = true
reader.onload = () => {
draftIcon.value = String(reader.result)
draftFile.value = file
reading.value = false
}
reader.onerror = () => {
reading.value = false
ElMessage.error('图片读取失败,请重新选择')
}
reader.readAsDataURL(file)
}

watch(
() => props.editable,
(editable) => {
if (!editable) close()
},
)
onBeforeUnmount(() => fileReader?.abort())
</script>

<template>
<!-- // TODO: 优化头像预览效果 -->
<el-popover :visible="visible" :width="414" placement="bottom-start" @after-leave="clearDraft">
<template #reference>
<!-- 悬停或点击修改头像 -->
<button
ref="referenceRef"
type="button"
class="inline-flex shrink-0 overflow-hidden rounded-md border-0 bg-transparent p-0 align-middle"
:class="editable ? 'cursor-pointer' : 'cursor-default'"
:disabled="!editable"
:title="editable ? '修改头像' : '头像'"
@mouseenter="open"
@focus="open"
@click.stop="open"
@keydown.esc.stop="close"
>
<slot :icon="avatar" :size="size">
<img :src="currentIcon" alt="头像" class="object-contain" :style="{ width: `${size}px`, height: `${size}px` }" />
</slot>
</button>
</template>

<div v-click-outside="closeOutside" class="text-N900" @keydown.esc.stop="close" @click.stop>
<div class="mb-1">Logo 设置<span class="ml-0.5 text-danger">*</span></div>
<el-radio-group v-model="logoMode" :disabled="reading" class="mb-2">
<el-radio value="default">默认 Logo</el-radio>
<el-radio value="custom">自定义上传</el-radio>
</el-radio-group>

<img v-if="logoMode === 'default'" :src="defaultIcon" alt="默认 Logo" class="h-[106px] w-[106px] rounded-md object-contain" />
<div v-else>
<input ref="fileInputRef" type="file" accept="image/jpeg,image/png,image/gif" class="hidden" @change="selectImage" />
<!-- 选择或更换头像图片 -->
<button
type="button"
class="flex-center h-20 w-20 cursor-pointer overflow-hidden rounded-md border border-dashed bg-N100 p-0 hover:border-primary"
:disabled="reading"
:title="draftIcon ? '更换头像图片' : '上传头像图片'"
@click="fileInputRef?.click()"
>
<img v-if="draftIcon" :src="draftIcon" alt="自定义头像预览" class="h-full w-full object-contain" />
<MkIcon v-else :icon="Plus" :size="28" />
</button>
<p class="mt-1 text-N600">支持 JPG、PNG、GIF,文件大小不超过 10MB</p>
</div>

<div class="mt-4 flex justify-end gap-3">
<!-- 取消头像修改 -->
<el-button class="min-w-20" @click="close">取消</el-button>
<!-- 确认头像修改 -->
<el-button class="ml-0! min-w-20" type="primary" :loading="reading" :disabled="logoMode === 'custom' && !draftIcon" @click="confirm">
确定
</el-button>
</div>
</div>
</el-popover>
</template>
60 changes: 11 additions & 49 deletions ui/src/views/tool/tool-card/action-dropdown/EditToolAction.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue'
import type ToolApi from '@/api/admin/workspace/tool/tool'
import type { ToolItem, ToolStoreItem, ToolStoreResponse } from '@/api/types'
import type { ToolItem, ToolStoreResponse } from '@/api/types'
import { TOOL_TYPE } from '@/api/enums'
import ToolFormDrawer from '@/views/tool/tool-form/tool-custom/ToolFormDrawer.vue'
import DataSourceFormDrawer from '@/views/tool/tool-form/DataSourceFormDrawer.vue'
import McpFormDrawer from '@/views/tool/tool-form/McpFormDrawer.vue'
import SkillToolFormDrawer from '@/views/tool/tool-form/SkillToolFormDrawer.vue'
import StoreToolFormDialog from '@/views/tool/tool-form/StoreToolFormDialog.vue'
import WorkflowFormDialog from '@/views/tool/tool-form/WorkflowFormDialog.vue'
import ToolStoreDetailDrawer from '@/views/tool/tool-store/ToolStoreDetailDrawer.vue'

defineOptions({ name: 'EditToolAction' })

Expand All @@ -18,47 +16,13 @@ const props = defineProps<{ api: typeof ToolApi; label: string; storeTools: Tool
const emit = defineEmits<{ update: [tool: ToolItem] }>()

const formMounted = ref(false)
const storeToolFormDialogRef = useTemplateRef<InstanceType<typeof StoreToolFormDialog>>('storeToolFormDialogRef')
const toolStoreDetailDrawerRef = useTemplateRef<InstanceType<typeof ToolStoreDetailDrawer>>('toolStoreDetailDrawerRef')
const toolFormDrawerRef = useTemplateRef<InstanceType<typeof ToolFormDrawer>>('toolFormDrawerRef')
const dataSourceFormDrawerRef = useTemplateRef<InstanceType<typeof DataSourceFormDrawer>>('dataSourceFormDrawerRef')
const mcpFormDrawerRef = useTemplateRef<InstanceType<typeof McpFormDrawer>>('mcpFormDrawerRef')
const skillToolFormDrawerRef = useTemplateRef<InstanceType<typeof SkillToolFormDrawer>>('skillToolFormDrawerRef')
const workflowFormDialogRef = useTemplateRef<InstanceType<typeof WorkflowFormDialog>>('workflowFormDialogRef')

function handleOpenToolForm() {
// TODO 模板转换而来的工具只允许修改名称。
if (props.tool.template_id) {
formMounted.value = true
void nextTick(() =>
storeToolFormDialogRef.value?.open(
{ desc: props.tool.desc, icon: props.tool.icon, id: props.tool.id, name: props.tool.name, source: 'store', tool_type: props.tool.tool_type, version: props.tool.version },
true,
),
)
return
}

// TODO 有版本号的展示readme,是商店更新过来的
if (props.tool.version) {
formMounted.value = true
void nextTick(() => {
const storeTool = props.storeTools.find((item) => item.id === props.tool.template_id)
const toolDetail: ToolStoreItem = {
...storeTool,
desc: props.tool.desc,
icon: props.tool.icon,
id: props.tool.id,
name: props.tool.name,
source: 'store',
tool_type: props.tool.tool_type,
version: props.tool.version,
}
toolStoreDetailDrawerRef.value?.open(toolDetail, storeTool?.readMe)
})
return
}

formMounted.value = true
return nextTick(() => {
if (props.tool.tool_type === TOOL_TYPE.DATA_SOURCE) {
Expand Down Expand Up @@ -87,19 +51,8 @@ function handleFormClosed() {
</MkDropdownItem>

<template v-if="formMounted">
<StoreToolFormDialog v-if="tool.template_id" ref="storeToolFormDialogRef" @closed="handleFormClosed" @update="emit('update', $event)" />
<ToolStoreDetailDrawer v-else-if="tool.version" ref="toolStoreDetailDrawerRef" :show-add="false" @closed="handleFormClosed" />
<ToolFormDrawer
v-else-if="tool.tool_type === TOOL_TYPE.CUSTOM"
ref="toolFormDrawerRef"
title="编辑工具"
:api="api"
:folder-id="tool.folder_id ?? ''"
@closed="handleFormClosed"
@update="emit('update', $event)"
/>
<DataSourceFormDrawer
v-else-if="tool.tool_type === TOOL_TYPE.DATA_SOURCE"
v-if="tool.tool_type === TOOL_TYPE.DATA_SOURCE"
ref="dataSourceFormDrawerRef"
title="编辑数据源"
:api="api"
Expand Down Expand Up @@ -134,5 +87,14 @@ function handleFormClosed() {
@closed="handleFormClosed"
@update="emit('update', $event)"
/>
<ToolFormDrawer
v-else
ref="toolFormDrawerRef"
title="编辑工具"
:api="api"
:folder-id="tool.folder_id ?? ''"
@closed="handleFormClosed"
@update="emit('update', $event)"
/>
</template>
</template>
22 changes: 18 additions & 4 deletions ui/src/views/tool/tool-form/tool-custom/ToolFormDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,27 @@ defineExpose({ open })

<template>
<MkDrawer v-model="visible" :before-close="handleBeforeClose" :title="title" size="60%" @closed="handleClosed">
<el-form ref="formRef" v-loading="formLoading" :model="toolForm" :rules="formRules" label-position="top" require-asterisk-position="right" @submit.prevent>
<el-form
ref="formRef"
v-loading="formLoading"
:model="toolForm"
:rules="formRules"
label-position="top"
require-asterisk-position="right"
@submit.prevent
>
<h4 class="mk-title-decoration mb-4">基本信息</h4>
<el-form-item label="名称" prop="name">
<div class="flex w-full items-center gap-3">
<!-- // TODO 头像 统一修改组件-->
<ToolIcon :size="32" />
<el-input v-model="toolForm.name" maxlength="64" placeholder="请输入工具名称" show-word-limit @blur="toolForm.name = toolForm.name.trim()" />
<!-- TODO 统一修改修改工具头像 -->
<ToolIcon :icon="toolForm.icon" />
<el-input
v-model="toolForm.name"
maxlength="64"
placeholder="请输入工具名称"
show-word-limit
@blur="toolForm.name = toolForm.name.trim()"
/>
</div>
</el-form-item>
<el-form-item label="描述">
Expand Down
2 changes: 1 addition & 1 deletion ui/src/views/tool/tool-store/component/ToolStoreCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { computed, nextTick, ref, useTemplateRef } from 'vue'
import type { ToolStoreItem } from '@/api/types'
import { resetUrl } from '@/utils/icon'
import { numberFormat } from '@/utils/number'
import StoreToolFormDialog from '../../tool-form/StoreToolFormDialog.vue'
import StoreToolFormDialog from '../StoreToolFormDialog.vue'
import ToolStoreDetailDrawer from '../ToolStoreDetailDrawer.vue'

defineOptions({ name: 'ToolStoreCard' })
Expand Down
Loading