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: 8 additions & 0 deletions ui/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ declare module 'vue' {
MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default']
MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default']
MkAction: typeof import('./components/global/mk-table/MkAction.vue')['default']
MkAudio: typeof import('./components/global/mk-media-preview/MkAudio.vue')['default']
MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default']
MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default']
MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default']
Expand All @@ -30,9 +31,11 @@ declare module 'vue' {
MkDropdownMenu: typeof import('./components/global/mk-dropdown/MkDropdownMenu.vue')['default']
MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default']
MkFormList: typeof import('./components/global/mk-form-list/index.vue')['default']
MkHorizontalScroll: typeof import('./components/global/mk-horizontal-scroll/index.vue')['default']
MkIcon: typeof import('./components/global/mk-icon/index.vue')['default']
MkInfiniteScroll: typeof import('./components/global/mk-infinite-scroll/index.vue')['default']
MkListItem: typeof import('./components/global/mk-list-item/index.vue')['default']
MkMediaPreview: typeof import('./components/global/mk-media-preview/index.vue')['default']
MkQuickCreate: typeof import('./components/global/mk-table/MkQuickCreate.vue')['default']
MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
MkSlider: typeof import('./components/global/mk-slider/index.vue')['default']
Expand All @@ -45,6 +48,7 @@ declare module 'vue' {
MkTableMoreDropdown: typeof import('./components/global/mk-table/MkTableMoreDropdown.vue')['default']
MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default']
MkVideo: typeof import('./components/global/mk-media-preview/MkVideo.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']
Expand All @@ -69,6 +73,7 @@ declare global {
const MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default']
const MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default']
const MkAction: typeof import('./components/global/mk-table/MkAction.vue')['default']
const MkAudio: typeof import('./components/global/mk-media-preview/MkAudio.vue')['default']
const MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default']
const MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default']
const MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default']
Expand All @@ -78,9 +83,11 @@ declare global {
const MkDropdownMenu: typeof import('./components/global/mk-dropdown/MkDropdownMenu.vue')['default']
const MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default']
const MkFormList: typeof import('./components/global/mk-form-list/index.vue')['default']
const MkHorizontalScroll: typeof import('./components/global/mk-horizontal-scroll/index.vue')['default']
const MkIcon: typeof import('./components/global/mk-icon/index.vue')['default']
const MkInfiniteScroll: typeof import('./components/global/mk-infinite-scroll/index.vue')['default']
const MkListItem: typeof import('./components/global/mk-list-item/index.vue')['default']
const MkMediaPreview: typeof import('./components/global/mk-media-preview/index.vue')['default']
const MkQuickCreate: typeof import('./components/global/mk-table/MkQuickCreate.vue')['default']
const MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
const MkSlider: typeof import('./components/global/mk-slider/index.vue')['default']
Expand All @@ -93,6 +100,7 @@ declare global {
const MkTableMoreDropdown: typeof import('./components/global/mk-table/MkTableMoreDropdown.vue')['default']
const MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
const MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default']
const MkVideo: typeof import('./components/global/mk-media-preview/MkVideo.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']
Expand Down
50 changes: 46 additions & 4 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,47 @@ import { MkDynamicsForm, MkDynamicsFormConstructor } from '@/components/mk-dynam

## 全局 UI 组件

### MkHorizontalScroll

全局横向滚动组件,位于 `global/mk-horizontal-scroll/index.vue`,模板自动注册。
通过默认插槽传入内容,内部横向 Flex 排列并提供 8px 间距;固定宽度的内容应设置 `shrink-0`。
左右箭头分别在对应方向可滚动时显示,到达边界即隐藏;按钮绝对定位在容器两侧并垂直居中,
不占用内容宽度。每次点击平滑滚动可视宽度的 80%。
底层使用 `el-scrollbar`,隐藏横向滚动条,保留触控板和触屏的原生横向滚动。
`ResizeObserver` 监听容器和内容尺寸,自动响应内容增删、图片加载及窗口缩放;卸载时清理监听。
外部间距由使用方通过 `class` 设置,不在组件内绑定附件业务。

```vue
<MkHorizontalScroll class="mb-3">
<template v-for="file in files" :key="file.uid">
<el-card class="w-60 shrink-0">{{ file.name }}</el-card>
</template>
</MkHorizontalScroll>
```

### MkAudio、MkVideo

位于 `global/mk-media-preview/`,两个全局组件分别提供音频、视频遮罩播放器。
传入必填 `src`、`name`;通过默认插槽传入触发内容,卡片、图标、文件大小及样式由调用方维护。
`src` 为空时不打开预览;触发区域支持点击、Enter 和空格打开,遮罩内居中显示原生音频或视频控制条并尝试自动播放;
自动播放受浏览器策略限制时可手动播放。关闭按钮、点击遮罩或 Esc 关闭预览,立即卸载播放器。
附件删除或列表清空导致组件卸载时也会清理播放器;地址变化时关闭旧预览。
预览采用页面遮罩,不主动请求浏览器全屏,视频原生控制条仍可提供全屏功能。
内部入口 `index.vue`(`MkMediaPreview`) 统一无样式的触发容器与媒体遮罩,不使用 `MkDialog`。
遮罩通过 Teleport 挂载到 body,使用 `ElOverlay` 与 `useZIndex` 管理遮罩和层级,
`useLockscreen` 锁定背景滚动,`TrapFocus` 约束键盘焦点;打开时聚焦关闭按钮,关闭后焦点返回触发区。
预览区处理 Esc 关闭,界面只展示居中的播放器和右上角关闭按钮。业务方使用两个公开组件,
不直接调用内部组件。组件不创建或释放 Object URL,由调用方维护地址生命周期。

```vue
<MkAudio :src="audioUrl" name="录音.mp3">
<el-card>录音附件卡片</el-card>
</MkAudio>
<MkVideo :src="videoUrl" name="演示.mp4">
<el-card>视频附件卡片</el-card>
</MkVideo>
```

### MkTooltip

普通提示统一使用 `MkTooltip`,业务代码不直接使用 `el-tooltip`。默认悬停 300ms 后显示,
Expand Down Expand Up @@ -549,10 +590,11 @@ const avatar = ref('')
### MkTagsEdit

手动导入 `@/components/mk-tags-edit/index.vue`,通过必填的 `string[]` 类型 `v-model`
编辑标签。可选 `reservedTags` 接收保留标签数组,默认为空;组件检查保留值与当前列表的
重复项,并提示“该标签已存在”。输入失焦或按回车时去除首尾空格,空值不添加;默认保留大小写
和前导点。业务需要格式化时通过 `normalizeTag(tag)` 传入转换函数,在重复检查前执行。
`addText` 默认为“添加标签”。组件内部维护输入框显隐与自动聚焦,根节点阻止点击冒泡;外部间距通过 `class` 设置。
编辑文件后缀。可选 `presetExtensions` 接收预设文件后缀数组,默认为空。
输入失焦或按回车时,组件内部去除首尾空格和一个前导点,并转为大写,空值不添加;
重复检查对预设后缀与当前列表应用相同规则,兼容已有的小写后缀,重复时提示“文件后缀已存在”。
已有数据不自动改写,新添加的后缀按上述格式保存。添加按钮和输入提示固定为“添加后缀名”。
组件内部维护输入框显隐与自动聚焦,根节点阻止点击冒泡;外部间距通过 `class` 设置。

### MkCardCheckbox

Expand Down
75 changes: 75 additions & 0 deletions ui/src/components/global/mk-horizontal-scroll/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
import type { ScrollbarInstance } from 'element-plus'

defineOptions({ name: 'MkHorizontalScroll' })
defineSlots<{ default(): unknown }>()

const contentRef = useTemplateRef<HTMLDivElement>('contentRef')
const scrollbarRef = useTemplateRef<ScrollbarInstance>('scrollbarRef')
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
let resizeObserver: ResizeObserver | undefined

// 根据当前滚动位置分别控制左右箭头,边界保留 1px 容差。
function updateScrollState() {
const viewport = scrollbarRef.value?.wrapRef
if (!viewport) return
canScrollLeft.value = viewport.scrollLeft > 1
canScrollRight.value = viewport.scrollWidth - viewport.clientWidth - viewport.scrollLeft > 1
}

function scrollContent(direction: -1 | 1) {
const viewport = scrollbarRef.value?.wrapRef
if (!viewport) return
viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: 'smooth' })
}

// 内容增删、图片加载和容器缩放都会触发尺寸更新。
onMounted(() => {
resizeObserver = new ResizeObserver(updateScrollState)
for (const element of [contentRef.value, scrollbarRef.value?.wrapRef]) {
if (element) resizeObserver.observe(element)
}
updateScrollState()
})
onBeforeUnmount(() => resizeObserver?.disconnect())
</script>

<template>
<div class="mk-horizontal-scroll relative flex-align-center min-w-0">
<!-- 向左滚动内容 -->
<div v-if="canScrollLeft" class="mk-horizontal-scroll-prev absolute inset-y-0 left-0 z-2 flex-center w-13">
<el-button text @click="scrollContent(-1)" class="-ml-7">
<MkIcon name="icon_left_outlined" :size="18" />
</el-button>
</div>

<el-scrollbar ref="scrollbarRef" class="min-w-0 flex-1 h-auto!" @scroll="updateScrollState">
<div ref="contentRef" class="flex-align-center w-max gap-2">
<slot />
</div>
</el-scrollbar>
<!-- 向右滚动内容 -->
<div v-if="canScrollRight" class="mk-horizontal-scroll-next absolute inset-y-0 right-0 z-2 flex-center w-13">
<el-button text @click="scrollContent(1)" class="-mr-7">
<MkIcon name="icon_right_outlined" :size="18" />
</el-button>
</div>
</div>
</template>

<style scoped lang="scss">
.mk-horizontal-scroll {
&-prev {
background: linear-gradient(90deg, #ffffff 45.45%, rgba(255, 255, 255, 0) 98.96%);
}
&-next {
background: linear-gradient(270deg, #ffffff 45.45%, rgba(255, 255, 255, 0) 98.96%);
}

:deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
}
</style>
18 changes: 18 additions & 0 deletions ui/src/components/global/mk-media-preview/MkAudio.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script setup lang="ts">
import MkMediaPreview from './index.vue'
import { MsgWarning } from '@/utils/message'
import type { MediaPreviewProps } from './types'

defineOptions({ name: 'MkAudio' })
const props = defineProps<MediaPreviewProps>()
defineSlots<{ default(): unknown }>()
</script>

<template>
<MkMediaPreview v-bind="props">
<slot />
<template #player>
<audio :src="src" controls tabindex="0" autoplay class="w-full max-w-160" @error="MsgWarning('无法播放,请检查音频格式或文件地址')" />
</template>
</MkMediaPreview>
</template>
26 changes: 26 additions & 0 deletions ui/src/components/global/mk-media-preview/MkVideo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import MkMediaPreview from './index.vue'
import { MsgWarning } from '@/utils/message'
import type { MediaPreviewProps } from './types'

defineOptions({ name: 'MkVideo' })
const props = defineProps<MediaPreviewProps>()
defineSlots<{ default(): unknown }>()
</script>

<template>
<MkMediaPreview v-bind="props">
<slot />
<template #player>
<video
:src="src"
controls
tabindex="0"
autoplay
playsinline
class="w-full max-h-[75vh] object-contain"
@error="MsgWarning('无法播放,请检查视频格式或文件地址')"
/>
</template>
</MkMediaPreview>
</template>
76 changes: 76 additions & 0 deletions ui/src/components/global/mk-media-preview/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useId, useTemplateRef, watch } from 'vue'
import { ElOverlay, TrapFocus as vTrapFocus, useLockscreen, useZIndex, type ButtonInstance } from 'element-plus'
import type { MediaPreviewProps } from './types'

defineOptions({ name: 'MkMediaPreview' })
const props = defineProps<MediaPreviewProps>()
defineSlots<{ default(): unknown; player(): unknown }>()
const visible = ref(false)
const triggerRef = useTemplateRef<HTMLDivElement>('triggerRef')
const closeButtonRef = useTemplateRef<ButtonInstance>('closeButtonRef')
const overlayZIndex = ref(0)
const { nextZIndex } = useZIndex()
useLockscreen(visible)
const titleId = useId()

// 复用 Element Plus 遮罩、层级、滚动锁定和焦点约束。
function open() {
if (!props.src || visible.value) return
overlayZIndex.value = nextZIndex()
visible.value = true
void nextTick(() => closeButtonRef.value?.ref?.focus())
}

function close() {
if (!visible.value) return
visible.value = false
triggerRef.value?.focus()
}

// 地址变化或组件卸载时关闭预览并停止播放。
watch(() => props.src, close)
onBeforeUnmount(close)
</script>

<template>
<div ref="triggerRef" role="button" :tabindex="src ? 0 : -1" @click="open" @keydown.enter.stop.prevent="open" @keydown.space.stop.prevent="open">
<slot />
</div>

<Teleport to="body">
<el-overlay v-if="visible" :z-index="overlayZIndex" @click="close">
<div
v-trap-focus
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
class="flex-center min-h-full p-6"
@click.self="close"
@keydown.esc.stop.prevent="close"
>
<!-- 关闭媒体预览 -->
<el-button ref="closeButtonRef" circle size="large" type="info" class="mk-media-preview-close absolute right-6 top-6" @click="close">
<MkIcon name="icon_close_outlined" :size="22" />
</el-button>
<div class="w-full max-w-240 min-w-0">
<div class="flex-center">
<slot name="player" />
</div>
</div>
</div>
</el-overlay>
</Teleport>
</template>

<style scoped lang="scss">
.mk-media-preview-close {
&,
&:hover,
&:active {
border: rgb(var(--mk-N900-rgb) / 80%);
background-color: rgb(var(--mk-N900-rgb) / 80%);
color: white;
}
}
</style>
5 changes: 5 additions & 0 deletions ui/src/components/global/mk-media-preview/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** 音视频遮罩预览的公共参数。 */
export interface MediaPreviewProps {
src: string
name: string
}
17 changes: 12 additions & 5 deletions ui/src/components/mk-tags-edit/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { MsgWarning } from '@/utils/message'
defineOptions({ name: 'MkTagsEdit' })

const tags = defineModel<string[]>({ required: true })
const props = withDefaults(defineProps<{ reservedTags?: readonly string[]; normalizeTag?: (tag: string) => string }>(), {
reservedTags: () => [],
const props = withDefaults(defineProps<{ presetExtensions?: readonly string[] }>(), {
presetExtensions: () => [],
})

// 标签编辑:自动聚焦,确认时清理输入并检查重复值。
Expand All @@ -20,11 +20,17 @@ function showTagInput() {
nextTick(() => inputRef.value?.focus())
}

function normalizeExtension(extension: string) {
return extension.trim().replace(/^\./, '').toUpperCase()
}

function confirmTag() {
const input = tagInput.value.trim()
const tag = props.normalizeTag ? props.normalizeTag(input) : input
const tag = normalizeExtension(tagInput.value)
if (tag) {
if (props.reservedTags.includes(tag) || tags.value.includes(tag)) {
if (
props.presetExtensions.some((presetExtension) => normalizeExtension(presetExtension) === tag) ||
tags.value.some((currentTag) => normalizeExtension(currentTag) === tag)
) {
MsgWarning('文件后缀已存在')
} else {
tags.value = [...tags.value, tag]
Expand Down Expand Up @@ -57,6 +63,7 @@ function removeTag(tag: string) {
@blur="confirmTag"
@keyup.enter="confirmTag"
/>
<!-- 添加文件后缀 -->
<el-button class="mk-add-tag-button" plain v-else size="small" @click="showTagInput">
<MkIcon name="icon_add_outlined" size="14" />
<span>添加后缀名</span>
Expand Down
3 changes: 3 additions & 0 deletions ui/src/constants/CONSTANT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ const knowledgeTypeLabel = KNOWLEDGE_TYPE_LABELS[knowledgeType]

`document.ts` 的 `DOCUMENT_HIT_HANDLING_LABELS` 维护文档命中处理方式的展示文案,
映射键引用 API 枚举 `DOCUMENT_HIT_HANDLING`。

`file-type.ts` 维护图片、文档、视频和音频的文件扩展名常量,扩展名统一使用大写。
使用方判断文件类型时先将后缀转换为大写,再与对应常量匹配。
5 changes: 5 additions & 0 deletions ui/src/constants/file-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// 文件类型对应的扩展名,统一使用大写。
export const IMAGE_EXTENSIONS: readonly string[] = ['JPG', 'JPEG', 'PNG', 'GIF', 'BMP']
export const DOCUMENT_EXTENSIONS: readonly string[] = ['PDF', 'DOCX', 'TXT', 'XLS', 'XLSX', 'MD', 'HTML', 'CSV']
export const VIDEO_EXTENSIONS: readonly string[] = ['MP4', 'AVI', 'MKV', 'MOV', 'FLV', 'WMV']
export const AUDIO_EXTENSIONS: readonly string[] = ['MP3', 'WAV', 'OGG', 'AAC', 'M4A']
Loading
Loading