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">
|
<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 { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { useAccess } from '@easyflow/access';
|
import { useAccess } from '@easyflow/access';
|
||||||
@@ -7,25 +7,64 @@ import { $t } from '@easyflow/locales';
|
|||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
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 { api } from '#/api/request';
|
||||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||||
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
|
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||||
import DocumentCollectionDataConfig from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
|
|
||||||
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
const ChunkDocumentTable = defineAsyncComponent(
|
||||||
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
() => import('#/views/ai/documentCollection/ChunkDocumentTable.vue'),
|
||||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
);
|
||||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
const DocumentCollectionDataConfig = defineAsyncComponent(
|
||||||
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
|
() =>
|
||||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
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 route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { hasAccessByCodes } = useAccess();
|
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 knowledgeId = ref<string>((route.query.id as string) || '');
|
||||||
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
||||||
const knowledgeInfo = ref<any>({});
|
const knowledgeInfo = ref<any>({});
|
||||||
@@ -149,10 +188,9 @@ const headerButtons = [
|
|||||||
];
|
];
|
||||||
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||||
const documentTableRef = ref();
|
const documentTableRef = ref();
|
||||||
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
|
||||||
const documentTitle = ref('');
|
const documentTitle = ref('');
|
||||||
const handleSearch = (searchParams: string) => {
|
const handleSearch = (searchParams: string) => {
|
||||||
documentTableRef.value.search(searchParams);
|
documentTableRef.value?.search?.(searchParams);
|
||||||
};
|
};
|
||||||
const handleButtonClick = (event: any) => {
|
const handleButtonClick = (event: any) => {
|
||||||
// 根据按钮 key 执行不同操作
|
// 根据按钮 key 执行不同操作
|
||||||
@@ -162,7 +200,7 @@ const handleButtonClick = (event: any) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'importFile': {
|
case 'importFile': {
|
||||||
importDocModalRef.value?.openDialog?.();
|
void openImportDialog();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +231,7 @@ const backDoc = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="document-container">
|
<div v-loading="importDialogLoading" class="document-container">
|
||||||
<div class="doc-header-container">
|
<div class="doc-header-container">
|
||||||
<div class="doc-knowledge-container">
|
<div class="doc-knowledge-container">
|
||||||
<div @click="back()" style="cursor: pointer">
|
<div @click="back()" style="cursor: pointer">
|
||||||
@@ -301,7 +339,9 @@ const backDoc = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ImportKnowledgeDocFile
|
<component
|
||||||
|
:is="ImportKnowledgeDocFileComponent"
|
||||||
|
v-if="ImportKnowledgeDocFileComponent"
|
||||||
ref="importDocModalRef"
|
ref="importDocModalRef"
|
||||||
:knowledge-id-prop="String(knowledgeId)"
|
:knowledge-id-prop="String(knowledgeId)"
|
||||||
@imported="backDoc"
|
@imported="backDoc"
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ import CardPage from '#/components/page/CardList.vue';
|
|||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
import PageSide from '#/components/page/PageSide.vue';
|
||||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||||
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
|
||||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +70,29 @@ const collectionTypeLabelMap = {
|
|||||||
};
|
};
|
||||||
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
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(() =>
|
const canManageKnowledgePermission = computed(() =>
|
||||||
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
||||||
);
|
);
|
||||||
@@ -205,7 +228,7 @@ const actions: ActionButton[] = [
|
|||||||
if (!ensureManageKnowledgeItem(row)) {
|
if (!ensureManageKnowledgeItem(row)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
aiKnowledgeModalRef.value.openDialog(row);
|
void openKnowledgeModal(row);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -460,7 +483,6 @@ function resolvePublishStatusMeta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const aiKnowledgeModalRef = ref();
|
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
{
|
{
|
||||||
key: 'add',
|
key: 'add',
|
||||||
@@ -474,7 +496,7 @@ const headerButtons = [
|
|||||||
const handleButtonClick = (event: any, _item: any) => {
|
const handleButtonClick = (event: any, _item: any) => {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'add': {
|
case 'add': {
|
||||||
aiKnowledgeModalRef.value.openDialog({});
|
void openKnowledgeModal({});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -667,7 +689,7 @@ function changeCategory(category: any) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="knowledge-header">
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
:buttons="headerButtons"
|
:buttons="headerButtons"
|
||||||
@@ -865,7 +887,9 @@ function changeCategory(category: any) {
|
|||||||
</EasyFlowFormModal>
|
</EasyFlowFormModal>
|
||||||
|
|
||||||
<!-- 新增知识库模态框-->
|
<!-- 新增知识库模态框-->
|
||||||
<DocumentCollectionModal
|
<component
|
||||||
|
:is="DocumentCollectionModalComponent"
|
||||||
|
v-if="DocumentCollectionModalComponent"
|
||||||
ref="aiKnowledgeModalRef"
|
ref="aiKnowledgeModalRef"
|
||||||
@reload="reloadKnowledgeList"
|
@reload="reloadKnowledgeList"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
|
|||||||
import CardPage from '#/components/page/CardList.vue';
|
import CardPage from '#/components/page/CardList.vue';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.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 { buildPluginPageQueryParams } from './plugin-query';
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +61,29 @@ interface CategoryFormData {
|
|||||||
name: string;
|
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) {
|
function resolveNavTitle(item: PluginRecord) {
|
||||||
return (item.title as string) || (item.name as string) || '';
|
return (item.title as string) || (item.name as string) || '';
|
||||||
}
|
}
|
||||||
@@ -93,7 +116,7 @@ const actions: ActionButton[] = [
|
|||||||
permission: '/api/v1/plugin/save',
|
permission: '/api/v1/plugin/save',
|
||||||
placement: 'inline',
|
placement: 'inline',
|
||||||
onClick(item) {
|
onClick(item) {
|
||||||
aiPluginModalRef.value.openDialog(item);
|
void openPluginDialog(item);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -194,7 +217,6 @@ const handleDelete = (item: PluginRecord) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const aiPluginModalRef = ref();
|
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
{
|
{
|
||||||
key: 'add',
|
key: 'add',
|
||||||
@@ -221,7 +243,7 @@ const handleSubmit = () => {
|
|||||||
const handleButtonClick = (event: HeaderActionEvent, _item: unknown) => {
|
const handleButtonClick = (event: HeaderActionEvent, _item: unknown) => {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'add': {
|
case 'add': {
|
||||||
aiPluginModalRef.value.openDialog({});
|
void openPluginDialog({});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,7 +338,7 @@ const handleClickCategory = (item: PluginCategory) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="knowledge-container">
|
<div v-loading="pluginModalLoading" class="knowledge-container">
|
||||||
<div class="knowledge-header">
|
<div class="knowledge-header">
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
:buttons="headerButtons"
|
:buttons="headerButtons"
|
||||||
@@ -378,7 +400,12 @@ const handleClickCategory = (item: PluginCategory) => {
|
|||||||
</PageData>
|
</PageData>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AddPluginModal ref="aiPluginModalRef" @reload="reloadCurrentList" />
|
<component
|
||||||
|
:is="AddPluginModalComponent"
|
||||||
|
v-if="AddPluginModalComponent"
|
||||||
|
ref="aiPluginModalRef"
|
||||||
|
@reload="reloadCurrentList"
|
||||||
|
/>
|
||||||
<EasyFlowFormModal
|
<EasyFlowFormModal
|
||||||
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
|
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
|
||||||
v-model:open="dialogVisible"
|
v-model:open="dialogVisible"
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { $t } from '#/locales';
|
|||||||
import { router } from '#/router';
|
import { router } from '#/router';
|
||||||
import { useDictStore } from '#/store';
|
import { useDictStore } from '#/store';
|
||||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||||
@@ -82,7 +83,6 @@ import {
|
|||||||
mergeWorkflowListRouteQuery,
|
mergeWorkflowListRouteQuery,
|
||||||
parseWorkflowListRouteState,
|
parseWorkflowListRouteState,
|
||||||
} from './workflow-list-route-state';
|
} from './workflow-list-route-state';
|
||||||
import WorkflowModal from './WorkflowModal.vue';
|
|
||||||
|
|
||||||
const ElXMarkdown = defineAsyncComponent(
|
const ElXMarkdown = defineAsyncComponent(
|
||||||
() => import('vue-element-plus-x/es/XMarkdown/index.js'),
|
() => import('vue-element-plus-x/es/XMarkdown/index.js'),
|
||||||
@@ -116,6 +116,19 @@ interface ApiFieldDoc {
|
|||||||
placeholder: string;
|
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 = {
|
const primaryAction: CardPrimaryAction = {
|
||||||
showIndicator: false,
|
showIndicator: false,
|
||||||
text: $t('button.design'),
|
text: $t('button.design'),
|
||||||
@@ -283,7 +296,6 @@ onMounted(() => {
|
|||||||
getSideList();
|
getSideList();
|
||||||
});
|
});
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const saveDialog = ref();
|
|
||||||
const selectedCategoryId = ref<number | string>(initialListState.categoryId);
|
const selectedCategoryId = ref<number | string>(initialListState.categoryId);
|
||||||
const searchKeyword = ref(initialListState.keyword);
|
const searchKeyword = ref(initialListState.keyword);
|
||||||
const currentPageNumber = ref(initialListState.pageNumber);
|
const currentPageNumber = ref(initialListState.pageNumber);
|
||||||
@@ -401,8 +413,14 @@ function handleSearch(keyword: string) {
|
|||||||
function reloadCurrentList() {
|
function reloadCurrentList() {
|
||||||
pageDataRef.value?.reload?.();
|
pageDataRef.value?.reload?.();
|
||||||
}
|
}
|
||||||
function showDialog(row: any, importMode = false) {
|
async function showDialog(row: any, importMode = false) {
|
||||||
saveDialog.value.openDialog({ ...row }, importMode);
|
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) {
|
function resolveNavTitle(row: any) {
|
||||||
return row?.title || row?.name || '';
|
return row?.title || row?.name || '';
|
||||||
@@ -1144,8 +1162,13 @@ function handleHeaderButtonClick(data: any) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-6 p-6">
|
<div v-loading="workflowModalLoading" class="flex h-full flex-col gap-6 p-6">
|
||||||
<WorkflowModal ref="saveDialog" @reload="reloadCurrentList" />
|
<component
|
||||||
|
:is="WorkflowModalComponent"
|
||||||
|
v-if="WorkflowModalComponent"
|
||||||
|
ref="saveDialog"
|
||||||
|
@reload="reloadCurrentList"
|
||||||
|
/>
|
||||||
<ElDialog
|
<ElDialog
|
||||||
v-model="apiInstructionVisible"
|
v-model="apiInstructionVisible"
|
||||||
:title="$t('aiWorkflow.apiInstruction')"
|
:title="$t('aiWorkflow.apiInstruction')"
|
||||||
|
|||||||
@@ -1,13 +1,40 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { defineConfig } from '@easyflow/vite-config';
|
import { defineConfig } from '@easyflow/vite-config';
|
||||||
|
|
||||||
import ElementPlus from 'unplugin-element-plus/vite';
|
import ElementPlus from 'unplugin-element-plus/vite';
|
||||||
|
|
||||||
import { createBasePathRedirectPlugin } from './vite-base-path-redirect';
|
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 () => {
|
export default defineConfig(async () => {
|
||||||
return {
|
return {
|
||||||
application: {},
|
application: {},
|
||||||
vite: {
|
vite: {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (isResourceListModule(id)) {
|
||||||
|
return 'resource-list';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onlyExplicitManualChunks: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// @vueuse/motion expects tslib default export during dev pre-bundling
|
// @vueuse/motion expects tslib default export during dev pre-bundling
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function defineApplicationConfig(userConfigPromise?: DefineApplicationOptions) {
|
|||||||
entryFileNames: 'jse/index-[name]-[hash].js',
|
entryFileNames: 'jse/index-[name]-[hash].js',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
target: 'es2015',
|
target: 'chrome90',
|
||||||
},
|
},
|
||||||
css: createCssOptions(injectGlobalScss),
|
css: createCssOptions(injectGlobalScss),
|
||||||
esbuild: {
|
esbuild: {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
html {
|
html {
|
||||||
@apply text-foreground bg-background font-sans text-[100%];
|
@apply text-foreground bg-background font-sans text-[100%];
|
||||||
|
|
||||||
background-color: hsl(var(--surface-canvas));
|
|
||||||
font-variation-settings: normal;
|
font-variation-settings: normal;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
text-size-adjust: 100%;
|
text-size-adjust: 100%;
|
||||||
@@ -34,6 +33,8 @@
|
|||||||
html {
|
html {
|
||||||
@apply size-full;
|
@apply size-full;
|
||||||
|
|
||||||
|
background-color: hsl(var(--surface-canvas));
|
||||||
|
|
||||||
/* scrollbar-gutter: stable; */
|
/* 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 isCheckingUpdates = false;
|
||||||
let resumeCheckFrame: number | undefined;
|
|
||||||
let versionCheckController: AbortController | null = null;
|
let versionCheckController: AbortController | null = null;
|
||||||
const VERSION_CHECK_TIMEOUT_MS = 10_000;
|
const VERSION_CHECK_TIMEOUT_MS = 10_000;
|
||||||
const currentVersionTag = ref('');
|
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() {
|
function handleVisibilitychange() {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
cancelResumeCheck();
|
|
||||||
stop();
|
stop();
|
||||||
} else {
|
} else {
|
||||||
scheduleResumeCheck();
|
// 恢复窗口时只重启轮询,避免与旧浏览器的首帧重绘争抢资源。
|
||||||
|
start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +144,6 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cancelResumeCheck();
|
|
||||||
stop();
|
stop();
|
||||||
document.removeEventListener('visibilitychange', handleVisibilitychange);
|
document.removeEventListener('visibilitychange', handleVisibilitychange);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
html {
|
html {
|
||||||
@apply size-full;
|
@apply size-full;
|
||||||
|
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
|
||||||
/* scrollbar-gutter: stable; */
|
/* 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,12 +124,9 @@ function handleVisibilitychange() {
|
|||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
stop();
|
stop();
|
||||||
} else {
|
} else {
|
||||||
runUpdateCheck().finally(() => {
|
// 恢复窗口时只重启轮询,避免与旧浏览器的首帧重绘争抢资源。
|
||||||
if (!document.hidden) {
|
|
||||||
start();
|
start();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
|
|||||||
Reference in New Issue
Block a user