diff --git a/.changeset/bright-cameras-matter.md b/.changeset/bright-cameras-matter.md new file mode 100644 index 0000000000..b58c9caa23 --- /dev/null +++ b/.changeset/bright-cameras-matter.md @@ -0,0 +1,6 @@ +--- +"@scow/lib-web": patch +"@scow/docs": patch +--- + +UI扩展增加导航栏链接自动刷新功能 diff --git a/docs/docs/integration/ui-extension/develop.md b/docs/docs/integration/ui-extension/develop.md index 6fa6f70cc8..dc57250c4f 100644 --- a/docs/docs/integration/ui-extension/develop.md +++ b/docs/docs/integration/ui-extension/develop.md @@ -41,16 +41,19 @@ UI扩展的功能应实现为标准的网页。当访问SCOW的扩展路径时 对于此接口,您需要返回如下类型的JSON内容: -| JSON属性路径 | 类型 | 是否必须 | 解释 | -| --------------------------- | ------ | -------- | --------------------------------------------------- | -| `portal` | 对象 | 否 | 关于门户系统的配置 | -| `portal.rewriteNavigations` | 布尔值 | 否 | 是否重写门户系统的导航项。默认为`false` | -| `portal.navbarLinks` | 布尔值 | 否 | 是否在门户系统中增加导航栏右侧的链接。默认为`false` | -| `mis` | 对象 | 否 | 关于管理系统的配置 | -| `mis.rewriteNavigations` | 布尔值 | 否 | 是否重写管理系统的导航项。默认为`false` | -| `mis.navbarLinks` | 布尔值 | 否 | 是否在管理系统中增加导航栏右侧的链接。默认为`false` | +| JSON属性路径 | 类型 | 是否必须 | 解释 | +| ------------------------------------------- | -------------- | -------- | ------------------------------------------------------ | +| `portal` | 对象 | 否 | 关于门户系统的配置 | +| `portal.rewriteNavigations` | 布尔值 | 否 | 是否重写门户系统的导航项。默认为`false` | +| `portal.navbarLinks` | 布尔值或者对象 | 否 | 是否在门户系统中增加导航栏右侧的链接。默认为`false` | +| `portal.navbarLinks.enabled` | 布尔值 | 否 | 是否在管理系统中增加导航栏右侧的链接。默认为`false` | +| `portal.navbarLinks.autoRefresh` | 对象 | 否 | 是否定时自动刷新导航栏右侧的链接。不设置为不定时刷新。 | +| `portal.navbarLinks.autoRefresh.enabled` | 布尔值 | 否 | 是否定时自动刷新导航栏右侧的链接。默认为`false` | +| `portal.navbarLinks.autoRefresh.intervalMs` | 数字 | 否 | 定时刷新导航栏右侧的链接的间隔,单位`ms` | -例如,您可以返回如下类型的JSON,表示在门户系统中重写导航项并增加导航栏右侧的链接,在管理系统中不重写管理系统的导航项,也不增加导航栏右侧的链接。 +将路径中的`portal`改为`mis`即设置管理系统的配置,门户系统和管理系统的配置完全一致。 + +例如,您可以返回如下类型的JSON,表示在门户系统中重写导航项并增加导航栏右侧的链接,在管理系统中不重写管理系统的导航项,增加导航栏右侧的链接,并且开启自动刷新,每5s自动刷新一次。 ```json { @@ -60,6 +63,13 @@ UI扩展的功能应实现为标准的网页。当访问SCOW的扩展路径时 }, "mis": { "rewriteNavigations": false, + "navbarLinks": { + "enabled": true, + "autoRefresh": { + "enabled": true, + "intervalMs": 5000, + } + } } } ``` @@ -161,6 +171,7 @@ SCOW在调用接口时,会将[上下文参数](#上下文参数)作为查询 #### 其他注意事项 - 当右上角导航栏链接数量**大于等于5个**,或者屏幕宽度小于**768px**时,所有导航栏链接将会仅显示图标。 +- 如果您开启了导航栏链接自动刷新功能,并希望每次都重新加载图标,请确保每次返回的图标的链接要有变化,否则浏览器缓存将不会重新刷新图标。 ## 扩展消息 diff --git a/libs/web/src/extensions/ExtensionPage.tsx b/libs/web/src/extensions/ExtensionPage.tsx index c581417cbf..96e835ac2a 100644 --- a/libs/web/src/extensions/ExtensionPage.tsx +++ b/libs/web/src/extensions/ExtensionPage.tsx @@ -14,6 +14,7 @@ import { joinWithUrl } from "@scow/utils"; import { useRouter } from "next/router"; import React, { useEffect, useRef } from "react"; import { Head } from "src/components/head"; +import { getExtensionRouteQuery } from "src/extensions/common"; import { extensionEvents } from "src/extensions/events"; import { ExtensionManifestWithUrl, UiExtensionStoreData } from "src/extensions/UiExtensionStore"; import { UserInfo } from "src/layouts/base/types"; @@ -79,17 +80,12 @@ export const ExtensionPage: React.FC = ({ const darkMode = useDarkMode(); - const query = new URLSearchParams( - Object.fromEntries(Object.entries(rest).filter(([_, val]) => typeof val === "string")) as Record, - ); - - if (user) { - query.set("scowUserToken", user.token); - } - - query.set("scowDark", darkMode.dark ? "true" : "false"); + const extensionQuery = getExtensionRouteQuery(darkMode.dark, currentLanguageId, user?.token); - query.set("scowLangId", currentLanguageId); + const query = new URLSearchParams({ + ...Object.fromEntries(Object.entries(rest).filter(([_, val]) => typeof val === "string")), + ...extensionQuery, + }); const url = joinWithUrl(config.url, "extensions", ...pathParts) + "?" + query.toString(); diff --git a/libs/web/src/extensions/UiExtensionStore.ts b/libs/web/src/extensions/UiExtensionStore.ts index 6603474453..b209fc14f5 100644 --- a/libs/web/src/extensions/UiExtensionStore.ts +++ b/libs/web/src/extensions/UiExtensionStore.ts @@ -13,7 +13,7 @@ import { UiExtensionConfigSchema } from "@scow/config/build/uiExtensions"; import { useCallback } from "react"; import { useAsync } from "react-async"; -import { fetchExtensionManifests } from "src/extensions/manifests"; +import { ExtensionManifestsSchema, fetchExtensionManifests } from "src/extensions/manifests"; const fetchManifestsWithErrorHandling = (url: string, name?: string): Promise => fetchExtensionManifests(url) @@ -21,7 +21,8 @@ const fetchManifestsWithErrorHandling = (url: string, name?: string): Promise { console.error(`Error fetching extension manifests. ${e}`); return undefined; }); export interface ExtensionManifestWithUrl { - url: string; manifests: Awaited> + url: string; + manifests: ExtensionManifestsSchema; name?: string; } diff --git a/libs/web/src/extensions/common.ts b/libs/web/src/extensions/common.ts index 06d7e13124..74310f1bc9 100644 --- a/libs/web/src/extensions/common.ts +++ b/libs/web/src/extensions/common.ts @@ -12,13 +12,13 @@ import { z } from "zod"; -export const ScowExtensionRouteContext = z.object({ +export const ExtensionRouteQuery = z.object({ scowUserToken: z.string().optional(), scowDark: z.enum(["true", "false"]), scowLangId: z.string(), }); -export type ScowExtensionRouteContext = z.infer; +export type ExtensionRouteQuery = z.infer; export function isUrl(input: string): boolean { try { @@ -29,3 +29,8 @@ export function isUrl(input: string): boolean { } } +export const getExtensionRouteQuery = (dark: boolean, languageId: string, userToken?: string) => ({ + scowDark: dark ? "true" : "false", + scowLangId: languageId, + ...userToken ? { scowUserToken: userToken } : {}, +}) as ExtensionRouteQuery; diff --git a/libs/web/src/extensions/manifests.ts b/libs/web/src/extensions/manifests.ts index 6477e7321b..61ac746138 100644 --- a/libs/web/src/extensions/manifests.ts +++ b/libs/web/src/extensions/manifests.ts @@ -15,7 +15,18 @@ import { z } from "zod"; export const CommonExtensionManifestsSchema = z.object({ rewriteNavigations: z.boolean().default(false), - navbarLinks: z.boolean().default(false), + + navbarLinks: z.union([ + z.boolean(), + z.object({ + enabled: z.boolean().default(false), + autoRefresh: z.optional(z.object({ + enabled: z.boolean().default(false), + intervalMs: z.number(), + })), + }), + ]).default(false), + }); export const ExtensionManifestsSchema = z.object({ diff --git a/libs/web/src/extensions/navbarLinks.tsx b/libs/web/src/extensions/navbarLinks.tsx index 1854247807..72bb78e4bb 100644 --- a/libs/web/src/extensions/navbarLinks.tsx +++ b/libs/web/src/extensions/navbarLinks.tsx @@ -10,7 +10,7 @@ * See the Mulan PSL v2 for more details. */ -import { ScowExtensionRouteContext } from "src/extensions/common"; +import { ExtensionRouteQuery } from "src/extensions/common"; import { defineExtensionRoute } from "src/extensions/routes"; import { z } from "zod"; @@ -23,6 +23,9 @@ export const NavbarLink = z.object({ })), openInNewPage: z.boolean().default(true), priority: z.number().default(0), + autoRefresh: z.optional(z.object({ + intervalMs: z.number(), + })), }); export type NavbarLink = z.infer; @@ -30,7 +33,7 @@ export type NavbarLink = z.infer; export const navbarLinksRoute = (from: "portal" | "mis") => defineExtensionRoute({ path: `/${from}/navbarLinks`, method: "POST" as const, - query: ScowExtensionRouteContext, + query: ExtensionRouteQuery, responses: { 200: z.object({ navbarLinks: z.optional(z.array(NavbarLink)), diff --git a/libs/web/src/extensions/navigations.tsx b/libs/web/src/extensions/navigations.tsx index 500179930d..377178375c 100644 --- a/libs/web/src/extensions/navigations.tsx +++ b/libs/web/src/extensions/navigations.tsx @@ -12,7 +12,7 @@ import { LinkOutlined } from "@ant-design/icons"; import { join } from "path"; -import { isUrl, ScowExtensionRouteContext } from "src/extensions/common"; +import { ExtensionRouteQuery,isUrl } from "src/extensions/common"; import { defineExtensionRoute } from "src/extensions/routes"; import { NavItemProps } from "src/layouts/base/types"; import { NavIcon } from "src/layouts/icon"; @@ -40,7 +40,7 @@ export const NavItem = BaseNavItem.extend({ export const rewriteNavigationsRoute = (from: "portal" | "mis") => defineExtensionRoute({ path: `/${from}/rewriteNavigations`, method: "POST" as const, - query: ScowExtensionRouteContext, + query: ExtensionRouteQuery, body: z.object({ navs: z.array(NavItem) as z.ZodType, }), diff --git a/libs/web/src/layouts/base/BaseLayout.tsx b/libs/web/src/layouts/base/BaseLayout.tsx index 0cfedf8bc8..dfc85356b4 100644 --- a/libs/web/src/layouts/base/BaseLayout.tsx +++ b/libs/web/src/layouts/base/BaseLayout.tsx @@ -12,15 +12,12 @@ "use client"; -import { LinkOutlined } from "@ant-design/icons"; import { arrayContainsElement } from "@scow/utils"; import { Grid, Layout } from "antd"; import { useRouter } from "next/router"; -import { join } from "path"; import React, { PropsWithChildren, useCallback, useMemo, useState } from "react"; import { useAsync } from "react-async"; -import { isUrl, ScowExtensionRouteContext } from "src/extensions/common"; -import { NavbarLink, navbarLinksRoute } from "src/extensions/navbarLinks"; +import { getExtensionRouteQuery } from "src/extensions/common"; import { fromNavItemProps, rewriteNavigationsRoute, toNavItemProps } from "src/extensions/navigations"; import { callExtensionRoute } from "src/extensions/routes"; import { UiExtensionStoreData } from "src/extensions/UiExtensionStore"; @@ -30,7 +27,6 @@ import { Header, HeaderNavbarLink } from "src/layouts/base/header"; import { SideNav } from "src/layouts/base/SideNav"; import { NavItemProps, UserInfo, UserLink } from "src/layouts/base/types"; import { useDarkMode } from "src/layouts/darkMode"; -import { NavIcon } from "src/layouts/icon"; import { styled } from "styled-components"; // import logo from "src/assets/logo-no-text.svg"; const { useBreakpoint } = Grid; @@ -93,11 +89,11 @@ export const BaseLayout: React.FC> = ({ ? extensionStoreData : extensionStoreData ? [extensionStoreData] : [], [extensionStoreData]); - const routeQuery = useMemo(() => ({ - scowDark: dark.dark ? "true" : "false", - scowLangId: languageId, - scowUserToken: user?.token, - }) as ScowExtensionRouteContext, [dark.dark, languageId, user?.token]); + const routeQuery = useMemo(() => getExtensionRouteQuery( + dark.dark, + languageId, + user?.token, + ), [dark.dark, languageId, user?.token]); const { data: finalRoutesData } = useAsync({ promiseFn: useCallback(async () => { @@ -138,57 +134,11 @@ export const BaseLayout: React.FC> = ({ const hasSidebar = arrayContainsElement(sidebarRoutes); - - // navbar links - const { data: extensionNavbarLinks } = useAsync({ - promiseFn: useCallback(async () => { - if (extensions.length === 0) { return undefined; } - - const result = await Promise.all(extensions.map(async (extension) => { - const resp = await callExtensionRoute(navbarLinksRoute(from), routeQuery, {}, extension.url) - .catch((e) => { - console.warn(`Failed to call navbarLinks of extension ${extension.name ?? extension.url}. Error: `, e); - return { 200: { navbarLinks: [] as NavbarLink[] } }; - }); - - if (resp[200]) { - return resp[200].navbarLinks?.map((x) => { - - if (!isUrl(x.path)) { - const parts = ["/extensions"]; - - if (extension.name) { - parts.push(extension.name); - } - - parts.push(x.path); - x.path = join(...parts); - } - - return x; - }); - } - })); - - const filtered = result.flat().filter((x) => x) as NavbarLink[]; - - // order by priority and index. sort is stable, index is preserved - filtered.sort((a, b) => { - return b.priority - a.priority; - }); - - return filtered.map((x) => ({ - href: x.path, - text: x.text, - icon: x.icon ? : , - }satisfies HeaderNavbarLink)); - - }, [from, routeQuery, extensions]), - }); - return (
> = ({ userLinks={userLinks} languageId={languageId} right={headerRightContent} - navbarLinks={[...extensionNavbarLinks ?? [], ...headerNavbarLinks ?? []]} + staticNavbarLinks={headerNavbarLinks} + from={from} activeKeys={activeKeys} /> diff --git a/libs/web/src/layouts/base/header/BigScreenMenu.tsx b/libs/web/src/layouts/base/header/BigScreenMenu.tsx index be1784e344..9e31f9733f 100644 --- a/libs/web/src/layouts/base/header/BigScreenMenu.tsx +++ b/libs/web/src/layouts/base/header/BigScreenMenu.tsx @@ -45,7 +45,7 @@ interface Props { } export const BigScreenMenu: React.FC = ({ - routes, className, pathname, activeKeys, + routes, className, activeKeys, }) => { const router = useRouter(); diff --git a/libs/web/src/layouts/base/header/index.tsx b/libs/web/src/layouts/base/header/index.tsx index 0742baa3c0..625e884201 100644 --- a/libs/web/src/layouts/base/header/index.tsx +++ b/libs/web/src/layouts/base/header/index.tsx @@ -10,15 +10,22 @@ * See the Mulan PSL v2 for more details. */ -import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; +import { LinkOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; import { Space } from "antd"; -import React from "react"; +import { join } from "path"; +import React, { useCallback, useState } from "react"; +import { useAsync } from "react-async"; +import { ExtensionRouteQuery, isUrl } from "src/extensions/common"; +import { NavbarLink, navbarLinksRoute } from "src/extensions/navbarLinks"; +import { callExtensionRoute } from "src/extensions/routes"; +import { ExtensionManifestWithUrl } from "src/extensions/UiExtensionStore"; import { antdBreakpoints } from "src/layouts/base/constants"; import { BigScreenMenu } from "src/layouts/base/header/BigScreenMenu"; import { HeaderItem, JumpToAnotherLink } from "src/layouts/base/header/components"; import { Logo } from "src/layouts/base/header/Logo"; import { UserIndicator } from "src/layouts/base/header/UserIndicator"; import { NavItemProps, UserInfo, UserLink } from "src/layouts/base/types"; +import { NavIcon } from "src/layouts/icon"; import { styled } from "styled-components"; interface ComponentProps { @@ -85,10 +92,18 @@ interface Props { userLinks?: UserLink[]; languageId: string; right?: React.ReactNode; - navbarLinks?: HeaderNavbarLink[]; + staticNavbarLinks?: HeaderNavbarLink[]; + extensions: ExtensionManifestWithUrl[]; + from: "mis" | "portal"; + routeQuery: ExtensionRouteQuery; activeKeys: string[]; } +interface SourcedHeaderNavbarLink { + link: HeaderNavbarLink; + extension: ExtensionManifestWithUrl; + priority: number; +}; export const Header: React.FC = ({ hasSidebar, routes, @@ -96,9 +111,37 @@ export const Header: React.FC = ({ pathname, user, logout, basePath, userLinks, languageId, activeKeys, - navbarLinks, right, + right, staticNavbarLinks, + extensions, + from, routeQuery, }) => { + const [links, setLinks] = useState([]); + + const onFetched = (extension: ExtensionManifestWithUrl) => (data: NavbarLink[]) => { + setLinks((links) => { + + // remove all existing links from the same extension + links = links.filter((x) => x.extension !== extension); + + // append newly got links + links.push(...data.map((x) => ({ link: { + href: x.path, + text: x.text, + icon: x.icon ? : , + }, extension, priority: x.priority }))); + + // order by priority and index. sort is stable, index is preserved + links.sort((a, b) => { + return b.priority - a.priority; + }); + + return links; + }); + }; + + const navbarLinks = [...links.map((x) => x.link), ...(staticNavbarLinks ?? [])]; + const hideLinkText = navbarLinks && navbarLinks.length >= 5; const navbarLinkComponents = navbarLinks?.map((x, i) => { @@ -117,6 +160,23 @@ export const Header: React.FC = ({ return ( + {extensions.map((extension) => { + const navbarLinksConfig = extension.manifests[from]?.navbarLinks; + + if (navbarLinksConfig === true || (typeof navbarLinksConfig === "object" && navbarLinksConfig?.enabled)) { + return ( + + ); + } else { + return undefined; + } + })} {hasSidebar @@ -150,3 +210,52 @@ export const Header: React.FC = ({ ); }; + + +interface FetcherProps { + extension: ExtensionManifestWithUrl; + from: "mis" | "portal"; + routeQuery: ExtensionRouteQuery; + onDataFetched: (links: NavbarLink[]) => void; +} + +const NavbarLinkFetcher = ({ extension, from, routeQuery, onDataFetched }: FetcherProps) => { + + const { reload } = useAsync({ + promiseFn: useCallback(async () => { + const resp = await callExtensionRoute(navbarLinksRoute(from), routeQuery, {}, extension.url) + .catch((e) => { + console.warn(`Failed to call navbarLinks of extension ${extension.name ?? extension.url}. Error: `, e); + return { 200: { navbarLinks: [] as NavbarLink[] } }; + }); + + const data = resp[200]?.navbarLinks?.map((x) => { + + if (!isUrl(x.path)) { + const parts = ["/extensions"]; + + if (extension.name) { + parts.push(extension.name); + } + + parts.push(x.path); + x.path = join(...parts); + } + + return x; + }); + + onDataFetched(data ?? []); + + const navbarLinksConfig = extension.manifests[from]?.navbarLinks; + + if (typeof navbarLinksConfig === "object" + && navbarLinksConfig?.enabled && navbarLinksConfig.autoRefresh?.enabled) { + setTimeout(reload, navbarLinksConfig.autoRefresh.intervalMs); + } + + }, [from, routeQuery, extension]), + }); + + return <>; +};