Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): UI扩展增加导航栏链接自动刷新 #1391

Merged
merged 7 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-cameras-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@scow/lib-web": patch
"@scow/docs": patch
---

UI扩展增加导航栏链接自动刷新功能
29 changes: 20 additions & 9 deletions docs/docs/integration/ui-extension/develop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -60,6 +63,13 @@ UI扩展的功能应实现为标准的网页。当访问SCOW的扩展路径时
},
"mis": {
"rewriteNavigations": false,
"navbarLinks": {
"enabled": true,
"autoRefresh": {
"enabled": true,
"intervalMs": 5000,
}
}
}
}
```
Expand Down Expand Up @@ -161,6 +171,7 @@ SCOW在调用接口时,会将[上下文参数](#上下文参数)作为查询
#### 其他注意事项

- 当右上角导航栏链接数量**大于等于5个**,或者屏幕宽度小于**768px**时,所有导航栏链接将会仅显示图标。
- 如果您开启了导航栏链接自动刷新功能,并希望每次都重新加载图标,请确保每次返回的图标的链接要有变化,否则浏览器缓存将不会重新刷新图标。

## 扩展消息

Expand Down
16 changes: 6 additions & 10 deletions libs/web/src/extensions/ExtensionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -79,17 +80,12 @@ export const ExtensionPage: React.FC<Props> = ({

const darkMode = useDarkMode();

const query = new URLSearchParams(
Object.fromEntries(Object.entries(rest).filter(([_, val]) => typeof val === "string")) as Record<string, string>,
);

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();
Expand Down
5 changes: 3 additions & 2 deletions libs/web/src/extensions/UiExtensionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@
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<ExtensionManifestWithUrl | undefined> =>
fetchExtensionManifests(url)
.then((x) => ({ url, manifests: x, name }))
.catch((e) => { console.error(`Error fetching extension manifests. ${e}`); return undefined; });

export interface ExtensionManifestWithUrl {
url: string; manifests: Awaited<ReturnType<typeof fetchExtensionManifests>>
url: string;
manifests: ExtensionManifestsSchema;
name?: string;
}

Expand Down
9 changes: 7 additions & 2 deletions libs/web/src/extensions/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ScowExtensionRouteContext>;
export type ExtensionRouteQuery = z.infer<typeof ExtensionRouteQuery>;

export function isUrl(input: string): boolean {
try {
Expand All @@ -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;
13 changes: 12 additions & 1 deletion libs/web/src/extensions/manifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 5 additions & 2 deletions libs/web/src/extensions/navbarLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -23,14 +23,17 @@ 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<typeof NavbarLink>;

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)),
Expand Down
4 changes: 2 additions & 2 deletions libs/web/src/extensions/navigations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<NavItem[]>,
}),
Expand Down
69 changes: 10 additions & 59 deletions libs/web/src/layouts/base/BaseLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -93,11 +89,11 @@ export const BaseLayout: React.FC<PropsWithChildren<Props>> = ({
? 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 () => {
Expand Down Expand Up @@ -138,57 +134,11 @@ export const BaseLayout: React.FC<PropsWithChildren<Props>> = ({

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 ? <NavIcon src={x.icon.src} alt={x.icon.alt ?? ""} /> : <LinkOutlined />,
}satisfies HeaderNavbarLink));

}, [from, routeQuery, extensions]),
});

return (
<Root>
<Header
extensions={extensions}
routeQuery={routeQuery}
setSidebarCollapsed={setSidebarCollapsed}
pathname={router.asPath}
sidebarCollapsed={sidebarCollapsed}
Expand All @@ -200,7 +150,8 @@ export const BaseLayout: React.FC<PropsWithChildren<Props>> = ({
userLinks={userLinks}
languageId={languageId}
right={headerRightContent}
navbarLinks={[...extensionNavbarLinks ?? [], ...headerNavbarLinks ?? []]}
staticNavbarLinks={headerNavbarLinks}
from={from}
activeKeys={activeKeys}
/>
<StyledLayout>
Expand Down
2 changes: 1 addition & 1 deletion libs/web/src/layouts/base/header/BigScreenMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ interface Props {
}

export const BigScreenMenu: React.FC<Props> = ({
routes, className, pathname, activeKeys,
routes, className, activeKeys,
}) => {

const router = useRouter();
Expand Down
Loading
Loading