From 170afb4a3b5c25e345d6ab5951b3766e99d285e3 Mon Sep 17 00:00:00 2001 From: Noa Virellia Date: Fri, 13 Feb 2026 13:25:36 +0800 Subject: [PATCH] feat: scanner sidebar Signed-off-by: Noa Virellia --- client/cms/package.json | 3 +- client/cms/playwright.config.ts | 20 ++++++ client/cms/pnpm-lock.yaml | 40 +++++++---- .../checkin/checkin-scanner-nav.container.tsx | 12 ++++ .../checkin/checkin-scanner-nav.view.tsx | 28 ++++++++ .../components/sidebar/app-sidebar.view.tsx | 6 +- .../components/sidebar/nav-secondary.view.tsx | 3 + client/cms/src/routes/_workbenchLayout.tsx | 6 ++ client/cms/tests/checkin-scanner.spec.ts | 66 +++++++++++++++++++ 9 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 client/cms/playwright.config.ts create mode 100644 client/cms/src/components/checkin/checkin-scanner-nav.container.tsx create mode 100644 client/cms/src/components/checkin/checkin-scanner-nav.view.tsx create mode 100644 client/cms/tests/checkin-scanner.spec.ts diff --git a/client/cms/package.json b/client/cms/package.json index 1ed41a6..56e9ac3 100644 --- a/client/cms/package.json +++ b/client/cms/package.json @@ -82,6 +82,7 @@ "@eslint-react/eslint-plugin": "^2.3.13", "@eslint/js": "^9.39.1", "@hey-api/openapi-ts": "0.91.0", + "@playwright/test": "^1.58.2", "@redux-devtools/extension": "^3.3.0", "@storybook/addon-a11y": "^10.2.3", "@storybook/addon-docs": "^10.2.3", @@ -109,7 +110,7 @@ "eslint-plugin-storybook": "^10.2.3", "globals": "^16.5.0", "lint-staged": "^16.2.7", - "playwright": "^1.58.0", + "playwright": "^1.58.2", "simple-git-hooks": "^2.13.1", "storybook": "^10.2.3", "tw-animate-css": "^1.4.0", diff --git a/client/cms/playwright.config.ts b/client/cms/playwright.config.ts new file mode 100644 index 0000000..59c3023 --- /dev/null +++ b/client/cms/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/client/cms/pnpm-lock.yaml b/client/cms/pnpm-lock.yaml index ece0349..09700eb 100644 --- a/client/cms/pnpm-lock.yaml +++ b/client/cms/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@hey-api/openapi-ts': specifier: 0.91.0 version: 0.91.0(magicast@0.5.1)(typescript@5.9.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@redux-devtools/extension': specifier: ^3.3.0 version: 3.3.0(redux@5.0.1) @@ -266,7 +269,7 @@ importers: version: 5.1.2(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/browser-playwright': specifier: ^4.0.18 - version: 4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) @@ -289,8 +292,8 @@ importers: specifier: ^16.2.7 version: 16.2.7 playwright: - specifier: ^1.58.0 - version: 1.58.0 + specifier: ^1.58.2 + version: 1.58.2 simple-git-hooks: specifier: ^2.13.1 version: 2.13.1 @@ -1125,6 +1128,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4599,13 +4607,13 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - playwright-core@1.58.0: - resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} hasBin: true - playwright@1.58.0: - resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==} + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true @@ -6353,6 +6361,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.1': {} @@ -7291,7 +7303,7 @@ snapshots: storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/browser-playwright': 4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/runner': 4.0.18 vitest: 4.0.18(@types/node@25.0.9)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: @@ -7970,11 +7982,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) - playwright: 1.58.0 + playwright: 1.58.2 tinyrainbow: 3.0.3 vitest: 4.0.18(@types/node@25.0.9)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: @@ -10254,11 +10266,11 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - playwright-core@1.58.0: {} + playwright-core@1.58.2: {} - playwright@1.58.0: + playwright@1.58.2: dependencies: - playwright-core: 1.58.0 + playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 @@ -11196,7 +11208,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.0.9 - '@vitest/browser-playwright': 4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) transitivePeerDependencies: - jiti - less diff --git a/client/cms/src/components/checkin/checkin-scanner-nav.container.tsx b/client/cms/src/components/checkin/checkin-scanner-nav.container.tsx new file mode 100644 index 0000000..1e1e821 --- /dev/null +++ b/client/cms/src/components/checkin/checkin-scanner-nav.container.tsx @@ -0,0 +1,12 @@ +import { useUserInfo } from '@/hooks/data/useUserInfo'; +import { CheckinScannerNavView } from './checkin-scanner-nav.view'; + +export function CheckinScannerNavContainer() { + const { data } = useUserInfo(); + + if ((data.data?.permission_level ?? 0) <= 20) { + return null; + } + + return ; +} diff --git a/client/cms/src/components/checkin/checkin-scanner-nav.view.tsx b/client/cms/src/components/checkin/checkin-scanner-nav.view.tsx new file mode 100644 index 0000000..dee5b18 --- /dev/null +++ b/client/cms/src/components/checkin/checkin-scanner-nav.view.tsx @@ -0,0 +1,28 @@ +import { IconScan } from '@tabler/icons-react'; +import { useState } from 'react'; +import { Dialog, DialogTrigger } from '@/components/ui/dialog'; +import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; +import { CheckinScannerDialogView } from './checkin-scanner.dialog.view'; + +export function CheckinScannerNavView() { + const [open, setOpen] = useState(false); + + const handleScan = (value: string) => { + console.log('Scanned:', value); + setOpen(false); + }; + + return ( + + + + + + 扫码签到 + + + + + + ); +} diff --git a/client/cms/src/components/sidebar/app-sidebar.view.tsx b/client/cms/src/components/sidebar/app-sidebar.view.tsx index f6f1017..af60510 100644 --- a/client/cms/src/components/sidebar/app-sidebar.view.tsx +++ b/client/cms/src/components/sidebar/app-sidebar.view.tsx @@ -13,7 +13,7 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; -export function AppSidebar({ navData, footerWidget, ...props }: React.ComponentProps & { navData: NavData; footerWidget: React.ReactNode }) { +export function AppSidebar({ navData, footerWidget, secondaryNavExtra, ...props }: React.ComponentProps & { navData: NavData; footerWidget: React.ReactNode; secondaryNavExtra?: React.ReactNode }) { return ( @@ -33,7 +33,9 @@ export function AppSidebar({ navData, footerWidget, ...props }: React.ComponentP - + + {secondaryNavExtra} + {footerWidget} diff --git a/client/cms/src/components/sidebar/nav-secondary.view.tsx b/client/cms/src/components/sidebar/nav-secondary.view.tsx index b0da89c..9f85e7b 100644 --- a/client/cms/src/components/sidebar/nav-secondary.view.tsx +++ b/client/cms/src/components/sidebar/nav-secondary.view.tsx @@ -14,6 +14,7 @@ import { export function NavSecondary({ items, + children, ...props }: { items: { @@ -21,6 +22,7 @@ export function NavSecondary({ url: string; icon: Icon; }[]; + children?: React.ReactNode; } & React.ComponentPropsWithoutRef) { return ( @@ -40,6 +42,7 @@ export function NavSecondary({ ))} + {children} diff --git a/client/cms/src/routes/_workbenchLayout.tsx b/client/cms/src/routes/_workbenchLayout.tsx index 65a366f..cc1d80d 100644 --- a/client/cms/src/routes/_workbenchLayout.tsx +++ b/client/cms/src/routes/_workbenchLayout.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Outlet, useRouterState } from '@tanstack/react-router'; import { Suspense } from 'react'; +import { CheckinScannerNavContainer } from '@/components/checkin/checkin-scanner-nav.container'; import { AppSidebar } from '@/components/sidebar/app-sidebar.view'; import { NavUserContainer } from '@/components/sidebar/nav-user.container'; import { NavUserSkeleton } from '@/components/sidebar/nav-user.skeletion'; @@ -37,6 +38,11 @@ function RouteComponent() { )} + secondaryNavExtra={( + + + + )} variant="inset" /> diff --git a/client/cms/tests/checkin-scanner.spec.ts b/client/cms/tests/checkin-scanner.spec.ts new file mode 100644 index 0000000..a45278c --- /dev/null +++ b/client/cms/tests/checkin-scanner.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Check-in Scanner Navigation', () => { + test('should be visible for users with high permission', async ({ page }) => { + await page.route('**/user/info', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'success', + code: 200, + data: { + user_id: 'test-user-id', + username: 'testuser', + nickname: 'Test User', + permission_level: 21, + avatar: 'https://example.com/avatar.png', + }, + }), + }); + }); + + await page.goto('/'); + + const sidebar = page.locator('[data-slot="sidebar"]'); + await expect(sidebar).toBeVisible(); + + const scannerButton = page.getByRole('button', { name: '扫码签到' }); + await expect(scannerButton).toBeVisible(); + + await scannerButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await expect(page.getByText('扫描签到码')).toBeVisible(); + }); + + test('should NOT be visible for users with low permission', async ({ page }) => { + await page.route('**/user/info', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'success', + code: 200, + data: { + user_id: 'test-user-id', + username: 'testuser', + nickname: 'Test User', + permission_level: 10, + avatar: 'https://example.com/avatar.png', + }, + }), + }); + }); + + await page.goto('/'); + + const sidebar = page.locator('[data-slot="sidebar"]'); + await expect(sidebar).toBeVisible(); + + const scannerButton = page.getByRole('button', { name: '扫码签到' }); + await expect(scannerButton).not.toBeVisible(); + }); +});