peicj il y a 1 semaine
Parent
commit
52af3b79ec

+ 20 - 5
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java

@@ -177,6 +177,22 @@ public class ApiController extends BaseController {
         return AjaxResult.success(list);
     }
 
+    /**
+     * Login page gateway list: only purposes 1(phonebar) and 3(unlimited), exclude 2(outbound tasks)
+     */
+    @GetMapping("/gateway/phonebarList")
+    @ResponseBody
+    public AjaxResult getPhonebarGatewayList() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("purposes", Arrays.asList(1, 3));
+        List<CcGateways> list = ccGatewaysService.selectCcGatewaysList(new CcGateways().setParams(params));
+        // defense: never return purpose=2 (outbound tasks)
+        list = list.stream()
+                .filter(g -> g.getPurpose() != null && (g.getPurpose() == 1 || g.getPurpose() == 3))
+                .collect(Collectors.toList());
+        return AjaxResult.success(list);
+    }
+
     /**
      * 通话记录查询接口(支持按时间、坐席、号码、呼入/呼出类型筛选)
      * @param req
@@ -1221,6 +1237,10 @@ public class ApiController extends BaseController {
         encryptStr = String.format("var _phoneEncryptPassword='%s';", encryptStr);
 
         CcGateways ccGatewaysVo = new CcGateways();
+        // 电话条仅允许 purposes 1/3,排除 2(外呼任务)
+        Map<String, Object> phonebarPurposes = new HashMap<>();
+        phonebarPurposes.put("purposes", Arrays.asList(1, 3));
+        ccGatewaysVo.setParams(phonebarPurposes);
         //判断指定网关还是全部网关
         if(StringUtils.isNotBlank(myGateway)){
             List<Long> gatewayIds = Arrays.stream(myGateway.split(","))
@@ -1231,11 +1251,6 @@ public class ApiController extends BaseController {
             String wgName = param.get("wgName");
             if(StringUtils.isNotBlank(wgName)){
                 ccGatewaysVo.setGwName(wgName);
-            }else{
-                // 网关用途 0 已废弃; 1 电话条; 2 外呼任务; 3 无限制
-                Map<String, Object> params = new HashMap<>();
-                params.put("purposes", Arrays.asList(1,3));
-                ccGatewaysVo.setParams(params);
             }
         }
         //3.获取工具条网关列表

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/mapper/CcExtNumMapper.java

@@ -113,4 +113,6 @@ public interface CcExtNumMapper
      * @return 结果
      */
     int batchUpdateUserCodeByExtNum(@Param("list") List<CcExtNum> list);
+
+    CcExtNum selectCcExtNumByUserCodeTwo(@Param("username") String username);
 }

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcExtNumService.java

@@ -121,4 +121,6 @@ public interface ICcExtNumService
      * @return 结果
      */
     int batchUpdateUserCodeByExtNum(List<CcExtNum> list);
+
+    CcExtNum selectCcExtNumByUserCodeTwo(String username);
 }

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcExtNumServiceImpl.java

@@ -362,4 +362,9 @@ public class CcExtNumServiceImpl implements ICcExtNumService
     public int batchUpdateUserCodeByExtNum(List<CcExtNum> list) {
         return ccExtNumMapper.batchUpdateUserCodeByExtNum(list);
     }
+
+    @Override
+    public CcExtNum selectCcExtNumByUserCodeTwo(String username) {
+        return ccExtNumMapper.selectCcExtNumByUserCodeTwo(username);
+    }
 }

+ 31 - 6
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysIndexController.java

@@ -95,6 +95,8 @@ public class SysIndexController extends BaseController
         mmap.put("mainClass", contentMainClass(footer, tagsView));
         mmap.put("copyrightYear", RuoYiConfig.getCopyrightYear());
         mmap.put("sysVersion", sysVersion);
+        mmap.put("isLocalDebug", RuoYiConfig.isLocalDebug());
+        mmap.put("localRemoteHost", RuoYiConfig.getLocalRemoteHost());
         if (user.getLoginName().equals("admin")) {
             mmap.put("demoEnabled", RuoYiConfig.isDemoEnabled());
         } else {
@@ -171,16 +173,24 @@ public class SysIndexController extends BaseController
         CookieUtils.setCookie(response, "nav-style", style);
     }
 
