Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { default as MentionDropdown } from './mention-dropdown.vue';
|
||||
@@ -0,0 +1,555 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { useI18n } from '@vben/locales';
|
||||
|
||||
import { UserAvatar } from '#/components/user-avatar';
|
||||
import { getUserListApi, type User } from '#/api/core/user';
|
||||
|
||||
defineOptions({ name: 'MentionDropdown' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Element to anchor the dropdown positioning */
|
||||
anchorEl?: HTMLElement | null;
|
||||
/** Maximum dropdown height in px */
|
||||
maxHeight?: number;
|
||||
/** Preferred placement relative to anchor */
|
||||
placement?: 'auto' | 'bottom' | 'top';
|
||||
/** Search query (text after @) */
|
||||
query: string;
|
||||
/** Controls visibility */
|
||||
visible: boolean;
|
||||
}>(),
|
||||
{
|
||||
anchorEl: null,
|
||||
maxHeight: 300,
|
||||
placement: 'auto',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [user: User];
|
||||
'update:visible': [val: boolean];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const loading = ref(false);
|
||||
const activeIndex = ref(0);
|
||||
const dropdownRef = ref<HTMLElement | null>(null);
|
||||
const position = ref({ left: 0, top: 0 });
|
||||
const resolvedPlacement = ref<'bottom' | 'top'>('top');
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
watch(
|
||||
() => props.query,
|
||||
(q) => {
|
||||
if (!props.visible) return;
|
||||
activeIndex.value = 0;
|
||||
searchUsers(q);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) {
|
||||
activeIndex.value = 0;
|
||||
searchUsers(props.query);
|
||||
nextTick(updatePosition);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function searchUsers(query: string) {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
loading.value = true;
|
||||
searchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await getUserListApi({
|
||||
name: query || undefined,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
users.value = res.items || [];
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
loading.value = false;
|
||||
nextTick(updatePosition);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!props.anchorEl || !dropdownRef.value) return;
|
||||
|
||||
const anchorRect = props.anchorEl.getBoundingClientRect();
|
||||
const dropdownEl = dropdownRef.value;
|
||||
const dropdownHeight = dropdownEl.offsetHeight || props.maxHeight;
|
||||
const dropdownWidth = dropdownEl.offsetWidth || 300;
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
|
||||
let placement = props.placement;
|
||||
if (placement === 'auto') {
|
||||
const spaceAbove = anchorRect.top;
|
||||
const spaceBelow = vh - anchorRect.bottom;
|
||||
placement = spaceBelow >= dropdownHeight + 8 ? 'bottom' : 'top';
|
||||
}
|
||||
resolvedPlacement.value = placement;
|
||||
|
||||
let top: number;
|
||||
if (placement === 'bottom') {
|
||||
top = anchorRect.bottom + 4;
|
||||
} else {
|
||||
top = anchorRect.top - dropdownHeight - 4;
|
||||
}
|
||||
top = Math.max(8, Math.min(top, vh - dropdownHeight - 8));
|
||||
|
||||
let left = anchorRect.left;
|
||||
if (left + dropdownWidth > vw - 8) {
|
||||
left = vw - dropdownWidth - 8;
|
||||
}
|
||||
left = Math.max(8, left);
|
||||
|
||||
position.value = { left, top };
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): boolean {
|
||||
if (!props.visible) return false;
|
||||
|
||||
if (loading.value && users.value.length === 0) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
emit('update:visible', false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (users.value.length === 0) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
emit('update:visible', false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
activeIndex.value = (activeIndex.value + 1) % users.value.length;
|
||||
scrollToActive();
|
||||
return true;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
activeIndex.value =
|
||||
(activeIndex.value - 1 + users.value.length) % users.value.length;
|
||||
scrollToActive();
|
||||
return true;
|
||||
}
|
||||
case 'Enter': {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectUser(users.value[activeIndex.value]!);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case 'Tab': {
|
||||
e.preventDefault();
|
||||
selectUser(users.value[activeIndex.value]!);
|
||||
return true;
|
||||
}
|
||||
case 'Escape': {
|
||||
e.preventDefault();
|
||||
emit('update:visible', false);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToActive() {
|
||||
nextTick(() => {
|
||||
const listEl = dropdownRef.value?.querySelector('.mention-list');
|
||||
const activeItem = listEl?.children[activeIndex.value] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
if (activeItem && listEl) {
|
||||
activeItem.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectUser(user: User) {
|
||||
emit('select', user);
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): string {
|
||||
if (!query) return escapeHtml(text);
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escaped})`, 'gi');
|
||||
return escapeHtml(text).replace(
|
||||
regex,
|
||||
'<mark class="mention-highlight">$1</mark>',
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (props.visible) updatePosition();
|
||||
}
|
||||
|
||||
const showFooterHint = computed(() => {
|
||||
return props.visible && users.value.length > 0 && !loading.value;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onViewportChange);
|
||||
window.addEventListener('scroll', onViewportChange, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onViewportChange);
|
||||
window.removeEventListener('scroll', onViewportChange, true);
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
defineExpose({ handleKeydown });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="mention-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="dropdownRef"
|
||||
class="mention-dropdown"
|
||||
:class="[`placement-${resolvedPlacement}`]"
|
||||
:style="{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
}"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading && users.length === 0" class="mention-loading">
|
||||
<div v-for="i in 3" :key="i" class="mention-skeleton">
|
||||
<div class="skeleton-avatar" />
|
||||
<div class="skeleton-info">
|
||||
<div class="skeleton-name" />
|
||||
<div class="skeleton-dept" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!loading && users.length === 0" class="mention-empty">
|
||||
<div class="mention-empty-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>{{ t('mention.noResults') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- User list -->
|
||||
<div v-else class="mention-list">
|
||||
<div
|
||||
v-for="(user, idx) in users"
|
||||
:key="user.id"
|
||||
class="mention-item"
|
||||
:class="{ active: idx === activeIndex }"
|
||||
@mousedown.prevent="selectUser(user)"
|
||||
@mouseenter="activeIndex = idx"
|
||||
>
|
||||
<UserAvatar
|
||||
:user-id="user.id"
|
||||
:name="user.name || user.username"
|
||||
:size="32"
|
||||
:font-size="13"
|
||||
:shadow="false"
|
||||
:show-popover="false"
|
||||
/>
|
||||
<div class="mention-user-info">
|
||||
<div
|
||||
class="mention-user-name"
|
||||
v-html="highlightMatch(user.name || user.username || '', query)"
|
||||
/>
|
||||
<div v-if="user.dept_name" class="mention-user-dept">
|
||||
{{ user.dept_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer keyboard hints -->
|
||||
<div v-if="showFooterHint" class="mention-hint">
|
||||
<span
|
||||
><kbd>↑</kbd><kbd>↓</kbd>
|
||||
{{ t('mention.navigate') }}</span
|
||||
>
|
||||
<span><kbd>Enter</kbd> {{ t('mention.select') }}</span>
|
||||
<span><kbd>Esc</kbd> {{ t('mention.close') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mention-dropdown {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
overflow: hidden;
|
||||
background: var(--zq-bg-primary, #fff);
|
||||
border: 1px solid var(--zq-border-color-light, #e4e7ed);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.mention-list {
|
||||
max-height: 260px;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--zq-border-color, #dcdfe6);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.mention-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background 0.12s;
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9, #ecf5ff);
|
||||
}
|
||||
|
||||
&:not(.active):hover {
|
||||
background: var(--zq-bg-cell-hover, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
}
|
||||
|
||||
.mention-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mention-user-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--zq-text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.mention-highlight) {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary, #409eff);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.mention-user-dept {
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
color: var(--zq-text-placeholder);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.mention-loading {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mention-skeleton {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
|
||||
.skeleton-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--el-fill-color, #f0f2f5) 25%,
|
||||
var(--el-fill-color-light, #f5f7fa) 50%,
|
||||
var(--el-fill-color, #f0f2f5) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 50%;
|
||||
animation: mention-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.skeleton-name,
|
||||
.skeleton-dept {
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--el-fill-color, #f0f2f5) 25%,
|
||||
var(--el-fill-color-light, #f5f7fa) 50%,
|
||||
var(--el-fill-color, #f0f2f5) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: mention-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-name {
|
||||
width: 55%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.skeleton-dept {
|
||||
width: 35%;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.mention-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--zq-text-placeholder);
|
||||
}
|
||||
|
||||
.mention-empty-icon {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Footer hints */
|
||||
.mention-hint {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 5px 12px;
|
||||
font-size: 10px;
|
||||
color: var(--zq-text-placeholder);
|
||||
background: var(--zq-bg-secondary, #f5f7fa);
|
||||
border-top: 1px solid var(--zq-border-color-light, #e4e7ed);
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 3px;
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
color: var(--zq-text-secondary);
|
||||
background: var(--zq-bg-primary, #fff);
|
||||
border: 1px solid var(--zq-border-color, #dcdfe6);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.mention-fade-enter-active,
|
||||
.mention-fade-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.mention-fade-enter-from,
|
||||
.mention-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.placement-top {
|
||||
.mention-fade-enter-from,
|
||||
.mention-fade-leave-to {
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.placement-bottom {
|
||||
.mention-fade-enter-from,
|
||||
.mention-fade-leave-to {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mention-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user