Merge remote-tracking branch 'origin/develop' into guanyu
This commit is contained in:
@@ -46,6 +46,16 @@ public class CacheConstants {
|
||||
*/
|
||||
public static final String LOGIN_SELECTED_TENANT = "login_selected_tenant:";
|
||||
|
||||
/**
|
||||
* Token黑名单 redis key
|
||||
*/
|
||||
public static final String TOKEN_BLACKLIST_KEY = "token_blacklist:";
|
||||
|
||||
/**
|
||||
* 登录失败锁定 redis key
|
||||
*/
|
||||
public static final String LOGIN_FAIL_LOCK_KEY = "login_fail_lock:";
|
||||
|
||||
/**
|
||||
* 超出上限,排番失败(时间:{},KEY:{}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.core.framework.web.service;
|
||||
|
||||
import com.core.common.constant.CacheConstants;
|
||||
import com.core.common.core.redis.RedisCache;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 登录失败锁定服务
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@Component
|
||||
public class LoginFailLockService {
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Value("${user.password.maxRetryCount:5}")
|
||||
private int maxRetryCount;
|
||||
|
||||
@Value("${user.password.lockTime:10}")
|
||||
private int lockTime;
|
||||
|
||||
private String getLockKey(String username) {
|
||||
return CacheConstants.LOGIN_FAIL_LOCK_KEY + username;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否被锁定
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 是否被锁定
|
||||
*/
|
||||
public boolean isLocked(String username) {
|
||||
Integer failCount = redisCache.getCacheObject(getLockKey(username));
|
||||
return failCount != null && failCount >= maxRetryCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录失败次数
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
public void recordLoginFailure(String username) {
|
||||
String key = getLockKey(username);
|
||||
Integer count = redisCache.getCacheObject(key);
|
||||
if (count == null) {
|
||||
count = 0;
|
||||
}
|
||||
count++;
|
||||
redisCache.setCacheObject(key, count, lockTime, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除登录失败记录(登录成功时调用)
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
public void clearLoginFailure(String username) {
|
||||
redisCache.deleteObject(getLockKey(username));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余锁定时间(分钟)
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 剩余分钟数
|
||||
*/
|
||||
public int getRemainingLockMinutes(String username) {
|
||||
Long ttl = redisCache.getCacheObject(getLockKey(username) + ":ttl");
|
||||
if (ttl == null) {
|
||||
return 0;
|
||||
}
|
||||
return (int) Math.ceil(ttl / 60.0);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,9 @@ public class SysLoginService {
|
||||
@Autowired
|
||||
private ISysTenantOptionService sysTenantOptionService;
|
||||
|
||||
@Autowired
|
||||
private LoginFailLockService loginFailLockService;
|
||||
|
||||
/**
|
||||
* 登录验证
|
||||
*
|
||||
@@ -77,6 +80,11 @@ public class SysLoginService {
|
||||
* @return 结果
|
||||
*/
|
||||
public String login(String username, String password, String code, String uuid, Integer tenantId) {
|
||||
// 登录失败锁定检查
|
||||
if (loginFailLockService.isLocked(username)) {
|
||||
throw new com.core.common.exception.ServiceException("账号已被锁定,请稍后再试");
|
||||
}
|
||||
|
||||
// 验证码校验
|
||||
// validateCaptcha(username, code, uuid);
|
||||
|
||||
@@ -96,6 +104,8 @@ public class SysLoginService {
|
||||
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
|
||||
authentication = authenticationManager.authenticate(authenticationToken);
|
||||
} catch (Exception e) {
|
||||
// 记录登录失败
|
||||
loginFailLockService.recordLoginFailure(username);
|
||||
if (e instanceof BadCredentialsException ex) {
|
||||
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL,
|
||||
MessageUtils.message("user.password.not.match")));
|
||||
@@ -108,6 +118,8 @@ public class SysLoginService {
|
||||
} finally {
|
||||
AuthenticationContextHolder.clearContext();
|
||||
}
|
||||
// 登录成功,清除失败记录
|
||||
loginFailLockService.clearLoginFailure(username);
|
||||
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS,
|
||||
MessageUtils.message("user.login.success")));
|
||||
LoginUser loginUser = (LoginUser)authentication.getPrincipal();
|
||||
|
||||
@@ -59,6 +59,10 @@ public class TokenService {
|
||||
String token = getToken(request);
|
||||
if (StringUtils.isNotEmpty(token)) {
|
||||
try {
|
||||
// 检查token是否在黑名单中
|
||||
if (isTokenBlacklisted(token)) {
|
||||
return null;
|
||||
}
|
||||
Claims claims = parseToken(token);
|
||||
// 解析对应的权限以及用户信息
|
||||
String uuid = (String)claims.get(Constants.LOGIN_USER_KEY);
|
||||
@@ -113,6 +117,49 @@ public class TokenService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将token加入黑名单
|
||||
*
|
||||
* @param token 令牌
|
||||
* @param expireMinutes 过期时间(分钟)
|
||||
*/
|
||||
public void blacklistToken(String token, long expireMinutes) {
|
||||
if (StringUtils.isNotEmpty(token)) {
|
||||
String blacklistKey = CacheConstants.TOKEN_BLACKLIST_KEY + token;
|
||||
redisCache.setCacheObject(blacklistKey, "1", (int) expireMinutes, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否在黑名单中
|
||||
*
|
||||
* @param token 令牌
|
||||
* @return 是否被黑名单
|
||||
*/
|
||||
public boolean isTokenBlacklisted(String token) {
|
||||
if (StringUtils.isEmpty(token)) {
|
||||
return false;
|
||||
}
|
||||
String blacklistKey = CacheConstants.TOKEN_BLACKLIST_KEY + token;
|
||||
return redisCache.getCacheObject(blacklistKey) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌(生成新token)
|
||||
*
|
||||
* @param loginUser 用户信息
|
||||
* @return 新令牌
|
||||
*/
|
||||
public String refreshAccessToken(LoginUser loginUser) {
|
||||
if (StringUtils.isNull(loginUser) || StringUtils.isEmpty(loginUser.getToken())) {
|
||||
return null;
|
||||
}
|
||||
// 将旧token加入黑名单
|
||||
blacklistToken(loginUser.getToken(), expireTime);
|
||||
// 创建新token
|
||||
return createToken(loginUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建令牌
|
||||
*
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.healthlink.his.web.nursing.appservice;
|
||||
|
||||
import com.healthlink.his.web.nursing.dto.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface INursingMobileAppService {
|
||||
List<NursingMobilePatientDto> getMobilePatientList(String wardName, String searchKey);
|
||||
List<NursingMobileOrderDto> getMobileOrderList(Long patientId, Integer statusFilter);
|
||||
Map<String, Object> executeOrder(Long requestId, String adviceTable, Long encounterId, Long patientId);
|
||||
NursingMobileVitalSignDto saveVitalSign(NursingMobileVitalSignDto vitalSign);
|
||||
NursingMobileVitalSignTrendDto getVitalSignTrend(Long patientId, Integer days);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.healthlink.his.web.nursing.appservice.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.healthlink.his.nursing.domain.NursingVitalSignsChart;
|
||||
import com.healthlink.his.nursing.service.INursingVitalSignsChartService;
|
||||
import com.healthlink.his.web.nursing.appservice.INursingMobileAppService;
|
||||
import com.healthlink.his.web.nursing.dto.*;
|
||||
import com.healthlink.his.web.nursing.mapper.NursingMobileAppMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class NursingMobileAppServiceImpl implements INursingMobileAppService {
|
||||
|
||||
@Resource
|
||||
private NursingMobileAppMapper mobileMapper;
|
||||
|
||||
@Resource
|
||||
private INursingVitalSignsChartService vitalSignsChartService;
|
||||
|
||||
@Override
|
||||
public List<NursingMobilePatientDto> getMobilePatientList(String wardName, String searchKey) {
|
||||
return mobileMapper.selectMobilePatientList(wardName, searchKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NursingMobileOrderDto> getMobileOrderList(Long patientId, Integer statusFilter) {
|
||||
return mobileMapper.selectMobileOrderList(patientId, statusFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> executeOrder(Long requestId, String adviceTable, Long encounterId, Long patientId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("requestId", requestId);
|
||||
result.put("adviceTable", adviceTable);
|
||||
result.put("executeTime", new Date());
|
||||
result.put("status", "SUCCESS");
|
||||
result.put("message", "医嘱执行成功");
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public NursingMobileVitalSignDto saveVitalSign(NursingMobileVitalSignDto dto) {
|
||||
NursingVitalSignsChart chart = new NursingVitalSignsChart();
|
||||
chart.setEncounterId(dto.getEncounterId());
|
||||
chart.setPatientId(dto.getPatientId());
|
||||
chart.setPatientName(dto.getPatientName());
|
||||
chart.setRecordDate(dto.getRecordDate() != null ?
|
||||
new java.sql.Date(dto.getRecordDate().getTime()).toLocalDate() : LocalDate.now());
|
||||
chart.setRecordHour(dto.getRecordHour() != null ? dto.getRecordHour() : Calendar.getInstance().get(Calendar.HOUR_OF_DAY));
|
||||
chart.setTemperature(dto.getTemperature());
|
||||
chart.setPulse(dto.getPulse());
|
||||
chart.setRespiration(dto.getRespiration());
|
||||
chart.setSystolicBp(dto.getSystolicBp());
|
||||
chart.setDiastolicBp(dto.getDiastolicBp());
|
||||
chart.setHeightCm(dto.getHeightCm());
|
||||
chart.setWeightKg(dto.getWeightKg());
|
||||
chart.setPainScore(dto.getPainScore());
|
||||
chart.setConsciousLevel(dto.getConsciousLevel());
|
||||
chart.setInputMl(dto.getInputMl());
|
||||
chart.setOutputMl(dto.getOutputMl());
|
||||
chart.setStoolCount(dto.getStoolCount());
|
||||
chart.setNurseName(dto.getNurseName());
|
||||
chart.setCreateTime(new Date());
|
||||
vitalSignsChartService.save(chart);
|
||||
dto.setId(chart.getId());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NursingMobileVitalSignTrendDto getVitalSignTrend(Long patientId, Integer days) {
|
||||
NursingMobileVitalSignTrendDto trend = new NursingMobileVitalSignTrendDto();
|
||||
trend.setPatientId(patientId);
|
||||
|
||||
LocalDate endDate = LocalDate.now();
|
||||
LocalDate startDate = endDate.minusDays(days != null ? days : 7);
|
||||
|
||||
LambdaQueryWrapper<NursingVitalSignsChart> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(NursingVitalSignsChart::getPatientId, patientId)
|
||||
.ge(NursingVitalSignsChart::getRecordDate, startDate)
|
||||
.le(NursingVitalSignsChart::getRecordDate, endDate)
|
||||
.orderByAsc(NursingVitalSignsChart::getRecordDate)
|
||||
.orderByAsc(NursingVitalSignsChart::getRecordHour);
|
||||
|
||||
List<NursingVitalSignsChart> records = vitalSignsChartService.list(wrapper);
|
||||
|
||||
List<NursingMobileVitalSignTrendDto.VitalSignPoint> tempPoints = new ArrayList<>();
|
||||
List<NursingMobileVitalSignTrendDto.VitalSignPoint> pulsePoints = new ArrayList<>();
|
||||
List<NursingMobileVitalSignTrendDto.VitalSignPoint> sysPoints = new ArrayList<>();
|
||||
List<NursingMobileVitalSignTrendDto.VitalSignPoint> diaPoints = new ArrayList<>();
|
||||
List<NursingMobileVitalSignTrendDto.VitalSignPoint> respPoints = new ArrayList<>();
|
||||
|
||||
for (NursingVitalSignsChart r : records) {
|
||||
String label = r.getRecordDate() + " " + (r.getRecordHour() != null ? r.getRecordHour() + ":00" : "");
|
||||
Date dateVal = java.sql.Date.valueOf(r.getRecordDate());
|
||||
|
||||
if (r.getTemperature() != null) {
|
||||
NursingMobileVitalSignTrendDto.VitalSignPoint p = new NursingMobileVitalSignTrendDto.VitalSignPoint();
|
||||
p.setRecordDate(dateVal);
|
||||
p.setRecordHour(r.getRecordHour());
|
||||
p.setValue(r.getTemperature());
|
||||
p.setLabel(label);
|
||||
tempPoints.add(p);
|
||||
}
|
||||
if (r.getPulse() != null) {
|
||||
NursingMobileVitalSignTrendDto.VitalSignPoint p = new NursingMobileVitalSignTrendDto.VitalSignPoint();
|
||||
p.setRecordDate(dateVal);
|
||||
p.setRecordHour(r.getRecordHour());
|
||||
p.setValue(BigDecimal.valueOf(r.getPulse()));
|
||||
p.setLabel(label);
|
||||
pulsePoints.add(p);
|
||||
}
|
||||
if (r.getSystolicBp() != null) {
|
||||
NursingMobileVitalSignTrendDto.VitalSignPoint p = new NursingMobileVitalSignTrendDto.VitalSignPoint();
|
||||
p.setRecordDate(dateVal);
|
||||
p.setRecordHour(r.getRecordHour());
|
||||
p.setValue(BigDecimal.valueOf(r.getSystolicBp()));
|
||||
p.setLabel(label);
|
||||
sysPoints.add(p);
|
||||
}
|
||||
if (r.getDiastolicBp() != null) {
|
||||
NursingMobileVitalSignTrendDto.VitalSignPoint p = new NursingMobileVitalSignTrendDto.VitalSignPoint();
|
||||
p.setRecordDate(dateVal);
|
||||
p.setRecordHour(r.getRecordHour());
|
||||
p.setValue(BigDecimal.valueOf(r.getDiastolicBp()));
|
||||
p.setLabel(label);
|
||||
diaPoints.add(p);
|
||||
}
|
||||
if (r.getRespiration() != null) {
|
||||
NursingMobileVitalSignTrendDto.VitalSignPoint p = new NursingMobileVitalSignTrendDto.VitalSignPoint();
|
||||
p.setRecordDate(dateVal);
|
||||
p.setRecordHour(r.getRecordHour());
|
||||
p.setValue(BigDecimal.valueOf(r.getRespiration()));
|
||||
p.setLabel(label);
|
||||
respPoints.add(p);
|
||||
}
|
||||
|
||||
if (!records.isEmpty()) {
|
||||
trend.setPatientName(records.get(0).getPatientName());
|
||||
}
|
||||
}
|
||||
|
||||
trend.setTemperatureData(tempPoints);
|
||||
trend.setPulseData(pulsePoints);
|
||||
trend.setSystolicBpData(sysPoints);
|
||||
trend.setDiastolicBpData(diaPoints);
|
||||
trend.setRespirationData(respPoints);
|
||||
|
||||
return trend;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.healthlink.his.web.nursing.controller;
|
||||
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.web.nursing.appservice.INursingMobileAppService;
|
||||
import com.healthlink.his.web.nursing.dto.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "移动护理")
|
||||
@RestController
|
||||
@RequestMapping("/nursing/mobile")
|
||||
public class NursingMobileController {
|
||||
|
||||
@Resource
|
||||
private INursingMobileAppService mobileAppService;
|
||||
|
||||
@Operation(summary = "移动端患者列表")
|
||||
@GetMapping("/patient-list")
|
||||
@PreAuthorize("hasAuthority('nursing:nursing:list')")
|
||||
public R<?> getPatientList(
|
||||
@RequestParam(required = false) String wardName,
|
||||
@RequestParam(required = false) String searchKey) {
|
||||
List<NursingMobilePatientDto> list = mobileAppService.getMobilePatientList(wardName, searchKey);
|
||||
return R.ok(list);
|
||||
}
|
||||
|
||||
@Operation(summary = "待执行医嘱列表")
|
||||
@GetMapping("/order-list/{patientId}")
|
||||
@PreAuthorize("hasAuthority('nursing:nursing:list')")
|
||||
public R<?> getOrderList(
|
||||
@PathVariable Long patientId,
|
||||
@RequestParam(required = false) Integer statusFilter) {
|
||||
List<NursingMobileOrderDto> list = mobileAppService.getMobileOrderList(patientId, statusFilter);
|
||||
return R.ok(list);
|
||||
}
|
||||
|
||||
@Operation(summary = "扫码执行医嘱")
|
||||
@PostMapping("/order-execute")
|
||||
@PreAuthorize("hasAuthority('nursing:nursing:edit')")
|
||||
public R<?> executeOrder(@RequestBody Map<String, Object> params) {
|
||||
Long requestId = Long.valueOf(params.get("requestId").toString());
|
||||
String adviceTable = params.get("adviceTable").toString();
|
||||
Long encounterId = Long.valueOf(params.get("encounterId").toString());
|
||||
Long patientId = Long.valueOf(params.get("patientId").toString());
|
||||
Map<String, Object> result = mobileAppService.executeOrder(requestId, adviceTable, encounterId, patientId);
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "录入生命体征")
|
||||
@PostMapping("/vital-sign")
|
||||
@PreAuthorize("hasAuthority('nursing:nursing:edit')")
|
||||
public R<?> saveVitalSign(@RequestBody NursingMobileVitalSignDto vitalSign) {
|
||||
NursingMobileVitalSignDto saved = mobileAppService.saveVitalSign(vitalSign);
|
||||
return R.ok(saved);
|
||||
}
|
||||
|
||||
@Operation(summary = "体征趋势")
|
||||
@GetMapping("/vital-sign-trend/{patientId}")
|
||||
@PreAuthorize("hasAuthority('nursing:nursing:list')")
|
||||
public R<?> getVitalSignTrend(
|
||||
@PathVariable Long patientId,
|
||||
@RequestParam(required = false) Integer days) {
|
||||
NursingMobileVitalSignTrendDto trend = mobileAppService.getVitalSignTrend(patientId, days);
|
||||
return R.ok(trend);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.healthlink.his.web.nursing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class NursingMobileOrderDto {
|
||||
private Long requestId;
|
||||
private Long encounterId;
|
||||
private Long patientId;
|
||||
private String adviceName;
|
||||
private String adviceTable;
|
||||
private Integer requestStatus;
|
||||
private String requestStatusText;
|
||||
private Integer therapyEnum;
|
||||
private String therapyEnumText;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
private String requesterName;
|
||||
private String frequencyUsage;
|
||||
private String singleDose;
|
||||
private String volume;
|
||||
private Integer quantity;
|
||||
private String unitCodeText;
|
||||
private Integer executeCount;
|
||||
private Integer executeNum;
|
||||
private Date lastExecuteTime;
|
||||
private String barcode;
|
||||
private Long procedureId;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.healthlink.his.web.nursing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class NursingMobilePatientDto {
|
||||
private Long encounterId;
|
||||
private Long patientId;
|
||||
private String patientName;
|
||||
private Integer genderEnum;
|
||||
private String genderEnumText;
|
||||
private String bedName;
|
||||
private String wardName;
|
||||
private Integer nursingLevel;
|
||||
private String nursingLevelText;
|
||||
private Integer encounterStatus;
|
||||
private String encounterStatusText;
|
||||
private String diagnosis;
|
||||
private String admittingDoctorName;
|
||||
private Date admissionDate;
|
||||
private Integer priorityEnum;
|
||||
private String priorityEnumText;
|
||||
private Integer age;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.healthlink.his.web.nursing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class NursingMobileVitalSignDto {
|
||||
private Long id;
|
||||
private Long encounterId;
|
||||
private Long patientId;
|
||||
private String patientName;
|
||||
private Date recordDate;
|
||||
private Integer recordHour;
|
||||
private BigDecimal temperature;
|
||||
private Integer pulse;
|
||||
private Integer respiration;
|
||||
private Integer systolicBp;
|
||||
private Integer diastolicBp;
|
||||
private BigDecimal heightCm;
|
||||
private BigDecimal weightKg;
|
||||
private Integer painScore;
|
||||
private String consciousLevel;
|
||||
private Integer inputMl;
|
||||
private Integer outputMl;
|
||||
private Integer stoolCount;
|
||||
private String nurseName;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.healthlink.his.web.nursing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class NursingMobileVitalSignTrendDto {
|
||||
private Long patientId;
|
||||
private String patientName;
|
||||
private List<VitalSignPoint> temperatureData;
|
||||
private List<VitalSignPoint> pulseData;
|
||||
private List<VitalSignPoint> systolicBpData;
|
||||
private List<VitalSignPoint> diastolicBpData;
|
||||
private List<VitalSignPoint> respirationData;
|
||||
|
||||
@Data
|
||||
public static class VitalSignPoint {
|
||||
private Date recordDate;
|
||||
private Integer recordHour;
|
||||
private BigDecimal value;
|
||||
private String label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.healthlink.his.web.nursing.mapper;
|
||||
|
||||
import com.healthlink.his.web.nursing.dto.NursingMobileOrderDto;
|
||||
import com.healthlink.his.web.nursing.dto.NursingMobilePatientDto;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface NursingMobileAppMapper {
|
||||
List<NursingMobilePatientDto> selectMobilePatientList(
|
||||
@Param("wardName") String wardName,
|
||||
@Param("searchKey") String searchKey);
|
||||
|
||||
List<NursingMobileOrderDto> selectMobileOrderList(
|
||||
@Param("patientId") Long patientId,
|
||||
@Param("statusFilter") Integer statusFilter);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.healthlink.his.web.system.appservice;
|
||||
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.sys.domain.SysAuditLog;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审计日志增强服务接口
|
||||
*/
|
||||
public interface IAuditEnhanceAppService {
|
||||
|
||||
/**
|
||||
* 分页查询增强审计日志
|
||||
*/
|
||||
R<?> getEnhancedLogs(String userName, String module, String action, String riskLevel,
|
||||
String businessType, String startDate, String endDate,
|
||||
Integer pageNo, Integer pageSize);
|
||||
|
||||
/**
|
||||
* 获取审计日志统计数据
|
||||
*/
|
||||
R<?> getAuditStats(String startDate, String endDate);
|
||||
|
||||
/**
|
||||
* 记录增强审计日志
|
||||
*/
|
||||
void recordEnhancedLog(SysAuditLog log);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.healthlink.his.web.system.appservice.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.sys.domain.SysAuditLog;
|
||||
import com.healthlink.his.sys.mapper.SysAuditLogMapper;
|
||||
import com.healthlink.his.web.system.appservice.IAuditEnhanceAppService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 审计日志增强服务实现
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuditEnhanceAppServiceImpl implements IAuditEnhanceAppService {
|
||||
|
||||
private final SysAuditLogMapper auditLogMapper;
|
||||
|
||||
@Override
|
||||
public R<?> getEnhancedLogs(String userName, String module, String action, String riskLevel,
|
||||
String businessType, String startDate, String endDate,
|
||||
Integer pageNo, Integer pageSize) {
|
||||
LambdaQueryWrapper<SysAuditLog> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.like(StringUtils.hasText(userName), SysAuditLog::getUserName, userName)
|
||||
.eq(StringUtils.hasText(module), SysAuditLog::getModule, module)
|
||||
.eq(StringUtils.hasText(action), SysAuditLog::getAction, action)
|
||||
.eq(StringUtils.hasText(riskLevel), SysAuditLog::getRiskLevel, riskLevel)
|
||||
.eq(StringUtils.hasText(businessType), SysAuditLog::getBusinessType, businessType);
|
||||
|
||||
if (StringUtils.hasText(startDate)) {
|
||||
try {
|
||||
Date start = new SimpleDateFormat("yyyy-MM-dd").parse(startDate);
|
||||
wrapper.ge(SysAuditLog::getCreateTime, start);
|
||||
} catch (ParseException e) {
|
||||
log.warn("日期解析失败: {}", startDate);
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(endDate)) {
|
||||
try {
|
||||
Date end = new SimpleDateFormat("yyyy-MM-dd").parse(endDate);
|
||||
wrapper.le(SysAuditLog::getCreateTime, end);
|
||||
} catch (ParseException e) {
|
||||
log.warn("日期解析失败: {}", endDate);
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(SysAuditLog::getCreateTime);
|
||||
Page<SysAuditLog> page = auditLogMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
|
||||
return R.ok(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<?> getAuditStats(String startDate, String endDate) {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
// 按模块统计
|
||||
String moduleSql = "SELECT module, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'";
|
||||
// 按风险级别统计
|
||||
String riskSql = "SELECT risk_level, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'";
|
||||
// 按业务类型统计
|
||||
String businessSql = "SELECT business_type, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'";
|
||||
|
||||
if (StringUtils.hasText(startDate)) {
|
||||
moduleSql += " AND create_time >= '" + startDate + "'";
|
||||
riskSql += " AND create_time >= '" + startDate + "'";
|
||||
businessSql += " AND create_time >= '" + startDate + "'";
|
||||
}
|
||||
if (StringUtils.hasText(endDate)) {
|
||||
moduleSql += " AND create_time <= '" + endDate + " 23:59:59'";
|
||||
riskSql += " AND create_time <= '" + endDate + " 23:59:59'";
|
||||
businessSql += " AND create_time <= '" + endDate + " 23:59:59'";
|
||||
}
|
||||
|
||||
moduleSql += " GROUP BY module ORDER BY count DESC";
|
||||
riskSql += " GROUP BY risk_level ORDER BY count DESC";
|
||||
businessSql += " GROUP BY business_type ORDER BY count DESC";
|
||||
|
||||
try {
|
||||
stats.put("moduleStats", auditLogMapper.selectMaps(
|
||||
new LambdaQueryWrapper<SysAuditLog>().last(moduleSql)));
|
||||
stats.put("riskStats", auditLogMapper.selectMaps(
|
||||
new LambdaQueryWrapper<SysAuditLog>().last(riskSql)));
|
||||
stats.put("businessStats", auditLogMapper.selectMaps(
|
||||
new LambdaQueryWrapper<SysAuditLog>().last(businessSql)));
|
||||
} catch (Exception e) {
|
||||
log.error("统计审计日志失败", e);
|
||||
}
|
||||
|
||||
return R.ok(stats);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordEnhancedLog(SysAuditLog auditLog) {
|
||||
try {
|
||||
auditLogMapper.insert(auditLog);
|
||||
} catch (Exception e) {
|
||||
log.error("记录审计日志失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.healthlink.his.web.system.controller;
|
||||
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.web.system.appservice.IAuditEnhanceAppService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 审计日志增强 Controller
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/audit/enhanced")
|
||||
@RequiredArgsConstructor
|
||||
public class AuditEnhanceController {
|
||||
|
||||
private final IAuditEnhanceAppService auditEnhanceAppService;
|
||||
|
||||
/**
|
||||
* 分页查询增强审计日志
|
||||
*/
|
||||
@GetMapping("/logs")
|
||||
@PreAuthorize("@ss.hasPermi('system:audit:list')")
|
||||
public R<?> getEnhancedLogs(
|
||||
@RequestParam(value = "userName", required = false) String userName,
|
||||
@RequestParam(value = "module", required = false) String module,
|
||||
@RequestParam(value = "action", required = false) String action,
|
||||
@RequestParam(value = "riskLevel", required = false) String riskLevel,
|
||||
@RequestParam(value = "businessType", required = false) String businessType,
|
||||
@RequestParam(value = "startDate", required = false) String startDate,
|
||||
@RequestParam(value = "endDate", required = false) String endDate,
|
||||
@RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
|
||||
return auditEnhanceAppService.getEnhancedLogs(userName, module, action, riskLevel,
|
||||
businessType, startDate, endDate, pageNo, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审计日志统计数据
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
@PreAuthorize("@ss.hasPermi('system:audit:list')")
|
||||
public R<?> getAuditStats(
|
||||
@RequestParam(value = "startDate", required = false) String startDate,
|
||||
@RequestParam(value = "endDate", required = false) String endDate) {
|
||||
return auditEnhanceAppService.getAuditStats(startDate, endDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- V84: 审计日志增强 - 扩展字段
|
||||
-- 为系统操作审计日志添加增强字段
|
||||
|
||||
-- 添加新字段
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS user_agent VARCHAR(512) DEFAULT NULL COMMENT '用户代理';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS request_method VARCHAR(10) DEFAULT NULL COMMENT '请求方法(GET/POST/PUT/DELETE)';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS response_code INT DEFAULT NULL COMMENT '响应状态码';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS response_time_ms BIGINT DEFAULT NULL COMMENT '响应时间(毫秒)';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS client_ip VARCHAR(50) DEFAULT NULL COMMENT '客户端IP';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(100) DEFAULT NULL COMMENT '租户名称';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS org_name VARCHAR(100) DEFAULT NULL COMMENT '科室名称';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS risk_level VARCHAR(20) DEFAULT 'LOW' COMMENT '风险级别(LOW/MEDIUM/HIGH/CRITICAL)';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS business_type VARCHAR(50) DEFAULT NULL COMMENT '业务类型(login/logout/query/insert/update/delete/export)';
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON sys_audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_module ON sys_audit_log(module);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON sys_audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_create_time ON sys_audit_log(create_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_risk_level ON sys_audit_log(risk_level);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_business_type ON sys_audit_log(business_type);
|
||||
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.healthlink.his.web.nursing.mapper.NursingMobileAppMapper">
|
||||
|
||||
<select id="selectMobilePatientList"
|
||||
resultType="com.healthlink.his.web.nursing.dto.NursingMobilePatientDto">
|
||||
SELECT ae.id AS encounterId,
|
||||
ap.id AS patientId,
|
||||
ap.name AS patientName,
|
||||
ap.gender_enum AS genderEnum,
|
||||
CASE ap.gender_enum WHEN 1 THEN '男' WHEN 2 THEN '女' ELSE '未知' END AS genderEnumText,
|
||||
alb.location_name AS bedName,
|
||||
alw.location_name AS wardName,
|
||||
ae.priority_enum AS nursingLevel,
|
||||
CASE ae.priority_enum WHEN 1 THEN '一级' WHEN 2 THEN '二级' WHEN 3 THEN '三级' WHEN 4 THEN '特级' ELSE '普通' END AS nursingLevelText,
|
||||
ae.status_enum AS encounterStatus,
|
||||
ae.admitting_doctor_name AS admittingDoctorName,
|
||||
ae.priority_enum AS priorityEnum,
|
||||
CASE ae.priority_enum WHEN 1 THEN '急' WHEN 2 THEN '危' WHEN 3 THEN '一般' ELSE '普通' END AS priorityEnumText
|
||||
FROM adm_encounter ae
|
||||
LEFT JOIN adm_patient ap ON ae.patient_id = ap.id AND ap.delete_flag = '0'
|
||||
LEFT JOIN (SELECT ael.encounter_id, al.name AS location_name
|
||||
FROM adm_encounter_location ael
|
||||
LEFT JOIN adm_location al ON ael.location_id = al.id AND al.delete_flag = '0'
|
||||
WHERE ael.status_enum = 2 AND ael.delete_flag = '0'
|
||||
AND al.form_enum = 4) alw ON alw.encounter_id = ae.id
|
||||
LEFT JOIN (SELECT ael.encounter_id, al.name AS location_name
|
||||
FROM adm_encounter_location ael
|
||||
LEFT JOIN adm_location al ON ael.location_id = al.id AND al.delete_flag = '0'
|
||||
WHERE ael.status_enum = 2 AND ael.delete_flag = '0'
|
||||
AND al.form_enum = 6) alb ON alb.encounter_id = ae.id
|
||||
WHERE ae.delete_flag = '0'
|
||||
AND ae.class_enum = 3
|
||||
AND ae.status_enum IN (2, 3, 6)
|
||||
AND (#{wardName} IS NULL OR alw.location_name = #{wardName})
|
||||
AND (#{searchKey} IS NULL OR ap.name LIKE '%' || #{searchKey} || '%'
|
||||
OR ap.py_str LIKE '%' || #{searchKey} || '%'
|
||||
OR alb.location_name LIKE '%' || #{searchKey} || '%')
|
||||
ORDER BY ae.priority_enum ASC, alb.location_name ASC
|
||||
</select>
|
||||
|
||||
<select id="selectMobileOrderList"
|
||||
resultType="com.healthlink.his.web.nursing.dto.NursingMobileOrderDto">
|
||||
SELECT
|
||||
COALESCE(mr.id, sr.id, dr.id) AS requestId,
|
||||
COALESCE(mr.encounter_id, sr.encounter_id, dr.encounter_id) AS encounterId,
|
||||
COALESCE(mr.patient_id, sr.patient_id, dr.patient_id) AS patientId,
|
||||
COALESCE(mr.advice_name, sr.advice_name, dr.advice_name) AS adviceName,
|
||||
CASE
|
||||
WHEN mr.id IS NOT NULL THEN 'med_medication_request'
|
||||
WHEN sr.id IS NOT NULL THEN 'wor_service_request'
|
||||
WHEN dr.id IS NOT NULL THEN 'wor_device_request'
|
||||
END AS adviceTable,
|
||||
COALESCE(mr.status_enum, sr.status_enum, dr.status_enum) AS requestStatus,
|
||||
CASE COALESCE(mr.status_enum, sr.status_enum, dr.status_enum)
|
||||
WHEN 2 THEN '执行中'
|
||||
WHEN 3 THEN '已完成'
|
||||
WHEN 6 THEN '已停止'
|
||||
WHEN 10 THEN '已校对'
|
||||
WHEN 11 THEN '待接收'
|
||||
ELSE '未知'
|
||||
END AS requestStatusText,
|
||||
COALESCE(mr.therapy_enum, sr.therapy_enum, dr.therapy_enum) AS therapyEnum,
|
||||
CASE COALESCE(mr.therapy_enum, sr.therapy_enum, dr.therapy_enum)
|
||||
WHEN 1 THEN '长期'
|
||||
WHEN 2 THEN '临时'
|
||||
ELSE '未知'
|
||||
END AS therapyEnumText,
|
||||
COALESCE(mr.start_time, sr.start_time, dr.start_time) AS startTime,
|
||||
COALESCE(mr.end_time, sr.end_time, dr.end_time) AS endTime,
|
||||
COALESCE(mr.requester_name, sr.requester_name, dr.requester_name) AS requesterName,
|
||||
COALESCE(mr.rate_code, sr.rate_code, '') AS frequencyUsage,
|
||||
COALESCE(mr.dose, 0) AS singleDose,
|
||||
COALESCE(mr.volume, sr.volume, '') AS volume,
|
||||
COALESCE(mr.quantity, sr.quantity, dr.quantity, 0) AS quantity,
|
||||
COALESCE(mr.unit_code, sr.unit_code, dr.unit_code, '') AS unitCodeText
|
||||
FROM (
|
||||
SELECT id, encounter_id, patient_id, advice_name, status_enum, therapy_enum,
|
||||
start_time, end_time, requester_name, rate_code, dose, volume, quantity, unit_code
|
||||
FROM med_medication_request
|
||||
WHERE delete_flag = '0' AND status_enum IN (2, 3, 10, 11)
|
||||
AND patient_id = #{patientId}
|
||||
) mr
|
||||
LEFT JOIN (
|
||||
SELECT id, encounter_id, patient_id, advice_name, status_enum, therapy_enum,
|
||||
start_time, end_time, requester_name, rate_code, volume, quantity, unit_code
|
||||
FROM wor_service_request
|
||||
WHERE delete_flag = '0' AND status_enum IN (2, 3, 10, 11)
|
||||
AND patient_id = #{patientId}
|
||||
) sr ON 1=0
|
||||
LEFT JOIN (
|
||||
SELECT id, encounter_id, patient_id, advice_name, status_enum, therapy_enum,
|
||||
start_time, end_time, requester_name, volume, quantity, unit_code
|
||||
FROM wor_device_request
|
||||
WHERE delete_flag = '0' AND status_enum IN (2, 3, 10, 11)
|
||||
AND patient_id = #{patientId}
|
||||
) dr ON 1=0
|
||||
ORDER BY
|
||||
CASE COALESCE(mr.status_enum, sr.status_enum, dr.status_enum) WHEN 2 THEN 0 WHEN 10 THEN 1 ELSE 2 END,
|
||||
COALESCE(mr.therapy_enum, sr.therapy_enum, dr.therapy_enum) ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -25,4 +25,13 @@ public class SysAuditLog extends HisBaseEntity {
|
||||
private String result;
|
||||
private String errorMsg;
|
||||
private Integer durationMs;
|
||||
private String userAgent;
|
||||
private String requestMethod;
|
||||
private Integer responseCode;
|
||||
private Long responseTimeMs;
|
||||
private String clientIp;
|
||||
private String tenantName;
|
||||
private String orgName;
|
||||
private String riskLevel;
|
||||
private String businessType;
|
||||
}
|
||||
@@ -129,6 +129,37 @@ export const constantRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/nursingmobile',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: 'patient-list',
|
||||
component: () => import('@/views/nursingmobile/PatientList.vue'),
|
||||
name: 'NursingMobilePatientList',
|
||||
meta: {title: '移动护理-患者列表'}
|
||||
},
|
||||
{
|
||||
path: 'order-list',
|
||||
component: () => import('@/views/nursingmobile/OrderList.vue'),
|
||||
name: 'NursingMobileOrderList',
|
||||
meta: {title: '移动护理-医嘱列表'}
|
||||
},
|
||||
{
|
||||
path: 'vital-sign',
|
||||
component: () => import('@/views/nursingmobile/VitalSign.vue'),
|
||||
name: 'NursingMobileVitalSign',
|
||||
meta: {title: '移动护理-生命体征录入'}
|
||||
},
|
||||
{
|
||||
path: 'vital-sign-trend',
|
||||
component: () => import('@/views/nursingmobile/VitalSignTrend.vue'),
|
||||
name: 'NursingMobileVitalSignTrend',
|
||||
meta: {title: '移动护理-体征趋势'}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 添加套餐管理相关路由到公共路由,确保始终可用
|
||||
{
|
||||
path: '/maintainSystem/Inspection/PackageManagement',
|
||||
|
||||
19
healthlink-his-ui/src/views/auditlog-enhanced/api.js
Normal file
19
healthlink-his-ui/src/views/auditlog-enhanced/api.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 增强审计日志查询
|
||||
export function getEnhancedLogs(params) {
|
||||
return request({
|
||||
url: '/audit/enhanced/logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 审计日志统计
|
||||
export function getAuditStats(params) {
|
||||
return request({
|
||||
url: '/audit/enhanced/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
254
healthlink-his-ui/src/views/auditlog-enhanced/index.vue
Normal file
254
healthlink-his-ui/src/views/auditlog-enhanced/index.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div style="padding:16px">
|
||||
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:18px;font-weight:bold">审计日志管理</span>
|
||||
<el-button @click="showStats = !showStats">
|
||||
{{ showStats ? '隐藏统计' : '查看统计' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计面板 -->
|
||||
<el-card v-if="showStats" style="margin-bottom:16px">
|
||||
<template #header>
|
||||
<span>审计统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:24px;font-weight:bold;color:#409eff">{{ stats.totalCount || 0 }}</div>
|
||||
<div style="color:#909399">总日志数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:24px;font-weight:bold;color:#e6a23c">{{ stats.highRiskCount || 0 }}</div>
|
||||
<div style="color:#909399">高风险操作</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:24px;font-weight:bold;color:#f56c6c">{{ stats.criticalCount || 0 }}</div>
|
||||
<div style="color:#909399">严重风险</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider />
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<div style="font-weight:bold;margin-bottom:8px">按模块分布</div>
|
||||
<div v-for="item in stats.moduleStats" :key="item.module" style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||||
<span>{{ item.module || '未知' }}</span>
|
||||
<el-tag size="small">{{ item.count }}</el-tag>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div style="font-weight:bold;margin-bottom:8px">按风险级别分布</div>
|
||||
<div v-for="item in stats.riskStats" :key="item.risk_level" style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||||
<span>{{ getRiskLabel(item.risk_level) }}</span>
|
||||
<el-tag :type="getRiskType(item.risk_level)" size="small">{{ item.count }}</el-tag>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 查询条件 -->
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap">
|
||||
<el-input
|
||||
v-model="q.userName"
|
||||
placeholder="用户名"
|
||||
clearable
|
||||
style="width:120px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="q.module"
|
||||
placeholder="模块"
|
||||
clearable
|
||||
style="width:120px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="q.action"
|
||||
placeholder="操作"
|
||||
clearable
|
||||
style="width:120px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="q.riskLevel"
|
||||
placeholder="风险级别"
|
||||
clearable
|
||||
style="width:120px"
|
||||
>
|
||||
<el-option label="低" value="LOW" />
|
||||
<el-option label="中" value="MEDIUM" />
|
||||
<el-option label="高" value="HIGH" />
|
||||
<el-option label="严重" value="CRITICAL" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="q.businessType"
|
||||
placeholder="业务类型"
|
||||
clearable
|
||||
style="width:120px"
|
||||
>
|
||||
<el-option label="登录" value="login" />
|
||||
<el-option label="登出" value="logout" />
|
||||
<el-option label="查询" value="query" />
|
||||
<el-option label="新增" value="insert" />
|
||||
<el-option label="修改" value="update" />
|
||||
<el-option label="删除" value="delete" />
|
||||
<el-option label="导出" value="export" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width:240px"
|
||||
/>
|
||||
<el-button type="primary" @click="loadData">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" border stripe>
|
||||
<el-table-column prop="userName" label="用户" width="100" />
|
||||
<el-table-column prop="module" label="模块" width="120" />
|
||||
<el-table-column prop="action" label="操作" width="100" />
|
||||
<el-table-column prop="businessType" label="业务类型" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag size="small">{{ getBusinessLabel(row.businessType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="riskLevel" label="风险级别" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="getRiskType(row.riskLevel)" size="small">
|
||||
{{ getRiskLabel(row.riskLevel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="method" label="方法" width="70" align="center" />
|
||||
<el-table-column prop="url" label="URL" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="clientIp" label="IP" width="120" />
|
||||
<el-table-column prop="result" label="结果" width="80" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.result==='SUCCESS'" type="success" size="small">成功</el-tag>
|
||||
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="responseTimeMs" label="耗时ms" width="80" align="center" />
|
||||
<el-table-column prop="createTime" label="时间" width="170" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{row}">
|
||||
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="q.pageNo"
|
||||
v-model:page-size="q.pageSize"
|
||||
style="margin-top:12px;justify-content:flex-end"
|
||||
:total="total"
|
||||
layout="total,prev,pager,next"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="审计日志详情" width="700px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户">{{ detail.userName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">{{ detail.module }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作">{{ detail.action }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务类型">{{ getBusinessLabel(detail.businessType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险级别">
|
||||
<el-tag :type="getRiskType(detail.riskLevel)">{{ getRiskLabel(detail.riskLevel) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="方法">{{ detail.method }}</el-descriptions-item>
|
||||
<el-descriptions-item label="URL" :span="2">{{ detail.url }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP">{{ detail.clientIp }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时">{{ detail.responseTimeMs }}ms</el-descriptions-item>
|
||||
<el-descriptions-item label="租户">{{ detail.tenantName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="科室">{{ detail.orgName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="参数" :span="2">{{ detail.params }}</el-descriptions-item>
|
||||
<el-descriptions-item label="错误" :span="2">{{ detail.errorMsg }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户代理" :span="2">{{ detail.userAgent }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getEnhancedLogs, getAuditStats } from './api'
|
||||
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const q = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
userName: '',
|
||||
module: '',
|
||||
action: '',
|
||||
riskLevel: '',
|
||||
businessType: ''
|
||||
})
|
||||
const dateRange = ref(null)
|
||||
const showStats = ref(false)
|
||||
const stats = ref({})
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref({})
|
||||
|
||||
const loadData = async () => {
|
||||
const params = { ...q.value }
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.startDate = dateRange.value[0]
|
||||
params.endDate = dateRange.value[1]
|
||||
}
|
||||
const r = await getEnhancedLogs(params)
|
||||
tableData.value = r.data?.records || []
|
||||
total.value = r.data?.total || 0
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
const params = {}
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.startDate = dateRange.value[0]
|
||||
params.endDate = dateRange.value[1]
|
||||
}
|
||||
const r = await getAuditStats(params)
|
||||
stats.value = r.data || {}
|
||||
}
|
||||
|
||||
const resetQuery = () => {
|
||||
q.value = { pageNo: 1, pageSize: 20, userName: '', module: '', action: '', riskLevel: '', businessType: '' }
|
||||
dateRange.value = null
|
||||
loadData()
|
||||
}
|
||||
|
||||
const showDetail = (row) => {
|
||||
detail.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const getRiskLabel = (level) => {
|
||||
const map = { LOW: '低', MEDIUM: '中', HIGH: '高', CRITICAL: '严重' }
|
||||
return map[level] || level || '未知'
|
||||
}
|
||||
|
||||
const getRiskType = (level) => {
|
||||
const map = { LOW: 'info', MEDIUM: 'warning', HIGH: 'danger', CRITICAL: 'danger' }
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
const getBusinessLabel = (type) => {
|
||||
const map = { login: '登录', logout: '登出', query: '查询', insert: '新增', update: '修改', delete: '删除', export: '导出' }
|
||||
return map[type] || type || '未知'
|
||||
}
|
||||
|
||||
watch(showStats, (val) => {
|
||||
if (val) loadStats()
|
||||
})
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
245
healthlink-his-ui/src/views/nursingmobile/OrderList.vue
Normal file
245
healthlink-his-ui/src/views/nursingmobile/OrderList.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="mobile-order-list">
|
||||
<div class="page-header">
|
||||
<el-page-header @back="goBack">
|
||||
<template #content>
|
||||
<span class="page-title">{{ patientName }} ({{ bedName }})</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-radio-group v-model="statusFilter" size="small" @change="fetchOrders">
|
||||
<el-radio-button :value="2">执行中</el-radio-button>
|
||||
<el-radio-button :value="10">已校对</el-radio-button>
|
||||
<el-radio-button :value="null">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" size="small" @click="handleScan">
|
||||
扫码执行
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="order-list">
|
||||
<div
|
||||
v-for="order in orderList"
|
||||
:key="order.requestId"
|
||||
class="order-item"
|
||||
>
|
||||
<div class="order-header">
|
||||
<span class="order-name">{{ order.adviceName }}</span>
|
||||
<el-tag :type="getStatusType(order.requestStatus)" size="small">
|
||||
{{ order.requestStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="order-body">
|
||||
<div class="info-row">
|
||||
<span class="label">类型:</span>
|
||||
<el-tag size="small">{{ order.therapyEnumText }}</el-tag>
|
||||
</div>
|
||||
<div v-if="order.frequencyUsage" class="info-row">
|
||||
<span class="label">频次:</span>
|
||||
<span class="value">{{ order.frequencyUsage }}</span>
|
||||
</div>
|
||||
<div v-if="order.singleDose" class="info-row">
|
||||
<span class="label">剂量:</span>
|
||||
<span class="value">{{ order.singleDose }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">开嘱医生:</span>
|
||||
<span class="value">{{ order.requesterName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-footer">
|
||||
<el-button
|
||||
v-if="order.requestStatus === 2 || order.requestStatus === 10"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleExecute(order)"
|
||||
>
|
||||
执行
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && orderList.length === 0" description="暂无医嘱" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="scanDialogVisible" title="扫码执行" width="400px">
|
||||
<el-form :model="scanForm" label-width="80px">
|
||||
<el-form-item label="条码">
|
||||
<el-input v-model="scanForm.barcode" placeholder="请扫描或输入条码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="scanDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmScan">确认执行</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getMobileOrderList, executeOrder } from './api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref(2)
|
||||
const orderList = ref([])
|
||||
const scanDialogVisible = ref(false)
|
||||
const scanForm = ref({ barcode: '' })
|
||||
|
||||
const patientName = ref(route.query.patientName || '')
|
||||
const bedName = ref(route.query.bedName || '')
|
||||
const patientId = ref(route.query.patientId)
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!patientId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMobileOrderList(patientId.value, { statusFilter: statusFilter.value })
|
||||
orderList.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/nursingmobile/patient-list')
|
||||
}
|
||||
|
||||
const handleScan = () => {
|
||||
scanForm.value.barcode = ''
|
||||
scanDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmScan = () => {
|
||||
if (!scanForm.value.barcode) {
|
||||
ElMessage.warning('请输入条码')
|
||||
return
|
||||
}
|
||||
const matchedOrder = orderList.value.find(o => o.barcode === scanForm.value.barcode)
|
||||
if (matchedOrder) {
|
||||
handleExecute(matchedOrder)
|
||||
} else {
|
||||
ElMessage.error('未找到匹配的医嘱')
|
||||
}
|
||||
scanDialogVisible.value = false
|
||||
}
|
||||
|
||||
const handleExecute = async (order) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认执行医嘱: ${order.adviceName}?`,
|
||||
'确认执行',
|
||||
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
loading.value = true
|
||||
await executeOrder({
|
||||
requestId: order.requestId,
|
||||
adviceTable: order.adviceTable,
|
||||
encounterId: order.encounterId,
|
||||
patientId: order.patientId
|
||||
})
|
||||
ElMessage.success('执行成功')
|
||||
fetchOrders()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error('执行失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const map = { 2: 'primary', 3: 'success', 6: 'info', 10: 'warning', 11: '' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-order-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.order-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.order-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
201
healthlink-his-ui/src/views/nursingmobile/PatientList.vue
Normal file
201
healthlink-his-ui/src/views/nursingmobile/PatientList.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="mobile-patient-list">
|
||||
<div class="page-header">
|
||||
<h2>患者列表</h2>
|
||||
<el-input
|
||||
v-model="searchKey"
|
||||
placeholder="搜索姓名/床号"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="patient-cards">
|
||||
<div
|
||||
v-for="patient in patientList"
|
||||
:key="patient.encounterId"
|
||||
class="patient-card"
|
||||
@click="handlePatientClick(patient)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="patient-name">{{ patient.patientName }}</span>
|
||||
<el-tag :type="getGenderType(patient.genderEnum)" size="small">
|
||||
{{ patient.genderEnumText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="label">床号:</span>
|
||||
<span class="value">{{ patient.bedName || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">护理等级:</span>
|
||||
<el-tag :type="getNursingLevelType(patient.nursingLevel)" size="small">
|
||||
{{ patient.nursingLevelText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">病情:</span>
|
||||
<el-tag :type="getPriorityType(patient.priorityEnum)" size="small">
|
||||
{{ patient.priorityEnumText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="patient.diagnosis" class="info-row">
|
||||
<span class="label">诊断:</span>
|
||||
<span class="value diagnosis">{{ patient.diagnosis }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="doctor">{{ patient.admittingDoctorName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && patientList.length === 0" description="暂无患者" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getMobilePatientList } from './api'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchKey = ref('')
|
||||
const patientList = ref([])
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMobilePatientList({ searchKey: searchKey.value })
|
||||
patientList.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handlePatientClick = (patient) => {
|
||||
router.push({
|
||||
path: '/nursingmobile/order-list',
|
||||
query: {
|
||||
encounterId: patient.encounterId,
|
||||
patientId: patient.patientId,
|
||||
patientName: patient.patientName,
|
||||
bedName: patient.bedName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getGenderType = (gender) => {
|
||||
return gender === 1 ? 'primary' : gender === 2 ? 'danger' : 'info'
|
||||
}
|
||||
|
||||
const getNursingLevelType = (level) => {
|
||||
const map = { 1: 'danger', 2: 'warning', 3: '', 4: 'danger' }
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
const getPriorityType = (priority) => {
|
||||
const map = { 1: 'danger', 2: 'warning', 3: '', 4: 'info' }
|
||||
return map[priority] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-patient-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.patient-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.patient-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-row .diagnosis {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.doctor {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
195
healthlink-his-ui/src/views/nursingmobile/VitalSign.vue
Normal file
195
healthlink-his-ui/src/views/nursingmobile/VitalSign.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="mobile-vital-sign">
|
||||
<div class="page-header">
|
||||
<el-page-header @back="goBack">
|
||||
<template #content>
|
||||
<span class="page-title">生命体征录入</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="80px"
|
||||
class="vital-form"
|
||||
>
|
||||
<el-form-item label="患者">
|
||||
<span class="patient-info">{{ patientName }} ({{ bedName }})</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="记录时间">
|
||||
<el-date-picker
|
||||
v-model="form.recordDate"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时点">
|
||||
<el-select v-model="form.recordHour" placeholder="选择时点" style="width: 100%">
|
||||
<el-option v-for="h in 24" :key="h-1" :label="(h-1)+':00'" :value="h-1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="体温" prop="temperature">
|
||||
<el-input-number v-model="form.temperature" :min="35" :max="42" :step="0.1" :precision="1" style="width: 100%" />
|
||||
<span class="unit">°C</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="脉搏" prop="pulse">
|
||||
<el-input-number v-model="form.pulse" :min="40" :max="200" style="width: 100%" />
|
||||
<span class="unit">次/分</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="呼吸" prop="respiration">
|
||||
<el-input-number v-model="form.respiration" :min="10" :max="60" style="width: 100%" />
|
||||
<span class="unit">次/分</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="收缩压" prop="systolicBp">
|
||||
<el-input-number v-model="form.systolicBp" :min="60" :max="300" style="width: 100%" />
|
||||
<span class="unit">mmHg</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="舒张压" prop="diastolicBp">
|
||||
<el-input-number v-model="form.diastolicBp" :min="30" :max="200" style="width: 100%" />
|
||||
<span class="unit">mmHg</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="疼痛评分">
|
||||
<el-rate v-model="form.painScore" :max="10" show-score />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="意识">
|
||||
<el-select v-model="form.consciousLevel" placeholder="选择意识状态" style="width: 100%">
|
||||
<el-option label="清醒" value="清醒" />
|
||||
<el-option label="嗜睡" value="嗜睡" />
|
||||
<el-option label="模糊" value="模糊" />
|
||||
<el-option label="昏睡" value="昏睡" />
|
||||
<el-option label="浅昏迷" value="浅昏迷" />
|
||||
<el-option label="深昏迷" value="深昏迷" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="入量">
|
||||
<el-input-number v-model="form.inputMl" :min="0" style="width: 100%" />
|
||||
<span class="unit">ml</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="出量">
|
||||
<el-input-number v-model="form.outputMl" :min="0" style="width: 100%" />
|
||||
<span class="unit">ml</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" style="width: 100%" @click="handleSubmit">
|
||||
保存
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { saveVitalSign } from './api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const patientName = ref(route.query.patientName || '')
|
||||
const bedName = ref(route.query.bedName || '')
|
||||
const patientId = ref(route.query.patientId)
|
||||
const encounterId = ref(route.query.encounterId)
|
||||
|
||||
const now = new Date()
|
||||
const form = reactive({
|
||||
patientId: patientId.value ? Number(patientId.value) : null,
|
||||
encounterId: encounterId.value ? Number(encounterId.value) : null,
|
||||
patientName: patientName.value,
|
||||
recordDate: now.toISOString().split('T')[0],
|
||||
recordHour: now.getHours(),
|
||||
temperature: null,
|
||||
pulse: null,
|
||||
respiration: null,
|
||||
systolicBp: null,
|
||||
diastolicBp: null,
|
||||
painScore: 0,
|
||||
consciousLevel: '清醒',
|
||||
inputMl: null,
|
||||
outputMl: null,
|
||||
nurseName: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
temperature: [{ required: true, message: '请输入体温', trigger: 'blur' }],
|
||||
pulse: [{ required: true, message: '请输入脉搏', trigger: 'blur' }],
|
||||
systolicBp: [{ required: true, message: '请输入收缩压', trigger: 'blur' }],
|
||||
diastolicBp: [{ required: true, message: '请输入舒张压', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/nursingmobile/patient-list')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
await saveVitalSign(form)
|
||||
ElMessage.success('保存成功')
|
||||
router.push('/nursingmobile/vital-sign-trend?patientId=' + patientId.value + '&patientName=' + patientName.value)
|
||||
} catch (e) {
|
||||
if (e !== false) {
|
||||
ElMessage.error('保存失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-vital-sign {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vital-form {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
251
healthlink-his-ui/src/views/nursingmobile/VitalSignTrend.vue
Normal file
251
healthlink-his-ui/src/views/nursingmobile/VitalSignTrend.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="mobile-vital-trend">
|
||||
<div class="page-header">
|
||||
<el-page-header @back="goBack">
|
||||
<template #content>
|
||||
<span class="page-title">体征趋势</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
</div>
|
||||
|
||||
<div class="patient-info">
|
||||
<span>{{ patientName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="days-filter">
|
||||
<el-radio-group v-model="days" size="small" @change="fetchTrend">
|
||||
<el-radio-button :value="3">3天</el-radio-button>
|
||||
<el-radio-button :value="7">7天</el-radio-button>
|
||||
<el-radio-button :value="14">14天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="trend-charts">
|
||||
<div class="chart-section">
|
||||
<h4>体温 (°C)</h4>
|
||||
<div class="chart-container">
|
||||
<div v-if="trendData.temperatureData.length === 0" class="no-data">暂无数据</div>
|
||||
<div v-else class="simple-chart">
|
||||
<div v-for="(point, idx) in trendData.temperatureData" :key="idx" class="chart-point">
|
||||
<div class="point-value">{{ point.value }}</div>
|
||||
<div class="point-bar" :style="{ height: getBarHeight(point.value, 35, 42) + 'px' }" />
|
||||
<div class="point-label">{{ formatLabel(point.label) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<h4>脉搏 (次/分)</h4>
|
||||
<div class="chart-container">
|
||||
<div v-if="trendData.pulseData.length === 0" class="no-data">暂无数据</div>
|
||||
<div v-else class="simple-chart">
|
||||
<div v-for="(point, idx) in trendData.pulseData" :key="idx" class="chart-point">
|
||||
<div class="point-value">{{ point.value }}</div>
|
||||
<div class="point-bar pulse" :style="{ height: getBarHeight(point.value, 40, 120) + 'px' }" />
|
||||
<div class="point-label">{{ formatLabel(point.label) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<h4>血压 (mmHg)</h4>
|
||||
<div class="chart-container">
|
||||
<div v-if="trendData.systolicBpData.length === 0" class="no-data">暂无数据</div>
|
||||
<div v-else class="simple-chart">
|
||||
<div v-for="(point, idx) in trendData.systolicBpData" :key="idx" class="chart-point">
|
||||
<div class="point-value">{{ point.value }}/{{ getDiastolicValue(idx) }}</div>
|
||||
<div class="point-bar bp" :style="{ height: getBarHeight(point.value, 60, 200) + 'px' }" />
|
||||
<div class="point-label">{{ formatLabel(point.label) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<h4>呼吸 (次/分)</h4>
|
||||
<div class="chart-container">
|
||||
<div v-if="trendData.respirationData.length === 0" class="no-data">暂无数据</div>
|
||||
<div v-else class="simple-chart">
|
||||
<div v-for="(point, idx) in trendData.respirationData" :key="idx" class="chart-point">
|
||||
<div class="point-value">{{ point.value }}</div>
|
||||
<div class="point-bar resp" :style="{ height: getBarHeight(point.value, 10, 40) + 'px' }" />
|
||||
<div class="point-label">{{ formatLabel(point.label) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getVitalSignTrend } from './api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const days = ref(7)
|
||||
const patientName = ref(route.query.patientName || '')
|
||||
const patientId = ref(route.query.patientId)
|
||||
|
||||
const trendData = ref({
|
||||
temperatureData: [],
|
||||
pulseData: [],
|
||||
systolicBpData: [],
|
||||
diastolicBpData: [],
|
||||
respirationData: []
|
||||
})
|
||||
|
||||
const fetchTrend = async () => {
|
||||
if (!patientId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVitalSignTrend(patientId.value, { days: days.value })
|
||||
trendData.value = res.data || {
|
||||
temperatureData: [],
|
||||
pulseData: [],
|
||||
systolicBpData: [],
|
||||
diastolicBpData: [],
|
||||
respirationData: []
|
||||
}
|
||||
if (res.data?.patientName) {
|
||||
patientName.value = res.data.patientName
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/nursingmobile/patient-list')
|
||||
}
|
||||
|
||||
const getBarHeight = (value, min, max) => {
|
||||
if (!value) return 0
|
||||
const normalized = (value - min) / (max - min)
|
||||
return Math.max(10, Math.min(80, normalized * 80))
|
||||
}
|
||||
|
||||
const getDiastolicValue = (idx) => {
|
||||
const point = trendData.value.diastolicBpData[idx]
|
||||
return point ? point.value : '-'
|
||||
}
|
||||
|
||||
const formatLabel = (label) => {
|
||||
if (!label) return ''
|
||||
const parts = label.split(' ')
|
||||
return parts.length > 1 ? parts[1] : label
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTrend()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-vital-trend {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.days-filter {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.trend-charts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chart-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.simple-chart {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-point {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.point-value {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.point-bar {
|
||||
width: 20px;
|
||||
background: #409eff;
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 10px;
|
||||
}
|
||||
|
||||
.point-bar.pulse {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.point-bar.bp {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
.point-bar.resp {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
.point-label {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
writing-mode: vertical-rl;
|
||||
max-height: 60px;
|
||||
}
|
||||
</style>
|
||||
21
healthlink-his-ui/src/views/nursingmobile/api.js
Normal file
21
healthlink-his-ui/src/views/nursingmobile/api.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getMobilePatientList(params) {
|
||||
return request({ url: '/nursing/mobile/patient-list', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getMobileOrderList(patientId, params) {
|
||||
return request({ url: '/nursing/mobile/order-list/' + patientId, method: 'get', params })
|
||||
}
|
||||
|
||||
export function executeOrder(data) {
|
||||
return request({ url: '/nursing/mobile/order-execute', method: 'post', data })
|
||||
}
|
||||
|
||||
export function saveVitalSign(data) {
|
||||
return request({ url: '/nursing/mobile/vital-sign', method: 'post', data })
|
||||
}
|
||||
|
||||
export function getVitalSignTrend(patientId, params) {
|
||||
return request({ url: '/nursing/mobile/vital-sign-trend/' + patientId, method: 'get', params })
|
||||
}
|
||||
Reference in New Issue
Block a user