-    // 系统介绍
+    // system main / agent workbench (embed=1 supported)
     @GetMapping("/system/main")
     public String main(ModelMap mmap)
     {
         mmap.put("version", RuoYiConfig.getVersion());
+        boolean embed = "1".equals(ServletUtils.getParameter("embed"));
+        mmap.put("embedMode", embed);
+        mmap.put("isLocalDebug", RuoYiConfig.isLocalDebug());
+        mmap.put("localRemoteHost", RuoYiConfig.getLocalRemoteHost());
 
-        // 获取当前的用户
+        // current user
         SysUser currentUser = ShiroUtils.getSysUser();
-        // 获取分机号
+        // extension number
         CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCode(currentUser.getLoginName());
+        if (ccExtNum == null || ccExtNum.getExtNum() == null) {
+            // embed 下不要静默炸页;退回登录并尽量保留 embed
+            return "redirect:/login?embed=1&lang=zh_CN";
+        }
 
         String extnum = ccExtNum.getExtNum().toString();
         String opnum = ccExtNum.getUserCode();
@@ -191,10 +201,22 @@ public class SysIndexController extends BaseController
         String skillLevel = "9";
         String projectId = "1";
         String loginToken = ccExtNumService.createToken(extnum, opnum, groupId, skillLevel, projectId);
-        // 网关用途 0 dropped; 1 phonebar; 2 outbound tasks; 3. Unlimited
+
+        // myGateway from login; always restrict to purposes 1/3 (exclude 2 outbound tasks)
+        String myGateway = (String) CacheUtils.get("myGateway-" + currentUser.getLoginName());
+        CcGateways ccGatewaysVo = new CcGateways();
         Map<String, Object> params = new HashMap<>();
-        params.put("purposes", Arrays.asList(1,3));
-        List<CcGateways> gatewaysList = ccGatewaysService.selectCcGatewaysList(new CcGateways().setParams(params));
+        params.put("purposes", Arrays.asList(1, 3));
+        ccGatewaysVo.setParams(params);
+        if (StringUtils.isNotBlank(myGateway)) {
+            List<Long> gatewayIds = Arrays.stream(myGateway.split(","))
+                    .filter(StringUtils::isNotBlank)
+                    .map(String::trim)
+                    .map(Long::parseLong)
+                    .collect(java.util.stream.Collectors.toList());
+            ccGatewaysVo.setGatewayIds(gatewayIds);
+        }
+        List<CcGateways> gatewaysList = ccGatewaysService.selectCcGatewaysList(ccGatewaysVo);
         List<JSONObject> gatewayList = new ArrayList<>();
         for (CcGateways ccGateways: gatewaysList) {
             JSONObject configGateway = new JSONObject();
@@ -221,6 +243,9 @@ public class SysIndexController extends BaseController
         callConfig.put("scriptPort", scriptPort);
         callConfig.put("loginToken", loginToken);
         callConfig.put("gatewayList", gatewayList);
+        callConfig.put("extNum", extnum);
+        callConfig.put("opNum", opnum);
+        callConfig.put("myGateway", myGateway);
         mmap.put("callConfig", callConfig);
         String autoFree = ccParamsService.getParamValueByCode("phone-bar-free-auto", "false");
         mmap.put("autoFree", Boolean.valueOf(autoFree));

+ 41 - 6
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java

@@ -4,7 +4,9 @@ import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 import com.ruoyi.cc.domain.CcExtNum;
+import com.ruoyi.cc.domain.CcGateways;
 import com.ruoyi.cc.service.ICcExtNumService;
+import com.ruoyi.cc.service.ICcGatewaysService;
 import com.ruoyi.common.utils.CacheUtils;
 import org.apache.shiro.SecurityUtils;
 import org.apache.shiro.authc.AuthenticationException;
@@ -24,6 +26,12 @@ import com.ruoyi.common.utils.ServletUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.framework.web.service.ConfigService;
 
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
 /**
  * 登录验证
  * 
@@ -54,6 +62,8 @@ public class SysLoginController extends BaseController
     private ConfigService configService;
     @Autowired
     private ICcExtNumService ccExtNumService;
+    @Autowired
+    private ICcGatewaysService ccGatewaysService;
 
     @GetMapping("/login")
     public String login(HttpServletRequest request, HttpServletResponse response, ModelMap mmap)
@@ -76,17 +86,42 @@ public class SysLoginController extends BaseController
 
     @PostMapping("/login")
     @ResponseBody
-    public AjaxResult ajaxLogin(String username, String password, String extNum, String groupId, Boolean rememberMe)
+    public AjaxResult ajaxLogin(String username, String password, String extNum, String groupId, String myGateway, Boolean rememberMe)
     {
         // 校验分机号
-        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCode(username);
+        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCodeTwo(username);
         if (null == ccExtNum) {
-            return AjaxResult.error("未绑定分机号,请联系系统管理员!");
-        }
-        if (!ccExtNum.getExtNum().toString().equals(extNum)) {
-            return AjaxResult.error("分机号不正确!");
+            if(StringUtils.isNotEmpty(username) && username.startsWith("Runtian_")){
+                //润天的有可能账号被覆盖,先保证使用
+            }else{
+                return AjaxResult.error("未绑定分机号,请联系系统管理员!");
+            }
+        }else{
+            if (!ccExtNum.getExtNum().toString().equals(extNum)) {
+                return AjaxResult.error("username="+username+"的分机号不正确!线上:"+ccExtNum.getExtNum()+",传入的分机号:" + extNum);
+            }
         }
         CacheUtils.put("groupId-" + username, groupId);
+        // cache myGateway for workbench dialing; only purposes 1/3 allowed
+        if (StringUtils.isNotEmpty(myGateway)) {
+            List<Long> gatewayIds = Arrays.stream(myGateway.split(","))
+                    .filter(StringUtils::isNotBlank)
+                    .map(String::trim)
+                    .map(Long::parseLong)
+                    .collect(Collectors.toList());
+            CcGateways query = new CcGateways();
+            query.setGatewayIds(gatewayIds);
+            Map<String, Object> params = new HashMap<>();
+            params.put("purposes", Arrays.asList(1, 3));
+            query.setParams(params);
+            List<CcGateways> allowed = ccGatewaysService.selectCcGatewaysList(query);
+            if (allowed == null || allowed.isEmpty() || allowed.size() != gatewayIds.size()) {
+                return AjaxResult.error("指定网关无效,仅可选择电话条/不限制用途的网关");
+            }
+            CacheUtils.put("myGateway-" + username, myGateway);
+        } else {
+            CacheUtils.remove("myGateway-" + username);
+        }
         UsernamePasswordToken token = new UsernamePasswordToken(username, password, rememberMe);
         Subject subject = SecurityUtils.getSubject();
         try

+ 3 - 0
ruoyi-admin/src/main/resources/mapper/cc/CcExtNumMapper.xml

@@ -121,6 +121,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="selectMaxExtNum" resultType="java.lang.Long">
         select max(ext_num) from cc_ext_num
     </select>
+    <select id="selectCcExtNumByUserCodeTwo" resultType="com.ruoyi.cc.domain.CcExtNum">
+        select * from cc_ext_num where user_code = #{username} limit 1
+    </select>
 
     <update id="companyBatchUnbindSipExt">
         update cc_ext_num set user_code = '' where ext_num in

+ 2 - 2
ruoyi-admin/src/main/resources/mapper/cc/CcGatewaysMapper.xml

@@ -45,8 +45,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="purpose != null "> and purpose = #{purpose}</if>
             <if test="params.purposes != null and !params.purposes.isEmpty()">
                 and purpose in
-                <foreach item="purpose" collection="params.purposes" open="(" separator="," close=")">
-                    #{purpose}
+                <foreach item="purposeItem" collection="params.purposes" open="(" separator="," close=")">
+                    #{purposeItem}
                 </foreach>
             </if>
             <if test="gatewayIds != null and !gatewayIds.isEmpty()">

+ 19 - 4
ruoyi-admin/src/main/resources/static/phone-bar/ccPhoneBarSocket.js

@@ -222,14 +222,29 @@ function ccPhoneBarSocket() {
 		}
 
 		console.log("callConfig:", this.callConfig);
-		wsuri = 'ws://' + this.callConfig.ipccServer +
+		/**
+		 * WebSocket 地址规则(兼容 company1/2/3… 多域名 + HIS HTTPS 嵌入):
+		 * - HTTPS 页:必须 wss;主机用当前页 location.host(含端口,如 :8899),
+		 *   由 Nginx 把 /call-center/ 反代到真实 IPCC 1081,避免 Mixed Content,也无需按域名写死。
+		 * - HTTP 页(本地联调):仍用 callConfig.ipccServer(一般为 host:1081)+ ws://。
+		 */
+		var isHttps = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:');
+		var useWss = isHttps || !!this.callConfig.enableWss;
+		var wsProto = useWss ? 'wss://' : 'ws://';
+		var wsHost;
+		if (isHttps && window.location.host) {
+			wsHost = window.location.host;
+		} else {
+			wsHost = this.callConfig.ipccServer;
+		}
+		wsuri = wsProto + wsHost +
 			'/call-center/websocketServer?' +
 			'&loginToken=' + this.callConfig.loginToken;
 
 		console.log("ipccServer ws url: " + wsuri);
-		var ipccServerIpAddr = this.callConfig.ipccServer.split(":");
-		if(this.callConfig.enableWss &&  this.checkIP(ipccServerIpAddr)){
-			var tipsError = i18n('phonebar.msg.error_wss_domain') + this.callConfig.ipccServer;
+		var ipccServerIpAddr = String(wsHost || '').split(":")[0];
+		if(useWss && this.checkIP(ipccServerIpAddr)){
+			var tipsError = i18n('phonebar.msg.error_wss_domain') + wsHost;
 			console.log(tipsError);
 			alert(tipsError);
 		}

+ 9 - 1
ruoyi-admin/src/main/resources/static/ruoyi/js/common.js

@@ -5,7 +5,15 @@
 
 var startLayDate;
 var endLayDate;
-var isScrollToTop = parent.isScrollToTop;
+var isScrollToTop = true;
+try {
+    // embed 到跨域父页时访问 parent 会抛 SecurityError,必须兜底
+    if (window.parent && window.parent !== window && typeof window.parent.isScrollToTop !== 'undefined') {
+        isScrollToTop = !!window.parent.isScrollToTop;
+    }
+} catch (e) {
+    isScrollToTop = true;
+}
 
 $(function() {
 	

+ 129 - 30
ruoyi-admin/src/main/resources/static/ruoyi/login.js

@@ -1,4 +1,3 @@
-
 $(function() {
     validateKickout();
     validateRule();
@@ -6,17 +5,48 @@ $(function() {
         var url = ctx + "captcha/captchaImage?type=" + captchaType + "&s=" + Math.random();
         $(".imgcode").attr("src", url);
     });
+    initEmbedLoginBridge();
 });
 
-function login() {
-    var username = $.common.trim($("input[name='username']").val());
-    var password = $.common.trim($("input[name='password']").val());
-    var extNum = $.common.trim($("input[name='extNum']").val());
-    var groupId = $.common.trim($("select[name='groupId']").val());
+/** 是否为 HIS iframe 嵌入登录(URL embed=1,或实际处于 iframe 中) */
+function isEmbedMode() {
+    if (getParam("embed") === "1") return true;
+    if (document.documentElement.className.indexOf("his-ipcc-embed") >= 0) return true;
+    try { return window.self !== window.top; } catch (e) { return true; }
+}
+
+/** 嵌入登录成功后进工作台(跳过 index 外壳) */
+function getLoginSuccessUrl() {
+    if (isEmbedMode()) {
+        return ctx + "system/main?embed=1";
+    }
+    return ctx + "index";
+}
+
+function login(extra) {
+    extra = extra || {};
+    var username = $.common.trim(extra.username != null ? extra.username : $("input[name='username']").val());
+    var password = $.common.trim(extra.password != null ? extra.password : $("input[name='password']").val());
+    var extNum = $.common.trim(extra.extNum != null ? extra.extNum : $("input[name='extNum']").val());
+    var groupId = $.common.trim(extra.groupId != null ? extra.groupId : ($("select[name='groupId']").val() || "1"));
+    var myGateway = "";
+    if (extra.myGateway != null) {
+        myGateway = $.common.trim(String(extra.myGateway));
+    } else if ($("input[name='useGateway']:checked").val() === "1") {
+        myGateway = $.common.trim($("#gatewaySelect").val() || $("input[name='myGateway']").val() || "");
+    } else {
+        myGateway = $.common.trim(getParam("myGateway") || "");
+    }
+    $("input[name='myGateway']").val(myGateway);
     var validateCode = $("input[name='validateCode']").val();
     var rememberMe = $("input[name='rememberme']").is(':checked');
-    if($.common.isEmpty(validateCode) && captchaEnabled) {
+    if ($.common.isEmpty(validateCode) && captchaEnabled) {
         $.modal.msg("请输入验证码");
+        postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: "请输入验证码" });
+        return false;
+    }
+    if ($("input[name='useGateway']:checked").val() === "1" && $.common.isEmpty(myGateway) && !isEmbedMode()) {
+        $.modal.msg("请选择外呼网关");
         return false;
     }
     $.ajax({
@@ -28,20 +58,45 @@ function login() {
             "extNum": extNum,
             "validateCode": validateCode,
             "rememberMe": rememberMe,
-            "groupId": groupId
+            "groupId": groupId,
+            "myGateway": myGateway
         },
+        xhrFields: { withCredentials: true },
         beforeSend: function () {
-            $.modal.loading($("#btnSubmit").data("loading"));
+            if (!isEmbedMode()) {
+                $.modal.loading($("#btnSubmit").data("loading"));
+            }
         },
         success: function(r) {
             if (r.code == web_status.SUCCESS) {
-                location.href = ctx + 'index';
+                var nextUrl = getLoginSuccessUrl();
+                // 先通知父页,再由【本 iframe 内】同源跳转工作台。
+                // 切勿让父页改 iframe.src(跨源会导致会话 Cookie 丢失 → 踢回登录死循环)
+                postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: true, nextUrl: nextUrl });
+                if (isEmbedMode()) {
+                    window.location.replace(nextUrl);
+                } else {
+                    window.location.href = nextUrl;
+                }
             } else {
+                window.__hisIpccLoginJumping = false;
                 $('.imgcode').click();
                 $(".code").val("");
-                $.modal.msg(r.msg);
+                if (!isEmbedMode()) {
+                    $.modal.msg(r.msg);
+                }
+                postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: r.msg || "登录失败" });
+            }
+            if (!isEmbedMode()) {
+                $.modal.closeLoading();
+            }
+        },
+        error: function(xhr, status, error) {
+            if (!isEmbedMode()) {
+                $.modal.closeLoading();
+                $.modal.msg(error || "登录异常");
             }
-            $.modal.closeLoading();
+            postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: error || "登录异常" });
         }
     });
 }
