ended form mode interface

This commit is contained in:
2025-09-22 16:55:45 +06:00
parent 0fa566c165
commit 10af1a9a63
4 changed files with 703 additions and 10 deletions

View File

@@ -0,0 +1,348 @@
# Документация: FormArray в компоненте KnockPageComponent
## Обзор
В компоненте `KnockPageComponent` используется Angular FormArray для управления динамическими формами целей (targets) в режиме "form". Это позволяет пользователям добавлять, удалять и редактировать неограниченное количество целей для пропинывания портов.
## Архитектура FormArray
### 1. Структура данных
```typescript
form = this.fb.group({
// ... другие поля
targetForms: this.fb.array([]) // FormArray для динамических форм
});
get targetForms(): FormArray {
return this.form.get('targetForms') as FormArray;
}
```
### 2. Структура отдельной формы цели
Каждая форма цели содержит следующие поля:
```typescript
private createTargetForm(): FormGroup {
return this.fb.group({
protocol: ['tcp', Validators.required], // Протокол (TCP/UDP)
host: ['127.0.0.1', Validators.required], // IP адрес хоста
port: [22, [Validators.required, Validators.min(1), Validators.max(65535)]], // Порт
gateway: [''] // Шлюз (опционально)
});
}
```
## Основные методы работы с FormArray
### 1. Создание новой формы цели
```typescript
addTarget(): void {
const newTargetForm = this.createTargetForm();
this.targetForms.push(newTargetForm);
this.serializeFormTargets();
}
```
**Что происходит:**
- Создается новая FormGroup с полями по умолчанию
- Форма добавляется в FormArray через `push()`
- Автоматически вызывается сериализация данных
### 2. Удаление формы цели
```typescript
removeTarget(index: number): void {
if (this.targetForms.length > 1) {
this.targetForms.removeAt(index);
this.serializeFormTargets();
}
}
```
**Особенности:**
- Защита от удаления последней формы (минимум 1 форма)
- Используется `removeAt(index)` для удаления по индексу
- Автоматическая сериализация после удаления
### 3. Сериализация данных форм
```typescript
private serializeFormTargets(): void {
if (this.form.value.mode !== 'form') return;
const targets: string[] = [];
this.targetForms.controls.forEach(targetForm => {
const value = targetForm.value;
if (value.protocol && value.host && value.port) {
let targetString = `${value.protocol}:${value.host}:${value.port}`;
if (value.gateway && value.gateway.trim()) {
targetString += `:${value.gateway.trim()}`;
}
targets.push(targetString);
}
});
this.form.patchValue({ targets: targets.join(';') });
}
```
**Процесс сериализации:**
1. Проверка, что текущий режим - "form"
2. Итерация по всем формам в FormArray
3. Сборка строки в формате `protocol:host:port:gateway`
4. Объединение всех целей через `;`
5. Обновление поля `targets` в основной форме
## Интеграция с HTML шаблоном
### 1. Отображение форм
```html
<div class="form-targets-list">
<div
*ngFor="let targetForm of targetForms.controls; let i = index"
class="form-target-item"
[formGroup]="$any(targetForm)"
>
<!-- Поля формы -->
</div>
</div>
```
**Ключевые моменты:**
- `*ngFor` итерируется по `targetForms.controls`
- `[formGroup]` связывает каждую форму с FormGroup
- `$any(targetForm)` решает проблему типизации TypeScript
### 2. Поля формы
```html
<div class="col-12 md:col-3">
<label>Protocol</label>
<p-dropdown
formControlName="protocol"
[options]="[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' }
]"
optionLabel="label"
optionValue="value"
class="w-full"
></p-dropdown>
</div>
```
## Автоматическое сохранение и восстановление
### 1. Подписка на изменения
```typescript
private setupAutoSave() {
// Подписка на изменения в формах целей
this.targetForms.valueChanges.subscribe(() => {
if (this.form.value.mode === 'form') {
this.serializeFormTargets();
setTimeout(() => this.saveStateToLocalStorage(), 300);
}
});
}
```
### 2. Сохранение в localStorage
```typescript
private saveStateToLocalStorage() {
const state: any = {
// ... другие поля
};
// Сохраняем данные форм целей для режима form
if (formValue.mode === 'form' && this.targetForms.length > 0) {
state.targetForms = this.targetForms.value;
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
}
```
### 3. Восстановление из localStorage
```typescript
private loadStateFromLocalStorage() {
// ... загрузка других полей
// Загружаем сохраненные формы целей для режима form
if (state.mode === 'form' && state.targetForms && Array.isArray(state.targetForms)) {
this.targetForms.clear();
state.targetForms.forEach((targetData: any) => {
const targetForm = this.fb.group({
protocol: [targetData.protocol || 'tcp', Validators.required],
host: [targetData.host || '127.0.0.1', Validators.required],
port: [targetData.port || 22, [Validators.required, Validators.min(1), Validators.max(65535)]],
gateway: [targetData.gateway || '']
});
this.targetForms.push(targetForm);
});
}
}
```
## Преобразование между режимами
### 1. Конвертация в режим Form
```typescript
private convertInlineToForm() {
const targetsString = this.form.value.targets || '';
const targets = targetsString.split(';').filter(t => t.trim());
targets.forEach(target => {
const parts = target.trim().split(':');
if (parts.length >= 3) {
const targetForm = this.fb.group({
protocol: [parts[0] || 'tcp', Validators.required],
host: [parts[1] || '127.0.0.1', Validators.required],
port: [parseInt(parts[2]) || 22, [Validators.required, Validators.min(1), Validators.max(65535)]],
gateway: [parts[3] || '']
});
this.targetForms.push(targetForm);
}
});
}
```
### 2. Конвертация из режима Form
```typescript
private handleModeChangeFromForm(previousMode: string, newMode: string) {
// Сначала сериализуем данные формы
this.serializeFormTargets();
if (newMode === 'inline') {
// Данные уже в targets, ничего дополнительно не нужно
} else if (newMode === 'yaml') {
this.convertInlineToYaml();
}
}
```
## Жизненный цикл FormArray
### 1. Инициализация
```typescript
private initializeFormMode(): void {
// Если нет форм целей, создаем одну по умолчанию
if (this.targetForms.length === 0) {
this.addTarget();
}
}
```
### 2. Очистка при смене режима
```typescript
private handleModeChangeToForm(previousMode: string) {
// Очищаем существующие формы
this.targetForms.clear();
// Конвертируем данные из предыдущего режима
if (previousMode === 'inline') {
this.convertInlineToForm();
} else if (previousMode === 'yaml') {
this.convertYamlToForm();
}
// Инициализируем формы если их нет
if (this.targetForms.length === 0) {
this.addTarget();
}
}
```
## Валидация
### 1. Валидация полей формы
```typescript
private createTargetForm(): FormGroup {
return this.fb.group({
protocol: ['tcp', Validators.required],
host: ['127.0.0.1', Validators.required],
port: [22, [Validators.required, Validators.min(1), Validators.max(65535)]],
gateway: ['']
});
}
```
### 2. Защита от удаления всех форм
```typescript
removeTarget(index: number): void {
if (this.targetForms.length > 1) {
this.targetForms.removeAt(index);
this.serializeFormTargets();
}
}
```
## Преимущества использования FormArray
1. **Динамичность**: Возможность добавления/удаления форм в runtime
2. **Валидация**: Встроенная валидация для каждой формы
3. **Реактивность**: Автоматическое обновление UI при изменениях
4. **Типобезопасность**: TypeScript поддержка
5. **Интеграция**: Легкая интеграция с Angular Reactive Forms
6. **Сериализация**: Простое преобразование в различные форматы
## Потенциальные проблемы и решения
### 1. Проблема типизации в шаблоне
**Проблема:**
```html
[formGroup]="targetForm" <!-- TypeScript ошибка -->
```
**Решение:**
```html
[formGroup]="$any(targetForm)" <!-- Приведение типа -->
```
### 2. Защита от пустого FormArray
**Проблема:** Пользователь может удалить все формы
**Решение:**
```typescript
removeTarget(index: number): void {
if (this.targetForms.length > 1) { // Минимум 1 форма
this.targetForms.removeAt(index);
}
}
```
### 3. Производительность при большом количестве форм
**Проблема:** Много форм может замедлить приложение
**Решение:** Виртуализация или пагинация (не реализовано в текущей версии)
## Заключение
FormArray в данном компоненте обеспечивает гибкую и мощную систему управления динамическими формами. Реализация включает:
- Полный жизненный цикл форм (создание, редактирование, удаление)
- Автоматическую сериализацию/десериализацию
- Интеграцию с системой режимов
- Сохранение состояния в localStorage
- Валидацию и защиту от некорректных данных
Этот подход делает компонент удобным для пользователей и легко расширяемым для разработчиков.

