效果显示

在这里插入图片描述

前言

最近在做一个用户管理系统的时候,需要频繁使用 Dialog 来处理创建、编辑、删除等操作。一开始直接用 DevUI 的 Dialog 组件,但写到第三个对话框的时候,我就开始觉得这样做有点重复了。每次都要配置一堆参数、定义按钮、处理验证,代码看起来很冗长。

后来我想,既然这些逻辑都是重复的,为什么不把它们封装起来呢?于是就有了这个二次封装的 Dialog 组件。用了一段时间以后,感觉开发效率确实提高了不少。今天就把这个方案分享出来,希望对大家有帮助。


一、问题的根源

原生 Dialog 的几个痛点

用过 DevUI Dialog 的人应该都知道,每次打开一个对话框都需要做这些事:

首先要在组件里注入 DialogService,然后配置一堆参数——宽度、高度、标题、按钮、事件处理等等。如果对话框里还有表单,还要手动创建 FormGroup、定义验证规则、处理错误提示。最后还要处理数据的传递和回调。

写第一个对话框的时候还好,但写到第三个、第四个的时候,你就会发现自己在重复做同样的事情。而且因为每次都要手动配置,很容易出错。比如忘记处理某个验证规则,或者按钮事件没有正确绑定。

验证逻辑特别麻烦

表单验证是最烦人的。你需要:

  • 定义每个字段的验证规则
  • 写代码来显示错误信息
  • 根据表单的有效性动态更新按钮状态

这些逻辑虽然不复杂,但是重复性很强。

数据处理也很繁琐

获取对话框中的数据,需要手动创建 FormGroup、实现数据获取方法、处理各种回调。如果有多个对话框,这些代码会重复很多次。


二、我的解决方案

想法很简单

既然每次都要做同样的事情,那就把这些重复的逻辑提取出来,做成一个可复用的服务。这样下次需要对话框的时候,就不用重复写那些配置代码了。

我的方案分为两层:

第一层是 SimpleDialogService,这是给日常使用的。它提供了三个简单的方法:openForm()openConfirm()openAlert()。用这个服务,打开一个对话框只需要一行代码。

第二层是 DialogWrapperService,这是给需要更多控制的场景用的。如果 SimpleDialogService 不够灵活,可以用这个来做更复杂的配置。

然后下面是两个内容组件:DialogFormComponent 用来渲染表单,DialogConfirmComponent 用来渲染确认信息。这两个组件负责处理所有的验证和错误提示。

最底层就是 DevUI 的原生 Dialog 组件,我们的所有东西都是基于它来构建的。


三、开始编码

先定义接口

首先要定义两个接口。一个是对话框的配置,一个是表单字段的配置。

对话框配置接口很直接,就是对话框需要的那些参数:

export interface DialogConfig {
  id?: string;
  title: string;
  width?: string;
  maxHeight?: string;
  backdropCloseable?: boolean;
  showCancelButton?: boolean;
  confirmButtonText?: string;
  cancelButtonText?: string;
  onConfirm?: (data: any) => void;
  onCancel?: () => void;
  onClose?: () => void;
}

这些字段都很好理解。title 是必填的,其他的都有默认值。比如宽度默认 600px,点击背景默认可以关闭对话框。

然后是表单字段的配置:

export interface FormFieldConfig {
  name: string;
  label: string;
  type: 'text' | 'textarea' | 'email' | 'password' | 'number';
  placeholder?: string;
  value?: any;
  required?: boolean;
  validators?: any[];
  maxLength?: number;
  helpTips?: string;
  rows?: number;
}

这个接口定义了表单字段需要的所有信息。namelabel 是必填的,其他都是可选的。比如 required 表示这个字段是否必填,maxLength 表示最大长度,helpTips 是给用户的帮助提示。rows 是用来设置 textarea 的行数。

实现服务

现在来实现 SimpleDialogService。这是整个方案的核心。

@Injectable({
  providedIn: 'root'
})
export class SimpleDialogService {
  constructor(
    private dialogService: DialogService,
    private fb: FormBuilder
  ) {}