@@ -50,35 +105,29 @@ function validateRule() {
     var icon = "<i class='fa fa-times-circle'></i> ";
     $("#signupForm").validate({
         rules: {
-            username: {
-                required: true
-            },
-            password: {
-                required: true
-            }
+            username: { required: true },
+            password: { required: true }
         },
         messages: {
-            username: {
-                required: icon + "请输入您的用户名",
-            },
-            password: {
-                required: icon + "请输入您的密码",
-            }
+            username: { required: icon + "请输入您的用户名" },
+            password: { required: icon + "请输入您的密码" }
         },
-        submitHandler: function(form) {
+        submitHandler: function() {
             login();
         }
-    })
+    });
 }
 
 function validateKickout() {
     if (getParam("kickout") == 1) {
+        if (isEmbedMode()) {
+            postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: "您已在别处登录" });
+            return;
+        }
         layer.alert("<font color='red'>您已在别处登录,请您修改密码或重新登录</font>", {
             icon: 0,
             title: "系统提示"
-        },
-        function(index) {
-            //关闭弹窗
+        }, function(index) {
             layer.close(index);
             if (top != self) {
                 top.location = self.location;
@@ -99,4 +148,54 @@ function getParam(paramName) {
     var r = window.location.search.substr(1).match(reg);
     if (r != null) return decodeURI(r[2]);
     return null;
-}
+}
+
+/** 与 main.html / HIS 约定:{ channel, type, payload } */
+function postEmbedToParent(type, payload) {
+    if (!isEmbedMode() || !window.parent || window.parent === window) return;
+    try {
+        window.parent.postMessage({
+            channel: "HIS_IPCC_EMBED",
+            type: type,
+            payload: payload || {}
+        }, "*");
+    } catch (e) {}
+}
+
+function initEmbedLoginBridge() {
+    if (!isEmbedMode()) return;
+    // 防重复绑定
+    if (window.__hisIpccLoginBridgeBound) {
+        postEmbedToParent("HIS_IPCC_LOGIN_READY", {});
+        return;
+    }
+    window.__hisIpccLoginBridgeBound = true;
+    postEmbedToParent("HIS_IPCC_LOGIN_READY", {});
+    window.addEventListener("message", function(event) {
+        var data = event.data;
+        if (!data || data.channel !== "HIS_IPCC_EMBED") return;
+        if (data.type === "HIS_IPCC_LOGIN") {
+            var p = data.payload || {};
+            if (!p.username || !p.password || !p.extNum) {
+                postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: "登录参数不完整" });
+                return;
+            }
+            // 正在跳转工作台时忽略重复 LOGIN,避免死循环
+            if (window.__hisIpccLoginJumping) return;
+            window.__hisIpccLoginJumping = true;
+            login({
+                username: p.username,
+                password: p.password,
+                extNum: String(p.extNum),
+                groupId: p.groupId || "1",
+                myGateway: p.myGateway != null ? String(p.myGateway) : ""
+            });
+            // 登录失败时允许再次尝试
+            setTimeout(function () {
+                if (/\/login/i.test(window.location.pathname || "")) {
+                    window.__hisIpccLoginJumping = false;
+                }
+            }, 3000);
+        }
+    });
+}

+ 2 - 2
ruoyi-admin/src/main/resources/templates/index-topnav.html

@@ -45,7 +45,7 @@
 				<a class="menuItem noactive" title="个人中心" th:href="@{/system/user/profile}">
 					<div class="hide" th:text="个人中心1"></div>
 					<div class="pull-left image">
-						<img th:src="(${#strings.isEmpty(user.avatar)}) ? @{/img/profile.png} : @{${user.avatar}}" th:onerror="this.src='img/profile.png'" class="img-circle" alt="User Image">
+						<img th:src="${#strings.isEmpty(user.avatar) ? '/img/profile.png' : ((isLocalDebug == true and (#request.serverName == '127.0.0.1' or #request.serverName == 'localhost')) ? ('http://' + localRemoteHost + user.avatar) : user.avatar)}" th:onerror="this.src='img/profile.png'" class="img-circle" alt="User Image">
 					</div>
 				</a>
 				<div class="pull-left info">
@@ -265,7 +265,7 @@
                 
                     <li class="dropdown user-menu">
 						<a href="javascript:void(0)" class="dropdown-toggle" data-hover="dropdown">
-							<img th:src="(${#strings.isEmpty(user.avatar)}) ? @{/img/profile.png} : @{${user.avatar}}" th:onerror="this.src='img/profile.png'" class="user-image">
+							<img th:src="${#strings.isEmpty(user.avatar) ? '/img/profile.png' : ((isLocalDebug == true and (#request.serverName == '127.0.0.1' or #request.serverName == 'localhost')) ? ('http://' + localRemoteHost + user.avatar) : user.avatar)}" th:onerror="this.src='img/profile.png'" class="user-image">
 							<span class="hidden-xs">[[${#strings.defaultString(user.userName, '-')}]]</span>
 						</a>
 						<ul class="dropdown-menu">

+ 1 - 1
ruoyi-admin/src/main/resources/templates/index.html

@@ -214,7 +214,7 @@
 					<li><a data-toggle="tooltip" data-trigger="hover" data-placement="bottom" th:text="#{top.fullScreen.text}" href="javascript:;" id="fullScreen"><i class="fa fa-arrows-alt"></i><span th:title="#{top.fullScreen.title}" /></a></li>
                     <li class="dropdown user-menu">
 						<a href="javascript:void(0)" class="dropdown-toggle" data-hover="dropdown">
-							<img th:src="(${#strings.isEmpty(user.avatar)}) ? @{/img/profile.png} : @{${user.avatar}}" th:onerror="this.src='img/profile.png'" class="user-image">
+							<img th:src="${#strings.isEmpty(user.avatar) ? '/img/profile.png' : ((isLocalDebug == true and (#request.serverName == '127.0.0.1' or #request.serverName == 'localhost')) ? ('http://' + localRemoteHost + user.avatar) : user.avatar)}" th:onerror="this.src='img/profile.png'" class="user-image">
 							<span class="hidden-xs">[[${#strings.defaultString(user.userName, '-')}]]</span>
 						</a>
 						<ul class="dropdown-menu">

+ 366 - 24
ruoyi-admin/src/main/resources/templates/login.html

@@ -16,6 +16,8 @@
     <link href="../static/css/login.min.css" th:href="@{/css/login.min.css}" rel="stylesheet"/>
     <link href="../static/css/theme-modern.css" th:href="@{/css/theme-modern.css?v=20260524}" rel="stylesheet"/>
     <link href="../static/ruoyi/css/ry-ui.css" th:href="@{/ruoyi/css/ry-ui.css?v=4.7.10}" rel="stylesheet"/>
+    <link th:href="@{/ajax/libs/select2/select2.min.css?v=4.0.13}" rel="stylesheet"/>
+    <link th:href="@{/ajax/libs/select2/select2-bootstrap.min.css?v=4.0.13}" rel="stylesheet"/>
     <!-- 360浏览器急速模式 -->
     <meta name="renderer" content="webkit">
     <!-- 避免IE使用兼容模式 -->
@@ -23,8 +25,41 @@
     <link rel="shortcut icon" href="../static/favicon.ico" th:href="@{favicon.ico}"/>
     <style type="text/css">label.error { position:inherit;  }</style>
     <script>
-        if(window.top!==window.self){alert('未登录或登录超时。请重新登录');window.top.location=window.location};
+        (function () {
+            var inFrame = false;
+            try { inFrame = window.self !== window.top; } catch (e) { inFrame = true; }
+            var embed = /(?:^|[?&])embed=1(?:&|$)/.test(window.location.search || '');
+            // 只要在 iframe 里就按 HIS 嵌入处理(防止 Shiro 踢回 /login 时丢掉 embed=1)
+            if (inFrame || embed) {
+                try {
+                    document.documentElement.className += ' his-ipcc-embed';
+                } catch (e) {}
+                try { window.alert = function () {}; } catch (e) {}
+                // URL 丢了 embed 时补回,避免后续语言脚本按非嵌入逻辑整页跳
+                if (inFrame && !embed) {
+                    try {
+                        var p = new URLSearchParams(window.location.search || '');
+                        p.set('embed', '1');
+                        if (!p.get('lang')) {
+                            p.set('lang', localStorage.getItem('lang') || 'zh_CN');
+                        }
+                        window.history.replaceState({}, document.title, window.location.pathname + '?' + p.toString());
+                    } catch (e) {}
+                }
+                return;
+            }
+            // 仅非 iframe 的嵌套页才尝试顶层跳转(正常单页登录不会进这里)
+            if (window.top !== window.self) {
+                try { window.top.location = window.location; } catch (e) {}
+            }
+        })();
     </script>
+    <style>
+        html.his-ipcc-embed body.signin { background: transparent !important; }
+        html.his-ipcc-embed .signinpanel,
+        html.his-ipcc-embed .language-selector,
+        html.his-ipcc-embed .signup-footer { display: none !important; }
+    </style>
 
     <script th:inline="javascript">
         // //获取国际化翻译值
@@ -94,6 +129,147 @@
         #groupId option:first-child {
             color: #999999;              /* 灰色提示文字 */
         }
+
+        /* 外呼网关:与登录表单统一的分段切换 */
+        .gateway-field {
+            margin-top: 14px;
+        }
+        .gateway-field-label {
+            display: block;
+            margin: 0 0 8px;
+            color: #6b7280;
+            font-size: 13px;
+            font-weight: 500;
+            letter-spacing: 0.02em;
+        }
+        .gateway-seg {
+            display: flex;
+            padding: 4px;
+            border: 1px solid #dbe1ee;
+            border-radius: 12px;
+            background: #f3f5fa;
+            gap: 4px;
+        }
+        .gateway-seg label {
+            flex: 1;
+            position: relative;
+            margin: 0;
+            cursor: pointer;
+            font-weight: 500;
+        }
+        .gateway-seg input[type="radio"] {
+            position: absolute;
+            opacity: 0;
+            pointer-events: none;
+        }
+        .gateway-seg-item {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            gap: 6px;
+            height: 36px;
+            border-radius: 9px;
+            color: #6b7280;
+            font-size: 13px;
+            line-height: 1;
+            transition: background .18s ease, color .18s ease, box-shadow .18s ease;
+            user-select: none;
+        }
+        .gateway-seg-item i {
+            font-size: 13px;
+            opacity: .85;
+        }
+        .gateway-seg input[type="radio"]:checked + .gateway-seg-item {
+            background: #fff;
+            color: #1d6fe8;
+            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
+            font-weight: 600;
+        }
+        .gateway-seg label:hover .gateway-seg-item {
+            color: #374151;
+        }
+        .gateway-seg input[type="radio"]:checked + .gateway-seg-item:hover {
+            color: #1d6fe8;
+        }
+        #gatewaySelectWrap {
+            margin-top: 10px;
+            display: none;
+            animation: gwFadeIn .2s ease;
+        }
+        @keyframes gwFadeIn {
+            from { opacity: 0; transform: translateY(-4px); }
+            to { opacity: 1; transform: translateY(0); }
+        }
+        #gatewaySelectWrap .select2-container {
+            width: 100% !important;
+        }
+        #gatewaySelectWrap .select2-selection--single {
+            height: 46px !important;
+            border: 1px solid #dbe1ee !important;
+            border-radius: 12px !important;
+            background-color: #fff !important;
+            box-shadow: none !important;
+        }
+        #gatewaySelectWrap .select2-selection--single:focus,
+        #gatewaySelectWrap .select2-container--open .select2-selection--single {
+            border-color: #1d6fe8 !important;
+        }
+        #gatewaySelectWrap .select2-selection__rendered {
+            line-height: 44px !important;
+            color: #111827 !important;
+            padding-left: 16px !important;
+            padding-right: 48px !important;
+            font-size: 14px;
+        }
+        #gatewaySelectWrap .select2-selection__placeholder {
+            color: #9ca3af !important;
+        }
+        #gatewaySelectWrap .select2-selection__arrow {
+            height: 44px !important;
+            width: 28px !important;
+            right: 8px !important;
+        }
+        #gatewaySelectWrap .select2-selection__clear {
+            margin-right: 8px;
+            font-size: 16px;
+            color: #9ca3af;
+        }
+        .select2-container--bootstrap .select2-dropdown {
+            border: 1px solid #dbe1ee !important;
+            border-radius: 12px !important;
+            box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1) !important;
+            overflow: hidden;
+            margin-top: 4px;
+        }
+        .select2-container--bootstrap .select2-results__option {
+            padding: 10px 14px;
+            font-size: 13px;
+        }
+        .select2-container--bootstrap .select2-results__option--highlighted {
+            background: #eef4ff !important;
+            color: #1d6fe8 !important;
+        }
+        .select2-gw-option {
+            display: flex;
+            flex-direction: column;
+            gap: 2px;
+            line-height: 1.3;
+        }
+        .select2-gw-option .gw-title {
+            color: #111827;
+            font-size: 13px;
+            font-weight: 500;
+        }
+        .select2-gw-option .gw-sub {
+            color: #9ca3af;
+            font-size: 12px;
+        }
+        .select2-results__option--highlighted .select2-gw-option .gw-title {
+            color: #1d6fe8;
+        }
+        .select2-results__option--highlighted .select2-gw-option .gw-sub {
+            color: #6b9ef5;
+        }
     </style>
 </head>
 <body class="signin">
