feat(web): update list entry management and UI labels
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m38s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Changed label for 'list' to 'Вложенный список' in status badge and list entry management.
- Refactored list entry handling to support new 'plaintext' and 'nested' modes for adding entries.
- Enhanced user experience by auto-detecting entry types and improving placeholder text.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-21 00:58:07 +07:00
co-authored by Cursor
parent 8aa265b1c8
commit 70bee59fc6
3 changed files with 67 additions and 37 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ const STATUS_LABELS: Record<string, string> = {
ip: 'IP',
cidr: 'CIDR',
hostname: 'Домен',
list: 'Список',
list: 'Вложенный список',
unknown: 'Неизвестно',
}
+65 -35
View File
@@ -54,6 +54,8 @@ import {
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
isManualListType,
parseListEntry,
splitListPlaintext,
type ListEntryKind,
} from '@evofw/shared'
@@ -91,7 +93,8 @@ function ListDetailPage() {
const listQ = useQuery(listQueryOptions(id))
const [addOpen, setAddOpen] = useState(false)
const [addKind, setAddKind] = useState<ListEntryKind>('ip')
/** plaintext = auto-detect IP/CIDR/hostname; nested = link another list */
const [addMode, setAddMode] = useState<'plaintext' | 'nested'>('plaintext')
const [addValue, setAddValue] = useState('')
const [addListRef, setAddListRef] = useState('')
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
@@ -121,7 +124,7 @@ function ListDetailPage() {
const addEntries = useMutation({
mutationFn: async () => {
if (addKind === 'list') {
if (addMode === 'nested') {
if (!addListRef) throw new Error('Выберите список')
return apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'POST',
@@ -132,18 +135,7 @@ function ListDetailPage() {
}
const text = addValue.trim()
if (!text) throw new Error('Введите значение')
const lines = text
.split(/[\n,;]+/)
.map((s) => s.trim())
.filter(Boolean)
if (lines.length === 1) {
return apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'POST',
body: JSON.stringify({
items: [{ kind: addKind, value: lines[0]! }],
}),
})
}
// Backend auto-classifies each token via parseListEntry (ip / cidr / hostname)
return apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'POST',
body: JSON.stringify({ values: [text] }),
@@ -153,7 +145,7 @@ function ListDetailPage() {
toast.success('Добавлено')
setAddValue('')
setAddListRef('')
setAddKind('ip')
setAddMode('plaintext')
setAddOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
@@ -196,7 +188,7 @@ function ListDetailPage() {
{ value: 'ip', label: 'IP' },
{ value: 'cidr', label: 'CIDR' },
{ value: 'hostname', label: 'Домен' },
{ value: 'list', label: 'Список' },
{ value: 'list', label: 'Вложенный список' },
],
},
],
@@ -281,8 +273,24 @@ function ListDetailPage() {
[manual],
)
const detectedKinds = useMemo(() => {
if (addMode !== 'plaintext' || !addValue.trim()) return []
const kinds = new Set<Exclude<ListEntryKind, 'list'>>()
for (const token of splitListPlaintext(addValue)) {
try {
const parsed = parseListEntry(token)
if (parsed.kind !== 'list') kinds.add(parsed.kind)
} catch {
/* preview only — backend will reject invalid on submit */
}
}
return [...kinds]
}, [addMode, addValue])
const canAdd =
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
addMode === 'nested'
? Boolean(addListRef)
: Boolean(addValue.trim())
if (listQ.isLoading) {
return (
@@ -481,37 +489,47 @@ function ListDetailPage() {
disabled={removeEntry.isPending}
/>
<Sheet open={addOpen} onOpenChange={setAddOpen}>
<Sheet
open={addOpen}
onOpenChange={(open) => {
setAddOpen(open)
if (!open) {
setAddValue('')
setAddListRef('')
setAddMode('plaintext')
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Добавить запись</SheetTitle>
<SheetDescription>
Выберите вид и значение. Для IP/CIDR/доменов можно вставить
несколько строк сразу.
IP, CIDR и домены определяются автоматически. Можно вставить
несколько строк сразу. Либо вложите другой список целиком.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
<Field>
<FieldLabel>Вид</FieldLabel>
<FieldLabel>Режим</FieldLabel>
<Select
value={addKind}
value={addMode}
onValueChange={(v) => {
if (v) setAddKind(v as ListEntryKind)
if (v === 'plaintext' || v === 'nested') setAddMode(v)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ip">IP</SelectItem>
<SelectItem value="cidr">CIDR / диапазон</SelectItem>
<SelectItem value="hostname">Домен</SelectItem>
<SelectItem value="list">Список</SelectItem>
<SelectItem value="plaintext">
Значения (автоопределение)
</SelectItem>
<SelectItem value="nested">Вложенный список</SelectItem>
</SelectContent>
</Select>
</Field>
{addKind === 'list' ? (
{addMode === 'nested' ? (
<Field>
<FieldLabel>Список</FieldLabel>
<Select
@@ -531,6 +549,10 @@ function ListDetailPage() {
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Содержимое выбранного списка подтянется в эту политику
(ссылка, не копия).
</p>
</Field>
) : (
<Field>
@@ -539,15 +561,23 @@ function ListDetailPage() {
id="entry-value"
rows={5}
value={addValue}
placeholder={
addKind === 'ip'
? '8.8.8.8'
: addKind === 'cidr'
? '10.0.0.0/8'
: 'bad.example.com'
}
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
onChange={(e) => setAddValue(e.target.value)}
/>
{detectedKinds.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs">
Определено:
</span>
{detectedKinds.map((kind) => (
<StatusBadge key={kind} status={kind} />
))}
</div>
) : addValue.trim() ? (
<p className="text-muted-foreground text-xs">
Не распознано проверьте формат перед добавлением.
</p>
) : null}
</Field>
)}
</div>
+1 -1
View File
@@ -35,7 +35,7 @@ export const listEntryKindLabels: Record<ListEntryKind, string> = {
ip: 'IP',
cidr: 'CIDR',
hostname: 'Домен',
list: 'Список',
list: 'Вложенный список',
}
const IPV4 =