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,7 @@
import { defineAsyncComponent } from 'vue';
export const ZqApiSelect = defineAsyncComponent(() =>
import('./zq-api-select.vue').then((module) => module.default),
);
export * from './types';
@@ -0,0 +1,79 @@
import type { SelectProps } from 'element-plus';
/** API 查询参数 */
export interface ApiSelectQueryParams {
page: number;
pageSize: number;
keyword?: string;
}
/** API 查询结果 */
export interface ApiSelectQueryResult<T = any> {
items: T[];
total: number;
}
/** 选项数据结构 */
export interface ApiSelectOption {
value: string;
label: string;
extra?: string;
raw?: any;
}
export interface ZqApiSelectProps extends Partial<
Omit<SelectProps, 'modelValue' | 'onChange'>
> {
/** 选中值 */
modelValue?: string | string[];
/** 是否多选 */
multiple?: boolean;
/** 占位符 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否可清空 */
clearable?: boolean;
/** 是否可搜索 */
filterable?: boolean;
// === 核心配置 ===
/** 列表查询 API(必填) */
api: (params: ApiSelectQueryParams) => Promise<ApiSelectQueryResult>;
/** 根据 ID 查询 API(可选,用于回显) */
apiByIds?: (ids: string[]) => Promise<any[]>;
/** 值字段名,默认 'id' */
valueField?: string;
/** 标签字段名,默认 'name' */
labelField?: string;
/** 额外信息字段名(可选,显示在右侧) */
extraField?: string;
// === 弹窗配置 ===
/** 弹窗标题 */
dialogTitle?: string;
/** 弹窗宽度,默认 '45%' */
dialogWidth?: string;
// === 分页配置 ===
/** 每页数量,默认 20 */
pageSize?: number;
}
export interface ZqApiSelectEmits {
(e: 'update:modelValue', value: string | string[] | undefined): void;
(e: 'change', value: string | string[] | undefined): void;
}
@@ -0,0 +1,675 @@
<script lang="ts" setup>
import type {
ApiSelectOption,
ZqApiSelectEmits,
ZqApiSelectProps,
} from './types';
import { computed, onMounted, ref, useAttrs, watch } from 'vue';
import { Search } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElEmpty,
ElInput,
ElOption,
ElSelect,
ElSkeleton,
ElSkeletonItem,
ElTag,
} from 'element-plus';
import { ZqDialog } from '#/components/zq-dialog';
defineOptions({
name: 'ZqApiSelect',
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
multiple: false,
placeholder: () => $t('ui.placeholder.select') || 'Please select',
disabled: false,
clearable: true,
filterable: true,
valueField: 'id',
labelField: 'name',
extraField: undefined,
dialogTitle: () => $t('ui.placeholder.select') || 'Please select',
dialogWidth: '45%',
pageSize: 20,
});
const emit = defineEmits<ZqApiSelectEmits>();
interface Props extends ZqApiSelectProps {}
const attrs = useAttrs();
const modalVisible = ref(false);
const options = ref<ApiSelectOption[]>([]);
const selectedValues = ref<Set<string>>(
new Set(
Array.isArray(props.modelValue)
? props.modelValue
: props.modelValue
? [props.modelValue]
: [],
),
);
const tempSelectedValues = ref<Set<string>>(new Set());
const loading = ref(false);
const searchText = ref('');
const currentPage = ref(1);
const totalItems = ref(0);
const isLoadingMore = ref(false);
const hasLoadedData = ref(false);
const hasLoadedFullList = ref(false);
// 计算显示值
const displayValue = computed({
get() {
if (selectedValues.value.size === 0) return undefined;
if (props.multiple) {
return [...selectedValues.value];
}
return [...selectedValues.value][0];
},
set(_value) {
// ElSelect 会改变这个值,但我们不需要处理
},
});
// 获取已选项的信息
const selectedOptionsWithInfo = computed(() => {
const result: ApiSelectOption[] = [];
const seenValues = new Set<string>();
for (const value of selectedValues.value) {
if (seenValues.has(value)) continue;
const option = options.value.find((o) => o.value === value);
if (option) {
seenValues.add(value);
result.push(option);
}
}
return result;
});
// 获取临时选择项的信息
const tempSelectedOptionsWithInfo = computed(() => {
const result: ApiSelectOption[] = [];
const seenValues = new Set<string>();
for (const value of tempSelectedValues.value) {
if (seenValues.has(value)) continue;
const option = options.value.find((o) => o.value === value);
if (option) {
seenValues.add(value);
result.push(option);
}
}
return result;
});
// 将原始数据转换为选项
const convertToOption = (item: any): ApiSelectOption => {
return {
value: String(item[props.valueField]),
label: String(item[props.labelField] || ''),
extra: props.extraField ? String(item[props.extraField] || '') : undefined,
raw: item,
};
};
// 加载数据(分页)
const loadData = async (page: number = 1, append: boolean = false) => {
try {
if (page === 1) {
loading.value = true;
} else {
isLoadingMore.value = true;
}
const result = await props.api({
page,
pageSize: props.pageSize,
keyword: searchText.value || undefined,
});
if (result) {
const existingValues = new Set(options.value.map((o) => o.value));
const newOptions = (result.items || [])
.map(convertToOption)
.filter((o) => !existingValues.has(o.value));
options.value = append
? [...options.value, ...newOptions]
: [...options.value, ...newOptions];
totalItems.value = result.total || 0;
currentPage.value = page;
hasLoadedData.value = true;
hasLoadedFullList.value = true;
}
loading.value = false;
isLoadingMore.value = false;
} catch (error) {
console.error('Failed to load data:', error);
loading.value = false;
isLoadingMore.value = false;
}
};
// 根据 ID 加载特定数据(用于回显)
const loadDataByIds = async (ids: string[]) => {
if (!ids || ids.length === 0 || !props.apiByIds) return;
try {
loading.value = true;
const result = await props.apiByIds(ids);
if (result && result.length > 0) {
const existingValues = new Set(options.value.map((o) => o.value));
const newOptions = result
.map(convertToOption)
.filter((o) => !existingValues.has(o.value));
options.value = [...options.value, ...newOptions];
hasLoadedData.value = true;
}
loading.value = false;
} catch (error) {
console.error('Failed to load data by ids:', error);
loading.value = false;
}
};
// 判断是否还有更多数据
const hasMoreData = computed(() => {
return options.value.length < totalItems.value;
});
// 防抖搜索定时器
let searchTimer: null | ReturnType<typeof setTimeout> = null;
// 监听搜索文本变化
watch(searchText, () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
currentPage.value = 1;
loadData(1, false);
}, 300);
});
// 处理选择
const handleSelect = (value: string) => {
if (props.multiple) {
if (tempSelectedValues.value.has(value)) {
tempSelectedValues.value.delete(value);
} else {
tempSelectedValues.value.add(value);
}
} else {
// 单选模式:只更新临时选择,不自动确认
tempSelectedValues.value.clear();
tempSelectedValues.value.add(value);
}
};
// 打开弹窗
const openModal = async () => {
if (props.disabled) return;
modalVisible.value = true;
};
// 弹窗打开后加载数据
const handleModalOpened = async () => {
tempSelectedValues.value = new Set(selectedValues.value);
if (!hasLoadedFullList.value) {
await loadData(1, false);
}
};
// 触底加载更多
const handleScroll = ({ scrollTop }: { scrollTop: number }) => {
const scrollbarRef = document.querySelector(
'.zq-api-selector-list .el-scrollbar__wrap',
);
if (!scrollbarRef) return;
const scrollHeight = scrollbarRef.scrollHeight;
const clientHeight = scrollbarRef.clientHeight;
if (
scrollTop + clientHeight >= scrollHeight - 50 &&
hasMoreData.value &&
!isLoadingMore.value &&
!loading.value
) {
loadData(currentPage.value + 1, true);
}
};
// 确认选择
const handleConfirm = () => {
selectedValues.value = new Set(tempSelectedValues.value);
const value = props.multiple
? [...selectedValues.value]
: selectedValues.value.size > 0
? [...selectedValues.value][0]
: '';
emit('update:modelValue', value);
emit('change', value);
modalVisible.value = false;
};
// 清除选择
const handleClear = (e?: MouseEvent) => {
if (e) {
e.stopPropagation();
}
tempSelectedValues.value.clear();
selectedValues.value.clear();
const emptyValue = props.multiple ? [] : '';
emit('update:modelValue', emptyValue);
emit('change', emptyValue);
};
// 删除单个选中项
const handleRemoveTag = (value: string) => {
selectedValues.value.delete(value);
const newValue = props.multiple ? [...selectedValues.value] : '';
emit('update:modelValue', newValue);
emit('change', newValue);
};
// 更新内部值
const updateInternalValue = () => {
selectedValues.value.clear();
tempSelectedValues.value.clear();
if (Array.isArray(props.modelValue)) {
props.modelValue.forEach((v) => selectedValues.value.add(v));
} else if (props.modelValue) {
selectedValues.value.add(props.modelValue);
}
if (modalVisible.value) {
tempSelectedValues.value = new Set(selectedValues.value);
}
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
async (newValue) => {
updateInternalValue();
if (
((Array.isArray(newValue) && newValue.length > 0) ||
(typeof newValue === 'string' && newValue)) &&
!hasLoadedData.value &&
props.apiByIds
) {
const ids = Array.isArray(newValue) ? newValue : [newValue];
await loadDataByIds(ids);
}
},
{ immediate: true },
);
// 组件挂载时加载初始数据
onMounted(async () => {
if (
((Array.isArray(props.modelValue) && props.modelValue.length > 0) ||
(typeof props.modelValue === 'string' && props.modelValue)) &&
props.apiByIds
) {
const ids = Array.isArray(props.modelValue)
? props.modelValue
: [props.modelValue];
await loadDataByIds(ids);
}
});
defineExpose({
openModal,
});
</script>
<template>
<div class="zq-api-select">
<!-- 选择框 -->
<div class="zq-api-select-input" :class="{ disabled }">
<ElSelect
v-bind="attrs"
v-model="displayValue"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable && selectedValues.size > 0"
:multiple="multiple"
readonly
@click="openModal"
@clear="() => handleClear()"
@remove-tag="handleRemoveTag"
>
<ElOption
v-for="item in selectedOptionsWithInfo"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</div>
<!-- Modal -->
<ZqDialog
v-model="modalVisible"
:title="dialogTitle"
:width="dialogWidth"
class="zq-api-select-modal"
:show-footer="true"
@opened="handleModalOpened"
@confirm="handleConfirm"
>
<div class="zq-api-select-content">
<!-- 顶部已选项左侧+ 搜索框右侧 -->
<div class="zq-api-select-header-row">
<div class="header-middle">
<div
v-if="tempSelectedOptionsWithInfo.length > 0"
class="selected-tags"
>
<ElTag
v-for="item of tempSelectedOptionsWithInfo"
:key="item.value"
closable
type="info"
size="small"
@close="
() => {
tempSelectedValues.delete(item.value);
}
"
>
{{ item.label }}
</ElTag>
</div>
<span v-else class="empty-text">-</span>
</div>
<!-- 已选数量和清空按钮 -->
<div class="header-actions">
<span class="selected-count">
{{ tempSelectedValues.size }}
{{ $t('common.selected') || 'selected' }}
</span>
<el-button
v-if="tempSelectedValues.size > 0"
link
type="danger"
size="small"
@click="() => tempSelectedValues.clear()"
>
{{ $t('common.clear') || 'Clear' }}
</el-button>
</div>
<!-- 搜索框右侧 -->
<div v-if="filterable" class="header-search">
<ElInput
v-model="searchText"
:placeholder="$t('common.search') || 'Search'"
clearable
:prefix-icon="Search"
/>
</div>
</div>
<!-- 列表 -->
<el-scrollbar class="zq-api-select-list" @scroll="handleScroll">
<ElSkeleton :loading="loading" animated :count="8">
<template #template>
<div class="zq-api-select-list-content">
<div v-for="i in 8" :key="i" class="skeleton-item">
<ElSkeletonItem
variant="text"
style="width: 100%; height: 40px; margin: 8px 0"
/>
</div>
</div>
</template>
<template #default>
<div class="zq-api-select-list-content">
<ElEmpty
v-if="options.length === 0 && !loading"
:description="$t('common.noData') || 'No Data'"
/>
<div v-else class="option-list">
<div
v-for="option in options"
:key="option.value"
class="option-item"
:class="[
tempSelectedValues.has(option.value)
? 'bg-primary/15 dark:bg-accent text-primary'
: 'hover:bg-[var(--el-fill-color-light)]',
]"
@click="handleSelect(option.value)"
>
<!-- 标签 -->
<div class="option-label">{{ option.label }}</div>
<!-- 额外信息 -->
<div v-if="option.extra" class="option-extra">
{{ option.extra }}
</div>
</div>
<!-- 加载更多提示 -->
<div v-if="isLoadingMore" class="loading-more">
<ElSkeletonItem
variant="text"
style="width: 100%; height: 40px"
/>
</div>
<!-- 没有更多数据提示 -->
<div
v-if="!hasMoreData && options.length > 0"
class="no-more-data"
>
{{ $t('common.noMoreData') || 'No more data' }}
</div>
</div>
</div>
</template>
</ElSkeleton>
</el-scrollbar>
</div>
</ZqDialog>
</div>
</template>
<style lang="scss" scoped>
.zq-api-select {
width: 100%;
&-input {
cursor: pointer;
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
:deep(.el-input) {
&.is-disabled {
background-color: var(--background-deep, #f5f7fa);
}
}
}
&-content {
display: flex;
flex-direction: column;
gap: 0;
height: 500px;
padding: 0;
overflow: hidden;
background-color: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
box-shadow: 0 1px 3px hsl(var(--border) / 12%);
}
&-header-row {
display: flex;
flex-shrink: 0;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: linear-gradient(
90deg,
hsl(var(--background)) 0%,
hsl(var(--background-deep) / 30%) 100%
);
border-bottom: 1px solid hsl(var(--border));
.header-middle {
display: flex;
flex: 1;
gap: 8px;
align-items: center;
min-width: 80px;
max-height: 40px;
overflow-y: auto;
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
:deep(.el-tag) {
height: 24px;
font-size: 12px;
line-height: 22px;
}
}
.empty-text {
font-size: 12px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
}
.header-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
.selected-count {
font-size: 12px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
}
.header-search {
flex-shrink: 0;
width: 160px;
:deep(.el-input) {
font-size: 14px;
}
}
}
&-list {
flex: 1;
overflow-y: auto;
border-top: none;
}
&-list-content {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
padding: 8px 10px;
.option-list {
display: flex;
flex-direction: column;
}
.option-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 42px;
padding: 0 12px;
margin: 1px 0;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
.option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
white-space: nowrap;
transition: color 0.2s ease;
}
.option-extra {
flex-shrink: 0;
padding: 2px 8px;
margin-left: 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
background: hsl(var(--background-deep) / 50%);
border-radius: 4px;
}
}
}
}
.skeleton-item {
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 12px;
}
.no-more-data {
padding: 12px;
font-size: 12px;
color: hsl(var(--muted-foreground));
text-align: center;
}
</style>