Commit 9f69342a by DaiJiezhang

feat: add device asset management

parent 45443f00
package com.xyw.console.asset.config;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
import jakarta.servlet.MultipartConfigElement;
/** 文件用途(白话):把应用 multipart 请求上限设为可容纳两张 20MB 设备图片和少量表单字段的安全大小。 */
@Configuration
public class DeviceAssetMultipartConfig {
/** 代码作用(白话):创建 Spring 上传限制配置。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):浏览器 multipart 请求 -> Spring 大小检查 -> Controller 或错误 JSON。 */
@Bean public MultipartConfigElement multipartConfigElement() { MultipartConfigFactory factory=new MultipartConfigFactory(); factory.setMaxFileSize(DataSize.ofMegabytes(20)); factory.setMaxRequestSize(DataSize.ofMegabytes(42)); return factory.createMultipartConfig(); }
}
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/** 文件用途(白话):提供设备资产列表、增改删、使用人搜索和受控图片读取的浏览器接口。 */
@RestController
@RequestMapping("/api/device-assets")
public class DeviceAssetController {
private final DeviceAssetService service;
/** 代码作用(白话):接收设备业务服务。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP Controller -> Service -> Mapper/文件服务。 */
public DeviceAssetController(DeviceAssetService service) { this.service=service; }
/** 代码作用(白话):返回设备分页列表。关联文件:DeviceAssetPageQuery.java、DeviceAssetService.java。关联逻辑(调用链/数据流):GET 参数 -> page -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<DeviceAssetPageResponse> page(@Valid DeviceAssetPageQuery query) { return ApiResponse.success(service.page(query)); }
/** 代码作用(白话):创建设备及可选图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart POST -> create -> 文件/数据库 -> JSON 响应。 */
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> create(@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("新增成功",service.create(request)); }
/** 代码作用(白话):编辑有效设备并可替换或移除图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart PUT -> update -> 文件/数据库 -> JSON 响应。 */
@PutMapping(value="/{id}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> update(@PathVariable Long id,@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("编辑成功",service.update(id,request)); }
/** 代码作用(白话):在没有有效引用时软删除设备。关联文件:DeviceAssetService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):DELETE -> 引用检查 -> deleteTime 更新 -> 前端刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id) { service.softDelete(id); return ApiResponse.success("删除成功",null); }
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); }
}
package com.xyw.console.asset.controller;
import com.xyw.console.asset.exception.DeviceAssetNotFoundException;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/** 文件用途(白话):把设备资产的业务异常转换成前端可读的统一 JSON 错误。 */
@RestControllerAdvice(assignableTypes = DeviceAssetController.class)
public class DeviceAssetExceptionHandler {
/** 代码作用(白话):把字段、图片和引用保护失败转为 400。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):Service 校验异常 -> Advice -> ApiResponse -> 前端提示。 */
@ExceptionHandler(DeviceAssetValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> validation(DeviceAssetValidationException exception) { return ApiResponse.error(400, exception.getMessage()); }
/** 代码作用(白话):把不存在的设备转为 404。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):Service 查无设备 -> Advice -> ApiResponse -> 前端提示。 */
@ExceptionHandler(DeviceAssetNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> notFound(DeviceAssetNotFoundException exception) { return ApiResponse.error(404, exception.getMessage()); }
/** 代码作用(白话):把超过整个 multipart 请求限制的上传转为可读错误。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):浏览器上传 -> Spring 限制 -> Advice -> 前端提示。 */
@ExceptionHandler(MaxUploadSizeExceededException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> uploadTooLarge(MaxUploadSizeExceededException exception) { return ApiResponse.error(400, "两张图片及表单总大小不能超过 42MB"); }
}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收设备资产列表的分页与筛选条件,不承载新增或编辑表单数据。 */
public record DeviceAssetPageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
String deviceName,
Long userPersonId,
String userUsageStatus,
String assetRelationStatus) {
/** 代码作用(白话):未传页码时返回第 1 页。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedPage -> MyBatis 分页。 */
public int resolvedPage() { return page == null ? 1 : page; }
/** 代码作用(白话):未传每页数量时返回 20 条,避免一次读取过多设备。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedSize -> Mapper 分页。 */
public int resolvedSize() { return size == null ? 20 : size; }
}
package com.xyw.console.asset.dto;
import java.util.List;
/** 文件用途(白话):承载设备列表记录、总数和分页信息,供前端表格与分页器使用。 */
public record DeviceAssetPageResponse(List<DeviceAssetResponse> records, long total, int page, int size) {}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。 */
public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url,
Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus,
LocalDateTime createTime, LocalDateTime updateTime) {}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
/** 文件用途(白话):接收设备新增或编辑表单,包括设备字段、两张可选图片与单独移除图片的开关。 */
@Data
public class DeviceAssetSaveRequest {
@NotBlank(message = "设备名称不能为空") private String deviceName;
private Long userPersonId;
@NotBlank(message = "请选择使用状态") private String userUsageStatus;
@NotBlank(message = "请选择资产关联状态") private String assetRelationStatus;
private MultipartFile imageAttachment1;
private MultipartFile imageAttachment2;
private Boolean removeImageAttachment1;
private Boolean removeImageAttachment2;
}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载设备表单使用人搜索的最小信息,避免下拉框读取完整人员档案。 */
public record DevicePersonLookupResponse(Long id, String personName) {}
package com.xyw.console.asset.exception;
/** 文件用途(白话):表示请求的设备不存在或已经软删除。 */
public class DeviceAssetNotFoundException extends RuntimeException {
/** 代码作用(白话):保存可返回给页面的设备不存在原因。关联文件:DeviceAssetService.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> HTTP 404 响应。 */
public DeviceAssetNotFoundException(String message) { super(message); }
}
package com.xyw.console.asset.exception;
/** 文件用途(白话):表示设备字段、图片或删除引用检查未通过。 */
public class DeviceAssetValidationException extends RuntimeException {
/** 代码作用(白话):保存可返回给页面的规则校验失败原因。关联文件:DeviceAssetService.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):表单/删除请求 -> Service 校验 -> HTTP 400 响应。 */
public DeviceAssetValidationException(String message) { super(message); }
}
package com.xyw.console.asset.service;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import javax.imageio.ImageIO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/** 文件用途(白话):校验并保存设备原图,且只允许用不透明标识在受控目录中读取图片。 */
@Service
public class DeviceAssetFileStorageService {
private static final long MAX_IMAGE_BYTES = 20L * 1024 * 1024;
private static final Set<String> EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif");
private final Path root;
/** 代码作用(白话):解析并创建设备图片根目录。关联文件:DeviceAssetService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):配置/默认目录 -> 文件服务 -> 保存和读取图片。 */
public DeviceAssetFileStorageService(@Value("${xyw.device-assets.upload-dir:./uploads/device-assets}") String uploadDirectory) {
try { root = Path.of(uploadDirectory).toAbsolutePath().normalize(); Files.createDirectories(root); }
catch (IOException exception) { throw new IllegalStateException("设备图片目录无法创建", exception); }
}
/** 代码作用(白话):保存一张已通过校验的原图并返回不透明文件标识。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart 图片 -> store -> 文件标识 -> as_asset_device 附件列。 */
public String store(MultipartFile image) {
if (image == null || image.isEmpty()) return null;
validateImage(image);
String identifier = createOpaqueIdentifier(extensionOf(image.getOriginalFilename()));
Path target = resolveInsideRoot(identifier);
try (InputStream input = image.getInputStream()) { Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); return identifier; }
catch (IOException exception) { throw new DeviceAssetValidationException("设备图片保存失败"); }
}
/** 代码作用(白话):把不透明标识解析为受目录约束的可读取资源。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL -> findImage -> resolve -> ResponseEntity 文件响应。 */
public Resource resolve(String identifier) {
if (identifier == null || identifier.isBlank()) throw new DeviceAssetValidationException("图片不存在");
try { Resource resource = new UrlResource(resolveInsideRoot(identifier).toUri()); if (!resource.exists() || !resource.isReadable()) throw new DeviceAssetValidationException("图片不存在"); return resource; }
catch (IOException exception) { throw new DeviceAssetValidationException("图片读取失败"); }
}
/** 代码作用(白话):保存替换图或保留旧标识,避免编辑未选图时丢失原图。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):编辑表单 -> replace -> 新/旧标识 -> 设备更新。 */
public String replace(String currentIdentifier, MultipartFile replacement, boolean remove) {
if (replacement != null && !replacement.isEmpty()) return store(replacement);
return remove ? null : currentIdentifier;
}
/** 代码作用(白话):清空数据库中的附件引用但保留物理原图,以支持软删除后追溯。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):移除图片标记 -> removeReference -> 数据库字段置空 -> 原图保留。 */
public String removeReference() { return null; }
/** 代码作用(白话):删除本次失败请求新写入的文件,不接收历史附件标识。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):保存失败 -> cleanupNewFile -> 删除临时新图。 */
public void cleanupNewFile(String identifier) {
if (identifier == null) return;
try { Files.deleteIfExists(resolveInsideRoot(identifier)); } catch (IOException ignored) { }
}
/** 代码作用(白话):同时检查文件大小、扩展名和可解码图像内容。关联文件:DeviceAssetSaveRequest.java、DeviceAssetController.java。关联逻辑(调用链/数据流):浏览器文件 -> validateImage -> 允许保存或返回 400。 */
private void validateImage(MultipartFile image) {
if (image.getSize() > MAX_IMAGE_BYTES) throw new DeviceAssetValidationException("每张图片不能超过 20MB");
String extension = extensionOf(image.getOriginalFilename());
if (!EXTENSIONS.contains(extension)) throw new DeviceAssetValidationException("仅支持 JPG、PNG、GIF 图片");
try (InputStream input = image.getInputStream()) { BufferedImage decoded = ImageIO.read(input); if (decoded == null) throw new DeviceAssetValidationException("图片内容无效"); }
catch (IOException exception) { throw new DeviceAssetValidationException("图片内容无效"); }
}
/** 代码作用(白话):生成不包含原始文件名的随机标识,降低猜测路径风险。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):上传文件 -> 随机标识 -> 受控访问 URL。 */
private String createOpaqueIdentifier(String extension) { return UUID.randomUUID() + "." + extension; }
/** 代码作用(白话):从原始文件名提取允许的小写扩展名。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):文件名 -> extensionOf -> 类型校验与目标文件名。 */
private String extensionOf(String originalFilename) {
String name = originalFilename == null ? "" : originalFilename.trim(); int dot = name.lastIndexOf('.');
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
/** 代码作用(白话):只允许标准随机标识定位根目录内文件,阻止路径穿越。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):URL 文件标识 -> resolveInsideRoot -> 本地资源。 */
private Path resolveInsideRoot(String identifier) {
if (!identifier.matches("[0-9a-fA-F-]{36}\\.(jpg|jpeg|png|gif)")) throw new DeviceAssetValidationException("图片标识无效");
Path resolved = root.resolve(identifier).normalize();
if (!resolved.startsWith(root)) throw new DeviceAssetValidationException("图片标识无效");
return resolved;
}
}
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.DeviceAssetPageQuery;
import com.xyw.console.asset.dto.DeviceAssetSaveRequest;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.mapper.*;
import java.util.List;
import org.junit.jupiter.api.Test;
/** 文件用途(白话):以 Mock Mapper 验证设备列表名称解析和关键校验规则,不访问真实数据库。 */
class DeviceAssetServiceTest {
/** 代码作用(白话):验证分页行会批量解析有效使用人姓名。关联文件:DeviceAssetService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):设备 Mapper -> page -> 人员 Mapper -> 响应行。 */
@Test void resolvesUserPersonNameForPageRecord() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class); CompanyPersonMapper people=mock(CompanyPersonMapper.class); DeviceAssetService service=service(devices,people);
AssetDeviceEntity device=new AssetDeviceEntity();device.setId(1L);device.setDeviceName("测试机");device.setUserPersonId(9L);device.setUserUsageStatus("使用中");device.setAssetRelationStatus("已关联");Page<AssetDeviceEntity> page=new Page<>(1,20);page.setRecords(List.of(device));page.setTotal(1);
CompanyPersonEntity person=new CompanyPersonEntity();person.setId(9L);person.setPersonName("张三");when(devices.selectPage(any(Page.class),any(Wrapper.class))).thenReturn(page);when(people.selectList(any(Wrapper.class))).thenReturn(List.of(person));
assertEquals("张三",service.page(new DeviceAssetPageQuery(null,null,null,null,null,null)).records().get(0).userPersonName());
}
/** 代码作用(白话):验证服务端不接受前端伪造的状态值。关联文件:DeviceAssetService.java、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):保存表单 -> validateStatus -> 400 业务异常。 */
@Test void rejectsUnsupportedUsageStatus() {
DeviceAssetService service=service(mock(AssetDeviceMapper.class),mock(CompanyPersonMapper.class));DeviceAssetSaveRequest request=new DeviceAssetSaveRequest();request.setDeviceName("测试机");request.setUserUsageStatus("未知");request.setAssetRelationStatus("已关联");
assertThrows(DeviceAssetValidationException.class,()->service.create(request));
}
/** 代码作用(白话):构造只含当前测试所需 Mock 的设备服务。关联文件:DeviceAssetService.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):测试 -> Mock Mapper/文件服务 -> Service 规则。 */
private DeviceAssetService service(AssetDeviceMapper devices,CompanyPersonMapper people){return new DeviceAssetService(devices,people,mock(PhoneAssetMapper.class),mock(WecomAccountMapper.class),mock(WechatAccountMapper.class),mock(DouyinAccountMapper.class),new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images"));}
}
import { defineConfig } from '@playwright/test';
/** 文件用途(白话):为设备资产测试单独启动 5174 端口,避免复用其他工作树正在运行的 5173 开发服务。 */
export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://127.0.0.1:5174', browserName: 'chromium' },
webServer: { command: 'npm run dev -- --host 127.0.0.1 --port 5174', url: 'http://127.0.0.1:5174/', reuseExistingServer: false, timeout: 30_000 }
});
/** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */ /** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js、DeviceAssetView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */
export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` }; export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/device-assets">设备资产管理</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` };
\ No newline at end of file
/** 文件用途(白话):统一调用设备资产接口,并将非成功响应变为页面可显示的错误。 */
async function request(path, options = {}) {
/** 代码作用(白话):发送一次设备接口请求并提取统一响应数据。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):页面动作 -> request -> ApiResponse -> 页面状态或错误提示。 */
const response = await fetch(path, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '设备资产请求失败');
return payload.data;
}
/** 代码作用(白话):按筛选条件读取设备列表。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):筛选器 -> GET -> 分页响应 -> 表格。 */
export function listDeviceAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key,value); }); return request('/api/device-assets?' + params); }
/** 代码作用(白话):用 multipart 表单新建设备。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 文件/设备写入 -> 响应。 */
export function createDeviceAsset(form) { return request('/api/device-assets', { method: 'POST', body: form }); }
/** 代码作用(白话):用 multipart 表单编辑设备。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 文件/设备更新 -> 响应。 */
export function updateDeviceAsset(id, form) { return request('/api/device-assets/' + id, { method: 'PUT', body: form }); }
/** 代码作用(白话):请求后端执行受引用保护的软删除。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 引用检查/软删除 -> 列表刷新。 */
export function deleteDeviceAsset(id) { return request('/api/device-assets/' + id, { method: 'DELETE' }); }
/** 代码作用(白话):搜索可作为设备使用人的公司人员。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):远程选择器 -> GET lookup -> 人员选项。 */
export function searchDeviceCompanyPersons(keyword) { return request('/api/device-assets/lookups/company-persons?keyword=' + encodeURIComponent(keyword || '')); }
/* 文件用途(白话):仅为设备资产管理页面提供样式,避免修改正在使用的全局 app.css。 */
.device-asset-page{max-width:1680px;margin:0 auto;padding:30px 4px}.device-asset-page__header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.device-asset-page__header h2{margin:0;font-size:28px}.device-asset-page__panel{padding:20px;margin-bottom:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px}.device-asset-page__filters{display:flex;flex-wrap:wrap;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:210px}.device-asset-page__images{display:flex;gap:14px;flex-wrap:wrap}.device-asset-page__image-slot{width:150px}.device-asset-page__preview{display:flex;width:132px;height:96px;margin:8px 0;align-items:center;justify-content:center;overflow:hidden;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc}.device-asset-page__preview img{width:100%;height:100%;object-fit:cover}.device-asset-page__pagination{display:flex;justify-content:space-between;align-items:center;padding-top:16px}.device-asset-page__image-cell img{width:44px;height:44px;object-fit:cover;border-radius:6px;cursor:pointer}@media(max-width:700px){.device-asset-page{padding:20px 0}.device-asset-page__header{align-items:stretch;flex-direction:column;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:100%}.device-asset-page__pagination{align-items:flex-start;flex-direction:column}}
...@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'; ...@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import LegacyReferenceView from '../reference/LegacyReferenceView.js'; import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js'; import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
import WecomAccountView from '../modules/wecom/WecomAccountView.js'; import WecomAccountView from '../modules/wecom/WecomAccountView.js';
import DeviceAssetView from '../modules/device/DeviceAssetView.js';
/** /**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。 * 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
...@@ -23,6 +24,7 @@ const router = createRouter({ ...@@ -23,6 +24,7 @@ const router = createRouter({
{ path: '/', redirect: '/overview' }, { path: '/', redirect: '/overview' },
{ path: '/overview', component: createPlaceholderView('资产总览') }, { path: '/overview', component: createPlaceholderView('资产总览') },
{ path: '/phone-assets', component: PhoneAssetView }, { path: '/phone-assets', component: PhoneAssetView },
{ path: '/device-assets', component: DeviceAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') }, { path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') }, { path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wecom', component: WecomAccountView }, { path: '/reference/wecom', component: WecomAccountView },
......
import { expect, test } from '@playwright/test';
/** 文件用途(白话):验证设备资产管理路由、列表数据和状态筛选控件,不依赖真实后端数据库。 */
test('shows the device management page and readable device row', async ({ page }) => {
/** 代码作用(白话):为设备列表和人员搜索提供稳定模拟响应。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> route.fulfill -> Vue 表格与下拉项。 */
await page.route('**/api/device-assets**', async route => {
const url = route.request().url();
const data = url.includes('/lookups/company-persons') ? [{ id: 9, personName: '张三' }] : { records: [{ id: 1, deviceName: 'iPhone 15-01', imageAttachment1Url: null, imageAttachment2Url: null, userPersonId: 9, userPersonName: '张三', userUsageStatus: '使用中', assetRelationStatus: '已关联', createTime: '2026-08-01T10:00:00', updateTime: '2026-08-01T10:00:00' }], total: 1, page: 1, size: 20 };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data }) });
});
await page.goto('/asset/#/device-assets');
await expect(page.getByRole('heading', { name: '设备资产管理' })).toBeVisible();
await expect(page.getByText('iPhone 15-01')).toBeVisible();
await expect(page.getByText('张三(9)')).toBeVisible();
await expect(page.locator('.el-table').getByText('使用中', { exact: true })).toBeVisible();
await expect(page.locator('.el-table').getByText('已关联', { exact: true })).toBeVisible();
});
/** 文件用途(白话):验证图片选择在浏览器端拒绝超过 20MB 的文件,避免无效请求发送到服务器。 */
test('rejects an oversized image before device save', async ({ page }) => {
/** 代码作用(白话):模拟空设备列表,让测试直接打开新增弹窗。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):页面 GET -> route.fulfill -> 新增表单。 */
await page.route('**/api/device-assets**', async route => { await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { records: [], total: 0, page: 1, size: 20 } }) }); });
await page.goto('/asset/#/device-assets');
await page.getByRole('button', { name: '新增设备' }).click();
const file = { name: 'too-large.png', mimeType: 'image/png', buffer: Buffer.alloc(20 * 1024 * 1024 + 1) };
await page.locator('input[type=file]').first().setInputFiles(file);
await expect(page.getByText('每张图片不能超过 20MB')).toBeVisible();
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment