feat(ui): add management dashboard layout and tests (#450)

This commit is contained in:
shenlan 2025-10-07 10:18:36 +08:00 committed by GitHub
parent fcc58b091a
commit cb2c9716dc
10 changed files with 2566 additions and 30 deletions

View File

@ -0,0 +1,80 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OverviewCards from '../components/OverviewCards'
import TrendChart from '../components/TrendChart'
import PermissionMatrixEditor from '../components/PermissionMatrixEditor'
import UserGroupManagement from '../components/UserGroupManagement'
describe('Management dashboard components', () => {
it('renders loading state for overview cards', () => {
const { container } = render(<OverviewCards isLoading />)
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
})
it('supports switching trend granularity', () => {
const series = {
daily: [
{ period: '2025-03-01', total: 120, active: 80, subscribed: 40 },
{ period: '2025-03-02', total: 140, active: 90, subscribed: 50 },
],
weekly: [
{ period: '2025-W09', total: 900, active: 600, subscribed: 320 },
],
}
render(<TrendChart series={series} />)
expect(screen.getByText('2025-03-01')).toBeInTheDocument()
const weeklyButton = screen.getByRole('button', { name: '按周' })
fireEvent.click(weeklyButton)
expect(screen.getByText('2025-W09')).toBeInTheDocument()
})
it('disables permission matrix editing when read only', () => {
const matrix = {
registration: { admin: true, operator: false, user: false },
}
render(
<PermissionMatrixEditor
matrix={matrix}
roles={['admin', 'operator', 'user']}
canEdit={false}
/>,
)
for (const checkbox of screen.getAllByRole('checkbox')) {
expect(checkbox).toBeDisabled()
}
expect(screen.queryByRole('button', { name: /保存/ })).not.toBeInTheDocument()
})
it('flags pending role updates in user group management', () => {
const handleRoleChange = vi.fn()
const users = [
{ id: '1', email: 'admin@example.com', role: 'admin', active: true },
{ id: '2', email: 'operator@example.com', role: 'operator', active: false },
]
render(
<UserGroupManagement
users={users}
canEditRoles
pendingUserIds={new Set(['1'])}
onRoleChange={handleRoleChange}
/>,
)
const pendingSelect = screen.getAllByRole('combobox')[0]
expect(pendingSelect).toBeDisabled()
expect(screen.getByText('更新中…')).toBeInTheDocument()
const editableSelect = screen.getAllByRole('combobox')[1]
fireEvent.change(editableSelect, { target: { value: 'admin' } })
expect(handleRoleChange).toHaveBeenCalledWith('2', 'admin')
})
})

View File

@ -0,0 +1,67 @@
'use client'
import Card from '../../components/Card'
export type MetricsOverview = {
totalUsers: number
activeUsers: number
subscribedUsers: number
newUsersLast24h: number
}
type OverviewCardsProps = {
overview?: MetricsOverview
isLoading?: boolean
lastUpdatedLabel?: string
}
const METRIC_ITEMS: Array<{ key: keyof MetricsOverview; label: string; helper?: string }> = [
{ key: 'totalUsers', label: '注册用户' },
{ key: 'subscribedUsers', label: '订阅用户' },
{ key: 'activeUsers', label: '活跃用户' },
{ key: 'newUsersLast24h', label: '近 24 小时新增', helper: '包含注册与导入用户' },
]
export function OverviewCards({ overview, isLoading = false, lastUpdatedLabel }: OverviewCardsProps) {
return (
<Card>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p>
</div>
{lastUpdatedLabel ? (
<p className="text-xs text-gray-400">{lastUpdatedLabel}</p>
) : null}
</header>
<dl
className={`grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4 ${
isLoading ? 'animate-pulse opacity-80' : ''
}`}
aria-live="polite"
aria-busy={isLoading}
>
{METRIC_ITEMS.map(({ key, label, helper }) => {
const value = overview?.[key]
return (
<div
key={key}
className="rounded-xl border border-gray-200 bg-white/60 p-4 shadow-sm transition hover:border-purple-200 hover:shadow"
>
<dt className="text-sm font-medium text-gray-600">{label}</dt>
<dd className="mt-2 text-2xl font-semibold text-gray-900">
{isLoading ? <span className="inline-block h-6 w-20 rounded bg-gray-200" /> : value ?? '—'}
</dd>
{helper ? <p className="mt-1 text-xs text-gray-400">{helper}</p> : null}
</div>
)
})}
</dl>
</div>
</Card>
)
}
export default OverviewCards

View File

@ -0,0 +1,124 @@
'use client'
import Card from '../../components/Card'
export type PermissionMatrix = Record<string, Record<string, boolean>>
export type PermissionMatrixEditorProps = {
matrix?: PermissionMatrix
roles: string[]
canEdit: boolean
isLoading?: boolean
isSaving?: boolean
hasChanges?: boolean
statusMessage?: string
errorMessage?: string
onToggle?: (moduleKey: string, role: string, nextValue: boolean) => void
onSave?: () => void
}
export function PermissionMatrixEditor({
matrix,
roles,
canEdit,
isLoading = false,
isSaving = false,
hasChanges = false,
statusMessage,
errorMessage,
onToggle,
onSave,
}: PermissionMatrixEditorProps) {
const moduleEntries = matrix ? Object.entries(matrix) : []
const showEmptyState = !isLoading && moduleEntries.length === 0
return (
<Card>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500">访</p>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{canEdit ? '管理员可编辑配置' : '只读视图'}</span>
{isSaving ? <span className="text-purple-500"></span> : null}
{statusMessage ? <span className="text-green-600">{statusMessage}</span> : null}
{errorMessage ? <span className="text-red-500">{errorMessage}</span> : null}
</div>
</header>
<div className="overflow-x-auto" aria-busy={isLoading} aria-live="polite">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-10 w-full animate-pulse rounded bg-gray-200/70" />
))}
</div>
) : showEmptyState ? (
<div className="rounded-lg border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500">
</div>
) : (
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50/80">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-600"></th>
{roles.map((role) => (
<th key={role} className="px-4 py-2 text-left font-medium text-gray-600 capitalize">
{role}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white/80">
{moduleEntries.map(([moduleKey, roleMap]) => (
<tr key={moduleKey} className="transition hover:bg-purple-50/50">
<td className="px-4 py-3 font-medium text-gray-700">{moduleKey}</td>
{roles.map((role) => {
const checked = Boolean(roleMap?.[role])
return (
<td key={`${moduleKey}-${role}`} className="px-4 py-3">
<label className={`inline-flex items-center gap-2 text-sm ${canEdit ? 'cursor-pointer' : 'cursor-not-allowed'}`}>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={checked}
disabled={!canEdit}
onChange={() => {
if (!onToggle) {
return
}
onToggle(moduleKey, role, !checked)
}}
/>
<span className="capitalize text-gray-600">{checked ? '启用' : '关闭'}</span>
</label>
</td>
)
})}
</tr>
))}
</tbody>
</table>
)}
</div>
{canEdit ? (
<div className="flex justify-end">
<button
type="button"
onClick={onSave}
disabled={!hasChanges || isSaving}
className="inline-flex items-center rounded-full bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow transition enabled:hover:bg-purple-500 disabled:cursor-not-allowed disabled:bg-purple-200"
>
{hasChanges && !isSaving ? '*' : ''}
</button>
</div>
) : null}
</div>
</Card>
)
}
export default PermissionMatrixEditor