  openForm(
    title: string,
    fields: FormFieldConfig[],
    onConfirm?: (data: any) => void
  ): Promise<any> {
    return new Promise((resolve, reject) => {
      const formGroup = this.createFormGroup(fields);
      
      const results = this.dialogService.open({
        id: `form-dialog-${Date.now()}`,
        width: '600px',
        maxHeight: '600px',
        title,
        content: DialogFormComponent,
        backdropCloseable: true,
        buttons: [
          {
            cssClass: 'primary',
            text: '确定',
            disabled: !formGroup.valid,
            handler: () => {
              if (formGroup.valid) {
                const data = formGroup.value;
                onConfirm?.(data);
                results.modalInstance.hide();
                resolve(data);
              }
            },
          },
          {
            id: 'btn-cancel',
            cssClass: 'common',
            text: '取消',
            handler: () => {
              results.modalInstance.hide();
              reject(new Error('Cancelled'));
            },
          },
        ],
        data: {
          formGroup,
          fields,
          onFormChange: () => {
            results.modalInstance.updateButtonOptions([{ disabled: !formGroup.valid }]);
          }
        },
      });
    });
  }
}

这个 openForm 方法做的事情很多,但对外只暴露了三个参数:标题、字段配置和确认回调。

方法内部做了什么呢?首先根据字段配置自动创建 FormGroup,这样就不用手动定义验证规则了。然后配置对话框的各种参数——宽度、高度、按钮等等。最关键的是,当表单数据变化时,会自动更新确定按钮的禁用状态。如果表单无效,按钮就是禁用的;如果表单有效,按钮就是启用的。

最后返回一个 Promise,这样调用者可以用 .then()async/await 来处理结果。

3.3 实现表单内容组件

@Component({
  selector: 'd-dialog-form',
  standalone: true,
  imports: [
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    TextInputModule,
    FormModule,
    TextareaModule
  ],
  template: `
    <div class="dialog-form-wrapper">
      <form [formGroup]="formGroup" (ngSubmit)="onSubmit()">
        <div *ngFor="let field of fields" class="form-field">
          <d-form-item>
            <d-form-label 
              [required]="field.required || false"
              [hasHelp]="!!field.helpTips"
              [helpTips]="field.helpTips || ''">
              {{ field.label }}
            </d-form-label>
            <d-form-control>
              <div *ngIf="field.type === 'textarea'">
                <textarea
                  dTextarea
                  [formControlName]="field.name"
                  [placeholder]="field.placeholder || ''"
                  [maxlength]="field.maxLength || 9999"
                  [rows]="field.rows || 4"
                  (change)="onFieldChange()">
                </textarea>
              </div>
              <div *ngIf="field.type !== 'textarea'">
                <input
                  dTextInput
                  [type]="field.type"
                  [formControlName]="field.name"
                  [placeholder]="field.placeholder || ''"
                  [maxlength]="field.maxLength || 9999"
                  (change)="onFieldChange()"
                />
              </div>
              <div class="error-message" *ngIf="isFieldInvalid(field.name)">
                {{ getErrorMessage(field) }}
              </div>
            </d-form-control>
          </d-form-item>
        </div>
      </form>
    </div>
  `
})
export class DialogFormComponent {
  @Input() data: any;
  
  formGroup!: FormGroup;
  fields: FormFieldConfig[] = [];

  ngOnInit() {
    if (this.data) {
      this.formGroup = this.data.formGroup;
      this.fields = this.data.fields;
    }
  }

  onFieldChange() {
    this.data?.onFormChange?.();
  }

  isFieldInvalid(fieldName: string): boolean {
    const field = this.formGroup.get(fieldName);
    return !!(field && field.invalid && (field.dirty || field.touched));
  }

  getErrorMessage(field: FormFieldConfig): string {
    const control = this.formGroup.get(field.name);
    
    if (!control) return '';
    
    if (control.hasError('required')) {
      return `${field.label} 不能为空`;
    }
    
    if (control.hasError('email')) {
      return `${field.label} 格式不正确`;
    }
    
    if (control.hasError('maxlength')) {
      const maxLength = control.getError('maxlength').requiredLength;
      return `${field.label} 不能超过 ${maxLength} 个字符`;
    }
    
    return '字段验证失败';
  }
}

解释DialogFormComponent 是对话框的内容组件。它的职责是:

  1. 根据 fields 配置动态渲染表单字段
  2. 支持多种字段类型(text、email、password、number、textarea)
  3. 实时验证和显示错误信息
  4. 监听字段变化,触发回调更新按钮状态

