feat: add device treatment rules management and custom parameter configuration modules

This commit is contained in:
zhangsz committed 2026-10-19 15:52:21 +08:00
1 parent 0dbaaac397
commit aed405743a
21 files changed
+2042 -27

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# ==============================================================================
# UniAI Health (原愈极刻) WSL-Ubuntu 本地验证环境一键初始化脚本
# 适用环境:WSL2 Ubuntu 22.04 / 24.04 LTS
# ==============================================================================
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
echo -e "${BLUE}==================================================================${NC}"
echo -e "${BLUE} UniAI Health (原愈极刻) - WSL 验证环境搭建工具 ${NC}"
echo -e "${BLUE}==================================================================${NC}"
echo -e "项目路径: ${PROJECT_ROOT}"
# 1. 检查基础依赖 (Java 17 / Maven / Python3)
echo -e "\n${YELLOW}[1/5] 检查 Java、Maven 与 Python 环境...${NC}"
if command -v java >/dev/null 2>&1; then
JAVA_VER=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}')
echo -e "${GREEN}✓ 检测到 Java: ${JAVA_VER}${NC}"
else
echo -e "${RED}✗ 未检测到 Java,正在通过 apt 安装 OpenJDK 17...${NC}"
sudo apt-get update && sudo apt-get install -y openjdk-17-jdk
fi
if command -v mvn >/dev/null 2>&1; then
echo -e "${GREEN}✓ 检测到 Maven: $(mvn -v | head -n 1)${NC}"
else
if [ -f "/mnt/d/apache/apache-maven-3.9.9/bin/mvn" ]; then
echo -e "${GREEN}✓ 关联本地 Maven 路径至 /usr/local/bin/mvn${NC}"
sudo ln -sf /mnt/d/apache/apache-maven-3.9.9/bin/mvn /usr/local/bin/mvn
else
echo -e "${RED}正在通过 apt 安装 maven...${NC}"
sudo apt-get install -y maven
fi
fi
if command -v python3 >/dev/null 2>&1; then
echo -e "${GREEN}✓ 检测到 Python3: $(python3 --version)${NC}"
else
sudo apt-get install -y python3 python3-pip
fi
# 2. 安装与配置本地 MySQL 8.0 & Redis
echo -e "\n${YELLOW}[2/5] 检查与安装本地 MySQL & Redis 服务...${NC}"
# 安装与启动 MySQL
if ! command -v mysql >/dev/null 2>&1; then
echo -e "正在通过 apt 安装 mysql-server..."
sudo apt-get update
sudo apt-get install -y mysql-server
fi
sudo service mysql status >/dev/null 2>&1 || sudo service mysql start
echo -e "${GREEN}✓ MySQL 服务已就绪 (端口 3306)${NC}"
# 安装与启动 Redis
if ! command -v redis-server >/dev/null 2>&1; then
echo -e "正在安装 redis-server..."
sudo apt-get install -y redis-server
fi
sudo service redis-server status >/dev/null 2>&1 || sudo service redis-server start
echo -e "${GREEN}✓ Redis 服务已就绪 (端口 6379)${NC}"
# 3. 初始化 MySQL 数据库、用户及数据表
echo -e "\n${YELLOW}[3/5] 初始化测试数据库 uni_ai_doctor 及权限...${NC}"
sudo mysql <<EOF
CREATE DATABASE IF NOT EXISTS \`uni_ai_doctor\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'unizk'@'%' IDENTIFIED BY 'Unizk@2025';
CREATE USER IF NOT EXISTS 'unizk'@'localhost' IDENTIFIED BY 'Unizk@2025';
ALTER USER 'unizk'@'%' IDENTIFIED BY 'Unizk@2025';
ALTER USER 'unizk'@'localhost' IDENTIFIED BY 'Unizk@2025';
GRANT ALL PRIVILEGES ON \`uni_ai_doctor\`.* TO 'unizk'@'%';
GRANT ALL PRIVILEGES ON \`uni_ai_doctor\`.* TO 'unizk'@'localhost';
FLUSH PRIVILEGES;
EOF
echo -e "${GREEN}✓ 数据库及专用用户 unizk 初始化完成${NC}"
# 4. 导入验证表结构与初始种子数据
echo -e "\n${YELLOW}[4/5] 导入定制治疗参数数据表与基准测试数据...${NC}"
SEED_SQL="${PROJECT_ROOT}/docs/sql/init_verification_seed_data.sql"
if [ -f "${SEED_SQL}" ]; then
mysql -uunizk -p'Unizk@2025' uni_ai_doctor < "${SEED_SQL}"
echo -e "${GREEN}✓ 验证表结构与测试种子数据导入成功!${NC}"
else
echo -e "${YELLOW}执行定制参数 DDL...${NC}"
mysql -uunizk -p'Unizk@2025' uni_ai_doctor < "${PROJECT_ROOT}/docs/sql/20261019_device_custom_treatment_parameters.sql"
fi
# 5. 完成提示
echo -e "\n${BLUE}==================================================================${NC}"
echo -e "${GREEN}恭喜!WSL-Ubuntu 本地验证环境已就绪!${NC}"
echo -e "${BLUE}==================================================================${NC}"
echo -e "您可以通过以下方式启动并验证服务:"
echo -e ""
echo -e " 1. 编译并启动后端 (在 WSL 中执行):"
echo -e " ${YELLOW}cd ${PROJECT_ROOT}/server && mvn spring-boot:run -pl admin -Dspring-boot.run.profiles=local${NC}"
echo -e ""
echo -e " 2. 运行一键端到端功能验证脚本 (验证定制参数及 UDP 调度):"
echo -e " ${YELLOW}python3 ${PROJECT_ROOT}/tools/test_verify_custom_params.py${NC}"
echo -e ""
echo -e " 3. 启动管理后台前端 (在 Windows 或 WSL 中均可):"
echo -e " ${YELLOW}cd ${PROJECT_ROOT}/web/admin && npm run dev${NC}"
echo -e "${BLUE}==================================================================${NC}"
+223
View File
@@ -0,0 +1,223 @@
-- ==============================================================================
-- UniAI Health 验证环境专用初始表结构与测试种子数据
-- ==============================================================================
USE `uni_ai_doctor`;
-- 1. 设备表 (device)
CREATE TABLE IF NOT EXISTS `device` (
`device_id` VARCHAR(64) NOT NULL COMMENT '设备ID',
`muid` VARCHAR(64) DEFAULT NULL COMMENT 'MUID硬件识别码',
`store_id` INT DEFAULT NULL COMMENT '所属门店ID',
`agent_id` INT DEFAULT NULL,
`user_id` INT DEFAULT NULL,
`status` VARCHAR(10) DEFAULT '1' COMMENT '设备状态 1-启用 0-禁用',
`affiliated_with` VARCHAR(255) DEFAULT '测试机构',
`payment_method` INT DEFAULT 1,
`expire_time` VARCHAR(64) DEFAULT '999',
`warehousing_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`outbound_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`balance` DECIMAL(10,2) DEFAULT '0.00',
`surplus` INT DEFAULT 999 COMMENT '剩余使用次数',
`force_pay` INT DEFAULT 0,
`lo` VARCHAR(64) DEFAULT '114.05',
`la` VARCHAR(64) DEFAULT '22.54',
`free_count` INT DEFAULT 0,
`upgrade_switch` INT DEFAULT 0,
`operating_status` INT DEFAULT 3 COMMENT '运行状态:0-运行 1-关机 2-异常 3-待机',
`last_run_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`cf` VARCHAR(10) DEFAULT '1' COMMENT '机器配置:1-医疗版本 2-非医疗版本',
`wm` VARCHAR(10) DEFAULT '1' COMMENT '工作模式:1-联网 0-脱机',
`hw` VARCHAR(32) DEFAULT 'V1.0',
`sw` VARCHAR(32) DEFAULT 'V1.0',
`iccid` VARCHAR(64) DEFAULT '89860000000000000000',
`cr` VARCHAR(32) DEFAULT '4G_V1.0',
`mo` VARCHAR(10) DEFAULT '3',
`recharge_count` INT DEFAULT 0,
`remark` VARCHAR(255) DEFAULT '测试设备',
`treat_status` INT DEFAULT 0,
`device_tags` VARCHAR(255) DEFAULT '' COMMENT '设备标签(逗号隔开)',
`model_category` VARCHAR(64) DEFAULT 'DEFAULT' COMMENT '机型品类',
PRIMARY KEY (`device_id`),
KEY `idx_muid` (`muid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 全局基准治疗参数表 (treat_parameter)
CREATE TABLE IF NOT EXISTS `treat_parameter` (
`id` INT NOT NULL AUTO_INCREMENT,
`symptoms_name` VARCHAR(128) NOT NULL COMMENT '病症名称',
`acupoint` VARCHAR(64) DEFAULT '阿是穴' COMMENT '穴位',
`ul` VARCHAR(10) DEFAULT '01' COMMENT '超声幅度',
`pl` VARCHAR(10) DEFAULT '08' COMMENT '脉幅',
`pw` VARCHAR(10) DEFAULT '50' COMMENT '脉宽',
`fr` VARCHAR(10) DEFAULT '03' COMMENT '频率',
`tl` VARCHAR(10) DEFAULT '017' COMMENT '时长',
`part` VARCHAR(64) DEFAULT '膝关节' COMMENT '部位',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. 定制参数规则表 (device_treat_rule)
CREATE TABLE IF NOT EXISTS `device_treat_rule` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`rule_name` VARCHAR(64) NOT NULL,
`target_type` TINYINT NOT NULL COMMENT '1-单机, 2-标签, 3-机型',
`target_value` VARCHAR(128) NOT NULL,
`symptom_id` INT NOT NULL,
`fr` VARCHAR(10) DEFAULT NULL,
`pw` VARCHAR(10) DEFAULT NULL,
`pl` VARCHAR(10) DEFAULT NULL,
`ul` VARCHAR(10) DEFAULT NULL,
`tl` VARCHAR(10) DEFAULT NULL,
`priority` INT NOT NULL DEFAULT 10,
`status` TINYINT NOT NULL DEFAULT 1,
`remark` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_symptom_target` (`symptom_id`, `target_type`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. 移动端用户表 (mobile_app_user)
DROP TABLE IF EXISTS `mobile_app_user`;
CREATE TABLE `mobile_app_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`openid` VARCHAR(64) DEFAULT 'test_openid',
`phone_number` VARCHAR(32) DEFAULT '13800000000',
`nickname` VARCHAR(64) DEFAULT '测试患者',
`avatar` VARCHAR(255) DEFAULT '',
`pain_number` VARCHAR(64) DEFAULT '1' COMMENT '问诊匹配的病症ID',
`age` INT DEFAULT 30,
`height` DOUBLE DEFAULT 170.0,
`weight` DOUBLE DEFAULT 65.0,
`symptom` VARCHAR(255) DEFAULT '膝关节半月板损伤',
`points` INT DEFAULT 0,
`has_member` INT DEFAULT 0,
`therapy` VARCHAR(255) DEFAULT '',
`fours_rating` INT DEFAULT 0,
`evaluation_result` VARCHAR(255) DEFAULT '',
`user_type` VARCHAR(10) DEFAULT 'C',
`gender` INT DEFAULT 1,
`free_trial` INT DEFAULT 0,
`first_store_id` INT DEFAULT NULL,
`first_physio_id` INT DEFAULT NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 5. 设备运行时参数记录表 (user_treatment_parameters)
CREATE TABLE IF NOT EXISTS `user_treatment_parameters` (
`id` INT NOT NULL AUTO_INCREMENT,
`app_user_id` INT DEFAULT NULL,
`device_id` VARCHAR(64) DEFAULT NULL,
`ul` VARCHAR(10) DEFAULT NULL,
`pl` VARCHAR(10) DEFAULT NULL,
`pw` VARCHAR(10) DEFAULT NULL,
`fr` VARCHAR(10) DEFAULT NULL,
`tl` VARCHAR(10) DEFAULT NULL,
`status` INT DEFAULT 1,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 6. 设备使用记录表 (device_usage_record)
CREATE TABLE IF NOT EXISTS `device_usage_record` (
`id` INT NOT NULL AUTO_INCREMENT,
`device_id` VARCHAR(64) DEFAULT NULL,
`start_time` DATETIME DEFAULT NULL,
`end_time` DATETIME DEFAULT NULL,
`start_use_flag` VARCHAR(10) DEFAULT 'false',
`wm` VARCHAR(10) DEFAULT '1',
`belong` VARCHAR(64) DEFAULT NULL,
`muid` VARCHAR(64) DEFAULT NULL,
`invalid_flag` INT DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 7. 用户设备理疗记录表 (user_device_treat_record)
CREATE TABLE IF NOT EXISTS `user_device_treat_record` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT DEFAULT NULL,
`device_id` VARCHAR(64) DEFAULT NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 8. 系统用户表 (sys_user)
CREATE TABLE IF NOT EXISTS `sys_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`role` VARCHAR(32) DEFAULT 'ROLE_ADMIN',
`store_id` INT DEFAULT NULL,
`username` VARCHAR(64) NOT NULL,
`password` VARCHAR(128) NOT NULL,
`dept` VARCHAR(64) DEFAULT '管理部',
`status` VARCHAR(16) DEFAULT 'enable',
`avatar` VARCHAR(255) DEFAULT '',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 9. 门店表 (jk_store)
CREATE TABLE IF NOT EXISTS `jk_store` (
`id` INT NOT NULL AUTO_INCREMENT,
`store_name` VARCHAR(128) DEFAULT '测试旗舰店',
`store_image` VARCHAR(255) DEFAULT '',
`address` VARCHAR(255) DEFAULT '北京市海淀区',
`status` VARCHAR(16) DEFAULT 'enable',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ==============================================================================
-- 插入测试种子数据
-- ==============================================================================
-- 清空旧测试数据
DELETE FROM `treat_parameter` WHERE `id` = 1;
DELETE FROM `mobile_app_user` WHERE `id` = 9;
DELETE FROM `device` WHERE `device_id` LIKE 'DEV_%';
DELETE FROM `device_treat_rule` WHERE `id` IN (101, 102, 103);
DELETE FROM `sys_user` WHERE `username` = 'admin';
-- (1) 基准病症参数: ID=1 膝关节半月板损伤 (默认: UL=01, PL=08, PW=50, FR=03, TL=017)
INSERT INTO `treat_parameter` (`id`, `symptoms_name`, `acupoint`, `ul`, `pl`, `pw`, `fr`, `tl`, `part`)
VALUES (1, '膝关节半月板损伤', '膝眼/鹤顶', '01', '08', '50', '03', '017', '膝关节');
-- (2) 默认测试用户: ID=9, 当前问诊病症 pain_number = 1
INSERT INTO `mobile_app_user` (`id`, `openid`, `phone_number`, `nickname`, `pain_number`, `user_type`)
VALUES (9, 'oTestUser009', '13800138000', '张三(测试理疗)', '1', 'C');
-- (3) 4 台不同维度的测试设备
-- 设备A: 打上 golf_vip 标签的设备 (测试标签定制覆盖)
INSERT INTO `device` (`device_id`, `muid`, `cf`, `device_tags`, `status`, `operating_status`, `surplus`)
VALUES ('DEV_GOLF_01', 'DEV_GOLF_01', '1', 'golf_vip,vip_care', '1', 3, 999);
-- 设备B: 单机 DEV_SPEC_02 (测试单机专属定制覆盖)
INSERT INTO `device` (`device_id`, `muid`, `cf`, `device_tags`, `status`, `operating_status`, `surplus`)
VALUES ('DEV_SPEC_02', 'DEV_SPEC_02', '1', 'elderly_care', '1', 3, 999);
-- 设备C: 非医疗版本民用机 cf=2 (测试安全上限截断 UL <= 10)
INSERT INTO `device` (`device_id`, `muid`, `cf`, `device_tags`, `status`, `operating_status`, `surplus`)
VALUES ('DEV_CIVIL_03', 'DEV_CIVIL_03', '2', '', '1', 3, 999);
-- 设备D: 普通未打标签的医疗设备 (测试基准默认参数兜底)
INSERT INTO `device` (`device_id`, `muid`, `cf`, `device_tags`, `status`, `operating_status`, `surplus`)
VALUES ('DEV_DEF_04', 'DEV_DEF_04', '1', '', '1', 3, 999);
-- (4) 3 条定制规则
-- 规则1: 针对标签 [golf_vip],病症1 定制高能高频 (FR=08, PW=55, PL=12, UL=05, TL=025)
INSERT INTO `device_treat_rule` (`id`, `rule_name`, `target_type`, `target_value`, `symptom_id`, `fr`, `pw`, `pl`, `ul`, `tl`, `priority`, `status`)
VALUES (101, '高尔夫专享定制', 2, 'golf_vip', 1, '08', '55', '12', '05', '025', 10, 1);
-- 规则2: 针对单机 [DEV_SPEC_02],病症1 定制温和轻柔 (FR=02, PW=40, PL=04, UL=02, TL=010)
INSERT INTO `device_treat_rule` (`id`, `rule_name`, `target_type`, `target_value`, `symptom_id`, `fr`, `pw`, `pl`, `ul`, `tl`, `priority`, `status`)
VALUES (102, '特定设备单机特护', 1, 'DEV_SPEC_02', 1, '02', '40', '04', '02', '010', 50, 1);
-- 规则3: 针对机型配置 [2] (非医疗民用版),将 UL 故意配置为过高值 18 (验证安全引擎自动截断至 10)
INSERT INTO `device_treat_rule` (`id`, `rule_name`, `target_type`, `target_value`, `symptom_id`, `fr`, `pw`, `pl`, `ul`, `tl`, `priority`, `status`)
VALUES (103, '非医疗版超标规则', 3, '2', 1, '04', '45', '09', '18', '015', 5, 1);
-- (5) 系统管理员用户: admin / admin123 (BCrypt hash)
INSERT INTO `sys_user` (`id`, `username`, `password`, `role`, `status`)
VALUES (1, 'admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'ROLE_ADMIN', 'enable');
@@ -0,0 +1,170 @@
package cn.unizk.controller;
import cn.unizk.entity.po.DeviceTreatRule;
import cn.unizk.entity.po.TreatParameter;
import cn.unizk.entity.vo.ResultData;
import cn.unizk.entity.vo.UniPage;
import cn.unizk.exception.ServiceException;
import cn.unizk.mapper.DeviceTreatRuleMapper;
import cn.unizk.mapper.TreatParameterMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 设备治疗参数定制规则控制器
*
* @author hao / antigravity
* @date 2026/10/19
*/
@Slf4j
@RestController
@RequestMapping("/pc/admin/deviceTreatRule")
public class DeviceTreatRuleController {
@Resource
private DeviceTreatRuleMapper deviceTreatRuleMapper;
@Resource
private TreatParameterMapper treatParameterMapper;
/**
* 分页查询定制规则
*/
@GetMapping("/page")
public Page<DeviceTreatRule> page(UniPage<DeviceTreatRule> uniPage,
@RequestParam(value = "ruleName", required = false) String ruleName,
@RequestParam(value = "targetType", required = false) Integer targetType,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "symptomId", required = false) Integer symptomId) {
Page<DeviceTreatRule> page = new Page<>(uniPage.getCurrentPage(), uniPage.getPageSize());
LambdaQueryWrapper<DeviceTreatRule> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.like(StringUtils.isNotBlank(ruleName), DeviceTreatRule::getRuleName, ruleName)
.eq(targetType != null, DeviceTreatRule::getTargetType, targetType)
.eq(status != null, DeviceTreatRule::getStatus, status)
.eq(symptomId != null, DeviceTreatRule::getSymptomId, symptomId)
.orderByDesc(DeviceTreatRule::getPriority)
.orderByDesc(DeviceTreatRule::getId);
Page<DeviceTreatRule> resultPage = deviceTreatRuleMapper.selectPage(page, queryWrapper);
List<DeviceTreatRule> records = resultPage.getRecords();
if (CollectionUtils.isNotEmpty(records)) {
List<Integer> symptomIds = records.stream()
.map(DeviceTreatRule::getSymptomId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (CollectionUtils.isNotEmpty(symptomIds)) {
List<TreatParameter> treatParameters = treatParameterMapper.selectBatchIds(symptomIds);
Map<Integer, String> symptomMap = treatParameters.stream()
.collect(Collectors.toMap(TreatParameter::getId, TreatParameter::getSymptomsName, (k1, k2) -> k1));
records.forEach(r -> r.setSymptomsName(symptomMap.get(r.getSymptomId())));
}
}
return resultPage;
}
/**
* 新增或更新定制规则
*/
@PostMapping("/saveOrUpdate")
public ResultData saveOrUpdate(@RequestBody DeviceTreatRule rule) {
if (StringUtils.isBlank(rule.getRuleName())) {
throw new ServiceException("规则名称不能为空");
}
if (rule.getTargetType() == null || rule.getTargetType() < 1 || rule.getTargetType() > 3) {
throw new ServiceException("适用类型不正确(1:单机 2:标签 3:机型)");
}
if (StringUtils.isBlank(rule.getTargetValue())) {
throw new ServiceException("匹配目标标识不能为空");
}
if (rule.getSymptomId() == null) {
throw new ServiceException("关联病症不能为空");
}
if (rule.getPriority() == null) {
rule.setPriority(10);
}
if (rule.getStatus() == null) {
rule.setStatus(1);
}
// 参数长度标准化(两字符数字)
rule.setFr(formatParam(rule.getFr(), true));
rule.setPl(formatParam(rule.getPl(), true));
rule.setPw(formatParam(rule.getPw(), true));
rule.setUl(formatParam(rule.getUl(), true));
rule.setTl(formatParam(rule.getTl(), false));
if (rule.getId() == null) {
rule.setCreateTime(LocalDateTime.now());
rule.setUpdateTime(LocalDateTime.now());
deviceTreatRuleMapper.insert(rule);
} else {
rule.setUpdateTime(LocalDateTime.now());
deviceTreatRuleMapper.updateById(rule);
}
return ResultData.success();
}
/**
* 删除定制规则
*/
@PostMapping("/delete")
public ResultData delete(@RequestParam("id") Integer id) {
if (id == null) {
throw new ServiceException("ID不能为空");
}
return ResultData.success(deviceTreatRuleMapper.deleteById(id));
}
/**
* 启用/禁用规则
*/
@PostMapping("/changeStatus")
public ResultData changeStatus(@RequestParam("id") Integer id, @RequestParam("status") Integer status) {
if (id == null || status == null) {
throw new ServiceException("参数不能为空");
}
DeviceTreatRule rule = new DeviceTreatRule();
rule.setId(id);
rule.setStatus(status);
rule.setUpdateTime(LocalDateTime.now());
return ResultData.success(deviceTreatRuleMapper.updateById(rule));
}
/**
* 获取详情
*/
@GetMapping("/getOne")
public ResultData<DeviceTreatRule> getOne(@RequestParam("id") Integer id) {
DeviceTreatRule rule = deviceTreatRuleMapper.selectById(id);
if (rule != null && rule.getSymptomId() != null) {
TreatParameter treatParameter = treatParameterMapper.selectById(rule.getSymptomId());
if (treatParameter != null) {
rule.setSymptomsName(treatParameter.getSymptomsName());
}
}
return ResultData.success(rule);
}
private String formatParam(String p, boolean padZero) {
if (StringUtils.isBlank(p)) {
return null;
}
p = p.trim();
if (padZero && p.length() == 1) {
return "0" + p;
}
return p;
}
}
@@ -425,6 +425,39 @@ public class SysDeviceController {
return true; return true;
} }
@PostMapping("/setTags")
public boolean setTags(@RequestBody DeviceTagsVO vo) {
List<String> deviceIds = vo.getDeviceIds();
if (CollectionUtils.isEmpty(deviceIds)) {
return false;
}
List<Device> existingDevices = deviceService.listByIds(deviceIds);
List<Device> updateList = new ArrayList<>();
for (Device dev : existingDevices) {
Device updateDevice = new Device();
updateDevice.setDeviceId(dev.getDeviceId());
if (vo.getMode() != null && vo.getMode() == 2) {
String currentTags = dev.getDeviceTags();
if (StringUtils.isBlank(currentTags)) {
updateDevice.setDeviceTags(vo.getTags() == null ? "" : vo.getTags());
} else {
Set<String> tagSet = new LinkedHashSet<>(Arrays.asList(currentTags.split(",")));
if (StringUtils.isNotBlank(vo.getTags())) {
tagSet.addAll(Arrays.asList(vo.getTags().split(",")));
}
updateDevice.setDeviceTags(String.join(",", tagSet));
}
} else {
updateDevice.setDeviceTags(vo.getTags() == null ? "" : vo.getTags());
}
if (StringUtils.isNotBlank(vo.getModelCategory())) {
updateDevice.setModelCategory(vo.getModelCategory());
}
updateList.add(updateDevice);
}
return deviceService.updateBatchById(updateList);
}
@PostMapping("/upgrade") @PostMapping("/upgrade")
public ResultData upgrade(@RequestParam(value = "id",required = false) Integer id, public ResultData upgrade(@RequestParam(value = "id",required = false) Integer id,
@RequestParam(value = "file",required = false) MultipartFile file, @RequestParam(value = "file",required = false) MultipartFile file,
@@ -1,68 +1,210 @@
package cn.unizk.service; package cn.unizk.service;
import cn.unizk.entity.dto.PrDTO; import cn.unizk.entity.dto.PrDTO;
import cn.unizk.entity.po.Device;
import cn.unizk.entity.po.DeviceTreatRule;
import cn.unizk.entity.po.MobileAppUser; import cn.unizk.entity.po.MobileAppUser;
import cn.unizk.entity.po.TreatParameter; import cn.unizk.entity.po.TreatParameter;
import cn.unizk.mapper.DeviceMapper;
import cn.unizk.mapper.DeviceTreatRuleMapper;
import cn.unizk.mapper.MobileAppUserMapper; import cn.unizk.mapper.MobileAppUserMapper;
import cn.unizk.mapper.TreatParameterMapper; import cn.unizk.mapper.TreatParameterMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.ArrayList; import java.util.*;
import java.util.List;
/** /**
* @Author hao * 治疗处方参数生成引擎(支持单机、标签、机型品类分层覆盖)
* @Date 2025/12/18 17:00 *
* @Version 1.0 * @author hao / antigravity
* @date 2026/10/19
**/ **/
@Slf4j
@Service @Service
public class GeneratePrService { public class GeneratePrService {
@Resource @Resource
private MobileAppUserMapper mobileAppUserMapper; private MobileAppUserMapper mobileAppUserMapper;
@Resource @Resource
private TreatParameterMapper treatParameterMapper; private TreatParameterMapper treatParameterMapper;
public PrDTO generatePr(Integer userId){ @Resource
if (userId == null){ private DeviceMapper deviceMapper;
@Resource
private DeviceTreatRuleMapper deviceTreatRuleMapper;
/**
* 兼容旧接口:无设备上下文时使用全局基准参数
*/
public PrDTO generatePr(Integer userId) {
return generatePr(userId, null);
}
/**
* 新接口:传入具体设备,应用分层定制覆盖算法
*
* @param userId 用户ID
* @param deviceId 设备ID
* @return 处方DTO
*/
public PrDTO generatePr(Integer userId, String deviceId) {
if (userId == null) {
return null; return null;
} }
MobileAppUser user = mobileAppUserMapper.selectById(userId); MobileAppUser user = mobileAppUserMapper.selectById(userId);
if (user == null || StringUtils.isBlank(user.getPainNumber())){ if (user == null || StringUtils.isBlank(user.getPainNumber())) {
return null; return null;
} }
Device device = StringUtils.isNotBlank(deviceId) ? deviceMapper.selectById(deviceId) : null;
PrDTO prDTO = new PrDTO(); PrDTO prDTO = new PrDTO();
String uuid = RandomStringUtils.randomNumeric(16); String uuid = RandomStringUtils.randomNumeric(16);
prDTO.setId(uuid); prDTO.setId(uuid);
List<PrDTO.PrItem> prItems = new ArrayList<>(); List<PrDTO.PrItem> prItems = new ArrayList<>();
String painNumber = user.getPainNumber(); String painNumber = user.getPainNumber();
if (painNumber.contains(",")){ if (painNumber.contains(",")) {
String[] split = painNumber.split(","); String[] split = painNumber.split(",");
for (String s : split) { for (String s : split) {
PrDTO.PrItem prItem = fill(s); PrDTO.PrItem prItem = fill(s.trim(), device);
if (prItem != null) {
prItems.add(prItem);
}
}
} else {
PrDTO.PrItem prItem = fill(painNumber.trim(), device);
if (prItem != null) {
prItems.add(prItem); prItems.add(prItem);
} }
prDTO.setPrItems(prItems);
}else {
PrDTO.PrItem prItem = fill(painNumber);
prItems.add(prItem);
prDTO.setPrItems(prItems);
} }
prDTO.setPrItems(prItems);
return prDTO; return prDTO;
} }
private PrDTO.PrItem fill(String painNumber){ /**
TreatParameter treatParameter = treatParameterMapper.selectById(Integer.parseInt(painNumber)); * 解析单个病症项的参数
*/
private PrDTO.PrItem fill(String painNumber, Device device) {
if (StringUtils.isBlank(painNumber)) {
return null;
}
Integer symptomId = Integer.parseInt(painNumber);
TreatParameter baseline = treatParameterMapper.selectById(symptomId);
if (baseline == null) {
log.warn("未找到病症编号为 {} 的基准治疗参数", painNumber);
return null;
}
PrDTO.PrItem prItem = new PrDTO.PrItem(); PrDTO.PrItem prItem = new PrDTO.PrItem();
prItem.setFr(treatParameter.getFr()); // 1. 基准参数兜底
prItem.setPl(treatParameter.getPl()); prItem.setFr(baseline.getFr());
prItem.setPw(treatParameter.getPw()); prItem.setPl(baseline.getPl());
prItem.setUl(treatParameter.getUl()); prItem.setPw(baseline.getPw());
prItem.setTl(treatParameter.getTl()); prItem.setUl(baseline.getUl());
prItem.setTl(baseline.getTl());
if (device == null) {
return prItem;
}
// 2. 查询该病症适用的定制规则(状态为启用)
List<DeviceTreatRule> rules = deviceTreatRuleMapper.selectList(
new LambdaQueryWrapper<DeviceTreatRule>()
.eq(DeviceTreatRule::getSymptomId, symptomId)
.eq(DeviceTreatRule::getStatus, 1)
);
if (CollectionUtils.isNotEmpty(rules)) {
DeviceTreatRule bestRule = findBestMatchedRule(rules, device);
if (bestRule != null) {
log.info("设备[{}]命中定制治疗参数规则:[{}], 规则ID:{}",
device.getDeviceId(), bestRule.getRuleName(), bestRule.getId());
// 非空字段覆盖基准
if (StringUtils.isNotBlank(bestRule.getFr())) prItem.setFr(bestRule.getFr());
if (StringUtils.isNotBlank(bestRule.getPl())) prItem.setPl(bestRule.getPl());
if (StringUtils.isNotBlank(bestRule.getPw())) prItem.setPw(bestRule.getPw());
if (StringUtils.isNotBlank(bestRule.getUl())) prItem.setUl(bestRule.getUl());
if (StringUtils.isNotBlank(bestRule.getTl())) prItem.setTl(bestRule.getTl());
}
}
// 3. 硬件物理安全底线校验与截断
applySafetyLimits(prItem, device);
return prItem; return prItem;
} }
/**
* 规则匹配器:按 单机(Type 1) > 标签(Type 2) > 机型品类(Type 3) 结合优先级权重挑选最优规则
*/
private DeviceTreatRule findBestMatchedRule(List<DeviceTreatRule> rules, Device device) {
List<String> tags = Collections.emptyList();
if (StringUtils.isNotBlank(device.getDeviceTags())) {
tags = Arrays.asList(device.getDeviceTags().split(","));
}
List<DeviceTreatRule> matchedList = new ArrayList<>();
for (DeviceTreatRule rule : rules) {
if (rule.getTargetType() == null || StringUtils.isBlank(rule.getTargetValue())) {
continue;
}
// 目标类型1:单机精确匹配 deviceId
if (rule.getTargetType() == 1 && rule.getTargetValue().equalsIgnoreCase(device.getDeviceId())) {
matchedList.add(rule);
continue;
}
// 目标类型2:设备标签匹配
if (rule.getTargetType() == 2 && tags.contains(rule.getTargetValue())) {
matchedList.add(rule);
continue;
}
// 目标类型3:机型/配置匹配(匹配 cf 如 "1"医疗/"2"非医疗,或 modelCategory)
if (rule.getTargetType() == 3) {
if (rule.getTargetValue().equalsIgnoreCase(device.getCf())
|| (StringUtils.isNotBlank(device.getModelCategory()) && rule.getTargetValue().equalsIgnoreCase(device.getModelCategory()))) {
matchedList.add(rule);
}
}
}
if (matchedList.isEmpty()) {
return null;
}
// 排序规则:单机(3000分) > 标签(2000分) > 机型(1000分) + priority数值
return matchedList.stream().max(Comparator.comparingInt(r -> {
int typeWeight = 0;
if (r.getTargetType() == 1) typeWeight = 3000;
else if (r.getTargetType() == 2) typeWeight = 2000;
else if (r.getTargetType() == 3) typeWeight = 1000;
int priority = r.getPriority() != null ? r.getPriority() : 0;
return typeWeight + priority;
})).orElse(null);
}
/**
* 硬件物理安全极限保护
*/
private void applySafetyLimits(PrDTO.PrItem item, Device device) {
try {
// 针对非医疗版本(cf = 2),强制限制超声幅度上限为 10,防止高能灼伤
if ("2".equals(device.getCf()) && StringUtils.isNotBlank(item.getUl())) {
int ulVal = Integer.parseInt(item.getUl());
if (ulVal > 10) {
log.warn("设备[{}]为非医疗版本,超声幅度[{}]超出民用合规上限10,已自动安全截断", device.getDeviceId(), ulVal);
item.setUl("10");
}
}
} catch (Exception e) {
log.error("应用安全极限时数值解析异常", e);
}
}
} }
@@ -328,7 +328,7 @@ public class UdpServerService2 {
// String openid = (String) redisTemplate.opsForValue().get(UniConstant.DEVICE_ID + device.getDeviceId()); // String openid = (String) redisTemplate.opsForValue().get(UniConstant.DEVICE_ID + device.getDeviceId());
// String openid = "oATNGvnMbu6tbNgbnFZPWtJBGvFM"; // String openid = "oATNGvnMbu6tbNgbnFZPWtJBGvFM";
Integer appUserId = 9; Integer appUserId = 9;
PrDTO prDTO = generatePrService.generatePr(appUserId); PrDTO prDTO = generatePrService.generatePr(appUserId, device.getDeviceId());
if (Objects.nonNull(prDTO)){ if (Objects.nonNull(prDTO)){
List<PrDTO.PrItem> prItems = prDTO.getPrItems(); List<PrDTO.PrItem> prItems = prDTO.getPrItems();
if (CollectionUtils.isNotEmpty(prItems)){ if (CollectionUtils.isNotEmpty(prItems)){
@@ -57,6 +57,8 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
wrapper.eq(StringUtils.isNotBlank(query.getSw()),Device::getSw,query.getSw()); wrapper.eq(StringUtils.isNotBlank(query.getSw()),Device::getSw,query.getSw());
wrapper.eq(StringUtils.isNotBlank(query.getCr()),Device::getCr,query.getCr()); wrapper.eq(StringUtils.isNotBlank(query.getCr()),Device::getCr,query.getCr());
wrapper.eq(query.getUpgradeSwitch() != null,Device::getUpgradeSwitch,query.getUpgradeSwitch()); wrapper.eq(query.getUpgradeSwitch() != null,Device::getUpgradeSwitch,query.getUpgradeSwitch());
wrapper.like(StringUtils.isNotBlank(query.getDeviceTags()),Device::getDeviceTags,query.getDeviceTags());
wrapper.eq(StringUtils.isNotBlank(query.getModelCategory()),Device::getModelCategory,query.getModelCategory());
wrapper.and(hasKeyword, w -> { wrapper.and(hasKeyword, w -> {
w.eq(hasKeyword, Device::getMuid, query.getKeyword()) w.eq(hasKeyword, Device::getMuid, query.getKeyword())
.or() .or()
@@ -0,0 +1,79 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
druid:
url: jdbc:mysql://127.0.0.1:3306/uni_ai_doctor?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true
username: unizk
password: Unizk@2025
redis:
port: 6379
host: 127.0.0.1
username:
password:
database: 0
timeout: 5000
lettuce:
pool:
max-active: 8
max-idle: 6
min-idle: 2
max-wait: -1ms
shutdown-timeout: 360
token:
header: jkzy-auth
expireTime: 60
security:
enabled: false
minio:
endpoint: http://127.0.0.1:8002
accessKey: mock
secretKey: mock
username: jkzy
password: jkzy@2025
nginxHost:
xf:
appid: 3fa7b7a0
long-sound:
keySecret: b44d730380dcfae02313dcea7101efc8
instant:
host: https://iat-api.xfyun.cn/v2/iat
APISecret: N2Q5Y2NiOTZmNThhODZjYTgxMjg1NjA4
apiKey: da390c58ef0f2ad8424c3dccc6d458c7
aliyun:
accessKeyId: LTAI5tBQGeqmhe6yhfqupaZD
accessKeySecret: YM2lsEtpjCLOF2kJGdsCBcwRHLX8aa
wx:
appid: wx71e559ade446f09a
secret: 00b7f3cff0f4bde07daacd0dea7548c9
redirectUrl: http://369.peaktcm.com/wx/authCallback
hf:
huifuId: 6666000179463008
payApi: https://api.huifu.com/v3/trade/payment/jspay
refundApi: https://api.huifu.com/v3/trade/payment/scanpay/refund
publicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3IcOEDMBOiHaa5dLsPWVVx+9yE/VEGjRSVZFUAospbCEhGDimE7UEL8ecXwbG9AN5k8yX+Ltj6zaeLIt3KgxbX5/4pnrOoPqFhtaoNuQrwl8/9/rkgGkoksH9e//mRan2+TjbHLKXWZKUduksaoWw4VehOx0nxE+wx7Bk7/VQAegnrDz4z/8zEd2Mcm51THceHJ5wVoMoXvo5e8nhUUAFJeHtlI5jxC85lK6kz2UNhGQ7Kutc7sBzK1U/mi29j/iStN3Z0fzND7+nRfgWPu4b0cKNnNwlIaGiabFTbpVZyV8LTg1BandmHJLNUUJyrc/s3W72VFUdp+BwgoERDcEwwIDAQAB
privateKey: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDchw4QMwE6Idprl0uw9ZVXH73IT9UQaNFJVkVQCiylsISEYOKYTtQQvx5xfBsb0A3mTzJf4u2PrNp4si3cqDFtfn/imes6g+oWG1qg25CvCXz/3+uSAaSiSwf17/+ZFqfb5ONscspdZkpR26SxqhbDhV6E7HSfET7DHsGTv9VAB6CesPPjP/zMR3YxybnVMdx4cnnBWgyhe+jl7yeFRQAUl4e2UjmPELzmUrqTPZQ2EZDsq61zuwHMrVT+aLb2P+JK03dnR/M0Pv6dF+BY+7hvRwo2c3CUhoaJpsVNulVnJXwtODUFqd2Ycks1RQnKtz+zdbvZUVR2n4HCCgRENwTDAgMBAAECggEAbFWAaB7WVHt69G3fxi/uzF0Qe04UijojzhYueLJ22nKLtwhtaIsZDJ0G1JM2MgSUP3badVZig7lTngVSGFsb5QFGy8f3Ucip/3AypmEWg5ZyrLSQ4ioieYPIhl3/P3Jsxc58lrMRjK6KhgoTrx+yjRyAoWYTqGztWydJy05q8UekdbIn9K2Dq0294hHbarCjB3C/3SXX4xniEbDtVBTdjP81731gxnM2HJ98GDrvUf5MDrX4SdExvCYV0P0mU8uaRPK8ylWCmtsEuMxKupFUFePJNZdyG+P/RZs6CbgUgJyYUzKlOyeWH8KwQF1FLntipkIPlJ+uVkdwK2eDD6bzWQKBgQDylFiSnlFy0fwXSpWHDnDgukaLYxm2GNWaPyg0f2IqK/368o/Xu4TWOPuknR6ngeiJZz3SVA/kFeCz3+UxtDlbUheoSkkbo0cZg8PaKx875gE97oCHgRkaPS4l44kmu1ZwOMgfZDQPrLZMEbAfjqvMEa7ci5kXk1quOGeSJRIg3QKBgQDoumM2dZ+RxYxtxB2k1kJJIndZX3hhVyMfDIh5TwHHKNml1poa/s+aoT27BZgiM8snLR55xk4cQ59kr9Yic9Oa4seECsrAgP8mbB6eC/y7lRDZDFjJvHXl6a1ePo+hBlYarL83AUMIE1/SfZrdyM5yRmp3+cwHkKVAQuiEXOSHwKBgEDkpn0ylR9eRU4FydKDbnbv3gdTz4EDS8EjvCGvIheeBSeo1AomQ/b673auAZFWE1sOn18w1b9yitYTw0B0shNX9rQyizQ1jcOpmC848fYYJaTntMKLKqf2Te4c3Ifb/devmPms2LCIx4u5/5+xjwvcfybIwX+pD80jkbFq8VqRAoGBAKulTKdN+XK6EOikTHjWzezCzuPL1i105yJvS/isnNMW/e+iZ+ahKN0TL0RQy/Pt31f1nxSRfeq6tUS9q2Gdq+QAfsaUbxWsDoN4GtzWWoC/YL20rYecyBfaUVPBMI3C7p6d6inXdGjwYSwOF7qb8Ce4Z641PpYVyOm5K6tM08BhAoGBALNkfH3ZPImRKN19bGcHAtrzoDMFAAQrhjyMr9aUpMGLoAjxh1U2pYbj1y0e03ArUT/TRzOXNbHuST82tDZEarVSF8RmU8DNUAHSI9dmtXPtgeKpG8TNyP5q3VnJEHdSRZVLb5pGfZuo3tegsHwZF/oCGK+GHD/fhmSFB04GPJon
notifyUrl: https://peaktcm.com/applet/huifuPay/callback
productId: EDUSTD
configureAppletCallbackUrl: https://peaktcm.com/applet/huifuPay/configureAppletCallback
huifu-vendor:
sysId: 6666000188069628
huifuId: 6666000188735389
publicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0uJDRMFj1xoQCJbReVaNdiqjL9vdKolMzWKe2vMhZwkBlgQMpaqtHqNKNCn3DPL/7ugPUNzj4PLr3PSR4DK+TBOOa3xRuKLt3s2x6wouVNpiRRglUzh5OgciXXYPPl7QGu2YlJZ9F7ikiaD7KG9PJI7oidbA7lQ4qMcIGlSBUF4l3TeoOpad0zLs41IRJJPIMKPf5Jj9HS/1Tggcn7f36h1l4JWpTRyfB8HyF66IHzA3mQa5yskUHf1LLuO0hxtIfy9Ua+xJYqEWD334xaMzauZQsLM98d1Pj7I1YRvzNohabpCJqO1wF6PDB3rqdLarAZagN/JhYIHHGXMBGfQZxwIDAQAB
privateKey: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDS4kNEwWPXGhAIltF5Vo12KqMv290qiUzNYp7a8yFnCQGWBAylqq0eo0o0KfcM8v/u6A9Q3OPg8uvc9JHgMr5ME45rfFG4ou3ezbHrCi5U2mJFGCVTOHk6ByJddg8+XtAa7ZiUln0XuKSJoPsob08kjuiJ1sDuVDioxwgaVIFQXiXdN6g6lp3TMuzjUhEkk8gwo9/kmP0dL/VOCByft/fqHWXglalNHJ8HwfIXrogfMDeZBrnKyRQd/Usu47SHG0h/L1Rr7ElioRYPffjFozNq5lCwsz3x3U+PsjVhG/M2iFpukImo7XAXo8MHeup0tqsBlqA38mFggccZcwEZ9BnHAgMBAAECggEABRZaQxVbQDwruNlSklfJL9mVeDkYm8cWKDpB1MxauO6ZsDHG0FROutd7mciNYi2WiP/mJrA9exbBI+JuhncS6ijqnaMxIYAktuRFb4MeXQOrrTkI5XoOSp0Tcie/9GQ7JvsmXCdJO0JEDeHpTS9qPFqKN5ELtHQprcCnbGMvf23KyqOFx71Sbe4JxDqV4j0MeJJ/ZEOnLHJAhJ/o/0Gw+2cAVBxOq/xDTiSzKQaR4Cy7h3HMlDXnBXlAwKLb7x/F6efFyBv3RGZjtTgt8G7319icRJUHyXa3JPk/UbKTHrZ3saCj0ByAPpCwSVx6uNxhVeEOFqi3VwtV8s4ClAJtUQKBgQDwEGA1Shr2klTnM1VN+R3YkgaG8QtkZQQG77vCR1q3uMPKpWUQOi/2wQs3SbTAr7ZX+8CHNb8M3KGVAEFR2PDEFok/LA0snFTwdL6dLkXkmOxmNyR2wmXIUimMBogQIc4bI11EpPoocbrzuJ3q48fDIAr7Hc4d/0HasEAQHvAdfwKBgQDg4gCr5M7atD4XdKqmNs5kltjw36EeSig2cajfQb7aARmHS+1F9cn1cpgTvqMBHxBZBx5cW1ETLTxDEq8/ONHHAtmgCVgrc0lgch+Dph1Nt4Jl/aNxxaZjLpLQtcHnFR4/7RGuJMUTAzAUcOer9XiCJskE2EVLrT53pE665Se3uQKBgFcXmc+7cszBicvW87u2BP3s/DE4gcmKXEun7ZYuDNtaTK3YEzIv6fa7UjZcpxbAqIaC7vYeqZEg+f30eb4X1kFNa71fXegxTx9tOzoK75BfamQq3S+4lvR12lzSgcpYeU2fJtcggyDpIcoJ0EDHIlHH7jPsPDrlwB52ksdGchZBAoGBAM49dvAuf6cD9o38aSTWzqJh1cPS1Ci7SfXkd0OJQEcfCxKSB6FJsVZxKhexljzJCG0dojKBbPFxYvsAf8gzi/BAJOYoq7vtlzXkdBKAng9QZT60Rtlosxfy9qKCjronG/70pGpZPoQZQFb+1PlXvpVejKzsy2CEGe4PukSdNLfBAoGATUJ7FGVmG2Ym8UHcP0bMeWwS7XROuiquliLtamWwpZTByZaLO1nK2FtC1oVj3il4Wox6/In1bBbdtqxmRmKcD9q1JExr1PHxXQTUbIoWb4GXNHq7h5kOhpra1uVRmNIewfJUYTCEzzHTrJe8dADBc7lGpsibwk174YD2343Aupw=
productId: PAYUN
notifyUrl: https://peaktcm.com/hf/webhook/vendorPaySuccess
refundApi: https://api.huifu.com/v3/trade/payment/scanpay/refund
huaweiyun:
accessKey: HPUACMRQ2NKQNOXNCFNO
secretKey: KC3yEw7ARoL0oxcmXy7vE3hyMBPKjLsG1LCSQdYh
@@ -27,8 +27,8 @@
</encoder> </encoder>
</appender> </appender>
<!-- 开发环境配置 --> <!-- 开发与本地环境配置 -->
<springProfile name="dev"> <springProfile name="dev,local">
<root level="INFO"> <root level="INFO">
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/> <appender-ref ref="FILE"/>
@@ -106,6 +106,9 @@ public class Device {
private Integer treatStatus; private Integer treatStatus;
@ExcelProperty(value = "设备标签")
private String deviceTags;
@ExcelProperty(value = "机型品类")
private String modelCategory;
} }
@@ -0,0 +1,102 @@
package cn.unizk.entity.po;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 设备治疗参数定制规则实体
*
* @author hao / antigravity
* @date 2026/10/19
*/
@Data
@TableName("device_treat_rule")
public class DeviceTreatRule implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/**
* 规则名称
*/
private String ruleName;
/**
* 适用对象类型:1-单机(deviceId), 2-设备标签(tag), 3-机型分类(model/cf)
*/
private Integer targetType;
/**
* 匹配标识值:如具体的 deviceId / 标签名(golf_vip) / 机型配置(1/2)
*/
private String targetValue;
/**
* 关联的基准病症ID (对应 treat_parameter.id)
*/
private Integer symptomId;
/**
* 关联病症名称(非数据库字段,用于前端展示)
*/
@TableField(exist = false)
private String symptomsName;
/**
* 定制频率(01-99)
*/
private String fr;
/**
* 定制脉宽
*/
private String pw;
/**
* 定制脉幅
*/
private String pl;
/**
* 定制超声幅度
*/
private String ul;
/**
* 定制理疗时长
*/
private String tl;
/**
* 优先级权重 (数值越大越优先生效)
*/
private Integer priority;
/**
* 状态:1-启用, 0-禁用
*/
private Integer status;
/**
* 规则备注
*/
private String remark;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;
}
@@ -0,0 +1,38 @@
package cn.unizk.entity.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 设备标签批量设置 VO
*
* @author hao / antigravity
* @date 2026/10/19
*/
@Data
public class DeviceTagsVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 目标设备ID列表
*/
private List<String> deviceIds;
/**
* 标签列表(英文逗号分隔,如 "golf_vip,elderly_care")
*/
private String tags;
/**
* 机型品类编码(可选)
*/
private String modelCategory;
/**
* 设置模式:1-覆盖(默认),2-追加
*/
private Integer mode = 1;
}
@@ -0,0 +1,16 @@
package cn.unizk.mapper;
import cn.unizk.entity.po.DeviceTreatRule;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 设备治疗参数定制规则 Mapper 接口
*
* @author hao / antigravity
* @date 2026/10/19
*/
@Mapper
public interface DeviceTreatRuleMapper extends BaseMapper<DeviceTreatRule> {
}
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
UniAI Health (原愈极刻) 定制设备治疗参数功能 - 端到端全链路验证工具
1. 校验 RESTful API (管理员登录鉴权、规则管理、标签打标)
2. 模拟 IoT 设备与后台 UDP 服务 (端口 61100) 双向报文交互
3. 验证四层参数决策引擎:标签定制、单机定制、安全截断兜底、基准兜底
"""
import socket
import sys
import time
import json
import urllib.request
import urllib.error
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
RESET = "\033[0m"
UDP_HOST = "127.0.0.1"
UDP_PORT = 61100
HTTP_BASE = "http://127.0.0.1:8082"
def calc_crc(content: str) -> str:
"""计算异或校验码 (XOR Checksum)"""
if "$" in content and "*" in content:
raw = content[content.index("$") + 1 : content.rindex("*")]
else:
raw = content
xor_val = 0
for ch in raw.encode("ascii"):
xor_val ^= ch
return f"{xor_val:02X}"
def build_packet(inner_body: str) -> bytes:
"""构建带 $ 与 *{CRC}\\r\\n 的完整报文"""
raw = f"${inner_body}*"
crc = calc_crc(raw)
packet_str = f"${inner_body}*{crc}\r\n"
return packet_str.encode("utf-8")
def parse_kv(packet_str: str) -> dict:
"""解析以管道符分隔的报文内容"""
packet_str = packet_str.strip().strip("\r\n")
if packet_str.startswith("$"):
packet_str = packet_str[1:]
if "*" in packet_str:
packet_str = packet_str[:packet_str.rindex("*")]
parts = packet_str.split("|")
res = {}
if parts:
res["CMD"] = parts[0]
for p in parts[1:]:
if len(p) >= 2:
res[p[:2]] = p[2:]
return res
def test_login() -> str:
"""登录系统获取管理员 JWT Token"""
url = f"{HTTP_BASE}/pc/loginByUsername"
payload = json.dumps({"username": "admin", "password": "admin123"}).encode("utf-8")
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=3) as resp:
raw_data = json.loads(resp.read().decode("utf-8"))
token = raw_data.get("token")
if not token and isinstance(raw_data.get("data"), dict):
token = raw_data["data"].get("token")
if token:
print(f" {GREEN}✓ 管理员身份认证成功 (username: admin){RESET}")
return token
else:
print(f" {RED}✗ 登录成功但未包含 Token: {raw_data}{RESET}")
return ""
except Exception as e:
print(f" {RED}✗ 管理员登录异常: {e}{RESET}")
return ""
def test_rest_api(token: str):
"""测试 REST 接口:规则分页与批量标签"""
print(f"\n{BLUE}▶ 测试用例: [REST API] 验证定制规则管理与标签接口{RESET}")
if not token:
print(f" {RED}✗ 缺少认证 Token,跳过鉴权接口测试{RESET}")
return False
headers = {
"jkzy-auth": token,
"User-Agent": "VerifyScript",
"Content-Type": "application/json"
}
# 1. 查询规则列表
url_rules = f"{HTTP_BASE}/pc/admin/deviceTreatRule/page?pageNo=1&pageSize=10"
try:
req = urllib.request.Request(url_rules, headers=headers)
with urllib.request.urlopen(req, timeout=3) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("code") == 200:
records = data.get("data", {}).get("records", [])
print(f" {GREEN}✓ GET /pc/admin/deviceTreatRule/page 响应成功,已加载 {len(records)} 条定制规则{RESET}")
for r in records[:3]:
print(f" - [{r.get('ruleName')}] 类型:{r.get('targetType')} 目标:{r.get('targetValue')} FR:{r.get('fr')} UL:{r.get('ul')}")
else:
print(f" {RED}✗ 规则接口返回错误: {data}{RESET}")
return False
except Exception as e:
print(f" {RED}✗ 规则列表接口访问失败: {e}{RESET}")
return False
# 2. 批量打标测试
url_tags = f"{HTTP_BASE}/pc/admin/device/setTags"
try:
payload = json.dumps({
"deviceIds": ["DEV_DEF_04"],
"tags": "temp_test_tag,wsl_verify"
}).encode("utf-8")
req = urllib.request.Request(url_tags, data=payload, headers=headers)
with urllib.request.urlopen(req, timeout=3) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("code") == 200:
print(f" {GREEN}✓ POST /pc/admin/device/setTags 批量设备标签更新成功{RESET}")
else:
print(f" {YELLOW}⚠ 打标接口响应: {data.get('message')}{RESET}")
except Exception as e:
print(f" {YELLOW}⚠ 打标接口调用异常: {e}{RESET}")
return True
def test_device_treatment_params(device_id: str, case_name: str, expected_params: dict):
"""通过 UDP 模拟设备请求并断言下发的参数"""
print(f"\n{BLUE}▶ 测试用例: [{device_id}] - {case_name}{RESET}")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3.0)
try:
# 第一步:设备上报状态 03(就绪等待处方)
req1 = build_packet(f"03|MU{device_id}|CI00|CN00|")
# print(f" [TX -> 61100] {req1.decode().strip()}")
sock.sendto(req1, (UDP_HOST, UDP_PORT))
# 接收服务端的 03 回复(包含生成的处方流水号 CI)
data1, _ = sock.recvfrom(2048)
resp1_str = data1.decode("utf-8", errors="ignore")
# print(f" [RX <- 61100] {resp1_str.strip()}")
kv1 = parse_kv(resp1_str)
ci = kv1.get("CI")
if not ci or ci == "00":
print(f" {RED}✗ 失败: 服务端未返回有效处方流水号 (CI={ci}){RESET}")
return False
# 第二步:设备使用状态 30 请求处方参数分段 TN1
req2 = build_packet(f"30|MU{device_id}|CI{ci}|TN1|")
# print(f" [TX -> 61100] {req2.decode().strip()}")
sock.sendto(req2, (UDP_HOST, UDP_PORT))
# 接收服务端下发的 30 处方参数报文
data2, _ = sock.recvfrom(2048)
resp2_str = data2.decode("utf-8", errors="ignore")
# print(f" [RX <- 61100] {resp2_str.strip()}")
kv2 = parse_kv(resp2_str)
# 断言校验
all_passed = True
actual_details = []
for param, exp_val in expected_params.items():
act_val = kv2.get(param)
# 兼容前导 0
match = (act_val == exp_val) or (act_val and act_val.lstrip("0") == exp_val.lstrip("0"))
if match:
actual_details.append(f"{param}={act_val} {GREEN}✓{RESET}")
else:
actual_details.append(f"{param}={act_val}(预期:{exp_val}) {RED}✗{RESET}")
all_passed = False
print(" 参数下发结果: " + ", ".join(actual_details))
if all_passed:
print(f" {GREEN}✓ PASS: 该设备下发参数完全符合定制/安全/基准规则预期!{RESET}")
return True
else:
print(f" {RED}✗ FAIL: 参数与预期不符!{RESET}")
return False
except socket.timeout:
print(f" {RED}✗ 接收超时: 服务端未及时回复 UDP 报文{RESET}")
return False
except Exception as e:
print(f" {RED}✗ 异常: {e}{RESET}")
return False
finally:
sock.close()
def main():
print(f"{BLUE}=================================================================={RESET}")
print(f"{BLUE} UniAI Health 定制治疗参数功能 WSL 本地全链路验证工具 {RESET}")
print(f"{BLUE}=================================================================={RESET}")
# 检查 HTTP 服务就绪
print(f"\n正在检查后台 HTTP (8082) 与 UDP (61100) 端口可用性...")
http_ready = False
try:
with urllib.request.urlopen(f"{HTTP_BASE}/pc/loginByUsername", timeout=2) as resp:
http_ready = True
except urllib.error.HTTPError as e:
# POST endpoint called with GET will return 405 Method Not Allowed, which proves server is up
http_ready = (e.code in (400, 405))
except Exception:
http_ready = False
if not http_ready:
print(f"{RED}✗ 后台服务尚未就绪或 8082 端口未启动。{RESET}")
print(f"{YELLOW}请先在 WSL-Ubuntu 终端中运行以下命令启动服务:{RESET}")
print(f" cd /mnt/d/Workspace/uniaihealth/server")
print(f" mvn spring-boot:run -pl admin -Dspring-boot.run.profiles=local\n")
sys.exit(1)
print(f"{GREEN}✓ 后台服务连接成功!开始执行测试用例...{RESET}")
# 登录获取 Token
token = test_login()
results = []
# 1. REST API
results.append(("REST API 测试", test_rest_api(token)))
# 2. 标签匹配覆盖 (DEV_GOLF_01 打了 golf_vip 标签)
results.append(("标签定制覆盖 (golf_vip)", test_device_treatment_params(
device_id="DEV_GOLF_01",
case_name="标签定制匹配 (golf_vip 覆盖基准)",
expected_params={"FR": "08", "PW": "55", "PL": "12", "UL": "05", "TL": "025"}
)))
# 3. 单机精确匹配覆盖 (DEV_SPEC_02 配置了单机规则)
results.append(("单机专属定制 (DEV_SPEC_02)", test_device_treatment_params(
device_id="DEV_SPEC_02",
case_name="单机专属规则覆盖 (DEV_SPEC_02 覆盖基准与标签)",
expected_params={"FR": "02", "PW": "40", "PL": "04", "UL": "02", "TL": "010"}
)))
# 4. 非医疗民用版安全极限截断 (DEV_CIVIL_03, cf=2, 规则配置超标 UL=18)
results.append(("民用机安全截断 (DEV_CIVIL_03)", test_device_treatment_params(
device_id="DEV_CIVIL_03",
case_name="硬件安全底线防护 (cf=2 非医疗版强制限幅 UL <= 10)",
expected_params={"UL": "10"}
)))
# 5. 普通设备基准默认参数兜底 (DEV_DEF_04, 无标签无单机规则)
results.append(("基准参数兜底 (DEV_DEF_04)", test_device_treatment_params(
device_id="DEV_DEF_04",
case_name="全局病症基准参数正常下发兜底",
expected_params={"FR": "03", "PW": "50", "PL": "08", "UL": "01", "TL": "017"}
)))
# 汇总
print(f"\n{BLUE}=================================================================={RESET}")
print(f"{BLUE} 测试结果汇总报告 {RESET}")
print(f"{BLUE}=================================================================={RESET}")
total = len(results)
passed = sum(1 for _, ok in results if ok)
for name, ok in results:
status_text = f"{GREEN}PASS ✓{RESET}" if ok else f"{RED}FAIL ✗{RESET}"
print(f" {name: <35} : {status_text}")
print(f"{BLUE}------------------------------------------------------------------{RESET}")
print(f"总计: {total} 项,成功: {GREEN}{passed}{RESET} 项,失败: {RED}{total - passed}{RESET} 项")
if passed == total:
print(f"{GREEN}★ 所有定制治疗参数与调度验证均 100% 成功通过!★{RESET}\n")
sys.exit(0)
else:
print(f"{RED}⚠ 存在未通过的测试项,请检查日志。{RESET}\n")
sys.exit(1)
if __name__ == "__main__":
main()
+1
View File
@@ -18,6 +18,7 @@ const files = {
export: 'exportDevices', export: 'exportDevices',
remark: 'remark', remark: 'remark',
frequency: 'getTopThree', frequency: 'getTopThree',
setTags: 'setTags',
} }
/** /**
@@ -0,0 +1,44 @@
import request from '@/utils/request'
/**
* 设备治疗参数定制规则 API
*/
export const getRulePage = params => {
return request({
url: '/api/pc/admin/deviceTreatRule/page',
method: 'get',
params
})
}
export const saveOrUpdateRule = data => {
return request({
url: '/api/pc/admin/deviceTreatRule/saveOrUpdate',
method: 'post',
data
})
}
export const deleteRule = id => {
return request({
url: '/api/pc/admin/deviceTreatRule/delete',
method: 'post',
params: { id }
})
}
export const changeRuleStatus = (id, status) => {
return request({
url: '/api/pc/admin/deviceTreatRule/changeStatus',
method: 'post',
params: { id, status }
})
}
export const getRuleDetail = id => {
return request({
url: '/api/pc/admin/deviceTreatRule/getOne',
method: 'get',
params: { id }
})
}
@@ -0,0 +1,195 @@
<template>
<el-dialog v-model="dialogVisible" :title="title" width="700px" align-center draggable @closed="resetFields">
<section class="p-4">
<el-form :model="formData" label-width="110px">
<el-form-item label="操作模式:">
<el-radio-group v-model="formData.mode">
<el-radio :label="1">覆盖原有标签</el-radio>
<el-radio :label="2">追加新标签</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="快捷标签:">
<div class="tag-shortcuts">
<el-check-tag
v-for="tag in commonTags"
:key="tag.value"
:checked="selectedTags.includes(tag.value)"
@change="handleTagToggle(tag.value)"
class="mr-2 mb-2"
>
{{ tag.label }}
</el-check-tag>
</div>
</el-form-item>
<el-form-item label="设备标签:">
<el-input
v-model.trim="formData.tags"
placeholder="支持输入多个标签,以英文逗号分隔,如:golf_vip,elderly_care"
clearable
/>
<div class="tip-text">用于匹配参数定制规则,例如打上 "golf_vip" 即可享受高尔夫专属治疗参数</div>
</el-form-item>
<el-form-item label="机型品类:">
<el-select v-model="formData.modelCategory" clearable placeholder="可选:指定机型品类编码" style="width: 100%;">
<el-option label="标准款 (DEFAULT)" value="DEFAULT" />
<el-option label="尊享款 (PRO)" value="PRO" />
<el-option label="体验款 (TRIAL)" value="TRIAL" />
<el-option label="轻便款 (LITE)" value="LITE" />
</el-select>
</el-form-item>
<el-form-item label="选中设备数:">
<span style="font-weight: bold; color: #409eff;">共 {{ deviceList.length }} 台设备</span>
<div class="device-chips mt-1">
<el-tag
v-for="item in deviceList"
:key="item.deviceId"
size="small"
class="mr-1 mb-1"
closable
@close="handleRemoveDevice(item.deviceId)"
>
{{ item.deviceId }}
</el-tag>
</div>
</el-form-item>
</el-form>
<div class="flex justify-center mt-4">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleConfirm">确认保存</el-button>
</div>
</section>
</el-dialog>
</template>
<script setup>
import { reactive, ref, getCurrentInstance, watch } from 'vue'
import { api } from '@/apis/admin/device'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['success'])
const { title = '设置设备标签' } = defineProps({
title: String
})
const dialogVisible = ref(false)
const submitting = ref(false)
const deviceList = ref([])
const selectedTags = ref([])
const commonTags = [
{ label: '高尔夫专享 (golf_vip)', value: 'golf_vip' },
{ label: '养老康养示范 (elderly_care)', value: 'elderly_care' },
{ label: '运动康复专科 (sports_rehab)', value: 'sports_rehab' },
{ label: 'VIP特护定制 (vip_care)', value: 'vip_care' },
{ label: '体验展厅机 (showroom)', value: 'showroom' }
]
const formData = reactive({
tags: '',
mode: 1,
modelCategory: ''
})
const handleTagToggle = tagValue => {
const index = selectedTags.value.indexOf(tagValue)
if (index > -1) {
selectedTags.value.splice(index, 1)
} else {
selectedTags.value.push(tagValue)
}
formData.tags = selectedTags.value.join(',')
}
watch(
() => formData.tags,
val => {
if (!val) {
selectedTags.value = []
return
}
const current = val.split(',').map(s => s.trim()).filter(Boolean)
selectedTags.value = current
}
)
const resetFields = () => {
formData.tags = ''
formData.mode = 1
formData.modelCategory = ''
deviceList.value = []
selectedTags.value = []
}
const handleRemoveDevice = id => {
const idx = deviceList.value.findIndex(item => item.deviceId === id)
if (idx > -1) {
deviceList.value.splice(idx, 1)
}
}
const handleConfirm = async () => {
if (!deviceList.value.length) {
return proxy.$message.warning('请至少保留一台设备')
}
submitting.value = true
try {
const res = await api({
type: 'setTags',
data: {
deviceIds: deviceList.value.map(item => item.deviceId),
tags: formData.tags,
modelCategory: formData.modelCategory,
mode: formData.mode
}
})
const { code, message } = res
if (code !== 200 && code !== undefined && res !== true) {
return proxy.$message.error(message || '保存失败')
}
proxy.$message.success('设备标签设置成功')
dialogVisible.value = false
emit('success')
} catch (error) {
console.error(error)
} finally {
submitting.value = false
}
}
const show = list => {
dialogVisible.value = true
deviceList.value = [...list]
if (list.length === 1 && list[0].deviceTags) {
formData.tags = list[0].deviceTags
formData.modelCategory = list[0].modelCategory || ''
}
}
defineExpose({ show })
</script>
<style lang="scss" scoped>
.tip-text {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.device-chips {
max-height: 120px;
overflow-y: auto;
background-color: #f8f9fa;
padding: 6px;
border-radius: 4px;
}
.tag-shortcuts {
display: flex;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,261 @@
<template>
<el-dialog v-model="dialogVisible" :title="title" width="680px" align-center draggable @closed="handleClosed">
<section class="p-4">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px" label-position="right">
<el-form-item label="规则名称" prop="ruleName">
<el-input v-model.trim="formData.ruleName" placeholder="例如:高尔夫专享温和理疗" />
</el-form-item>
<el-form-item label="适用对象类型" prop="targetType">
<el-radio-group v-model="formData.targetType" @change="handleTypeChange">
<el-radio :label="2">设备标签 (Tag)</el-radio>
<el-radio :label="3">机型/配置 (Category)</el-radio>
<el-radio :label="1">指定单机 (Device ID)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="匹配目标标识" prop="targetValue">
<!-- 标签类型推荐 -->
<div v-if="formData.targetType === 2" style="width: 100%;">
<el-select
v-model="formData.targetValue"
filterable
allow-create
default-first-option
placeholder="请选择或输入标签名,如 golf_vip"
style="width: 100%;"
>
<el-option label="高尔夫专享 (golf_vip)" value="golf_vip" />
<el-option label="养老康养示范 (elderly_care)" value="elderly_care" />
<el-option label="运动康复专科 (sports_rehab)" value="sports_rehab" />
<el-option label="VIP特护定制 (vip_care)" value="vip_care" />
<el-option label="体验展厅机 (showroom)" value="showroom" />
</el-select>
<div class="tip-text">设备只要打上该标签即可生效</div>
</div>
<!-- 机型配置类型 -->
<div v-else-if="formData.targetType === 3" style="width: 100%;">
<el-select v-model="formData.targetValue" placeholder="请选择机器配置版本" style="width: 100%;">
<el-option label="医疗版本 (1)" value="1" />
<el-option label="非医疗版本 (2)" value="2" />
</el-select>
<div class="tip-text">所有属于该配置版本的设备均会生效</div>
</div>
<!-- 单机类型 -->
<div v-else style="width: 100%;">
<el-input v-model.trim="formData.targetValue" placeholder="请输入具体设备ID,如 12345678" />
<div class="tip-text">单机规则具有最高生效优先级</div>
</div>
</el-form-item>
<el-form-item label="关联病症" prop="symptomId">
<el-select
v-model="formData.symptomId"
filterable
placeholder="请选择要覆盖的基准病症"
style="width: 100%;"
:loading="symptomLoading"
>
<el-option
v-for="item in symptomList"
:key="item.id"
:label="`${item.symptomsName} (${item.acupoint || '常规'}) - 基准参数: 超声${item.ul} / 频率${item.fr}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-divider content-position="left">定制治疗参数(留空则自动继承基准参数)</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="超声幅度 (ul)">
<el-input v-model.trim="formData.ul" placeholder="留空继承默认" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉冲幅度 (pl)">
<el-input v-model.trim="formData.pl" placeholder="留空继承默认" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="脉冲宽度 (pw)">
<el-input v-model.trim="formData.pw" placeholder="留空继承默认" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉冲频率 (fr)">
<el-input v-model.trim="formData.fr" placeholder="留空继承默认" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="理疗时长 (tl)">
<el-input v-model.trim="formData.tl" placeholder="留空继承默认,如 015 表示15分钟" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="优先级权重" prop="priority">
<el-input-number v-model="formData.priority" :min="1" :max="999" />
<div class="tip-text">同级别冲突时数值越大越优先</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启停状态">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="规则备注">
<el-input v-model.trim="formData.remark" type="textarea" :rows="2" placeholder="填写业务背景或备注说明" />
</el-form-item>
</el-form>
<div class="flex justify-center mt-4">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存规则</el-button>
</div>
</section>
</el-dialog>
</template>
<script setup>
import { ref, reactive, getCurrentInstance } from 'vue'
import { saveOrUpdateRule } from '@/apis/admin/deviceTreatRule'
import { api as therapistParamsApi } from '@/apis/admin/therapistParams'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['success'])
const { title = '定制治疗参数规则' } = defineProps({
title: String
})
const dialogVisible = ref(false)
const submitting = ref(false)
const symptomLoading = ref(false)
const symptomList = ref([])
const formRef = ref()
const formData = reactive({
id: undefined,
ruleName: '',
targetType: 2,
targetValue: '',
symptomId: undefined,
fr: '',
pw: '',
pl: '',
ul: '',
tl: '',
priority: 10,
status: 1,
remark: ''
})
const formRules = {
ruleName: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
targetType: [{ required: true, message: '请选择适用对象类型', trigger: 'change' }],
targetValue: [{ required: true, message: '请输入或选择匹配标识', trigger: 'change' }],
symptomId: [{ required: true, message: '请选择关联病症', trigger: 'change' }]
}
const handleTypeChange = () => {
formData.targetValue = ''
}
const loadSymptoms = async () => {
if (symptomList.value.length) return
symptomLoading.value = true
try {
const res = await therapistParamsApi({
type: 'list',
method: 'get'
})
const { code, data } = res
if (code === 200 || Array.isArray(data)) {
symptomList.value = data || []
}
} catch (error) {
console.error(error)
} finally {
symptomLoading.value = false
}
}
const handleClosed = () => {
formData.id = undefined
formData.ruleName = ''
formData.targetType = 2
formData.targetValue = ''
formData.symptomId = undefined
formData.fr = ''
formData.pw = ''
formData.pl = ''
formData.ul = ''
formData.tl = ''
formData.priority = 10
formData.status = 1
formData.remark = ''
formRef.value?.resetFields()
}
const show = async (data = null) => {
dialogVisible.value = true
await loadSymptoms()
if (data) {
formData.id = data.id
formData.ruleName = data.ruleName
formData.targetType = data.targetType
formData.targetValue = data.targetValue
formData.symptomId = data.symptomId
formData.fr = data.fr || ''
formData.pw = data.pw || ''
formData.pl = data.pl || ''
formData.ul = data.ul || ''
formData.tl = data.tl || ''
formData.priority = data.priority || 10
formData.status = data.status !== undefined ? data.status : 1
formData.remark = data.remark || ''
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async valid => {
if (!valid) return
submitting.value = true
try {
const res = await saveOrUpdateRule({ ...formData })
const { code, message } = res
if (code !== 200 && code !== undefined && res !== true) {
return proxy.$message.error(message || '保存失败')
}
proxy.$message.success('保存成功')
dialogVisible.value = false
emit('success')
} catch (error) {
console.error(error)
} finally {
submitting.value = false
}
})
}
defineExpose({ show })
</script>
<style lang="scss" scoped>
.tip-text {
font-size: 12px;
color: #909399;
line-height: 1.4;
margin-top: 4px;
}
</style>
+4
View File
@@ -17,6 +17,10 @@ export default [
path: '/admin/physio/therapistParams', path: '/admin/physio/therapistParams',
component: () => import('@/views/admin/physio/therapistParams.vue') component: () => import('@/views/admin/physio/therapistParams.vue')
}, },
{
path: '/admin/physio/customParams',
component: () => import('@/views/admin/physio/customParams.vue')
},
{ {
path: '/admin/physio/reportsTemp', path: '/admin/physio/reportsTemp',
component: () => import('@/views/admin/physio/reportsTemp.vue') component: () => import('@/views/admin/physio/reportsTemp.vue')
+33
View File
@@ -77,6 +77,9 @@
<el-tooltip :disabled="isMultiple" effect="dark" content="本页至少选择一项" placement="bottom"> <el-tooltip :disabled="isMultiple" effect="dark" content="本页至少选择一项" placement="bottom">
<el-button :disabled="!isMultiple" type="primary" @click="handleSetDeviceConfig">设备配置</el-button> <el-button :disabled="!isMultiple" type="primary" @click="handleSetDeviceConfig">设备配置</el-button>
</el-tooltip> </el-tooltip>
<el-tooltip :disabled="isMultiple" effect="dark" content="本页至少选择一项" placement="bottom">
<el-button :disabled="!isMultiple" type="primary" @click="handleSetDeviceTags">设置标签</el-button>
</el-tooltip>
<el-tooltip :disabled="isMultiple" effect="dark" content="本页至少选择一项" placement="bottom"> <el-tooltip :disabled="isMultiple" effect="dark" content="本页至少选择一项" placement="bottom">
<el-button :disabled="!isMultiple" type="primary" @click="handleDeviceClear">设备清零</el-button> <el-button :disabled="!isMultiple" type="primary" @click="handleDeviceClear">设备清零</el-button>
</el-tooltip> </el-tooltip>
@@ -132,6 +135,23 @@
<el-table-column label="机器配置" align="center" prop="cfWord" width="200" /> <el-table-column label="机器配置" align="center" prop="cfWord" width="200" />
<el-table-column label="设备标签" align="center" prop="deviceTags" width="180">
<template #default="props">
<div v-if="props.row.deviceTags" class="flex flex-wrap justify-center">
<el-tag
v-for="tag in props.row.deviceTags.split(',').filter(Boolean)"
:key="tag"
size="small"
class="mr-1 mb-1"
type="info"
>
{{ tag }}
</el-tag>
</div>
<span v-else style="color: #c0c4cc; font-size: 12px;">-</span>
</template>
</el-table-column>
<el-table-column label="工作模式" align="center" prop="wmWord" width="200" /> <el-table-column label="工作模式" align="center" prop="wmWord" width="200" />
<el-table-column label="硬件版本" align="center" prop="hw" width="200" /> <el-table-column label="硬件版本" align="center" prop="hw" width="200" />
@@ -171,6 +191,7 @@
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="handleShowDeviceLocation(props.row)">位置</el-dropdown-item> <el-dropdown-item @click="handleShowDeviceLocation(props.row)">位置</el-dropdown-item>
<el-dropdown-item @click="handleSetSingleDeviceTags(props.row)">设置标签</el-dropdown-item>
<el-dropdown-item @click="handleCheckUseRecord(props.row.deviceId)">使用记录</el-dropdown-item> <el-dropdown-item @click="handleCheckUseRecord(props.row.deviceId)">使用记录</el-dropdown-item>
<el-dropdown-item @click="handleCheckRechargeRecord(props.row.deviceId)">充值记录</el-dropdown-item> <el-dropdown-item @click="handleCheckRechargeRecord(props.row.deviceId)">充值记录</el-dropdown-item>
<el-dropdown-item @click="handleSetRemark(props.row)">备注</el-dropdown-item> <el-dropdown-item @click="handleSetRemark(props.row)">备注</el-dropdown-item>
@@ -206,11 +227,15 @@
<!-- 设备配置 --> <!-- 设备配置 -->
<set-device-config ref="setDeviceConfigRef" title="设备配置" @success="handleInsideSuccess" /> <set-device-config ref="setDeviceConfigRef" title="设备配置" @success="handleInsideSuccess" />
<!-- 设置标签 -->
<set-device-tags ref="setDeviceTagsRef" @success="handleInsideSuccess" />
<!-- 备注 --> <!-- 备注 -->
<remark-form ref="remarkFormRef" title="修改备注" @success="getList" /> <remark-form ref="remarkFormRef" title="修改备注" @success="getList" />
</template> </template>
<script setup> <script setup>
import SetDeviceTags from '@/components/admin/device/SetDeviceTags.vue'
import RemarkForm from '@/components/admin/device/RemarkForm.vue' import RemarkForm from '@/components/admin/device/RemarkForm.vue'
import RechargeRecord from '@/components/admin/device/RechargeRecord.vue' import RechargeRecord from '@/components/admin/device/RechargeRecord.vue'
import SetDeviceConfig from '@/components/admin/device/SetDeviceConfig.vue' import SetDeviceConfig from '@/components/admin/device/SetDeviceConfig.vue'
@@ -449,6 +474,14 @@ const handleSetDeviceConfig = () => {
setDeviceConfigRef.value.show([...multipleSelection.value]) setDeviceConfigRef.value.show([...multipleSelection.value])
} }
const setDeviceTagsRef = ref()
const handleSetDeviceTags = () => {
setDeviceTagsRef.value.show([...multipleSelection.value])
}
const handleSetSingleDeviceTags = row => {
setDeviceTagsRef.value.show([row])
}
const handleDeviceClear = () => { const handleDeviceClear = () => {
proxy.$messageBox.confirm('是否确认清空使用次数?', '提示', { proxy.$messageBox.confirm('是否确认清空使用次数?', '提示', {
confirmButtonText: '确认', confirmButtonText: '确认',
@@ -0,0 +1,276 @@
<template>
<section class="full-height flex flex-column">
<header class="header p-2">
<el-form inline class="pl-2 pt-2">
<el-form-item label="规则名称:">
<el-input v-model.trim="queryForm.ruleName" clearable placeholder="输入规则名称" />
</el-form-item>
<el-form-item label="适用类型:">
<el-select v-model="queryForm.targetType" clearable placeholder="全部" style="width: 150px;">
<el-option label="单台设备" :value="1" />
<el-option label="设备标签" :value="2" />
<el-option label="机型分类" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="状态:">
<el-select v-model="queryForm.status" clearable placeholder="全部" style="width: 120px;">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="success" @click="handleAddClick">添加定制规则</el-button>
</el-form-item>
</el-form>
</header>
<main class="main hidden flex-1 pl-2 pr-2 pt-2 pb-2 mt-2">
<el-table :data="tableData.list" v-loading="loading" row-key="id" height="100%">
<el-table-column label="ID" prop="id" align="center" width="70" />
<el-table-column label="规则名称" prop="ruleName" min-width="160" show-overflow-tooltip />
<el-table-column label="适用对象类型" align="center" width="130">
<template #default="props">
<el-tag v-if="props.row.targetType === 1" type="warning" size="small">单台设备</el-tag>
<el-tag v-else-if="props.row.targetType === 2" type="primary" size="small">设备标签</el-tag>
<el-tag v-else-if="props.row.targetType === 3" type="success" size="small">机型/配置</el-tag>
</template>
</el-table-column>
<el-table-column label="匹配标识值" prop="targetValue" align="center" width="160" show-overflow-tooltip>
<template #default="props">
<span v-if="props.row.targetType === 3">
{{ props.row.targetValue === '1' ? '医疗版本(1)' : props.row.targetValue === '2' ? '非医疗版本(2)' : props.row.targetValue }}
</span>
<el-tag v-else-if="props.row.targetType === 2" size="small" effect="plain">{{ props.row.targetValue }}</el-tag>
<span v-else>{{ props.row.targetValue }}</span>
</template>
</el-table-column>
<el-table-column label="覆盖病症" prop="symptomsName" min-width="150" show-overflow-tooltip>
<template #default="props">
<span>{{ props.row.symptomsName || `病症ID: ${props.row.symptomId}` }}</span>
</template>
</el-table-column>
<el-table-column label="定制超声 (ul)" prop="ul" align="center" width="110">
<template #default="props">
<el-tag v-if="props.row.ul" size="small">{{ props.row.ul }}</el-tag>
<span v-else style="color: #c0c4cc; font-size: 12px;">继承默认</span>
</template>
</el-table-column>
<el-table-column label="定制脉幅 (pl)" prop="pl" align="center" width="110">
<template #default="props">
<el-tag v-if="props.row.pl" size="small">{{ props.row.pl }}</el-tag>
<span v-else style="color: #c0c4cc; font-size: 12px;">继承默认</span>
</template>
</el-table-column>
<el-table-column label="定制脉宽 (pw)" prop="pw" align="center" width="110">
<template #default="props">
<el-tag v-if="props.row.pw" size="small">{{ props.row.pw }}</el-tag>
<span v-else style="color: #c0c4cc; font-size: 12px;">继承默认</span>
</template>
</el-table-column>
<el-table-column label="定制频率 (fr)" prop="fr" align="center" width="110">
<template #default="props">
<el-tag v-if="props.row.fr" size="small">{{ props.row.fr }}</el-tag>
<span v-else style="color: #c0c4cc; font-size: 12px;">继承默认</span>
</template>
</el-table-column>
<el-table-column label="定制时长 (tl)" prop="tl" align="center" width="110">
<template #default="props">
<el-tag v-if="props.row.tl" size="small">{{ props.row.tl }}</el-tag>
<span v-else style="color: #c0c4cc; font-size: 12px;">继承默认</span>
</template>
</el-table-column>
<el-table-column label="优先级" prop="priority" align="center" width="80" />
<el-table-column label="状态" align="center" width="90">
<template #default="props">
<el-switch
v-model="props.row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(props.row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="props">
<span class="pointer active" @click="handleEditClick(props.row)">编辑</span>
<el-divider direction="vertical" />
<span class="pointer active" style="color: #f56c6c;" @click="handleDeleteClick(props.row)">删除</span>
</template>
</el-table-column>
</el-table>
</main>
<footer class="footer p-2 pr-4">
<el-pagination
v-model:current-page="queryForm.page"
:page-size="queryForm.limit"
layout="->, prev, pager, next, jumper, total"
:total="tableData.total"
@current-change="handlePageChange"
/>
</footer>
</section>
<!-- 编辑/新建弹窗 -->
<custom-params-form ref="customParamsFormRef" :title="dialogTitle" @success="getList" />
</template>
<script setup>
import { reactive, ref, getCurrentInstance, onMounted } from 'vue'
import CustomParamsForm from '@/components/admin/physio/CustomParamsForm.vue'
import { getRulePage, deleteRule, changeRuleStatus } from '@/apis/admin/deviceTreatRule'
const { proxy } = getCurrentInstance()
const loading = ref(false)
const dialogTitle = ref('添加定制治疗参数规则')
const customParamsFormRef = ref()
const queryForm = reactive({
ruleName: '',
targetType: '',
status: '',
page: 1,
limit: 10
})
const tableData = reactive({
list: [],
total: 0
})
const handleSearch = () => {
queryForm.page = 1
getList()
}
const handleReset = () => {
queryForm.ruleName = ''
queryForm.targetType = ''
queryForm.status = ''
queryForm.page = 1
getList()
}
const handlePageChange = val => {
queryForm.page = val
getList()
}
const getList = async () => {
loading.value = true
try {
const res = await getRulePage({
currentPage: queryForm.page,
pageSize: queryForm.limit,
ruleName: queryForm.ruleName || undefined,
targetType: queryForm.targetType !== '' ? queryForm.targetType : undefined,
status: queryForm.status !== '' ? queryForm.status : undefined
})
const { code, data } = res
if (code === 200 || data) {
tableData.list = data.records || []
tableData.total = data.total || 0
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
const handleAddClick = () => {
dialogTitle.value = '添加定制治疗参数规则'
customParamsFormRef.value.show()
}
const handleEditClick = row => {
dialogTitle.value = '编辑定制治疗参数规则'
customParamsFormRef.value.show(row)
}
const handleStatusChange = async row => {
try {
const res = await changeRuleStatus(row.id, row.status)
const { code, message } = res
if (code !== 200 && code !== undefined && res !== true) {
row.status = row.status === 1 ? 0 : 1
return proxy.$message.error(message || '状态切换失败')
}
proxy.$message.success('状态已更新')
} catch (error) {
row.status = row.status === 1 ? 0 : 1
console.error(error)
}
}
const handleDeleteClick = row => {
proxy.$confirm(`确认删除规则【${row.ruleName}】吗?`, '删除警告', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteRule(row.id)
const { code, message } = res
if (code !== 200 && code !== undefined && res !== true) {
return proxy.$message.error(message || '删除失败')
}
proxy.$message.success('删除成功')
getList()
} catch (error) {
console.error(error)
}
})
}
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.header,
.main,
.footer {
background-color: #ffffff;
}
.header {
border-radius: 5px;
.el-input {
width: 190px;
}
}
.main {
border-radius: 5px 5px 0 0;
.el-table {
width: 100%;
height: 100%;
}
}
.footer {
border-radius: 0 0 5px 5px;
}
</style>