View File

@ -0,0 +1,150 @@
'use client'
import { useMemo, useState } from 'react'
import Card from '../../components/Card'
export type MetricsPoint = {
period: string
total: number
active: number
subscribed: number
}
export type MetricsSeries = {
daily: MetricsPoint[]
weekly: MetricsPoint[]
}
type TrendChartProps = {
series?: MetricsSeries
isLoading?: boolean
}
type Granularity = 'daily' | 'weekly'
function buildSparkline(points: MetricsPoint[]) {
if (!points || points.length === 0) {
return ''
}
const totals = points.map((point) => point.total)
const maxValue = Math.max(...totals, 1)
const lastIndex = totals.length - 1 || 1
return totals
.map((value, index) => {
const x = (index / lastIndex) * 100
const y = 100 - (value / maxValue) * 100
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`
})
.join(' ')
}
export function TrendChart({ series, isLoading = false }: TrendChartProps) {
const [granularity, setGranularity] = useState<Granularity>('daily')
const points = useMemo(() => {
if (!series) {
return [] as MetricsPoint[]
}
return granularity === 'daily' ? series.daily : series.weekly
}, [granularity, series])
const sparklinePath = useMemo(() => buildSparkline(points), [points])
return (
<Card>
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white/80 p-1 text-xs shadow-sm">
{(
[
{ key: 'daily', label: '按日' },
{ key: 'weekly', label: '按周' },
] as Array<{ key: Granularity; label: string }>
).map((option) => (
<button
key={option.key}
type="button"
className={`rounded-full px-3 py-1 font-medium transition ${
granularity === option.key
? 'bg-purple-600 text-white shadow'
: 'text-gray-600 hover:bg-purple-50'
}`}
onClick={() => setGranularity(option.key)}
aria-pressed={granularity === option.key}
>
{option.label}
</button>
))}
</div>
</header>
<div className="flex flex-col gap-4" aria-busy={isLoading} aria-live="polite">
<div className="relative h-32 w-full overflow-hidden rounded-xl border border-purple-100 bg-gradient-to-br from-purple-50 via-white to-indigo-50">
{isLoading ? (
<div className="absolute inset-0 animate-pulse bg-gradient-to-r from-transparent via-purple-100/60 to-transparent" />
) : sparklinePath ? (
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full text-purple-500">
<path
d={`${sparklinePath}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-400"></div>
)}
</div>
<div className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm">
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500">
<tr>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white/80">
{isLoading
? Array.from({ length: 4 }).map((_, index) => (
<tr key={index} className="animate-pulse">
<td className="px-3 py-3">
<span className="inline-block h-4 w-24 rounded bg-gray-200" />
</td>
<td className="px-3 py-3">
<span className="inline-block h-4 w-16 rounded bg-gray-200" />
</td>
<td className="px-3 py-3">
<span className="inline-block h-4 w-16 rounded bg-gray-200" />
</td>
<td className="px-3 py-3">
<span className="inline-block h-4 w-16 rounded bg-gray-200" />
</td>
</tr>
))
: points.map((point) => (
<tr key={`${granularity}-${point.period}`} className="transition hover:bg-purple-50/50">
<td className="px-3 py-2 font-medium text-gray-700">{point.period}</td>
<td className="px-3 py-2 text-gray-900">{point.total}</td>
<td className="px-3 py-2 text-gray-900">{point.active}</td>
<td className="px-3 py-2 text-gray-900">{point.subscribed}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</Card>
)
}
export default TrendChart

View File

@ -0,0 +1,142 @@
'use client'
import { useMemo } from 'react'
import Card from '../../components/Card'
export type ManagedUser = {
id: string
email: string
role?: string
groups?: string[]
active?: boolean
created_at?: string
}
type UserGroupManagementProps = {
users?: ManagedUser[]
isLoading?: boolean
pendingUserIds?: Set<string>
canEditRoles: boolean
onRoleChange?: (userId: string, role: string) => void
onInvite?: () => void
onImport?: () => void
}
const ROLE_OPTIONS = [
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '运营者' },
{ value: 'user', label: '用户' },
]
export function UserGroupManagement({
users,
isLoading = false,
pendingUserIds,
canEditRoles,
onRoleChange,
onInvite,
onImport,
}: UserGroupManagementProps) {
const data = useMemo(() => users ?? [], [users])
const pendingSet = pendingUserIds ?? new Set<string>()
return (
<Card>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onInvite}
className="inline-flex items-center rounded-full border border-purple-200 px-4 py-2 text-sm font-medium text-purple-600 transition hover:border-purple-300 hover:bg-purple-50"
>
</button>
<button
type="button"
onClick={onImport}
className="inline-flex items-center rounded-full border border-purple-200 px-4 py-2 text-sm font-medium text-purple-600 transition hover:border-purple-300 hover:bg-purple-50"
>
</button>
</div>
</header>
<div className="overflow-x-auto" aria-busy={isLoading} aria-live="polite">
<table className="min-w-full divide-y divide-gray-200 text-left text-sm">
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white/80">
{isLoading
? Array.from({ length: 5 }).map((_, index) => (
<tr key={index} className="animate-pulse">
<td className="px-4 py-3">
<span className="inline-block h-4 w-48 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-24 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-32 rounded bg-gray-200" />
</td>
<td className="px-4 py-3">
<span className="inline-block h-4 w-16 rounded bg-gray-200" />
</td>
</tr>
))
: data.map((user) => {
const role = user.role ?? 'user'
const isPending = pendingSet.has(user.id)
return (
<tr key={user.id} className="transition hover:bg-purple-50/50">
<td className="px-4 py-3 text-sm font-medium text-gray-800">{user.email}</td>
<td className="px-4 py-3">
<select
className="w-40 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
value={role}
disabled={!canEditRoles || isPending}
onChange={(event) => onRoleChange?.(user.id, event.target.value)}
>
{ROLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isPending ? <p className="mt-1 text-xs text-purple-500"></p> : null}
</td>
<td className="px-4 py-3 text-gray-600">{user.groups?.join('、') || '—'}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
user.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}
>
{user.active ? '活跃' : '未激活'}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
{!isLoading && data.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500"></div>
) : null}
</div>
</div>
</Card>
)
}
export default UserGroupManagement

View File

@ -0,0 +1,276 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import useSWR from 'swr'
import TrendChart, { type MetricsSeries } from './components/TrendChart'
import OverviewCards, { type MetricsOverview } from './components/OverviewCards'
import PermissionMatrixEditor, {
type PermissionMatrix,
} from './components/PermissionMatrixEditor'
import UserGroupManagement, { type ManagedUser } from './components/UserGroupManagement'
import Card from '../components/Card'
import { useUser } from '@lib/userStore'
type UserMetricsResponse = {
overview: MetricsOverview
series: MetricsSeries
}
type AdminSettingsResponse = {
version: number
matrix: PermissionMatrix
}
type ApiError = {
error?: string
message?: string
matrix?: PermissionMatrix
version?: number
}
async function jsonFetcher<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
...init,
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers),
},
cache: 'no-store',
})
if (!response.ok) {
let payload: ApiError | undefined
try {
payload = (await response.json()) as ApiError
} catch (error) {
// Ignore JSON parse errors; fall back to status text below.
}
const message = payload?.error ?? payload?.message ?? response.statusText
throw new Error(message || '请求失败')
}
return (await response.json()) as T
}
export default function ManagementPage() {
const { user, isLoading: isUserLoading } = useUser()
const canAccess = Boolean(user?.isAdmin || user?.isOperator)
const canEditPermissions = Boolean(user?.isAdmin)
const canEditRoles = Boolean(user?.isAdmin)
const [matrixDraft, setMatrixDraft] = useState<PermissionMatrix>({})
const [matrixVersion, setMatrixVersion] = useState<number>(0)
const [matrixDirty, setMatrixDirty] = useState(false)
const [matrixSaving, setMatrixSaving] = useState(false)
const [matrixStatus, setMatrixStatus] = useState<string | undefined>()
const [matrixError, setMatrixError] = useState<string | undefined>()
const [roleUpdateMessage, setRoleUpdateMessage] = useState<string | undefined>()
const [pendingRoleUpdates, setPendingRoleUpdates] = useState<Set<string>>(new Set())
const metricsSWR = useSWR<UserMetricsResponse>(canAccess ? '/api/admin/users/metrics' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const settingsSWR = useSWR<AdminSettingsResponse>(canAccess ? '/api/admin/settings' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const usersSWR = useSWR<ManagedUser[]>(canAccess ? '/api/users' : null, jsonFetcher, {
revalidateOnFocus: false,
})
useEffect(() => {
if (settingsSWR.data?.matrix) {
setMatrixDraft(settingsSWR.data.matrix)
setMatrixVersion(settingsSWR.data.version)
setMatrixDirty(false)
setMatrixError(undefined)
}
}, [settingsSWR.data])
const lastUpdatedLabel = useMemo(() => {
if (!metricsSWR.data) {
return undefined
}
const now = new Date()
return `更新于 ${now.toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}`
}, [metricsSWR.data])
const handleTogglePermission = useCallback(
(moduleKey: string, role: string, nextValue: boolean) => {
setMatrixDraft((prev) => {
const next: PermissionMatrix = { ...prev }
const normalizedModuleKey = moduleKey.trim()
const normalizedRole = role.trim()
const currentRoleMap = next[normalizedModuleKey] ?? {}
next[normalizedModuleKey] = { ...currentRoleMap, [normalizedRole]: nextValue }
return next
})
setMatrixDirty(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
},
[],
)
const handleSaveMatrix = useCallback(async () => {
if (!canEditPermissions || !matrixDirty) {
return
}
setMatrixSaving(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
version: matrixVersion,
matrix: matrixDraft,
}),
})
if (response.ok) {
const payload = (await response.json()) as AdminSettingsResponse
setMatrixDraft(payload.matrix)
setMatrixVersion(payload.version)
setMatrixDirty(false)
setMatrixStatus('已保存')
settingsSWR.mutate(payload, { revalidate: false })
return
}
let payload: ApiError | undefined
try {
payload = (await response.json()) as ApiError
} catch (error) {
// ignore parsing error
}
if (response.status === 409 && payload?.matrix) {
setMatrixDraft(payload.matrix)
if (typeof payload.version === 'number') {
setMatrixVersion(payload.version)
}
setMatrixDirty(false)
setMatrixError(payload.message ?? '配置已被其他人更新,已同步最新版本')
return
}
const message = payload?.error ?? payload?.message ?? '保存失败'
throw new Error(message)
} catch (error) {
setMatrixError(error instanceof Error ? error.message : '保存失败')
} finally {
setMatrixSaving(false)
}
}, [canEditPermissions, matrixDirty, matrixDraft, matrixVersion, settingsSWR])
const markRolePending = useCallback((userId: string, pending: boolean) => {
setPendingRoleUpdates((prev) => {
const next = new Set(prev)
if (pending) {
next.add(userId)
} else {
next.delete(userId)
}
return next
})
}, [])
const handleRoleChange = useCallback(
async (userId: string, role: string) => {
if (!canEditRoles) {
return
}
setRoleUpdateMessage(undefined)
markRolePending(userId, true)
try {
await jsonFetcher(`/api/admin/users/${userId}/role`, {
method: 'POST',
body: JSON.stringify({ role }),
})
setRoleUpdateMessage('角色已更新')
usersSWR.mutate()
} catch (error) {
setRoleUpdateMessage(error instanceof Error ? error.message : '角色更新失败')
} finally {
markRolePending(userId, false)
}
},
[canEditRoles, markRolePending, usersSWR],
)
if (isUserLoading) {
return (
<div className="flex flex-col gap-4">
<Card>
<div className="h-24 animate-pulse rounded bg-gray-200/60" />
</Card>
<Card>
<div className="h-64 animate-pulse rounded bg-gray-200/60" />
</Card>
</div>
)
}
if (!canAccess) {
return (
<Card>
<div className="flex flex-col items-start gap-3 text-sm text-gray-700">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p></p>
</div>
</Card>
)
}
return (
<div className="flex flex-col gap-5">
<OverviewCards
overview={metricsSWR.data?.overview}
isLoading={metricsSWR.isLoading}
lastUpdatedLabel={lastUpdatedLabel}
/>
<TrendChart series={metricsSWR.data?.series} isLoading={metricsSWR.isLoading} />
<PermissionMatrixEditor
matrix={matrixDraft}
roles={['admin', 'operator', 'user']}
canEdit={canEditPermissions}
isLoading={settingsSWR.isLoading}
isSaving={matrixSaving}
hasChanges={matrixDirty}
statusMessage={matrixStatus}
errorMessage={matrixError}
onToggle={handleTogglePermission}
onSave={handleSaveMatrix}
/>
<UserGroupManagement
users={usersSWR.data}
isLoading={usersSWR.isLoading}
canEditRoles={canEditRoles}
pendingUserIds={pendingRoleUpdates}
onRoleChange={handleRoleChange}
onInvite={() => setRoleUpdateMessage('邀请入口尚未接入,可在后台触发工单流程。')}
onImport={() => setRoleUpdateMessage('导入入口尚未接入,请联系管理员执行批量导入。')}
/>
{roleUpdateMessage ? (
<div className="rounded-xl border border-purple-100 bg-purple-50/60 px-4 py-3 text-sm text-purple-700">
{roleUpdateMessage}
</div>
) : null}
</div>
)
}

