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
1 change: 1 addition & 0 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ API 对象和工作空间上下文,作为该抽屉的范围选择例外;用

`admin/file.ts` 与 `chat/file.ts` 分别提供 `postUploadFile(file, sourceId, sourceType, onProgress?)`,
通过各自请求客户端向 `/oss/file` 提交 FormData 的 `file`、`source_id` 和 `source_type`。
Admin 上传尚未创建的资源文件时,`sourceId` 可传 `undefined`,省略 `source_id` 并使用后端默认值。
返回值统一为 `{ request, abort }`,`request` 解包得到文件地址,`abort()` 中断客户端请求。
普通上传直接等待 `request`,需要进度时传入 `(percent, event)`;只有能获取上传总量时才回调
0–100 的百分比,100 表示请求体已上传,不代表服务端处理成功,完成状态以 `request` 为准。
Expand Down
4 changes: 2 additions & 2 deletions ui/src/api/admin/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import type { FileSourceType } from '@/api/types'
/** 上传资源文件,支持可选的进度回调与取消操作。 */
const postUploadFile = (
file: File,
sourceId: string,
sourceId: string | undefined,
sourceType: FileSourceType,
onProgress?: (percent: number, event: AxiosProgressEvent) => void,
) => {
const formData = new FormData()
formData.append('file', file)
formData.append('source_id', sourceId)
if (sourceId !== undefined) formData.append('source_id', sourceId)
formData.append('source_type', sourceType)
return postUpload<string>('/oss/file', formData, onProgress)
}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/assets/iconfont.js

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启
手动导入 `@/components/mk-edit-avatar/index.vue`,不参与全局注册。字符串 `v-model` 为当前
自定义头像 URL,空字符串表示使用默认图标;不再接收 `defaultIcon`。
`size` 默认为 `32`(px),只设置 `referenceRef` 触发容器的宽高;弹层预览和上传区域固定为 80px。
`editable` 默认为 `true`,设为 `false` 时仅展示头像。
`disabled` 默认为 `false`,直接传给 `el-popover` 控制禁用,不额外维护禁用状态监听。

必填默认插槽同时渲染触发区和默认 Logo 预览,只透出 `{ icon }`。触发区传入当前头像
(空值转为 `undefined`);默认预览始终传入 `icon: undefined`。两处容器通过样式让插槽的
Expand All @@ -376,10 +376,14 @@ Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启
门户编辑通过此插槽渲染 `PortalIcon`,并将头像绑定到 `portalForm.logo`。

悬停头像打开 Logo 设置,打开后保持显示以便选择本地文件;取消、点击外部或弹层内按 Escape
关闭并丢弃草稿,确定后才更新 `v-model` 并触发 `change(icon, file)`。
自定义图片支持 JPG、PNG、GIF,大小不超过 10MB;确认时 `icon` 为本地 Data URL,`file`
为所选 `File`,未重新选文件或使用默认 Logo 时为 `null`。组件不请求上传接口;调用方负责上传
和持久化,并可将返回的图片 URL 写回 `v-model`。
关闭并丢弃草稿。选择新图片后确定,先将预览地址写入 `v-model`,再触发 `change(file)`;保留已有自定义
头像直接确定时不触发事件。恢复默认时先将 `v-model` 设为空,再触发 `change(null)`。
自定义图片支持 JPG、PNG、GIF,大小不超过 10MB;组件通过 `URL.createObjectURL` 创建预览,
统一释放不再被 `previewIcon` 或 `v-model` 使用的临时地址,卸载时释放剩余地址。
调用方不重复创建或释放预览地址;确认后即可通过插槽展示新头像,关闭弹层不会释放仍在显示的地址。
`blob:` 地址仅供当前组件存续期间预览,不能提交给后端作为持久图片地址。
组件只通过 `change(file)` 通知调用方,不请求接口,也不规定文件何时上传。
调用方按业务流程处理文件,通过 `v-model` 传入用于展示的头像地址。

