diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 3bd04be2..60e1b080 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -76,6 +76,15 @@ public class NamespaceController extends BaseApiController { namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal))); } + @GetMapping("/me/namespaces/page") + public ApiResponse> listMyNamespacesPage( + Pageable pageable, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespacesPage(pageable, userNsRoles, platformRoles(principal))); + } + @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug, @RequestAttribute("userId") String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 7946c943..43c506fb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -103,6 +103,26 @@ public class NamespacePortalQueryAppService { .toList(); } + @Transactional(readOnly = true) + public PageResponse listMyNamespacesPage(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + Page empty = new PageImpl<>(List.of(), pageable, 0); + return PageResponse.from(empty); + } + + Pageable sortedPageable = pageable.isPaged() + ? PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + org.springframework.data.domain.Sort.by("slug").ascending()) + : PageRequest.of(0, 10, org.springframework.data.domain.Sort.by("slug").ascending()); + Page page = namespaceRepository + .findByIdIn(namespaceRoles.keySet().stream().toList(), sortedPageable) + .map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId()))); + return PageResponse.from(page); + } + @Transactional(readOnly = true) public NamespaceResponse getNamespace(String slug, String userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index d6353a7f..d7dc9142 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -95,6 +96,26 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data[0].canDelete").value(false)); } + @Test + void listMyNamespacesPage_limitsResultsAndReturnsTotal() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceRepository.findByIdIn(eq(List.of(1L)), any(org.springframework.data.domain.Pageable.class))) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(namespace), PageRequest.of(0, 10), 1)); + given(namespaceMemberRepository.findByUserId("owner-1")) + .willReturn(List.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "10") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.size").value(10)); + } + @Test void listMyNamespaces_doesNotTreatSuperAdminOverrideAsNamespaceMembership() throws Exception { given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index 2cf39c13..58e32532 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -13,6 +13,7 @@ public interface NamespaceRepository { Optional findById(Long id); List findAll(); List findByIdIn(List ids); + Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 7e7f3db0..e71835a7 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -18,6 +18,7 @@ import java.util.Optional; public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { List findByIdIn(List ids); + Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); } diff --git a/web/e2e/dashboard-shell.spec.ts b/web/e2e/dashboard-shell.spec.ts index edf16833..d16b86b1 100644 --- a/web/e2e/dashboard-shell.spec.ts +++ b/web/e2e/dashboard-shell.spec.ts @@ -8,12 +8,14 @@ test.describe('Dashboard Shell (Real API)', () => { await registerSession(page, testInfo) }) - test('renders account summary and quick links', async ({ page }) => { + test('renders account navigation and overview links', async ({ page }) => { await page.goto('/dashboard') await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() - await expect(page.getByText('Account Information')).toBeVisible() - await expect(page.getByRole('link', { name: 'View API Tokens' })).toBeVisible() - await expect(page.getByRole('link', { name: 'View My Skills' }).first()).toBeVisible() + const sidebar = page.getByRole('complementary') + await expect(sidebar.getByRole('link', { name: 'Profile', exact: true })).toBeVisible() + await expect(sidebar.getByRole('link', { name: 'My Skills', exact: true })).toBeVisible() + await expect(sidebar.getByRole('link', { name: 'API Tokens', exact: true })).toBeVisible() + await expect(page.getByText('View and manage all your published skills')).toBeVisible() }) }) diff --git a/web/e2e/landing-navigation.spec.ts b/web/e2e/landing-navigation.spec.ts index f3368b29..1f96018d 100644 --- a/web/e2e/landing-navigation.spec.ts +++ b/web/e2e/landing-navigation.spec.ts @@ -9,7 +9,7 @@ test.describe('Landing Navigation (Real API)', () => { test('submits the hero search to the search page', async ({ page }) => { await page.goto('/') - await expect(page.getByRole('heading', { name: 'Discover & Share AI Skills' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Turn team expertise into Agent-ready skills' })).toBeVisible() const searchInput = page.getByPlaceholder('Search skills...') await searchInput.fill('agent ops') diff --git a/web/e2e/landing-quick-start-cli.spec.ts b/web/e2e/landing-quick-start-cli.spec.ts index 76b2351f..6b85f14b 100644 --- a/web/e2e/landing-quick-start-cli.spec.ts +++ b/web/e2e/landing-quick-start-cli.spec.ts @@ -1,51 +1,56 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -test.describe('Landing Quick Start CLI Tab (Real API)', () => { +test.describe('Landing access methods (Real API)', () => { test.beforeEach(async ({ page }) => { await setEnglishLocale(page) }) - test('renders three peer tabs and exposes the CLI install command', async ({ page }) => { + test('renders three access methods and exposes current CLI commands', async ({ page }) => { await page.goto('/') - const agentTab = page.getByRole('button', { name: 'I am Agent', exact: true }) - const humanTab = page.getByRole('button', { name: 'I am Human', exact: true }) - const cliTab = page.getByRole('button', { name: 'CLI', exact: true }) + const agentMode = page.getByRole('button', { name: /Agent integration/ }) + const cliMode = page.getByRole('button', { name: /\bCLI\b/ }) + const webMode = page.getByRole('button', { name: /Web interface/ }) - await expect(agentTab).toBeVisible() - await expect(humanTab).toBeVisible() - await expect(cliTab).toBeVisible() + await expect(agentMode).toBeVisible({ timeout: 15_000 }) + await expect(cliMode).toBeVisible() + await expect(webMode).toBeVisible() + await expect(agentMode).toHaveAttribute('aria-pressed', 'true') - await expect(agentTab).toHaveAttribute('aria-pressed', 'true') + await cliMode.click() + await expect(cliMode).toHaveAttribute('aria-pressed', 'true') + await expect(agentMode).toHaveAttribute('aria-pressed', 'false') + await expect(webMode).toHaveAttribute('aria-pressed', 'false') - await cliTab.click() - await expect(cliTab).toHaveAttribute('aria-pressed', 'true') - await expect(agentTab).toHaveAttribute('aria-pressed', 'false') - await expect(humanTab).toHaveAttribute('aria-pressed', 'false') - - await expect( - page.getByText('Install the SkillHub CLI locally to run skillhub install for skills.'), - ).toBeVisible() - await expect(page.getByText('npm i -g @astron-team/skillhub', { exact: true })).toBeVisible() + await expect(page.getByText('npx -y @astron-team/skillhub@0.1.12 --version', { exact: true })).toBeVisible() + await expect(page.getByText(/npx -y @astron-team\/skillhub@0\.1\.12 search weather/)).toBeVisible() + await expect(page.getByText(/npx -y @astron-team\/skillhub@0\.1\.12 install @global\/weather/)).toBeVisible() + await expect(page.getByRole('link', { name: 'CLI docs' })).toHaveAttribute( + 'href', + 'https://github.com/iflytek/skillhub/tree/main/cli', + ) }) - test('agent and human tabs expose the current SkillHub guidance', async ({ page }) => { + test('agent views expose Registry configuration and implicit discovery', async ({ page }) => { await page.goto('/') - const agentTab = page.getByRole('button', { name: 'I am Agent', exact: true }) - const humanTab = page.getByRole('button', { name: 'I am Human', exact: true }) + const registryTab = page.getByRole('tab', { name: 'Registry setup' }) + const discoveryTab = page.getByRole('tab', { name: 'Implicit discovery' }) + + await expect(registryTab).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText(/registry\/skill\.md/).first()).toBeVisible() + + await discoveryTab.click() + await expect(discoveryTab).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText('Search SkillHub Registry')).toBeVisible() + await expect(page.getByText('Match @global/weather · v1.3.0')).toBeVisible() - await expect( - page.getByText( - 'Connect SkillHub using http://127.0.0.1:3000/registry/skill.md', - { exact: true }, - ), - ).toBeVisible() const guideResponse = await page.request.get('/registry/skill.md') expect(guideResponse.status()).toBe(200) const guide = await guideResponse.text() - expect(guide).toContain('http://127.0.0.1:3000') + expect(guide).toContain('name: skillhub-cli') + expect(guide).toContain('removing the trailing `/registry/skill.md`') expect(guideResponse.headers()['cache-control']).toContain('no-cache') const hostileHostResponse = await page.request.get('/registry/skill.md', { headers: { Host: 'attacker.example' }, @@ -55,23 +60,5 @@ test.describe('Landing Quick Start CLI Tab (Real API)', () => { headers: { Host: 'chrome-extension:evil;echo_injected' }, }) expect(extensionHostResponse.status()).toBe(400) - - await humanTab.click() - await expect(humanTab).toHaveAttribute('aria-pressed', 'true') - await expect( - page.getByText( - 'npx @astron-team/skillhub@latest search --registry http://127.0.0.1:3000', - { exact: true }, - ), - ).toBeVisible() - - await agentTab.click() - await expect(agentTab).toHaveAttribute('aria-pressed', 'true') - await expect( - page.getByText( - 'Connect SkillHub using http://127.0.0.1:3000/registry/skill.md', - { exact: true }, - ), - ).toBeVisible() }) }) diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts index f41f808b..f3a28dde 100644 --- a/web/e2e/settings-pages.spec.ts +++ b/web/e2e/settings-pages.spec.ts @@ -15,11 +15,11 @@ test.describe('Settings Pages (Real API)', () => { await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible() }) - test('navigates to reset-password page from profile settings', async ({ page }) => { + test('navigates to security settings from profile settings', async ({ page }) => { await page.goto('/settings/profile') await page.getByRole('button', { name: 'Reset Password' }).click() - await expect(page).toHaveURL('/reset-password') - await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + await expect(page).toHaveURL('/settings/security') + await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() }) test('shows validation when current password is missing', async ({ page }) => { diff --git a/web/e2e/theme-toggle.spec.ts b/web/e2e/theme-toggle.spec.ts index e01105c8..556df646 100644 --- a/web/e2e/theme-toggle.spec.ts +++ b/web/e2e/theme-toggle.spec.ts @@ -161,7 +161,7 @@ test.describe('Light and dark theme', () => { await page.reload() await expect(page.locator('html')).toHaveClass(/dark/) await expect(page.getByRole('switch', { name: 'Dark theme' })).toHaveAttribute('aria-checked', 'true') - await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Turn team expertise into Agent-ready skills' })).toBeVisible() await expect.poll(() => page.evaluate(() => ( window as Window & { __themeAtFirstReactContent?: boolean } ).__themeAtFirstReactContent)).toBe(true) diff --git a/web/e2e/user-id-display.spec.ts b/web/e2e/user-id-display.spec.ts index 0f3f0485..e6901183 100644 --- a/web/e2e/user-id-display.spec.ts +++ b/web/e2e/user-id-display.spec.ts @@ -8,11 +8,10 @@ test.describe('User ID Display', () => { await registerSession(page, testInfo) }) - test('shows user ID in dashboard account card', async ({ page }) => { + test('shows user ID in the dashboard sidebar', async ({ page }) => { await page.goto('/dashboard') - await expect(page.getByText('Account Information')).toBeVisible() - const userIdText = page.getByText('User ID', { exact: false }) + const userIdText = page.getByRole('complementary').getByText('User ID', { exact: false }) await expect(userIdText).toBeVisible() // The dashboard renders "User ID: " in a single element. diff --git a/web/package.json b/web/package.json index c1c0f5f1..993b16bb 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "skillhub-web", "private": true, - "version": "0.1.0", + "version": "0.1.18", "type": "module", "packageManager": "pnpm@10.33.0", "pnpm": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c503257f..ceadcccd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -660,6 +660,13 @@ export const namespaceApi = { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, + async listMinePage(params?: { page?: number; size?: number }): Promise> { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params?.page ?? 0)) + searchParams.set('size', String(params?.size ?? 10)) + return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?${searchParams.toString()}`) + }, + async getDetail(slug: string): Promise { return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5fc821ed..d6c0271a 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2964,6 +2964,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/me/namespaces": { parameters: { query?: never; @@ -5093,11 +5125,11 @@ export interface components { /** Format: int32 */ size?: number; }; - ApiResponseListMyNamespaceResponse: { + ApiResponsePageResponseMyNamespaceResponse: { /** Format: int32 */ code?: number; msg?: string; - data?: components["schemas"]["MyNamespaceResponse"][]; + data?: components["schemas"]["PageResponseMyNamespaceResponse"]; /** Format: date-time */ timestamp?: string; requestId?: string; @@ -5127,6 +5159,24 @@ export interface components { canRestore?: boolean; canDelete?: boolean; }; + PageResponseMyNamespaceResponse: { + items?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponseListMyNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseGovernanceSummaryResponse: { /** Format: int32 */ code?: number; @@ -11240,6 +11290,50 @@ export interface operations { }; }; }; + listMyNamespacesPage: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + }; + }; + }; + }; + listMyNamespacesPage_1: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + }; + }; + }; + }; listMyNamespaces: { parameters: { query?: never; diff --git a/web/src/app/layout-header-style.test.ts b/web/src/app/layout-header-style.test.ts index 680aa658..11ed5c92 100644 --- a/web/src/app/layout-header-style.test.ts +++ b/web/src/app/layout-header-style.test.ts @@ -6,7 +6,7 @@ describe('getAppHeaderClassName', () => { const className = getAppHeaderClassName(false) expect(className).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME) - expect(className).toContain('bg-background/90') + expect(className).toContain('bg-background/70') expect(className).not.toContain('bg-white') }) diff --git a/web/src/app/layout-header-style.ts b/web/src/app/layout-header-style.ts index 49288285..4c1d420b 100644 --- a/web/src/app/layout-header-style.ts +++ b/web/src/app/layout-header-style.ts @@ -1,10 +1,10 @@ import { cn } from '@/shared/lib/utils' export const APP_HEADER_BASE_CLASS_NAME = - 'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12' + 'sticky top-0 z-50 flex items-center justify-between border-b border-border/30 bg-background/70 px-4 py-2.5 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/60 sm:px-6 md:px-12 min-h-[52px]' export const APP_HEADER_ELEVATED_CLASS_NAME = - 'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]' + 'border-b border-border/30 shadow-[0_1px_2px_0_rgb(0_0_0/0.04)]' export function getAppHeaderClassName(isElevated: boolean): string { return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME) diff --git a/web/src/app/layout-main-content.test.ts b/web/src/app/layout-main-content.test.ts index 1eb979a0..3a0e6f8b 100644 --- a/web/src/app/layout-main-content.test.ts +++ b/web/src/app/layout-main-content.test.ts @@ -33,7 +33,7 @@ describe('getAppMainContentLayout', () => { mainClassName: CENTERED_MAIN_CLASS_NAME, contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, }) - expect(layout.contentClassName).toContain('max-w-[1200px]') + expect(layout.contentClassName).toContain('max-w-[1100px]') }) it('leaves other non-landing routes on the default full-width app content layout', () => { diff --git a/web/src/app/layout-main-content.ts b/web/src/app/layout-main-content.ts index 28dedc11..e6ad95f4 100644 --- a/web/src/app/layout-main-content.ts +++ b/web/src/app/layout-main-content.ts @@ -1,8 +1,10 @@ export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10' export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12' -export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-8 md:py-10 lg:px-10 xl:px-14 2xl:px-20' +export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-12 md:py-10' export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' -export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' +export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1100px]' + +export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const interface AppMainContentLayout { mainClassName: string @@ -31,7 +33,7 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout } } - if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) { + if (pathname === '/dashboard' || pathname.startsWith('/dashboard/') || pathname.startsWith('/settings/')) { return { mainClassName: CENTERED_MAIN_CLASS_NAME, contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 7d5000a8..f99f7650 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,16 +1,22 @@ import { Suspense, useEffect, useRef, useState } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { Menu, X } from 'lucide-react' import { useAuth } from '@/features/auth/use-auth' +import { BrandMark } from '@/shared/components/brand-mark' import { LanguageSwitcher } from '@/shared/components/language-switcher' import { ThemeToggle } from '@/shared/components/theme-toggle' import { UserMenu } from '@/shared/components/user-menu' import { NotificationBell } from '@/features/notification/notification-bell' import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays' import { syncDocumentLanguage } from '@/shared/lib/document-language' +import { DashboardSidebar, SIDEBAR_GROUPS } from '@/pages/dashboard' +import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { getAppHeaderClassName } from './layout-header-style' import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' +const FOOTER_LINK_CLASS_NAME = 'group relative inline-flex py-0.5 transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:bg-foreground/60 after:transition-transform after:duration-200 hover:after:scale-x-100 motion-reduce:after:transition-none' + /** * Application shell shared by all routed pages. * @@ -27,9 +33,22 @@ export function Layout() { }) const { user, isLoading } = useAuth() const [isHeaderElevated, setIsHeaderElevated] = useState(false) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const previousPathnameRef = useRef(pathname) const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname) const mainContentLayout = getAppMainContentLayout(contentLayoutPathname) + const isDashboardSubRoute = pathname !== '/dashboard' && pathname.startsWith('/dashboard') + const showSidebar = (isDashboardSubRoute && pathname !== '/dashboard/publish') || pathname.startsWith('/settings/') + const governanceVisible = canViewGovernanceCenter(user?.platformRoles) + const filteredDashboardGroups = SIDEBAR_GROUPS + .map((group) => ({ + ...group, + items: group.items.filter((item) => ( + (!item.admin || governanceVisible) + && (!item.passwordCapability || user?.canChangePassword === true) + )), + })) + .filter((group) => group.items.length > 0) useEffect(() => { syncDocumentLanguage(i18n.resolvedLanguage ?? i18n.language) @@ -72,6 +91,10 @@ export function Layout() { const isActive = (to: string, exact?: boolean) => { if (exact) return pathname === to + // 「控制台」按钮:仅在 /dashboard 主页或 /settings/* 时高亮 + if (to === '/dashboard') { + return pathname === '/dashboard' || pathname.startsWith('/settings/') + } // Keep matching strict so parent dashboard paths do not highlight unrelated child links. return pathname === to } @@ -91,11 +114,12 @@ export function Layout() { {/* Header */}
- + SkillHub -
+ {/* Mobile nav dropdown */} + {mobileMenuOpen ? ( +
+ +
+ ) : null} + {/* Main content */}
-
-
-
+
+
+
+
} >
+ {showSidebar ? ( +
+ +
+ +
+
+ ) : ( -
+ )} +
{/* Footer */} -