diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index c229d3408c13..f4d5b1168555 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1312,6 +1312,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") return } + switch item.OpenMode { + case "", "embed", "redirect", "newtab": + // valid; empty defaults to embed + default: + response.BadRequest(c, "Custom menu item open_mode must be 'embed', 'redirect' or 'newtab'") + return + } if len(item.IconSVG) > maxMenuItemIconSVGLen { response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)") return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 17772a2eef76..d5b05ab5649c 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -14,8 +14,13 @@ type CustomMenuItem struct { IconSVG string `json:"icon_svg"` URL string `json:"url"` PageSlug string `json:"page_slug,omitempty"` - Visibility string `json:"visibility"` // "user" or "admin" - SortOrder int `json:"sort_order"` + Visibility string `json:"visibility"` // "user" or "admin" + OpenMode string `json:"open_mode,omitempty"` // "embed" (default), "redirect", or "newtab" + // WithUserParams controls whether user context params (token, user_id, + // theme, lang, ...) are appended to the URL when opening it. A nil value + // (legacy items) is treated as true to preserve existing behavior. + WithUserParams *bool `json:"with_user_params,omitempty"` + SortOrder int `json:"sort_order"` } // CustomEndpoint represents an admin-configured API endpoint for quick copy. diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 3d7f1604c7e8..d1001d633ca1 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -69,9 +69,13 @@ - {{ item.label }} - + @@ -101,10 +105,14 @@ - {{ item.label }} - + @@ -186,6 +198,8 @@ import { useI18n } from 'vue-i18n' import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores' import VersionBadge from '@/components/common/VersionBadge.vue' import { sanitizeSvg } from '@/utils/sanitize' +import { buildEmbeddedUrl } from '@/utils/embedded-url' +import type { CustomMenuItem } from '@/types' import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags' interface NavItem { @@ -200,6 +214,13 @@ interface NavItem { * does NOT navigate to its `path`. The `path` is purely a stable key. */ expandOnly?: boolean + /** + * 当设置时,该菜单项渲染为外链 `` 而非 ``,直接打开此 URL。 + * 用于自定义菜单的「跳转」/「新开标签页」模式。`path` 仍作为稳定 key。 + */ + externalUrl?: string + /** externalUrl 是否在新标签页打开(target="_blank")。 */ + externalNewTab?: boolean /** * 可选的功能开关 getter。返回 false 时菜单项被隐藏;返回 undefined/true 时显示。 * 宽容策略(undefined → 显示)避免 public settings 未加载完成时菜单闪烁消失。 @@ -224,7 +245,7 @@ function applyFeatureFlags(items: NavItem[]): NavItem[] { return out } -const { t } = useI18n() +const { t, locale } = useI18n() const route = useRoute() const router = useRouter() @@ -675,12 +696,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate }, { path: '/profile', label: t('nav.profile'), icon: UserIcon }, - ...customMenuItemsForUser.value.map((item): NavItem => ({ - path: `/custom/${item.id}`, - label: item.label, - icon: null, - iconSvg: item.icon_svg, - })), + ...customMenuItemsForUser.value.map((item): NavItem => customMenuToNavItem(item)), ) return items } @@ -699,6 +715,40 @@ const userNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(tru // separate admin entry, since the page is purely a user-facing view. const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(false))) +// 把一个自定义菜单项转换为 NavItem。 +// - embed(默认)或 Markdown 页面(md: 前缀):路由到应用内 /custom/:id(iframe 或 md 渲染)。 +// - redirect / newtab 且为 http(s) 外链:渲染为 外链,携带用户上下文参数直接打开。 +function customMenuToNavItem(item: CustomMenuItem): NavItem { + const mode = item.open_mode ?? 'embed' + const url = item.url ?? '' + const isHttp = /^https?:\/\//i.test(url) + if ((mode === 'redirect' || mode === 'newtab') && isHttp) { + const withParams = item.with_user_params !== false + return { + path: `/custom/${item.id}`, + label: item.label, + icon: null, + iconSvg: item.icon_svg, + externalUrl: withParams + ? buildEmbeddedUrl( + url, + authStore.user?.id, + authStore.token, + isDark.value ? 'dark' : 'light', + locale.value, + ) + : url, + externalNewTab: mode === 'newtab', + } + } + return { + path: `/custom/${item.id}`, + label: item.label, + icon: null, + iconSvg: item.icon_svg, + } +} + // Custom menu items filtered by visibility const customMenuItemsForUser = computed(() => { const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] @@ -775,14 +825,14 @@ const adminNavItems = computed((): NavItem[] => { filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) for (const cm of customMenuItemsForAdmin.value) { - filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + filtered.push(customMenuToNavItem(cm)) } return filtered } visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) for (const cm of customMenuItemsForAdmin.value) { - visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + visible.push(customMenuToNavItem(cm)) } return visible }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index b4e231e73f1a..3090a3d0d4a2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5758,6 +5758,13 @@ export default { visibility: 'Visible To', visibilityUser: 'Regular Users', visibilityAdmin: 'Administrators', + openMode: 'Open Mode', + openModeEmbed: 'Embed URL (iframe)', + openModeRedirect: 'Redirect current tab', + openModeNewTab: 'Open in new tab', + openModeHint: 'Embed: open the URL in an in-app iframe; Redirect: navigate the current tab to the URL; New tab: open the URL in a new browser tab. Markdown pages (md: prefix) always open in-app.', + withUserParams: 'Carry User Params', + withUserParamsHint: 'When enabled, user context params (token, user_id, theme, lang, ...) are appended to the URL; when disabled, the raw URL is used.', add: 'Add Menu Item', remove: 'Remove', moveUp: 'Move Up', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 70a7f6dfcbb8..cc5a356d2137 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5914,6 +5914,13 @@ export default { visibility: '可见角色', visibilityUser: '普通用户', visibilityAdmin: '管理员', + openMode: '打开方式', + openModeEmbed: '网址内嵌(iframe)', + openModeRedirect: '当前标签页跳转', + openModeNewTab: '新开标签页', + openModeHint: '内嵌:在应用内以 iframe 打开;跳转:当前页面直接跳转到该网址;新开标签页:在浏览器新标签打开。Markdown 页面(md: 开头)始终在应用内打开。', + withUserParams: '携带用户参数', + withUserParamsHint: '开启后,打开网址时会附加用户上下文参数(token、user_id、theme、lang 等);关闭则使用原始网址。', add: '添加菜单项', remove: '删除', moveUp: '上移', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 718c35cb27ef..03a9e77bc12a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -171,6 +171,19 @@ export interface CustomMenuItem { url: string page_slug?: string visibility: 'user' | 'admin' + /** + * How the menu item opens its URL: + * - 'embed' (default): render the URL in an in-app iframe page (/custom/:id) + * - 'redirect': navigate the current tab directly to the URL + * - 'newtab': open the URL in a new browser tab + */ + open_mode?: 'embed' | 'redirect' | 'newtab' + /** + * Whether to append user context params (token, user_id, theme, lang, ...) + * to the URL when opening it. Defaults to true (undefined is treated as true) + * to preserve legacy behavior. + */ + with_user_params?: boolean sort_order: number } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 239ce2d7b72d..296cab6884e4 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4888,6 +4888,47 @@ + +
+ + +

+ {{ t("admin.settings.customMenu.openModeHint") }} +

+
+ + +
+
+ + +
+

+ {{ t("admin.settings.customMenu.withUserParamsHint") }} +

+
+