View File

@@ -71,7 +71,7 @@
/>
</div>
<div class="col-12 md:col-4" *ngIf="form.value.mode === 'inline'">
<div class="col-12 md:col-4" *ngIf="form.value.mode !== 'yaml'">
<label>Delay</label>
<input
pInputText
@@ -95,7 +95,7 @@
<label class="checkbox-label">Wait connection</label>
</div>
<div class="col-12">
<div class="col-12" style="display: none;">
<label>Gateway</label>
<input
pInputText
@@ -117,6 +117,93 @@
></textarea>
</div>
<!-- Form Mode Section -->
<div class="col-12" *ngIf="form.value.mode === 'form'">
<div class="form-targets-container">
<label>Targets Configuration</label>
<div class="form-targets-list">
<div
*ngFor="let targetForm of targetForms.controls; let i = index"
class="form-target-item"
[formGroup]="$any(targetForm)"
>
<div class="form-target-header">
<h4>Target {{ i + 1 }}</h4>
<button
*ngIf="targetForms.length > 1"
pButton
type="button"
icon="pi pi-trash"
class="p-button-danger p-button-sm"
(click)="removeTarget(i)"
pTooltip="Remove target"
></button>
</div>
<div class="grid">
<div class="col-12 md:col-3">
<label>Protocol</label>
<p-dropdown
formControlName="protocol"
[options]="[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' }
]"
optionLabel="label"
optionValue="value"
class="w-full"
></p-dropdown>
</div>
<div class="col-12 md:col-3">
<label>Host</label>
<input
pInputText
type="text"
formControlName="host"
placeholder="127.0.0.1"
class="w-full"
/>
</div>
<div class="col-12 md:col-3">
<label>Port</label>
<input
pInputText
type="number"
formControlName="port"
placeholder="22"
class="w-full"
/>
</div>
<div class="col-12 md:col-3">
<label>Gateway</label>
<input
pInputText
type="text"
formControlName="gateway"
placeholder="optional"
class="w-full"
/>
</div>
</div>
</div>
</div>
<div class="form-target-actions">
<button
pButton
type="button"
label="Add Target"
icon="pi pi-plus"
class="p-button-outlined"
(click)="addTarget()"
></button>
</div>
</div>
</div>
<!-- File controls directly under YAML -->
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
<div class="flex flex-wrap gap-2 align-items-center">
@@ -171,7 +258,7 @@
></button>
</div>
<!-- Row 2: Encrypt / Decrypt half/half on desktop; stacked on mobile (только для YAML режима) -->
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
<button
pButton
type="button"
@@ -186,7 +273,7 @@
"
></button>
</div>
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
<button
pButton
type="button"
@@ -202,7 +289,7 @@
></button>
</div>
<!-- Row 3: Download actions half/half on desktop; stacked on mobile (только для YAML режима) -->
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
<button
pButton
type="button"
@@ -211,7 +298,7 @@
class="p-button-text w-full"
></button>
</div>
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
<button
pButton
type="button"

