diff --git a/public/locales/en-US/dashboard.json b/public/locales/en-US/dashboard.json
index 80d3bad..5d956e6 100644
--- a/public/locales/en-US/dashboard.json
+++ b/public/locales/en-US/dashboard.json
@@ -112,6 +112,11 @@
"retryDelayDes": "Initial delay time (seconds) for task retries."
},
"settings": {
+ "addNavItem": "Add navigation item",
+ "customNavItems": "Custom sidebar items",
+ "customNavItemsDes": "You can add custom items to the sidebar, and users will be redirected to the corresponding link when clicked.",
+ "navItemUrl": "Link",
+ "iconifyNamePlaceholder": "Iconify icon identifier, e.g. fluent:home-24-regular",
"imageUrl": "Image URL",
"iconifyName": "Iconify icon name",
"oidc": "OpenID Connect (OIDC)",
diff --git a/public/locales/ja-JP/dashboard.json b/public/locales/ja-JP/dashboard.json
index de0d1bc..88759a1 100644
--- a/public/locales/ja-JP/dashboard.json
+++ b/public/locales/ja-JP/dashboard.json
@@ -112,6 +112,11 @@
"retryDelayDes": "タスク再試行の初期遅延時間(秒)"
},
"settings": {
+ "addNavItem": "ナビゲーション項目を追加",
+ "customNavItems": "サイドナビゲーションバーのカスタマイズ",
+ "customNavItemsDes": "左側のナビゲーションバーにカスタム項目を追加できます。ユーザーがクリックすると、対応するリンクに移動します。",
+ "navItemUrl": "リンク",
+ "iconifyNamePlaceholder": "Iconify アイコン識別子(例:fluent:home-24-regular)",
"imageUrl": "画像 URL",
"iconifyName": "Iconify アイコン名",
"oidc": "OpenID Connect (OIDC)",
diff --git a/public/locales/zh-CN/dashboard.json b/public/locales/zh-CN/dashboard.json
index 914ae6e..5786cce 100644
--- a/public/locales/zh-CN/dashboard.json
+++ b/public/locales/zh-CN/dashboard.json
@@ -112,7 +112,11 @@
"retryDelayDes": "任务重试的初始延迟时间(秒)。"
},
"settings": {
- "imageUrl": "图片 URL",
+ "addNavItem": "添加导航条目",
+ "customNavItems": "自定义侧边导航栏",
+ "customNavItemsDes": "你可以在左侧导航栏中添加自定义的条目,用户点击后会跳转到对应的链接。",
+ "navItemUrl": "链接",
+ "iconifyNamePlaceholder": "Iconify 图标标识,比如:fluent:home-24-regular",
"iconifyName": "Iconify 图标名",
"oidc": "OpenID Connect (OIDC)",
"oidcDes": "OpenID Connect (OIDC) 是一种开放的认证协议,用于在不同的系统之间进行身份验证。在第三方身份平台中创建应用后,请将 <0>{{url}}0> 添加到 “重定向 URI” 中。详情请参考 <1>官方文档1>。",
diff --git a/public/locales/zh-TW/dashboard.json b/public/locales/zh-TW/dashboard.json
index 670b315..907abc8 100644
--- a/public/locales/zh-TW/dashboard.json
+++ b/public/locales/zh-TW/dashboard.json
@@ -112,6 +112,11 @@
"retryDelayDes": "任務重試的初始延遲時間(秒)。"
},
"settings": {
+ "addNavItem": "新增導航條目",
+ "customNavItems": "自定義側邊導航欄",
+ "customNavItemsDes": "你可以在側邊導航欄中新增自定義的條目,用戶點擊後會跳轉到對應的鏈接。",
+ "navItemUrl": "鏈接",
+ "iconifyNamePlaceholder": "Iconify 圖標標識,比如:fluent:home-24-regular",
"imageUrl": "圖片 URL",
"iconifyName": "Iconify 圖標名",
"oidc": "OpenID Connect (OIDC)",
diff --git a/src/api/site.ts b/src/api/site.ts
index 9b86f49..d329095 100644
--- a/src/api/site.ts
+++ b/src/api/site.ts
@@ -42,9 +42,16 @@ export interface SiteConfig {
thumbnail_width?: number;
thumbnail_height?: number;
custom_props?: CustomProps[];
+ custom_nav_items?: CustomNavItem[];
}
export interface CaptchaResponse {
ticket: string;
image: string;
}
+
+export interface CustomNavItem {
+ name: string;
+ url: string;
+ icon: string;
+}
diff --git a/src/component/Admin/Settings/Appearance/Appearance.tsx b/src/component/Admin/Settings/Appearance/Appearance.tsx
index 4827ee3..8d881ec 100644
--- a/src/component/Admin/Settings/Appearance/Appearance.tsx
+++ b/src/component/Admin/Settings/Appearance/Appearance.tsx
@@ -3,6 +3,7 @@ import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { SettingSection } from "../Settings";
import { SettingContext } from "../SettingWrapper";
+import CustomNavItems from "./CustomNavItems";
import ThemeOptions from "./ThemeOptions";
const Appearance = () => {
@@ -20,6 +21,12 @@ const Appearance = () => {
onDefaultThemeChange={(value: string) => setSettings({ defaultTheme: value })}
/>
+
+ setSettings({ custom_nav_items: value })}
+ />
+
);
diff --git a/src/component/Admin/Settings/Appearance/CustomNavItems.tsx b/src/component/Admin/Settings/Appearance/CustomNavItems.tsx
new file mode 100644
index 0000000..6aa863a
--- /dev/null
+++ b/src/component/Admin/Settings/Appearance/CustomNavItems.tsx
@@ -0,0 +1,378 @@
+import { Icon } from "@iconify/react/dist/iconify.js";
+import {
+ Box,
+ IconButton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+ useTheme,
+} from "@mui/material";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { DndProvider, useDrag, useDrop } from "react-dnd";
+import { HTML5Backend } from "react-dnd-html5-backend";
+import { useTranslation } from "react-i18next";
+import { CustomNavItem } from "../../../../api/site";
+import {
+ DenseFilledTextField,
+ NoWrapCell,
+ NoWrapTableCell,
+ SecondaryButton,
+ StyledTableContainerPaper,
+} from "../../../Common/StyledComponents";
+import Add from "../../../Icons/Add";
+import ArrowDown from "../../../Icons/ArrowDown";
+import Delete from "../../../Icons/Delete";
+
+export interface CustomNavItemsProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+const DND_TYPE = "custom-nav-item-row";
+
+// 拖拽item类型
+type DragItem = { index: number };
+
+interface DraggableNavItemRowProps {
+ item: CustomNavItem;
+ index: number;
+ moveRow: (from: number, to: number) => void;
+ onDelete: (index: number) => void;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ isFirst: boolean;
+ isLast: boolean;
+ inputCache: { [key: number]: { [field: string]: string | undefined } };
+ onInputChange: (index: number, field: string, value: string) => void;
+ onInputBlur: (index: number, field: keyof CustomNavItem) => void;
+ IconPreview: React.ComponentType<{ iconName: string }>;
+ t: any;
+ style?: React.CSSProperties;
+}
+
+const DraggableNavItemRow = React.memo(
+ React.forwardRef(
+ (
+ {
+ item,
+ index,
+ moveRow,
+ onDelete,
+ onMoveUp,
+ onMoveDown,
+ isFirst,
+ isLast,
+ inputCache,
+ onInputChange,
+ onInputBlur,
+ IconPreview,
+ t,
+ style,
+ },
+ ref,
+ ): JSX.Element => {
+ const [, drop] = useDrop({
+ accept: DND_TYPE,
+ hover(dragItem, monitor) {
+ if (!(ref && typeof ref !== "function" && ref.current)) return;
+ const dragIndex = dragItem.index;
+ const hoverIndex = index;
+ if (dragIndex === hoverIndex) return;
+ const hoverBoundingRect = ref.current.getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ if (!clientOffset) return;
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+ if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return;
+ if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return;
+ moveRow(dragIndex, hoverIndex);
+ dragItem.index = hoverIndex;
+ },
+ });
+ const [{ isDragging }, drag] = useDrag({
+ type: DND_TYPE,
+ item: { index },
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging(),
+ }),
+ });
+ // 兼容ref为function和对象
+ const setRowRef = (node: HTMLTableRowElement | null) => {
+ if (typeof ref === "function") {
+ ref(node);
+ } else if (ref) {
+ (ref as React.MutableRefObject).current = node;
+ }
+ drag(drop(node));
+ };
+ return (
+
+
+
+
+
+ onInputChange(index, "icon", e.target.value)}
+ onBlur={() => onInputBlur(index, "icon")}
+ placeholder={t("settings.iconifyNamePlaceholder")}
+ />
+
+
+ onInputChange(index, "name", e.target.value)}
+ onBlur={() => onInputBlur(index, "name")}
+ placeholder={t("settings.displayNameDes")}
+ />
+
+
+ onInputChange(index, "url", e.target.value)}
+ onBlur={() => onInputBlur(index, "url")}
+ placeholder="https://example.com"
+ />
+
+
+ onDelete(index)}>
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+ ),
+);
+
+const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => {
+ const { t } = useTranslation("dashboard");
+ const theme = useTheme();
+ const [items, setItems] = useState([]);
+ const [inputCache, setInputCache] = useState<{
+ [key: number]: { [field: string]: string | undefined };
+ }>({});
+
+ useEffect(() => {
+ try {
+ const parsedItems = JSON.parse(value);
+ setItems(Array.isArray(parsedItems) ? parsedItems : []);
+ } catch (e) {
+ setItems([]);
+ }
+ }, [value]);
+
+ const handleSave = useCallback(
+ (newItems: CustomNavItem[]) => {
+ onChange(JSON.stringify(newItems));
+ },
+ [onChange],
+ );
+
+ const handleDelete = useCallback(
+ (index: number) => {
+ const newItems = items.filter((_, i) => i !== index);
+ handleSave(newItems);
+ },
+ [items, handleSave],
+ );
+
+ const handleAdd = useCallback(() => {
+ const newItems = [
+ ...items,
+ {
+ name: "",
+ url: "",
+ icon: "fluent:home-24-regular",
+ },
+ ];
+ handleSave(newItems);
+ }, [items, handleSave]);
+
+ const handleFieldChange = useCallback(
+ (index: number, field: keyof CustomNavItem, value: string) => {
+ const newItems = [...items];
+ newItems[index] = {
+ ...newItems[index],
+ [field]: value,
+ };
+ handleSave(newItems);
+ },
+ [items, handleSave],
+ );
+
+ const handleInputChange = useCallback((index: number, field: string, value: string) => {
+ setInputCache((prev) => ({
+ ...prev,
+ [index]: {
+ ...prev[index],
+ [field]: value,
+ },
+ }));
+ }, []);
+
+ const handleInputBlur = useCallback(
+ (index: number, field: keyof CustomNavItem) => {
+ const cachedValue = inputCache[index]?.[field];
+ if (cachedValue !== undefined) {
+ handleFieldChange(index, field, cachedValue);
+ setInputCache((prev) => ({
+ ...prev,
+ [index]: {
+ ...prev[index],
+ [field]: undefined,
+ },
+ }));
+ }
+ },
+ [inputCache, handleFieldChange],
+ );
+
+ // 拖拽排序逻辑
+ const moveRow = useCallback(
+ (from: number, to: number) => {
+ if (from === to) return;
+ const updated = [...items];
+ const [moved] = updated.splice(from, 1);
+ updated.splice(to, 0, moved);
+ setItems(updated);
+ handleSave(updated);
+ },
+ [items, handleSave],
+ );
+
+ const handleMoveUp = (idx: number) => {
+ if (idx <= 0) return;
+ moveRow(idx, idx - 1);
+ };
+ const handleMoveDown = (idx: number) => {
+ if (idx >= items.length - 1) return;
+ moveRow(idx, idx + 1);
+ };
+
+ const IconPreview = useMemo(
+ () =>
+ ({ iconName }: { iconName: string }) => {
+ if (!iconName) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ [],
+ );
+
+ return (
+
+
+ {t("settings.customNavItems")}
+
+
+ {t("settings.customNavItemsDes")}
+
+
+
+
+
+
+
+ {t("settings.icon")}
+ {t("settings.iconifyName")}
+ {t("settings.displayName")}
+ {t("settings.navItemUrl")}
+
+
+
+
+
+ {items.map((item, index) => {
+ const rowRef = React.createRef();
+ return (
+ handleMoveUp(index)}
+ onMoveDown={() => handleMoveDown(index)}
+ isFirst={index === 0}
+ isLast={index === items.length - 1}
+ inputCache={inputCache}
+ onInputChange={handleInputChange}
+ onInputBlur={handleInputBlur}
+ IconPreview={IconPreview}
+ t={t}
+ />
+ );
+ })}
+ {items.length === 0 && (
+
+
+
+ {t("application:setting.listEmpty")}
+
+
+
+ )}
+
+
+
+
+
+ } onClick={handleAdd} sx={{ mt: 2 }}>
+ {t("settings.addNavItem")}
+
+
+ );
+};
+
+export default CustomNavItems;
diff --git a/src/component/Admin/Settings/Settings.tsx b/src/component/Admin/Settings/Settings.tsx
index d6fd40e..6f5aa3a 100644
--- a/src/component/Admin/Settings/Settings.tsx
+++ b/src/component/Admin/Settings/Settings.tsx
@@ -300,7 +300,7 @@ const Settings = () => {
)}
{tab === SettingsPageTab.Appearance && (
-
+
)}
diff --git a/src/component/Frame/NavBar/PageNavigation.tsx b/src/component/Frame/NavBar/PageNavigation.tsx
index 933faf8..89a2b96 100644
--- a/src/component/Frame/NavBar/PageNavigation.tsx
+++ b/src/component/Frame/NavBar/PageNavigation.tsx
@@ -1,4 +1,5 @@
-import { Box, SvgIconProps } from "@mui/material";
+import { Icon as Iconify } from "@iconify/react";
+import { Box, SvgIconProps, useTheme } from "@mui/material";
import SvgIcon from "@mui/material/SvgIcon/SvgIcon";
import { memo, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -46,7 +47,8 @@ import SideNavItem from "./SideNavItem.tsx";
export interface NavigationItem {
label: string;
- icon: ((props: SvgIconProps) => JSX.Element)[] | (typeof SvgIcon)[];
+ icon?: ((props: SvgIconProps) => JSX.Element)[] | (typeof SvgIcon)[];
+ iconifyName?: string;
path: string;
pro?: boolean;
}
@@ -82,6 +84,7 @@ export const SideNavItemComponent = ({ item }: { item: NavigationItem }) => {
const { t } = useTranslation("application");
const navigate = useNavigate();
const location = useLocation();
+ const theme = useTheme();
const [proOpen, setProOpen] = useState(false);
const active = useMemo(() => {
return location.pathname == item.path || location.pathname.startsWith(item.path + "/");
@@ -91,7 +94,9 @@ export const SideNavItemComponent = ({ item }: { item: NavigationItem }) => {
{item.pro && setProOpen(false)} />}
(item.pro ? setProOpen(true) : navigate(item.path))}
+ onClick={() =>
+ item.pro ? setProOpen(true) : item.iconifyName ? window.open(item.path, "_blank") : navigate(item.path)
+ }
label={
item.pro ? (
@@ -112,12 +117,29 @@ export const SideNavItemComponent = ({ item }: { item: NavigationItem }) => {
}
active={active}
icon={
-
+ !item.icon ? (
+
+
+
+ ) : (
+
+ )
}
/>
>
@@ -229,6 +251,7 @@ const PageNavigation = () => {
return GroupBS(user?.user).enabled(GroupPermission.webdav) || appPromotionEnabled;
}, [user?.user?.group?.permission, appPromotionEnabled]);
const isLogin = !!user;
+ const customNavItems = useAppSelector((state) => state.siteConfig.basic.config.custom_nav_items);
return (
<>
@@ -244,6 +267,20 @@ const PageNavigation = () => {
>
)}
+ {customNavItems && customNavItems.length > 0 && (
+
+ {customNavItems.map((item) => (
+
+ ))}
+
+ )}
{isLogin && isAdmin && (