tuqin
2026-10-29 19636a3507391a5ac137bb1f46a413ff662e1177
feat(LIMS): 样品/实验/数据接口三页面增强

样品管理:加「出库」按钮与操作列项、出库弹窗(数量/领用方/去向/事由/关联实验)、
详情抽屉(基本信息 + 出入库流水表 + 状态变更时间轴)。

实验管理:加「分配资源」按钮与操作列项、详情抽屉(基本信息 + 进度条 + 流程节点时间轴
可标记完成 + 已分配资源表)、资源分配弹窗(仪器/试剂/人员三类,试剂显示库存)、
流程编辑弹窗(可套用三套 SOP 模板、增删步骤、填 SOP 内容与预计耗时)。

数据接口管理:操作列加 测试/采集/字段映射/日志 四项;字段映射弹窗(源字段+JSON路径+
目标字段+转换方式+转换参数+默认值,可增删行);调用日志抽屉(时间/类型/结果/耗时/
写入数/错误信息,带分页)。

新增 6 个 API 文件,3 个现有 API 追加方法。
已添加6个文件
已修改6个文件
1080 ■■■■■ 文件已修改
src/api/lims/dataInterface.js 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/experiment.js 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/experimentResource.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/experimentStep.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/interfaceField.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/interfaceLog.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/sample.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/sampleStatusLog.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/sampleStockRecord.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/lims/dataInterfaceManagement/index.vue 247 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/lims/experimentManagement/index.vue 358 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/lims/sampleManagement/index.vue 238 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/dataInterface.js
@@ -44,3 +44,30 @@
        data: ids
    })
}
// ===== ç¬¬äºŒæ‰¹éœ€æ±‚新增 =====
// æµ‹è¯•连接
export function testInterface(id) {
    return request({ url: '/lims/dataInterface/test/' + id, method: 'post' })
}
// ç«‹å³é‡‡é›†ä¸€æ¬¡
export function collectInterface(id) {
    return request({ url: '/lims/dataInterface/collect/' + id, method: 'post' })
}
// æŸ¥è¯¢å­—段映射
export function listInterfaceFields(interfaceId) {
    return request({ url: '/lims/dataInterface/fields/' + interfaceId, method: 'get' })
}
// æ‰¹é‡ä¿å­˜å­—段映射
export function saveInterfaceFields(data) {
    return request({ url: '/lims/dataInterface/field/saveBatch', method: 'post', data: data })
}
// æŸ¥è¯¢è°ƒç”¨æ—¥å¿—
export function listInterfaceLogs(query) {
    return request({ url: '/lims/dataInterface/logs', method: 'get', params: query })
}
src/api/lims/experiment.js
@@ -44,3 +44,35 @@
        data: ids
    })
}
// ===== ç¬¬äºŒæ‰¹éœ€æ±‚新增 =====
// å®žéªŒè¯¦æƒ…(含流程节点与资源分配)
export function getExperimentDetail(id) {
    return request({ url: '/lims/experiment/detail/' + id, method: 'get' })
}
// æ‰¹é‡ä¿å­˜å®žéªŒæµç¨‹èŠ‚ç‚¹
export function saveExperimentSteps(data) {
    return request({ url: '/lims/experiment/step/saveBatch', method: 'post', data: data })
}
// å®Œæˆå®žéªŒæ­¥éª¤
export function completeExperimentStep(data) {
    return request({ url: '/lims/experiment/step/complete', method: 'post', data: data })
}
// ä¸ºå®žéªŒåˆ†é…èµ„源
export function assignExperimentResource(data) {
    return request({ url: '/lims/experiment/assignResource', method: 'post', data: data })
}
// ç§»é™¤å®žéªŒèµ„源
export function removeExperimentResource(id) {
    return request({ url: '/lims/experiment/resource/' + id, method: 'delete' })
}
// èŽ·å–æµç¨‹æ¨¡æ¿
export function getStepTemplate(type) {
    return request({ url: '/lims/experiment/stepTemplate', method: 'get', params: { type } })
}
src/api/lims/experimentResource.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
// å®žéªŒèµ„源分配接口
import request from '@/utils/request'
export function listExperimentResource(query) {
    return request({ url: '/lims/experimentResource/listPage', method: 'get', params: query })
}
export function listAllExperimentResource(query) {
    return request({ url: '/lims/experimentResource/listAll', method: 'get', params: query })
}
export function getExperimentResource(id) {
    return request({ url: '/lims/experimentResource/' + id, method: 'get' })
}
export function addExperimentResource(data) {
    return request({ url: '/lims/experimentResource/add', method: 'post', data: data })
}
export function updateExperimentResource(data) {
    return request({ url: '/lims/experimentResource/update', method: 'post', data: data })
}
export function delExperimentResource(ids) {
    return request({ url: '/lims/experimentResource/delete', method: 'delete', data: ids })
}
src/api/lims/experimentStep.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
// å®žéªŒæµç¨‹èŠ‚ç‚¹æŽ¥å£
import request from '@/utils/request'
export function listExperimentStep(query) {
    return request({ url: '/lims/experimentStep/listPage', method: 'get', params: query })
}
export function listAllExperimentStep(query) {
    return request({ url: '/lims/experimentStep/listAll', method: 'get', params: query })
}
export function getExperimentStep(id) {
    return request({ url: '/lims/experimentStep/' + id, method: 'get' })
}
export function addExperimentStep(data) {
    return request({ url: '/lims/experimentStep/add', method: 'post', data: data })
}
export function updateExperimentStep(data) {
    return request({ url: '/lims/experimentStep/update', method: 'post', data: data })
}
export function delExperimentStep(ids) {
    return request({ url: '/lims/experimentStep/delete', method: 'delete', data: ids })
}
src/api/lims/interfaceField.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
// æŽ¥å£å­—段映射接口
import request from '@/utils/request'
export function listInterfaceField(query) {
    return request({ url: '/lims/interfaceField/listPage', method: 'get', params: query })
}
export function listAllInterfaceField(query) {
    return request({ url: '/lims/interfaceField/listAll', method: 'get', params: query })
}
export function getInterfaceField(id) {
    return request({ url: '/lims/interfaceField/' + id, method: 'get' })
}
export function addInterfaceField(data) {
    return request({ url: '/lims/interfaceField/add', method: 'post', data: data })
}
export function updateInterfaceField(data) {
    return request({ url: '/lims/interfaceField/update', method: 'post', data: data })
}
export function delInterfaceField(ids) {
    return request({ url: '/lims/interfaceField/delete', method: 'delete', data: ids })
}
src/api/lims/interfaceLog.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
// æŽ¥å£è°ƒç”¨æ—¥å¿—接口
import request from '@/utils/request'
export function listInterfaceLog(query) {
    return request({ url: '/lims/interfaceLog/listPage', method: 'get', params: query })
}
export function listAllInterfaceLog(query) {
    return request({ url: '/lims/interfaceLog/listAll', method: 'get', params: query })
}
export function getInterfaceLog(id) {
    return request({ url: '/lims/interfaceLog/' + id, method: 'get' })
}
export function addInterfaceLog(data) {
    return request({ url: '/lims/interfaceLog/add', method: 'post', data: data })
}
export function updateInterfaceLog(data) {
    return request({ url: '/lims/interfaceLog/update', method: 'post', data: data })
}
export function delInterfaceLog(ids) {
    return request({ url: '/lims/interfaceLog/delete', method: 'delete', data: ids })
}
src/api/lims/sample.js
@@ -44,3 +44,25 @@
        data: ids
    })
}
// ===== ç¬¬äºŒæ‰¹éœ€æ±‚新增 =====
// æ ·å“è¯¦æƒ…(含出入库流水与状态变更历史)
export function getSampleDetail(id) {
    return request({ url: '/lims/sample/detail/' + id, method: 'get' })
}
// æ ·å“å‡ºåº“
export function outStockSample(data) {
    return request({ url: '/lims/sample/outStock', method: 'post', data: data })
}
// æ ·å“å…¥åº“
export function inStockSample(data) {
    return request({ url: '/lims/sample/inStock', method: 'post', data: data })
}
// æ ·å“çŠ¶æ€æµè½¬
export function changeSampleStatus(data) {
    return request({ url: '/lims/sample/changeStatus', method: 'post', data: data })
}
src/api/lims/sampleStatusLog.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
// æ ·å“çŠ¶æ€å˜æ›´åŽ†å²æŽ¥å£
import request from '@/utils/request'
export function listSampleStatusLog(query) {
    return request({ url: '/lims/sampleStatusLog/listPage', method: 'get', params: query })
}
export function listAllSampleStatusLog(query) {
    return request({ url: '/lims/sampleStatusLog/listAll', method: 'get', params: query })
}
export function getSampleStatusLog(id) {
    return request({ url: '/lims/sampleStatusLog/' + id, method: 'get' })
}
export function addSampleStatusLog(data) {
    return request({ url: '/lims/sampleStatusLog/add', method: 'post', data: data })
}
export function updateSampleStatusLog(data) {
    return request({ url: '/lims/sampleStatusLog/update', method: 'post', data: data })
}
export function delSampleStatusLog(ids) {
    return request({ url: '/lims/sampleStatusLog/delete', method: 'delete', data: ids })
}
src/api/lims/sampleStockRecord.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
// æ ·å“å‡ºå…¥åº“流水接口
import request from '@/utils/request'
export function listSampleStockRecord(query) {
    return request({ url: '/lims/sampleStockRecord/listPage', method: 'get', params: query })
}
export function listAllSampleStockRecord(query) {
    return request({ url: '/lims/sampleStockRecord/listAll', method: 'get', params: query })
}
export function getSampleStockRecord(id) {
    return request({ url: '/lims/sampleStockRecord/' + id, method: 'get' })
}
export function addSampleStockRecord(data) {
    return request({ url: '/lims/sampleStockRecord/add', method: 'post', data: data })
}
export function updateSampleStockRecord(data) {
    return request({ url: '/lims/sampleStockRecord/update', method: 'post', data: data })
}
export function delSampleStockRecord(ids) {
    return request({ url: '/lims/sampleStockRecord/delete', method: 'delete', data: ids })
}
src/views/lims/dataInterfaceManagement/index.vue
@@ -204,6 +204,88 @@
      </template>
    </el-dialog>
  </div>
    <!-- ===== å­—段映射弹窗 ===== -->
    <el-dialog v-model="fieldVisible" :title="'字段映射 â€”— ' + (fieldInterface.interfaceName || '')" width="1000px">
      <el-alert type="info" :closable="false" style="margin-bottom: 12px">
        <template #title>
          è®¾å¤‡è¿”回 JSON æ—¶ï¼Œç”¨ã€ŒJSON è·¯å¾„」取值(如 $.data.temperature);返回纯文本时只需填「源字段名」,整段文本会作为值。
        </template>
      </el-alert>
      <el-table :data="fieldData" border size="small" max-height="420">
        <el-table-column label="源字段名" width="150">
          <template #default="{ row }"><el-input v-model="row.sourceField" placeholder="如 temp" size="small" /></template>
        </el-table-column>
        <el-table-column label="JSON è·¯å¾„" width="180">
          <template #default="{ row }"><el-input v-model="row.sourcePath" placeholder="如 $.data.temp" size="small" /></template>
        </el-table-column>
        <el-table-column label="目标字段" width="140">
          <template #default="{ row }">
            <el-select v-model="row.targetField" size="small" style="width: 100%">
              <el-option v-for="t in TARGET_FIELDS" :key="t.value" :label="t.label" :value="t.value" />
            </el-select>
          </template>
        </el-table-column>
        <el-table-column label="转换方式" width="130">
          <template #default="{ row }">
            <el-select v-model="row.transformType" size="small" style="width: 100%">
              <el-option v-for="t in TRANSFORM_TYPES" :key="t.value" :label="t.label" :value="t.value" />
            </el-select>
          </template>
        </el-table-column>
        <el-table-column label="转换参数" width="130">
          <template #default="{ row }"><el-input v-model="row.transformValue" placeholder="系数/偏移/映射JSON" size="small" /></template>
        </el-table-column>
        <el-table-column label="默认值" width="110">
          <template #default="{ row }"><el-input v-model="row.defaultValue" size="small" /></template>
        </el-table-column>
        <el-table-column label="操作" width="70" align="center">
          <template #default="{ $index }">
            <el-button link type="danger" @click="removeFieldRow($index)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button @click="addFieldRow">新增一行</el-button>
        <el-button @click="fieldVisible = false">取消</el-button>
        <el-button type="primary" @click="saveFields">保存映射</el-button>
      </template>
    </el-dialog>
    <!-- ===== è°ƒç”¨æ—¥å¿—抽屉 ===== -->
    <el-drawer v-model="logVisible" title="接口调用日志" size="900px" destroy-on-close>
      <el-table :data="logData" :loading="logLoading" border size="small" max-height="620">
        <el-table-column prop="invokeTime" label="时间" width="160" />
        <el-table-column prop="interfaceName" label="接口" width="160" show-overflow-tooltip />
        <el-table-column prop="invokeType" label="类型" width="80">
          <template #default="{ row }">{{ { test: "测试", manual: "手动", auto: "定时" }[row.invokeType] || row.invokeType }}</template>
        </el-table-column>
        <el-table-column prop="success" label="结果" width="70">
          <template #default="{ row }">
            <el-tag :type="row.success === '1' ? 'success' : 'danger'" size="small">{{ row.success === "1" ? "成功" : "失败" }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="durationMs" label="耗时" width="80">
          <template #default="{ row }">{{ row.durationMs }} ms</template>
        </el-table-column>
        <el-table-column prop="recordCount" label="写入" width="70" />
        <el-table-column prop="errorMsg" label="错误信息 / å“åº”摘要" show-overflow-tooltip>
          <template #default="{ row }">{{ row.errorMsg || row.responseData || "—" }}</template>
        </el-table-column>
        <el-table-column prop="operator" label="触发人" width="90" />
      </el-table>
      <div style="margin-top: 12px; text-align: right">
        <el-pagination
          v-model:current-page="logQuery.pageNum"
          v-model:page-size="logQuery.pageSize"
          :total="logTotal"
          :page-sizes="[20, 50, 100]"
          layout="total, sizes, prev, pager, next"
          @current-change="loadLogs"
          @size-change="loadLogs"
        />
      </div>
    </el-drawer>
</template>
<script setup>
@@ -216,8 +298,9 @@
  listDataInterface,
  updateDataInterface,
} from "@/api/lims/dataInterface.js";
import { ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox, ElLoading } from "element-plus";
import { userListNoPage } from "@/api/system/user.js";
import { testInterface, collectInterface, listInterfaceFields, saveInterfaceFields, listInterfaceLogs } from "@/api/lims/dataInterface";
import useUserStore from "@/store/modules/user";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
@@ -273,22 +356,39 @@
    width: 100,
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: 'right',
      width: 300,
      operation: [
        {
          name: "测试",
          type: "text",
          clickFun: (row) => { doTest(row); },
        },
        disabled: (row) => {
          return row.maintainer !== userStore.nickName
        }
      },
    ],
        {
          name: "采集",
          type: "text",
          clickFun: (row) => { doCollect(row); },
        },
        {
          name: "字段映射",
          type: "text",
          clickFun: (row) => { openFields(row); },
        },
        {
          name: "日志",
          type: "text",
          clickFun: (row) => { openLogs(row); },
        },
        {
          name: "编辑",
          type: "text",
          clickFun: (row) => { openForm("edit", row); },
          disabled: (row) => row.maintainer !== userStore.nickName,
        },
      ],
  },
]);
const tableData = ref([]);
@@ -471,6 +571,121 @@
  return `${year}-${month}-${day}`;
}
// ==================== ç¬¬äºŒæ‰¹éœ€æ±‚:测试/采集/字段映射/日志 ====================
const fieldVisible = ref(false);
const fieldData = ref([]);
const fieldInterface = ref({});
const logVisible = ref(false);
const logData = ref([]);
const logLoading = ref(false);
const logTotal = ref(0);
const logQuery = ref({ pageNum: 1, pageSize: 20 });
const collecting = ref(false);
const TARGET_FIELDS = [
  { value: "collectionValue", label: "采集数值" },
  { value: "deviceName", label: "设备名称" },
  { value: "deviceCode", label: "设备编号" },
  { value: "dataType", label: "数据类型" },
  { value: "unit", label: "计量单位" },
  { value: "rawData", label: "原始数据" },
  { value: "processedData", label: "处理后数据" },
];
const TRANSFORM_TYPES = [
  { value: "none", label: "直接使用" },
  { value: "multiply", label: "乘以系数" },
  { value: "divide", label: "除以系数" },
  { value: "offset", label: "加偏移量" },
  { value: "map", label: "字典映射" },
];
// æµ‹è¯•连接
const doTest = (row) => {
  const loading = ElLoading.service({ text: "正在测试连接…", background: "rgba(0,0,0,0.3)" });
  testInterface(row.id).then((res) => {
    loading.close();
    ElMessageBox.alert(res.msg || "连接成功", "测试结果", { type: "success" });
  }).catch((e) => {
    loading.close();
    const m = (e && e.msg) || (e && e.response && e.response.data && e.response.data.msg) || "连接失败";
    ElMessageBox.alert(m, "测试结果", { type: "error" });
  });
};
// ç«‹å³é‡‡é›†
const doCollect = (row) => {
  ElMessageBox.confirm(`确认对「${row.interfaceName}」立即采集一次数据?`, "提示", { type: "info" }).then(() => {
    collecting.value = true;
    const loading = ElLoading.service({ text: "正在采集…", background: "rgba(0,0,0,0.3)" });
    collectInterface(row.id).then((res) => {
      loading.close();
      collecting.value = false;
      const d = res.data || {};
      let msg = res.msg || "采集完成";
      if (d.rawLength !== undefined) msg += `\n响应长度:${d.rawLength} å­—符`;
      if (d.warnings && d.warnings.length) msg += "\n提示:" + d.warnings.join(";");
      ElMessageBox.alert(msg.replace(/\n/g, "<br/>"), "采集结果", {
        type: "success",
        dangerouslyUseHTMLString: true,
      });
    }).catch((e) => {
      loading.close();
      collecting.value = false;
      const m = (e && e.msg) || "采集失败";
      ElMessageBox.alert(m, "采集结果", { type: "error" });
    });
  }).catch(() => {});
};
// å­—段映射
const openFields = (row) => {
  fieldInterface.value = row;
  fieldData.value = [];
  fieldVisible.value = true;
  listInterfaceFields(row.id).then((res) => {
    const list = res.data || [];
    fieldData.value = list.length ? list.map((x) => ({ ...x })) : [newFieldRow()];
  });
};
const newFieldRow = () => ({
  sourceField: "",
  sourcePath: "",
  targetField: "collectionValue",
  transformType: "none",
  transformValue: "",
  defaultValue: "",
  remark: "",
});
const addFieldRow = () => { fieldData.value.push(newFieldRow()); };
const removeFieldRow = (i) => { fieldData.value.splice(i, 1); };
const saveFields = () => {
  const rows = fieldData.value.filter((r) => r.sourceField && r.sourceField.trim());
  if (!rows.length) { ElMessage.warning("至少配置一条字段映射"); return; }
  saveInterfaceFields({
    interfaceId: fieldInterface.value.id,
    fields: rows.map((r, i) => ({ ...r, interfaceCode: fieldInterface.value.interfaceCode, sortNo: i + 1 })),
  }).then((res) => {
    ElMessage.success(res.msg || "已保存");
    fieldVisible.value = false;
  });
};
// è°ƒç”¨æ—¥å¿—
const openLogs = (row) => {
  logQuery.value = { pageNum: 1, pageSize: 20, interfaceId: row ? row.id : undefined };
  logData.value = [];
  logVisible.value = true;
  loadLogs();
};
const loadLogs = () => {
  logLoading.value = true;
  listInterfaceLogs(logQuery.value).then((res) => {
    logLoading.value = false;
    logData.value = res.data.records || [];
    logTotal.value = res.data.total || 0;
  }).catch(() => (logLoading.value = false));
};
onMounted(() => {
  getList();
});
src/views/lims/experimentManagement/index.vue
@@ -38,6 +38,7 @@
      </div>
      <div>
        <el-button type="primary" @click="openForm('add')">新增实验</el-button>
        <el-button type="success" plain @click="openResource()">分配资源</el-button>
        <el-button @click="handleOut">导出</el-button>
        <el-button type="danger" plain @click="handleDelete">删除</el-button>
      </div>