@@ -116,6 +292,25 @@
                     <input type="text"     name="username" class="form-control uname"     th:attr="placeholder=#{user.login.username}"    />
                     <input type="password" name="password" class="form-control pword"     th:attr="placeholder=#{user.login.password}" />
                     <input type="text"     name="extNum" class="form-control uname"     th:attr="placeholder=#{user.login.extnum}"    />
+                    <input type="hidden"   name="myGateway" id="myGateway" value="" />
+                    <div class="gateway-field">
+                        <span class="gateway-field-label">外呼网关</span>
+                        <div class="gateway-seg" role="radiogroup" aria-label="外呼网关">
+                            <label>
+                                <input type="radio" name="useGateway" value="0" checked="checked" />
+                                <span class="gateway-seg-item"><i class="fa fa-random"></i>自动分配</span>
+                            </label>
+                            <label>
+                                <input type="radio" name="useGateway" value="1" />
+                                <span class="gateway-seg-item"><i class="fa fa-sitemap"></i>指定网关</span>
+                            </label>
+                        </div>
+                        <div id="gatewaySelectWrap">
+                            <select id="gatewaySelect" class="form-control" style="width:100%;">
+                                <option value=""></option>
+                            </select>
+                        </div>
+                    </div>
                     <select th:if="${showDynamicGroupid==true}" name="groupId" id="groupId" class="form-control m-t">
 
                     </select>
@@ -148,7 +343,17 @@
     var captchaEnabled = [[${captchaEnabled}]];
     var sysVersion = [[${sysVersion}]];
     var showDynamicGroupid = [[${showDynamicGroupid}]];
-
+</script>
+<!--[if lte IE 8]><script>window.location.href=ctx+'html/ie.html';</script><![endif]-->
+<!-- 全局js -->
+<script src="../static/js/jquery.min.js" th:src="@{/js/jquery.min.js}"></script>
+<script src="../static/ajax/libs/validate/jquery.validate.min.js" th:src="@{/ajax/libs/validate/jquery.validate.min.js}"></script>
+<script src="../static/ajax/libs/layer/layer.min.js" th:src="@{/ajax/libs/layer/layer.min.js}"></script>
+<script src="../static/ajax/libs/blockUI/jquery.blockUI.js" th:src="@{/ajax/libs/blockUI/jquery.blockUI.js}"></script>
+<script th:src="@{/ajax/libs/select2/select2.min.js?v=4.0.13}"></script>
+<script src="../static/ruoyi/js/ry-ui.js" th:src="@{/ruoyi/js/ry-ui.js?v=4.7.11}"></script>
+<script src="../static/ruoyi/login.js" th:src="@{/ruoyi/login.js}"></script>
+<script th:inline="javascript">
     // 加载业务组列表
     function loadBusiGroups() {
         if (!showDynamicGroupid) return;
@@ -178,54 +383,191 @@
         });
     }
 
-    // 页面加载完成后初始化
+    function syncMyGateway() {
+        var useGateway = $('input[name="useGateway"]:checked').val() === '1';
+        if (!useGateway) {
+            $('#myGateway').val('');
+            return;
+        }
+        var gw = $('#gatewaySelect').val() || '';
+        $('#myGateway').val(gw);
+    }
+
+    function toggleGatewaySelect() {
+        var useGateway = $('input[name="useGateway"]:checked').val() === '1';
+        if (useGateway) {
+            $('#gatewaySelectWrap').show();
+            // 指定网关时优先默认选中 gwName=outbound
+            if (!$('#gatewaySelect').val()) {
+                var $outbound = $('#gatewaySelect option').filter(function() {
+                    return $(this).attr('data-gwname') === 'outbound';
+                }).first();
+                if ($outbound.length) {
+                    $('#gatewaySelect').val($outbound.val()).trigger('change');
+                }
+            }
+        } else {
+            $('#gatewaySelectWrap').hide();
+            $('#gatewaySelect').val(null).trigger('change');
+        }
+        syncMyGateway();
+    }
+
+    function formatGatewayOption(opt) {
+        if (!opt.id) {
+            return opt.text;
+        }
+        var $el = $(opt.element);
+        var title = $el.data('title') || opt.text;
+        var sub = $el.data('sub') || '';
+        if (!sub) {
+            return $('<span class="select2-gw-option"><span class="gw-title"></span></span>')
+                .find('.gw-title').text(title).end();
+        }
+        return $(
+            '<span class="select2-gw-option">' +
+                '<span class="gw-title"></span>' +
+                '<span class="gw-sub"></span>' +
+            '</span>'
+        ).find('.gw-title').text(title).end()
+         .find('.gw-sub').text(sub).end();
+    }
+
+    function formatGatewaySelection(opt) {
+        if (!opt.id) {
+            return opt.text;
+        }
+        var $el = $(opt.element);
+        var title = $el.data('title') || opt.text;
+        var sub = $el.data('sub') || '';
+        return sub ? (title + ' · ' + sub) : title;
+    }
+
+    function initGatewaySelect() {
+        $('#gatewaySelect').select2({
+            theme: 'bootstrap',
+            placeholder: '请选择外呼网关',
+            allowClear: true,
+            width: '100%',
+            dropdownParent: $('.signinpanel form'),
+            templateResult: formatGatewayOption,
+            templateSelection: formatGatewaySelection,
+            language: {
+                noResults: function() { return '无匹配网关'; },
+                searching: function() { return '搜索中…'; }
+            }
+        });
+        $('#gatewaySelect').on('change', syncMyGateway);
+        $('input[name="useGateway"]').on('change', toggleGatewaySelect);
+    }
+
+    function loadGateways() {
+        $.ajax({
+            url: ctx + 'aicall/api/gateway/phonebarList',
+            type: 'GET',
+            dataType: 'json',
+            success: function(response) {
+                var $select = $('#gatewaySelect');
+                $select.empty().append('<option value=""></option>');
+                if (response && response.code === 0 && response.data && response.data.length > 0) {
+                    $.each(response.data, function(index, item) {
+                        // only purposes 1(phonebar) / 3(unlimited), exclude 2
+                        var purpose = item.purpose;
+                        if (purpose != 1 && purpose != 3) {
+                            return;
+                        }
+                        var id = item.id != null ? String(item.id) : '';
+                        var gwName = item.gwName || '';
+                        var gwDesc = item.gwDesc || '';
+                        var title = gwDesc || gwName || id;
+                        var sub = (gwName && gwDesc && gwDesc !== gwName) ? gwName : '';
+                        var text = sub ? (title + ' (' + sub + ')') : title;
+                        $select.append(
+                            $('<option></option>')
+                                .attr('value', id)
+                                .attr('data-gwname', gwName)
+                                .attr('data-title', title)
+                                .attr('data-sub', sub)
+                                .text(text)
+                        );
+                    });
+                }
+                initGatewaySelect();
+                toggleGatewaySelect();
+            },
+            error: function(xhr, status, error) {
+                console.error('请求网关列表失败:', error);
+                initGatewaySelect();
+                toggleGatewaySelect();
+            }
+        });
+    }
+
     $(document).ready(function() {
         loadBusiGroups();
+        loadGateways();
     });
