发布 v1.10 #5
@@ -4,7 +4,7 @@ VITE_BASE=/flow/
|
||||
VITE_GLOB_API_URL=/flow
|
||||
|
||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||
VITE_COMPRESS=none
|
||||
VITE_COMPRESS=gzip
|
||||
|
||||
# 是否开启 PWA
|
||||
VITE_PWA=false
|
||||
|
||||
@@ -25,7 +25,7 @@ export const overridesPreferences = defineOverridesPreferences({
|
||||
transition: {
|
||||
enable: false,
|
||||
loading: false,
|
||||
progress: false,
|
||||
progress: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RouteRecordStringComponent } from '@easyflow/types';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { disableUnsafeMenuRouteCaching } from '../route-cache-safety';
|
||||
|
||||
function createRoute(
|
||||
path: string,
|
||||
children?: RouteRecordStringComponent[],
|
||||
): RouteRecordStringComponent {
|
||||
return {
|
||||
children,
|
||||
component: '/example/Index',
|
||||
meta: { keepAlive: true, title: path },
|
||||
name: path,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
describe('route cache safety', () => {
|
||||
it('disables stale keep-alive metadata for affected menus recursively', () => {
|
||||
const knowledgeRoute = createRoute('/ai/documentCollection');
|
||||
const routes = [
|
||||
createRoute('/ai/workflow'),
|
||||
createRoute('/ai', [knowledgeRoute]),
|
||||
];
|
||||
|
||||
expect(disableUnsafeMenuRouteCaching(routes)).toBe(routes);
|
||||
expect(routes[0]?.meta?.keepAlive).toBe(false);
|
||||
expect(knowledgeRoute.meta?.keepAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('does not change unrelated route caching', () => {
|
||||
const unrelatedRoute = createRoute('/example/cached');
|
||||
|
||||
disableUnsafeMenuRouteCaching([unrelatedRoute]);
|
||||
|
||||
expect(unrelatedRoute.meta?.keepAlive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '@easyflow/types';
|
||||
|
||||
import { generateAccessible } from '@easyflow/access';
|
||||
import { loadMissingIconCollections } from '@easyflow/icons';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
@@ -12,8 +13,35 @@ import { getAllMenusApi } from '#/api';
|
||||
import { BasicLayout, IFrameView } from '#/layouts';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { disableUnsafeMenuRouteCaching } from './route-cache-safety';
|
||||
|
||||
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
|
||||
|
||||
function collectMenuIcons(routes: unknown[]): string[] {
|
||||
const icons: string[] = [];
|
||||
routes.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return;
|
||||
}
|
||||
const route = item as Record<string, unknown>;
|
||||
const meta =
|
||||
route.meta && typeof route.meta === 'object'
|
||||
? (route.meta as Record<string, unknown>)
|
||||
: undefined;
|
||||
[route.icon, route.activeIcon, meta?.icon, meta?.activeIcon].forEach(
|
||||
(icon) => {
|
||||
if (typeof icon === 'string') {
|
||||
icons.push(icon);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (Array.isArray(route.children)) {
|
||||
icons.push(...collectMenuIcons(route.children));
|
||||
}
|
||||
});
|
||||
return icons;
|
||||
}
|
||||
|
||||
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
||||
|
||||
@@ -29,7 +57,9 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
duration: 1500,
|
||||
message: `${$t('common.loadingMenu')}...`,
|
||||
});
|
||||
return await getAllMenusApi();
|
||||
const menuRoutes = await getAllMenusApi();
|
||||
await loadMissingIconCollections(collectMenuIcons(menuRoutes));
|
||||
return disableUnsafeMenuRouteCaching(menuRoutes);
|
||||
},
|
||||
// 可以指定没有权限跳转403页面
|
||||
forbiddenComponent,
|
||||
|
||||
31
easyflow-ui-admin/app/src/router/route-cache-safety.ts
Normal file
31
easyflow-ui-admin/app/src/router/route-cache-safety.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { RouteRecordStringComponent } from '@easyflow/types';
|
||||
|
||||
const UNSAFE_KEEP_ALIVE_MENU_PATHS = new Set([
|
||||
'/ai/agents',
|
||||
'/ai/documentCollection',
|
||||
'/ai/model',
|
||||
'/ai/skill',
|
||||
'/ai/workflow',
|
||||
'/dashboard/workspace',
|
||||
'/sys/sysAccount',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Disable route-level caching that conflicts with independent hidden child
|
||||
* routes. Assigning false also replaces stale true values in persisted tabs.
|
||||
*/
|
||||
function disableUnsafeMenuRouteCaching(
|
||||
routes: RouteRecordStringComponent[],
|
||||
): RouteRecordStringComponent[] {
|
||||
for (const route of routes) {
|
||||
if (UNSAFE_KEEP_ALIVE_MENU_PATHS.has(route.path) && route.meta) {
|
||||
route.meta.keepAlive = false;
|
||||
}
|
||||
if (route.children?.length) {
|
||||
disableUnsafeMenuRouteCaching(route.children);
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
export { disableUnsafeMenuRouteCaching };
|
||||
@@ -213,7 +213,7 @@ const loadProviderDetail = async (
|
||||
if (!options.keepDraft) {
|
||||
syncProviderDraft();
|
||||
}
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
isDetailLoading.value = true;
|
||||
@@ -234,30 +234,64 @@ const loadProviderDetail = async (
|
||||
if (!options.keepDraft) {
|
||||
syncProviderDraft(selectedProvider.value);
|
||||
}
|
||||
return res.data;
|
||||
} else {
|
||||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||||
}
|
||||
} finally {
|
||||
isDetailLoading.value = false;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const refreshProviderCounts = async (list: any[]) => {
|
||||
const entries = await Promise.all(
|
||||
list.map(async (provider) => {
|
||||
let providerCountRequestId = 0;
|
||||
const PROVIDER_COUNT_CONCURRENCY = 2;
|
||||
|
||||
const refreshProviderCounts = async (
|
||||
list: any[],
|
||||
excludedProviderId?: string,
|
||||
) => {
|
||||
const currentRequestId = ++providerCountRequestId;
|
||||
const pendingProviders = list.filter(
|
||||
(provider) => String(provider.id) !== excludedProviderId,
|
||||
);
|
||||
const entries: Array<[string, number]> = [];
|
||||
let cursor = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (cursor < pendingProviders.length) {
|
||||
const provider = pendingProviders[cursor++];
|
||||
const providerId = String(provider.id);
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/api/v1/model/getList?providerId=${provider.id}`,
|
||||
`/api/v1/model/getList?providerId=${providerId}`,
|
||||
{},
|
||||
);
|
||||
return [provider.id, res.errorCode === 0 ? countModels(res.data) : 0];
|
||||
entries.push([
|
||||
providerId,
|
||||
res.errorCode === 0 ? countModels(res.data) : 0,
|
||||
]);
|
||||
} catch {
|
||||
return [provider.id, 0];
|
||||
entries.push([providerId, 0]);
|
||||
}
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{
|
||||
length: Math.min(PROVIDER_COUNT_CONCURRENCY, pendingProviders.length),
|
||||
},
|
||||
worker,
|
||||
),
|
||||
);
|
||||
|
||||
providerCounts.value = Object.fromEntries(entries);
|
||||
if (currentRequestId === providerCountRequestId) {
|
||||
providerCounts.value = {
|
||||
...providerCounts.value,
|
||||
...Object.fromEntries(entries),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const loadProviders = async (
|
||||
@@ -272,12 +306,15 @@ const loadProviders = async (
|
||||
const res = await getLlmProviderList();
|
||||
const list = res?.data || [];
|
||||
providers.value = list;
|
||||
|
||||
if (refreshCounts) {
|
||||
await refreshProviderCounts(list);
|
||||
}
|
||||
providerCounts.value = Object.fromEntries(
|
||||
list.map((provider: any) => [
|
||||
String(provider.id),
|
||||
providerCounts.value[String(provider.id)] ?? 0,
|
||||
]),
|
||||
);
|
||||
|
||||
if (list.length === 0) {
|
||||
providerCountRequestId += 1;
|
||||
selectedProviderId.value = '';
|
||||
syncProviderDraft();
|
||||
syncGroupedModels({});
|
||||
@@ -300,6 +337,9 @@ const loadProviders = async (
|
||||
await loadProviderDetail(nextProviderId, {
|
||||
keepDraft: keepDraft && previousId === nextProviderId,
|
||||
});
|
||||
if (refreshCounts) {
|
||||
void refreshProviderCounts(list, String(nextProviderId));
|
||||
}
|
||||
} finally {
|
||||
isPageLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import type {
|
||||
} from '#/components/page/CardList.vue';
|
||||
import type { OfflineImpactCheck } from '#/views/ai/shared/offline-impact';
|
||||
|
||||
import { computed, h, markRaw, onMounted, ref } from 'vue';
|
||||
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
h,
|
||||
markRaw,
|
||||
onMounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
@@ -40,6 +45,7 @@ import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElPopover,
|
||||
ElSkeleton,
|
||||
} from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
@@ -67,6 +73,10 @@ import {
|
||||
|
||||
import WorkflowModal from './WorkflowModal.vue';
|
||||
|
||||
const ElXMarkdown = defineAsyncComponent(
|
||||
() => import('vue-element-plus-x/es/XMarkdown/index.js'),
|
||||
);
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
interface FieldDefinition {
|
||||
@@ -1007,11 +1017,16 @@ function handleHeaderButtonClick(data: any) {
|
||||
class="workflow-api-markdown-wrap"
|
||||
@click="handleApiDocClick"
|
||||
>
|
||||
<Suspense>
|
||||
<ElXMarkdown
|
||||
:markdown="apiDocMarkdown"
|
||||
:allow-html="true"
|
||||
:sanitize="false"
|
||||
/>
|
||||
<template #fallback>
|
||||
<ElSkeleton :rows="6" animated />
|
||||
</template>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ElDialog>
|
||||
<HeaderSearch
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
||||
import type { EchartsUIType } from '@easyflow/plugins/echarts/workspace';
|
||||
|
||||
import type {
|
||||
DashboardAssistantTrendSeries,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from 'vue';
|
||||
|
||||
import { AnalysisChartCard } from '@easyflow/common-ui';
|
||||
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
|
||||
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts/workspace';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
import { convertToRgb, downloadFileFromBlob } from '@easyflow/utils';
|
||||
|
||||
@@ -307,17 +307,15 @@ async function loadOverview() {
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const [data] = await Promise.all([
|
||||
getDashboardOverview(buildOverviewQuery()),
|
||||
loadAssistantOptions().catch(() => undefined),
|
||||
]);
|
||||
void loadAssistantOptions().catch(() => undefined);
|
||||
const data = await getDashboardOverview(buildOverviewQuery());
|
||||
overview.value = data;
|
||||
resetAssistantTrendSelection();
|
||||
await renderCharts();
|
||||
if (data.chatStatus?.available === false) {
|
||||
resetUserRanks();
|
||||
} else {
|
||||
await loadUserRanks();
|
||||
void loadUserRanks();
|
||||
}
|
||||
} catch (error) {
|
||||
overview.value = null;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isLocalIcon, loadIconCollection } from './index';
|
||||
|
||||
describe('offline icon collections', () => {
|
||||
it('keeps common icons in the startup registry', () => {
|
||||
expect(isLocalIcon('mdi:github')).toBe(true);
|
||||
expect(isLocalIcon('ant-design:appstore-outlined')).toBe(true);
|
||||
});
|
||||
|
||||
it('loads uncommon icons from a bundled collection on demand', async () => {
|
||||
expect(isLocalIcon('mdi:ab-testing')).toBe(false);
|
||||
|
||||
await loadIconCollection('mdi');
|
||||
|
||||
expect(isLocalIcon('mdi:ab-testing')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,40 @@
|
||||
import antDesign from '@iconify/json/json/ant-design.json';
|
||||
import ep from '@iconify/json/json/ep.json';
|
||||
import mdi from '@iconify/json/json/mdi.json';
|
||||
import {
|
||||
addCollection as addOfflineCollection,
|
||||
addIcon as addOfflineIcon,
|
||||
Icon as IconifyIcon,
|
||||
} from '@iconify/vue/offline';
|
||||
import type {
|
||||
IconifyIcon as IconifyIconData,
|
||||
IconifyJSON,
|
||||
} from '@iconify/vue/offline';
|
||||
|
||||
import {
|
||||
addCollection as addOfflineCollection,
|
||||
addIcon as addOfflineIcon,
|
||||
Icon as IconifyIcon,
|
||||
} from '@iconify/vue/offline';
|
||||
|
||||
import { LOCAL_ICON_DATA } from './local-icons';
|
||||
import { STARTUP_ICON_DATA } from './startup-icons';
|
||||
|
||||
type LazyCollectionPrefix = 'ant-design' | 'ep' | 'mdi';
|
||||
|
||||
const LOCAL_ICON_NAMES = new Set<string>();
|
||||
const COLLECTION_LOADERS: Record<
|
||||
LazyCollectionPrefix,
|
||||
() => Promise<IconifyJSON>
|
||||
> = {
|
||||
'ant-design': async () => {
|
||||
const collection = await import('@iconify/json/json/ant-design.json');
|
||||
return collection.default;
|
||||
},
|
||||
ep: async () => {
|
||||
const collection = await import('@iconify/json/json/ep.json');
|
||||
return collection.default;
|
||||
},
|
||||
mdi: async () => {
|
||||
const collection = await import('@iconify/json/json/mdi.json');
|
||||
return collection.default;
|
||||
},
|
||||
};
|
||||
const COLLECTION_LOAD_PROMISES = new Map<LazyCollectionPrefix, Promise<void>>();
|
||||
|
||||
function getCollectionPrefix(data: IconifyJSON, prefix?: string | boolean) {
|
||||
function getCollectionPrefix(data: IconifyJSON, prefix?: boolean | string) {
|
||||
if (prefix === false) {
|
||||
return '';
|
||||
}
|
||||
@@ -25,7 +44,7 @@ function getCollectionPrefix(data: IconifyJSON, prefix?: string | boolean) {
|
||||
/**
|
||||
* Register an icon collection in the offline Iconify store and local name index.
|
||||
*/
|
||||
function registerCollection(data: IconifyJSON, prefix?: string | boolean) {
|
||||
function registerCollection(data: IconifyJSON, prefix?: boolean | string) {
|
||||
addOfflineCollection(data, prefix);
|
||||
const iconPrefix = getCollectionPrefix(data, prefix);
|
||||
Object.keys(data.icons).forEach((name) => {
|
||||
@@ -62,12 +81,66 @@ function isLocalIcon(name: string) {
|
||||
return LOCAL_ICON_NAMES.has(name);
|
||||
}
|
||||
|
||||
registerCollection(antDesign);
|
||||
registerCollection(ep);
|
||||
registerCollection(mdi);
|
||||
Object.entries(LOCAL_ICON_DATA).forEach(([name, data]) => {
|
||||
registerIcon(name, data);
|
||||
function isLazyCollectionPrefix(
|
||||
prefix: string,
|
||||
): prefix is LazyCollectionPrefix {
|
||||
return Object.prototype.hasOwnProperty.call(COLLECTION_LOADERS, prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a bundled offline icon collection once, without using a remote API.
|
||||
*/
|
||||
async function loadIconCollection(prefix: string) {
|
||||
if (!isLazyCollectionPrefix(prefix)) {
|
||||
return listIcons('', prefix);
|
||||
}
|
||||
|
||||
let pending = COLLECTION_LOAD_PROMISES.get(prefix);
|
||||
if (!pending) {
|
||||
pending = COLLECTION_LOADERS[prefix]()
|
||||
.then((data) => {
|
||||
registerCollection(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
COLLECTION_LOAD_PROMISES.delete(prefix);
|
||||
throw error;
|
||||
});
|
||||
COLLECTION_LOAD_PROMISES.set(prefix, pending);
|
||||
}
|
||||
await pending;
|
||||
return listIcons('', prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve historical menu icons by loading only the collections they need.
|
||||
*/
|
||||
async function loadMissingIconCollections(iconNames: string[]) {
|
||||
const prefixes = new Set<LazyCollectionPrefix>();
|
||||
iconNames.forEach((name) => {
|
||||
if (isLocalIcon(name)) {
|
||||
return;
|
||||
}
|
||||
const prefix = name.split(':', 1)[0] ?? '';
|
||||
if (isLazyCollectionPrefix(prefix)) {
|
||||
prefixes.add(prefix);
|
||||
}
|
||||
});
|
||||
await Promise.all(
|
||||
[...prefixes].map(async (prefix) => {
|
||||
try {
|
||||
await loadIconCollection(prefix);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load menu icon collection: ${prefix}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Object.entries({ ...LOCAL_ICON_DATA, ...STARTUP_ICON_DATA }).forEach(
|
||||
([name, data]) => {
|
||||
registerIcon(name, data);
|
||||
},
|
||||
);
|
||||
|
||||
export * from './create-icon';
|
||||
|
||||
@@ -75,9 +148,11 @@ export * from './lucide';
|
||||
|
||||
export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue/offline';
|
||||
export {
|
||||
registerCollection as addCollection,
|
||||
registerIcon as addIcon,
|
||||
IconifyIcon,
|
||||
isLocalIcon,
|
||||
listIcons,
|
||||
registerCollection as addCollection,
|
||||
registerIcon as addIcon,
|
||||
loadIconCollection,
|
||||
loadMissingIconCollections,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { IconifyIcon } from '@iconify/vue/offline';
|
||||
|
||||
/**
|
||||
* Icons referenced directly by application source or default persisted menus.
|
||||
* Full icon collections are loaded lazily when an uncommon icon is requested.
|
||||
*/
|
||||
export const STARTUP_ICON_DATA: Record<string, IconifyIcon> = {
|
||||
'ant-design:apartment-outlined': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M908 640H804V488c0-4.4-3.6-8-8-8H548v-96h108c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16H368c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h108v96H228c-4.4 0-8 3.6-8 8v152H116c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16H292v-88h440v88H620c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16m-564 76v168H176V716zm84-408V140h168v168zm420 576H680V716h168z"/>',
|
||||
},
|
||||
'ant-design:appstore-outlined': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16m-52 268H212V212h200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16m-52 268H612V212h200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16m-52 268H212V612h200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16m-52 268H612V612h200z"/>',
|
||||
},
|
||||
'ep:arrow-right': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512L340.864 831.872a30.59 30.59 0 0 0 0 42.752a29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"/>',
|
||||
},
|
||||
'ep:expand': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M128 192h768v128H128zm0 256h512v128H128zm0 256h768v128H128zm576-352l192 160l-192 128z"/>',
|
||||
},
|
||||
'ep:fold': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M896 192H128v128h768zm0 256H384v128h512zm0 256H128v128h768zM320 384L128 512l192 128z"/>',
|
||||
},
|
||||
'mdi:chat-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M12 3C6.5 3 2 6.58 2 11a7.22 7.22 0 0 0 2.75 5.5c0 .6-.42 2.17-2.75 4.5c2.37-.11 4.64-1 6.47-2.5c1.14.33 2.34.5 3.53.5c5.5 0 10-3.58 10-8s-4.5-8-10-8m0 14c-4.42 0-8-2.69-8-6s3.58-6 8-6s8 2.69 8 6s-3.58 6-8 6"/>',
|
||||
},
|
||||
'mdi:clock-time-five-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M12 20c4.4 0 8-3.6 8-8s-3.6-8-8-8s-8 3.6-8 8s3.6 8 8 8m0-18c5.5 0 10 4.5 10 10s-4.5 10-10 10S2 17.5 2 12S6.5 2 12 2m3.3 14.2L14 17l-3-5.2V7h1.5v4.4z"/>',
|
||||
},
|
||||
'mdi:github': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"/>',
|
||||
},
|
||||
'mdi:hammer': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M2 19.63L13.43 8.2l-.71-.7l1.42-1.43L12 3.89c1.2-1.19 3.09-1.19 4.27 0l3.6 3.61l-1.42 1.41h2.84l.71.71l-3.55 3.59l-.71-.71V9.62l-1.47 1.42l-.71-.71L4.13 21.76z"/>',
|
||||
},
|
||||
'mdi:home-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="m12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81zM12 3L2 12h3v8h6v-6h2v6h6v-8h3"/>',
|
||||
},
|
||||
'mdi:image-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z"/>',
|
||||
},
|
||||
'mdi:keyboard-esc': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1zm10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2"/>',
|
||||
},
|
||||
};
|
||||
@@ -91,6 +91,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:data-menu-path="path"
|
||||
:class="[
|
||||
rootMenu.theme,
|
||||
b(),
|
||||
|
||||
@@ -50,6 +50,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [string, string[]];
|
||||
enter: [string];
|
||||
open: [string, string[]];
|
||||
select: [string, string[]];
|
||||
}>();
|
||||
@@ -257,6 +258,25 @@ function handleMenuItemClick(data: MenuItemClicked) {
|
||||
emit('select', path, parentPaths);
|
||||
}
|
||||
|
||||
let enteredPath = '';
|
||||
|
||||
function handleMenuItemEnter(event: Event) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const menuItem = target.closest<HTMLElement>('[data-menu-path]');
|
||||
if (!menuItem || !menu.value?.contains(menuItem)) {
|
||||
return;
|
||||
}
|
||||
const path = menuItem.dataset.menuPath;
|
||||
if (!path || path === enteredPath) {
|
||||
return;
|
||||
}
|
||||
enteredPath = path;
|
||||
emit('enter', path);
|
||||
}
|
||||
|
||||
function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
|
||||
const isOpened = openedMenus.value.includes(path);
|
||||
|
||||
@@ -349,6 +369,9 @@ function getActivePaths() {
|
||||
]"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@focusin="handleMenuItemEnter"
|
||||
@mouseleave="enteredPath = ''"
|
||||
@mouseover="handleMenuItemEnter"
|
||||
>
|
||||
<template v-if="mode === 'horizontal' && getSlot.showSlotMore">
|
||||
<template v-for="item in getSlot.slotDefault" :key="item.key">
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { VNode } from 'vue';
|
||||
import { computed, ref, useAttrs, watch, watchEffect } from 'vue';
|
||||
|
||||
import { usePagination } from '@easyflow/hooks';
|
||||
import { EmptyIcon, Grip, listIcons } from '@easyflow/icons';
|
||||
import {
|
||||
EmptyIcon,
|
||||
Grip,
|
||||
listIcons,
|
||||
loadIconCollection,
|
||||
} from '@easyflow/icons';
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
@@ -75,18 +80,9 @@ const currentPage = ref(1);
|
||||
const keyword = ref('');
|
||||
const keywordDebounce = refDebounced(keyword, 300);
|
||||
const innerIcons = ref<string[]>([]);
|
||||
let iconLoadRequestId = 0;
|
||||
|
||||
/* 当检索关键词变化时,重置分页 */
|
||||
watch(keywordDebounce, () => {
|
||||
currentPage.value = 1;
|
||||
setCurrentPage(1);
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
() => props.prefix,
|
||||
async (prefix) => {
|
||||
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
|
||||
innerIcons.value = [
|
||||
const commonSvgIcons = [
|
||||
'svg:talk',
|
||||
'svg:plugin',
|
||||
'svg:workflow',
|
||||
@@ -107,11 +103,47 @@ watchDebounced(
|
||||
'svg:user-feedback',
|
||||
'svg:oauth',
|
||||
'svg:mcp',
|
||||
...getLocalIconsData(prefix),
|
||||
];
|
||||
|
||||
/* 当检索关键词变化时,重置分页 */
|
||||
watch(keywordDebounce, () => {
|
||||
currentPage.value = 1;
|
||||
setCurrentPage(1);
|
||||
});
|
||||
|
||||
async function refreshInnerIcons(prefix?: string) {
|
||||
if (!prefix || prefix === 'svg' || !props.autoFetchApi) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++iconLoadRequestId;
|
||||
try {
|
||||
await loadIconCollection(prefix);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load icon collection: ${prefix}`, error);
|
||||
}
|
||||
if (requestId === iconLoadRequestId && prefix === props.prefix) {
|
||||
innerIcons.value = [...commonSvgIcons, ...getLocalIconsData(prefix, true)];
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(isVisible) => {
|
||||
if (isVisible) {
|
||||
void refreshInnerIcons(props.prefix);
|
||||
}
|
||||
},
|
||||
{ immediate: true, debounce: 500, maxWait: 1000 },
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watchDebounced(
|
||||
() => props.prefix,
|
||||
(prefix) => {
|
||||
if (visible.value) {
|
||||
void refreshInnerIcons(prefix);
|
||||
}
|
||||
},
|
||||
{ debounce: 300, maxWait: 600 },
|
||||
);
|
||||
|
||||
const currentList = computed(() => {
|
||||
|
||||
@@ -12,8 +12,8 @@ export const ICONS_MAP: Recordable<string[]> = {};
|
||||
* @param prefix 图标集名称
|
||||
* @returns 图标集中包含的所有图标名称
|
||||
*/
|
||||
export function getLocalIconsData(prefix: string): string[] {
|
||||
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
|
||||
export function getLocalIconsData(prefix: string, refresh = false): string[] {
|
||||
if (!refresh && Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
|
||||
return ICONS_MAP[prefix];
|
||||
}
|
||||
ICONS_MAP[prefix] = listIcons('', prefix);
|
||||
|
||||
@@ -103,6 +103,7 @@ const showHeaderNav = computed(() => {
|
||||
});
|
||||
|
||||
const {
|
||||
handleMenuEnter,
|
||||
handleMenuSelect,
|
||||
handleMenuOpen,
|
||||
headerActive,
|
||||
@@ -302,6 +303,7 @@ const headerSlots = computed(() => {
|
||||
:theme="headerTheme"
|
||||
class="w-full"
|
||||
mode="horizontal"
|
||||
@enter="handleMenuEnter"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -330,6 +332,7 @@ const headerSlots = computed(() => {
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
mode="vertical"
|
||||
@enter="handleMenuEnter"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
enter: [string];
|
||||
open: [string, string[]];
|
||||
select: [string, string?];
|
||||
}>();
|
||||
@@ -26,6 +27,10 @@ function handleMenuSelect(key: string) {
|
||||
function handleMenuOpen(key: string, path: string[]) {
|
||||
emit('open', key, path);
|
||||
}
|
||||
|
||||
function handleMenuEnter(key: string) {
|
||||
emit('enter', key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,6 +44,7 @@ function handleMenuOpen(key: string, path: string[]) {
|
||||
:rounded="rounded"
|
||||
scroll-to-active
|
||||
:theme="theme"
|
||||
@enter="handleMenuEnter"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useNavigation } from './use-navigation';
|
||||
|
||||
function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
|
||||
const accessStore = useAccessStore();
|
||||
const { navigation, prefetch, willOpenedByWindow } = useNavigation();
|
||||
const { navigation, schedulePrefetch, willOpenedByWindow } = useNavigation();
|
||||
|
||||
const menus = computed(() => useRootMenus?.value ?? accessStore.accessMenus);
|
||||
|
||||
@@ -87,7 +87,7 @@ function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
|
||||
};
|
||||
|
||||
const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
|
||||
prefetch(menu.path);
|
||||
schedulePrefetch(menu.children?.[0]?.path ?? menu.path);
|
||||
|
||||
if (!preferences.sidebar.expandOnHover) {
|
||||
const { findMenu } = findRootMenuByPath(menus.value, menu.path);
|
||||
|
||||
@@ -9,8 +9,12 @@ import { findRootMenuByPath } from '@easyflow/utils';
|
||||
|
||||
import { useNavigation } from './use-navigation';
|
||||
|
||||
const MAX_AUTOMATIC_PREFETCH_COUNT = 3;
|
||||
const PREFETCH_INTERVAL = 120;
|
||||
|
||||
function useMixedMenu() {
|
||||
const { navigation, prefetch, willOpenedByWindow } = useNavigation();
|
||||
const { navigation, prefetch, schedulePrefetch, willOpenedByWindow } =
|
||||
useNavigation();
|
||||
const accessStore = useAccessStore();
|
||||
const route = useRoute();
|
||||
const splitSideMenus = ref<MenuRecordRaw[]>([]);
|
||||
@@ -97,9 +101,11 @@ function useMixedMenu() {
|
||||
if (!willOpenedByWindow(key)) {
|
||||
rootMenuPath.value = rootMenu?.path ?? '';
|
||||
splitSideMenus.value = _splitSideMenus;
|
||||
_splitSideMenus.forEach((menu) => {
|
||||
_splitSideMenus
|
||||
.slice(0, MAX_AUTOMATIC_PREFETCH_COUNT)
|
||||
.forEach((menu, index) => {
|
||||
if (menu.path) {
|
||||
prefetch(menu.path);
|
||||
schedulePrefetch(menu.path, PREFETCH_INTERVAL * (index + 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -130,6 +136,10 @@ function useMixedMenu() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuEnter = (key: string) => {
|
||||
schedulePrefetch(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算侧边菜单
|
||||
* @param path 路由路径
|
||||
@@ -166,6 +176,7 @@ function useMixedMenu() {
|
||||
});
|
||||
|
||||
return {
|
||||
handleMenuEnter,
|
||||
handleMenuSelect,
|
||||
handleMenuOpen,
|
||||
headerActive,
|
||||
|
||||
@@ -4,11 +4,37 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import { isHttpUrl, openRouteInNewWindow, openWindow } from '@easyflow/utils';
|
||||
|
||||
interface NetworkConnectionLike {
|
||||
effectiveType?: string;
|
||||
saveData?: boolean;
|
||||
}
|
||||
|
||||
function canSpeculativelyPrefetch() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const nav = navigator as Navigator & {
|
||||
connection?: NetworkConnectionLike;
|
||||
mozConnection?: NetworkConnectionLike;
|
||||
webkitConnection?: NetworkConnectionLike;
|
||||
};
|
||||
const connection =
|
||||
nav.connection ?? nav.mozConnection ?? nav.webkitConnection;
|
||||
if (!connection) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!connection.saveData &&
|
||||
!['2g', '3g', 'slow-2g'].includes(connection.effectiveType ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
function useNavigation() {
|
||||
const router = useRouter();
|
||||
const routeMetaMap = new Map<string, RouteRecordNormalized>();
|
||||
const prefetchedPaths = new Set<string>();
|
||||
const prefetchingPaths = new Set<string>();
|
||||
const scheduledPrefetches = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// 初始化路由映射
|
||||
const initRouteMetaMap = () => {
|
||||
@@ -40,6 +66,12 @@ function useNavigation() {
|
||||
};
|
||||
|
||||
const prefetch = (path: string) => {
|
||||
const scheduledPrefetch = scheduledPrefetches.get(path);
|
||||
if (scheduledPrefetch) {
|
||||
clearTimeout(scheduledPrefetch);
|
||||
scheduledPrefetches.delete(path);
|
||||
}
|
||||
|
||||
if (
|
||||
isHttpUrl(path) ||
|
||||
prefetchedPaths.has(path) ||
|
||||
@@ -84,6 +116,24 @@ function useNavigation() {
|
||||
});
|
||||
};
|
||||
|
||||
const schedulePrefetch = (path: string, delay = 120) => {
|
||||
if (
|
||||
!canSpeculativelyPrefetch() ||
|
||||
isHttpUrl(path) ||
|
||||
prefetchedPaths.has(path) ||
|
||||
prefetchingPaths.has(path) ||
|
||||
scheduledPrefetches.has(path)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
scheduledPrefetches.delete(path);
|
||||
prefetch(path);
|
||||
}, delay);
|
||||
scheduledPrefetches.set(path, timer);
|
||||
};
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
try {
|
||||
const route = routeMetaMap.get(path);
|
||||
@@ -116,7 +166,7 @@ function useNavigation() {
|
||||
return shouldOpenInNewWindow(path);
|
||||
};
|
||||
|
||||
return { navigation, prefetch, willOpenedByWindow };
|
||||
return { navigation, prefetch, schedulePrefetch, willOpenedByWindow };
|
||||
}
|
||||
|
||||
export { useNavigation };
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"types": "./src/echarts/index.ts",
|
||||
"default": "./src/echarts/index.ts"
|
||||
},
|
||||
"./echarts/workspace": {
|
||||
"types": "./src/echarts/workspace.ts",
|
||||
"default": "./src/echarts/workspace.ts"
|
||||
},
|
||||
"./vxe-table": {
|
||||
"types": "./src/vxe-table/index.ts",
|
||||
"default": "./src/vxe-table/index.ts"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type { EChartsType } from 'echarts/core';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { Nullable } from '@easyflow/types';
|
||||
|
||||
import type EchartsUI from './echarts-ui.vue';
|
||||
|
||||
import { computed, nextTick, watch } from 'vue';
|
||||
|
||||
import { usePreferences } from '@easyflow/preferences';
|
||||
|
||||
import {
|
||||
tryOnUnmounted,
|
||||
useDebounceFn,
|
||||
useResizeObserver,
|
||||
useTimeoutFn,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core';
|
||||
|
||||
type EchartsUIType = typeof EchartsUI | undefined;
|
||||
|
||||
interface EchartsEngine {
|
||||
init: (element: HTMLElement) => EChartsType;
|
||||
}
|
||||
|
||||
function createUseEcharts(echarts: EchartsEngine) {
|
||||
return function useEcharts(chartRef: Ref<EchartsUIType>) {
|
||||
let chartInstance: EChartsType | null = null;
|
||||
let cacheOptions: EChartsOption = {};
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { height, width } = useWindowSize();
|
||||
const resizeHandler: () => void = useDebounceFn(resize, 200);
|
||||
|
||||
const getChartEl = (): HTMLElement | null => {
|
||||
const refValue = chartRef?.value as unknown;
|
||||
if (!refValue) return null;
|
||||
if (refValue instanceof HTMLElement) {
|
||||
return refValue;
|
||||
}
|
||||
const maybeComponent = refValue as { $el?: HTMLElement };
|
||||
return maybeComponent.$el ?? null;
|
||||
};
|
||||
|
||||
const isElHidden = (el: HTMLElement | null): boolean => {
|
||||
if (!el) return true;
|
||||
return el.offsetHeight === 0 || el.offsetWidth === 0;
|
||||
};
|
||||
|
||||
const getOptions = computed((): EChartsOption => {
|
||||
if (!isDark.value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
};
|
||||
});
|
||||
|
||||
const initCharts = () => {
|
||||
const el = chartRef?.value?.$el;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
chartInstance = echarts.init(el);
|
||||
|
||||
return chartInstance;
|
||||
};
|
||||
|
||||
const renderEcharts = (
|
||||
options: EChartsOption,
|
||||
clear = true,
|
||||
): Promise<Nullable<EChartsType>> => {
|
||||
cacheOptions = options;
|
||||
const currentOptions = {
|
||||
...options,
|
||||
...getOptions.value,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
if (chartRef.value?.offsetHeight === 0) {
|
||||
useTimeoutFn(async () => {
|
||||
resolve(await renderEcharts(currentOptions));
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
useTimeoutFn(async () => {
|
||||
resolve(await renderEcharts(currentOptions));
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
useTimeoutFn(() => {
|
||||
if (!chartInstance) {
|
||||
const instance = initCharts();
|
||||
if (!instance) return;
|
||||
}
|
||||
clear && chartInstance?.clear();
|
||||
chartInstance?.setOption(currentOptions);
|
||||
resolve(chartInstance);
|
||||
}, 30);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function resize() {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
return;
|
||||
}
|
||||
chartInstance?.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'quadraticIn',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch([width, height], () => {
|
||||
resizeHandler?.();
|
||||
});
|
||||
|
||||
useResizeObserver(chartRef as never, resizeHandler);
|
||||
|
||||
watch(isDark, () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
initCharts();
|
||||
renderEcharts(cacheOptions);
|
||||
resize();
|
||||
}
|
||||
});
|
||||
|
||||
tryOnUnmounted(() => {
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
return {
|
||||
renderEcharts,
|
||||
resize,
|
||||
getChartInstance: () => chartInstance,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export { createUseEcharts };
|
||||
export type { EchartsUIType };
|
||||
@@ -1,146 +1,7 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { Nullable } from '@easyflow/types';
|
||||
|
||||
import type EchartsUI from './echarts-ui.vue';
|
||||
|
||||
import { computed, nextTick, watch } from 'vue';
|
||||
|
||||
import { usePreferences } from '@easyflow/preferences';
|
||||
|
||||
import {
|
||||
tryOnUnmounted,
|
||||
useDebounceFn,
|
||||
useResizeObserver,
|
||||
useTimeoutFn,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core';
|
||||
|
||||
import echarts from './echarts';
|
||||
import { createUseEcharts } from './use-echarts-base';
|
||||
|
||||
type EchartsUIType = typeof EchartsUI | undefined;
|
||||
|
||||
function useEcharts(chartRef: Ref<EchartsUIType>) {
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
let cacheOptions: EChartsOption = {};
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { height, width } = useWindowSize();
|
||||
const resizeHandler: () => void = useDebounceFn(resize, 200);
|
||||
|
||||
const getChartEl = (): HTMLElement | null => {
|
||||
const refValue = chartRef?.value as unknown;
|
||||
if (!refValue) return null;
|
||||
if (refValue instanceof HTMLElement) {
|
||||
return refValue;
|
||||
}
|
||||
const maybeComponent = refValue as { $el?: HTMLElement };
|
||||
return maybeComponent.$el ?? null;
|
||||
};
|
||||
|
||||
const isElHidden = (el: HTMLElement | null): boolean => {
|
||||
if (!el) return true;
|
||||
return el.offsetHeight === 0 || el.offsetWidth === 0;
|
||||
};
|
||||
|
||||
const getOptions = computed((): EChartsOption => {
|
||||
if (!isDark.value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
};
|
||||
});
|
||||
|
||||
const initCharts = () => {
|
||||
const el = chartRef?.value?.$el;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
chartInstance = echarts.init(el);
|
||||
|
||||
return chartInstance;
|
||||
};
|
||||
|
||||
const renderEcharts = (
|
||||
options: EChartsOption,
|
||||
clear = true,
|
||||
): Promise<Nullable<echarts.ECharts>> => {
|
||||
cacheOptions = options;
|
||||
const currentOptions = {
|
||||
...options,
|
||||
...getOptions.value,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
if (chartRef.value?.offsetHeight === 0) {
|
||||
useTimeoutFn(async () => {
|
||||
resolve(await renderEcharts(currentOptions));
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
useTimeoutFn(async () => {
|
||||
resolve(await renderEcharts(currentOptions));
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
useTimeoutFn(() => {
|
||||
if (!chartInstance) {
|
||||
const instance = initCharts();
|
||||
if (!instance) return;
|
||||
}
|
||||
clear && chartInstance?.clear();
|
||||
chartInstance?.setOption(currentOptions);
|
||||
resolve(chartInstance);
|
||||
}, 30);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function resize() {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
return;
|
||||
}
|
||||
chartInstance?.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'quadraticIn',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch([width, height], () => {
|
||||
resizeHandler?.();
|
||||
});
|
||||
|
||||
useResizeObserver(chartRef as never, resizeHandler);
|
||||
|
||||
watch(isDark, () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
initCharts();
|
||||
renderEcharts(cacheOptions);
|
||||
resize();
|
||||
}
|
||||
});
|
||||
|
||||
tryOnUnmounted(() => {
|
||||
// 销毁实例,释放资源
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
return {
|
||||
renderEcharts,
|
||||
resize,
|
||||
getChartInstance: () => chartInstance,
|
||||
};
|
||||
}
|
||||
const useEcharts = createUseEcharts(echarts);
|
||||
|
||||
export { useEcharts };
|
||||
|
||||
export type { EchartsUIType };
|
||||
export type { EchartsUIType } from './use-echarts-base';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { LineChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
echarts.use([
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
LabelLayout,
|
||||
UniversalTransition,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
export default echarts;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createUseEcharts } from './use-echarts-base';
|
||||
import workspaceEcharts from './workspace-echarts';
|
||||
|
||||
const useEcharts = createUseEcharts(workspaceEcharts);
|
||||
|
||||
export { default as EchartsUI } from './echarts-ui.vue';
|
||||
export { useEcharts };
|
||||
export type { EchartsUIType } from './use-echarts-base';
|
||||
@@ -22,7 +22,7 @@ RUN rm -f /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/app/dist/ /usr/share/nginx/html/flow/
|
||||
COPY scripts/deploy/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
RUN nginx -t && chown -R nginx:nginx /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
@@ -12,6 +12,22 @@ http {
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 500m;
|
||||
|
||||
gzip on;
|
||||
gzip_static on;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/wasm
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
@@ -90,9 +106,26 @@ http {
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
location ^~ /flow/ {
|
||||
location = /flow/index.html {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
}
|
||||
|
||||
location = /flow/_app.config.js {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
}
|
||||
|
||||
location ~* ^/flow/(?:avif|css|eot|gif|ico|jpeg|jpg|js|jse|png|svg|ttf|wasm|webp|woff|woff2)/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
location /flow/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /flow/index.html;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user