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}} 添加到 “重定向 URI” 中。详情请参考 <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 && (