```vue
<script setup lang="ts">
Expand Down
80 changes: 39 additions & 41 deletions ui/src/components/mk-edit-avatar/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,37 @@ import type { UploadFile, UploadInstance } from 'element-plus'

defineOptions({ name: 'MkEditAvatar' })

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

// 头像编辑草稿:确认前不修改外部值。
const visible = ref(false)
const logoMode = ref<'default' | 'custom'>('default')
const draftIcon = ref('')
const previewIcon = ref('')
const draftFile = ref<File | null>(null)
const reading = ref(false)
const referenceRef = useTemplateRef<HTMLElement>('referenceRef')
const uploadRef = useTemplateRef<UploadInstance>('uploadRef')

let fileReader: FileReader | undefined

function open() {
if (!props.editable || visible.value) return
if (visible.value) return
logoMode.value = avatar.value ? 'custom' : 'default'
draftIcon.value = logoMode.value === 'custom' ? avatar.value : ''
previewIcon.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 = ''
previewIcon.value = ''
draftFile.value = null
uploadRef.value?.clearFiles()
}
Expand All @@ -51,11 +46,14 @@ function closeOutside(event: MouseEvent) {
}

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)
if (logoMode.value === 'custom' && !previewIcon.value) return
if (logoMode.value === 'default') {
avatar.value = ''
emit('change', null)
} else if (draftFile.value) {
avatar.value = previewIcon.value
emit('change', draftFile.value)
}
close()
}

Expand All @@ -72,39 +70,40 @@ function selectImage(uploadFile: UploadFile) {
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('图片读取失败,请重新选择')
previewIcon.value = URL.createObjectURL(file)
draftFile.value = file
}

// 临时地址由组件统一管理,仍用于头像或弹层预览时不释放。
function releaseUnusedPreview(icon: string) {
if (icon.startsWith('blob:') && icon !== avatar.value && icon !== previewIcon.value) {
URL.revokeObjectURL(icon)
}
reader.readAsDataURL(file)
}

watch(
() => props.editable,
(editable) => {
if (!editable) close()
[avatar, previewIcon],
(_current, [previousAvatar, previousPreview]) => {
releaseUnusedPreview(previousAvatar)
if (previousPreview !== previousAvatar) releaseUnusedPreview(previousPreview)
},
{ flush: 'sync' },
)
onBeforeUnmount(() => fileReader?.abort())
onBeforeUnmount(() => {
if (previewIcon.value.startsWith('blob:') && previewIcon.value !== avatar.value) URL.revokeObjectURL(previewIcon.value)
if (avatar.value.startsWith('blob:')) URL.revokeObjectURL(avatar.value)
})
</script>

<template>
<el-popover :visible="visible" :width="414" placement="bottom-start" @after-leave="clearDraft">
<!-- 单向控制显隐,避免选择文件时鼠标移出触发自动关闭。 -->
<el-popover :visible="visible" :disabled="props.disabled" :width="414" placement="bottom-start" @after-leave="clearDraft">
<template #reference>
<!-- 悬停修改头像 -->
<span
ref="referenceRef"
class="inline-flex shrink-0 [&>*]:h-full! [&>*]:w-full!"
:class="{ 'cursor-pointer': props.editable }"
:class="{ 'cursor-pointer': !props.disabled }"
:style="{ width: `${props.size}px`, height: `${props.size}px` }"
@mouseenter="open"
>
Expand All @@ -114,7 +113,7 @@ onBeforeUnmount(() => fileReader?.abort())

<div v-click-outside="closeOutside" class="p-4" @keydown.esc.stop="close" @click.stop>
<div class="mb-1">Logo 设置</div>
<el-radio-group v-model="logoMode" :disabled="reading" class="mb-2">
<el-radio-group v-model="logoMode" class="mb-2">
<el-radio value="default">默认 Logo</el-radio>
<el-radio value="custom">自定义上传</el-radio>
</el-radio-group>
Expand All @@ -130,11 +129,10 @@ onBeforeUnmount(() => fileReader?.abort())
accept="image/jpeg,image/png,image/gif"
:auto-upload="false"
:show-file-list="false"
:disabled="reading"
:on-change="selectImage"
>
<el-avatar v-if="draftIcon" :size="80" shape="square">
<img :src="draftIcon" class="object-cover" />
<el-avatar v-if="previewIcon" :size="80" shape="square">
<img :src="previewIcon" class="object-cover" />
</el-avatar>

<div v-else class="flex-center h-20 w-20 rounded-md border border-dashed bg-N200 hover:border-primary">
Expand All @@ -148,7 +146,7 @@ onBeforeUnmount(() => fileReader?.abort())
<!-- 取消头像修改 -->
<el-button plain @click="close">取消</el-button>
<!-- 确认头像修改 -->
<el-button type="primary" :loading="reading" :disabled="logoMode === 'custom' && !draftIcon" @click="confirm">确定</el-button>
<el-button type="primary" :disabled="logoMode === 'custom' && !previewIcon" @click="confirm">确定</el-button>
</div>
</div>
</el-popover>
Expand Down
17 changes: 16 additions & 1 deletion ui/src/styles/STYLE_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

`tailwind.css` 内部按照 `@import`、`@theme`、`@custom-variant`、`@layer base`、
`@layer components`、`@utility` 的顺序组织;不存在的部分直接省略。`@theme` 内部按照颜色、
背景图片、间距、排版分组,每组通过注释标明职责,不将不同类型的 Token 混合排序。
背景图片、间距、阴影、排版分组,每组通过注释标明职责,不将不同类型的 Token 混合排序。

`src/main.ts` 和 `src/chat.ts` 应按以下顺序加载样式:

Expand Down Expand Up @@ -208,6 +208,21 @@ CSS 自定义属性同样按变量名排序。嵌套选择器、伪类和媒体
白色内容卡片使用 `mk-white-card` 或 `mk-white-card-sm`,内边距和圆角与同尺寸灰色卡片一致。
两种背景的尺寸类均可独立使用,包含对应背景色,无需叠加基础类。

## 阴影

大号阴影统一由 `variables.scss` 中的 `--mk-box-shadow-lg` 定义,值为
`0 6px 24px 0 rgb(var(--mk-N900-rgb) / 8%)`。`tailwind.css` 将其映射为 `shadow-lg`,
业务模板直接使用该工具类,悬停效果使用 `hover:shadow-lg`:

```html
<div class="shadow-lg">常驻阴影</div>
<div class="hover:shadow-lg">悬停阴影</div>
```

普通 CSS/SCSS 使用 `box-shadow: var(--mk-box-shadow-lg)`。Element Plus 卡片的常驻、
悬停和焦点阴影复用该变量。现有 `shadow-sm`、`shadow-md` 分别保持映射到
`--el-box-shadow-lighter`、`--el-box-shadow-light`。

## 常用交互工具类

卡片内需要文字居左、单选圆点居右并垂直居中时,在 `el-radio` 上添加 `mk-card-radio`。
Expand Down
2 changes: 1 addition & 1 deletion ui/src/styles/element-plus.scss
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@
.el-card.is-always-shadow,
.el-card.is-hover-shadow:hover,
.el-card.is-hover-shadow:focus {
box-shadow: 0px 6px 24px 0px rgb(var(--mk-N900-rgb) / 8%);
box-shadow: var(--mk-box-shadow-lg);
}

/* cascader */
Expand Down
1 change: 1 addition & 0 deletions ui/src/styles/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
--spacing-chat-sidebar: var(--mk-chat-sidebar-width);

/* shadows */
--shadow-lg: var(--mk-box-shadow-lg);
--shadow-md: var(--el-box-shadow-light);
--shadow-sm: var(--el-box-shadow-lighter);

Expand Down
3 changes: 3 additions & 0 deletions ui/src/styles/variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
--mk-sidebar-expanded-width: 240px;
--mk-sidebar-width: 68px;

/* shadows */
--mk-box-shadow-lg: 0 6px 24px 0 rgb(var(--mk-N900-rgb) / 8%);

// 对话
--mk-chat-sidebar-width: 280px;
}
5 changes: 5 additions & 0 deletions ui/src/views/VIEW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@ Workspace 的自定义工具和工作流工具菜单
代码围栏,普通编辑区直接回写表单草稿,放大编辑器通过 `header-extra` 的 `replaceCode` 回调
替换全屏草稿,点击“确定”后再同步表单。公共组件统一处理 `ConversationStream`、停止、重新生成和输入交互,
不接收工具表单、不判断资源范围,不维护原始输入、暂停或继续状态。

`ToolFormDrawer` 通过 `MkEditAvatar` 暂存确认后的头像文件;保存校验通过后先调用 Admin
`postUploadFile`,将返回地址写入 `toolForm.icon` 再提交工具。恢复默认时直接提交空字符串,
未更换图片时不上传;上传失败保留文件供重试,上传成功后清空暂存文件,避免工具保存失败后重复上传。

工具列表页面通过 `ToolCard` 的 `actions` 和 `action-dropdown` 插槽组合
操作;编辑 Action 负责按 `TOOL_TYPE` 打开对应类型表单,启动参数 Action 获取工具详情后打开
`InitParamDialog`,MCP 配置 Action 获取 MCP 工具详情后打开 `McpConfigDialog`。工作流 Action 进入
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ function handleOpenPortalEdit() {
editVisible.value = true
}

function handleLogoChange(icon: string, file: File | null) {
// 保留已有图片及尚未保存的文件,只有新选图片或恢复默认时更新。
if (icon && !file) return
function handleLogoChange(file: File | null) {
logoFile.value = file
logoChanged.value = true
}
Expand Down Expand Up @@ -71,7 +69,7 @@ function handleClosePortalEdit() {
>
<el-form-item label="名称" prop="name">
<div class="flex-align-center w-full gap-3">
<MkEditAvatar :editable="!saving" v-model="portalForm.logo" @change="handleLogoChange" :size="32">
<MkEditAvatar v-model="portalForm.logo" @change="handleLogoChange" :size="32">
<template #default="{ icon }">
<PortalIcon :icon="icon" />
</template>
Expand Down
Loading
Loading