@@ -248,6 +249,153 @@
      </template>
    </el-dialog>
  </div>
    <!-- ===== å®žéªŒè¯¦æƒ…抽屉:流程节点 + èµ„源分配 ===== -->
    <el-drawer v-model="detailVisible" title="实验详情" size="900px" destroy-on-close>
      <div v-if="detailData.experiment" style="padding: 0 12px">
        <el-descriptions :column="3" border size="small" title="基本信息">
          <el-descriptions-item label="实验编号">{{ detailData.experiment.experimentCode }}</el-descriptions-item>
          <el-descriptions-item label="实验名称">{{ detailData.experiment.experimentName }}</el-descriptions-item>
          <el-descriptions-item label="实验类型">{{ detailData.experiment.experimentType }}</el-descriptions-item>
          <el-descriptions-item label="实验标准">{{ detailData.experiment.experimentStandard }}</el-descriptions-item>
          <el-descriptions-item label="负责人">{{ detailData.experiment.responsiblePerson }}</el-descriptions-item>
          <el-descriptions-item label="状态">
            <el-tag :type="expStatusTag(detailData.experiment.experimentStatus)" size="small">{{ expStatusText(detailData.experiment.experimentStatus) }}</el-tag>
          </el-descriptions-item>
          <el-descriptions-item label="计划时间" :span="2">{{ detailData.experiment.planStartDate }} ~ {{ detailData.experiment.planEndDate }}</el-descriptions-item>
          <el-descriptions-item label="流程进度">
            <el-progress :percentage="detailData.progress || 0" :stroke-width="14" style="width: 160px" />
            <span style="margin-left: 8px; color: #909399">{{ detailData.stepCompleted || 0 }}/{{ detailData.stepTotal || 0 }} æ­¥</span>
          </el-descriptions-item>
        </el-descriptions>
        <el-divider content-position="left">
          å®žéªŒæµç¨‹èŠ‚ç‚¹ï¼ˆ{{ (detailData.steps || []).length }} æ­¥ï¼‰
          <el-button link type="primary" style="margin-left: 8px" @click="openStepEditor">编辑流程</el-button>
        </el-divider>
        <el-timeline v-if="(detailData.steps || []).length">
          <el-timeline-item
            v-for="s in detailData.steps"
            :key="s.id"
            :timestamp="'第 ' + s.stepNo + ' æ­¥ã€€' + (s.actualEndTime || s.planStartTime || '')"
            :type="s.stepStatus === 'completed' ? 'success' : s.stepStatus === 'running' ? 'warning' : 'info'"
            placement="top"
          >
            <div style="font-weight: 600">{{ s.stepName }}
              <el-tag :type="stepStatusTag(s.stepStatus)" size="small" style="margin-left: 8px">{{ stepStatusText(s.stepStatus) }}</el-tag>
              <el-button v-if="s.stepStatus !== 'completed'" link type="primary" size="small" style="margin-left: 8px" @click="doCompleteStep(s)">标记完成</el-button>
            </div>
            <div style="color: #606266; font-size: 13px; margin-top: 4px; white-space: pre-wrap">{{ s.stepContent }}</div>
            <div v-if="s.stepResult" style="color: #67c23a; font-size: 12px; margin-top: 4px">执行结果:{{ s.stepResult }}</div>
            <div v-if="s.executor" style="color: #909399; font-size: 12px">执行人:{{ s.executor }}</div>
          </el-timeline-item>
        </el-timeline>
        <el-empty v-else description="尚未配置流程节点,点右上「编辑流程」或套用模板" :image-size="60" />
        <el-divider content-position="left">已分配资源({{ (detailData.resources || []).length }} é¡¹ï¼‰</el-divider>
        <el-table :data="detailData.resources || []" border size="small" max-height="260">
          <el-table-column prop="resourceType" label="类型" width="90">
            <template #default="{ row }">{{ resTypeText(row.resourceType) }}</template>
          </el-table-column>
          <el-table-column prop="resourceName" label="名称" show-overflow-tooltip />
          <el-table-column prop="quantity" label="数量" width="90" />
          <el-table-column prop="unit" label="单位" width="70" />
          <el-table-column prop="usageTime" label="使用时段" width="150" show-overflow-tooltip />
          <el-table-column prop="status" label="状态" width="90">
            <template #default="{ row }">{{ resStatusText(row.status) }}</template>
          </el-table-column>
          <el-table-column label="操作" width="80" align="center">
            <template #default="{ row }">
              <el-button link type="danger" @click="doRemoveResource(row)">移除</el-button>
            </template>
          </el-table-column>
        </el-table>
      </div>
    </el-drawer>
    <!-- ===== èµ„源分配弹窗 ===== -->
    <el-dialog v-model="resourceVisible" title="为实验分配资源" width="600px">
      <el-form :model="resForm" label-position="top">
        <el-form-item label="实验">
          <el-input :model-value="resForm.experimentName + '(' + resForm.experimentCode + ')'" disabled />
        </el-form-item>
        <el-form-item label="资源类型">
          <el-radio-group v-model="resForm.resourceType" @change="onResTypeChange">
            <el-radio-button label="instrument">仪器设备</el-radio-button>
            <el-radio-button label="reagent">试剂耗材</el-radio-button>
            <el-radio-button label="personnel">参与人员</el-radio-button>
          </el-radio-group>
        </el-form-item>
        <el-form-item v-if="resForm.resourceType !== 'personnel'" :label="resForm.resourceType === 'instrument' ? '选择仪器' : '选择试剂/耗材'">
          <el-select v-model="resForm.resourceId" filterable style="width: 100%" placeholder="请选择" @change="onResSelect">
            <el-option
              v-for="o in resOptions"
              :key="o.id"
              :label="resOptionLabel(o)"
              :value="o.id"
            />
          </el-select>
        </el-form-item>
        <el-form-item v-else label="人员姓名">
          <el-input v-model="resForm.resourceName" placeholder="请输入参与人" />
        </el-form-item>
        <el-row :gutter="16">
          <el-col :span="12">
            <el-form-item :label="resForm.resourceType === 'reagent' ? '领用数量(会扣减库存)' : '数量'">
              <el-input-number v-model="resForm.quantity" :precision="4" :min="0" style="width: 100%" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="单位">
              <el-input v-model="resForm.unit" placeholder="如 å° / mL / äºº" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="使用时段">
          <el-input v-model="resForm.usageTime" placeholder="如 2026-10-29 09:00~12:00" />
        </el-form-item>
        <el-form-item label="备注">
          <el-input v-model="resForm.remark" type="textarea" :rows="2" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="resourceVisible = false">取消</el-button>
        <el-button type="primary" @click="submitResource">确认分配</el-button>
      </template>
    </el-dialog>
    <!-- ===== æµç¨‹ç¼–辑弹窗 ===== -->
    <el-dialog v-model="stepEditorVisible" title="编辑实验流程" width="900px">
      <div style="margin-bottom: 12px">
        <el-select v-model="templateType" placeholder="套用流程模板" style="width: 240px" clearable @change="applyTemplate">
          <el-option label="化学分析(通用)" value="chemicalAnalysis" />
          <el-option label="物理测试(通用)" value="physicalTest" />
          <el-option label="微生物检测(通用)" value="microbialTest" />
        </el-select>
        <el-button type="primary" plain style="margin-left: 10px" @click="addStepRow">新增步骤</el-button>
      </div>
      <el-table :data="stepEditorData" border size="small" max-height="420">
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column label="步骤名称" width="180">
          <template #default="{ row }"><el-input v-model="row.stepName" placeholder="请输入" /></template>
        </el-table-column>
        <el-table-column label="标准操作内容(SOP)">
          <template #default="{ row }"><el-input v-model="row.stepContent" type="textarea" :rows="2" placeholder="请输入" /></template>
        </el-table-column>
        <el-table-column label="耗时(分钟)" width="110">
          <template #default="{ row }"><el-input-number v-model="row.expectedDuration" :min="0" controls-position="right" style="width: 100%" /></template>
        </el-table-column>
        <el-table-column label="操作" width="70" align="center">
          <template #default="{ $index }">
            <el-button link type="danger" @click="removeStepRow($index)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button @click="stepEditorVisible = false">取消</el-button>
        <el-button type="primary" @click="saveSteps">保存流程</el-button>
      </template>
    </el-dialog>
</template>
<script setup>
@@ -260,8 +408,11 @@
  listExperiment,
  updateExperiment,
} from "@/api/lims/experiment.js";
import { ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { userListNoPage } from "@/api/system/user.js";
import { getExperimentDetail, assignExperimentResource, saveExperimentSteps, completeExperimentStep, getStepTemplate } from "@/api/lims/experiment";
import { listInstrument } from "@/api/lims/instrument";
import { listReagent } from "@/api/lims/reagent";
import useUserStore from "@/store/modules/user";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
@@ -317,22 +468,36 @@
    width: 100,
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: 'right',
      operation: [
        {
          name: "详情",
          type: "text",
          clickFun: (row) => {
            openDetail(row);
          },
        },
        disabled: (row) => {
          return row.maintainer !== userStore.nickName
        }
      },
    ],
        {
          name: "分配资源",
          type: "text",
          clickFun: (row) => {
            openResource(row);
          },
        },
        {
          name: "编辑",
          type: "text",
          clickFun: (row) => {
            openForm("edit", row);
          },
          disabled: (row) => {
            return row.maintainer !== userStore.nickName
          }
        },
      ],
  },
]);
const tableData = ref([]);
@@ -518,6 +683,167 @@
  return `${year}-${month}-${day}`;
}
// ==================== ç¬¬äºŒæ‰¹éœ€æ±‚:详情 / æ­¥éª¤ / èµ„源 ====================
const detailVisible = ref(false);
const detailData = ref({ experiment: null, steps: [], resources: [], progress: 0, stepTotal: 0, stepCompleted: 0 });
const resourceVisible = ref(false);
const resOptions = ref([]);
const resForm = ref({});
const stepEditorVisible = ref(false);
const stepEditorData = ref([]);
const templateType = ref("");
const EXP_STATUS_MAP = { planned: "计划中", inProgress: "进行中", completed: "已完成", cancelled: "已取消" };
const STEP_STATUS_MAP = { pending: "待执行", running: "执行中", completed: "已完成", skipped: "已跳过", failed: "失败" };
const RES_TYPE_MAP = { instrument: "仪器设备", reagent: "试剂耗材", personnel: "参与人员" };
const RES_STATUS_MAP = { planned: "已安排", inuse: "使用中", returned: "已归还", consumed: "已消耗" };
const expStatusText = (s) => EXP_STATUS_MAP[s] || s || "—";
const stepStatusText = (s) => STEP_STATUS_MAP[s] || s || "—";
const resTypeText = (s) => RES_TYPE_MAP[s] || s || "—";
const resStatusText = (s) => RES_STATUS_MAP[s] || s || "—";
const expStatusTag = (s) => (s === "completed" ? "success" : s === "inProgress" ? "warning" : s === "cancelled" ? "danger" : "info");
const stepStatusTag = (s) => (s === "completed" ? "success" : s === "running" ? "warning" : s === "failed" ? "danger" : "info");
// è¯¦æƒ…
const openDetail = (row) => {
  detailData.value = { experiment: null, steps: [], resources: [], progress: 0, stepTotal: 0, stepCompleted: 0 };
  detailVisible.value = true;
  getExperimentDetail(row.id).then((res) => {
    detailData.value = res.data || detailData.value;
  });
};
// åˆ†é…èµ„源
const openResource = (row) => {
  let target = row;
  if (!target) {
    if (!selectedRows.value.length) { ElMessage.warning("请先选择实验"); return; }
    if (selectedRows.value.length > 1) { ElMessage.warning("一次只能给一个实验分配资源"); return; }
    target = selectedRows.value[0];
  }
  resForm.value = {
    experimentId: target.id,
    experimentCode: target.experimentCode,
    experimentName: target.experimentName,
    resourceType: "instrument",
    resourceId: null,
    resourceName: "",
    quantity: null,
    unit: "",
    usageTime: "",
    remark: "",
  };
  resOptions.value = [];
  loadResOptions("instrument");
  resourceVisible.value = true;
};
const loadResOptions = (type) => {
  if (type === "instrument") {
    listInstrument({ pageNum: 1, pageSize: 500 }).then((r) => { resOptions.value = r.data.records || []; });
  } else if (type === "reagent") {
    listReagent({ pageNum: 1, pageSize: 500 }).then((r) => { resOptions.value = r.data.records || []; });
  } else {
    resOptions.value = [];
  }
};
const onResTypeChange = (t) => {
  resForm.value.resourceId = null;
  resForm.value.resourceName = "";
  loadResOptions(t);
};
const onResSelect = (id) => {
  const o = resOptions.value.find((x) => x.id === id);
  if (!o) return;
  if (resForm.value.resourceType === "reagent") {
    resForm.value.unit = o.unit || "";
    resForm.value.quantity = null;
  } else {
    resForm.value.unit = "台";
    resForm.value.quantity = 1;
  }
};
const resOptionLabel = (o) => {
  if (resForm.value.resourceType === "reagent") {
    return `${o.reagentName}(库存 ${o.currentStock ?? 0}${o.unit || ""})`;
  }
  return `${o.instrumentName}(${o.instrumentCode})`;
};
const submitResource = () => {
  const f = resForm.value;
  if (f.resourceType !== "personnel" && !f.resourceId) { ElMessage.warning("请选择资源"); return; }
  if (f.resourceType === "personnel" && !f.resourceName) { ElMessage.warning("请输入人员姓名"); return; }
  assignExperimentResource({ ...f }).then((res) => {
    ElMessage.success(res.msg || "资源已分配");
    resourceVisible.value = false;
    if (detailVisible.value) openDetail({ id: f.experimentId });
  });
};
const doRemoveResource = (row) => {
  ElMessageBox.confirm("确认移除该资源分配?", "提示", { type: "warning" }).then(() => {
    removeExperimentResource(row.id).then(() => {
      ElMessage.success("已移除");
      openDetail({ id: row.experimentId });
    });
  }).catch(() => {});
};
// æ­¥éª¤
const openStepEditor = () => {
  const steps = detailData.value.steps || [];
  stepEditorData.value = steps.length
    ? steps.map((s) => ({ stepName: s.stepName, stepContent: s.stepContent, expectedDuration: s.expectedDuration }))
    : [];
  templateType.value = "";
  stepEditorVisible.value = true;
};
const addStepRow = () => {
  stepEditorData.value.push({ stepName: "", stepContent: "", expectedDuration: null });
};
const removeStepRow = (i) => { stepEditorData.value.splice(i, 1); };
const saveSteps = () => {
  const exp = detailData.value.experiment;
  if (!exp) { ElMessage.warning("实验不存在"); return; }
  const steps = stepEditorData.value
    .filter((s) => s.stepName && s.stepName.trim())
    .map((s, i) => ({
      stepNo: i + 1,
      stepName: s.stepName,
      stepContent: s.stepContent,
      expectedDuration: s.expectedDuration,
      stepStatus: "pending",
    }));
  if (!steps.length) { ElMessage.warning("至少需要一个步骤"); return; }
  saveExperimentSteps({ experimentId: exp.id, steps }).then((res) => {
    ElMessage.success(res.msg || "流程已保存");
    stepEditorVisible.value = false;
    openDetail({ id: exp.id });
  });
};
const applyTemplate = (type) => {
  if (!type) return;
  getStepTemplate(type).then((res) => {
    const list = res.data || [];
    stepEditorData.value = list.map((s) => ({
      stepName: s.stepName,
      stepContent: s.stepContent,
      expectedDuration: s.expectedDuration,
    }));
    ElMessage.success("已套用模板,共 " + list.length + " æ­¥");
  });
};
const doCompleteStep = (s) => {
  ElMessageBox.prompt("可填写执行结果(选填)", "完成步骤:" + s.stepName, {
    inputPlaceholder: "如 æµ‹å®šå®Œæˆï¼Œç»“æžœ 45.2",
    confirmButtonText: "确认完成",
    cancelButtonText: "取消",
  }).then(({ value }) => {
    completeExperimentStep({ stepId: s.id, stepResult: value }).then(() => {
      ElMessage.success("步骤已完成");
      openDetail({ id: s.experimentId });
    });
  }).catch(() => {});
};
onMounted(() => {
  getList();
});
src/views/lims/sampleManagement/index.vue
@@ -38,6 +38,7 @@
      </div>
      <div>
        <el-button type="primary" @click="openForm('add')">新增样品</el-button>
        <el-button type="warning" plain @click="openOutStock()">出库</el-button>
        <el-button @click="handleOut">导出</el-button>
        <el-button type="danger" plain @click="handleDelete">删除</el-button>
      </div>
