tuqin
2026-10-29 f591f31789ff3da7bc669b849f8acc2561732557
feat(LIMS): 补「安全与权限管理」独立菜单与页面

原来需求⑧的内容分散在两处、验收时在左侧菜单看不到「安全与权限」这个模块:
- 审计日志藏在「系统配置」的第 5 个 Tab
- 访问控制复用框架的「系统管理」
- 安全参数混在 13 条系统参数里

现在补一个独立菜单「安全与权限」(menu_id=2109),页面内 3 个 Tab:
- 审计追踪:审计日志列表,按模块/动作/操作人/结果筛选,展示字段变更前后值、操作人 IP
- 访问控制:用户/角色/菜单/登录日志/操作日志/在线用户 六个入口卡片 + 安全措施清单表
(明确标注哪些已实施、哪些待定,如字段级加密未做)
- 安全策略:security.* 三类参数(审计开关、保留天数、脱敏开关)可直接改值

配套 SQL:11_lims_menu_security.sql
已添加1个文件
314 ■■■■■ 文件已修改
src/views/lims/securityManagement/index.vue 314 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/lims/securityManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,314 @@
<template>
  <div class="app-container">
    <el-tabs v-model="activeTab" type="border-card">
      <!-- ==================== Tab1: å®¡è®¡è¿½è¸ª ==================== -->
      <el-tab-pane label="审计追踪" name="audit">
        <el-alert type="info" :closable="false" style="margin-bottom: 12px">
          <template #title>
            è®°å½•敏感数据的增删改与关键业务操作(样品出库、实验资源分配、告警处理等),
            åŒ…含操作人、IP、字段变更前后值,满足合规追溯要求。
          </template>
        </el-alert>
        <div class="search_form">
          <div>
            <span class="search_title">模块:</span>
            <el-input v-model="auditSearch.module" style="width: 140px" placeholder="如 æ ·å“ç®¡ç†" clearable @change="loadAudit" />
            <span class="search_title" style="margin-left: 14px">动作:</span>
            <el-select v-model="auditSearch.action" style="width: 150px" placeholder="全部" clearable @change="loadAudit">
              <el-option label="新增 create" value="create" />
              <el-option label="修改 update" value="update" />
              <el-option label="出库 stock_out" value="stock_out" />
              <el-option label="入库 stock_in" value="stock_in" />
              <el-option label="分配资源 assign_resource" value="assign_resource" />
              <el-option label="处理告警 handle_alert" value="handle_alert" />
            </el-select>
            <span class="search_title" style="margin-left: 14px">操作人:</span>
            <el-input v-model="auditSearch.operator" style="width: 130px" placeholder="请输入" clearable @change="loadAudit" />
            <span class="search_title" style="margin-left: 14px">结果:</span>
            <el-select v-model="auditSearch.execResult" style="width: 110px" placeholder="全部" clearable @change="loadAudit">
              <el-option label="成功" value="success" />
              <el-option label="失败" value="fail" />
            </el-select>
            <el-button type="primary" style="margin-left: 10px" @click="loadAudit">搜索</el-button>
          </div>
          <div>
            <el-button @click="loadAudit">刷新</el-button>
          </div>
        </div>
        <PIMTable rowKey="id" :column="auditColumns" :tableData="auditData" :page="auditPage"
          :tableLoading="auditLoading" @pagination="paginationAudit" />
      </el-tab-pane>
      <!-- ==================== Tab2: è®¿é—®æŽ§åˆ¶ ==================== -->
      <el-tab-pane label="访问控制" name="access">
        <el-alert type="success" :closable="false" style="margin-bottom: 16px">
          <template #title>
            åŸºäºŽ RuoYi æƒé™ä½“系:用户 â†’ è§’色 â†’ èœå•/按钮权限。所有接口都有 @PreAuthorize æ ¡éªŒï¼Œ
            æœªæŽˆæƒè®¿é—®è¿”回 401/403;登录密码用 BCrypt åŠ å¯†å­˜å‚¨ï¼Œä¸è½æ˜Žæ–‡ã€‚
          </template>
        </el-alert>
        <el-row :gutter="16">
          <el-col :span="8">
            <el-card shadow="hover">
              <template #header>
                <div style="display: flex; justify-content: space-between; align-items: center">
                  <span style="font-weight: 600">用户管理</span>
                  <el-button link type="primary" @click="goSystem('user')">前往 â†’</el-button>
                </div>
              </template>
              <div style="color: #606266; font-size: 13px; line-height: 1.8">
                ç»´æŠ¤ç³»ç»Ÿä½¿ç”¨è€…账号,分配角色与部门。<br>
                å½“前用户:<b>{{ stat.userCount ?? '-' }}</b> äºº
              </div>
            </el-card>
          </el-col>
          <el-col :span="8">
            <el-card shadow="hover">
              <template #header>
                <div style="display: flex; justify-content: space-between; align-items: center">
                  <span style="font-weight: 600">角色管理</span>
                  <el-button link type="primary" @click="goSystem('role')">前往 â†’</el-button>
                </div>
              </template>
              <div style="color: #606266; font-size: 13px; line-height: 1.8">
                æŒ‰å²—位划分角色,配置可访问的菜单和数据范围。<br>
                å½“前角色:<b>{{ stat.roleCount ?? '-' }}</b> ä¸ª
              </div>
            </el-card>
          </el-col>
          <el-col :span="8">
            <el-card shadow="hover">
              <template #header>
                <div style="display: flex; justify-content: space-between; align-items: center">
                  <span style="font-weight: 600">菜单权限</span>
                  <el-button link type="primary" @click="goSystem('menu')">前往 â†’</el-button>
                </div>
              </template>
              <div style="color: #606266; font-size: 13px; line-height: 1.8">
                æŽ§åˆ¶æ¯ä¸ªè§’色能看到哪些菜单、能点哪些按钮。<br>
                å½“前菜单:<b>{{ stat.menuCount ?? '-' }}</b> é¡¹
              </div>
            </el-card>
          </el-col>
        </el-row>
        <el-divider content-position="left">登录与操作审计</el-divider>
        <el-row :gutter="16">
          <el-col :span="8">
            <el-card shadow="hover">
              <template #header>
                <div style="display: flex; justify-content: space-between; align-items: center">
                  <span style="font-weight: 600">登录日志</span>
                  <el-button link type="primary" @click="goMonitor('logininfor')">前往 â†’</el-button>
                </div>
              </template>
              <div style="color: #606266; font-size: 13px">记录每次登录的 IP、地点、浏览器、成功/失败</div>
            </el-card>
          </el-col>
          <el-col :span="8">
            <el-card shadow="hover">
              <template #header>
                <div style="display: flex; justify-content: space-between; align-items: center">
                  <span style="font-weight: 600">操作日志</span>
                  <el-button link type="primary" @click="goMonitor('operlog')">前往 â†’</el-button>
                </div>
              </template>
              <div style="color: #606266; font-size: 13px">框架级操作日志(所有带 @Log æ³¨è§£çš„æŽ¥å£ï¼‰</div>
            </el-card>
          </el-col>
          <el-col :span="8">
            <el-card shadow="hover">
              <template #header>
                <div style="display: flex; justify-content: space-between; align-items: center">
                  <span style="font-weight: 600">在线用户</span>
                  <el-button link type="primary" @click="goMonitor('online')">前往 â†’</el-button>
                </div>
              </template>
              <div style="color: #606266; font-size: 13px">当前登录会话,可强制下线</div>
            </el-card>
          </el-col>
        </el-row>
        <el-divider content-position="left">安全措施说明</el-divider>
        <el-table :data="securityMeasures" border size="small">
          <el-table-column prop="item" label="措施" width="180" />
          <el-table-column prop="impl" label="实现方式" />
          <el-table-column prop="status" label="状态" width="100" align="center">
            <template #default="{ row }">
              <el-tag :type="row.status === '已实施' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
            </template>
          </el-table-column>
        </el-table>
      </el-tab-pane>
      <!-- ==================== Tab3: å®‰å…¨ç­–ç•¥ ==================== -->
      <el-tab-pane label="安全策略" name="policy">
        <el-alert type="warning" :closable="false" style="margin-bottom: 12px">
          <template #title>
            ä»¥ä¸‹å‚数控制安全相关行为,修改后立即生效(审计与参数服务有本地缓存,改完刷新页面即可)。
          </template>
        </el-alert>
        <div class="search_form">
          <div>
            <span class="search_title">参数名:</span>
            <el-input v-model="secSearch.settingName" style="width: 180px" placeholder="请输入" clearable @change="loadSecurity" />
            <el-button type="primary" style="margin-left: 10px" @click="loadSecurity">搜索</el-button>
          </div>
          <div>
            <el-button @click="loadSecurity">刷新</el-button>
          </div>
        </div>
        <el-table :data="secData" :loading="secLoading" border size="small">
          <el-table-column prop="settingKey" label="参数键" width="230" />
          <el-table-column prop="settingName" label="参数名" width="160" />
          <el-table-column prop="settingValue" label="当前值" width="140" />
          <el-table-column prop="defaultValue" label="默认值" width="110" />
          <el-table-column prop="description" label="说明" show-overflow-tooltip />
          <el-table-column label="操作" width="90" align="center">
            <template #default="{ row }">
              <el-button link type="primary" @click="openEdit(row)">修改</el-button>
            </template>
          </el-table-column>
        </el-table>
      </el-tab-pane>
    </el-tabs>
    <!-- ===== å‚数编辑弹窗 ===== -->
    <el-dialog v-model="editVisible" title="修改安全策略" width="560px">
      <el-form :model="editForm" label-position="top">
        <el-form-item label="参数键"><el-input v-model="editForm.settingKey" disabled /></el-form-item>
        <el-form-item :label="editForm.settingName">
          <el-input v-model="editForm.settingValue" placeholder="请输入" />
          <div style="color: #909399; font-size: 12px; margin-top: 4px">{{ editForm.description }}</div>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="editVisible = false">取消</el-button>
        <el-button type="primary" @click="submitEdit">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { listAuditLog } from "@/api/lims/auditLog";
import { listSetting, updateSetting } from "@/api/lims/setting";
const { proxy } = getCurrentInstance();
const router = useRouter();
const activeTab = ref("audit");
/* ==================== å®¡è®¡è¿½è¸ª ==================== */
const auditSearch = ref({});
const auditData = ref([]);
const auditLoading = ref(false);
const auditPage = reactive({ pageNum: 1, pageSize: 20, total: 0 });
const auditColumns = ref([
  { label: "时间", prop: "operateTime", width: 160 },
  { label: "模块", prop: "module", width: 110 },
  { label: "动作", prop: "action", width: 140 },
  { label: "业务编号", prop: "bizCode", width: 130 },
  { label: "说明", prop: "description", showOverflowTooltip: true },
  { label: "字段", prop: "fieldName", width: 100 },
  { label: "旧值", prop: "oldValue", width: 100 },
  { label: "新值", prop: "newValue", width: 100 },
  { label: "操作人", prop: "operator", width: 100 },
  { label: "IP", prop: "operatorIp", width: 130 },
  { label: "结果", prop: "execResult", width: 80 },
]);
const loadAudit = () => {
  auditLoading.value = true;
  listAuditLog({ ...auditSearch.value, ...auditPage }).then((res) => {
    auditLoading.value = false;
    auditData.value = res.data.records || [];
    auditPage.total = res.data.total || 0;
  }).catch(() => (auditLoading.value = false));
};
const paginationAudit = (p) => { auditPage.pageNum = p.pageNum; auditPage.pageSize = p.pageSize; loadAudit(); };
/* ==================== è®¿é—®æŽ§åˆ¶ ==================== */
const stat = ref({});
const securityMeasures = ref([
  { item: "密码存储", impl: "BCrypt åŠ ç›å“ˆå¸Œï¼ˆä¸å¯é€†ï¼‰ï¼Œæ•°æ®åº“ä¸­ä¸è½æ˜Žæ–‡", status: "已实施" },
  { item: "接口鉴权", impl: "JWT Token + Spring Security æ‹¦æˆªï¼ŒæœªæŽˆæƒè¿”回 401", status: "已实施" },
  { item: "菜单/按钮权限", impl: "用户→角色→菜单三级,按钮级 perms æ ¡éªŒ", status: "已实施" },
  { item: "数据隔离", impl: "多租户插件自动拼接 tenant_id æ¡ä»¶", status: "已实施" },
  { item: "敏感操作审计", impl: "样品出入库、实验资源分配、告警处理写 lims_audit_log", status: "已实施" },
  { item: "审计留痕内容", impl: "操作人、IP、字段变更前后值、操作结果", status: "已实施" },
  { item: "登录日志", impl: "记录每次登录的 IP、地点、浏览器、成功失败", status: "已实施" },
  { item: "字段级加密", impl: "未做,如客户有等保要求需另行开发", status: "待定" },
  { item: "列表脱敏显示", impl: "参数 security.maskSensitive å·²é¢„留,前端展示未实现", status: "待定" },
]);
const goSystem = (path) => { router.push("/system/" + path); };
const goMonitor = (path) => { router.push("/monitor/" + path); };
// ç»Ÿè®¡ï¼ˆç”¨å·²æœ‰çš„æŽ¥å£é¡ºæ‰‹å–数,失败不影响展示)
const loadStat = () => {
  Promise.all([
    import("@/api/system/user").then((m) => m.listUser({ pageNum: 1, pageSize: 1 })).catch(() => null),
    import("@/api/system/role").then((m) => m.listRole({ pageNum: 1, pageSize: 1 })).catch(() => null),
    import("@/api/system/menu").then((m) => m.listMenu({})).catch(() => null),
  ]).then(([u, r, m]) => {
    stat.value = {
      userCount: u && u.total !== undefined ? u.total : (u && u.data && u.data.total),
      roleCount: r && r.total !== undefined ? r.total : (r && r.data && r.data.total),
      menuCount: Array.isArray(m) ? m.length : (m && m.data ? m.data.length : undefined),
    };
  }).catch(() => {});
};
/* ==================== å®‰å…¨ç­–ç•¥ ==================== */
const secSearch = ref({});
const secData = ref([]);
const secLoading = ref(false);
const editVisible = ref(false);
const editForm = ref({});
const loadSecurity = () => {
  secLoading.value = true;
  listSetting({ ...secSearch.value, category: "security", pageNum: 1, pageSize: 50 }).then((res) => {
    secLoading.value = false;
    secData.value = res.data.records || [];
  }).catch(() => (secLoading.value = false));
};
const openEdit = (row) => {
  editForm.value = { ...row };
  editVisible.value = true;
};
const submitEdit = () => {
  updateSetting(editForm.value).then(() => {
    ElMessage.success("已保存,刷新页面后生效");
    editVisible.value = false;
    loadSecurity();
  });
};
onMounted(() => {
  loadAudit();
  loadStat();
  loadSecurity();
});
</script>
<style scoped>
.search_form {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: #606266;
}
</style>