Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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>&uarr;</kbd><kbd>&darr;</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>