perf: 优化旧版浏览器页面恢复与资源加载
- 页面恢复时延后版本检查并补齐根背景,减少白闪与首帧卡顿 - 面向 Chrome 90 收敛公共分包,按需加载资源弹窗和知识库面板 - 压缩工作流图标并补充延迟加载与可见性生命周期测试
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 8.9 KiB |
86
easyflow-ui-admin/app/src/utils/lazy-component.test.ts
Normal file
86
easyflow-ui-admin/app/src/utils/lazy-component.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable vue/one-component-per-file -- Test-specific host components keep lazy-mount scenarios isolated. */
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createLazyComponentController } from './lazy-component';
|
||||
|
||||
interface DeferredInstance {
|
||||
openDialog: () => void;
|
||||
}
|
||||
|
||||
describe('createLazyComponentController', () => {
|
||||
it('loads and mounts the component only when its instance is requested', async () => {
|
||||
const openDialog = vi.fn();
|
||||
const DeferredComponent = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ openDialog });
|
||||
return () => h('div', 'deferred');
|
||||
},
|
||||
});
|
||||
const loader = vi.fn().mockResolvedValue({ default: DeferredComponent });
|
||||
const controller = createLazyComponentController<DeferredInstance>(loader);
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
controller.component.value
|
||||
? h(controller.component.value, {
|
||||
ref: controller.componentRef,
|
||||
})
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(Host);
|
||||
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
expect(controller.loading.value).toBe(false);
|
||||
|
||||
const instance = await controller.ensureInstance();
|
||||
instance.openDialog();
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(openDialog).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.text()).toBe('deferred');
|
||||
expect(controller.loading.value).toBe(false);
|
||||
|
||||
await controller.ensureInstance();
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears loading state and allows retry after a loading failure', async () => {
|
||||
const DeferredComponent = defineComponent({
|
||||
setup() {
|
||||
return () => h('div', 'deferred');
|
||||
},
|
||||
});
|
||||
const loader = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('chunk failed'))
|
||||
.mockResolvedValueOnce({ default: DeferredComponent });
|
||||
const controller = createLazyComponentController<DeferredInstance>(loader);
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
controller.component.value
|
||||
? h(controller.component.value, {
|
||||
ref: controller.componentRef,
|
||||
})
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
mount(Host);
|
||||
|
||||
await expect(controller.ensureInstance()).rejects.toThrow('chunk failed');
|
||||
expect(controller.loading.value).toBe(false);
|
||||
|
||||
const retryPromise = controller.ensureInstance();
|
||||
await flushPromises();
|
||||
|
||||
await expect(retryPromise).resolves.toBeDefined();
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
expect(controller.loading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
84
easyflow-ui-admin/app/src/utils/lazy-component.ts
Normal file
84
easyflow-ui-admin/app/src/utils/lazy-component.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Component, DeepReadonly, Ref, ShallowRef } from 'vue';
|
||||
|
||||
import { markRaw, nextTick, readonly, ref, shallowRef } from 'vue';
|
||||
|
||||
interface LazyComponentModule {
|
||||
default: Component;
|
||||
}
|
||||
|
||||
interface LazyComponentController<Instance extends object> {
|
||||
component: ShallowRef<Component | undefined>;
|
||||
componentRef: ShallowRef<Instance | undefined>;
|
||||
ensureInstance: () => Promise<Instance>;
|
||||
loading: DeepReadonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟加载并挂载通过模板 ref 调用的组件。
|
||||
*
|
||||
* @param loader 组件模块加载函数。
|
||||
* @returns 延迟组件、组件实例引用、加载状态和实例获取函数。
|
||||
*/
|
||||
function createLazyComponentController<Instance extends object>(
|
||||
loader: () => Promise<LazyComponentModule>,
|
||||
): LazyComponentController<Instance> {
|
||||
const component = shallowRef<Component>();
|
||||
const componentRef = shallowRef<Instance>();
|
||||
const loading = ref(false);
|
||||
let loadPromise: Promise<Component> | undefined;
|
||||
let mountPromise: Promise<Instance> | undefined;
|
||||
|
||||
async function loadComponent() {
|
||||
if (component.value) {
|
||||
return component.value;
|
||||
}
|
||||
if (!loadPromise) {
|
||||
loadPromise = loader()
|
||||
.then((module) => {
|
||||
if (!module.default) {
|
||||
throw new Error('Lazy component module has no default export');
|
||||
}
|
||||
return markRaw(module.default);
|
||||
})
|
||||
.catch((error) => {
|
||||
loadPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
const resolvedComponent = await loadPromise;
|
||||
component.value = resolvedComponent;
|
||||
return resolvedComponent;
|
||||
}
|
||||
|
||||
function ensureInstance() {
|
||||
if (componentRef.value) {
|
||||
return Promise.resolve(componentRef.value);
|
||||
}
|
||||
if (mountPromise) {
|
||||
return mountPromise;
|
||||
}
|
||||
loading.value = true;
|
||||
mountPromise = loadComponent()
|
||||
.then(async () => {
|
||||
await nextTick();
|
||||
if (!componentRef.value) {
|
||||
throw new Error('Lazy component failed to mount');
|
||||
}
|
||||
return componentRef.value;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
mountPromise = undefined;
|
||||
});
|
||||
return mountPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
component,
|
||||
componentRef,
|
||||
ensureInstance,
|
||||
loading: readonly(loading),
|
||||
};
|
||||
}
|
||||
|
||||
export { createLazyComponentController };
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
@@ -7,25 +7,64 @@ import { $t } from '@easyflow/locales';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElIcon, ElImage } from 'element-plus';
|
||||
import { ElButton, ElIcon, ElImage, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
|
||||
import DocumentCollectionDataConfig from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
|
||||
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
||||
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
|
||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
|
||||
const ChunkDocumentTable = defineAsyncComponent(
|
||||
() => import('#/views/ai/documentCollection/ChunkDocumentTable.vue'),
|
||||
);
|
||||
const DocumentCollectionDataConfig = defineAsyncComponent(
|
||||
() =>
|
||||
import('#/views/ai/documentCollection/DocumentCollectionDataConfig.vue'),
|
||||
);
|
||||
const DocumentTable = defineAsyncComponent(
|
||||
() => import('#/views/ai/documentCollection/DocumentTable.vue'),
|
||||
);
|
||||
const FaqTable = defineAsyncComponent(
|
||||
() => import('#/views/ai/documentCollection/FaqTable.vue'),
|
||||
);
|
||||
const KnowledgeSearch = defineAsyncComponent(
|
||||
() => import('#/views/ai/documentCollection/KnowledgeSearch.vue'),
|
||||
);
|
||||
const KnowledgeShareManagement = defineAsyncComponent(
|
||||
() => import('#/views/ai/documentCollection/KnowledgeShareManagement.vue'),
|
||||
);
|
||||
const SegmenterDoc = defineAsyncComponent(
|
||||
() => import('#/views/ai/documentCollection/SegmenterDoc.vue'),
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
interface ImportKnowledgeDocFileInstance {
|
||||
openDialog: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
component: ImportKnowledgeDocFileComponent,
|
||||
componentRef: importDocModalRef,
|
||||
ensureInstance: ensureImportKnowledgeDocFile,
|
||||
loading: importDialogLoading,
|
||||
} = createLazyComponentController<ImportKnowledgeDocFileInstance>(
|
||||
() => import('#/views/ai/documentCollection/ImportKnowledgeDocFile.vue'),
|
||||
);
|
||||
|
||||
async function openImportDialog() {
|
||||
try {
|
||||
const modal = await ensureImportKnowledgeDocFile();
|
||||
modal.openDialog();
|
||||
} catch (error) {
|
||||
console.error('Failed to load document import modal:', error);
|
||||
ElMessage.error($t('message.getDataError'));
|
||||
}
|
||||
}
|
||||
|
||||
const knowledgeId = ref<string>((route.query.id as string) || '');
|
||||
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
||||
const knowledgeInfo = ref<any>({});
|
||||
@@ -149,10 +188,9 @@ const headerButtons = [
|
||||
];
|
||||
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||
const documentTableRef = ref();
|
||||
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
||||
const documentTitle = ref('');
|
||||
const handleSearch = (searchParams: string) => {
|
||||
documentTableRef.value.search(searchParams);
|
||||
documentTableRef.value?.search?.(searchParams);
|
||||
};
|
||||
const handleButtonClick = (event: any) => {
|
||||
// 根据按钮 key 执行不同操作
|
||||
@@ -162,7 +200,7 @@ const handleButtonClick = (event: any) => {
|
||||
break;
|
||||
}
|
||||
case 'importFile': {
|
||||
importDocModalRef.value?.openDialog?.();
|
||||
void openImportDialog();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -193,7 +231,7 @@ const backDoc = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="document-container">
|
||||
<div v-loading="importDialogLoading" class="document-container">
|
||||
<div class="doc-header-container">
|
||||
<div class="doc-knowledge-container">
|
||||
<div @click="back()" style="cursor: pointer">
|
||||
@@ -301,7 +339,9 @@ const backDoc = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ImportKnowledgeDocFile
|
||||
<component
|
||||
:is="ImportKnowledgeDocFileComponent"
|
||||
v-if="ImportKnowledgeDocFileComponent"
|
||||
ref="importDocModalRef"
|
||||
:knowledge-id-prop="String(knowledgeId)"
|
||||
@imported="backDoc"
|
||||
|
||||
@@ -44,8 +44,8 @@ import CardPage from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||
import {
|
||||
@@ -70,6 +70,29 @@ const collectionTypeLabelMap = {
|
||||
};
|
||||
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
||||
|
||||
interface DocumentCollectionModalInstance {
|
||||
openDialog: (row: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
component: DocumentCollectionModalComponent,
|
||||
componentRef: aiKnowledgeModalRef,
|
||||
ensureInstance: ensureDocumentCollectionModal,
|
||||
loading: knowledgeModalLoading,
|
||||
} = createLazyComponentController<DocumentCollectionModalInstance>(
|
||||
() => import('#/views/ai/documentCollection/DocumentCollectionModal.vue'),
|
||||
);
|
||||
|
||||
async function openKnowledgeModal(row: Record<string, unknown>) {
|
||||
try {
|
||||
const modal = await ensureDocumentCollectionModal();
|
||||
modal.openDialog(row);
|
||||
} catch (error) {
|
||||
console.error('Failed to load knowledge modal:', error);
|
||||
ElMessage.error($t('message.getDataError'));
|
||||
}
|
||||
}
|
||||
|
||||
const canManageKnowledgePermission = computed(() =>
|
||||
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
||||
);
|
||||
@@ -205,7 +228,7 @@ const actions: ActionButton[] = [
|
||||
if (!ensureManageKnowledgeItem(row)) {
|
||||
return;
|
||||
}
|
||||
aiKnowledgeModalRef.value.openDialog(row);
|
||||
void openKnowledgeModal(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -460,7 +483,6 @@ function resolvePublishStatusMeta(
|
||||
}
|
||||
|
||||
const pageDataRef = ref();
|
||||
const aiKnowledgeModalRef = ref();
|
||||
const headerButtons = [
|
||||
{
|
||||
key: 'add',
|
||||
@@ -474,7 +496,7 @@ const headerButtons = [
|
||||
const handleButtonClick = (event: any, _item: any) => {
|
||||
switch (event.key) {
|
||||
case 'add': {
|
||||
aiKnowledgeModalRef.value.openDialog({});
|
||||
void openKnowledgeModal({});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -667,7 +689,7 @@ function changeCategory(category: any) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<div v-loading="knowledgeModalLoading" class="flex h-full flex-col gap-6 p-6">
|
||||
<div class="knowledge-header">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@@ -865,7 +887,9 @@ function changeCategory(category: any) {
|
||||
</EasyFlowFormModal>
|
||||
|
||||
<!-- 新增知识库模态框-->
|
||||
<DocumentCollectionModal
|
||||
<component
|
||||
:is="DocumentCollectionModalComponent"
|
||||
v-if="DocumentCollectionModalComponent"
|
||||
ref="aiKnowledgeModalRef"
|
||||
@reload="reloadKnowledgeList"
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
|
||||
import CardPage from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import AddPluginModal from '#/views/ai/plugin/AddPluginModal.vue';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
|
||||
import { buildPluginPageQueryParams } from './plugin-query';
|
||||
import {
|
||||
@@ -61,6 +61,29 @@ interface CategoryFormData {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AddPluginModalInstance {
|
||||
openDialog: (row: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
component: AddPluginModalComponent,
|
||||
componentRef: aiPluginModalRef,
|
||||
ensureInstance: ensureAddPluginModal,
|
||||
loading: pluginModalLoading,
|
||||
} = createLazyComponentController<AddPluginModalInstance>(
|
||||
() => import('#/views/ai/plugin/AddPluginModal.vue'),
|
||||
);
|
||||
|
||||
async function openPluginDialog(row: Record<string, unknown>) {
|
||||
try {
|
||||
const modal = await ensureAddPluginModal();
|
||||
modal.openDialog(row);
|
||||
} catch (error) {
|
||||
console.error('Failed to load plugin modal:', error);
|
||||
ElMessage.error($t('message.getDataError'));
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNavTitle(item: PluginRecord) {
|
||||
return (item.title as string) || (item.name as string) || '';
|
||||
}
|
||||
@@ -93,7 +116,7 @@ const actions: ActionButton[] = [
|
||||
permission: '/api/v1/plugin/save',
|
||||
placement: 'inline',
|
||||
onClick(item) {
|
||||
aiPluginModalRef.value.openDialog(item);
|
||||
void openPluginDialog(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -194,7 +217,6 @@ const handleDelete = (item: PluginRecord) => {
|
||||
};
|
||||
|
||||
const pageDataRef = ref();
|
||||
const aiPluginModalRef = ref();
|
||||
const headerButtons = [
|
||||
{
|
||||
key: 'add',
|
||||
@@ -221,7 +243,7 @@ const handleSubmit = () => {
|
||||
const handleButtonClick = (event: HeaderActionEvent, _item: unknown) => {
|
||||
switch (event.key) {
|
||||
case 'add': {
|
||||
aiPluginModalRef.value.openDialog({});
|
||||
void openPluginDialog({});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -316,7 +338,7 @@ const handleClickCategory = (item: PluginCategory) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="knowledge-container">
|
||||
<div v-loading="pluginModalLoading" class="knowledge-container">
|
||||
<div class="knowledge-header">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@@ -378,7 +400,12 @@ const handleClickCategory = (item: PluginCategory) => {
|
||||
</PageData>
|
||||
</div>
|
||||
</div>
|
||||
<AddPluginModal ref="aiPluginModalRef" @reload="reloadCurrentList" />
|
||||
<component
|
||||
:is="AddPluginModalComponent"
|
||||
v-if="AddPluginModalComponent"
|
||||
ref="aiPluginModalRef"
|
||||
@reload="reloadCurrentList"
|
||||
/>
|
||||
<EasyFlowFormModal
|
||||
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
|
||||
v-model:open="dialogVisible"
|
||||
|
||||
@@ -64,6 +64,7 @@ import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { useDictStore } from '#/store';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||
@@ -82,7 +83,6 @@ import {
|
||||
mergeWorkflowListRouteQuery,
|
||||
parseWorkflowListRouteState,
|
||||
} from './workflow-list-route-state';
|
||||
import WorkflowModal from './WorkflowModal.vue';
|
||||
|
||||
const ElXMarkdown = defineAsyncComponent(
|
||||
() => import('vue-element-plus-x/es/XMarkdown/index.js'),
|
||||
@@ -116,6 +116,19 @@ interface ApiFieldDoc {
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
interface WorkflowModalInstance {
|
||||
openDialog: (row: Record<string, unknown>, importMode?: boolean) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
component: WorkflowModalComponent,
|
||||
componentRef: saveDialog,
|
||||
ensureInstance: ensureWorkflowModal,
|
||||
loading: workflowModalLoading,
|
||||
} = createLazyComponentController<WorkflowModalInstance>(
|
||||
() => import('./WorkflowModal.vue'),
|
||||
);
|
||||
|
||||
const primaryAction: CardPrimaryAction = {
|
||||
showIndicator: false,
|
||||
text: $t('button.design'),
|
||||
@@ -283,7 +296,6 @@ onMounted(() => {
|
||||
getSideList();
|
||||
});
|
||||
const pageDataRef = ref();
|
||||
const saveDialog = ref();
|
||||
const selectedCategoryId = ref<number | string>(initialListState.categoryId);
|
||||
const searchKeyword = ref(initialListState.keyword);
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
@@ -401,8 +413,14 @@ function handleSearch(keyword: string) {
|
||||
function reloadCurrentList() {
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
function showDialog(row: any, importMode = false) {
|
||||
saveDialog.value.openDialog({ ...row }, importMode);
|
||||
async function showDialog(row: any, importMode = false) {
|
||||
try {
|
||||
const modal = await ensureWorkflowModal();
|
||||
modal.openDialog({ ...row }, importMode);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflow modal:', error);
|
||||
ElMessage.error($t('message.getDataError'));
|
||||
}
|
||||
}
|
||||
function resolveNavTitle(row: any) {
|
||||
return row?.title || row?.name || '';
|
||||
@@ -1144,8 +1162,13 @@ function handleHeaderButtonClick(data: any) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<WorkflowModal ref="saveDialog" @reload="reloadCurrentList" />
|
||||
<div v-loading="workflowModalLoading" class="flex h-full flex-col gap-6 p-6">
|
||||
<component
|
||||
:is="WorkflowModalComponent"
|
||||
v-if="WorkflowModalComponent"
|
||||
ref="saveDialog"
|
||||
@reload="reloadCurrentList"
|
||||
/>
|
||||
<ElDialog
|
||||
v-model="apiInstructionVisible"
|
||||
:title="$t('aiWorkflow.apiInstruction')"
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from '@easyflow/vite-config';
|
||||
|
||||
import ElementPlus from 'unplugin-element-plus/vite';
|
||||
|
||||
import { createBasePathRedirectPlugin } from './vite-base-path-redirect';
|
||||
|
||||
const resourceListChunkEntries = [
|
||||
'./src/components/headerSearch/HeaderSearch.vue',
|
||||
'./src/components/page/CardList.vue',
|
||||
'./src/components/page/PageData.vue',
|
||||
'./src/components/page/PageSide.vue',
|
||||
].map((file) => fileURLToPath(new URL(file, import.meta.url)));
|
||||
|
||||
function isResourceListModule(id: string) {
|
||||
return resourceListChunkEntries.some(
|
||||
(entry) => id === entry || id.startsWith(`${entry}?`),
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
application: {},
|
||||
vite: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (isResourceListModule(id)) {
|
||||
return 'resource-list';
|
||||
}
|
||||
},
|
||||
onlyExplicitManualChunks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// @vueuse/motion expects tslib default export during dev pre-bundling
|
||||
|
||||
@@ -66,7 +66,7 @@ function defineApplicationConfig(userConfigPromise?: DefineApplicationOptions) {
|
||||
entryFileNames: 'jse/index-[name]-[hash].js',
|
||||
},
|
||||
},
|
||||
target: 'es2015',
|
||||
target: 'chrome90',
|
||||
},
|
||||
css: createCssOptions(injectGlobalScss),
|
||||
esbuild: {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
html {
|
||||
@apply text-foreground bg-background font-sans text-[100%];
|
||||
|
||||
background-color: hsl(var(--surface-canvas));
|
||||
font-variation-settings: normal;
|
||||
line-height: 1.15;
|
||||
text-size-adjust: 100%;
|
||||
@@ -34,6 +33,8 @@
|
||||
html {
|
||||
@apply size-full;
|
||||
|
||||
background-color: hsl(var(--surface-canvas));
|
||||
|
||||
/* scrollbar-gutter: stable; */
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CheckUpdates from './check-updates.vue';
|
||||
|
||||
const { modalOpen } = vi.hoisted(() => ({
|
||||
modalOpen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow-core/popup-ui', () => ({
|
||||
useEasyFlowModal: () => [
|
||||
{
|
||||
name: 'UpdateNoticeModalStub',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
{ open: modalOpen },
|
||||
],
|
||||
}));
|
||||
|
||||
describe('check updates visibility lifecycle', () => {
|
||||
let hidden = false;
|
||||
|
||||
beforeEach(() => {
|
||||
hidden = false;
|
||||
modalOpen.mockClear();
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
|
||||
vi.stubGlobal('location', { hostname: 'customer.example' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
headers: {
|
||||
get: (name: string) => (name === 'etag' ? 'version-1' : null),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('restarts polling without fetching immediately when the page becomes visible', async () => {
|
||||
const wrapper = mount(CheckUpdates);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
|
||||
hidden = true;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
hidden = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(59_999);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await flushPromises();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/',
|
||||
expect.objectContaining({ method: 'HEAD' }),
|
||||
);
|
||||
expect(modalOpen).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
let isCheckingUpdates = false;
|
||||
let resumeCheckFrame: number | undefined;
|
||||
let versionCheckController: AbortController | null = null;
|
||||
const VERSION_CHECK_TIMEOUT_MS = 10_000;
|
||||
const currentVersionTag = ref('');
|
||||
@@ -121,36 +120,12 @@ function start() {
|
||||
);
|
||||
}
|
||||
|
||||
function cancelResumeCheck() {
|
||||
if (resumeCheckFrame === undefined) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(resumeCheckFrame);
|
||||
resumeCheckFrame = undefined;
|
||||
}
|
||||
|
||||
function scheduleResumeCheck() {
|
||||
cancelResumeCheck();
|
||||
// 页面恢复后先让浏览器完成两帧绘制,再发起版本检查请求。
|
||||
resumeCheckFrame = requestAnimationFrame(() => {
|
||||
resumeCheckFrame = requestAnimationFrame(() => {
|
||||
resumeCheckFrame = undefined;
|
||||
if (document.hidden) return;
|
||||
runUpdateCheck().finally(() => {
|
||||
if (!document.hidden) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleVisibilitychange() {
|
||||
if (document.hidden) {
|
||||
cancelResumeCheck();
|
||||
stop();
|
||||
} else {
|
||||
scheduleResumeCheck();
|
||||
// 恢复窗口时只重启轮询,避免与旧浏览器的首帧重绘争抢资源。
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +144,6 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelResumeCheck();
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', handleVisibilitychange);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
html {
|
||||
@apply size-full;
|
||||
|
||||
background-color: hsl(var(--background));
|
||||
|
||||
/* scrollbar-gutter: stable; */
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CheckUpdates from './check-updates.vue';
|
||||
|
||||
const { modalOpen } = vi.hoisted(() => ({
|
||||
modalOpen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow-core/popup-ui', () => ({
|
||||
useEasyFlowModal: () => [
|
||||
{
|
||||
name: 'UpdateNoticeModalStub',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
{ open: modalOpen },
|
||||
],
|
||||
}));
|
||||
|
||||
describe('check updates visibility lifecycle', () => {
|
||||
let hidden = false;
|
||||
|
||||
beforeEach(() => {
|
||||
hidden = false;
|
||||
modalOpen.mockClear();
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
|
||||
vi.stubGlobal('location', { hostname: 'customer.example' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
headers: {
|
||||
get: (name: string) => (name === 'etag' ? 'version-1' : null),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('restarts polling without fetching immediately when the page becomes visible', async () => {
|
||||
const wrapper = mount(CheckUpdates);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
|
||||
hidden = true;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
hidden = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(59_999);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await flushPromises();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/',
|
||||
expect.objectContaining({ method: 'HEAD' }),
|
||||
);
|
||||
expect(modalOpen).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -124,11 +124,8 @@ function handleVisibilitychange() {
|
||||
if (document.hidden) {
|
||||
stop();
|
||||
} else {
|
||||
runUpdateCheck().finally(() => {
|
||||
if (!document.hidden) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
// 恢复窗口时只重启轮询,避免与旧浏览器的首帧重绘争抢资源。
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user