-
 </script>
-<!--[if lte IE 8]><script>window.location.href=ctx+'html/ie.html';</script><![endif]-->
-<!-- 全局js -->
-<script src="../static/js/jquery.min.js" th:src="@{/js/jquery.min.js}"></script>
-<script src="../static/ajax/libs/validate/jquery.validate.min.js" th:src="@{/ajax/libs/validate/jquery.validate.min.js}"></script>
-<script src="../static/ajax/libs/layer/layer.min.js" th:src="@{/ajax/libs/layer/layer.min.js}"></script>
-<script src="../static/ajax/libs/blockUI/jquery.blockUI.js" th:src="@{/ajax/libs/blockUI/jquery.blockUI.js}"></script>
-<script src="../static/ruoyi/js/ry-ui.js" th:src="@{/ruoyi/js/ry-ui.js?v=4.7.11}"></script>
-<script src="../static/ruoyi/login.js" th:src="@{/ruoyi/login.js}"></script>
 <script th:inline="javascript">
     function setLanguage(lang) {
         console.log('设置语言:' + lang)
-        if (lang) {
-            localStorage.setItem('lang', lang); // 存储语言到本地存储
-            window.location.href = window.location.pathname + "?lang=" + lang; // 通过 URL 参数切换语言
+        if (!lang) return;
+        localStorage.setItem('lang', lang);
+        var inFrame = false;
+        try { inFrame = window.self !== window.top; } catch (e) { inFrame = true; }
+        var params = new URLSearchParams(window.location.search || '');
+        params.set('lang', lang);
+        if (inFrame ||
+            /(?:^|[?&])embed=1(?:&|$)/.test(window.location.search || '') ||
+            document.documentElement.className.indexOf('his-ipcc-embed') >= 0) {
+            params.set('embed', '1');
         }
+        // iframe 内只用 replaceState,避免整页刷新丢掉登录桥 / 触发顶层跳转
+        if (inFrame) {
+            try {
+                window.history.replaceState({}, document.title, window.location.pathname + '?' + params.toString());
+            } catch (e) {
+                window.location.href = window.location.pathname + '?' + params.toString();
+            }
+            return;
+        }
+        window.location.href = window.location.pathname + '?' + params.toString();
     }
 
     document.querySelectorAll('.lang-option').forEach(link => {
         link.addEventListener('click', function(event) {
-            event.preventDefault(); // 阻止默认行为
+            event.preventDefault();
             setLanguage(this.getAttribute('data-lang'));
         });
     });
 
     document.addEventListener('DOMContentLoaded', function() {
-        // 从 URL 参数获取语言设置
         var lang = new URLSearchParams(window.location.search).get('lang');
-        // 如果 URL 参数中没有语言设置,则从本地存储获取
         if (!lang) {
             lang = localStorage.getItem('lang');
         }
-        // 如果本地存储中也没有语言设置,则从浏览器设置获取
         if (!lang) {
-            lang = navigator.language || 'en_US'; // 默认值为 'en_US'
+            lang = navigator.language || 'en_US';
         }
         console.log('当前语言:' + lang);
-        localStorage.setItem('lang', lang); // 更新本地存储的语言设置
+        localStorage.setItem('lang', lang);
 
-        // 如果 URL 中没有 lang 参数,且本地存储中有 lang 设置,则更新 URL 参数
+        var inFrame = false;
+        try { inFrame = window.self !== window.top; } catch (e) { inFrame = true; }
+        var isEmbed = inFrame ||
+            /(?:^|[?&])embed=1(?:&|$)/.test(window.location.search || '') ||
+            document.documentElement.className.indexOf('his-ipcc-embed') >= 0;
         if (lang && !new URLSearchParams(window.location.search).has('lang')) {
-            setLanguage(lang);
-            // window.history.replaceState({}, document.title, window.location.pathname + "?lang=" + lang);
+            if (isEmbed) {
+                var p = new URLSearchParams(window.location.search || '');
+                p.set('lang', lang);
+                p.set('embed', '1');
+                try {
+                    window.history.replaceState({}, document.title, window.location.pathname + '?' + p.toString());
+                } catch (e) {}
+            } else {
+                setLanguage(lang);
+            }
         }
     });
 

+ 359 - 72
ruoyi-admin/src/main/resources/templates/main.html

@@ -16,7 +16,7 @@
     <link href="../static/css/style.min.css" th:href="@{/css/style.min.css}" rel="stylesheet"/>
     <link href="../static/css/theme-modern.css" th:href="@{/css/theme-modern.css?v=20260524}" rel="stylesheet"/>
     <link rel="stylesheet" th:href="@{/phone-bar/page.css?v=1.0.4}"></link>
-    <script th:src="@{/phone-bar/ccPhoneBarSocket.js?v=1.0.4}"></script>
+    <script th:src="@{/phone-bar/ccPhoneBarSocket.js?v=1.0.5}"></script>
     <script>
         //所有的列表页默认显示10条记录;
         localStorage["bsTablePageSize"] = 10;
@@ -89,13 +89,74 @@
         #popup-container iframe::-webkit-scrollbar-thumb:hover {
             background: #a8a8a8;
         }
+
+        /* HIS 嵌入模式:隐藏可视 UI,仅保留 PhoneBar 连接 */
+        html.his-ipcc-embed body,
+        html.his-ipcc-embed .wrapper,
+        html.his-ipcc-embed #ccPhoneBar,
+        html.his-ipcc-embed .gray-bg {
+            background: transparent !important;
+        }
+        html.his-ipcc-embed .wrapper,
+        html.his-ipcc-embed form,
+        html.his-ipcc-embed #work-container,
+        html.his-ipcc-embed .ibox,
+        html.his-ipcc-embed .phonebar-box {
+            display: none !important;
+            visibility: hidden !important;
+            height: 0 !important;
+            overflow: hidden !important;
+        }
     </style>
+    <script th:inline="javascript">
+        var _hisEmbedMode = /*[[${embedMode}]]*/ false;
+        if (_hisEmbedMode) {
+            try { document.documentElement.className += ' his-ipcc-embed'; } catch (e) {}
+            // 跨域嵌入:禁止访问 top/parent.document,避免 SecurityError 打断外呼
+            window.isScrollToTop = true;
+            (function () {
+                function safeNoop() {}
+                function patchModal() {
+                    if (!window.$ || !$.modal) return false;
+                    $.modal.loading = safeNoop;
+                    $.modal.closeLoading = safeNoop;
+                    $.modal.disable = safeNoop;
+                    $.modal.enable = safeNoop;
+                    var _msg = $.modal.msg;
+                    $.modal.msg = function () { try { console.debug.apply(console, arguments); } catch (e) {} };
+                    var _alert = $.modal.alert;
+                    $.modal.alert = function (m) { try { console.warn('[embed]', m); } catch (e) {} };
+                    var _msgSuccess = $.modal.msgSuccess;
+                    $.modal.msgSuccess = safeNoop;
+                    var _msgError = $.modal.msgError;
+                    $.modal.msgError = function (m) { try { console.warn('[embed]', m); } catch (e) {} };
+                    return true;
+                }
+                if (!patchModal()) {
+                    document.addEventListener('DOMContentLoaded', function () { patchModal(); });
+                    setTimeout(patchModal, 0);
+                    setTimeout(patchModal, 500);
+                }
+            })();
+        }
+    </script>
 </head>
 
 <body class="gray-bg">
 
+<th:block th:if="${embedMode}">
+<!-- HIS embed:仅加载 IPCC PhoneBar 必要依赖,跳过表格/校验等业务脚本;普通打开工作台不受影响 -->
+<script th:inline="javascript"> var ctx = [[@{/}]]; </script>
+<script th:src="@{/js/bootstrap.min.js?v=3.3.7}"></script>
+<script th:src="@{/ajax/libs/blockUI/jquery.blockUI.js?v=2.70.0}"></script>
+<script th:src="@{/ajax/libs/layer/layer.min.js?v=3.7.0}"></script>
+<script th:src="@{/ruoyi/js/common.js?v=4.7.11}"></script>
+<script th:src="@{/ruoyi/js/ry-ui.js?v=4.7.11}"></script>
+</th:block>
+<th:block th:unless="${embedMode}">
 <th:block th:include="include :: footer" />
 <th:block th:include="include :: layout-latest-js" />