View File

@ -12,7 +12,8 @@
"build:static": "yarn prebuild && node ../../scripts/check-build.js && node ./node_modules/next/dist/bin/next build",
"export": "node ./node_modules/next/dist/bin/next build",
"start": "node ./node_modules/next/dist/bin/next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"dompurify": "^3.2.6",
@ -32,15 +33,21 @@
"zustand": "^4.5.4"
},
"devDependencies": {
"@testing-library/dom": "^9.3.1",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "24.0.3",
"@types/react": "19.1.8",
"@types/react-grid-layout": "^1.3.5",
"autoprefixer": "^10.4.16",
"eslint": "8.57.0",
"eslint-config-next": "^15.5.3",
"jsdom": "^24.0.0",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.3",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
"typescript": "^5.4.2",
"vitest": "^1.6.0"
}
}

View File

@ -0,0 +1,28 @@
import path from 'node:path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
include: ['app/**/*.test.{ts,tsx}', 'app/**/*.__tests__/*.{ts,tsx}', 'app/**/__tests__/**/*.{ts,tsx}'],
environmentOptions: {
jsdom: {
url: 'http://localhost',
},
},
},
resolve: {
alias: {
'@components': path.resolve(__dirname, 'components'),
'@i18n': path.resolve(__dirname, 'i18n'),
'@lib': path.resolve(__dirname, 'lib'),
'@types': path.resolve(__dirname, 'types'),
},
},
esbuild: {
loader: 'tsx',
jsx: 'automatic',
},
})

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

File diff suppressed because it is too large Load Diff