1. 引言
Vaadin是一个现代化的Java Web框架,允许开发者使用纯Java代码构建富客户端Web应用,而无需编写HTML、CSS或JavaScript。结合Spring Boot的便捷性和Vaadin的强大UI组件,可以快速开发出功能丰富、用户体验良好的企业级Web应用。本文将详细介绍如何在Spring Boot项目中集成Vaadin,并构建一个完整的管理系统示例。
2. 技术栈与环境准备
2.1 技术栈
- 后端框架:Spring Boot 2.7+
- UI框架:Vaadin 23.3+
- 数据库:MySQL 8.0+
- ORM框架:Spring Data JPA
- 安全框架:Spring Security
- 构建工具:Maven 3.8+
- Java版本:JDK 11+
2.2 项目依赖配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.8</version><relativePath/></parent><groupId>com.example</groupId><artifactId>vaadin-demo</artifactId><version>1.0.0</version><name>Spring Boot Vaadin Demo</name><properties><java.version>11</java.version><vaadin.version>23.3.5</vaadin.version><maven.compiler.source>11</maven.compiler.source><maven.compiler.target>11</maven.compiler.target></properties><dependencyManagement><dependencies><dependency><groupId>com.vaadin</groupId><artifactId>vaadin-bom</artifactId><version>${vaadin.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><dependencies><!-- Spring Boot Starters --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><!-- Vaadin --><dependency><groupId>com.vaadin</groupId><artifactId>vaadin-spring-boot-starter</artifactId></dependency><!-- Database --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency><!-- Development --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><scope>runtime</scope><optional>true</optional></dependency><!-- Testing --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin><plugin><groupId>com.vaadin</groupId><artifactId>vaadin-maven-plugin</artifactId><version>${vaadin.version}</version><executions><execution><goals><goal>prepare-frontend</goal></goals></execution></executions></plugin></plugins></build>
</project>
3. 基础配置
3.1 应用配置
# application.yml
spring:application:name: vaadin-demodatasource:url: jdbc:mysql://localhost:3306/vaadin_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghaiusername: rootpassword: your_passworddriver-class-name: com.mysql.cj.jdbc.Driverhikari:maximum-pool-size: 20minimum-idle: 5connection-timeout: 30000jpa:hibernate:ddl-auto: updateshow-sql: trueproperties:hibernate:dialect: org.hibernate.dialect.MySQL8Dialectformat_sql: truesecurity:user:name: adminpassword: adminroles: ADMIN# Vaadin配置
vaadin:# 开发模式productionMode: false# 启用前端热重载frontend:hotdeploy: true# 自定义主题theme: my-themelogging:level:com.example: DEBUGorg.springframework.security: DEBUGserver:port: 8080
3.2 主启动类
@SpringBootApplication
@EnableJpaRepositories
public class VaadinDemoApplication {public static void main(String[] args) {SpringApplication.run(VaadinDemoApplication.class, args);}
}
4. 数据模型与服务层
4.1 实体类定义
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;@Column(unique = true, nullable = false)@NotBlank(message = "用户名不能为空")@Size(min = 3, max = 50, message = "用户名长度必须在3-50之间")private String username;@Column(nullable = false)@NotBlank(message = "密码不能为空")@Size(min = 6, message = "密码长度至少6位")private String password;@Column(unique = true, nullable = false)@Email(message = "邮箱格式不正确")@NotBlank(message = "邮箱不能为空")private String email;@Column(name = "full_name")@NotBlank(message = "姓名不能为空")private String fullName;@Column(name = "phone_number")private String phoneNumber;@Enumerated(EnumType.STRING)@Column(nullable = false)private UserRole role = UserRole.USER;@Enumerated(EnumType.STRING)@Column(nullable = false)private UserStatus status = UserStatus.ACTIVE;@Column(name = "created_at")@CreationTimestampprivate LocalDateTime createdAt;@Column(name = "updated_at")@UpdateTimestampprivate LocalDateTime updatedAt;@Column(name = "last_login")private LocalDateTime lastLogin;// 构造函数public User(String username, String password, String email, String fullName) {this.username = username;this.password = password;this.email = email;this.fullName = fullName;}
}// 枚举类
public enum UserRole {ADMIN("管理员"),USER("普通用户"),GUEST("访客");private final String displayName;UserRole(String displayName) {this.displayName = displayName;}public String getDisplayName() {return displayName;}
}public enum UserStatus {ACTIVE("激活"),INACTIVE("未激活"),BLOCKED("已封禁"),DELETED("已删除");private final String displayName;UserStatus(String displayName) {this.displayName = displayName;}public String getDisplayName() {return displayName;}
}
4.2 数据访问层
@Repository
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {Optional<User> findByUsername(String username);Optional<User> findByEmail(String email);boolean existsByUsername(String username);boolean existsByEmail(String email);@Query("SELECT u FROM User u WHERE u.status = :status")List<User> findByStatus(@Param("status") UserStatus status);@Query("SELECT u FROM User u WHERE u.fullName LIKE %:name% OR u.username LIKE %:name%")List<User> findByNameContaining(@Param("name") String name);@Modifying@Query("UPDATE User u SET u.lastLogin = :loginTime WHERE u.id = :id")void updateLastLogin(@Param("id") Long id, @Param("loginTime") LocalDateTime loginTime);
}
4.3 业务服务层
@Service
@Transactional
public class UserService {private static final Logger logger = LoggerFactory.getLogger(UserService.class);@Autowiredprivate UserRepository userRepository;@Autowiredprivate PasswordEncoder passwordEncoder;public List<User> findAll() {return userRepository.findAll();}public Optional<User> findById(Long id) {return userRepository.findById(id);}public Optional<User> findByUsername(String username) {return userRepository.findByUsername(username);}public Page<User> findAll(Pageable pageable) {return userRepository.findAll(pageable);}public List<User> search(String searchTerm) {if (StringUtils.hasText(searchTerm)) {return userRepository.findByNameContaining(searchTerm);}return findAll();}public User save(User user) {// 验证用户名和邮箱的唯一性if (user.getId() == null) {if (userRepository.existsByUsername(user.getUsername())) {throw new IllegalArgumentException("用户名已存在");}if (userRepository.existsByEmail(user.getEmail())) {throw new IllegalArgumentException("邮箱已存在");}// 加密密码user.setPassword(passwordEncoder.encode(user.getPassword()));} else {// 更新时检查唯一性(排除自己)User existingUser = userRepository.findById(user.getId()).orElse(null);if (existingUser != null) {if (!existingUser.getUsername().equals(user.getUsername()) && userRepository.existsByUsername(user.getUsername())) {throw new IllegalArgumentException("用户名已存在");}if (!existingUser.getEmail().equals(user.getEmail()) && userRepository.existsByEmail(user.getEmail())) {throw new IllegalArgumentException("邮箱已存在");}// 如果密码未更改,保持原密码if (StringUtils.isEmpty(user.getPassword()) || user.getPassword().equals(existingUser.getPassword())) {user.setPassword(existingUser.getPassword());} else {user.setPassword(passwordEncoder.encode(user.getPassword()));}}}User savedUser = userRepository.save(user);logger.info("用户保存成功: {}", savedUser.getUsername());return savedUser;}public void delete(User user) {userRepository.delete(user);logger.info("用户删除成功: {}", user.getUsername());}public void updateLastLogin(Long userId) {userRepository.updateLastLogin(userId, LocalDateTime.now());}public long count() {return userRepository.count();}public List<User> findByStatus(UserStatus status) {return userRepository.findByStatus(status);}
}
5. 安全配置
5.1 Spring Security配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {@Autowiredprivate UserService userService;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic UserDetailsService userDetailsService() {return new CustomUserDetailsService(userService);}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf().disable().authorizeHttpRequests(auth -> auth.requestMatchers("/login", "/register", "/public/**").permitAll().requestMatchers("/admin/**").hasRole("ADMIN").anyRequest().authenticated()).formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true).failureUrl("/login?error=true").permitAll()).logout(logout -> logout.logoutUrl("/logout").logoutSuccessUrl("/login?logout=true").invalidateHttpSession(true).clearAuthentication(true).permitAll()).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED).maximumSessions(1).maxSessionsPreventsLogin(false));return http.build();}
}@Service
public class CustomUserDetailsService implements UserDetailsService {private final UserService userService;public CustomUserDetailsService(UserService userService) {this.userService = userService;}@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {User user = userService.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));if (user.getStatus() != UserStatus.ACTIVE) {throw new DisabledException("用户账号未激活或已被禁用");}// 更新最后登录时间userService.updateLastLogin(user.getId());return org.springframework.security.core.userdetails.User.builder().username(user.getUsername()).password(user.getPassword()).authorities("ROLE_" + user.getRole().name()).accountExpired(false).accountLocked(user.getStatus() == UserStatus.BLOCKED).credentialsExpired(false).disabled(user.getStatus() != UserStatus.ACTIVE).build();}
}
6. Vaadin UI组件实现
6.1 主布局
@CssImport("./styles/shared-styles.css")
public class MainLayout extends AppLayout implements RouterLayout {private static final Logger logger = LoggerFactory.getLogger(MainLayout.class);private final SecurityService securityService;public MainLayout(@Autowired SecurityService securityService) {this.securityService = securityService;createHeader();createDrawer();}private void createHeader() {H1 logo = new H1("管理系统");logo.addClassNames(LumoUtility.FontSize.LARGE,LumoUtility.Margin.MEDIUM);String username = securityService.getAuthenticatedUser().map(User::getFullName).orElse("未知用户");Button logout = new Button("退出 (" + username + ")", e -> securityService.logout());logout.addThemeVariants(ButtonVariant.LUMO_TERTIARY);HorizontalLayout header = new HorizontalLayout(new DrawerToggle(), logo, logout);header.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);header.expand(logo);header.setWidthFull();header.addClassNames(LumoUtility.Padding.Vertical.NONE,LumoUtility.Padding.Horizontal.MEDIUM);addToNavbar(header);}private void createDrawer() {RouterLink listLink = new RouterLink("用户管理", UserListView.class);listLink.setHighlightCondition(HighlightConditions.sameLocation());RouterLink dashboardLink = new RouterLink("仪表盘", DashboardView.class);dashboardLink.setHighlightCondition(HighlightConditions.sameLocation());RouterLink profileLink = new RouterLink("个人资料", ProfileView.class);profileLink.setHighlightCondition(HighlightConditions.sameLocation());addToDrawer(new VerticalLayout(new H3("菜单"),dashboardLink,listLink,profileLink));}
}@Service
public class SecurityService {private static final String LOGOUT_SUCCESS_URL = "/";public Optional<User> getAuthenticatedUser() {SecurityContext context = SecurityContextHolder.getContext();Authentication authentication = context.getAuthentication();if (authentication == null || !authentication.isAuthenticated() || authentication instanceof AnonymousAuthenticationToken) {return Optional.empty();}// 这里可以根据需要从数据库加载完整的用户信息return Optional.empty(); // 简化实现}public void logout() {SecurityContextHolder.clearContext();VaadinSession.getCurrent().getSession().invalidate();UI.getCurrent().navigate(LOGOUT_SUCCESS_URL);UI.getCurrent().getPage().reload();}
}
6.2 用户列表视图
@Route(value = "users", layout = MainLayout.class)
@PageTitle("用户管理")
@PreAuthorize("hasRole('ADMIN')")
public class UserListView extends VerticalLayout {private final UserService userService;private Grid<User> grid;private TextField filterText;private Button addUserButton;public UserListView(UserService userService) {this.userService = userService;addClassName("user-list-view");setSizeFull();configureGrid();configureForm();add(getToolbar(), getContent());updateList();}private void configureGrid() {grid = new Grid<>(User.class, false);grid.addClassName("user-grid");grid.setSizeFull();grid.addColumn(User::getId).setHeader("ID").setWidth("80px").setFlexGrow(0);grid.addColumn(User::getUsername).setHeader("用户名").setSortable(true);grid.addColumn(User::getFullName).setHeader("姓名").setSortable(true);grid.addColumn(User::getEmail).setHeader("邮箱").setSortable(true);grid.addColumn(user -> user.getRole().getDisplayName()).setHeader("角色").setSortable(true);grid.addColumn(user -> user.getStatus().getDisplayName()).setHeader("状态").setSortable(true);grid.addColumn(new LocalDateTimeRenderer<>(User::getCreatedAt, "yyyy-MM-dd HH:mm:ss")).setHeader("创建时间").setSortable(true);grid.addColumn(new LocalDateTimeRenderer<>(User::getLastLogin, "yyyy-MM-dd HH:mm:ss")).setHeader("最后登录").setSortable(true);// 操作列grid.addComponentColumn(user -> {Button editButton = new Button("编辑");editButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL);editButton.addClickListener(e -> editUser(user));Button deleteButton = new Button("删除");deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_SMALL);deleteButton.addClickListener(e -> deleteUser(user));return new HorizontalLayout(editButton, deleteButton);}).setHeader("操作").setWidth("200px").setFlexGrow(0);grid.getColumns().forEach(col -> col.setAutoWidth(true));}private void configureForm() {// 表单将在对话框中显示,这里不需要配置}private HorizontalLayout getToolbar() {filterText = new TextField();filterText.setPlaceholder("搜索用户...");filterText.setClearButtonVisible(true);filterText.setValueChangeMode(ValueChangeMode.LAZY);filterText.addValueChangeListener(e -> updateList());addUserButton = new Button("添加用户");addUserButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);addUserButton.addClickListener(e -> addUser());HorizontalLayout toolbar = new HorizontalLayout(filterText, addUserButton);toolbar.addClassName("toolbar");return toolbar;}private Component getContent() {HorizontalLayout content = new HorizontalLayout(grid);content.setFlexGrow(2, grid);content.addClassName("content");content.setSizeFull();return content;}private void updateList() {String filter = filterText.getValue();if (StringUtils.hasText(filter)) {grid.setItems(userService.search(filter));} else {grid.setItems(userService.findAll());}}private void addUser() {User user = new User();openUserForm(user, "添加用户");}private void editUser(User user) {openUserForm(user, "编辑用户");}private void openUserForm(User user, String title) {UserFormDialog dialog = new UserFormDialog(title, user, userService);dialog.addSaveListener(e -> {updateList();Notification.show("用户保存成功", 3000, Notification.Position.TOP_CENTER);});dialog.open();}private void deleteUser(User user) {ConfirmDialog dialog = new ConfirmDialog();dialog.setHeader("确认删除");dialog.setText("确定要删除用户 \"" + user.getFullName() + "\" 吗?此操作不可恢复。");dialog.setCancelable(true);dialog.addCancelListener(e -> dialog.close());dialog.addConfirmListener(e -> {try {userService.delete(user);updateList();Notification.show("用户删除成功", 3000, Notification.Position.TOP_CENTER);} catch (Exception ex) {Notification.show("删除失败: " + ex.getMessage(), 3000, Notification.Position.TOP_CENTER).addThemeVariants(NotificationVariant.LUMO_ERROR);}});dialog.open();}
}
6.3 用户表单对话框
public class UserFormDialog extends Dialog {private final UserService userService;private final User user;private TextField username;private PasswordField password;private PasswordField confirmPassword;private EmailField email;private TextField fullName;private TextField phoneNumber;private Select<UserRole> roleSelect;private Select<UserStatus> statusSelect;private Button saveButton;private Button cancelButton;private Binder<User> binder;private final List<ComponentEventListener<SaveEvent>> saveListeners = new ArrayList<>();public UserFormDialog(String title, User user, UserService userService) {this.userService = userService;this.user = user;setHeaderTitle(title);setWidth("600px");setHeight("700px");setResizable(false);setDraggable(false);createFormFields();createButtonBar();configureValidation();if (user.getId() != null) {binder.readBean(user);}}private void createFormFields() {username = new TextField("用户名");username.setRequired(true);username.setWidthFull();password = new PasswordField("密码");password.setRequired(user.getId() == null); // 新用户必须填写密码password.setWidthFull();confirmPassword = new PasswordField("确认密码");confirmPassword.setWidthFull();email = new EmailField("邮箱");email.setRequired(true);email.setWidthFull();fullName = new TextField("姓名");fullName.setRequired(true);fullName.setWidthFull();phoneNumber = new TextField("电话号码");phoneNumber.setWidthFull();roleSelect = new Select<>();roleSelect.setLabel("角色");roleSelect.setItems(UserRole.values());roleSelect.setItemLabelGenerator(UserRole::getDisplayName);roleSelect.setValue(UserRole.USER);roleSelect.setWidthFull();statusSelect = new Select<>();statusSelect.setLabel("状态");statusSelect.setItems(UserStatus.values());statusSelect.setItemLabelGenerator(UserStatus::getDisplayName);statusSelect.setValue(UserStatus.ACTIVE);statusSelect.setWidthFull();FormLayout formLayout = new FormLayout();formLayout.add(username, password, confirmPassword, email, fullName, phoneNumber, roleSelect, statusSelect);formLayout.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),new FormLayout.ResponsiveStep("500px", 2));add(formLayout);}private void createButtonBar() {saveButton = new Button("保存", e -> save());saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);cancelButton = new Button("取消", e -> close());HorizontalLayout buttonLayout = new HorizontalLayout(saveButton, cancelButton);buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);getFooter().add(buttonLayout);}private void configureValidation() {binder = new BeanValidationBinder<>(User.class);// 用户名验证binder.forField(username).withValidator(name -> name.length() >= 3, "用户名长度至少3位").withValidator(name -> name.length() <= 50, "用户名长度不能超过50位").bind(User::getUsername, User::setUsername);// 密码验证binder.forField(password).withValidator(pass -> user.getId() != null || !pass.isEmpty(), "新用户密码不能为空").withValidator(pass -> pass.isEmpty() || pass.length() >= 6, "密码长度至少6位").bind(user -> "", // 不显示现有密码User::setPassword);// 确认密码验证binder.forField(confirmPassword).withValidator(confirmPass -> password.getValue().equals(confirmPass), "两次密码输入不一致").bind(user -> "",(user, value) -> {} // 不需要绑定到实体);// 邮箱验证binder.forField(email).withValidator(new EmailValidator("邮箱格式不正确")).bind(User::getEmail, User::setEmail);// 姓名验证binder.forField(fullName).withValidator(name -> !name.trim().isEmpty(), "姓名不能为空").bind(User::getFullName, User::setFullName);// 电话验证binder.forField(phoneNumber).bind(User::getPhoneNumber, User::setPhoneNumber);// 角色和状态绑定binder.forField(roleSelect).bind(User::getRole, User::setRole);binder.forField(statusSelect).bind(User::getStatus, User::setStatus);}private void save() {try {User userToSave = user.getId() == null ? new User() : user;if (binder.validate().isOk()) {binder.writeBean(userToSave);// 如果是编辑用户且密码为空,则不更新密码if (user.getId() != null && password.getValue().isEmpty()) {userToSave.setPassword(user.getPassword());}userService.save(userToSave);fireEvent(new SaveEvent(this, userToSave));close();}} catch (ValidationException e) {Notification.show("请检查输入信息", 3000, Notification.Position.TOP_CENTER).addThemeVariants(NotificationVariant.LUMO_ERROR);} catch (Exception e) {Notification.show("保存失败: " + e.getMessage(), 3000, Notification.Position.TOP_CENTER).addThemeVariants(NotificationVariant.LUMO_ERROR);}}public void addSaveListener(ComponentEventListener<SaveEvent> listener) {saveListeners.add(listener);}private void fireEvent(SaveEvent event) {saveListeners.forEach(listener -> listener.onComponentEvent(event));}public static class SaveEvent extends ComponentEvent<UserFormDialog> {private final User user;public SaveEvent(UserFormDialog source, User user) {super(source, false);this.user = user;}public User getUser() {return user;}}
}
7. 仪表盘视图
7.1 数据统计服务
@Service
public class DashboardService {@Autowiredprivate UserService userService;public DashboardData getDashboardData() {DashboardData data = new DashboardData();// 用户统计data.setTotalUsers(userService.count());data.setActiveUsers(userService.findByStatus(UserStatus.ACTIVE).size());data.setInactiveUsers(userService.findByStatus(UserStatus.INACTIVE).size());data.setBlockedUsers(userService.findByStatus(UserStatus.BLOCKED).size());// 角色统计List<User> allUsers = userService.findAll();Map<UserRole, Long> roleStats = allUsers.stream().collect(Collectors.groupingBy(User::getRole, Collectors.counting()));data.setRoleStatistics(roleStats);// 最近注册用户List<User> recentUsers = allUsers.stream().sorted((u1, u2) -> u2.getCreatedAt().compareTo(u1.getCreatedAt())).limit(5).collect(Collectors.toList());data.setRecentUsers(recentUsers);return data;}
}@Data
public class DashboardData {private long totalUsers;private long activeUsers;private long inactiveUsers;private long blockedUsers;private Map<UserRole, Long> roleStatistics = new HashMap<>();private List<User> recentUsers = new ArrayList<>();
}
7.2 仪表盘视图实现
@Route(value = "", layout = MainLayout.class)
@PageTitle("仪表盘")
@RoleAllowed({"ADMIN", "USER"})
public class DashboardView extends VerticalLayout {private final DashboardService dashboardService;public DashboardView(DashboardService dashboardService) {this.dashboardService = dashboardService;addClassName("dashboard-view");setDefaultHorizontalComponentAlignment(Alignment.STRETCH);setSizeFull();createDashboard();}private void createDashboard() {DashboardData data = dashboardService.getDashboardData();// 标题H2 title = new H2("系统概览");title.addClassNames(LumoUtility.Margin.Bottom.LARGE, LumoUtility.Margin.Top.NONE);// 统计卡片HorizontalLayout statsCards = createStatsCards(data);// 图表和表格布局HorizontalLayout chartsLayout = new HorizontalLayout();chartsLayout.setSizeFull();// 角色分布图Component roleChart = createRoleChart(data.getRoleStatistics());// 最近用户表格Component recentUsersTable = createRecentUsersTable(data.getRecentUsers());chartsLayout.add(roleChart, recentUsersTable);chartsLayout.setFlexGrow(1, roleChart);chartsLayout.setFlexGrow(1, recentUsersTable);add(title, statsCards, chartsLayout);}private HorizontalLayout createStatsCards(DashboardData data) {HorizontalLayout layout = new HorizontalLayout();layout.addClassName("stats-cards");layout.setWidthFull();layout.setJustifyContentMode(JustifyContentMode.AROUND);// 总用户数Component totalCard = createStatCard("总用户数", String.valueOf(data.getTotalUsers()), "users-icon", "primary");// 活跃用户Component activeCard = createStatCard("活跃用户", String.valueOf(data.getActiveUsers()), "user-check-icon", "success");// 未激活用户Component inactiveCard = createStatCard("未激活用户", String.valueOf(data.getInactiveUsers()), "user-x-icon", "contrast");// 被封禁用户Component blockedCard = createStatCard("封禁用户", String.valueOf(data.getBlockedUsers()), "user-minus-icon", "error");layout.add(totalCard, activeCard, inactiveCard, blockedCard);return layout;}private Component createStatCard(String title, String value, String iconClass, String theme) {Div card = new Div();card.addClassNames(LumoUtility.Background.CONTRAST_5,LumoUtility.BorderRadius.LARGE,LumoUtility.Padding.LARGE);card.getStyle().set("min-width", "200px");Icon icon = new Icon(VaadinIcon.USER);icon.addClassNames(LumoUtility.IconSize.LARGE);icon.getStyle().set("color", "var(--lumo-" + theme + "-color)");H3 valueElement = new H3(value);valueElement.addClassNames(LumoUtility.Margin.NONE, LumoUtility.FontSize.XXLARGE);valueElement.getStyle().set("color", "var(--lumo-" + theme + "-color)");Span titleElement = new Span(title);titleElement.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL);HorizontalLayout header = new HorizontalLayout(icon);header.setJustifyContentMode(JustifyContentMode.END);header.setWidthFull();card.add(header, valueElement, titleElement);return card;}private Component createRoleChart(Map<UserRole, Long> roleStats) {Div chartContainer = new Div();chartContainer.addClassName("chart-container");chartContainer.getStyle().set("height", "400px");H4 chartTitle = new H4("用户角色分布");chartTitle.addClassNames(LumoUtility.Margin.Bottom.MEDIUM);VerticalLayout chartLayout = new VerticalLayout();chartLayout.addClassName("role-chart");chartLayout.setPadding(true);chartLayout.setSpacing(true);// 创建简单的条形图roleStats.forEach((role, count) -> {HorizontalLayout bar = createRoleBar(role.getDisplayName(), count, roleStats.values().stream().mapToLong(Long::longValue).max().orElse(1));chartLayout.add(bar);});chartContainer.add(chartTitle, chartLayout);return chartContainer;}private HorizontalLayout createRoleBar(String roleName, Long count, Long maxCount) {HorizontalLayout barContainer = new HorizontalLayout();barContainer.setWidthFull();barContainer.setDefaultVerticalComponentAlignment(Alignment.CENTER);Span label = new Span(roleName);label.getStyle().set("min-width", "80px");Div barTrack = new Div();barTrack.getStyle().set("background-color", "var(--lumo-contrast-10pct)").set("border-radius", "4px").set("height", "20px").set("flex", "1").set("position", "relative");Div barFill = new Div();double percentage = (double) count / maxCount * 100;barFill.getStyle().set("background-color", "var(--lumo-primary-color)").set("border-radius", "4px").set("height", "100%").set("width", percentage + "%").set("transition", "width 0.3s ease");barTrack.add(barFill);Span valueLabel = new Span(count.toString());valueLabel.getStyle().set("min-width", "30px").set("text-align", "right");barContainer.add(label, barTrack, valueLabel);barContainer.setFlexGrow(1, barTrack);return barContainer;}private Component createRecentUsersTable(List<User> recentUsers) {VerticalLayout container = new VerticalLayout();container.addClassName("recent-users-container");container.setPadding(true);container.setSpacing(true);H4 tableTitle = new H4("最近注册用户");tableTitle.addClassNames(LumoUtility.Margin.Bottom.MEDIUM);Grid<User> grid = new Grid<>(User.class, false);grid.addClassName("recent-users-grid");grid.setHeight("350px");grid.setAllRowsVisible(true);grid.addColumn(User::getUsername).setHeader("用户名").setFlexGrow(1);grid.addColumn(User::getFullName).setHeader("姓名").setFlexGrow(1);grid.addColumn(user -> user.getRole().getDisplayName()).setHeader("角色").setFlexGrow(0).setWidth("80px");grid.addColumn(new LocalDateTimeRenderer<>(User::getCreatedAt, "MM-dd HH:mm")).setHeader("注册时间").setFlexGrow(0).setWidth("100px");grid.setItems(recentUsers);container.add(tableTitle, grid);return container;}
}
8. 登录视图
8.1 登录页面实现
@Route("login")
@PageTitle("用户登录")
@AnonymousAllowed
public class LoginView extends VerticalLayout implements BeforeEnterObserver {private static final String LOGIN_SUCCESS_URL = "/";private final AuthenticationManager authenticationManager;private LoginForm loginForm;public LoginView(AuthenticationManager authenticationManager) {this.authenticationManager = authenticationManager;addClassName("login-view");setSizeFull();setJustifyContentMode(JustifyContentMode.CENTER);setDefaultHorizontalComponentAlignment(Alignment.CENTER);createLoginForm();}private void createLoginForm() {// 应用标题H1 title = new H1("管理系统");title.addClassNames(LumoUtility.TextColor.PRIMARY);// 登录表单loginForm = new LoginForm();loginForm.setForgotPasswordButtonVisible(false);// 自定义样式loginForm.getStyle().set("background-color", "var(--lumo-base-color)").set("padding", "var(--lumo-space-xl)").set("border-radius", "var(--lumo-border-radius-l)").set("box-shadow", "var(--lumo-box-shadow-s)");// 登录事件处理loginForm.addLoginListener(this::handleLogin);// 创建注册链接RouterLink registerLink = new RouterLink("还没有账号?点击注册", RegisterView.class);registerLink.addClassNames(LumoUtility.TextColor.SECONDARY);add(title, loginForm, registerLink);}private void handleLogin(LoginForm.LoginEvent event) {try {String username = event.getUsername();String password = event.getPassword();// 创建认证令牌UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password);// 执行认证Authentication authentication = authenticationManager.authenticate(authToken);// 设置安全上下文SecurityContextHolder.getContext().setAuthentication(authentication);// 重定向到主页UI.getCurrent().navigate(LOGIN_SUCCESS_URL);} catch (BadCredentialsException | DisabledException e) {loginForm.setError(true);Notification.show("用户名或密码错误", 3000, Notification.Position.TOP_CENTER).addThemeVariants(NotificationVariant.LUMO_ERROR);} catch (Exception e) {loginForm.setError(true);Notification.show("登录失败: " + e.getMessage(), 3000, Notification.Position.TOP_CENTER).addThemeVariants(NotificationVariant.LUMO_ERROR);}}@Overridepublic void beforeEnter(BeforeEnterEvent event) {// 检查是否已登录Authentication auth = SecurityContextHolder.getContext().getAuthentication();if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {event.forwardTo("");}// 显示登录错误信息if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) {loginForm.setError(true);}// 显示登出成功信息if (event.getLocation().getQueryParameters().getParameters().containsKey("logout")) {Notification.show("已成功登出", 3000, Notification.Position.TOP_CENTER).addThemeVariants(NotificationVariant.LUMO_SUCCESS);}}
}
9. 样式和主题定制
9.1 自定义CSS样式
/* frontend/styles/shared-styles.css *//* 全局样式 */
.app-layout {background-color: var(--lumo-base-color);
}/* 仪表盘样式 */
.dashboard-view {padding: var(--lumo-space-l);
}.stats-cards {margin-bottom: var(--lumo-space-xl);gap: var(--lumo-space-l);
}.chart-container {background: var(--lumo-base-color);border: 1px solid var(--lumo-contrast-10pct);border-radius: var(--lumo-border-radius-m);padding: var(--lumo-space-l);
}.role-chart .horizontal-layout {margin-bottom: var(--lumo-space-s);
}.recent-users-container {background: var(--lumo-base-color);border: 1px solid var(--lumo-contrast-10pct);border-radius: var(--lumo-border-radius-m);
}/* 用户列表样式 */
.user-list-view {padding: var(--lumo-space-l);
}.toolbar {margin-bottom: var(--lumo-space-l);padding: var(--lumo-space-m);background: var(--lumo-base-color);border: 1px solid var(--lumo-contrast-10pct);border-radius: var(--lumo-border-radius-m);
}.user-grid {border: 1px solid var(--lumo-contrast-10pct);border-radius: var(--lumo-border-radius-m);
}/* 登录页面样式 */
.login-view {background: linear-gradient(135deg, var(--lumo-primary-color-10pct) 0%, var(--lumo-primary-color-50pct) 100%);
}.login-view vaadin-login-form {max-width: 400px;box-shadow: var(--lumo-box-shadow-l);
}/* 响应式设计 */
@media (max-width: 768px) {.stats-cards {flex-direction: column;}.toolbar {flex-direction: column;align-items: stretch;}.toolbar > * {margin-bottom: var(--lumo-space-s);}
}/* 动画效果 */
.user-grid vaadin-grid-cell-content {transition: all 0.2s ease;
}.stats-cards > div {transition: transform 0.2s ease, box-shadow 0.2s ease;
}.stats-cards > div:hover {transform: translateY(-4px);box-shadow: var(--lumo-box-shadow-l);
}
10. 配置和部署
10.1 生产环境配置
# application-prod.yml
spring:profiles:active: proddatasource:url: jdbc:mysql://prod-db-server:3306/vaadin_prod?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghaiusername: ${DB_USERNAME:prod_user}password: ${DB_PASSWORD:prod_password}hikari:maximum-pool-size: 50minimum-idle: 10connection-timeout: 20000idle-timeout: 300000max-lifetime: 600000jpa:hibernate:ddl-auto: validateshow-sql: falsevaadin:productionMode: truefrontend:hotdeploy: falsewhitelisted-packages: com.examplelogging:level:root: INFOcom.example: INFOfile:name: /var/log/vaadin-demo/application.logmax-size: 100MBmax-history: 30management:endpoints:web:exposure:include: health,info,metricsendpoint:health:show-details: when-authorizedserver:port: 8080compression:enabled: truehttp2:enabled: true
10.2 Docker部署配置
# Dockerfile
FROM openjdk:11-jre-slimLABEL maintainer="your-email@example.com"# 设置工作目录
WORKDIR /app# 复制jar文件
COPY target/vaadin-demo-*.jar app.jar# 创建日志目录
RUN mkdir -p /var/log/vaadin-demo# 设置JVM参数
ENV JAVA_OPTS="-Xmx512m -Xms256m"# 暴露端口
EXPOSE 8080# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \CMD curl -f http://localhost:8080/actuator/health || exit 1# 启动应用
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
# docker-compose.yml
version: '3.8'
services:vaadin-app:build: .ports:- "8080:8080"environment:- SPRING_PROFILES_ACTIVE=prod- DB_USERNAME=vaadin_user- DB_PASSWORD=your_secure_passworddepends_on:- mysqlvolumes:- ./logs:/var/log/vaadin-demorestart: unless-stoppedmysql:image: mysql:8.0environment:- MYSQL_DATABASE=vaadin_prod- MYSQL_USER=vaadin_user- MYSQL_PASSWORD=your_secure_password- MYSQL_ROOT_PASSWORD=root_passwordports:- "3306:3306"volumes:- mysql_data:/var/lib/mysqlrestart: unless-stoppedvolumes:mysql_data:
11. 性能优化和最佳实践
11.1 Vaadin性能优化
@Configuration
public class VaadinConfig implements VaadinServiceInitListener {@Overridepublic void serviceInit(ServiceInitEvent event) {event.addBootstrapListener(this::modifyBootstrapPage);event.addUIInitListener(this::initializeUI);}private void modifyBootstrapPage(BootstrapPageResponse response) {// 添加meta标签response.getDocument().head().appendElement("meta").attr("name", "viewport").attr("content", "width=device-width, initial-scale=1.0");// 添加预加载资源response.getDocument().head().appendElement("link").attr("rel", "preload").attr("href", "/frontend/styles/shared-styles.css").attr("as", "style");}private void initializeUI(UIInitEvent event) {UI ui = event.getUI();// 设置错误处理器ui.getSession().setErrorHandler(new CustomErrorHandler());// 配置推送设置(如果需要)// ui.getPushConfiguration().setPushMode(PushMode.AUTOMATIC);}private static class CustomErrorHandler implements ErrorHandler {private static final Logger logger = LoggerFactory.getLogger(CustomErrorHandler.class);@Overridepublic void error(ErrorEvent event) {logger.error("UI错误", event.getThrowable());Notification notification = Notification.show("系统发生错误,请稍后重试", 5000, Notification.Position.TOP_CENTER);notification.addThemeVariants(NotificationVariant.LUMO_ERROR);}}
}
12. 总结
本文详细介绍了Spring Boot与Vaadin集成的完整解决方案,涵盖了从项目搭建到生产部署的各个环节。主要特点包括:
12.1 技术优势
- 纯Java开发:无需编写HTML、CSS、JavaScript,降低学习成本
- 组件化架构:丰富的UI组件库,支持自定义扩展
- 响应式设计:自适应各种设备屏幕尺寸
- 类型安全:编译时错误检查,减少运行时异常
- Spring集成:充分利用Spring生态系统的优势
12.2 功能特性
- 用户管理:完整的CRUD操作和权限控制
- 仪表盘:数据可视化和统计分析
- 安全认证:集成Spring Security的登录认证
- 表单验证:客户端和服务端双重验证
- 响应式布局:适配桌面和移动设备
12.3 性能优化
- 懒加载:按需加载组件和数据
- 缓存策略:合理使用缓存减少数据库访问
- 前端优化:资源压缩和预加载
- 数据库优化:连接池配置和查询优化
通过本文的实践,开发者可以快速构建功能完整、用户体验良好的企业级Web应用程序。Vaadin与Spring Boot的结合为Java开发者提供了一条高效的全栈开发路径。