+</th:block>
 
 <form>
     <!-- 首页/工作台 -->
@@ -331,11 +392,20 @@
 <script th:inline="javascript">
     var _phoneBar = new ccPhoneBarSocket();
     var _scriptServer = window.location.host.split(":")[0];
+    // only when ruoyi.isLocalDebug=true
+    var _isLocalDebug = /*[[${isLocalDebug}]]*/ false;
+    var _localRemoteHost = /*[[${localRemoteHost}]]*/ '129.28.164.235';
+    if (_isLocalDebug && (_scriptServer === '127.0.0.1' || _scriptServer === 'localhost')) {
+        _scriptServer = _localRemoteHost || '129.28.164.235';
+    }
     // var _scriptServer = [[${callConfig.scriptServer}]];
     var _scriptPort = [[${callConfig.scriptPort}]];
     var _loginToken = [[${callConfig.loginToken}]];
     var _gatewayList = [[${callConfig.gatewayList}]];
     var _autoFree = [[${autoFree}]];
+    var _embedExtNum = /*[[${callConfig.extNum}]]*/ '';
+    var _embedMyGateway = /*[[${callConfig.myGateway}]]*/ '';
+    var _ctxPath = /*[[@{/}]]*/ '/';
 
     var _phoneNum = '';
     var _uuid = '';
@@ -346,10 +416,37 @@
     var reConnTimes = 0;
 
     var _callConfig = null;
+
+    /** 向 HIS 父页回传 IPCC 状态(embed 模式) */
+    function hisIpccPost(type, payload) {
+        if (!_hisEmbedMode || !window.parent || window.parent === window) return;
+        try {
+            window.parent.postMessage({
+                channel: 'HIS_IPCC_EMBED',
+                type: type,
+                payload: payload || {}
+            }, '*');
+        } catch (e) {}
+    }
+
+    function hisIpccForwardEvent(code, msg) {
+        hisIpccPost('EVENT', {
+            code: String(code),
+            msg: msg || {},
+            connected: !!( _phoneBar && typeof _phoneBar.getIsConnected === 'function' && _phoneBar.getIsConnected()),
+            callConnected: !!( _phoneBar && typeof _phoneBar.getCallConnected === 'function' && _phoneBar.getCallConnected()),
+            activeCallUuid: (_phoneBar && typeof _phoneBar.getActiveCallUuid === 'function') ? (_phoneBar.getActiveCallUuid() || '') : '',
+            isCalling: !!( _phoneBar && _phoneBar.isCalling),
+            agentStatus: (_phoneBar && _phoneBar.agentStatus != null) ? _phoneBar.agentStatus : null,
+            myGateway: _embedMyGateway || ''
+        });
+    }
     $(function() {
-        // 调用函数填充视频清晰度的下拉列表
-        populateVideoLevelDropdown('videoLevelSelect');
-        populateVideoLevelDropdown('member_video_level');
+        // 普通工作台才初始化视频下拉;HIS embed 仅要 IPCC 信令,跳过无关 UI
+        if (!_hisEmbedMode) {
+            populateVideoLevelDropdown('videoLevelSelect');
+            populateVideoLevelDropdown('member_video_level');
+        }
 
         // websocket通信对象断开事件;
         _phoneBar.on(ccPhoneBarSocket.eventListWithTextInfo.ws_disconnected.code, function(msg){
@@ -373,7 +470,9 @@
         _phoneBar.on(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code, function(msg){
             console.log(msg, "用户已在其他设备登录");
             _phoneBar.updatePhoneBar(msg, ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code);
-            $.modal.msgWarning(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.msg);
+            if (!_hisEmbedMode) {
+                $.modal.msgWarning(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.msg);
+            }
             manualDisConn = true; // 被挤掉线的也不自动重连
         });
 
@@ -385,16 +484,16 @@
         });
 
         _phoneBar.on(ccPhoneBarSocket.eventList.caller_not_login, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
         _phoneBar.on(ccPhoneBarSocket.eventList.outbound_finished, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
         _phoneBar.on(ccPhoneBarSocket.eventList.server_error_audio_codec_not_match, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
         _phoneBar.on(ccPhoneBarSocket.eventList.server_error, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
 
         // 被叫振铃事件
@@ -406,11 +505,12 @@
         // 呼入事件
         _phoneBar.on(ccPhoneBarSocket.eventList.new_inbound_call, function(msg){
             console.log(msg, "呼入" );
-
-            // 呼入弹屏
             _phoneNum = msg.object.caller;
             _uuid = msg.object.uuid;
-            addCallRecord(_phoneNum, _uuid, 1);
+            // HIS embed:弹屏/CRM 由父页处理,避免多余 iframe/接口
+            if (!_hisEmbedMode) {
+                addCallRecord(_phoneNum, _uuid, 1);
+            }
         });
 
         // 主叫接通事件
@@ -418,20 +518,16 @@
             console.log(msg, "主叫接通" );
             $("#agentStatus").text("通话中");
             _phoneBar.updatePhoneBar(msg, ccPhoneBarSocket.eventListWithTextInfo.caller_answered.code);
-            // 外呼弹屏
-            // var _phoneNum = $("#ccphoneNumber").val();
-            // var _uuid = msg.object.uuid;
-            // addCallRecord(_phoneNum, _uuid, 2);
-
         });
 
         // 外呼事件
         _phoneBar.on(ccPhoneBarSocket.eventList.OUTBOUND_START, function (msg) {
             console.log('outbound_start',msg);
-            // 外呼弹屏
             _phoneNum = msg.object.destPhone;
             _uuid = msg.object.uuid;
-            addCallRecord(_phoneNum, _uuid, 2);
+            if (!_hisEmbedMode) {
+                addCallRecord(_phoneNum, _uuid, 2);
+            }
         });
 
 
@@ -443,8 +539,10 @@
             $("#sendVideoFileBtn").attr("disabled","disabled");
             $("#transfer_area").hide();
             _phoneBar.updatePhoneBar(msg, ccPhoneBarSocket.eventListWithTextInfo.caller_hangup.code);
-
-            addAsrRecord(_phoneNum, _uuid, _call_type, _asrResult);
+            // HIS embed:话单/ASR 由父页 sipCallCdrSync 负责
+            if (!_hisEmbedMode) {
+                addAsrRecord(_phoneNum, _uuid, _call_type, _asrResult);
+            }
         });
 
         // 被叫接通事件
@@ -630,9 +728,10 @@
             onTransferToConferenceSuccess(msg);
         });
 
-        // 订阅的坐席状态列表发生改变
+        // 订阅的坐席状态列表发生改变(转接 UI 仅普通工作台)
         _phoneBar.on(ccPhoneBarSocket.eventList.agent_status_data_changed, function (msg) {
             console.log("agent_status_data_changed.");
+            if (_hisEmbedMode) return;
             // 当 transfer_to_groupId 值改变时更新 transfer_to_member
             $(transferToGroupId).off("change");
             $(transferToGroupId).on("change", function () {
@@ -643,7 +742,7 @@
 
         // 电话工具条参数配置;
         _callConfig = {
-            'useDefaultUi' : true,
+            'useDefaultUi' : !_hisEmbedMode,
             // loginToken 信息是加密的字符串, 包含以下字段信息:extnum[分机号]、opnum[工号]、groupId[业务组]、skillLevel[技能等级]
             'loginToken': _loginToken,
 
@@ -654,30 +753,6 @@
             // 注意在注册模式下,网关参数更改之后,必须重启语音服务 [docker restart freeswitch] 方可生效,不支持热更新;
             // 支持多个网关同时使用,按照优先级依次使用, 支持网关负载容错溢出 [第一条网关外呼出错后,自动使用第二条网关重试,直至外呼不出错] ;
             'gatewayList' : _gatewayList,
-            // 'gatewayList': [
-            //     {
-            //         uuid: '01',   // 网关唯一编号;
-            //         updateTime: 1712611571863,  //网关信息更新时间, 每次修改必须更新这个字段,否则配置无法生效;
-            //         gatewayAddr: '192.168.67.210:5090',  // 网关地址或名称,  如果是注册模式: 网关地址参数则填写为网关名称;
-            //         callerNumber: '64901409',                 //主叫号码
-            //         calleePrefix: '',                    // 被叫前缀
-            //         priority: 2,                         //优先级,数字越小,越优先被使用;
-            //         concurrency: 20,                    // 网关并发数,同时支持呼叫数;
-            //         register: false,                     // 是否为注册模式
-            //         audioCodec: 'g711'                   // 网关通信语音编码;
-            //     } ,
-            //     {
-            //         uuid: '02',   // 网关唯一编号;
-            //         updateTime: 1712611571863,  //网关信息更新时间, 每次修改必须更新这个字段,否则配置无法生效;
-            //         gatewayAddr: '192.168.67.210:5092',  // 网关地址或名称,  如果是注册模式: 网关地址参数则填写为网关名称;
-            //         callerNumber: '64901408',                 //主叫号码
-            //         calleePrefix: '',                    // 被叫前缀
-            //         priority: 1,                         //优先级,数字越小,越优先被使用;
-            //         concurrency: 20,                    // 网关并发数,同时支持呼叫数;
-            //         register: false,                     // 是否为注册模式
-            //         audioCodec: 'g711'                   // 网关通信语音编码;
-            //     }
-            // ],
 
             // 网关列表信息是否为加密模式;
             'gatewayEncrypted': false
@@ -689,17 +764,225 @@
         console.log(_callConfig);
         _phoneBar.initConfig(_callConfig);
 
+        // embed:转发关键 IPCC 事件给父页
+        if (_hisEmbedMode) {
+            var _forwardCodes = [
+                ccPhoneBarSocket.eventListWithTextInfo.ws_connected.code,
+                ccPhoneBarSocket.eventListWithTextInfo.ws_disconnected.code,
+                ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code,
+                ccPhoneBarSocket.eventListWithTextInfo.request_args_error.code,
+                ccPhoneBarSocket.eventListWithTextInfo.status_changed.code,
+                ccPhoneBarSocket.eventListWithTextInfo.callee_ringing.code,
+                ccPhoneBarSocket.eventListWithTextInfo.caller_answered.code,
+                ccPhoneBarSocket.eventListWithTextInfo.caller_hangup.code,
+                ccPhoneBarSocket.eventListWithTextInfo.callee_answered.code,
+                ccPhoneBarSocket.eventListWithTextInfo.callee_hangup.code,
+                ccPhoneBarSocket.eventList.OUTBOUND_START,
+                ccPhoneBarSocket.eventList.caller_not_login,
+                ccPhoneBarSocket.eventList.outbound_finished,
+                ccPhoneBarSocket.eventList.server_error,
+                ccPhoneBarSocket.eventList.server_error_audio_codec_not_match,
+                ccPhoneBarSocket.eventList.on_audio_call_connected,
+                ccPhoneBarSocket.eventList.customer_channel_hold,
+                ccPhoneBarSocket.eventList.customer_channel_unhold,
+                ccPhoneBarSocket.eventList.customer_on_hold_hangup,
+                ccPhoneBarSocket.eventList.callee_busy,
+                ccPhoneBarSocket.eventList.caller_busy
+            ];
+            _forwardCodes.forEach(function(code) {
+                if (code == null) return;
+                _phoneBar.on(code, function(msg) {
+                    hisIpccForwardEvent(code, msg);
+                    var codeStr = String(code);
+                    if (codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.ws_connected.code)) {
+                        hisIpccPost('CONNECTED', {
+                            connected: true,
+                            extNum: _embedExtNum,
+                            myGateway: _embedMyGateway || ''
+                        });
+                    } else if (codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.ws_disconnected.code)
+                        || codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code)) {
+                        hisIpccPost('DISCONNECTED', {
+                            connected: false,
+                            code: codeStr,
+                            msg: msg
+                        });
+                    } else if (codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.status_changed.code)) {
+                        hisIpccPost('STATUS', {
+                            status: msg && msg.object ? msg.object.status : null,
+                            text: msg && msg.object ? msg.object.text : '',
+                            msg: msg
+                        });
+                    }
+                });
+            });
+            hisIpccPost('READY', { extNum: _embedExtNum, myGateway: _embedMyGateway || '' });
+        }
 
         // 设置自动连接电话工具条
         setTimeout(function () {
             console.log(" 正在尝试连接电话工具条, 端口 1081 ");
             _phoneBar.connect();
-        }, 1500);
+        }, _hisEmbedMode ? 300 : 1500);
 
         $("#work-container").hide();
 
+        // embed 控制指令
+        if (_hisEmbedMode) {
+            initHisIpccEmbedCommandBridge();
+        }
+
     });
 
+    function initHisIpccEmbedCommandBridge() {
+        window.addEventListener('message', function(event) {
+            var data = event.data;
+            if (!data || data.channel !== 'HIS_IPCC_EMBED') return;
+            var type = data.type;
+            var payload = data.payload || {};
+            try {
+                if (type === 'PING') {
+                    hisIpccPost('PONG', {
+                        connected: !!( _phoneBar && _phoneBar.getIsConnected && _phoneBar.getIsConnected()),
+                        callConnected: !!( _phoneBar && _phoneBar.getCallConnected && _phoneBar.getCallConnected()),
+                        activeCallUuid: (_phoneBar && _phoneBar.getActiveCallUuid) ? (_phoneBar.getActiveCallUuid() || '') : '',
+                        myGateway: _embedMyGateway || ''
+                    });
+                    return;
+                }
+                if (type === 'CALL') {
+                    var phone = payload.phone;
+                    var callType = payload.callType || 'audio';
+                    var videoLevel = payload.videoLevel;
+                    var started = false;
+                    var failMsg = '';
+                    try {
+                        if (!_phoneBar) {
+                            failMsg = '电话条未就绪';
+                        } else if (!phone) {
+                            failMsg = '号码为空';
+                        } else if (typeof _phoneBar.getIsConnected === 'function' && !_phoneBar.getIsConnected()) {
+                            failMsg = 'IPCC未连接';
+                        } else {
+                            // call() 本身无布尔返回值,发出指令即视为已受理
+                            _phoneBar.call(phone, callType, videoLevel);
+                            started = true;
+                        }
+                    } catch (callErr) {
+                        failMsg = (callErr && callErr.message) || String(callErr);
+                        started = false;
+                    }
+                    hisIpccPost('CALL_RESULT', { ok: started, phone: phone, msg: failMsg || '' });
+                    return;
+                }
+                if (type === 'HANGUP') {
+                    if (_phoneBar) _phoneBar.hangup();
+                    hisIpccPost('HANGUP_RESULT', { ok: true });
+                    return;
+                }
+                if (type === 'HOLD') {
+                    var holdOk = false;
+                    if (_phoneBar && typeof _phoneBar.holdCall === 'function') holdOk = !!_phoneBar.holdCall();
+                    hisIpccPost('HOLD_RESULT', { ok: holdOk });
+                    return;
+                }
+                if (type === 'UNHOLD') {
+                    var unholdOk = false;
+                    if (_phoneBar && typeof _phoneBar.unHoldCall === 'function') unholdOk = !!_phoneBar.unHoldCall();
+                    hisIpccPost('UNHOLD_RESULT', { ok: unholdOk });
+                    return;
+                }
+                if (type === 'DISCONNECT') {
+                    manualDisConn = true;
+                    if (_phoneBar) {
+                        if (typeof _phoneBar.forceDisconnect === 'function') _phoneBar.forceDisconnect();
+                        else _phoneBar.disconnect();
+                    }
+                    hisIpccPost('DISCONNECTED', { connected: false, manual: true });
+                    return;
+                }
+                if (type === 'SET_STATUS') {
+                    var st = payload.status;
+                    if (_phoneBar) {
+                        if (st === 'busy' && typeof _phoneBar.setStatusBusy === 'function') _phoneBar.setStatusBusy();
+                        else if (st === 'free' && typeof _phoneBar.setStatusFree === 'function') _phoneBar.setStatusFree();
+                        else if (st != null && typeof _phoneBar.setStatus === 'function') _phoneBar.setStatus(st);
+                    }
+                    hisIpccPost('SET_STATUS_RESULT', { ok: true, status: st });
+                    return;
+                }
+                if (type === 'SWITCH_GATEWAY') {
+                    hisIpccSwitchGateway(payload.myGateway);
+                    return;
+                }
+                if (type === 'RECONNECT') {
+                    manualDisConn = false;
+                    if (_phoneBar) {
+                        if (typeof _phoneBar.resetKickedState === 'function') _phoneBar.resetKickedState();
+                        if (typeof _phoneBar.reconnect === 'function') _phoneBar.reconnect();
+                        else _phoneBar.connect();
+                    }
+                    return;
+                }
+            } catch (err) {
+                hisIpccPost('ERROR', { msg: (err && err.message) || String(err), type: type });
+            }
+        });
+    }
+
+    function hisIpccSwitchGateway(myGateway) {
+        var gw = (myGateway == null || myGateway === '') ? '' : String(myGateway);
+        var extNum = _embedExtNum;
+        if (!extNum) {
+            hisIpccPost('ERROR', { msg: '分机号缺失,无法切换网关' });
+            return;
+        }
+        hisIpccPost('SWITCH_GATEWAY_START', { myGateway: gw });
+        $.ajax({
+            url: (typeof ctx !== 'undefined' ? ctx : (_ctxPath || '/')) + 'aicall/api/myPhoneBar/params',
+            type: 'POST',
+            contentType: 'application/json;charset=utf-8',
+            dataType: 'json',
+            data: JSON.stringify({ extNum: String(extNum), myGateway: gw }),
+            success: function(r) {
+                if (!r || (r.code !== 0 && r.code !== 200) || !r.data || !r.data.loginToken) {
+                    hisIpccPost('ERROR', { msg: (r && (r.msg || r.message)) || '切换网关失败', type: 'SWITCH_GATEWAY' });
+                    return;
+                }
+                _embedMyGateway = gw;
+                var data = r.data;
+                var scriptServer = data.scriptServer || _scriptServer;
+                var scriptPort = data.scriptPort || _scriptPort;
+                _callConfig = {
+                    useDefaultUi: false,
+                    loginToken: data.loginToken,
+                    ipccServer: scriptServer + ':' + scriptPort,
+                    gatewayList: data.gatewayList || [],
+                    gatewayEncrypted: false
+                };
+                try {
+                    if (_phoneBar) {
+                        if (typeof _phoneBar.forceDisconnect === 'function') _phoneBar.forceDisconnect();
+                        else _phoneBar.disconnect();
+                    }
+                } catch (e) {}
+                setTimeout(function() {
+                    try {
+                        _phoneBar.initConfig(_callConfig);
+                        if (typeof _phoneBar.resetKickedState === 'function') _phoneBar.resetKickedState();
+                        _phoneBar.connect();
+                        hisIpccPost('SWITCH_GATEWAY_DONE', { myGateway: gw, ok: true });
+                    } catch (err) {
+                        hisIpccPost('ERROR', { msg: (err && err.message) || String(err), type: 'SWITCH_GATEWAY' });
+                    }
+                }, 400);
+            },
+            error: function(xhr, status, error) {
+                hisIpccPost('ERROR', { msg: error || '切换网关请求失败', type: 'SWITCH_GATEWAY' });
+            }
+        });
+    }
+
     function onConferenceEnd() {
         document.getElementById("endConference").setAttribute("disabled", "true");
         document.getElementById("startConference").removeAttribute("disabled");
@@ -810,6 +1093,8 @@
     }
 
     function addCallRecord(phoneNum, uuid, call_type) {
+        // HIS iframe 集成:不加载弹屏/CRM iframe,避免多余接口与跨域噪音
+        if (_hisEmbedMode) return;
 
         $("#work-container").show();
         $("#chat-container").show();
@@ -830,16 +1115,6 @@
         $('#popup-container').empty().append(
             '<iframe src="' + url + '" title="' + title + '"></iframe>'
         );
-
-        // _phoneNum = phoneNum;
-        // _uuid = uuid;
-        // _call_type = call_type;
-        // var modealName = '外呼弹屏';
-        // if (_call_type == 1) {
-        //     modealName = '呼入弹屏';
-        // }
-        // var url = ctx + "cc/custcallrecord/add?phoneNum=" + _phoneNum + "&callType=" + _call_type + "&uuid=" + _uuid;
-        // $.modal.open(modealName, url);
     }
 
     $('#onLineBtn').on('click', function () {
@@ -854,23 +1129,26 @@
         }
     });
 
