400 lines
10 KiB
Vue
400 lines
10 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import { computed, ref, watch } from 'vue';
|
||
|
|
import { useRoute, useRouter } from 'vue-router';
|
||
|
|
import { Search } from '@element-plus/icons-vue';
|
||
|
|
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||
|
|
import { fetchGetProductPage, fetchGetProjectPage } from '@/service/api';
|
||
|
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||
|
|
|
||
|
|
defineOptions({ name: 'ObjectContextSwitcher' });
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
domainConfig: App.ObjectContext.DomainConfig;
|
||
|
|
}
|
||
|
|
|
||
|
|
type ObjectOption = {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
code?: string | null;
|
||
|
|
createTime?: string | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
const props = defineProps<Props>();
|
||
|
|
|
||
|
|
const route = useRoute();
|
||
|
|
const router = useRouter();
|
||
|
|
const objectContextStore = useObjectContextStore();
|
||
|
|
|
||
|
|
const visible = ref(false);
|
||
|
|
const keyword = ref('');
|
||
|
|
const expanded = ref(false);
|
||
|
|
const loading = ref(false);
|
||
|
|
const switchingId = ref('');
|
||
|
|
const options = ref<ObjectOption[]>([]);
|
||
|
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
|
|
||
|
|
const OBJECT_SWITCHER_PAGE_SIZE = 100;
|
||
|
|
|
||
|
|
const isProductDomain = computed(() => props.domainConfig.domainKey === 'product');
|
||
|
|
const domainLabel = computed(() => (isProductDomain.value ? '产品' : '项目'));
|
||
|
|
const allLabel = computed(() => `全部${domainLabel.value}`);
|
||
|
|
const placeholder = computed(() => `搜索${domainLabel.value}`);
|
||
|
|
const previewOptions = computed(() => options.value.slice(0, 3));
|
||
|
|
const displayOptions = computed(() => {
|
||
|
|
if (keyword.value.trim() || expanded.value) {
|
||
|
|
return options.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
return previewOptions.value;
|
||
|
|
});
|
||
|
|
const hiddenCount = computed(() => Math.max(options.value.length - previewOptions.value.length, 0));
|
||
|
|
const showAllEntry = computed(() => !keyword.value.trim() && !expanded.value && hiddenCount.value > 0);
|
||
|
|
|
||
|
|
function sortByCreateTimeDesc(list: ObjectOption[]) {
|
||
|
|
return list.slice().sort((left, right) => {
|
||
|
|
const leftTime = left.createTime ? new Date(left.createTime).getTime() : 0;
|
||
|
|
const rightTime = right.createTime ? new Date(right.createTime).getTime() : 0;
|
||
|
|
|
||
|
|
return rightTime - leftTime;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchObjectOptionsPage(pageNo: number, keywordValue?: string) {
|
||
|
|
const result =
|
||
|
|
props.domainConfig.domainKey === 'product'
|
||
|
|
? await fetchGetProductPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue })
|
||
|
|
: await fetchGetProjectPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue });
|
||
|
|
|
||
|
|
if (result.error || !result.data) {
|
||
|
|
return {
|
||
|
|
total: 0,
|
||
|
|
list: []
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const list = result.data.list.map(item => {
|
||
|
|
if (props.domainConfig.domainKey === 'product') {
|
||
|
|
const product = item as Api.Product.Product;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: product.id,
|
||
|
|
name: product.name,
|
||
|
|
code: product.code,
|
||
|
|
createTime: product.createTime
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const project = item as Api.Project.Project;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: project.id,
|
||
|
|
name: project.projectName,
|
||
|
|
code: project.projectCode,
|
||
|
|
createTime: project.createTime
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
total: result.data.total,
|
||
|
|
list
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadOptions() {
|
||
|
|
loading.value = true;
|
||
|
|
|
||
|
|
const keywordValue = keyword.value.trim() || undefined;
|
||
|
|
const firstPage = await fetchObjectOptionsPage(1, keywordValue);
|
||
|
|
const pageCount = Math.ceil(firstPage.total / OBJECT_SWITCHER_PAGE_SIZE);
|
||
|
|
const restPages =
|
||
|
|
pageCount > 1
|
||
|
|
? await Promise.all(
|
||
|
|
Array.from({ length: pageCount - 1 }, (_, index) => fetchObjectOptionsPage(index + 2, keywordValue))
|
||
|
|
)
|
||
|
|
: [];
|
||
|
|
const list = [firstPage, ...restPages].flatMap(page => page.list);
|
||
|
|
|
||
|
|
loading.value = false;
|
||
|
|
options.value = sortByCreateTimeDesc(list);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleVisibleChange(value: boolean) {
|
||
|
|
visible.value = value;
|
||
|
|
|
||
|
|
if (value) {
|
||
|
|
expanded.value = false;
|
||
|
|
loadOptions();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSelect(option: ObjectOption) {
|
||
|
|
if (option.id === objectContextStore.objectId) {
|
||
|
|
visible.value = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
switchingId.value = option.id;
|
||
|
|
const result = await objectContextStore.switchContext(props.domainConfig, option.id);
|
||
|
|
switchingId.value = '';
|
||
|
|
|
||
|
|
if (result.error) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
visible.value = false;
|
||
|
|
const query = {
|
||
|
|
...route.query,
|
||
|
|
[OBJECT_CONTEXT_QUERY_KEY]: option.id
|
||
|
|
};
|
||
|
|
const targetLocation = route.name ? { name: route.name, query } : { path: route.path, query };
|
||
|
|
|
||
|
|
await router.push(targetLocation);
|
||
|
|
}
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => keyword.value,
|
||
|
|
() => {
|
||
|
|
if (!visible.value) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
expanded.value = Boolean(keyword.value.trim());
|
||
|
|
|
||
|
|
if (searchTimer) {
|
||
|
|
clearTimeout(searchTimer);
|
||
|
|
}
|
||
|
|
|
||
|
|
searchTimer = setTimeout(() => {
|
||
|
|
loadOptions();
|
||
|
|
}, 250);
|
||
|
|
}
|
||
|
|
);
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<ElPopover
|
||
|
|
:visible="visible"
|
||
|
|
trigger="click"
|
||
|
|
placement="bottom-start"
|
||
|
|
:width="300"
|
||
|
|
popper-class="object-context-switcher__popper"
|
||
|
|
@update:visible="handleVisibleChange"
|
||
|
|
>
|
||
|
|
<template #reference>
|
||
|
|
<button type="button" class="object-context-switcher__trigger" :class="{ 'is-open': visible }">
|
||
|
|
<span class="object-context-switcher__trigger-label">{{ objectContextStore.objectName }}</span>
|
||
|
|
<icon-ep:sort class="object-context-switcher__trigger-icon" />
|
||
|
|
</button>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<div class="object-context-switcher__panel">
|
||
|
|
<ElInput v-model="keyword" clearable :placeholder="placeholder" class="object-context-switcher__search">
|
||
|
|
<template #suffix>
|
||
|
|
<ElIcon>
|
||
|
|
<Search />
|
||
|
|
</ElIcon>
|
||
|
|
</template>
|
||
|
|
</ElInput>
|
||
|
|
|
||
|
|
<div v-loading="loading" class="object-context-switcher__list">
|
||
|
|
<button
|
||
|
|
v-for="item in displayOptions"
|
||
|
|
:key="item.id"
|
||
|
|
type="button"
|
||
|
|
class="object-context-switcher__item"
|
||
|
|
:class="{ 'is-active': item.id === objectContextStore.objectId }"
|
||
|
|
:disabled="switchingId === item.id"
|
||
|
|
@click="handleSelect(item)"
|
||
|
|
>
|
||
|
|
<span class="object-context-switcher__item-icon">
|
||
|
|
<icon-ep:box v-if="isProductDomain" />
|
||
|
|
<icon-ep:folder v-else />
|
||
|
|
</span>
|
||
|
|
<span class="object-context-switcher__item-main">
|
||
|
|
<span class="object-context-switcher__item-name">{{ item.name }}</span>
|
||
|
|
<span v-if="item.code" class="object-context-switcher__item-code">{{ item.code }}</span>
|
||
|
|
</span>
|
||
|
|
<icon-ep:check v-if="item.id === objectContextStore.objectId" class="object-context-switcher__check" />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<ElEmpty v-if="!loading && !displayOptions.length" :description="`暂无可选${domainLabel}`" :image-size="54" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button v-if="showAllEntry" type="button" class="object-context-switcher__all" @click="expanded = true">
|
||
|
|
<span>{{ allLabel }}</span>
|
||
|
|
<span class="object-context-switcher__all-meta">{{ hiddenCount }} 个更多</span>
|
||
|
|
<icon-ep:arrow-right class="object-context-switcher__all-arrow" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</ElPopover>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.object-context-switcher__trigger {
|
||
|
|
appearance: none;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
max-width: 16rem;
|
||
|
|
height: 32px;
|
||
|
|
gap: 6px;
|
||
|
|
padding: 0 10px 0 12px;
|
||
|
|
border: none;
|
||
|
|
border-radius: 6px;
|
||
|
|
background: transparent;
|
||
|
|
color: var(--el-color-primary);
|
||
|
|
cursor: pointer;
|
||
|
|
font: inherit;
|
||
|
|
line-height: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__trigger:hover,
|
||
|
|
.object-context-switcher__trigger.is-open {
|
||
|
|
background: var(--el-color-primary-light-9);
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__trigger-label {
|
||
|
|
min-width: 0;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__trigger-icon {
|
||
|
|
flex-shrink: 0;
|
||
|
|
color: var(--el-color-primary);
|
||
|
|
font-size: 13px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__panel {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__search {
|
||
|
|
padding: 4px 4px 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__list {
|
||
|
|
min-height: 84px;
|
||
|
|
max-height: 300px;
|
||
|
|
overflow-y: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item {
|
||
|
|
appearance: none;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
width: 100%;
|
||
|
|
min-height: 42px;
|
||
|
|
gap: 10px;
|
||
|
|
padding: 7px 10px;
|
||
|
|
border: none;
|
||
|
|
border-radius: 8px;
|
||
|
|
background: transparent;
|
||
|
|
color: var(--el-text-color-primary);
|
||
|
|
cursor: pointer;
|
||
|
|
font: inherit;
|
||
|
|
text-align: left;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item:hover,
|
||
|
|
.object-context-switcher__item.is-active {
|
||
|
|
background: rgb(59 130 246 / 10%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item:disabled {
|
||
|
|
cursor: wait;
|
||
|
|
opacity: 0.75;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item-icon {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
width: 18px;
|
||
|
|
height: 18px;
|
||
|
|
flex-shrink: 0;
|
||
|
|
border-radius: 5px;
|
||
|
|
background: var(--el-color-primary-light-8);
|
||
|
|
color: var(--el-color-primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item-main {
|
||
|
|
display: flex;
|
||
|
|
min-width: 0;
|
||
|
|
flex: 1;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 2px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item-name,
|
||
|
|
.object-context-switcher__item-code {
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item-name {
|
||
|
|
font-size: 13px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__item-code {
|
||
|
|
color: var(--el-text-color-placeholder);
|
||
|
|
font-size: 11px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__check {
|
||
|
|
flex-shrink: 0;
|
||
|
|
color: var(--el-color-primary);
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__all {
|
||
|
|
appearance: none;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
width: calc(100% + 24px);
|
||
|
|
height: 38px;
|
||
|
|
gap: 8px;
|
||
|
|
margin: 0 -12px -12px;
|
||
|
|
padding: 0 14px;
|
||
|
|
border: none;
|
||
|
|
border-top: 1px solid var(--el-border-color-lighter);
|
||
|
|
background: transparent;
|
||
|
|
color: var(--el-text-color-primary);
|
||
|
|
cursor: pointer;
|
||
|
|
font: inherit;
|
||
|
|
text-align: left;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__all:hover {
|
||
|
|
background: var(--el-fill-color-light);
|
||
|
|
color: var(--el-color-primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__all-meta {
|
||
|
|
flex: 1;
|
||
|
|
color: var(--el-text-color-placeholder);
|
||
|
|
font-size: 12px;
|
||
|
|
text-align: right;
|
||
|
|
}
|
||
|
|
|
||
|
|
.object-context-switcher__all-arrow {
|
||
|
|
color: var(--el-text-color-placeholder);
|
||
|
|
font-size: 13px;
|
||
|
|
}
|
||
|
|
|
||
|
|
:global(.object-context-switcher__popper.el-popover) {
|
||
|
|
padding: 12px;
|
||
|
|
border: 1px solid rgb(226 232 240 / 90%);
|
||
|
|
border-radius: 10px;
|
||
|
|
box-shadow:
|
||
|
|
0 12px 28px rgb(15 23 42 / 10%),
|
||
|
|
0 2px 8px rgb(15 23 42 / 6%);
|
||
|
|
}
|
||
|
|
</style>
|