浏览代码

refactor(dashboard): Restructure UiConfigPluginOptions to remove AdminUiConfig dependency (#3718)

David Höck 5 月之前
父节点
当前提交
b599fca10f

+ 14 - 3
.github/workflows/scripts/dashboard-tests.js

@@ -116,11 +116,22 @@ async function runDashboardTests() {
         await page.screenshot({ path: '/tmp/dashboard-test-login.png' });
         console.log('Screenshot saved to /tmp/dashboard-test-login.png');
 
-        // navigate to the product list page by clicking the "Products" link in the sidebar
-        // based on the text label "Products"
+        // navigate to the product list page by first expanding the "Catalog" section
+        // and then clicking the "Products" link in the sidebar
+        console.log('Expanding Catalog section...');
+        const catalogSection = await page.locator('button:has-text("Catalog")').first();
+        if (!catalogSection) {
+            throw new Error('Catalog section not found');
+        }
+        await catalogSection.click();
+
+        // Wait for the section to expand and Products link to be visible
+        console.log('Waiting for Products link to be visible...');
+        await page.waitForSelector('a:has-text("Products")', { timeout: 5000 });
+        
         const productsLink = await page.locator('a:has-text("Products")').first();
         if (!productsLink) {
-            throw new Error('Products link not found');
+            throw new Error('Products link not found after expanding Catalog section');
         }
         await productsLink.click();
 

+ 1 - 1
.github/workflows/scripts/vite.config.mts

@@ -10,7 +10,7 @@ export default defineConfig({
     plugins: [
         vendureDashboardPlugin({
             vendureConfigPath: pathToFileURL('./src/vendure-config.ts'),
-            adminUiConfig: { apiHost: 'http://localhost', apiPort: 3000 },
+            api: { host: 'http://localhost', port: 3000 },
             gqlOutputPath: './src/gql',
         }),
     ],

+ 2 - 1
.vscode/settings.json

@@ -16,7 +16,8 @@
         "stellate-plugin",
         "testing",
         "ui-devkit",
-        "repo"
+        "repo",
+        "dashboard"
     ],
     "conventionalCommits.gitmoji": false,
     "typescript.tsdk": "node_modules/typescript/lib",

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_system/healthchecks.tsx

@@ -28,7 +28,7 @@ function HealthchecksPage() {
         queryKey: ['healthchecks'],
         queryFn: async () => {
             const schemeAndHost =
-                uiConfig.apiHost + (uiConfig.apiPort !== 'auto' ? `:${uiConfig.apiPort}` : '');
+                uiConfig.api.host + (uiConfig.api.port !== 'auto' ? `:${uiConfig.api.port}` : '');
 
             const res = await fetch(`${schemeAndHost}/health`);
             return res.json() as Promise<HealthcheckResponse>;

+ 2 - 1
packages/dashboard/src/lib/components/layout/language-dialog.tsx

@@ -10,7 +10,8 @@ import { Label } from '../ui/label.js';
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
 
 export function LanguageDialog() {
-    const { availableLocales, availableLanguages } = uiConfig;
+    const { i18n } = uiConfig;
+    const { availableLocales, availableLanguages } = i18n;
     const { settings, setDisplayLanguage, setDisplayLocale } = useUserSettings();
     const availableCurrencyCodes = Object.values(CurrencyCode);
     const { formatCurrency, formatLanguageName, formatCurrencyName, formatDate } = useLocalFormat();

+ 97 - 32
packages/dashboard/src/lib/components/layout/nav-main.tsx

@@ -14,7 +14,7 @@ import {
     NavMenuSection,
     NavMenuSectionPlacement,
 } from '@/vdb/framework/nav-menu/nav-menu-extensions.js';
-import { Link, useLocation } from '@tanstack/react-router';
+import { Link, useRouter, useRouterState } from '@tanstack/react-router';
 import { ChevronRight } from 'lucide-react';
 import * as React from 'react';
 import { NavItemWrapper } from './nav-item-wrapper.js';
@@ -29,10 +29,73 @@ function sortByOrder<T extends { order?: number; title: string }>(a: T, b: T) {
     return orderA - orderB;
 }
 
+/**
+ * Escapes special regex characters in a string to be used as a literal pattern
+ */
+function escapeRegexChars(str: string): string {
+    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
 export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavMenuItem> }>) {
-    const location = useLocation();
-    // State to track which bottom section is currently open
-    const [openBottomSectionId, setOpenBottomSectionId] = React.useState<string | null>(null);
+    const router = useRouter();
+    const routerState = useRouterState();
+    const currentPath = routerState.location.pathname;
+    const basePath = router.basepath || '';
+
+    // Helper to check if a path is active
+    const isPathActive = React.useCallback(
+        (itemUrl: string) => {
+            // Remove basepath prefix from current path for comparison
+            const normalizedCurrentPath = basePath ? currentPath.replace(new RegExp(`^${escapeRegexChars(basePath)}`), '') : currentPath;
+            
+            // Ensure normalized path starts with /
+            const cleanPath = normalizedCurrentPath.startsWith('/') ? normalizedCurrentPath : `/${normalizedCurrentPath}`;
+            
+            // Special handling for root path
+            if (itemUrl === '/') {
+                return cleanPath === '/' || cleanPath === '';
+            }
+            
+            // For other paths, check exact match or prefix match
+            return cleanPath === itemUrl || cleanPath.startsWith(`${itemUrl}/`);
+        },
+        [currentPath, basePath],
+    );
+
+    // Helper to find sections containing active routes
+    const findActiveSections = React.useCallback(
+        (sections: Array<NavMenuSection | NavMenuItem>) => {
+            const activeTopSections = new Set<string>();
+            let activeBottomSection: string | null = null;
+
+            for (const section of sections) {
+                if ('items' in section && section.items) {
+                    const hasActiveItem = section.items.some(item => isPathActive(item.url));
+                    if (hasActiveItem) {
+                        if (section.placement === 'top') {
+                            activeTopSections.add(section.id);
+                        } else if (section.placement === 'bottom' && !activeBottomSection) {
+                            activeBottomSection = section.id;
+                        }
+                    }
+                }
+            }
+
+            return { activeTopSections, activeBottomSection };
+        },
+        [isPathActive],
+    );
+
+    // Initialize state with active sections on mount
+    const [openBottomSectionId, setOpenBottomSectionId] = React.useState<string | null>(() => {
+        const { activeBottomSection } = findActiveSections(items);
+        return activeBottomSection;
+    });
+
+    const [openTopSectionIds, setOpenTopSectionIds] = React.useState<Set<string>>(() => {
+        const { activeTopSections } = findActiveSections(items);
+        return activeTopSections;
+    });
 
     // Helper to build a sorted list of sections for a given placement, memoized for stability
     const getSortedSections = React.useCallback(
@@ -53,6 +116,17 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
     const topSections = React.useMemo(() => getSortedSections('top'), [getSortedSections]);
     const bottomSections = React.useMemo(() => getSortedSections('bottom'), [getSortedSections]);
 
+    // Handle top section open/close (only one section open at a time)
+    const handleTopSectionToggle = (sectionId: string, isOpen: boolean) => {
+        if (isOpen) {
+            // When opening a section, close all others
+            setOpenTopSectionIds(new Set([sectionId]));
+        } else {
+            // When closing a section, remove it from the set
+            setOpenTopSectionIds(new Set());
+        }
+    };
+
     // Handle bottom section open/close
     const handleBottomSectionToggle = (sectionId: string, isOpen: boolean) => {
         if (isOpen) {
@@ -62,25 +136,17 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
         }
     };
 
-    // Auto-open the bottom section that contains the current route
+    // Update open sections when route changes (for client-side navigation)
     React.useEffect(() => {
-        const currentPath = location.pathname;
-
-        // Check if the current path is in any bottom section
-        for (const section of bottomSections) {
-            const matchingItem =
-                'items' in section
-                    ? section.items?.find(
-                          item => currentPath === item.url || currentPath.startsWith(`${item.url}/`),
-                      )
-                    : null;
-
-            if (matchingItem) {
-                setOpenBottomSectionId(section.id);
-                return;
-            }
+        const { activeTopSections, activeBottomSection } = findActiveSections(items);
+
+        // Replace open sections with only the active one
+        setOpenTopSectionIds(activeTopSections);
+
+        if (activeBottomSection) {
+            setOpenBottomSectionId(activeBottomSection);
         }
-    }, [location.pathname, bottomSections]);
+    }, [currentPath, items, findActiveSections]);
 
     // Render a top navigation section
     const renderTopSection = (item: NavMenuSection | NavMenuItem) => {
@@ -91,7 +157,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
                         <SidebarMenuButton
                             tooltip={item.title}
                             asChild
-                            isActive={location.pathname === item.url}
+                            isActive={isPathActive(item.url)}
                         >
                             <Link to={item.url}>
                                 {item.icon && <item.icon />}
@@ -105,7 +171,12 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
 
         return (
             <NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
-                <Collapsible asChild defaultOpen={item.defaultOpen} className="group/collapsible">
+                <Collapsible
+                    asChild
+                    open={openTopSectionIds.has(item.id)}
+                    onOpenChange={isOpen => handleTopSectionToggle(item.id, isOpen)}
+                    className="group/collapsible"
+                >
                     <SidebarMenuItem>
                         <CollapsibleTrigger asChild>
                             <SidebarMenuButton tooltip={item.title}>
@@ -126,10 +197,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
                                         <SidebarMenuSubItem>
                                             <SidebarMenuSubButton
                                                 asChild
-                                                isActive={
-                                                    location.pathname === subItem.url ||
-                                                    location.pathname.startsWith(`${subItem.url}/`)
-                                                }
+                                                isActive={isPathActive(subItem.url)}
                                             >
                                                 <Link to={subItem.url}>
                                                     <span>{subItem.title}</span>
@@ -155,7 +223,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
                         <SidebarMenuButton
                             tooltip={item.title}
                             asChild
-                            isActive={location.pathname === item.url}
+                            isActive={isPathActive(item.url)}
                         >
                             <Link to={item.url}>
                                 {item.icon && <item.icon />}
@@ -194,10 +262,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
                                         <SidebarMenuSubItem>
                                             <SidebarMenuSubButton
                                                 asChild
-                                                isActive={
-                                                    location.pathname === subItem.url ||
-                                                    location.pathname.startsWith(`${subItem.url}/`)
-                                                }
+                                                isActive={isPathActive(subItem.url)}
                                             >
                                                 <Link to={subItem.url}>
                                                     <span>{subItem.title}</span>

+ 42 - 0
packages/dashboard/src/lib/components/shared/detail-page-button.tsx

@@ -2,6 +2,48 @@ import { Link } from '@tanstack/react-router';
 import { ChevronRight } from 'lucide-react';
 import { Button } from '../ui/button.js';
 
+/**
+ * DetailPageButton is a reusable navigation component designed to provide consistent UX
+ * across list views when linking to detail pages. It renders as a ghost button with
+ * a chevron indicator, making it easy for users to identify clickable links that
+ * navigate to detail views.
+ *
+ * @component
+ * @example
+ * // Basic usage with ID (relative navigation)
+ * <DetailPageButton id="123" label="Product Name" />
+ *
+ * @example
+ * // Custom href with search params
+ * <DetailPageButton
+ *   href="/products/detail/456"
+ *   label="Custom Product"
+ *   search={{ tab: 'variants' }}
+ * />
+ *
+ * @example
+ * // Disabled state
+ * <DetailPageButton
+ *   id="789"
+ *   label="Unavailable Item"
+ *   disabled={true}
+ * />
+ *
+ * @param {Object} props - Component props
+ * @param {string|React.ReactNode} props.label - The text or content to display in the button
+ * @param {string} [props.id] - The ID for relative navigation (creates href as `./${id}`)
+ * @param {string} [props.href] - Custom href for navigation (takes precedence over id)
+ * @param {boolean} [props.disabled=false] - Whether the button is disabled (prevents navigation)
+ * @param {Record<string, string>} [props.search] - Search parameters to include in the navigation
+ *
+ * @returns {React.ReactElement} A styled button component that navigates to detail pages
+ *
+ * @remarks
+ * - Uses TanStack Router's Link component for client-side navigation
+ * - Includes a chevron icon (hidden when disabled) to indicate navigation
+ * - Preloading is disabled by default for performance optimization
+ * - Styled as a ghost button variant for subtle, consistent appearance
+ */
 export function DetailPageButton({
     id,
     href,

+ 0 - 6
packages/dashboard/src/lib/framework/defaults.ts

@@ -29,7 +29,6 @@ export function registerDefaults() {
                 id: 'catalog',
                 title: 'Catalog',
                 icon: SquareTerminal,
-                defaultOpen: true,
                 placement: 'top',
                 order: 200,
                 items: [
@@ -69,7 +68,6 @@ export function registerDefaults() {
                 id: 'sales',
                 title: 'Sales',
                 icon: ShoppingCart,
-                defaultOpen: true,
                 placement: 'top',
                 order: 300,
                 items: [
@@ -85,7 +83,6 @@ export function registerDefaults() {
                 id: 'customers',
                 title: 'Customers',
                 icon: Users,
-                defaultOpen: false,
                 placement: 'top',
                 order: 400,
                 items: [
@@ -107,7 +104,6 @@ export function registerDefaults() {
                 id: 'marketing',
                 title: 'Marketing',
                 icon: Mail,
-                defaultOpen: false,
                 placement: 'top',
                 order: 500,
                 items: [
@@ -123,7 +119,6 @@ export function registerDefaults() {
                 id: 'system',
                 title: 'System',
                 icon: Terminal,
-                defaultOpen: false,
                 placement: 'bottom',
                 order: 200,
                 items: [
@@ -151,7 +146,6 @@ export function registerDefaults() {
                 id: 'settings',
                 title: 'Settings',
                 icon: Settings2,
-                defaultOpen: false,
                 placement: 'bottom',
                 order: 100,
                 items: [

+ 5 - 2
packages/dashboard/src/lib/graphql/api.ts

@@ -3,7 +3,10 @@ import { AwesomeGraphQLClient } from 'awesome-graphql-client';
 import { DocumentNode, print } from 'graphql';
 import { uiConfig } from 'virtual:vendure-ui-config';
 
-const API_URL = uiConfig.apiHost + (uiConfig.apiPort !== 'auto' ? `:${uiConfig.apiPort}` : '') + '/admin-api';
+const API_URL =
+    uiConfig.api.host +
+    (uiConfig.api.port !== 'auto' ? `:${uiConfig.api.port}` : '') +
+    `/${uiConfig.api.adminApiPath}`;
 
 export type Variables = object;
 export type RequestDocument = string | DocumentNode;
@@ -16,7 +19,7 @@ const awesomeClient = new AwesomeGraphQLClient({
         const headers = new Headers(options.headers);
 
         if (channelToken) {
-            headers.set('vendure-token', channelToken);
+            headers.set(uiConfig.api.channelTokenKey, channelToken);
         }
 
         // Get the content language from user settings and add as query parameter

文件差异内容过多而无法显示
+ 3 - 3
packages/dashboard/src/lib/graphql/graphql-env.d.ts


+ 26 - 2
packages/dashboard/src/lib/virtual.d.ts

@@ -7,6 +7,30 @@ declare module 'virtual:dashboard-extensions' {
 }
 
 declare module 'virtual:vendure-ui-config' {
-    import { AdminUiConfig } from '@vendure/core';
-    export const uiConfig: AdminUiConfig;
+    import { LanguageCode } from '@vendure/core';
+
+    // TODO: Find a better way to share types between vite plugin and virtual module declaration
+    // Currently we have duplicated type definitions here and in vite-plugin-ui-config.ts
+    interface ResolvedApiConfig {
+        host: string | 'auto';
+        port: number | 'auto';
+        adminApiPath: string;
+        tokenMethod: 'cookie' | 'bearer';
+        authTokenHeaderKey: string;
+        channelTokenKey: string;
+    }
+
+    interface ResolvedI18nConfig {
+        defaultLanguage: LanguageCode;
+        defaultLocale: string | undefined;
+        availableLanguages: LanguageCode[];
+        availableLocales: string[];
+    }
+
+    interface ResolvedUiConfig {
+        api: ResolvedApiConfig;
+        i18n: ResolvedI18nConfig;
+    }
+
+    export const uiConfig: ResolvedUiConfig;
 }

+ 2 - 0
packages/dashboard/src/vite-env.d.ts

@@ -0,0 +1,2 @@
+/// <reference types="vite/client" />
+/// <reference path="./lib/virtual.d.ts" />

+ 30 - 42
packages/dashboard/vite/utils/ui-config.ts

@@ -3,7 +3,6 @@ import {
     DEFAULT_AUTH_TOKEN_HEADER_KEY,
     DEFAULT_CHANNEL_TOKEN_KEY,
 } from '@vendure/common/lib/shared-constants';
-import { AdminUiConfig } from '@vendure/common/lib/shared-types';
 import { VendureConfig } from '@vendure/core';
 
 import {
@@ -12,53 +11,42 @@ import {
     defaultLanguage,
     defaultLocale,
 } from '../constants.js';
+import { ResolvedUiConfig, UiConfigPluginOptions } from '../vite-plugin-ui-config.js';
 
-export function getAdminUiConfig(
-    config: VendureConfig,
-    adminUiConfig?: Partial<AdminUiConfig>,
-): AdminUiConfig {
+export function getUiConfig(config: VendureConfig, pluginOptions: UiConfigPluginOptions): ResolvedUiConfig {
     const { authOptions, apiOptions } = config;
 
-    const propOrDefault = <Prop extends keyof AdminUiConfig>(
-        prop: Prop,
-        defaultVal: AdminUiConfig[Prop],
-        isArray: boolean = false,
-    ): AdminUiConfig[Prop] => {
-        if (isArray) {
-            const isValidArray = !!adminUiConfig
-                ? !!((adminUiConfig as AdminUiConfig)[prop] as any[])?.length
-                : false;
+    // Merge API configuration with defaults
+    const api = {
+        adminApiPath: pluginOptions.api?.adminApiPath ?? apiOptions.adminApiPath ?? ADMIN_API_PATH,
+        host: pluginOptions.api?.host ?? 'auto',
+        port: pluginOptions.api?.port ?? 'auto',
+        tokenMethod:
+            pluginOptions.api?.tokenMethod ?? (authOptions.tokenMethod === 'bearer' ? 'bearer' : 'cookie'),
+        authTokenHeaderKey:
+            pluginOptions.api?.authTokenHeaderKey ??
+            authOptions.authTokenHeaderKey ??
+            DEFAULT_AUTH_TOKEN_HEADER_KEY,
+        channelTokenKey:
+            pluginOptions.api?.channelTokenKey ?? apiOptions.channelTokenKey ?? DEFAULT_CHANNEL_TOKEN_KEY,
+    };
 
-            return !!adminUiConfig && isValidArray ? (adminUiConfig as AdminUiConfig)[prop] : defaultVal;
-        } else {
-            return adminUiConfig ? (adminUiConfig as AdminUiConfig)[prop] || defaultVal : defaultVal;
-        }
+    // Merge i18n configuration with defaults
+    const i18n = {
+        defaultLanguage: pluginOptions.i18n?.defaultLanguage ?? defaultLanguage,
+        defaultLocale: pluginOptions.i18n?.defaultLocale ?? defaultLocale,
+        availableLanguages:
+            pluginOptions.i18n?.availableLanguages && pluginOptions.i18n.availableLanguages.length > 0
+                ? pluginOptions.i18n.availableLanguages
+                : defaultAvailableLanguages,
+        availableLocales:
+            pluginOptions.i18n?.availableLocales && pluginOptions.i18n.availableLocales.length > 0
+                ? pluginOptions.i18n.availableLocales
+                : defaultAvailableLocales,
     };
 
     return {
-        adminApiPath: propOrDefault('adminApiPath', apiOptions.adminApiPath || ADMIN_API_PATH),
-        apiHost: propOrDefault('apiHost', 'auto'),
-        apiPort: propOrDefault('apiPort', 'auto'),
-        tokenMethod: propOrDefault('tokenMethod', authOptions.tokenMethod === 'bearer' ? 'bearer' : 'cookie'),
-        authTokenHeaderKey: propOrDefault(
-            'authTokenHeaderKey',
-            authOptions.authTokenHeaderKey || DEFAULT_AUTH_TOKEN_HEADER_KEY,
-        ),
-        channelTokenKey: propOrDefault(
-            'channelTokenKey',
-            apiOptions.channelTokenKey || DEFAULT_CHANNEL_TOKEN_KEY,
-        ),
-        defaultLanguage: propOrDefault('defaultLanguage', defaultLanguage),
-        defaultLocale: propOrDefault('defaultLocale', defaultLocale),
-        availableLanguages: propOrDefault('availableLanguages', defaultAvailableLanguages, true),
-        availableLocales: propOrDefault('availableLocales', defaultAvailableLocales, true),
-        brand: adminUiConfig?.brand,
-        hideVendureBranding: propOrDefault(
-            'hideVendureBranding',
-            adminUiConfig?.hideVendureBranding || false,
-        ),
-        hideVersion: propOrDefault('hideVersion', adminUiConfig?.hideVersion || false),
-        loginImageUrl: adminUiConfig?.loginImageUrl,
-        cancellationReasons: propOrDefault('cancellationReasons', undefined),
+        api,
+        i18n,
     };
 }

+ 119 - 17
packages/dashboard/vite/vite-plugin-ui-config.ts

@@ -1,27 +1,137 @@
-import { AdminUiConfig, VendureConfig } from '@vendure/core';
-import path from 'path';
+import { LanguageCode, VendureConfig } from '@vendure/core';
 import { Plugin } from 'vite';
 
-import { getAdminUiConfig } from './utils/ui-config.js';
+import { getUiConfig } from './utils/ui-config.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
 const virtualModuleId = 'virtual:vendure-ui-config';
 const resolvedVirtualModuleId = `\0${virtualModuleId}`;
 
-export type UiConfigPluginOptions = {
+export interface ApiConfig {
     /**
      * @description
-     * The admin UI config to be passed to the Vendure Dashboard.
+     * The hostname of the Vendure server which the admin UI will be making API calls
+     * to. If set to "auto", the Admin UI app will determine the hostname from the
+     * current location (i.e. `window.location.hostname`).
+     *
+     * @default 'auto'
      */
-    adminUiConfig?: Partial<AdminUiConfig>;
-};
+    host?: string | 'auto';
+    /**
+     * @description
+     * The port of the Vendure server which the admin UI will be making API calls
+     * to. If set to "auto", the Admin UI app will determine the port from the
+     * current location (i.e. `window.location.port`).
+     *
+     * @default 'auto'
+     */
+    port?: number | 'auto';
+    /**
+     * @description
+     * The path to the GraphQL Admin API.
+     *
+     * @default 'admin-api'
+     */
+    adminApiPath?: string;
+    /**
+     * @description
+     * Whether to use cookies or bearer tokens to track sessions.
+     * Should match the setting of in the server's `tokenMethod` config
+     * option.
+     *
+     * @default 'cookie'
+     */
+    tokenMethod?: 'cookie' | 'bearer';
+    /**
+     * @description
+     * The header used when using the 'bearer' auth method. Should match the
+     * setting of the server's `authOptions.authTokenHeaderKey` config option.
+     *
+     * @default 'vendure-auth-token'
+     */
+    authTokenHeaderKey?: string;
+    /**
+     * @description
+     * The name of the header which contains the channel token. Should match the
+     * setting of the server's `apiOptions.channelTokenKey` config option.
+     *
+     * @default 'vendure-token'
+     */
+    channelTokenKey?: string;
+}
+
+export interface I18nConfig {
+    /**
+     * @description
+     * The default language for the Admin UI. Must be one of the
+     * items specified in the `availableLanguages` property.
+     *
+     * @default LanguageCode.en
+     */
+    defaultLanguage?: LanguageCode;
+    /**
+     * @description
+     * The default locale for the Admin UI. The locale affects the formatting of
+     * currencies & dates. Must be one of the items specified
+     * in the `availableLocales` property.
+     *
+     * If not set, the browser default locale will be used.
+     *
+     * @since 2.2.0
+     */
+    defaultLocale?: string;
+    /**
+     * @description
+     * An array of languages for which translations exist for the Admin UI.
+     */
+    availableLanguages?: LanguageCode[];
+    /**
+     * @description
+     * An array of locales to be used on Admin UI.
+     *
+     * @since 2.2.0
+     */
+    availableLocales?: string[];
+}
+
+export interface UiConfigPluginOptions {
+    /**
+     * @description
+     * Configuration for API connection settings
+     */
+    api?: ApiConfig;
+    /**
+     * @description
+     * Configuration for internationalization settings
+     */
+    i18n?: I18nConfig;
+}
+
+/**
+ * @description
+ * The resolved UI configuration with all defaults applied.
+ * This is the type of the configuration object available at runtime.
+ */
+export interface ResolvedUiConfig {
+    /**
+     * @description
+     * API connection settings with all defaults applied
+     */
+    api: Required<ApiConfig>;
+    /**
+     * @description
+     * Internationalization settings with all defaults applied.
+     * Note: defaultLocale remains optional as it can be undefined.
+     */
+    i18n: Required<Omit<I18nConfig, 'defaultLocale'>> & Pick<I18nConfig, 'defaultLocale'>;
+}
 
 /**
  * This Vite plugin scans the configured plugins for any dashboard extensions and dynamically
  * generates an import statement for each one, wrapped up in a `runDashboardExtensions()`
  * function which can then be imported and executed in the Dashboard app.
  */
-export function uiConfigPlugin({ adminUiConfig }: UiConfigPluginOptions): Plugin {
+export function uiConfigPlugin(options: UiConfigPluginOptions = {}): Plugin {
     let configLoaderApi: ConfigLoaderApi;
     let vendureConfig: VendureConfig;
 
@@ -42,7 +152,7 @@ export function uiConfigPlugin({ adminUiConfig }: UiConfigPluginOptions): Plugin
                     vendureConfig = result.vendureConfig;
                 }
 
-                const config = getAdminUiConfig(vendureConfig, adminUiConfig);
+                const config = getUiConfig(vendureConfig, options);
 
                 return `
                     export const uiConfig = ${JSON.stringify(config)}
@@ -51,11 +161,3 @@ export function uiConfigPlugin({ adminUiConfig }: UiConfigPluginOptions): Plugin
         },
     };
 }
-
-/**
- * Converts an import path to a normalized path relative to the rootDir.
- */
-function normalizeImportPath(rootDir: string, importPath: string): string {
-    const relativePath = path.relative(rootDir, importPath).replace(/\\/g, '/');
-    return relativePath.replace(/\.tsx?$/, '.js');
-}

+ 1 - 1
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -133,7 +133,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
         viteConfigPlugin({ packageRoot }),
         adminApiSchemaPlugin(),
         dashboardMetadataPlugin(),
-        uiConfigPlugin({ adminUiConfig: options.adminUiConfig }),
+        uiConfigPlugin(options),
         ...(options.gqlOutputPath
             ? [gqlTadaPlugin({ gqlTadaOutputPath: options.gqlOutputPath, tempDir, packageRoot })]
             : []),

+ 1 - 1
packages/dev-server/dev-config.ts

@@ -166,7 +166,7 @@ export const devConfig: VendureConfig = {
         // }),
         DashboardPlugin.init({
             route: 'dashboard',
-            app: path.join(__dirname, './dist'),
+            appDir: path.join(__dirname, './dist'),
         }),
     ],
 };

+ 4 - 1
packages/dev-server/vite.config.mts

@@ -8,7 +8,10 @@ export default defineConfig({
     plugins: [
         vendureDashboardPlugin({
             vendureConfigPath: pathToFileURL('./dev-config.ts'),
-            adminUiConfig: { apiHost: 'http://localhost', apiPort: 3000 },
+            api: {
+                host: 'http://localhost',
+                port: 3000,
+            },
             gqlOutputPath: path.resolve(__dirname, './graphql/'),
         }),
     ],

部分文件因为文件数量过多而无法显示