-    /* asr实时对话文本框的功能 */
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_started, function (msg) {
-        $(chatMessages).html("");
-    });
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_result_generate, function (msg) {
-        handleAsrMessage(msg);
-    });
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_customer, function (msg) {
-        handleAsrMessage(msg);
-    });
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_agent, function (msg) {
-        handleAsrMessage(msg);
-    });
+    /* asr实时对话:仅普通工作台;HIS embed 跳过,减少无效监听/接口 */
+    if (!_hisEmbedMode) {
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_started, function (msg) {
+            $(chatMessages).html("");
+        });
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_result_generate, function (msg) {
+            handleAsrMessage(msg);
+        });
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_customer, function (msg) {
+            handleAsrMessage(msg);
+        });
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_agent, function (msg) {
+            handleAsrMessage(msg);
+        });
+    }
 
 
     const chatMessages = document.getElementById('chat-messages');
     function handleAsrMessage(data) {
+        if (_hisEmbedMode) return;
         $("#chat-container").show();
         const { status, object } = data;
         if (status === 619 && object) {
@@ -917,7 +1195,8 @@
     }
 
     function addAsrRecord(_phoneNum, _uuid, _call_type, _asrResult) {
-        console.log(_autoFree);
+        // HIS embed:不写 custDialog / 不点置闲,话单由父页负责
+        if (_hisEmbedMode) return;
         if (_autoFree) {
             $("#setFree").click();
         }
@@ -934,10 +1213,18 @@
                     "dialog": JSON.stringify(_asrResult),
                 },
                 beforeSend: function () {
+                    // embed 跨域时 $.modal.disable 会读 parent.document → SecurityError
+                    if (_hisEmbedMode) return;
                     $.modal.loading(i18n("common.tip.loading"));
                     $.modal.disable();
                 },
                 success: function(result) {
+                    if (_hisEmbedMode) return;
+                    $.modal.closeLoading();
+                    $.modal.enable();
+                },
+                error: function () {
+                    if (_hisEmbedMode) return;
                     $.modal.closeLoading();
                     $.modal.enable();
                 }

+ 29 - 0
ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java

@@ -30,6 +30,15 @@ public class RuoYiConfig
     /** 获取地址开关 */
     private static boolean addressEnabled;
 
+    /**
+     * Local debug switch (default false).
+     * When true, replace 127.0.0.1/localhost with localRemoteHost.
+     */
+    private static boolean isLocalDebug = false;
+
+    /** Remote host used when isLocalDebug=true */
+    private static String localRemoteHost = "129.28.164.235";
+
     public static String getName()
     {
         return name;
@@ -90,6 +99,26 @@ public class RuoYiConfig
         RuoYiConfig.addressEnabled = addressEnabled;
     }
 
+    public static boolean isLocalDebug()
+    {
+        return isLocalDebug;
+    }
+
+    public void setIsLocalDebug(boolean isLocalDebug)
+    {
+        RuoYiConfig.isLocalDebug = isLocalDebug;
+    }
+
+    public static String getLocalRemoteHost()
+    {
+        return localRemoteHost;
+    }
+
+    public void setLocalRemoteHost(String localRemoteHost)
+    {
+        RuoYiConfig.localRemoteHost = localRemoteHost;
+    }
+
     /**
      * 获取导入上传路径
      */

+ 29 - 8
ruoyi-framework/src/main/java/com/ruoyi/framework/config/ResourcesConfig.java

@@ -1,7 +1,11 @@
 package com.ruoyi.framework.config;
 
+import javax.servlet.DispatcherType;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
@@ -10,17 +14,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 import com.ruoyi.common.config.RuoYiConfig;
 import com.ruoyi.common.constant.Constants;
 import com.ruoyi.framework.interceptor.RepeatSubmitInterceptor;
+import com.ruoyi.framework.web.filter.LocalProfileProxyFilter;
 
 /**
- * 通用配置
- * 
+ * General web mvc config
+ *
  * @author ruoyi
  */
 @Configuration
 public class ResourcesConfig implements WebMvcConfigurer
 {
     /**
-     * 首页地址
+     * Index url
      */
     @Value("${shiro.user.indexUrl}")
     private String indexUrl;
@@ -29,7 +34,23 @@ public class ResourcesConfig implements WebMvcConfigurer
     private RepeatSubmitInterceptor repeatSubmitInterceptor;
 
     /**
-     * 默认首页的设置,当输入域名是可以自动跳转到默认指定的网页
+     * Register local profile redirect filter only when ruoyi.isLocalDebug=true
+     */
+    @Bean
+    @ConditionalOnProperty(prefix = "ruoyi", name = "isLocalDebug", havingValue = "true")
+    public FilterRegistrationBean<LocalProfileProxyFilter> localProfileProxyFilterRegistration()
+    {
+        FilterRegistrationBean<LocalProfileProxyFilter> registration = new FilterRegistrationBean<>();
+        registration.setFilter(new LocalProfileProxyFilter());
+        registration.addUrlPatterns("/*");
+        registration.setName("localProfileProxyFilter");
+        registration.setDispatcherTypes(DispatcherType.REQUEST);
+        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE + 20);
+        return registration;
+    }
+
+    /**
+     * Default home page forward
      */
     @Override
     public void addViewControllers(ViewControllerRegistry registry)
@@ -40,19 +61,19 @@ public class ResourcesConfig implements WebMvcConfigurer
     @Override
     public void addResourceHandlers(ResourceHandlerRegistry registry)
     {
-        /** 本地文件上传路径 */
+        /** Local upload path */
         registry.addResourceHandler(Constants.RESOURCE_PREFIX + "/**").addResourceLocations("file:" + RuoYiConfig.getProfile() + "/");
 
-        /** swagger配置 */
+        /** swagger */
         registry.addResourceHandler("/swagger-ui/**").addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/");
     }
 
     /**
-     * 自定义拦截规则
+     * Interceptors
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
     {
         registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
     }
-}
+}

+ 61 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/web/filter/LocalProfileProxyFilter.java

@@ -0,0 +1,61 @@
+package com.ruoyi.framework.web.filter;
+
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.springframework.web.filter.OncePerRequestFilter;
+import com.ruoyi.common.config.RuoYiConfig;
+import com.ruoyi.common.constant.Constants;
+import com.ruoyi.common.utils.StringUtils;
+
+/**
+ * Local debug only: redirect /profile/** from 127.0.0.1/localhost to remote host.
+ * Enabled only when ruoyi.isLocalDebug=true.
+ */
+public class LocalProfileProxyFilter extends OncePerRequestFilter
+{
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request)
+    {
+        if (!RuoYiConfig.isLocalDebug())
+        {
+            return true;
+        }
+        String host = request.getServerName();
+        if (!"127.0.0.1".equals(host) && !"localhost".equals(host))
+        {
+            return true;
+        }
+        String path = resolvePath(request);
+        return path == null || !path.startsWith(Constants.RESOURCE_PREFIX + "/");
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException
+    {
+        String path = resolvePath(request);
+        String qs = request.getQueryString();
+        String remoteHost = RuoYiConfig.getLocalRemoteHost();
+        if (StringUtils.isEmpty(remoteHost))
+        {
+            remoteHost = "129.28.164.235";
+        }
+        String remoteUrl = "http://" + remoteHost + path + (StringUtils.isNotEmpty(qs) ? "?" + qs : "");
+        response.setStatus(HttpServletResponse.SC_FOUND);
+        response.setHeader("Location", remoteUrl);
+    }
+
+    private String resolvePath(HttpServletRequest request)
+    {
+        String uri = request.getRequestURI();
+        String contextPath = request.getContextPath();
+        if (StringUtils.isNotEmpty(contextPath) && uri != null && uri.startsWith(contextPath))
+        {
+            return uri.substring(contextPath.length());
+        }
+        return uri;
+    }
+}