Skip to content
Open
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
7 changes: 7 additions & 0 deletions backend/internal/handler/admin/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions backend/internal/handler/dto/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 68 additions & 18 deletions frontend/src/components/layout/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,13 @@
</div>
</template>
<!-- Normal item (no children) -->
<router-link
<component
:is="item.externalUrl ? 'a' : 'router-link'"
v-else
:to="item.path"
:to="item.externalUrl ? undefined : item.path"
:href="item.externalUrl"
:target="item.externalNewTab ? '_blank' : undefined"
:rel="item.externalNewTab ? 'noopener noreferrer' : undefined"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
Expand All @@ -89,7 +93,7 @@
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</component>
</template>
</div>

Expand All @@ -101,10 +105,14 @@
</span>
</div>

<router-link
<component
:is="item.externalUrl ? 'a' : 'router-link'"
v-for="item in personalNavItems"
:key="item.path"
:to="item.path"
:to="item.externalUrl ? undefined : item.path"
:href="item.externalUrl"
:target="item.externalNewTab ? '_blank' : undefined"
:rel="item.externalNewTab ? 'noopener noreferrer' : undefined"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
Expand All @@ -114,17 +122,21 @@
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</component>
</div>
</template>

<!-- Regular User View -->
<template v-else-if="!appStore.backendModeEnabled">
<div class="sidebar-section">
<router-link
<component
:is="item.externalUrl ? 'a' : 'router-link'"
v-for="item in userNavItems"
:key="item.path"
:to="item.path"
:to="item.externalUrl ? undefined : item.path"
:href="item.externalUrl"
:target="item.externalNewTab ? '_blank' : undefined"
:rel="item.externalNewTab ? 'noopener noreferrer' : undefined"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
Expand All @@ -134,7 +146,7 @@
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</component>
</div>
</template>
</nav>
Expand Down Expand Up @@ -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 {
Expand All @@ -200,6 +214,13 @@ interface NavItem {
* does NOT navigate to its `path`. The `path` is purely a stable key.
*/
expandOnly?: boolean
/**
* 当设置时,该菜单项渲染为外链 `<a>` 而非 `<router-link>`,直接打开此 URL。
* 用于自定义菜单的「跳转」/「新开标签页」模式。`path` 仍作为稳定 key。
*/
externalUrl?: string
/** externalUrl 是否在新标签页打开(target="_blank")。 */
externalNewTab?: boolean
/**
* 可选的功能开关 getter。返回 false 时菜单项被隐藏;返回 undefined/true 时显示。
* 宽容策略(undefined → 显示)避免 public settings 未加载完成时菜单闪烁消失。
Expand All @@ -224,7 +245,7 @@ function applyFeatureFlags(items: NavItem[]): NavItem[] {
return out
}

const { t } = useI18n()
const { t, locale } = useI18n()

const route = useRoute()
const router = useRouter()
Expand Down Expand Up @@ -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
}
Expand All @@ -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) 外链:渲染为 <a> 外链,携带用户上下文参数直接打开。
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 ?? []
Expand Down Expand Up @@ -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
})
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '上移',
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
56 changes: 56 additions & 0 deletions frontend/src/views/admin/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4888,6 +4888,47 @@
</select>
</div>

<!-- Open mode -->
<div class="sm:col-span-2">
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.customMenu.openMode") }}
</label>
<select v-model="item.open_mode" class="input text-sm">
<option value="embed">
{{ t("admin.settings.customMenu.openModeEmbed") }}
</option>
<option value="redirect">
{{ t("admin.settings.customMenu.openModeRedirect") }}
</option>
<option value="newtab">
{{ t("admin.settings.customMenu.openModeNewTab") }}
</option>
</select>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.customMenu.openModeHint") }}
</p>
</div>

<!-- Carry user params -->
<div class="sm:col-span-2">
<div class="flex items-center justify-between gap-3">
<label
class="text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.customMenu.withUserParams") }}
</label>
<Toggle
:model-value="item.with_user_params !== false"
@update:model-value="(v: boolean) => (item.with_user_params = v)"
/>
</div>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.customMenu.withUserParamsHint") }}
</p>
</div>

<!-- URL (full width) -->
<div class="sm:col-span-2">
<label
Expand Down Expand Up @@ -7044,6 +7085,8 @@ const form = reactive<SettingsForm>({
icon_svg: string;
url: string;
visibility: "user" | "admin";
open_mode: "embed" | "redirect" | "newtab";
with_user_params: boolean;
sort_order: number;
}>,
custom_endpoints: [] as Array<{
Expand Down Expand Up @@ -7677,6 +7720,8 @@ function addMenuItem() {
icon_svg: "",
url: "",
visibility: "user",
open_mode: "embed",
with_user_params: true,
sort_order: form.custom_menu_items.length,
});
}
Expand Down Expand Up @@ -7806,6 +7851,17 @@ async function loadSettings() {
}))
: defaultLoginAgreementDocuments();
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
// Ensure legacy menu items without open_mode default to embed so the
// <select> binding always has a matching option.
if (Array.isArray(form.custom_menu_items)) {
form.custom_menu_items.forEach((item) => {
if (item.open_mode !== "redirect" && item.open_mode !== "newtab") {
item.open_mode = "embed";
}
// Legacy items without the flag default to carrying user params.
item.with_user_params = item.with_user_params !== false;
});
}
form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas);
form.backend_mode_enabled = settings.backend_mode_enabled;
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
Expand Down
1 change: 1 addition & 0 deletions frontend/src/views/user/CustomPageView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ const isMarkdownMode = computed(() => !!markdownSlug.value)

const embeddedUrl = computed(() => {
if (!menuItem.value || isMarkdownMode.value) return ''
if (menuItem.value.with_user_params === false) return menuItem.value.url
return buildEmbeddedUrl(
menuItem.value.url,
authStore.user?.id,
Expand Down
Loading