Skip to content

Commit b140978

Browse files
feat: chat message input
1 parent 7046df4 commit b140978

24 files changed

Lines changed: 480 additions & 204 deletions

File tree

‎ui/src/components.d.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ declare module 'vue' {
2121
MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default']
2222
MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default']
2323
MkAction: typeof import('./components/global/mk-table/MkAction.vue')['default']
24+
MkAudio: typeof import('./components/global/mk-media-preview/MkAudio.vue')['default']
2425
MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default']
2526
MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default']
2627
MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default']
@@ -30,9 +31,11 @@ declare module 'vue' {
3031
MkDropdownMenu: typeof import('./components/global/mk-dropdown/MkDropdownMenu.vue')['default']
3132
MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default']
3233
MkFormList: typeof import('./components/global/mk-form-list/index.vue')['default']
34+
MkHorizontalScroll: typeof import('./components/global/mk-horizontal-scroll/index.vue')['default']
3335
MkIcon: typeof import('./components/global/mk-icon/index.vue')['default']
3436
MkInfiniteScroll: typeof import('./components/global/mk-infinite-scroll/index.vue')['default']
3537
MkListItem: typeof import('./components/global/mk-list-item/index.vue')['default']
38+
MkMediaPreview: typeof import('./components/global/mk-media-preview/index.vue')['default']
3639
MkQuickCreate: typeof import('./components/global/mk-table/MkQuickCreate.vue')['default']
3740
MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
3841
MkSlider: typeof import('./components/global/mk-slider/index.vue')['default']
@@ -45,6 +48,7 @@ declare module 'vue' {
4548
MkTableMoreDropdown: typeof import('./components/global/mk-table/MkTableMoreDropdown.vue')['default']
4649
MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
4750
MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default']
51+
MkVideo: typeof import('./components/global/mk-media-preview/MkVideo.vue')['default']
4852
MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default']
4953
PortalIcon: typeof import('./components/global/mk-icon/PortalIcon.vue')['default']
5054
RouterLink: typeof import('vue-router')['RouterLink']
@@ -69,6 +73,7 @@ declare global {
6973
const MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default']
7074
const MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default']
7175
const MkAction: typeof import('./components/global/mk-table/MkAction.vue')['default']
76+
const MkAudio: typeof import('./components/global/mk-media-preview/MkAudio.vue')['default']
7277
const MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default']
7378
const MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default']
7479
const MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default']
@@ -78,9 +83,11 @@ declare global {
7883
const MkDropdownMenu: typeof import('./components/global/mk-dropdown/MkDropdownMenu.vue')['default']
7984
const MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default']
8085
const MkFormList: typeof import('./components/global/mk-form-list/index.vue')['default']
86+
const MkHorizontalScroll: typeof import('./components/global/mk-horizontal-scroll/index.vue')['default']
8187
const MkIcon: typeof import('./components/global/mk-icon/index.vue')['default']
8288
const MkInfiniteScroll: typeof import('./components/global/mk-infinite-scroll/index.vue')['default']
8389
const MkListItem: typeof import('./components/global/mk-list-item/index.vue')['default']
90+
const MkMediaPreview: typeof import('./components/global/mk-media-preview/index.vue')['default']
8491
const MkQuickCreate: typeof import('./components/global/mk-table/MkQuickCreate.vue')['default']
8592
const MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
8693
const MkSlider: typeof import('./components/global/mk-slider/index.vue')['default']
@@ -93,6 +100,7 @@ declare global {
93100
const MkTableMoreDropdown: typeof import('./components/global/mk-table/MkTableMoreDropdown.vue')['default']
94101
const MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
95102
const MkTooltip: typeof import('./components/global/mk-tooltip/index.vue')['default']
103+
const MkVideo: typeof import('./components/global/mk-media-preview/MkVideo.vue')['default']
96104
const MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default']
97105
const PortalIcon: typeof import('./components/global/mk-icon/PortalIcon.vue')['default']
98106
const RouterLink: typeof import('vue-router')['RouterLink']

‎ui/src/components/COMPONENT_README.md‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,47 @@ import { MkDynamicsForm, MkDynamicsFormConstructor } from '@/components/mk-dynam
7575

7676
## 全局 UI 组件
7777

78+
### MkHorizontalScroll
79+
80+
全局横向滚动组件,位于 `global/mk-horizontal-scroll/index.vue`,模板自动注册。
81+
通过默认插槽传入内容,内部横向 Flex 排列并提供 8px 间距;固定宽度的内容应设置 `shrink-0`。
82+
左右箭头分别在对应方向可滚动时显示,到达边界即隐藏;按钮绝对定位在容器两侧并垂直居中,
83+
不占用内容宽度。每次点击平滑滚动可视宽度的 80%。
84+
底层使用 `el-scrollbar`,隐藏横向滚动条,保留触控板和触屏的原生横向滚动。
85+
`ResizeObserver` 监听容器和内容尺寸,自动响应内容增删、图片加载及窗口缩放;卸载时清理监听。
86+
外部间距由使用方通过 `class` 设置,不在组件内绑定附件业务。
87+
88+
```vue
89+
<MkHorizontalScroll class="mb-3">
90+
<template v-for="file in files" :key="file.uid">
91+
<el-card class="w-60 shrink-0">{{ file.name }}</el-card>
92+
</template>
93+
</MkHorizontalScroll>
94+
```
95+
96+
### MkAudio、MkVideo
97+
98+
位于 `global/mk-media-preview/`,两个全局组件分别提供音频、视频遮罩播放器。
99+
传入必填 `src`、`name`;通过默认插槽传入触发内容,卡片、图标、文件大小及样式由调用方维护。
100+
`src` 为空时不打开预览;触发区域支持点击、Enter 和空格打开,遮罩内居中显示原生音频或视频控制条并尝试自动播放;
101+
自动播放受浏览器策略限制时可手动播放。关闭按钮、点击遮罩或 Esc 关闭预览,立即卸载播放器。
102+
附件删除或列表清空导致组件卸载时也会清理播放器;地址变化时关闭旧预览。
103+
预览采用页面遮罩,不主动请求浏览器全屏,视频原生控制条仍可提供全屏功能。
104+
内部入口 `index.vue`(`MkMediaPreview`) 统一无样式的触发容器与媒体遮罩,不使用 `MkDialog`。
105+
遮罩通过 Teleport 挂载到 body,使用 `ElOverlay` 与 `useZIndex` 管理遮罩和层级,
106+
`useLockscreen` 锁定背景滚动,`TrapFocus` 约束键盘焦点;打开时聚焦关闭按钮,关闭后焦点返回触发区。
107+
预览区处理 Esc 关闭,界面只展示居中的播放器和右上角关闭按钮。业务方使用两个公开组件,
108+
不直接调用内部组件。组件不创建或释放 Object URL,由调用方维护地址生命周期。
109+
110+
```vue
111+
<MkAudio :src="audioUrl" name="录音.mp3">
112+
<el-card>录音附件卡片</el-card>
113+
</MkAudio>
114+
<MkVideo :src="videoUrl" name="演示.mp4">
115+
<el-card>视频附件卡片</el-card>
116+
</MkVideo>
117+
```
118+
78119
### MkTooltip
79120

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

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

557599
### MkCardCheckbox
558600

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<script setup lang="ts">
2+
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
3+
import type { ScrollbarInstance } from 'element-plus'
4+
5+
defineOptions({ name: 'MkHorizontalScroll' })
6+
defineSlots<{ default(): unknown }>()
7+
8+
const contentRef = useTemplateRef<HTMLDivElement>('contentRef')
9+
const scrollbarRef = useTemplateRef<ScrollbarInstance>('scrollbarRef')
10+
const canScrollLeft = ref(false)
11+
const canScrollRight = ref(false)
12+
let resizeObserver: ResizeObserver | undefined
13+
14+
// 根据当前滚动位置分别控制左右箭头,边界保留 1px 容差。
15+
function updateScrollState() {
16+
const viewport = scrollbarRef.value?.wrapRef
17+
if (!viewport) return
18+
canScrollLeft.value = viewport.scrollLeft > 1
19+
canScrollRight.value = viewport.scrollWidth - viewport.clientWidth - viewport.scrollLeft > 1
20+
}
21+
22+
function scrollContent(direction: -1 | 1) {
23+
const viewport = scrollbarRef.value?.wrapRef
24+
if (!viewport) return
25+
viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: 'smooth' })
26+
}
27+
28+
// 内容增删、图片加载和容器缩放都会触发尺寸更新。
29+
onMounted(() => {
30+
resizeObserver = new ResizeObserver(updateScrollState)
31+
for (const element of [contentRef.value, scrollbarRef.value?.wrapRef]) {
32+
if (element) resizeObserver.observe(element)
33+
}
34+
updateScrollState()
35+
})
36+
onBeforeUnmount(() => resizeObserver?.disconnect())
37+
</script>
38+
39+
<template>
40+
<div class="mk-horizontal-scroll relative flex-align-center min-w-0">
41+
<!-- 向左滚动内容 -->
42+
<div v-if="canScrollLeft" class="mk-horizontal-scroll-prev absolute inset-y-0 left-0 z-2 flex-center w-13">
43+
<el-button text @click="scrollContent(-1)" class="-ml-7">
44+
<MkIcon name="icon_left_outlined" :size="18" />
45+
</el-button>
46+
</div>
47+
48+
<el-scrollbar ref="scrollbarRef" class="min-w-0 flex-1 h-auto!" @scroll="updateScrollState">
49+
<div ref="contentRef" class="flex-align-center w-max gap-2">
50+
<slot />
51+
</div>
52+
</el-scrollbar>
53+
<!-- 向右滚动内容 -->
54+
<div v-if="canScrollRight" class="mk-horizontal-scroll-next absolute inset-y-0 right-0 z-2 flex-center w-13">
55+
<el-button text @click="scrollContent(1)" class="-mr-7">
56+
<MkIcon name="icon_right_outlined" :size="18" />
57+
</el-button>
58+
</div>
59+
</div>
60+
</template>
61+
62+
<style scoped lang="scss">
63+
.mk-horizontal-scroll {
64+
&-prev {
65+
background: linear-gradient(90deg, #ffffff 45.45%, rgba(255, 255, 255, 0) 98.96%);
66+
}
67+
&-next {
68+
background: linear-gradient(270deg, #ffffff 45.45%, rgba(255, 255, 255, 0) 98.96%);
69+
}
70+
71+
:deep(.el-scrollbar__bar.is-horizontal) {
72+
display: none;
73+
}
74+
}
75+
</style>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script setup lang="ts">
2+
import MkMediaPreview from './index.vue'
3+
import { MsgWarning } from '@/utils/message'
4+
import type { MediaPreviewProps } from './types'
5+
6+
defineOptions({ name: 'MkAudio' })
7+
const props = defineProps<MediaPreviewProps>()
8+
defineSlots<{ default(): unknown }>()
9+
</script>
10+
11+
<template>
12+
<MkMediaPreview v-bind="props">
13+
<slot />
14+
<template #player>
15+
<audio :src="src" controls tabindex="0" autoplay class="w-full max-w-160" @error="MsgWarning('无法播放,请检查音频格式或文件地址')" />
16+
</template>
17+
</MkMediaPreview>
18+
</template>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script setup lang="ts">
2+
import MkMediaPreview from './index.vue'
3+
import { MsgWarning } from '@/utils/message'
4+
import type { MediaPreviewProps } from './types'
5+
6+
defineOptions({ name: 'MkVideo' })
7+
const props = defineProps<MediaPreviewProps>()
8+
defineSlots<{ default(): unknown }>()
9+
</script>
10+
11+
<template>
12+
<MkMediaPreview v-bind="props">
13+
<slot />
14+
<template #player>
15+
<video
16+
:src="src"
17+
controls
18+
tabindex="0"
19+
autoplay
20+
playsinline
21+
class="w-full max-h-[75vh] object-contain"
22+
@error="MsgWarning('无法播放,请检查视频格式或文件地址')"
23+
/>
24+
</template>
25+
</MkMediaPreview>
26+
</template>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<script setup lang="ts">
2+
import { nextTick, onBeforeUnmount, ref, useId, useTemplateRef, watch } from 'vue'
3+
import { ElOverlay, TrapFocus as vTrapFocus, useLockscreen, useZIndex, type ButtonInstance } from 'element-plus'
4+
import type { MediaPreviewProps } from './types'
5+
6+
defineOptions({ name: 'MkMediaPreview' })
7+
const props = defineProps<MediaPreviewProps>()
8+
defineSlots<{ default(): unknown; player(): unknown }>()
9+
const visible = ref(false)
10+
const triggerRef = useTemplateRef<HTMLDivElement>('triggerRef')
11+
const closeButtonRef = useTemplateRef<ButtonInstance>('closeButtonRef')
12+
const overlayZIndex = ref(0)
13+
const { nextZIndex } = useZIndex()
14+
useLockscreen(visible)
15+
const titleId = useId()
16+
17+
// 复用 Element Plus 遮罩、层级、滚动锁定和焦点约束。
18+
function open() {
19+
if (!props.src || visible.value) return
20+
overlayZIndex.value = nextZIndex()
21+
visible.value = true
22+
void nextTick(() => closeButtonRef.value?.ref?.focus())
23+
}
24+
25+
function close() {
26+
if (!visible.value) return
27+
visible.value = false
28+
triggerRef.value?.focus()
29+
}
30+
31+
// 地址变化或组件卸载时关闭预览并停止播放。
32+
watch(() => props.src, close)
33+
onBeforeUnmount(close)
34+
</script>
35+
36+
<template>
37+
<div ref="triggerRef" role="button" :tabindex="src ? 0 : -1" @click="open" @keydown.enter.stop.prevent="open" @keydown.space.stop.prevent="open">
38+
<slot />
39+
</div>
40+
41+
<Teleport to="body">
42+
<el-overlay v-if="visible" :z-index="overlayZIndex" @click="close">
43+
<div
44+
v-trap-focus
45+
role="dialog"
46+
aria-modal="true"
47+
:aria-labelledby="titleId"
48+
class="flex-center min-h-full p-6"
49+
@click.self="close"
50+
@keydown.esc.stop.prevent="close"
51+
>
52+
<!-- 关闭媒体预览 -->
53+
<el-button ref="closeButtonRef" circle size="large" type="info" class="mk-media-preview-close absolute right-6 top-6" @click="close">
54+
<MkIcon name="icon_close_outlined" :size="22" />
55+
</el-button>
56+
<div class="w-full max-w-240 min-w-0">
57+
<div class="flex-center">
58+
<slot name="player" />
59+
</div>
60+
</div>
61+
</div>
62+
</el-overlay>
63+
</Teleport>
64+
</template>
65+
66+
<style scoped lang="scss">
67+
.mk-media-preview-close {
68+
&,
69+
&:hover,
70+
&:active {
71+
border: rgb(var(--mk-N900-rgb) / 80%);
72+
background-color: rgb(var(--mk-N900-rgb) / 80%);
73+
color: white;
74+
}
75+
}
76+
</style>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** 音视频遮罩预览的公共参数。 */
2+
export interface MediaPreviewProps {
3+
src: string
4+
name: string
5+
}

‎ui/src/components/mk-tags-edit/index.vue‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { MsgWarning } from '@/utils/message'
66
defineOptions({ name: 'MkTagsEdit' })
77
88
const tags = defineModel<string[]>({ required: true })
9-
const props = withDefaults(defineProps<{ reservedTags?: readonly string[]; normalizeTag?: (tag: string) => string }>(), {
10-
reservedTags: () => [],
9+
const props = withDefaults(defineProps<{ presetExtensions?: readonly string[] }>(), {
10+
presetExtensions: () => [],
1111
})
1212
1313
// 标签编辑:自动聚焦,确认时清理输入并检查重复值。
@@ -20,11 +20,17 @@ function showTagInput() {
2020
nextTick(() => inputRef.value?.focus())
2121
}
2222
23+
function normalizeExtension(extension: string) {
24+
return extension.trim().replace(/^\./, '').toUpperCase()
25+
}
26+
2327
function confirmTag() {
24-
const input = tagInput.value.trim()
25-
const tag = props.normalizeTag ? props.normalizeTag(input) : input
28+
const tag = normalizeExtension(tagInput.value)
2629
if (tag) {
27-
if (props.reservedTags.includes(tag) || tags.value.includes(tag)) {
30+
if (
31+
props.presetExtensions.some((presetExtension) => normalizeExtension(presetExtension) === tag) ||
32+
tags.value.some((currentTag) => normalizeExtension(currentTag) === tag)
33+
) {
2834
MsgWarning('文件后缀已存在')
2935
} else {
3036
tags.value = [...tags.value, tag]
@@ -57,6 +63,7 @@ function removeTag(tag: string) {
5763
@blur="confirmTag"
5864
@keyup.enter="confirmTag"
5965
/>
66+
<!-- 添加文件后缀 -->
6067
<el-button class="mk-add-tag-button" plain v-else size="small" @click="showTagInput">
6168
<MkIcon name="icon_add_outlined" size="14" />
6269
<span>添加后缀名</span>

‎ui/src/constants/CONSTANT_README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,6 @@ const knowledgeTypeLabel = KNOWLEDGE_TYPE_LABELS[knowledgeType]
4545

4646
`document.ts` 的 `DOCUMENT_HIT_HANDLING_LABELS` 维护文档命中处理方式的展示文案,
4747
映射键引用 API 枚举 `DOCUMENT_HIT_HANDLING`。
48+
49+
`file-type.ts` 维护图片、文档、视频和音频的文件扩展名常量,扩展名统一使用大写。
50+
使用方判断文件类型时先将后缀转换为大写,再与对应常量匹配。

‎ui/src/constants/file-type.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// 文件类型对应的扩展名,统一使用大写。
2+
export const IMAGE_EXTENSIONS: readonly string[] = ['JPG', 'JPEG', 'PNG', 'GIF', 'BMP']
3+
export const DOCUMENT_EXTENSIONS: readonly string[] = ['PDF', 'DOCX', 'TXT', 'XLS', 'XLSX', 'MD', 'HTML', 'CSV']
4+
export const VIDEO_EXTENSIONS: readonly string[] = ['MP4', 'AVI', 'MKV', 'MOV', 'FLV', 'WMV']
5+
export const AUDIO_EXTENSIONS: readonly string[] = ['MP3', 'WAV', 'OGG', 'AAC', 'M4A']

0 commit comments

Comments
 (0)