这个组件完全通用,可以用于任何表单场景。


四、实际应用

现在来看看怎么用这个服务。我做了一个用户管理系统的例子。

在组件中注入服务

import { Component } from '@angular/core';
import { SimpleDialogService } from './shared/dialog-wrapper.service';
import { FormFieldConfig } from './shared/dialog-wrapper.component';

@Component({
  selector: 'app-root',
  standalone: true,
  providers: [SimpleDialogService],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent {
  users: any[] = [];

  constructor(private simpleDialogService: SimpleDialogService) {}
}

很简单,就是在 providers 数组里加上 SimpleDialogService

创建用户

openCreateUserDialog(): void {
  const fields: FormFieldConfig[] = [
    {
      name: 'username',
      label: '用户名',
      type: 'text',
      placeholder: '请输入用户名',
      required: true,
      maxLength: 50,
      helpTips: '用户名长度 3-50 个字符'
    },
    {
      name: 'email',
      label: '邮箱',
      type: 'email',
      placeholder: '请输入邮箱地址',
      required: true,
      helpTips: '请输入有效的邮箱地址'
    },
    {
      name: 'phone',
      label: '电话',
      type: 'text',
      placeholder: '请输入电话号码',
      required: false,
      maxLength: 20
    },
    {
      name: 'department',
      label: '部门',
      type: 'text',
      placeholder: '请输入部门名称',
      required: true,
      maxLength: 100
    },
    {
      name: 'description',
      label: '描述',
      type: 'textarea',
      placeholder: '请输入用户描述信息',
      required: false,
      maxLength: 500,
      rows: 4
    }
  ];

  this.simpleDialogService.openForm(
    '创建新用户',
    fields,
    (data) => {
      console.log('User created:', data);
      this.users.push(data);
    }
  ).catch(err => {
    console.log('Dialog cancelled:', err.message);
  });
}

看这个代码,定义好字段配置以后,就直接调用 openForm 方法。传三个参数:对话框标题、字段配置、和一个回调函数。回调函数在用户点击确定按钮时被调用,参数是用户填写的数据。

就这么简单。没有任何关于 Dialog 配置、按钮事件、验证规则的代码。所有这些复杂的东西都被隐藏在 openForm 方法里面了。

编辑用户

编辑用户和创建用户的代码很像,但有一个区别——用 value 字段来预填充现有的数据:

openEditUserDialog(index: number): void {
  const user = this.users[index];
  
  const fields: FormFieldConfig[] = [
    {
      name: 'username',
      label: '用户名',
      type: 'text',
      value: user.username,  // 这里预填充现有数据
      required: true,
      maxLength: 50
    },
    // ... 其他字段
  ];

  this.simpleDialogService.openForm(
    '编辑用户信息',
    fields,
    (data) => {
      this.users[index] = data;
    }
  );
}

这样用户打开对话框时,就能看到当前的值,可以直接修改。

删除确认

删除的时候需要一个确认对话框。用 openConfirm 方法:

openDeleteConfirmDialog(index: number): void {
  const user = this.users[index];
  
  this.simpleDialogService.openConfirm(
    '删除确认',
    `确定要删除用户 "${user.username}" 吗?此操作不可撤销。`
  ).then(confirmed => {
    if (confirmed) {
      this.users.splice(index, 1);
    }
  });
}

openConfirm 返回一个 Promise,如果用户点击确定就返回 true,点击取消就返回 false。

警告对话框

有时候只需要显示一个提示信息,用 openAlert 就可以了:

openAlertDialog(): void {
  this.simpleDialogService.openAlert(
    '提示',
    '这是一个警告对话框。点击确定关闭。'
  );
}

就这么简单。


五、模板和样式

5.1 HTML 模板

<div class="demo-container">
  <h1>二次封装 Dialog 组件演示</h1>

  <!-- 操作按钮 -->
  <div class="button-section">
    <d-button bsStyle="primary" (click)="openCreateUserDialog()">创建用户</d-button>
    <d-button bsStyle="common" (click)="openAlertDialog()">打开警告对话框</d-button>
  </div>

  <!-- 用户列表 -->
  <div class="users-section" *ngIf="users.length > 0">
    <div class="users-header">
      <h3>用户列表 ({{ users.length }})</h3>
    </div>
    <div class="users-table">
      <table>
        <thead>
          <tr>
            <th>用户名</th>
            <th>邮箱</th>
            <th>电话</th>
            <th>部门</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          <tr *ngFor="let user of users; let i = index">
            <td>{{ user.username }}</td>
            <td>{{ user.email }}</td>
            <td>{{ user.phone || '-' }}</td>
            <td>{{ user.department }}</td>
            <td class="actions">
              <d-button bsStyle="text" size="sm" (click)="openEditUserDialog(i)">编辑</d-button>
              <d-button bsStyle="text" size="sm" (click)="deleteUser(i)">删除</d-button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>

  <!-- 空状态提示 -->
  <div class="empty-state" *ngIf="users.length === 0">
    <p>暂无用户数据,点击"创建用户"按钮添加新用户</p>
  </div>

  <!-- 二次封装 Dialog 的优势 -->
  <div class="feature-list">
    <h2>二次封装 Dialog 的优势</h2>
    <ul>
      <li><strong>简化使用:</strong>通过 SimpleDialogService 快速打开对话框,无需重复配置</li>
      <li><strong>预设样式:</strong>内置统一的样式和布局,保证视觉一致性</li>
      <li><strong>自动验证:</strong>根据字段配置自动生成验证规则,支持必填、邮箱、长度等验证</li>
      <li><strong>数据绑定:</strong>自动处理表单数据绑定和获取,无需手动操作</li>
      <li><strong>错误提示:</strong>自动显示字段验证错误信息,提升用户体验</li>
      <li><strong>按钮状态:</strong>自动根据表单有效性更新确定按钮的禁用状态</li>
      <li><strong>Promise 支持:</strong>使用 Promise 处理对话框结果,支持 async/await</li>
      <li><strong>多种对话框:</strong>支持表单对话框、确认对话框、警告对话框等多种类型</li>
    </ul>
  </div>
</div>

解释:模板非常简洁。主要包括:

  1. 操作按钮区域,用于打开各种对话框
  2. 用户列表,展示创建的用户和操作按钮
  3. 空状态提示,当没有用户时显示
  4. 功能说明区域,列出二次封装的优势

5.2 CSS 样式

.demo-container {
  padding: 20px;
  max-width: 1400px;
  margin: 0 auto;
}

h1 {
  color: #252b3a;
  margin-bottom: 24px;
  font-size: 28px;
}

/* 按钮部分 */
.button-section {
  margin-bottom: 24px;
  padding: 16px;
  background: #f8f9fa;
  border-radius: 4px;
  border: 1px solid #e1e5eb;
}

.button-section d-button {
  margin-right: 12px;
}

/* 用户列表样式 */
.users-section {
  margin-top: 20px;
}

.users-header h3 {
  color: #252b3a;
  font-size: 16px;
  margin: 0;
}

.users-table {
  overflow-x: auto;
  border: 1px solid #e1e5eb;
  border-radius: 4px;
}

.users-table table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}

.users-table th {
  padding: 12px;
  text-align: left;
  color: #575d6c;
  font-weight: 600;
  border-bottom: 1px solid #e1e5eb;
  background: #f8f9fa;
}

.users-table td {
  padding: 12px;
  border-bottom: 1px solid #e1e5eb;
  color: #252b3a;
}

.users-table tbody tr:hover {
  background: #f8f9fa;
}

/* 空状态样式 */
.empty-state {
  padding: 40px 20px;
  text-align: center;
  color: #a8abb2;
  background: #f8f9fa;
  border-radius: 4px;
  border: 1px dashed #e1e5eb;
}

/* 功能说明 */
.feature-list {
  margin-top: 24px;
  padding: 20px;
  background: linear-gradient(135deg, #f0f6ff 0%, #f5f9ff 100%);
  border-radius: 8px;
  border-left: 4px solid #5e7ce0;
  box-shadow: 0 2px 8px rgba(94, 124, 224, 0.08);
}

.feature-list h2 {
  margin: 0 0 16px 0;
  color: #252b3a;
  font-size: 18px;
  font-weight: 700;
}

.feature-list ul {
  margin: 0;
  padding-left: 20px;
  list-style: none;
}

.feature-list li {
  margin-bottom: 10px;
  color: #575d6c;
  line-height: 1.8;
  position: relative;
  padding-left: 8px;
}

.feature-list li:before {
  content: "✓";
  position: absolute;
  left: -16px;
  color: #5e7ce0;
  font-weight: bold;
}

解释:样式遵循 DevUI 的设计规范,包括:

  1. 统一的颜色和间距
  2. 清晰的视觉层次
  3. 响应式设计
  4. 悬停效果和交互反馈

六、总结一下优势

代码量确实少了很多

用原生 Dialog,一个对话框需要 50+ 行代码。用我的方案,只需要 10 行左右。

维护也简单了

所有 Dialog 的逻辑都在一个服务里。如果要改样式或改行为,只需要改一个地方。不用在每个组件里都改一遍。

风格统一

所有对话框都用同样的样式和交互方式。用户不会看到风格不一致的对话框。

开发快

写对话框的时候,不用关心 Dialog 怎么配置,只需要定义字段就行。

容易扩展

如果要加新的字段类型或验证规则,只需要改一个地方。


七、一些小技巧

用 async/await 会更清爽

如果你喜欢 async/await 的写法,可以这样写:

async openCreateUserDialog(): Promise<void> {
  try {
    const data = await this.simpleDialogService.openForm(
      '创建新用户',
      fields
    );
    this.users.push(data);
  } catch (err) {
    console.log('用户取消了');
  }
}

这样看起来就像同步代码一样,更容易理解。

自定义验证规则

有时候需要更复杂的验证。比如年龄要在 18 到 100 之间:

import { Validators } from '@angular/forms';

const fields: FormFieldConfig[] = [
  {
    name: 'age',
    label: '年龄',
    type: 'number',
    required: true,
    validators: [
      Validators.min(18),
      Validators.max(100)
    ]
  }
];

系统会自动应用这些验证规则。

预填充数据

编辑的时候,可以用 value 字段来预填充现有的数据:

const fields: FormFieldConfig[] = [
  {
    name: 'username',
    label: '用户名',
    type: 'text',
    value: existingUser.username,
    required: true
  }
];

八、一些建议

字段配置可以复用

如果有多个地方用同样的字段,可以把它提取出来:

const userFields: FormFieldConfig[] = [
  {
    name: 'username',
    label: '用户名',
    type: 'text',
    required: true,
    maxLength: 50
  },
  // ... 其他字段
];

// 创建和编辑都用这个配置
this.simpleDialogService.openForm('创建用户', userFields);
this.simpleDialogService.openForm('编辑用户', userFields);

这样就不用重复定义字段了。

要处理取消的情况

用户可能会点击取消按钮,所以要处理这种情况:

this.simpleDialogService.openForm('标题', fields, (data) => {
  // 用户点击了确定
  this.users.push(data);
}).catch(err => {
  // 用户点击了取消
  console.log('用户取消了');
});

操作完成后给个反馈

用户操作完成后,最好给个提示,让他知道操作成功了:

this.simpleDialogService.openForm('创建用户', fields, (data) => {
  this.users.push(data);
  this.showSuccessMessage('用户创建成功');
});

九、常见问题

怎样改对话框的宽度?

SimpleDialogService 的宽度是固定的。如果要改,可以用 DialogWrapperService:

this.dialogWrapperService.openFormDialog(
  {
    title: '创建用户',
    width: '800px',
    maxHeight: '700px'
  },
  fields
);

怎样加新的字段类型?

比如要加一个日期选择器。首先在 FormFieldConfig 接口里加一个新的类型:

type: 'text' | 'textarea' | 'email' | 'password' | 'number' | 'date';

然后在 DialogFormComponent 的模板里加上对应的输入控件就行。

怎样写复杂的验证规则?

比如要验证两个密码是否相同。可以写一个自定义验证器:

function passwordMatchValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    // 验证逻辑
    return null;
  };
}

{
  name: 'password',
  label: '密码',
  type: 'password',
  validators: [passwordMatchValidator()]
}

十、最后说两句

这个方案用了一段时间,感觉效果不错。主要的好处就是:

  • 代码少了很多,从 50+ 行减到 10 行
  • 所有对话框的风格都一样,看起来更专业
  • 改样式的时候,只需要改一个地方
  • 开发新功能的时候,不用关心 Dialog 怎么配置,只需要定义字段就行

如果你的项目里有很多对话框,这个方案应该会很有帮助。


相关资源

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