@@ -249,6 +250,93 @@
      </template>
    </el-dialog>
  </div>
    <!-- ===== æ ·å“è¯¦æƒ…抽屉:出入库流水 + çŠ¶æ€å˜æ›´åŽ†å² ===== -->
    <el-drawer v-model="detailVisible" title="样品详情" size="820px" destroy-on-close>
      <div v-if="detailData.sample" style="padding: 0 12px">
        <el-descriptions :column="3" border size="small" title="基本信息">
          <el-descriptions-item label="样品编号">{{ detailData.sample.sampleCode }}</el-descriptions-item>
          <el-descriptions-item label="样品名称">{{ detailData.sample.sampleName }}</el-descriptions-item>
          <el-descriptions-item label="类型">{{ detailData.sample.sampleType }}</el-descriptions-item>
          <el-descriptions-item label="规格">{{ detailData.sample.specification }}</el-descriptions-item>
          <el-descriptions-item label="数量">{{ detailData.sample.quantity }} {{ detailData.sample.unit }}</el-descriptions-item>
          <el-descriptions-item label="当前状态">
            <el-tag :type="statusTagType(detailData.sample.sampleStatus)" size="small">{{ statusText(detailData.sample.sampleStatus) }}</el-tag>
          </el-descriptions-item>
          <el-descriptions-item label="存储位置">{{ detailData.sample.storageLocation }}</el-descriptions-item>
          <el-descriptions-item label="入库日期">{{ detailData.sample.inStockDate }}</el-descriptions-item>
          <el-descriptions-item label="有效期至">{{ detailData.sample.validityDate }}</el-descriptions-item>
        </el-descriptions>
        <el-divider content-position="left">出入库流水({{ (detailData.stockRecords || []).length }} æ¡ï¼‰</el-divider>
        <el-table :data="detailData.stockRecords || []" border size="small" max-height="300">
          <el-table-column prop="operateTime" label="时间" width="160" />
          <el-table-column prop="recordType" label="类型" width="80">
            <template #default="{ row }">
              <el-tag :type="row.recordType === 'in' ? 'success' : 'warning'" size="small">{{ recordTypeText(row.recordType) }}</el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="quantity" label="数量" width="90" />
          <el-table-column label="状态变化" width="150">
            <template #default="{ row }">{{ statusText(row.beforeStatus) }} â†’ {{ statusText(row.afterStatus) }}</template>
          </el-table-column>
          <el-table-column prop="operator" label="经手人" width="100" />
          <el-table-column prop="recipient" label="领用方" width="110" show-overflow-tooltip />
          <el-table-column prop="purpose" label="事由" show-overflow-tooltip />
        </el-table>
        <el-divider content-position="left">状态变更历史({{ (detailData.statusLogs || []).length }} æ¡ï¼‰</el-divider>
        <el-timeline v-if="(detailData.statusLogs || []).length">
          <el-timeline-item
            v-for="log in detailData.statusLogs"
            :key="log.id"
            :timestamp="log.operateTime"
            placement="top"
          >
            <div>{{ statusText(log.fromStatus) }} <b>→</b> {{ statusText(log.toStatus) }}</div>
            <div style="color: #909399; font-size: 12px">{{ log.changeReason || "—" }} 操作人:{{ log.operator || "—" }}</div>
          </el-timeline-item>
        </el-timeline>
        <el-empty v-else description="暂无状态变更记录" :image-size="60" />
      </div>
    </el-drawer>
    <!-- ===== æ ·å“å‡ºåº“弹窗 ===== -->
    <el-dialog v-model="outStockVisible" title="样品出库" width="560px">
      <el-form :model="outForm" label-position="top">
        <el-form-item label="样品">
          <el-input :model-value="outForm.sampleName + '(' + outForm.sampleCode + ',现有 ' + (outForm.stock ?? 0) + (outForm.unit || '') + ')'" disabled />
        </el-form-item>
        <el-row :gutter="16">
          <el-col :span="12">
            <el-form-item label="出库数量">
              <el-input-number v-model="outForm.quantity" :precision="4" :min="0.0001" :max="outForm.stock || 999999" style="width: 100%" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="领用方/接收人">
              <el-input v-model="outForm.recipient" placeholder="请输入" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="去向 / æ–°å­˜å‚¨ä½ç½®">
          <el-input v-model="outForm.toLocation" placeholder="如:检测室 A / å®¢æˆ·çŽ°åœº" />
        </el-form-item>
        <el-form-item label="出库事由">
          <el-input v-model="outForm.purpose" placeholder="如:送检、留样转移、销毁前处理" />
        </el-form-item>
        <el-form-item label="关联实验编号(可选)">
          <el-input v-model="outForm.relatedExperiment" placeholder="请输入" />
        </el-form-item>
        <el-form-item label="备注">
          <el-input v-model="outForm.remark" type="textarea" :rows="2" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="outStockVisible = false">取消</el-button>
        <el-button type="primary" @click="submitOutStock">确认出库</el-button>
      </template>
    </el-dialog>
</template>
<script setup>
@@ -261,8 +349,9 @@
  listSample,
  updateSample,
} from "@/api/lims/sample.js";
import { ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { userListNoPage } from "@/api/system/user.js";
import { getSampleDetail, outStockSample } from "@/api/lims/sample";
import useUserStore from "@/store/modules/user";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
@@ -328,22 +417,36 @@
    width: 100,
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        },
        disabled: (row) => {
          return row.maintainer !== userStore.nickName
        }
      },
    ],
        dataType: "action",
        label: "操作",
        align: "center",
        fixed: 'right',
        operation: [
          {
            name: "详情",
            type: "text",
            clickFun: (row) => {
              openDetail(row);
            },
          },
          {
            name: "出库",
            type: "text",
            clickFun: (row) => {
              openOutStock(row);
            },
          },
          {
            name: "编辑",
            type: "text",
            clickFun: (row) => {
              openForm("edit", row);
            },
            disabled: (row) => {
              return row.maintainer !== userStore.nickName
            }
          },
        ],
  },
]);
const tableData = ref([]);
@@ -531,6 +634,107 @@
  return `${year}-${month}-${day}`;
}
// ==================== ç¬¬äºŒæ‰¹éœ€æ±‚:详情 / å‡ºåº“ ====================
const detailVisible = ref(false);
const detailData = ref({ sample: null, stockRecords: [], statusLogs: [] });
const outStockVisible = ref(false);
const outForm = ref({ sampleId: null, sampleCode: "", sampleName: "", stock: 0, unit: "", quantity: null });
const STATUS_MAP = {
  inStock: "在库",
  outStock: "出库",
  testing: "检测中",
  destroyed: "已销毁",
};
const RECORD_TYPE_MAP = {
  in: "入库",
  out: "出库",
  destroy: "销毁",
  return: "退库",
  transfer: "转移",
};
const statusText = (s) => STATUS_MAP[s] || s || "—";
const recordTypeText = (t) => RECORD_TYPE_MAP[t] || t || "—";
const statusTagType = (s) => {
  if (s === "inStock") return "success";
  if (s === "testing") return "warning";
  if (s === "destroyed") return "danger";
  return "info";
};
// æ‰“开详情抽屉
const openDetail = (row) => {
  detailData.value = { sample: null, stockRecords: [], statusLogs: [] };
  detailVisible.value = true;
  getSampleDetail(row.id).then((res) => {
    detailData.value = res.data || { sample: null, stockRecords: [], statusLogs: [] };
  });
};
// æ‰“开出库弹窗(不传 row æ—¶ç”¨è¡¨æ ¼é€‰ä¸­è¡Œï¼‰
const openOutStock = (row) => {
  let target = row;
  if (!target) {
    if (!selectedRows.value.length) {
      ElMessage.warning("请先选择要出库的样品");
      return;
    }
    if (selectedRows.value.length > 1) {
      ElMessage.warning("一次只能出库一个样品,请只选一条");
      return;
    }
    target = selectedRows.value[0];
  }
  if (target.sampleStatus === "destroyed") {
    ElMessage.warning("样品已销毁,不能出库");
    return;
  }
  if (target.sampleStatus === "outStock") {
    ElMessage.warning("样品已是出库状态");
    return;
  }
  outForm.value = {
    sampleId: target.id,
    sampleCode: target.sampleCode,
    sampleName: target.sampleName,
    stock: Number(target.quantity || 0),
    unit: target.unit || "",
    quantity: null,
    recipient: "",
    toLocation: "",
    purpose: "",
    relatedExperiment: "",
    remark: "",
  };
  outStockVisible.value = true;
};
// æäº¤å‡ºåº“
const submitOutStock = () => {
  const f = outForm.value;
  if (!f.quantity || f.quantity <= 0) {
    ElMessage.warning("请输入出库数量");
    return;
  }
  if (f.quantity > f.stock) {
    ElMessage.warning("出库数量超过当前库存(现有 " + f.stock + (f.unit || "") + ")");
    return;
  }
  outStockSample({
    sampleId: f.sampleId,
    quantity: f.quantity,
    recipient: f.recipient,
    toLocation: f.toLocation,
    purpose: f.purpose,
    relatedExperiment: f.relatedExperiment,
    remark: f.remark,
  }).then((res) => {
    ElMessage.success(res.msg || "出库成功");
    outStockVisible.value = false;
    getList();
  });
};
onMounted(() => {
  getList();
});