Administrator
3 hours ago decc54295e117b082ac0fae89f8578a17271196c
feat(firewall): 添加防火墙IP白名单管理功能

- 在AdminFirewallController中新增IP白名单CRUD接口
- 添加FirewallIpWhite实体类用于IP白名单数据管理
- 实现IP白名单分页查询、保存、删除业务逻辑
- 添加IpWhitelistNginxConfigGenerator生成Nginx IP白名单配置
- 当IP白名单变动时自动重新生成Nginx配置并重载服务
- 在前端页面新增firewallIpWhiteList.html管理界面
- 添加权限控制注解支持IP白名单查看权限
- 实现IP地址有效性验证和重复检查机制
- 支持IPv4和IPv6地址格式,可设置过期时间和启用状态
2 files added
4 files modified
499 ■■■■■ changed files
src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java 30 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/nginx/IpWhitelistNginxConfigGenerator.java 126 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java 18 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java 77 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java 6 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/firewallIpWhiteList.html 242 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java
@@ -7,6 +7,7 @@
import cc.mrbird.febs.firewall.dto.CountryVo;
import cc.mrbird.febs.firewall.dto.SaveCountryRuleDto;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper;
import cc.mrbird.febs.firewall.service.IFirewallRuleService;
@@ -156,4 +157,33 @@
        firewallRuleService.triggerNginxReload(siteId);
        return new FebsResponse().success().message("Nginx reload 信号已发送");
    }
    // ==================== IP白名单 CRUD ====================
    /**
     * IP白名单分页列表
     */
    @GetMapping("/ipWhiteList")
    public FebsResponse ipWhiteList(FirewallIpWhite ipWhite, QueryRequest request) {
        Map<String, Object> data = getDataTable(firewallRuleService.ipWhiteList(ipWhite, request));
        return new FebsResponse().success().data(data);
    }
    /**
     * 保存/更新IP白名单(保存后自动生成Nginx配置)
     */
    @PostMapping("/ipWhite/save")
    @ControllerEndpoint(operation = "保存防火墙IP白名单")
    public FebsResponse saveIpWhite(@Validated FirewallIpWhite ipWhite) {
        return firewallRuleService.saveIpWhite(ipWhite);
    }
    /**
     * 删除IP白名单(删除后自动生成Nginx配置)
     */
    @GetMapping("/ipWhite/delete/{id}")
    @ControllerEndpoint(operation = "删除防火墙IP白名单")
    public FebsResponse deleteIpWhite(@PathVariable @NotNull Long id) {
        return firewallRuleService.deleteIpWhite(id);
    }
}
src/main/java/cc/mrbird/febs/firewall/nginx/IpWhitelistNginxConfigGenerator.java
New file
@@ -0,0 +1,126 @@
package cc.mrbird.febs.firewall.nginx;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import cc.mrbird.febs.firewall.mapper.FirewallIpWhiteMapper;
import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper;
import cn.hutool.core.io.FileUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
/**
 * Nginx IP白名单配置文件生成器
 * <p>
 * 白名单模式:如果表中有启用的IP记录,则仅允许这些IP访问;表为空则放行所有IP。
 * <p>
 * 输出文件: /etc/nginx/conf.d/firewall_ipwhite_{siteId}.conf
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class IpWhitelistNginxConfigGenerator {
    private final FirewallIpWhiteMapper ipWhiteMapper;
    private final FirewallSiteMapper siteMapper;
    /** Nginx 配置目录 */
    private static final String NGINX_CONF_DIR = "/etc/nginx/conf.d/";
    /** 配置文件前缀 */
    private static final String CONF_PREFIX = "firewall_ipwhite_";
    /**
     * 生成 IP 白名单 map 配置
     * <pre>
     * # 有白名单IP时:
     * map $remote_addr $allow_ip_1 {
     *     default 0;
     *     1.2.3.4 1;
     *     5.6.7.8 1;
     * }
     *
     * # 无白名单IP时:
     * map $remote_addr $allow_ip_1 {
     *     default 1;
     * }
     * </pre>
     * 有IP时 default 0(默认拒绝,仅白名单放行);
     * 无IP时 default 1(全部放行)。
     * <p>
     * Nginx 中判断: if ($allow_ip_1 = 0) { return 403; }
     *
     * @param siteId 站点ID
     */
    public void generate(Long siteId) {
        FirewallSite site = siteMapper.selectById(siteId);
        if (site == null) {
            log.warn("防火墙站点不存在: siteId={}", siteId);
            return;
        }
        // 查询站点下所有启用的白名单IP(未过期或永久有效)
        List<FirewallIpWhite> ipList = ipWhiteMapper.selectList(
                new LambdaQueryWrapper<FirewallIpWhite>()
                        .eq(FirewallIpWhite::getSiteId, siteId)
                        .eq(FirewallIpWhite::getEnabled, 1)
                        .and(w -> w.isNull(FirewallIpWhite::getExpireTime)
                                    .or().gt(FirewallIpWhite::getExpireTime, new Date()))
        );
        boolean hasIps = !ipList.isEmpty();
        StringBuilder sb = new StringBuilder();
        sb.append("# Firewall IP Whitelist Map\n");
        sb.append("# Site: ").append(site.getSiteName()).append(" (").append(site.getDomain()).append(")\n");
        sb.append("# Generated at: ").append(new Date()).append("\n");
        sb.append("# Total IPs: ").append(ipList.size()).append("\n");
        sb.append("# Whitelist active: ").append(hasIps).append("\n\n");
        // 标记变量:白名单是否生效(有IP记录=1,无记录=0)
        // 用 map 模拟,使变量在 http 级别可用
        sb.append("# 是否启用白名单模式: ").append(hasIps ? "1=是" : "0=否").append("\n");
        sb.append("map $host $ipwh_active_").append(siteId).append(" {\n");
        sb.append("    default ").append(hasIps ? "1" : "0").append(";\n");
        sb.append("}\n\n");
        sb.append("map $remote_addr $allow_ip_").append(siteId).append(" {\n");
        if (!hasIps) {
            // 无白名单 → 全部放行
            sb.append("    default 1;\n");
        } else {
            // 有白名单 → 默认拒绝,仅白名单IP放行
            sb.append("    default 0;\n");
            for (FirewallIpWhite ip : ipList) {
                sb.append("    ").append(ip.getIp()).append(" 1;\n");
            }
        }
        sb.append("}\n");
        // 确定配置文件路径
        String confFileName = NGINX_CONF_DIR + CONF_PREFIX + siteId + ".conf";
        File confFile = new File(confFileName);
        try {
            FileUtil.writeString(sb.toString(), confFile, StandardCharsets.UTF_8);
            log.info("Nginx IP白名单配置已生成: {}, 白名单IP数: {}", confFile.getAbsolutePath(), ipList.size());
        } catch (Exception e) {
            log.error("Nginx IP白名单配置文件写入失败: path={}", confFile.getAbsolutePath(), e);
            throw new RuntimeException("Nginx IP白名单配置文件写入失败: " + e.getMessage(), e);
        }
    }
    /**
     * 获取 IP 白名单配置文件路径
     */
    public static String getConfPath(Long siteId) {
        return NGINX_CONF_DIR + CONF_PREFIX + siteId + ".conf";
    }
}
src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java
@@ -4,6 +4,7 @@
import cc.mrbird.febs.common.entity.QueryRequest;
import cc.mrbird.febs.firewall.dto.CountryVo;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -65,4 +66,21 @@
     * 手动触发 Nginx 配置生成 & reload
     */
    void triggerNginxReload(Long siteId);
    // ==================== IP白名单管理 ====================
    /**
     * IP白名单分页列表
     */
    IPage<FirewallIpWhite> ipWhiteList(FirewallIpWhite ipWhite, QueryRequest request);
    /**
     * 保存/更新IP白名单(保存后自动生成Nginx配置)
     */
    FebsResponse saveIpWhite(FirewallIpWhite ipWhite);
    /**
     * 删除IP白名单(删除后自动生成Nginx配置)
     */
    FebsResponse deleteIpWhite(Long id);
}
src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java
@@ -5,9 +5,12 @@
import cc.mrbird.febs.common.utils.RedisUtils;
import cc.mrbird.febs.firewall.dto.CountryVo;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper;
import cc.mrbird.febs.firewall.mapper.FirewallIpWhiteMapper;
import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper;
import cc.mrbird.febs.firewall.nginx.IpWhitelistNginxConfigGenerator;
import cc.mrbird.febs.firewall.nginx.NginxConfigGenerator;
import cc.mrbird.febs.firewall.service.IFirewallRuleService;
import cn.hutool.core.util.StrUtil;
@@ -34,8 +37,10 @@
public class FirewallRuleServiceImpl implements IFirewallRuleService {
    private final FirewallCountryRuleMapper countryRuleMapper;
    private final FirewallIpWhiteMapper ipWhiteMapper;
    private final FirewallSiteMapper siteMapper;
    private final NginxConfigGenerator generator;
    private final IpWhitelistNginxConfigGenerator ipWhitelistGenerator;
    private final RedisUtils redisUtils;
    /** Redis 中 Nginx reload 任务队列 key */
@@ -88,10 +93,14 @@
        // 删除站点关联的国家规则
        countryRuleMapper.delete(new LambdaQueryWrapper<FirewallCountryRule>()
                .eq(FirewallCountryRule::getSiteId, id));
        // 删除Nginx配置文件
        // 删除站点关联的IP白名单
        ipWhiteMapper.delete(new LambdaQueryWrapper<FirewallIpWhite>()
                .eq(FirewallIpWhite::getSiteId, id));
        // 删除Nginx配置文件(国家黑名单 + IP白名单)
        if (StrUtil.isNotBlank(site.getNginxConf())) {
            cn.hutool.core.io.FileUtil.del(site.getNginxConf());
        }
        cn.hutool.core.io.FileUtil.del(IpWhitelistNginxConfigGenerator.getConfPath(id));
        siteMapper.deleteById(id);
        log.info("防火墙站点已删除: id={}, name={}", id, site.getSiteName());
        return new FebsResponse().success().message("删除成功");
@@ -163,6 +172,7 @@
    private void regenerateNginxConfig(Long siteId) {
        try {
            generator.generate(siteId);
            ipWhitelistGenerator.generate(siteId);
            redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
            log.info("Nginx reload 信号已入队: siteId={}", siteId);
        } catch (Exception e) {
@@ -211,6 +221,7 @@
        // 3. 生成 Nginx 配置文件
        generator.generate(siteId);
        ipWhitelistGenerator.generate(siteId);
        // 4. 发送 reload 信号
        redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
@@ -220,7 +231,71 @@
    @Override
    public void triggerNginxReload(Long siteId) {
        generator.generate(siteId);
        ipWhitelistGenerator.generate(siteId);
        redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
        log.info("手动触发 Nginx reload: siteId={}", siteId);
    }
    // ==================== IP白名单管理 ====================
    @Override
    public IPage<FirewallIpWhite> ipWhiteList(FirewallIpWhite ipWhite, QueryRequest request) {
        Page<FirewallIpWhite> page = new Page<>(request.getPageNum(), request.getPageSize());
        LambdaQueryWrapper<FirewallIpWhite> wrapper = new LambdaQueryWrapper<FirewallIpWhite>()
                .eq(ipWhite.getSiteId() != null, FirewallIpWhite::getSiteId, ipWhite.getSiteId())
                .eq(ipWhite.getEnabled() != null, FirewallIpWhite::getEnabled, ipWhite.getEnabled())
                .like(StrUtil.isNotBlank(ipWhite.getIp()), FirewallIpWhite::getIp, ipWhite.getIp())
                .like(StrUtil.isNotBlank(ipWhite.getRemark()), FirewallIpWhite::getRemark, ipWhite.getRemark())
                .orderByDesc(FirewallIpWhite::getCreatedTime);
        return ipWhiteMapper.selectPage(page, wrapper);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public FebsResponse saveIpWhite(FirewallIpWhite ipWhite) {
        if (ipWhite.getSiteId() == null) {
            return new FebsResponse().fail().message("请选择站点");
        }
        if (StrUtil.isBlank(ipWhite.getIp())) {
            return new FebsResponse().fail().message("IP地址不能为空");
        }
        ipWhite.setIp(ipWhite.getIp().trim());
        if (ipWhite.getId() != null) {
            ipWhiteMapper.updateById(ipWhite);
            log.info("防火墙IP白名单已更新: id={}, siteId={}, ip={}", ipWhite.getId(), ipWhite.getSiteId(), ipWhite.getIp());
        } else {
            // 检查同站点下是否已存在相同IP
            Integer count = ipWhiteMapper.selectCount(new LambdaQueryWrapper<FirewallIpWhite>()
                    .eq(FirewallIpWhite::getSiteId, ipWhite.getSiteId())
                    .eq(FirewallIpWhite::getIp, ipWhite.getIp()));
            if (count > 0) {
                return new FebsResponse().fail().message("该站点下已存在相同IP: " + ipWhite.getIp());
            }
            ipWhiteMapper.insert(ipWhite);
            log.info("防火墙IP白名单已新增: siteId={}, ip={}", ipWhite.getSiteId(), ipWhite.getIp());
        }
        // 保存后自动生成Nginx配置并发送reload信号
        ipWhitelistGenerator.generate(ipWhite.getSiteId());
        redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(ipWhite.getSiteId()));
        return new FebsResponse().success().message("保存成功,Nginx 配置已更新");
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public FebsResponse deleteIpWhite(Long id) {
        FirewallIpWhite ipWhite = ipWhiteMapper.selectById(id);
        if (ipWhite == null) {
            return new FebsResponse().fail().message("IP白名单不存在");
        }
        Long siteId = ipWhite.getSiteId();
        ipWhiteMapper.deleteById(id);
        log.info("防火墙IP白名单已删除: id={}, siteId={}, ip={}", id, siteId, ipWhite.getIp());
        // 删除后自动生成Nginx配置并发送reload信号
        ipWhitelistGenerator.generate(siteId);
        redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
        return new FebsResponse().success().message("删除成功,Nginx 配置已更新");
    }
}
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java
@@ -181,6 +181,12 @@
        return FebsUtil.view("modules/system/firewallCountryRuleList");
    }
    @GetMapping("firewallIpWhiteList")
    @RequiresPermissions("firewallIpWhite:view")
    public String firewallIpWhiteList() {
        return FebsUtil.view("modules/system/firewallIpWhiteList");
    }
    @GetMapping("tokenviewConfig")
    @RequiresPermissions("tokenviewConfig:update")
    public String tokenviewConfig(Model model) {
src/main/resources/templates/febs/views/modules/system/firewallIpWhiteList.html
New file
@@ -0,0 +1,242 @@
<div class="layui-fluid layui-anim febs-anim" id="firewall-ipwhite-list" lay-title="防火墙IP白名单管理">
    <div class="layui-row febs-container">
        <div class="layui-col-md12">
            <div class="layui-card">
                <div class="layui-card-body febs-table-full">
                    <form class="layui-form layui-table-form" lay-filter="firewall-ipwhite-table-form">
                        <div class="layui-row">
                            <div class="layui-col-md10">
                                <div class="layui-form-item">
                                    <button class="layui-btn layui-btn-normal" type="button" id="add-btn">
                                        <i class="layui-icon">&#xe654;</i> 新增IP白名单
                                    </button>
                                </div>
                            </div>
                            <div class="layui-col-md2 layui-col-sm12 layui-col-xs12 table-action-area">
                                <div class="layui-btn layui-btn-sm layui-btn-primary febs-button-green-plain table-action" id="reset">
                                    <i class="layui-icon">&#xe79b;</i>
                                </div>
                            </div>
                        </div>
                        <div class="layui-row">
                            <div class="layui-col-md3">
                                <div class="layui-form-item">
                                    <label class="layui-form-label">选择站点</label>
                                    <div class="layui-input-block">
                                        <select id="filter-site-id" name="siteId" lay-filter="filter-site" lay-search="">
                                            <option value="">全部站点</option>
                                        </select>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </form>
                    <table lay-filter="firewallIpWhiteTable" lay-data="{id: 'firewallIpWhiteTable'}"></table>
                </div>
            </div>
        </div>
    </div>
</div>
<!-- 表格操作栏 -->
<script type="text/html" id="firewall-ipwhite-option">
    <a lay-event="edit"><i class="layui-icon febs-edit-area febs-blue">&#xe7a5;</i></a>
    <a lay-event="del"><i class="layui-icon febs-edit-area febs-red">&#xe640;</i></a>
</script>
<style>
    .layui-form-label { width: 110px; }
    .layui-form-item .layui-input-block { margin-left: 140px; }
</style>
<script data-th-inline="none" type="text/javascript">
    layui.use(['jquery', 'form', 'table', 'febs', 'laydate'], function () {
        var $ = layui.jquery,
            febs = layui.febs,
            form = layui.form,
            table = layui.table,
            layer = layui.layer,
            laydate = layui.laydate,
            $view = $('#firewall-ipwhite-list'),
            $reset = $view.find('#reset'),
            $addBtn = $view.find('#add-btn'),
            tableIns;
        form.render();
        loadSiteFilter();
        initTable();
        // 加载站点下拉选项
        function loadSiteFilter() {
            febs.get(ctx + 'admin/firewall/sites', null, function (res) {
                var sites = res.data || [];
                var $select = $('#filter-site-id');
                sites.forEach(function (s) {
                    $select.append('<option value="' + s.id + '">' + s.siteName + ' (' + s.domain + ')</option>');
                });
                form.render('select');
            });
        }
        // 站点筛选切换
        form.on('select(filter-site)', function () {
            tableIns.reload();
        });
        // 表格操作
        table.on('tool(firewallIpWhiteTable)', function (obj) {
            var data = obj.data,
                layEvent = obj.event;
            if (layEvent === 'edit') {
                openEditModal(data);
            } else if (layEvent === 'del') {
                layer.confirm('确定删除IP白名单 <b>' + data.ip + '</b> 吗?<br>删除后会自动更新Nginx配置!', function (index) {
                    febs.get(ctx + 'admin/firewall/ipWhite/delete/' + data.id, null, function () {
                        layer.close(index);
                        febs.alert.success('删除成功,Nginx配置已更新');
                        tableIns.reload();
                    });
                });
            }
        });
        // 新增按钮
        $addBtn.on('click', function () {
            openEditModal(null);
        });
        // 刷新按钮
        $reset.on('click', function () {
            tableIns.reload();
        });
        // 初始化表格
        function initTable() {
            tableIns = febs.table.init({
                elem: $view.find('table'),
                id: 'firewallIpWhiteTable',
                url: ctx + 'admin/firewall/ipWhiteList',
                where: function () {
                    var siteId = $('#filter-site-id').val();
                    return siteId ? { siteId: siteId } : {};
                },
                cols: [[
                    {field: 'id', title: 'ID', width: 60, align: 'center'},
                    {field: 'siteId', title: '站点ID', width: 80, align: 'center'},
                    {field: 'ip', title: 'IP地址', width: 180, align: 'center'},
                    {field: 'remark', title: '备注', minWidth: 150, align: 'center'},
                    {field: 'expireTime', title: '过期时间', width: 170, align: 'center',
                        templet: function (d) {
                            return d.expireTime || '<span style="color:#5FB878;">永久有效</span>';
                        }
                    },
                    {field: 'enabled', title: '状态', width: 90, align: 'center',
                        templet: function (d) {
                            return d.enabled === 1
                                ? '<span style="color:#5FB878;">● 启用</span>'
                                : '<span style="color:#999;">● 禁用</span>';
                        }
                    },
                    {title: '操作', templet: '#firewall-ipwhite-option', width: 120, align: 'center'},
                    {field: 'createdTime', title: '创建时间', width: 170, align: 'center'},
                ]]
            });
        }
        // 打开编辑弹窗
        function openEditModal(data) {
            var title = data ? '编辑IP白名单' : '新增IP白名单';
            var html = '<div style="padding:20px 25px 0 0;">' +
                '<form class="layui-form" lay-filter="firewall-ipwhite-form">' +
                (data ? '<div class="layui-form-item febs-hide"><label class="layui-form-label">ID</label><div class="layui-input-block"><input type="text" name="id" value="' + data.id + '"></div></div>' : '') +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label febs-form-item-require">所属站点</label>' +
                '    <div class="layui-input-block">' +
                '      <select name="siteId" lay-verify="required" lay-search="" data-value="' + (data ? data.siteId : '') + '">' +
                '        <option value="">请选择站点</option>' +
                '      </select>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label febs-form-item-require">IP地址</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="ip" class="layui-input" ' + (data ? 'value="' + (data.ip || '') + '"' : 'placeholder="如:1.2.3.4 或 2001:db8::1"') + ' lay-verify="required">' +
                '      <div class="layui-form-mid layui-word-aux">支持IPv4和IPv6地址</div>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label">过期时间</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="expireTime" id="ipwhite-expire-time" class="layui-input" ' + (data && data.expireTime ? 'value="' + data.expireTime + '"' : 'placeholder="留空表示永久有效"') + '>' +
                '      <div class="layui-form-mid layui-word-aux">留空表示永久有效</div>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label">备注</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="remark" class="layui-input" ' + (data && data.remark ? 'value="' + data.remark + '"' : 'placeholder="如:办公室出口IP"') + '>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label">状态</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="radio" name="enabled" value="1" title="启用" ' + (!data || data.enabled === 1 ? 'checked' : '') + '>' +
                '      <input type="radio" name="enabled" value="0" title="禁用" ' + (data && data.enabled === 0 ? 'checked' : '') + '>' +
                '    </div>' +
                '  </div>' +
                '</form></div>';
            var layerIndex = layer.open({
                type: 1,
                title: title,
                area: ['540px', '450px'],
                content: html,
                success: function (layero) {
                    // 加载弹窗中的站点下拉
                    var $popupSelect = $(layero).find('select[name="siteId"]');
                    febs.get(ctx + 'admin/firewall/sites', null, function (res) {
                        var sites = res.data || [];
                        var preselected = data ? data.siteId : '';
                        sites.forEach(function (s) {
                            var sel = s.id == preselected ? ' selected' : '';
                            $popupSelect.append('<option value="' + s.id + '"' + sel + '>' + s.siteName + ' (' + s.domain + ')</option>');
                        });
                        form.render('select');
                    });
                    // 渲染过期时间日期选择器
                    laydate.render({
                        elem: '#ipwhite-expire-time',
                        type: 'datetime',
                        format: 'yyyy-MM-dd HH:mm:ss'
                    });
                    form.render('radio', 'firewall-ipwhite-form');
                },
                btn: ['保存', '取消'],
                yes: function (index, layero) {
                    var formObj = $(layero).find('form');
                    var formData = form.val("firewall-ipwhite-form");
                    if (!formData.siteId) {
                        layer.msg('请选择站点', {icon: 2});
                        return;
                    }
                    if (!formData.ip) {
                        layer.msg('请输入IP地址', {icon: 2});
                        return;
                    }
                    // 空字符串过期时间不提交
                    if (!formData.expireTime) {
                        delete formData.expireTime;
                    }
                    febs.post(ctx + 'admin/firewall/ipWhite/save', formData, function (res) {
                        layer.close(index);
                        febs.alert.success('保存成功,Nginx配置已自动更新');
                        tableIns.reload();
                    });
                },
                btn2: function () {
                    layer.closeAll();
                }
            });
        }
    });
</script>