feat(admin, i18n): enhance admin dashboard and user management with new features and translations
Added the ability to filter admin dashboard data by site code and agent node ID, improving data retrieval capabilities. Introduced new functions for fetching dashboard data based on these parameters. Updated the admin users and roles management components to reflect these changes. Enhanced multi-language support by adding new translations for agent management and permission levels in English, Nepali, and Chinese, ensuring a consistent user experience across the admin interface.
This commit is contained in:
@@ -55,6 +55,7 @@ export function AdminAgentFilter({ id = "admin-agent-filter", value, onChange, c
|
||||
}, [profile?.agent?.admin_site_id]);
|
||||
|
||||
const selectValue = value ? String(value) : ALL;
|
||||
const selectedLabel = options.find((opt) => String(opt.id) === selectValue)?.label;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -67,7 +68,15 @@ export function AdminAgentFilter({ id = "admin-agent-filter", value, onChange, c
|
||||
disabled={loading || options.length === 0}
|
||||
>
|
||||
<SelectTrigger id={id} className="mt-1 h-9 w-full min-w-[10rem]">
|
||||
<SelectValue placeholder={t("agentColumns.filterAll")} />
|
||||
<SelectValue>
|
||||
{(raw) => {
|
||||
const current = String(raw ?? ALL);
|
||||
if (current === ALL) {
|
||||
return t("agentColumns.filterAll");
|
||||
}
|
||||
return selectedLabel ?? t("agentColumns.filterAll");
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t("agentColumns.filterAll")}</SelectItem>
|
||||
|
||||
222
src/components/admin/admin-permission-package-selector.tsx
Normal file
222
src/components/admin/admin-permission-package-selector.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ADMIN_PERMISSION_PACKAGES } from "@/lib/admin-permission-packages";
|
||||
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
||||
|
||||
type PackageSelectorProps = {
|
||||
catalog: AdminPermissionCatalogData | null;
|
||||
selectedSlugs: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
resolveGroupLabel: (key: string, fallback: string) => string;
|
||||
resolvePackageLabel: (key: string, fallback: string) => string;
|
||||
selectableSlugs?: string[] | null;
|
||||
helperText?: string;
|
||||
summaryText?: string;
|
||||
emptyText: string;
|
||||
heightClassName?: string;
|
||||
};
|
||||
|
||||
type RenderGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
packages: Array<{ key: string; label: string; slugs: string[] }>;
|
||||
};
|
||||
|
||||
const PACKAGE_LEVEL_ORDER: Record<string, number> = {
|
||||
view: 10,
|
||||
review: 20,
|
||||
export: 20,
|
||||
manage: 30,
|
||||
config: 30,
|
||||
control: 30,
|
||||
reopen: 30,
|
||||
special: 40,
|
||||
};
|
||||
|
||||
export function AdminPermissionPackageSelector({
|
||||
catalog,
|
||||
selectedSlugs,
|
||||
onChange,
|
||||
resolveGroupLabel,
|
||||
resolvePackageLabel,
|
||||
selectableSlugs = null,
|
||||
helperText,
|
||||
summaryText,
|
||||
emptyText,
|
||||
heightClassName = "h-[52vh]",
|
||||
}: PackageSelectorProps): React.ReactElement {
|
||||
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
|
||||
const catalogSlugSet = useMemo(
|
||||
() => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)),
|
||||
[catalog],
|
||||
);
|
||||
const allowedSet = useMemo(
|
||||
() => (selectableSlugs ? new Set(selectableSlugs) : null),
|
||||
[selectableSlugs],
|
||||
);
|
||||
|
||||
const groups = useMemo<RenderGroup[]>(() => {
|
||||
const defs = catalog?.permission_menu_groups ?? [];
|
||||
const out: RenderGroup[] = [];
|
||||
for (const group of defs) {
|
||||
const bundles = ADMIN_PERMISSION_PACKAGES[group.key] ?? [];
|
||||
if (bundles.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const renderedPackages = bundles
|
||||
.map((bundle) => {
|
||||
const slugs = bundle.slugs.filter((slug) => {
|
||||
if (!catalogSlugSet.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
if (allowedSet && !allowedSet.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
key: bundle.key,
|
||||
label: resolvePackageLabel(bundle.key, bundle.label),
|
||||
slugs,
|
||||
};
|
||||
})
|
||||
.filter((bundle) => bundle.slugs.length > 0);
|
||||
if (renderedPackages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
key: group.key,
|
||||
label: resolveGroupLabel(group.key, group.label),
|
||||
packages: renderedPackages,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [allowedSet, catalog, catalogSlugSet, resolveGroupLabel, resolvePackageLabel]);
|
||||
|
||||
const bundleCount = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.packages.length, 0),
|
||||
[groups],
|
||||
);
|
||||
|
||||
if (groups.length === 0 || bundleCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleBundle = (group: RenderGroup, bundleKey: string, slugs: string[], checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
const currentLevel = PACKAGE_LEVEL_ORDER[bundleKey] ?? 10;
|
||||
const relatedSlugs = group.packages
|
||||
.filter((item) => {
|
||||
const level = PACKAGE_LEVEL_ORDER[item.key] ?? 10;
|
||||
return checked ? level <= currentLevel : level >= currentLevel;
|
||||
})
|
||||
.flatMap((item) => item.slugs);
|
||||
|
||||
for (const slug of relatedSlugs.length > 0 ? relatedSlugs : slugs) {
|
||||
if (checked) next.add(slug);
|
||||
else next.delete(slug);
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
const toggleGroup = (group: RenderGroup, checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
const relatedSlugs = group.packages.flatMap((item) => item.slugs);
|
||||
for (const slug of relatedSlugs) {
|
||||
if (checked) next.add(slug);
|
||||
else next.delete(slug);
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{helperText || summaryText ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>{helperText}</span>
|
||||
<span>{summaryText}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn("relative w-full overflow-auto rounded-xl border border-border/60 bg-card", heightClassName)}>
|
||||
<table className="w-full min-w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-md border-b border-border/50">
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }} className="w-[180px] h-11 px-4 font-semibold text-foreground">模块</th>
|
||||
<th style={{ textAlign: "left" }} className="h-11 px-4 font-semibold text-foreground">具体权限</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{groups.map((group) => {
|
||||
const groupAllSlugs = group.packages.flatMap((p) => p.slugs);
|
||||
const groupSelectedCount = groupAllSlugs.filter((slug) => selectedSet.has(slug)).length;
|
||||
const groupChecked = groupSelectedCount === groupAllSlugs.length && groupAllSlugs.length > 0;
|
||||
|
||||
return (
|
||||
<tr key={group.key} className="hover:bg-muted/10 transition-colors">
|
||||
<td style={{ textAlign: "left" }} className="align-top py-4 pl-4">
|
||||
<label
|
||||
style={{ display: "flex", justifyContent: "flex-start" }}
|
||||
className="cursor-pointer items-center gap-2 font-medium w-full"
|
||||
>
|
||||
<Checkbox
|
||||
checked={groupChecked}
|
||||
onCheckedChange={(value) =>
|
||||
toggleGroup(group, value === true)
|
||||
}
|
||||
/>
|
||||
<span>{group.label}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ textAlign: "left" }} className="py-4 pl-4">
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-start" }}
|
||||
className="flex-wrap gap-3 w-full"
|
||||
>
|
||||
{group.packages.map((bundle) => {
|
||||
const checked = bundle.slugs.every((slug) => selectedSet.has(slug));
|
||||
return (
|
||||
<label
|
||||
key={`${group.key}.${bundle.key}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted/50",
|
||||
checked && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) =>
|
||||
toggleBundle(group, bundle.key, bundle.slugs, value === true)
|
||||
}
|
||||
/>
|
||||
<span>{bundle.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
src/components/admin/admin-permission-selector.tsx
Normal file
200
src/components/admin/admin-permission-selector.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
||||
|
||||
type PermissionSelectorProps = {
|
||||
catalog: AdminPermissionCatalogData | null;
|
||||
selectedSlugs: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
resolveGroupLabel: (key: string, fallback: string) => string;
|
||||
resolvePermissionLabel: (slug: string, fallback: string) => string;
|
||||
selectableSlugs?: string[] | null;
|
||||
helperText?: string;
|
||||
summaryText?: string;
|
||||
emptyText: string;
|
||||
heightClassName?: string;
|
||||
};
|
||||
|
||||
export function AdminPermissionSelector({
|
||||
catalog,
|
||||
selectedSlugs,
|
||||
onChange,
|
||||
resolveGroupLabel,
|
||||
resolvePermissionLabel,
|
||||
selectableSlugs = null,
|
||||
helperText,
|
||||
summaryText,
|
||||
emptyText,
|
||||
heightClassName = "h-[52vh]",
|
||||
}: PermissionSelectorProps): React.ReactElement {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
|
||||
const allowedSet = useMemo(
|
||||
() => (selectableSlugs ? new Set(selectableSlugs) : null),
|
||||
[selectableSlugs],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const rawGroups = catalog?.permission_menu_groups ?? [];
|
||||
const mapped = rawGroups
|
||||
.map((group) => ({
|
||||
key: group.key,
|
||||
label: resolveGroupLabel(group.key, group.label),
|
||||
permissions: group.permissions.filter((permission) =>
|
||||
allowedSet ? allowedSet.has(permission.slug) : true,
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.permissions.length > 0);
|
||||
|
||||
if (mapped.length > 0) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
const fallbackPermissions = (catalog?.permissions ?? []).filter((permission) =>
|
||||
allowedSet ? allowedSet.has(permission.slug) : true,
|
||||
);
|
||||
|
||||
if (fallbackPermissions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: "all",
|
||||
label: resolveGroupLabel("all", "全部权限"),
|
||||
permissions: fallbackPermissions,
|
||||
},
|
||||
];
|
||||
}, [allowedSet, catalog, resolveGroupLabel]);
|
||||
|
||||
const totalCount = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.permissions.length, 0),
|
||||
[groups],
|
||||
);
|
||||
|
||||
const toggleOne = (slug: string, checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (checked) {
|
||||
next.add(slug);
|
||||
} else {
|
||||
next.delete(slug);
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
const toggleGroup = (slugs: string[], checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
for (const slug of slugs) {
|
||||
if (checked) {
|
||||
next.add(slug);
|
||||
} else {
|
||||
next.delete(slug);
|
||||
}
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [key]: !(prev[key] ?? true) }));
|
||||
};
|
||||
|
||||
const isExpanded = (key: string): boolean => expandedGroups[key] ?? true;
|
||||
|
||||
if (groups.length === 0 || totalCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{helperText || summaryText ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>{helperText}</span>
|
||||
<span>{summaryText}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className={cn("pr-3", heightClassName)}>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||
{groups.map((group) => {
|
||||
const expanded = isExpanded(group.key);
|
||||
const groupSlugs = group.permissions.map((permission) => permission.slug);
|
||||
const selectedCount = groupSlugs.filter((slug) => selectedSet.has(slug)).length;
|
||||
const checkedState =
|
||||
selectedCount === 0 ? false : selectedCount === groupSlugs.length ? true : "indeterminate";
|
||||
|
||||
return (
|
||||
<div key={group.key} className={cn("border-b border-border/60 last:border-b-0", expanded && "bg-muted/10")}>
|
||||
<div className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => toggleExpanded(group.key)}
|
||||
aria-label={expanded ? "collapse" : "expand"}
|
||||
>
|
||||
<ChevronDown aria-hidden className={cn("size-4 transition-transform", expanded && "rotate-180")} />
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={checkedState === true}
|
||||
indeterminate={checkedState === "indeterminate"}
|
||||
onCheckedChange={(value) => toggleGroup(groupSlugs, value === true)}
|
||||
/>
|
||||
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => toggleExpanded(group.key)}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[15px] font-medium leading-6 text-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{group.permissions.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{selectedCount}/{group.permissions.length}
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="pb-2">
|
||||
{group.permissions.map((permission, index) => (
|
||||
<label
|
||||
key={permission.slug}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-muted/20",
|
||||
index === 0 && "border-t border-border/50",
|
||||
selectedSet.has(permission.slug) && "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<span className="mt-1 flex h-4 w-8 shrink-0 items-center">
|
||||
<span className="h-px w-full bg-border/70" />
|
||||
</span>
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={selectedSet.has(permission.slug)}
|
||||
onCheckedChange={(value) => toggleOne(permission.slug, value === true)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<div className="whitespace-normal break-words leading-6 text-foreground">
|
||||
{resolvePermissionLabel(permission.slug, permission.name)}
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -253,7 +253,6 @@ export function LoginForm() {
|
||||
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
|
||||
>
|
||||
{captchaSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- data URL from API
|
||||
<img
|
||||
src={captchaSrc}
|
||||
alt=""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
LogOutIcon,
|
||||
|
||||
Reference in New Issue
Block a user