fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s

This commit is contained in:
Denozordec
2026-06-30 22:22:51 +07:00
parent a1a9124f3d
commit d3a2d38b37
63 changed files with 8509 additions and 3652 deletions
+18 -150
View File
@@ -26,6 +26,9 @@ import {
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon,
} from "lucide-react"
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
@@ -568,100 +571,14 @@ function UserSheet({ open, user, onSave, onClose }: {
</p>
</div>
{/* table header */}
{form.subUsers.length > 0 && (
<div className="grid items-center gap-2 px-4 py-1.5 bg-muted/20 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
<span>Логин / описание</span>
<span>Пароль</span>
<span>JH-серверы</span>
<span>IP-клиента</span>
<span />
<span />
</div>
)}
{/* rows */}
<div className="divide-y divide-border/60">
{form.subUsers.length === 0 && !addSubOpen && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<CableIcon className="size-6 opacity-20" />
<p className="text-sm">Нет GRE-клиентов</p>
<p className="text-xs opacity-60">Добавьте учётки для подключения устройств</p>
</div>
)}
{form.subUsers.map(su => {
const jhs = servers.filter(s => su.jhServerIds.includes(s.id))
const revealed = revealedIds.has(su.id)
return (
<div key={su.id}
className={cn(
"grid items-center gap-2 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!su.active && "opacity-50",
)}
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
{/* login + description */}
<div className="min-w-0">
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
{su.description && (
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
)}
{su.lastSeen && (
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
)}
</div>
{/* password */}
<div className="flex items-center gap-1 min-w-0">
<span className="font-mono text-[11px] truncate flex-1">
{revealed ? su.password : "••••••••••••"}
</span>
<button onClick={() => toggleReveal(su.id)}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
{revealed
? <EyeOffIcon className="size-3" />
: <EyeIcon className="size-3" />}
</button>
<button onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
<CopyIcon className="size-3" />
</button>
</div>
{/* JH servers */}
<div className="flex flex-wrap gap-1 min-w-0">
{jhs.length === 0
? <span className="text-[11px] text-muted-foreground/40"></span>
: jhs.map(jh => (
<span key={jh.id} className="inline-flex items-center gap-1 text-[10px] font-medium
bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20
rounded px-1 py-0.5">
<Flag code={jh.country} size={10} />
{jh.name.split("-").slice(-1)[0]}
</span>
))
}
</div>
{/* client IP */}
<span className="font-mono text-[11px] text-muted-foreground truncate">
{su.clientIp || "—"}
</span>
{/* active toggle */}
<FormToggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
{/* delete */}
<button onClick={() => removeSubUser(su.id)}
className="text-muted-foreground/40 hover:text-destructive transition-colors flex justify-end">
<TrashIcon className="size-3.5" />
</button>
</div>
)
})}
</div>
<SubusersDataGrid
subUsers={form.subUsers}
servers={servers}
revealedIds={revealedIds}
onToggleReveal={toggleReveal}
onToggleActive={toggleSubUser}
onRemove={removeSubUser}
/>
{/* inline add form */}
{addSubOpen ? (
@@ -1561,66 +1478,17 @@ export default function SettingsPage() {
</Card>
{/* access summary */}
<Card className="overflow-hidden gap-0 py-0">
<DataPageCard>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<UserIcon className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">Сводка прав доступа</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/30">
<th className="text-left font-medium px-4 py-2">Пользователь</th>
<th className="text-left font-medium px-4 py-2">Разделы</th>
<th className="text-left font-medium px-4 py-2">Серверы</th>
<th className="text-left font-medium px-4 py-2">Права записи</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map(u => {
const writeSections = u.role === "admin" ? ALL_SECTIONS : u.sections.filter(s => s.level === "write").map(s => s.section)
const readSections = u.role === "admin" ? [] : u.sections.filter(s => s.level === "read").map(s => s.section)
const accessServers = u.role === "admin" ? servers : servers.filter(s => u.servers.find(p => p.serverId === s.id && p.level !== "none"))
return (
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{u.role === "admin"
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({ALL_SECTIONS.length})</span>
: <span>{(readSections.length + writeSections.length)} из {ALL_SECTIONS.length}</span>}
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{u.role === "admin"
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({servers.length})</span>
: <span>{accessServers.length} из {servers.length}</span>}
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{u.role === "admin"
? <span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">Полный доступ</span>
: writeSections.length === 0
? <span className="text-[10px] text-muted-foreground">Только просмотр</span>
: writeSections.slice(0, 3).map(s => (
<span key={s} className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">{s}</span>
))
}
{u.role !== "admin" && writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<SettingsAccessSummaryDataGrid
users={users}
servers={servers}
allSectionsCount={ALL_SECTIONS.length}
/>
</DataPageCard>
</div>
)