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
2 changes: 2 additions & 0 deletions ui/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ declare module 'vue' {
MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default']
MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default']
PortalIcon: typeof import('./components/global/mk-icon/PortalIcon.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ToolIcon: typeof import('./components/global/mk-icon/ToolIcon.vue')['default']
Expand Down Expand Up @@ -85,6 +86,7 @@ declare global {
const MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
const MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default']
const MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default']
const PortalIcon: typeof import('./components/global/mk-icon/PortalIcon.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const ToolIcon: typeof import('./components/global/mk-icon/ToolIcon.vue')['default']
Expand Down
31 changes: 19 additions & 12 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,16 +355,20 @@ Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启

### 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
手动导入 `@/components/mk-edit-avatar/index.vue`,不参与全局注册。字符串 `v-model` 为当前
自定义头像 URL,空字符串表示使用默认图标;不再接收 `defaultIcon`。
`size` 默认为 `32`(px),控制触发区传给插槽的尺寸;弹层预览和上传区域固定为 80px。
`editable` 默认为 `true`,设为 `false` 时仅展示头像。

必填默认插槽同时渲染触发区和默认 Logo 预览,透出 `{ icon, size }`。触发区传入当前头像
(空值转为 `undefined`)和 `props.size`;默认预览始终传入 `icon: undefined`、固定的 `size: 80`,不读取组件的
`props.size`。插槽参数由调用方按需使用,不会自动覆盖插槽内组件的尺寸。
调用方必须使用插槽的 `icon`,由 `PortalIcon`、`ToolIcon` 等资源组件自行展示默认图标,
不要在插槽内直接绑定外部头像值。
触发区由 `MkEditAvatar` 的 `span` 容器承载,统一处理悬停,插槽内只放展示内容。
门户编辑通过此插槽渲染 `PortalIcon`,并将头像绑定到 `portalForm.logo`。

悬停头像打开 Logo 设置,打开后保持显示以便选择本地文件;取消、点击外部或弹层内按 Escape
关闭并丢弃草稿,确定后才更新 `v-model` 并触发 `change(icon, file)`。
自定义图片支持 JPG、PNG、GIF,大小不超过 10MB;确认时 `icon` 为本地 Data URL,`file`
为所选 `File`,未重新选文件或使用默认 Logo 时为 `null`。组件不请求上传接口;调用方负责上传
Expand All @@ -374,13 +378,16 @@ Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启
<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" />
<MkEditAvatar v-model="avatar">
<template #default="{ icon, size: previewSize }">
<PortalIcon :icon="icon" :size="previewSize" />
</template>
</MkEditAvatar>
</template>
```

Expand Down
13 changes: 13 additions & 0 deletions ui/src/components/global/mk-icon/PortalIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { resetUrl } from '@/utils/icon'

defineOptions({ name: 'PortalIcon' })

withDefaults(defineProps<{ icon?: string; size?: number | string }>(), { size: 24 })
</script>

<template>
<el-avatar class="bg-transparent!" shape="square" :size="size">
<img :src="resetUrl(icon, true)" alt="" />
</el-avatar>
</template>
77 changes: 35 additions & 42 deletions ui/src/components/mk-edit-avatar/index.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
import { ClickOutside as vClickOutside, ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance } from 'element-plus'

defineOptions({ name: 'MkEditAvatar' })

const props = withDefaults(defineProps<{ defaultIcon: string; editable?: boolean; size?: number }>(), {
const props = withDefaults(defineProps<{ 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 }>()
defineSlots<{ default(props: { icon: string | undefined; size: number }): unknown }>()

// 头像编辑草稿:确认前不修改外部值。
const visible = ref(false)
Expand All @@ -20,13 +20,13 @@ 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)
const uploadRef = useTemplateRef<UploadInstance>('uploadRef')

let fileReader: FileReader | undefined

function open() {
if (!props.editable || visible.value) return
logoMode.value = avatar.value && avatar.value !== props.defaultIcon ? 'custom' : 'default'
logoMode.value = avatar.value ? 'custom' : 'default'
draftIcon.value = logoMode.value === 'custom' ? avatar.value : ''
draftFile.value = null
visible.value = true
Expand All @@ -42,7 +42,7 @@ function clearDraft() {
if (visible.value) return
draftIcon.value = ''
draftFile.value = null
if (fileInputRef.value) fileInputRef.value.value = ''
uploadRef.value?.clearFiles()
}

function closeOutside(event: MouseEvent) {
Expand All @@ -60,10 +60,9 @@ function confirm() {
}

// 本地图片校验与预览,文件由调用方上传。
function selectImage(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
function selectImage(uploadFile: UploadFile) {
const file = uploadFile.raw
uploadRef.value?.clearFiles()
if (!file) return
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
ElMessage.warning('仅支持 JPG、PNG、GIF 格式的图片')
Expand Down Expand Up @@ -99,26 +98,12 @@ 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>
<!-- 悬停修改头像 -->
<span ref="referenceRef" class="inline-flex" :class="{ 'cursor-pointer': props.editable }" @mouseenter="open">
<slot :icon="avatar || undefined" :size="props.size" />
</span>
</template>

<div v-click-outside="closeOutside" class="p-4" @keydown.esc.stop="close" @click.stop>
Expand All @@ -127,29 +112,37 @@ onBeforeUnmount(() => fileReader?.abort())
<el-radio value="default">默认 Logo</el-radio>
<el-radio value="custom">自定义上传</el-radio>
</el-radio-group>
<div v-if="logoMode === 'default'">
<slot :icon="undefined" :size="80" />
</div>

<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"
<el-upload
ref="uploadRef"
action="#"
accept="image/jpeg,image/png,image/gif"
:auto-upload="false"
:show-file-list="false"
:disabled="reading"
:title="draftIcon ? '更换头像图片' : '上传头像图片'"
@click="fileInputRef?.click()"
:on-change="selectImage"
>
<img v-if="draftIcon" :src="draftIcon" alt="自定义头像预览" class="h-full w-full object-contain" />
<MkIcon v-else :icon="Plus" :size="28" />
</button>
<p class="text-N600">支持 JPG、PNG、GIF,文件大小不超过 10MB</p>
<el-avatar v-if="draftIcon" :size="80" shape="square">
<img :src="draftIcon" alt="自定义头像预览" 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">
<MkIcon name="icon_add_outlined" :size="24" />
</div>
</el-upload>
<p class="text-N600 mt-1">支持 JPG、PNG、GIF,文件大小不超过 10MB</p>
</div>

<div class="mt-4 text-right">
<!-- 取消头像修改 -->
<el-button plain @click="close">取消</el-button>
<!-- 确认头像修改 -->
<el-button type="primary" :loading="reading" @click="confirm"> 确定 </el-button>
<el-button type="primary" :loading="reading" :disabled="logoMode === 'custom' && !draftIcon" @click="confirm">确定</el-button>
</div>
</div>
</el-popover>
Expand Down
21 changes: 3 additions & 18 deletions ui/src/layout/ResourceDetailLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { computed } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { getMatchedChildRouteList } from '@/router/admin/utils'
import type { LayoutMenuItem } from './types'
import ResourceDetailMenu from './sidebar/ResourceDetailMenu.vue'

defineOptions({ name: 'ResourceDetailLayout' })

Expand All @@ -17,10 +18,6 @@ const router = useRouter()
const detailMenuItems = computed(() => getMatchedChildRouteList(route))
const activeDetailMenuName = computed(() => route.meta.detailActiveMenu ?? String(route.name ?? ''))

function isDetailMenuActive(detailMenuItem: LayoutMenuItem) {
return detailMenuItem.name === activeDetailMenuName.value
}

function navigateBack() {
emit('back')
}
Expand All @@ -35,6 +32,7 @@ function navigateToDetailMenu(detailMenuItem: LayoutMenuItem) {
<template #aside="{ Header }">
<component :is="Header">
<div class="flex-align-center min-w-0 gap-2">
<!-- 返回资源列表 -->
<el-button class="-ml-1" text @click="navigateBack">
<MkIcon name="icon_arrow-left_outlined" :size="20" />
</el-button>
Expand All @@ -43,20 +41,7 @@ function navigateToDetailMenu(detailMenuItem: LayoutMenuItem) {
</component>

<el-scrollbar class="min-h-0 flex-1">
<div class="space-y-1 px-4">
<template v-for="detailMenuItem in detailMenuItems" :key="detailMenuItem.name">
<MkListItem :active="isDetailMenuActive(detailMenuItem)" @click="navigateToDetailMenu(detailMenuItem)">
<template #default="{ active }">
<MkIcon
v-if="detailMenuItem.icon"
class="mr-3"
:name="active ? (detailMenuItem.activeIcon ?? detailMenuItem.icon) : detailMenuItem.icon"
/>
<span class="min-w-0 flex-1 truncate" :title="detailMenuItem.label">{{ detailMenuItem.label }}</span>
</template>
</MkListItem>
</template>
</div>
<ResourceDetailMenu :menu-items="detailMenuItems" :active-name="activeDetailMenuName" @select="navigateToDetailMenu" />
</el-scrollbar>
</template>

Expand Down
92 changes: 92 additions & 0 deletions ui/src/layout/sidebar/ResourceDetailMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { LayoutMenuItem } from '../types'

defineOptions({ name: 'ResourceDetailMenu' })

const props = defineProps<{ menuItems: LayoutMenuItem[]; activeName: string }>()
const emit = defineEmits<{ select: [menuItem: LayoutMenuItem] }>()

/* 初始展开分组;导航时展开当前页所属分组,保留用户对其他分组的选择。 */
const expandedGroups = ref<string[]>(props.menuItems.filter((menuItem) => menuItem.children?.length).map((menuItem) => menuItem.name))

watch(
() => props.activeName,
(activeName) => {
const activeGroup = props.menuItems.find((menuItem) => menuItem.children?.some((child) => child.name === activeName))
if (activeGroup && !expandedGroups.value.includes(activeGroup.name)) expandedGroups.value.push(activeGroup.name)
},
{ immediate: true },
)
</script>

<template>
<el-collapse v-model="expandedGroups" class="resource-detail-menu space-y-1 border-0! px-4">
<template v-for="menuItem in props.menuItems" :key="menuItem.name">
<!-- 展开或收起菜单分组 -->
<el-collapse-item v-if="menuItem.children?.length" :name="menuItem.name">
<template #title>
<span class="flex-align-center min-w-0 gap-3">
<MkIcon v-if="menuItem.icon" :name="menuItem.icon" />
<span class="min-w-0 flex-1 truncate" :title="menuItem.label">{{ menuItem.label }}</span>
</span>
</template>
<template #icon="{ isActive }">
<MkIcon name="icon_down_outlined" class="ml-2 text-N500 transition-transform" :class="{ 'rotate-180': isActive }" />
</template>
<div class="space-y-1 pt-1">
<template v-for="child in menuItem.children" :key="child.name">
<!-- 切换二级菜单页面 -->
<MkListItem :active="child.name === props.activeName" @click="emit('select', child)">
<span class="ml-7 min-w-0 flex-1 truncate" :title="child.label">{{ child.label }}</span>
</MkListItem>
</template>
</div>
</el-collapse-item>
<!-- 切换一级菜单页面 -->
<MkListItem v-else :active="menuItem.name === props.activeName" @click="emit('select', menuItem)">
<template #default="{ active }">
<MkIcon v-if="menuItem.icon" class="mr-3" :name="active ? (menuItem.activeIcon ?? menuItem.icon) : menuItem.icon" />
<span class="min-w-0 flex-1 truncate" :title="menuItem.label">{{ menuItem.label }}</span>
</template>
</MkListItem>
</template>
</el-collapse>
</template>

<style lang="scss" scoped>
/* 详情导航折叠分组与 MkListItem 使用相同的行尺寸和悬停样式。 */
.resource-detail-menu {
:deep(.el-collapse-item__content) {
color: var(--mk-N900);
font-size: var(--mk-font-size-base);
line-height: var(--mk-line-height-base);
padding-bottom: 0;
}

:deep(.el-collapse-item__header) {
background: transparent;
border: 0;
border-radius: var(--el-border-radius-base);
color: var(--mk-N900);
font-size: var(--mk-font-size-base);
font-weight: 400;
height: auto;
line-height: var(--mk-line-height-base);
padding: 9px calc(var(--spacing) * 2);

&:hover {
background: rgb(var(--mk-N900-rgb) / 10%);
}
}

:deep(.el-collapse-item__title) {
min-width: 0;
}

:deep(.el-collapse-item__wrap) {
background: transparent;
border: 0;
}
}
</style>
Loading
Loading