feat(web): update list entry management and UI labels
- 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 <[email protected]>
This commit is contained in:
@@ -76,7 +76,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
ip: 'IP',
|
ip: 'IP',
|
||||||
cidr: 'CIDR',
|
cidr: 'CIDR',
|
||||||
hostname: 'Домен',
|
hostname: 'Домен',
|
||||||
list: 'Список',
|
list: 'Вложенный список',
|
||||||
unknown: 'Неизвестно',
|
unknown: 'Неизвестно',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import {
|
|||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
import {
|
import {
|
||||||
isManualListType,
|
isManualListType,
|
||||||
|
parseListEntry,
|
||||||
|
splitListPlaintext,
|
||||||
type ListEntryKind,
|
type ListEntryKind,
|
||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
|
|
||||||
@@ -91,7 +93,8 @@ function ListDetailPage() {
|
|||||||
const listQ = useQuery(listQueryOptions(id))
|
const listQ = useQuery(listQueryOptions(id))
|
||||||
|
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
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 [addValue, setAddValue] = useState('')
|
||||||
const [addListRef, setAddListRef] = useState('')
|
const [addListRef, setAddListRef] = useState('')
|
||||||
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
||||||
@@ -121,7 +124,7 @@ function ListDetailPage() {
|
|||||||
|
|
||||||
const addEntries = useMutation({
|
const addEntries = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (addKind === 'list') {
|
if (addMode === 'nested') {
|
||||||
if (!addListRef) throw new Error('Выберите список')
|
if (!addListRef) throw new Error('Выберите список')
|
||||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -132,18 +135,7 @@ function ListDetailPage() {
|
|||||||
}
|
}
|
||||||
const text = addValue.trim()
|
const text = addValue.trim()
|
||||||
if (!text) throw new Error('Введите значение')
|
if (!text) throw new Error('Введите значение')
|
||||||
const lines = text
|
// Backend auto-classifies each token via parseListEntry (ip / cidr / hostname)
|
||||||
.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]! }],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ values: [text] }),
|
body: JSON.stringify({ values: [text] }),
|
||||||
@@ -153,7 +145,7 @@ function ListDetailPage() {
|
|||||||
toast.success('Добавлено')
|
toast.success('Добавлено')
|
||||||
setAddValue('')
|
setAddValue('')
|
||||||
setAddListRef('')
|
setAddListRef('')
|
||||||
setAddKind('ip')
|
setAddMode('plaintext')
|
||||||
setAddOpen(false)
|
setAddOpen(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
},
|
},
|
||||||
@@ -196,7 +188,7 @@ function ListDetailPage() {
|
|||||||
{ value: 'ip', label: 'IP' },
|
{ value: 'ip', label: 'IP' },
|
||||||
{ value: 'cidr', label: 'CIDR' },
|
{ value: 'cidr', label: 'CIDR' },
|
||||||
{ value: 'hostname', label: 'Домен' },
|
{ value: 'hostname', label: 'Домен' },
|
||||||
{ value: 'list', label: 'Список' },
|
{ value: 'list', label: 'Вложенный список' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -281,8 +273,24 @@ function ListDetailPage() {
|
|||||||
[manual],
|
[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 =
|
const canAdd =
|
||||||
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
|
addMode === 'nested'
|
||||||
|
? Boolean(addListRef)
|
||||||
|
: Boolean(addValue.trim())
|
||||||
|
|
||||||
if (listQ.isLoading) {
|
if (listQ.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -481,37 +489,47 @@ function ListDetailPage() {
|
|||||||
disabled={removeEntry.isPending}
|
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">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader className="shrink-0">
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Добавить запись</SheetTitle>
|
<SheetTitle>Добавить запись</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Выберите вид и значение. Для IP/CIDR/доменов можно вставить
|
IP, CIDR и домены определяются автоматически. Можно вставить
|
||||||
несколько строк сразу.
|
несколько строк сразу. Либо вложите другой список целиком.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="flex-1 px-4">
|
<ScrollArea className="flex-1 px-4">
|
||||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Вид</FieldLabel>
|
<FieldLabel>Режим</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={addKind}
|
value={addMode}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setAddKind(v as ListEntryKind)
|
if (v === 'plaintext' || v === 'nested') setAddMode(v)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ip">IP</SelectItem>
|
<SelectItem value="plaintext">
|
||||||
<SelectItem value="cidr">CIDR / диапазон</SelectItem>
|
Значения (автоопределение)
|
||||||
<SelectItem value="hostname">Домен</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="list">Список</SelectItem>
|
<SelectItem value="nested">Вложенный список</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
{addKind === 'list' ? (
|
{addMode === 'nested' ? (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Список</FieldLabel>
|
<FieldLabel>Список</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -531,6 +549,10 @@ function ListDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Содержимое выбранного списка подтянется в эту политику
|
||||||
|
(ссылка, не копия).
|
||||||
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
) : (
|
) : (
|
||||||
<Field>
|
<Field>
|
||||||
@@ -539,15 +561,23 @@ function ListDetailPage() {
|
|||||||
id="entry-value"
|
id="entry-value"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={addValue}
|
value={addValue}
|
||||||
placeholder={
|
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
||||||
addKind === 'ip'
|
|
||||||
? '8.8.8.8'
|
|
||||||
: addKind === 'cidr'
|
|
||||||
? '10.0.0.0/8'
|
|
||||||
: 'bad.example.com'
|
|
||||||
}
|
|
||||||
onChange={(e) => setAddValue(e.target.value)}
|
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>
|
</Field>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const listEntryKindLabels: Record<ListEntryKind, string> = {
|
|||||||
ip: 'IP',
|
ip: 'IP',
|
||||||
cidr: 'CIDR',
|
cidr: 'CIDR',
|
||||||
hostname: 'Домен',
|
hostname: 'Домен',
|
||||||
list: 'Список',
|
list: 'Вложенный список',
|
||||||
}
|
}
|
||||||
|
|
||||||
const IPV4 =
|
const IPV4 =
|
||||||
|
|||||||
Reference in New Issue
Block a user