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();
+ });
+});