fix management dashboard user status
This commit is contained in:
parent
081bedd637
commit
ad7c76e6e6
@ -1,80 +1,122 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
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'
|
||||
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()
|
||||
})
|
||||
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', () => {
|
||||
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 },
|
||||
{ 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 },
|
||||
{ period: "2025-W09", total: 900, active: 600, subscribed: 320 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
render(<TrendChart series={series} />)
|
||||
render(<TrendChart series={series} />);
|
||||
|
||||
expect(screen.getByText('2025-03-01')).toBeInTheDocument()
|
||||
expect(screen.queryByText("2025-03-01")).not.toBeVisible();
|
||||
|
||||
const weeklyButton = screen.getByRole('button', { name: '按周' })
|
||||
fireEvent.click(weeklyButton)
|
||||
const detailsButton = screen.getByRole("button", { name: "展开明细" });
|
||||
expect(detailsButton).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(detailsButton);
|
||||
|
||||
expect(screen.getByText('2025-W09')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText("2025-03-01")).toBeVisible();
|
||||
expect(detailsButton).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
it('disables permission matrix editing when read only', () => {
|
||||
const weeklyButton = screen.getByRole("button", { name: "按周" });
|
||||
fireEvent.click(weeklyButton);
|
||||
|
||||
expect(screen.getByText("2025-W09")).toBeVisible();
|
||||
});
|
||||
|
||||
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']}
|
||||
roles={["admin", "operator", "user"]}
|
||||
canEdit={false}
|
||||
/>,
|
||||
)
|
||||
);
|
||||
|
||||
for (const checkbox of screen.getAllByRole('checkbox')) {
|
||||
expect(checkbox).toBeDisabled()
|
||||
for (const checkbox of screen.getAllByRole("checkbox")) {
|
||||
expect(checkbox).toBeDisabled();
|
||||
}
|
||||
expect(screen.queryByRole('button', { name: /保存/ })).not.toBeInTheDocument()
|
||||
})
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /保存/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags pending role updates in user group management', () => {
|
||||
const handleRoleChange = vi.fn()
|
||||
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 },
|
||||
]
|
||||
{
|
||||
id: "1",
|
||||
email: "admin@example.com",
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
email: "operator@example.com",
|
||||
role: "operator",
|
||||
active: false,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UserGroupManagement
|
||||
users={users}
|
||||
canEditRoles
|
||||
pendingUserIds={new Set(['1'])}
|
||||
pendingUserIds={new Set(["1"])}
|
||||
onRoleChange={handleRoleChange}
|
||||
/>,
|
||||
)
|
||||
);
|
||||
|
||||
const pendingSelect = screen.getAllByRole('combobox')[0]
|
||||
expect(pendingSelect).toBeDisabled()
|
||||
expect(screen.getByText('更新中…')).toBeInTheDocument()
|
||||
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')
|
||||
})
|
||||
})
|
||||
const editableSelect = screen.getAllByRole("combobox")[1];
|
||||
fireEvent.change(editableSelect, { target: { value: "admin" } });
|
||||
expect(handleRoleChange).toHaveBeenCalledWith("2", "admin");
|
||||
});
|
||||
|
||||
it("shows usernames and treats missing active flags as enabled", () => {
|
||||
render(
|
||||
<UserGroupManagement
|
||||
users={[
|
||||
{
|
||||
id: "1",
|
||||
email: "default-active@example.com",
|
||||
username: "defaultActive",
|
||||
role: "user",
|
||||
},
|
||||
]}
|
||||
canEditRoles
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("用户名")).toBeInTheDocument();
|
||||
expect(screen.getByText("defaultActive")).toBeInTheDocument();
|
||||
expect(screen.getByText("活跃")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "暂停" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("已暂停")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,54 +1,55 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import Card from '../../components/Card'
|
||||
import { useMemo, useState } from "react";
|
||||
import Card from "../../components/Card";
|
||||
|
||||
export type MetricsPoint = {
|
||||
period: string
|
||||
total: number
|
||||
active: number
|
||||
subscribed: number
|
||||
}
|
||||
period: string;
|
||||
total: number;
|
||||
active: number;
|
||||
subscribed: number;
|
||||
};
|
||||
|
||||
export type MetricsSeries = {
|
||||
daily: MetricsPoint[]
|
||||
weekly: MetricsPoint[]
|
||||
}
|
||||
daily: MetricsPoint[];
|
||||
weekly: MetricsPoint[];
|
||||
};
|
||||
|
||||
type TrendChartProps = {
|
||||
series?: MetricsSeries
|
||||
isLoading?: boolean
|
||||
}
|
||||
series?: MetricsSeries;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type Granularity = 'daily' | 'weekly'
|
||||
type Granularity = "daily" | "weekly";
|
||||
|
||||
function buildSparkline(points: MetricsPoint[]) {
|
||||
if (!points || points.length === 0) {
|
||||
return ''
|
||||
return "";
|
||||
}
|
||||
const totals = points.map((point) => point.total)
|
||||
const maxValue = Math.max(...totals, 1)
|
||||
const lastIndex = totals.length - 1 || 1
|
||||
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)}`
|
||||
const x = (index / lastIndex) * 100;
|
||||
const y = 100 - (value / maxValue) * 100;
|
||||
return `${index === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(' ')
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function TrendChart({ series, isLoading = false }: TrendChartProps) {
|
||||
const [granularity, setGranularity] = useState<Granularity>('daily')
|
||||
const [granularity, setGranularity] = useState<Granularity>("daily");
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
|
||||
const points = useMemo(() => {
|
||||
if (!series) {
|
||||
return [] as MetricsPoint[]
|
||||
return [] as MetricsPoint[];
|
||||
}
|
||||
return granularity === 'daily' ? series.daily : series.weekly
|
||||
}, [granularity, series])
|
||||
return granularity === "daily" ? series.daily : series.weekly;
|
||||
}, [granularity, series]);
|
||||
|
||||
const sparklinePath = useMemo(() => buildSparkline(points), [points])
|
||||
const sparklinePath = useMemo(() => buildSparkline(points), [points]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@ -56,13 +57,15 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
|
||||
<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>
|
||||
<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: '按周' },
|
||||
{ key: "daily", label: "按日" },
|
||||
{ key: "weekly", label: "按周" },
|
||||
] as Array<{ key: Granularity; label: string }>
|
||||
).map((option) => (
|
||||
<button
|
||||
@ -70,8 +73,8 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
|
||||
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'
|
||||
? "bg-purple-600 text-white shadow"
|
||||
: "text-gray-600 hover:bg-purple-50"
|
||||
}`}
|
||||
onClick={() => setGranularity(option.key)}
|
||||
aria-pressed={granularity === option.key}
|
||||
@ -82,12 +85,20 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-4" aria-busy={isLoading} aria-live="polite">
|
||||
<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">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full text-purple-500"
|
||||
>
|
||||
<path
|
||||
d={`${sparklinePath}`}
|
||||
fill="none"
|
||||
@ -98,11 +109,27 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">暂无数据</div>
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-fit items-center rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm transition hover:border-purple-200 hover:bg-purple-50 hover:text-purple-700"
|
||||
onClick={() => setIsDetailsOpen((current) => !current)}
|
||||
aria-expanded={isDetailsOpen}
|
||||
aria-controls="management-trend-details"
|
||||
>
|
||||
{isDetailsOpen ? "收起明细" : "展开明细"}
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="management-trend-details"
|
||||
className="overflow-x-auto"
|
||||
hidden={!isDetailsOpen}
|
||||
>
|
||||
<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>
|
||||
@ -131,11 +158,22 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
|
||||
</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
|
||||
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>
|
||||
@ -144,7 +182,7 @@ export function TrendChart({ series, isLoading = false }: TrendChartProps) {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default TrendChart
|
||||
export default TrendChart;
|
||||
|
||||
@ -1,55 +1,70 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useMemo, useState } from 'react'
|
||||
import Card from '../../components/Card'
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import Card from "../../components/Card";
|
||||
|
||||
export type ManagedUser = {
|
||||
id: string
|
||||
email: string
|
||||
role?: string
|
||||
groups?: string[]
|
||||
active?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
id: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
groups?: string[];
|
||||
active?: boolean;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type CreateManagedUserInput = {
|
||||
email: string
|
||||
uuid: string
|
||||
groups: string[]
|
||||
}
|
||||
email: string;
|
||||
uuid: string;
|
||||
groups: string[];
|
||||
};
|
||||
|
||||
type UserGroupManagementProps = {
|
||||
users?: ManagedUser[]
|
||||
isLoading?: boolean
|
||||
pendingUserIds?: Set<string>
|
||||
canEditRoles: boolean
|
||||
canCreateCustomUser?: boolean
|
||||
onRoleChange?: (userId: string, role: string) => void
|
||||
onInvite?: () => void
|
||||
onImport?: () => void
|
||||
onPauseUser?: (userId: string) => void
|
||||
onResumeUser?: (userId: string) => void
|
||||
onDeleteUser?: (userId: string) => void
|
||||
onRenewUuid?: (userId: string) => void
|
||||
onManageBlacklist?: () => void
|
||||
onCreateCustomUser?: (input: CreateManagedUserInput) => Promise<void> | void
|
||||
}
|
||||
users?: ManagedUser[];
|
||||
isLoading?: boolean;
|
||||
pendingUserIds?: Set<string>;
|
||||
canEditRoles: boolean;
|
||||
canCreateCustomUser?: boolean;
|
||||
onRoleChange?: (userId: string, role: string) => void;
|
||||
onInvite?: () => void;
|
||||
onImport?: () => void;
|
||||
onPauseUser?: (userId: string) => void;
|
||||
onResumeUser?: (userId: string) => void;
|
||||
onDeleteUser?: (userId: string) => void;
|
||||
onRenewUuid?: (userId: string) => void;
|
||||
onManageBlacklist?: () => void;
|
||||
onCreateCustomUser?: (input: CreateManagedUserInput) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'operator', label: '运营者' },
|
||||
{ value: 'user', label: '用户' },
|
||||
]
|
||||
{ value: "admin", label: "管理员" },
|
||||
{ value: "operator", label: "运营者" },
|
||||
{ value: "user", label: "用户" },
|
||||
];
|
||||
|
||||
function parseGroupList(input: string): string[] {
|
||||
const values = input
|
||||
.split(/[\n,,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
return Array.from(new Set(values))
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
function getUserDisplayName(user: ManagedUser): string {
|
||||
const username = user.username?.trim();
|
||||
if (username) {
|
||||
return username;
|
||||
}
|
||||
|
||||
const name = user.name?.trim();
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function UserGroupManagement({
|
||||
users,
|
||||
@ -67,53 +82,56 @@ export function UserGroupManagement({
|
||||
onManageBlacklist,
|
||||
onCreateCustomUser,
|
||||
}: UserGroupManagementProps) {
|
||||
const data = useMemo(() => users ?? [], [users])
|
||||
const pendingSet = pendingUserIds ?? new Set<string>()
|
||||
const data = useMemo(() => users ?? [], [users]);
|
||||
const pendingSet = pendingUserIds ?? new Set<string>();
|
||||
|
||||
const [customEmail, setCustomEmail] = useState('')
|
||||
const [customUuid, setCustomUuid] = useState('')
|
||||
const [customGroups, setCustomGroups] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createMessage, setCreateMessage] = useState<string | undefined>()
|
||||
const [createError, setCreateError] = useState<string | undefined>()
|
||||
const [customEmail, setCustomEmail] = useState("");
|
||||
const [customUuid, setCustomUuid] = useState("");
|
||||
const [customGroups, setCustomGroups] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createMessage, setCreateMessage] = useState<string | undefined>();
|
||||
const [createError, setCreateError] = useState<string | undefined>();
|
||||
|
||||
const parsedCustomGroups = useMemo(() => parseGroupList(customGroups), [customGroups])
|
||||
const parsedCustomGroups = useMemo(
|
||||
() => parseGroupList(customGroups),
|
||||
[customGroups],
|
||||
);
|
||||
|
||||
const handleCreateCustomUser = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
event.preventDefault();
|
||||
if (!onCreateCustomUser) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const email = customEmail.trim()
|
||||
const uuid = customUuid.trim()
|
||||
const email = customEmail.trim();
|
||||
const uuid = customUuid.trim();
|
||||
if (!email || !uuid) {
|
||||
setCreateError('请填写邮箱与 UUID')
|
||||
return
|
||||
setCreateError("请填写邮箱与 UUID");
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = parsedCustomGroups
|
||||
const groups = parsedCustomGroups;
|
||||
if (groups.length === 0) {
|
||||
setCreateError('请至少填写一个用户组')
|
||||
return
|
||||
setCreateError("请至少填写一个用户组");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true)
|
||||
setCreateError(undefined)
|
||||
setCreateMessage(undefined)
|
||||
setIsCreating(true);
|
||||
setCreateError(undefined);
|
||||
setCreateMessage(undefined);
|
||||
|
||||
try {
|
||||
await onCreateCustomUser({ email, uuid, groups })
|
||||
setCreateMessage('用户创建成功')
|
||||
setCustomEmail('')
|
||||
setCustomUuid('')
|
||||
setCustomGroups('')
|
||||
await onCreateCustomUser({ email, uuid, groups });
|
||||
setCreateMessage("用户创建成功");
|
||||
setCustomEmail("");
|
||||
setCustomUuid("");
|
||||
setCustomGroups("");
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : '创建失败')
|
||||
setCreateError(error instanceof Error ? error.message : "创建失败");
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@ -121,7 +139,9 @@ export function UserGroupManagement({
|
||||
<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>
|
||||
<p className="text-sm text-gray-500">
|
||||
查看当前成员并调整角色或发起邀请
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
@ -149,9 +169,16 @@ export function UserGroupManagement({
|
||||
</header>
|
||||
|
||||
{canCreateCustomUser ? (
|
||||
<form onSubmit={handleCreateCustomUser} className="rounded-xl border border-purple-100 bg-purple-50/60 p-4">
|
||||
<h3 className="text-sm font-semibold text-purple-800">Root 管理员专用:创建自定义 UUID 用户</h3>
|
||||
<p className="mt-1 text-xs text-purple-700">支持一次配置多个分组(逗号或换行分隔)。</p>
|
||||
<form
|
||||
onSubmit={handleCreateCustomUser}
|
||||
className="rounded-xl border border-purple-100 bg-purple-50/60 p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-purple-800">
|
||||
Root 管理员专用:创建自定义 UUID 用户
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-purple-700">
|
||||
支持一次配置多个分组(逗号或换行分隔)。
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-xs text-gray-600">
|
||||
邮箱
|
||||
@ -190,19 +217,28 @@ export function UserGroupManagement({
|
||||
disabled={isCreating}
|
||||
className="inline-flex items-center rounded-full bg-purple-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCreating ? '创建中…' : '创建用户'}
|
||||
{isCreating ? "创建中…" : "创建用户"}
|
||||
</button>
|
||||
{createMessage ? <span className="text-xs text-green-700">{createMessage}</span> : null}
|
||||
{createError ? <span className="text-xs text-red-600">{createError}</span> : null}
|
||||
{createMessage ? (
|
||||
<span className="text-xs text-green-700">{createMessage}</span>
|
||||
) : null}
|
||||
{createError ? (
|
||||
<span className="text-xs text-red-600">{createError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto" aria-busy={isLoading} aria-live="polite">
|
||||
<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>
|
||||
<th className="px-4 py-2 font-medium">状态</th>
|
||||
@ -212,99 +248,123 @@ export function UserGroupManagement({
|
||||
<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>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-block h-4 w-24 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>
|
||||
<tr key={index} className="animate-pulse">
|
||||
<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-red-100 text-red-700'}`}
|
||||
>
|
||||
{user.active ? '活跃' : '已暂停'}
|
||||
</span>
|
||||
<span className="inline-block h-4 w-48 rounded bg-gray-200" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{user.active ? (
|
||||
<button
|
||||
onClick={() => onPauseUser?.(user.id)}
|
||||
className="text-xs text-orange-600 hover:text-orange-700"
|
||||
>
|
||||
暂停
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onResumeUser?.(user.id)}
|
||||
className="text-xs text-green-600 hover:text-green-700"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRenewUuid?.(user.id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重置 UUID
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('确定要删除该用户吗?此操作不可逆。')) {
|
||||
onDeleteUser?.(user.id)
|
||||
}
|
||||
}}
|
||||
className="text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<span className="inline-block h-4 w-28 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>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-block h-4 w-24 rounded bg-gray-200" />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
))
|
||||
: data.map((user) => {
|
||||
const role = user.role ?? "user";
|
||||
const isPending = pendingSet.has(user.id);
|
||||
const isActive = user.active !== false;
|
||||
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 text-gray-700">
|
||||
{getUserDisplayName(user)}
|
||||
</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 ${isActive ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}`}
|
||||
>
|
||||
{isActive ? "活跃" : "已暂停"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{isActive ? (
|
||||
<button
|
||||
onClick={() => onPauseUser?.(user.id)}
|
||||
className="text-xs text-orange-600 hover:text-orange-700"
|
||||
>
|
||||
暂停
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onResumeUser?.(user.id)}
|
||||
className="text-xs text-green-600 hover:text-green-700"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRenewUuid?.(user.id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重置 UUID
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm("确定要删除该用户吗?此操作不可逆。")
|
||||
) {
|
||||
onDeleteUser?.(user.id);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!isLoading && data.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500">暂无用户数据</div>
|
||||
<div className="py-6 text-center text-sm text-gray-500">
|
||||
暂无用户数据
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
export default UserGroupManagement
|
||||
export default UserGroupManagement;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user