View File

@@ -130,3 +130,46 @@ input[type='text'], input[type='password'], select, textarea {
}
}
// Form Mode Styles
.form-targets-container {
.form-targets-list {
margin-bottom: 1rem;
}
.form-target-item {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.2s ease;
&:hover {
border-color: #007bff;
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.1);
}
.form-target-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h4 {
margin: 0;
color: #495057;
font-size: 1rem;
font-weight: 600;
}
}
}
.form-target-actions {
display: flex;
justify-content: center;
padding: 1rem 0;
border-top: 1px solid #dee2e6;
margin-top: 1rem;
}
}

View File

@@ -1,7 +1,7 @@
import { Component, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms';
import { FormBuilder, ReactiveFormsModule, Validators, FormsModule, FormArray, FormGroup } from '@angular/forms';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { InputTextModule } from 'primeng/inputtext';
import { PasswordModule } from 'primeng/password';
@@ -14,6 +14,7 @@ import { DividerModule } from 'primeng/divider';
import { FileUploadModule } from 'primeng/fileupload';
import { ProgressBarModule } from 'primeng/progressbar';
import { DialogModule } from 'primeng/dialog';
import { TooltipModule } from 'primeng/tooltip';
import * as yaml from 'js-yaml';
import { environment } from '../../environments/environment';
@@ -24,7 +25,7 @@ import { environment } from '../../environments/environment';
CommonModule, RouterModule, ReactiveFormsModule, FormsModule,
InputTextModule, PasswordModule, DropdownModule, CheckboxModule,
InputTextareaModule, ButtonModule, CardModule, DividerModule,
FileUploadModule, ProgressBarModule, DialogModule
FileUploadModule, ProgressBarModule, DialogModule, TooltipModule
],
templateUrl: './knock-page.component.html',
styleUrls: ['./knock-page.component.scss']
@@ -64,9 +65,14 @@ export class KnockPageComponent {
waitConnection: [false],
gateway: [''],
configYAML: [''],
serverFilePath: ['']
serverFilePath: [''],
targetForms: this.fb.array([])
});
get targetForms(): FormArray {
return this.form.get('targetForms') as FormArray;
}
constructor() {
// Загружаем сохраненное состояние из localStorage
this.loadStateFromLocalStorage();
@@ -108,6 +114,58 @@ export class KnockPageComponent {
// File System Access API detection (для обратной совместимости)
// Логика FSA теперь находится в отдельных компонентах
// Инициализируем с одной формой по умолчанию для режима form
this.initializeFormMode();
}
// Form Mode Methods
private createTargetForm(): FormGroup {
return this.fb.group({
protocol: ['tcp', Validators.required],
host: ['127.0.0.1', Validators.required],
port: [22, [Validators.required, Validators.min(1), Validators.max(65_535)]],
gateway: ['']
});
}
addTarget(): void {
const newTargetForm = this.createTargetForm();
this.targetForms.push(newTargetForm);
this.serializeFormTargets();
}
removeTarget(index: number): void {
if (this.targetForms.length > 1) {
this.targetForms.removeAt(index);
this.serializeFormTargets();
}
}
private initializeFormMode(): void {
// Если нет форм целей, создаем одну по умолчанию
if (this.targetForms.length === 0) {
this.addTarget();
}
}
private serializeFormTargets(): void {
if (this.form.value.mode !== 'form') return;
const targets: string[] = [];
this.targetForms.controls.forEach(targetForm => {
const value = targetForm.value;
if (!(value.protocol && value.host && value.port)) {
return;
}
let targetString = `${value.protocol}:${value.host}:${value.port}`;
if (value.gateway?.trim()) {
targetString += `:${value.gateway.trim()}`;
}
targets.push(targetString);
});
this.form.patchValue({ targets: targets.join(';') });
}
private authHeader(pass: string) {
@@ -466,6 +524,21 @@ export class KnockPageComponent {
}
}
}
// Загружаем сохраненные формы целей для режима form
if (state.mode === 'form' && state.targetForms && Array.isArray(state.targetForms)) {
this.targetForms.clear();
state.targetForms.forEach((targetData: any) => {
const targetForm = this.fb.group({
protocol: [targetData.protocol || 'tcp', Validators.required],
host: [targetData.host || '127.0.0.1', Validators.required],
port: [targetData.port || 22, [Validators.required, Validators.min(1), Validators.max(65535)]],
gateway: [targetData.gateway || '']
});
this.targetForms.push(targetForm);
});
environment.log('Loaded form targets from localStorage:', state.targetForms);
}
} catch (e) {
console.warn('Failed to load state from localStorage:', e);
}
@@ -474,7 +547,7 @@ export class KnockPageComponent {
private saveStateToLocalStorage() {
try {
const formValue = this.form.value;
const state = {
const state: any = {
mode: formValue.mode,
targets: formValue.targets,
delay: formValue.delay,
@@ -482,6 +555,12 @@ export class KnockPageComponent {
waitConnection: formValue.waitConnection,
configYAML: formValue.configYAML
};
// Сохраняем данные форм целей для режима form
if (formValue.mode === 'form' && this.targetForms.length > 0) {
state.targetForms = this.targetForms.value;
environment.log('Saving form targets to localStorage:', state.targetForms);
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
environment.log('State saved to localStorage:', state);
@@ -500,6 +579,14 @@ export class KnockPageComponent {
setTimeout(() => this.saveStateToLocalStorage(), 300);
});
});
// Подписываемся на изменения в формах целей для режима form
this.targetForms.valueChanges.subscribe(() => {
if (this.form.value.mode === 'form') {
this.serializeFormTargets();
setTimeout(() => this.saveStateToLocalStorage(), 300);
}
});
}
// Автоматическое преобразование между режимами
@@ -515,6 +602,10 @@ export class KnockPageComponent {
this.handleModeChangeToYaml();
} else if (previousMode === 'yaml' && newMode === 'inline') {
this.handleModeChangeToInline();
} else if (previousMode === 'form' && (newMode === 'inline' || newMode === 'yaml')) {
this.handleModeChangeFromForm(previousMode, newMode);
} else if ((previousMode === 'inline' || previousMode === 'yaml') && newMode === 'form') {
this.handleModeChangeToForm(previousMode);
}
previousMode = newMode;
@@ -699,6 +790,130 @@ export class KnockPageComponent {
}
}
// Form Mode Conversion Methods
private handleModeChangeToForm(previousMode: string) {
try {
environment.log(`Converting from ${previousMode} to form mode`);
// Очищаем существующие формы
this.targetForms.clear();
if (previousMode === 'inline') {
this.convertInlineToForm();
} else if (previousMode === 'yaml') {
this.convertYamlToForm();
}
// Инициализируем формы если их нет
if (this.targetForms.length === 0) {
this.addTarget();
}
} catch (e) {
console.warn('Failed to convert to form mode:', e);
}
}
private handleModeChangeFromForm(previousMode: string, newMode: string) {
try {
environment.log(`Converting from form mode to ${newMode}`);
// Сначала сериализуем данные формы
this.serializeFormTargets();
if (newMode === 'inline') {
// Данные уже в targets, ничего дополнительно не нужно
environment.log('Form data serialized to inline targets');
} else if (newMode === 'yaml') {
this.convertInlineToYaml();
}
} catch (e) {
console.warn('Failed to convert from form mode:', e);
}
}
private convertInlineToForm() {
try {
const targetsString = this.form.value.targets || '';
if (!targetsString.trim()) {
this.addTarget();
return;
}
const targets = targetsString.split(';').filter(t => t.trim());
targets.forEach(target => {
const parts = target.trim().split(':');
if (parts.length >= 3) {
const protocol = parts[0] || 'tcp';
const host = parts[1] || '127.0.0.1';
const port = parseInt(parts[2]) || 22;
const gateway = parts[3] || '';
const targetForm = this.fb.group({
protocol: [protocol, Validators.required],
host: [host, Validators.required],
port: [port, [Validators.required, Validators.min(1), Validators.max(65535)]],
gateway: [gateway]
});
this.targetForms.push(targetForm);
}
});
environment.log('Converted inline to form:', targets);
} catch (e) {
console.warn('Failed to convert inline to form:', e);
}
}
private convertYamlToForm() {
try {
const yamlContent = this.form.value.configYAML || '';
if (!yamlContent.trim()) {
this.addTarget();
return;
}
const config: any = yaml.load(yamlContent);
if (!config || !config.targets || !Array.isArray(config.targets)) {
this.addTarget();
return;
}
config.targets.forEach((target: any) => {
const protocol = target.protocol || 'tcp';
const host = target.host || '127.0.0.1';
const ports = target.ports || [target.port] || [22];
if (Array.isArray(ports)) {
ports.forEach((port: number) => {
const targetForm = this.fb.group({
protocol: [protocol, Validators.required],
host: [host, Validators.required],
port: [port, [Validators.required, Validators.min(1), Validators.max(65535)]],
gateway: ['']
});
this.targetForms.push(targetForm);
});
} else {
const targetForm = this.fb.group({
protocol: [protocol, Validators.required],
host: [host, Validators.required],
port: [ports, [Validators.required, Validators.min(1), Validators.max(65535)]],
gateway: ['']
});
this.targetForms.push(targetForm);
}
});
environment.log('Converted YAML to form:', config.targets);
} catch (e) {
console.warn('Failed to convert YAML to form:', e);
}
}
// Публичный метод для очистки сохраненного состояния (опционально)
clearSavedState() {
try {