feat(ui): add management dashboard layout and tests (#450)
This commit is contained in:
parent
fcc58b091a
commit
cb2c9716dc
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
150
ui/homepage/app/panel/management/components/TrendChart.tsx
Normal file
150
ui/homepage/app/panel/management/components/TrendChart.tsx
Normal 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
|
||||
@ -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
|
||||
276
ui/homepage/app/panel/management/page.tsx
Normal file
276
ui/homepage/app/panel/management/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
28
ui/homepage/vitest.config.ts
Normal file
28
ui/homepage/vitest.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
1
ui/homepage/vitest.setup.ts
Normal file
1
ui/homepage/vitest.setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user