ZN
5 小时以前 178c6c22dbe2e75cbd02f0732ba3bf742222263d
feat(耗材物流): 新增耗材物流模块相关页面与接口

- 添加耗材入库管理、出库台账、库存管理和库存报表页面
- 实现耗材合格/不合格库存的增删改查功能
- 新增通用分页组件和产品选择对话框组件
- 在首页添加耗材物料模块入口
- 集成耗材物流相关API接口
已添加18个文件
已修改2个文件
3101 ■■■■■ 文件已修改
src/api/consumablesLogistics/consumablesIn.js 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/consumablesLogistics/consumablesOutRecord.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/consumablesLogistics/consumablesUninventory.js 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/PIMTable/Pagination.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProductSelectDialog.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/dispatchLog/Record.vue 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/dispatchLog/index.vue 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/dispatchLog/view.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/receiptManagement/Record.vue 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/receiptManagement/index.vue 231 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/receiptManagement/view.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/stockManagement/Qualified.vue 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/stockManagement/Unqualified.vue 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/stockManagement/add.vue 283 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/stockManagement/index.vue 319 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/stockManagement/subtract.vue 209 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/stockManagement/view.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/consumablesLogistics/stockReport/index.vue 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/index.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/consumablesLogistics/consumablesIn.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
import request from "@/utils/request";
export const getConsumablesInListPage = (params) => {
  return request({
    url: "/consumablesInventory/pageConsumablesInventory",
    method: "get",
    params,
  });
};
export const createConsumablesIn = (params) => {
  return request({
    url: "/consumablesInventory/addConsumablesInventory",
    method: "post",
    data: params,
  });
};
export const subtractConsumablesIn = (params) => {
  return request({
    url: "/consumablesInventory/subtractConsumablesInventory",
    method: "post",
    data: params,
  });
};
export const getConsumablesInReportList = (params) => {
  return request({
    url: "/consumablesInventory/ConsumablesInventoryPage",
    method: "get",
    params,
  });
};
export const getConsumablesInInAndOutReportList = (params) => {
  return request({
    url: "/consumablesInventory/ConsumablesInAndOutRecord",
    method: "get",
    params,
  });
};
export const frozenConsumablesIn = (params) => {
  return request({
    url: "/consumablesInventory/frozenConsumables",
    method: "post",
    data: params,
  });
};
export const thawConsumablesIn = (params) => {
  return request({
    url: "/consumablesInventory/thawConsumables",
    method: "post",
    data: params,
  });
};
src/api/consumablesLogistics/consumablesOutRecord.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
import request from "@/utils/request";
export const getConsumablesOutRecordPage = (params) => {
  return request({
    url: "/consumablesOutRecord/listPage",
    method: "get",
    params,
  });
};
export const delConsumablesOutRecord = (ids) => {
  return request({
    url: "/consumablesOutRecord",
    method: "delete",
    data: ids,
  });
};
src/api/consumablesLogistics/consumablesUninventory.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
import request from "@/utils/request";
export const getConsumablesUninventoryListPage = (params) => {
  return request({
    url: "/consumablesUnInventory/pageConsumablesUnInventory",
    method: "get",
    params,
  });
};
export const createConsumablesUnInventory = (params) => {
  return request({
    url: "/consumablesUninventory/addstockUninventory",
    method: "post",
    data: params,
  });
};
export const subtractConsumablesUnInventory = (params) => {
  return request({
    url: "/consumablesUninventory/subtractstockUninventory",
    method: "post",
    data: params,
  });
};
export const frozenConsumablesUninventory = (params) => {
  return request({
    url: "/consumablesUninventory/frozenStock",
    method: "post",
    data: params,
  });
};
export const thawConsumablesUninventory = (params) => {
  return request({
    url: "/consumablesUninventory/thawStock",
    method: "post",
    data: params,
  });
};
src/components/PIMTable/Pagination.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
<template>
  <div class="pagination-container" v-show="total > 0">
    <el-pagination
      v-model:current-page="currentPage"
      v-model:page-size="pageSize"
      :total="total"
      :layout="layout"
      :page-sizes="pageSizes"
      background
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
  </div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
  total: { type: Number, default: 0 },
  page: { type: Number, default: 1 },
  limit: { type: Number, default: 10 },
  pageSizes: {
    type: Array,
    default: () => [10, 20, 50, 100],
  },
  layout: {
    type: String,
    default: "total, sizes, prev, pager, next, jumper",
  },
});
const emit = defineEmits(["pagination"]);
const currentPage = computed({
  get: () => props.page,
  set: val => {
    emit("pagination", { page: val, limit: props.limit });
  },
});
const pageSize = computed({
  get: () => props.limit,
  set: val => {
    emit("pagination", { page: 1, limit: val });
  },
});
const handleSizeChange = val => {
  emit("pagination", { page: 1, limit: val });
};
const handleCurrentChange = val => {
  emit("pagination", { page: val, limit: props.limit });
};
</script>
<style scoped>
.pagination-container {
  display: flex;
  justify-content: flex-end;
  padding-top: 12px;
}
</style>
src/components/ProductSelectDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<template>
  <el-dialog
    v-model="visibleProxy"
    title="选择产品"
    width="900px"
    @close="handleClose"
  >
    <div class="search-row">
      <el-input
        v-model="keyword"
        placeholder="请输入产品名称/规格搜索"
        clearable
        style="width: 320px"
        @keyup.enter="handleQuery"
      />
      <el-button type="primary" @click="handleQuery">查询</el-button>
      <el-button @click="handleReset">重置</el-button>
    </div>
    <el-table
      v-loading="loading"
      :data="tableData"
      border
      height="420"
      style="width: 100%"
      @selection-change="handleSelectionChange"
      @row-click="handleRowClick"
    >
      <el-table-column
        v-if="!single"
        type="selection"
        width="55"
        align="center"
      />
      <el-table-column label="产品名称" prop="productName" min-width="180" />
      <el-table-column label="规格型号" prop="model" min-width="180" />
      <el-table-column label="单位" prop="unit" width="100" />
      <el-table-column label="产品类型" prop="parentName" width="120" />
    </el-table>
    <Pagination
      :total="total"
      :page="page.current"
      :limit="page.size"
      @pagination="handlePagination"
    />
    <template #footer>
      <div class="dialog-footer">
        <el-button type="primary" :disabled="selectedRows.length === 0" @click="handleConfirm">
          ç¡®å®š
        </el-button>
        <el-button @click="visibleProxy = false">取消</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import Pagination from "@/components/PIMTable/Pagination.vue";
import { modelListPage } from "@/api/basicData/product.js";
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  single: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "confirm"]);
const visibleProxy = computed({
  get: () => props.modelValue,
  set: val => emit("update:modelValue", val),
});
const keyword = ref("");
const loading = ref(false);
const tableData = ref([]);
const selectedRows = ref([]);
const total = ref(0);
const page = reactive({
  current: 1,
  size: 10,
});
const normalizeRow = row => {
  if (!row) return row;
  return {
    ...row,
    productId: row.productId ?? row.product_id ?? row.product?.id,
    productName: row.productName ?? row.product_name ?? row.name,
    model: row.model ?? row.productModelName ?? row.modelName ?? row.specificationModel,
    id: row.id ?? row.productModelId ?? row.modelId,
    unit: row.unit ?? row.productUnit,
    productType: row.productType ?? row.type,
  };
};
const fetchList = () => {
  loading.value = true;
  const params = {
    current: page.current,
    size: page.size,
  };
  if (keyword.value) {
    params.keyword = keyword.value;
    params.productName = keyword.value;
    params.model = keyword.value;
  }
  modelListPage(params)
    .then(res => {
      const records = res?.data?.records || [];
      tableData.value = records.map(normalizeRow);
      total.value = res?.data?.total || 0;
    })
    .finally(() => {
      loading.value = false;
    });
};
const handleQuery = () => {
  page.current = 1;
  fetchList();
};
const handleReset = () => {
  keyword.value = "";
  page.current = 1;
  fetchList();
};
const handlePagination = ({ page: p, limit }) => {
  page.current = p;
  page.size = limit;
  fetchList();
};
const handleSelectionChange = selection => {
  selectedRows.value = Array.isArray(selection) ? selection : [];
};
const handleRowClick = row => {
  if (!props.single) return;
  selectedRows.value = row ? [row] : [];
};
const handleConfirm = () => {
  if (selectedRows.value.length === 0) return;
  emit("confirm", selectedRows.value);
  visibleProxy.value = false;
};
const handleClose = () => {
  selectedRows.value = [];
};
watch(
  () => props.modelValue,
  val => {
    if (!val) return;
    page.current = 1;
    selectedRows.value = [];
    fetchList();
  }
);
onMounted(() => {
  if (props.modelValue) fetchList();
});
</script>
<style scoped>
.search-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-bottom: 12px;
}
</style>
src/pages.json
@@ -757,6 +757,69 @@
      }
    },
    {
      "path": "pages/consumablesLogistics/receiptManagement/index",
      "style": {
        "navigationBarTitleText": "耗材入库管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/receiptManagement/view",
      "style": {
        "navigationBarTitleText": "入库详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/dispatchLog/index",
      "style": {
        "navigationBarTitleText": "耗材出库台账",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/dispatchLog/view",
      "style": {
        "navigationBarTitleText": "出库详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/stockManagement/index",
      "style": {
        "navigationBarTitleText": "耗材库存管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/stockManagement/add",
      "style": {
        "navigationBarTitleText": "新增库存",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/stockManagement/subtract",
      "style": {
        "navigationBarTitleText": "出库",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/stockManagement/view",
      "style": {
        "navigationBarTitleText": "库存详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/consumablesLogistics/stockReport/index",
      "style": {
        "navigationBarTitleText": "耗材库存报表",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/safeProduction/safeQualifications/index",
      "style": {
        "navigationBarTitleText": "规程与资质",
src/pages/consumablesLogistics/dispatchLog/Record.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,197 @@
<template>
  <div class="app-container">
    <div class="search_form">
      <div>
        <span class="search_title ml10">出库日期:</span>
        <el-date-picker
          v-model="searchForm.timeStr"
          type="date"
          placeholder="请选择日期"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          clearable
          @change="handleQuery"
        />
        <span class="search_title ml10">产品名称:</span>
        <el-input
          v-model="searchForm.productName"
          style="width: 240px"
          placeholder="请输入"
          clearable
          @keyup.enter="handleQuery"
        />
        <span class="search_title ml10">来源:</span>
        <el-select
          v-model="searchForm.recordType"
          style="width: 240px"
          placeholder="请选择"
          clearable
        >
          <el-option
            v-for="item in stockRecordTypeOptions"
            :key="item.value"
            :label="item.label"
            :value="item.value"
          />
        </el-select>
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
      <div>
        <el-button type="danger" plain @click="handleDelete">删除</el-button>
      </div>
    </div>
    <div class="table_list">
      <el-table
        :data="tableData"
        border
        v-loading="tableLoading"
        @selection-change="handleSelectionChange"
        style="width: 100%"
        height="calc(100vh - 18.5em)"
      >
        <el-table-column align="center" type="selection" width="55" />
        <el-table-column align="center" label="序号" type="index" width="60" />
        <el-table-column label="出库时间" prop="createTime" width="160" show-overflow-tooltip />
        <el-table-column label="产品名称" prop="productName" min-width="160" show-overflow-tooltip />
        <el-table-column label="规格型号" prop="model" min-width="160" show-overflow-tooltip />
        <el-table-column label="单位" prop="unit" width="100" show-overflow-tooltip />
        <el-table-column label="出库数量" prop="stockOutNum" width="110" show-overflow-tooltip />
        <el-table-column label="出库人" prop="createBy" width="120" show-overflow-tooltip />
        <el-table-column label="来源" prop="recordType" width="140" show-overflow-tooltip>
          <template #default="scope">
            {{ getRecordType(scope.row.recordType) }}
          </template>
        </el-table-column>
      </el-table>
      <Pagination
        v-show="total > 0"
        :total="total"
        :page="page.current"
        :limit="page.size"
        @pagination="paginationChange"
      />
    </div>
  </div>
</template>
<script setup>
import { onMounted, reactive, ref, toRefs, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import Pagination from "@/components/PIMTable/Pagination.vue";
import {
  delConsumablesOutRecord,
  getConsumablesOutRecordPage,
} from "@/api/consumablesLogistics/consumablesOutRecord.js";
import {
  findAllQualifiedStockOutRecordTypeOptions,
  findAllUnQualifiedStockOutRecordTypeOptions,
} from "@/api/basicData/enum.js";
const props = defineProps({
  type: {
    type: String,
    required: true,
    default: "0",
  },
});
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const stockRecordTypeOptions = ref([]);
const page = reactive({
  current: 1,
  size: 100,
});
const total = ref(0);
const data = reactive({
  searchForm: {
    timeStr: "",
    productName: "",
    recordType: "",
  },
});
const { searchForm } = toRefs(data);
const getRecordType = (recordType) => {
  return stockRecordTypeOptions.value.find((item) => item.value === recordType)?.label || "";
};
const fetchStockRecordTypeOptions = () => {
  const api =
    props.type === "1"
      ? findAllUnQualifiedStockOutRecordTypeOptions
      : findAllQualifiedStockOutRecordTypeOptions;
  api()
    .then((res) => {
      stockRecordTypeOptions.value = res.data || [];
    })
    .catch(() => {
      stockRecordTypeOptions.value = [];
    });
};
const handleQuery = () => {
  page.current = 1;
  getList();
};
const paginationChange = obj => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  getConsumablesOutRecordPage({ ...searchForm.value, ...page, type: props.type })
    .then(res => {
      tableData.value = res?.data?.records || [];
      total.value = res?.data?.total || 0;
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const handleSelectionChange = selection => {
  selectedRows.value = Array.isArray(selection) ? selection : [];
};
const handleDelete = () => {
  const ids = selectedRows.value.map(i => i.id).filter(Boolean);
  if (ids.length === 0) {
    ElMessage.warning("请选择数据");
    return;
  }
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "删除", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
    .then(() => delConsumablesOutRecord(ids))
    .then(() => {
      ElMessage.success("删除成功");
      getList();
    })
    .catch(() => {});
};
watch(
  () => props.type,
  () => {
    searchForm.value.recordType = "";
    fetchStockRecordTypeOptions();
    handleQuery();
  }
);
onMounted(() => {
  fetchStockRecordTypeOptions();
  getList();
});
</script>
src/pages/consumablesLogistics/dispatchLog/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,223 @@
<template>
  <view class="dispatch-page">
    <PageHeader title="出库台账" @back="goBack" />
    <view class="tabs-wrap">
      <view
        v-for="tab in tabs"
        :key="tab.name"
        class="tab-item"
        :class="{ active: activeTab === tab.name }"
        @click="activeTab = tab.name"
      >
        <text>{{ tab.label }}</text>
      </view>
    </view>
    <view class="search-section">
      <view class="search-row">
        <view class="search-input-wrap">
          <up-input v-model="searchForm.productName" placeholder="产品大类" clearable />
        </view>
        <view class="btn-search" @click="handleQuery">
          <view class="btn-search-inner">
            <up-icon name="search" size="22" color="#fff"></up-icon>
            <text>搜索</text>
          </view>
        </view>
      </view>
    </view>
    <view class="list-section">
      <view v-if="tableData.length > 0">
        <view v-for="(item, index) in tableData" :key="item.id || index" class="card-item">
          <view class="card-click" @click="goDetail(item)">
            <view class="card-header">
              <view class="header-main">
                <text class="product-name">{{ item.productName }}</text>
                <text class="outbound-date">{{ item.createTime }}</text>
              </view>
            </view>
            <up-divider />
            <view class="card-body">
              <view class="row"><text class="l">规格型号</text><text class="r">{{ item.model }}</text></view>
              <view class="row"><text class="l">单位</text><text class="r">{{ item.unit }}</text></view>
              <view class="row"><text class="l">出库数量</text><text class="r highlight">{{ item.stockOutNum }}</text></view>
              <view class="row"><text class="l">出库人</text><text class="r">{{ item.createBy }}</text></view>
              <view class="row" v-if="item.recordType !== undefined"><text class="l">来源</text><text class="r">{{ getRecordType(item.recordType) || item.recordType }}</text></view>
              <view class="row"><text class="l">毛重(吨)</text><text class="r">{{ item.grossWeight ?? '-' }}</text></view>
              <view class="row"><text class="l">皮重(吨)</text><text class="r">{{ item.tareWeight ?? '-' }}</text></view>
              <view class="row"><text class="l">净重(吨)</text><text class="r">{{ item.netWeight ?? '-' }}</text></view>
              <view class="row"><text class="l">过磅日期</text><text class="r">{{ item.weighingDate || '-' }}</text></view>
              <view class="row"><text class="l">过磅员</text><text class="r">{{ item.weighingOperator || '-' }}</text></view>
            </view>
          </view>
          <view class="card-actions">
            <view class="btn-delete" @click.stop="handleDeleteSingle(item)">删除</view>
          </view>
        </view>
      </view>
      <view v-else class="no-data">暂无数据</view>
    </view>
    <view class="load-more-wrap" v-if="tableData.length > 0">
      <u-loadmore :status="loadStatus" @loadmore="loadMore" />
    </view>
  </view>
</template>
<script setup>
import { reactive, ref, toRefs, watch } from "vue";
import { onReachBottom, onShow } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import { getConsumablesOutRecordPage, delConsumablesOutRecord } from "@/api/consumablesLogistics/consumablesOutRecord.js";
import { findAllQualifiedStockOutRecordTypeOptions, findAllUnQualifiedStockOutRecordTypeOptions } from "@/api/basicData/enum.js";
const activeTab = ref("qualified");
const stockRecordTypeOptions = ref([]);
const tabs = [
  { label: "合格出库", name: "qualified", type: "0" },
  { label: "不合格出库", name: "unqualified", type: "1" },
];
const tableData = ref([]);
const total = ref(0);
const loadStatus = ref("loadmore");
const page = reactive({ current: 1, size: 20 });
const data = reactive({ searchForm: { productName: "" } });
const { searchForm } = toRefs(data);
const currentType = () => tabs.find((t) => t.name === activeTab.value)?.type || "0";
function getRecordType(recordType) {
  if (recordType == null || recordType === "") return "";
  return stockRecordTypeOptions.value.find((item) => item.value === recordType)?.label || "";
}
function fetchRecordTypeOptions() {
  const api =
    currentType() === "1"
      ? findAllUnQualifiedStockOutRecordTypeOptions
      : findAllQualifiedStockOutRecordTypeOptions;
  api()
    .then((res) => {
      const list = res.data != null ? res.data : res;
      stockRecordTypeOptions.value = Array.isArray(list) ? list : [];
    })
    .catch(() => {
      stockRecordTypeOptions.value = [];
    });
}
const getList = () => {
  const isFirstPage = page.current === 1;
  if (isFirstPage) {
    uni.showLoading({ title: "加载中...", mask: true });
  }
  getConsumablesOutRecordPage({
    ...page,
    type: currentType(),
    productName: searchForm.value.productName,
  })
    .then((res) => {
      uni.hideLoading();
      const records = res.data?.records || [];
      const totalCount = res.data?.total || 0;
      if (isFirstPage) {
        tableData.value = records;
        fetchRecordTypeOptions();
      } else {
        tableData.value = [...tableData.value, ...records];
      }
      total.value = totalCount;
      loadStatus.value = tableData.value.length >= totalCount || totalCount === 0 ? "nomore" : "loadmore";
    })
    .catch(() => {
      uni.hideLoading();
      loadStatus.value = "error";
      if (isFirstPage) {
        uni.showToast({ title: "加载失败", icon: "none" });
      }
    });
};
const loadMore = () => {
  if (loadStatus.value === "nomore" || loadStatus.value === "loading") return;
  loadStatus.value = "loading";
  page.current++;
  getList();
};
watch(activeTab, () => {
  page.current = 1;
  loadStatus.value = "loadmore";
  stockRecordTypeOptions.value = [];
  getList();
});
const handleQuery = () => {
  page.current = 1;
  loadStatus.value = "loadmore";
  getList();
};
const goDetail = (item) => {
  if (!item?.id) return;
  try {
    uni.setStorageSync(
      "dispatchDetailItem",
      JSON.stringify({
        item,
        type: currentType(),
      })
    );
  } catch (e) {}
  uni.navigateTo({
    url: "/pages/consumablesLogistics/dispatchLog/view?id=" + item.id,
  });
};
const handleDeleteSingle = (item) => {
  if (!item?.id) return;
  uni.showModal({
    title: "删除",
    content: "确认删除该条出库记录?",
    success: (res) => {
      if (!res.confirm) return;
      delConsumablesOutRecord([item.id])
        .then(() => {
          uni.showToast({ title: "删除成功", icon: "success" });
          getList();
        })
        .catch(() => {
          uni.showToast({ title: "删除失败", icon: "none" });
        });
    },
  });
};
const goBack = () => uni.navigateBack();
onShow(() => getList());
onReachBottom(() => loadMore());
</script>
<style lang="scss" scoped>
.dispatch-page { min-height: 100vh; background: #f5f5f5; padding-bottom: 40rpx; }
.tabs-wrap { display: flex; background: #fff; padding: 24rpx; gap: 24rpx; }
.tab-item { flex: 1; text-align: center; padding: 20rpx; border-radius: 12rpx; background: #f0f0f0; font-size: 28rpx; color: #666; }
.tab-item.active { background: #2979ff; color: #fff; }
.search-section { background: #fff; margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.search-row { display: flex; align-items: center; }
.search-input-wrap { flex: 1; margin-right: 20rpx; min-width: 0; }
.btn-search { display: flex; align-items: center; justify-content: center; width: 160rpx; min-height: 72rpx; flex-shrink: 0; padding: 20rpx 24rpx; background: #2979ff; color: #fff; border-radius: 12rpx; font-size: 28rpx; box-sizing: border-box; text-align: center; }
.btn-search-inner { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 8rpx; }
.list-section { margin: 24rpx; }
.card-item { background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
.card-header { padding: 4rpx 0 12rpx; }
.header-main { display: flex; justify-content: space-between; gap: 16rpx; }
.product-name { font-size: 30rpx; font-weight: 500; color: #333; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.outbound-date { font-size: 24rpx; color: #999; }
.card-body .row { display: flex; justify-content: space-between; padding: 6rpx 0; font-size: 26rpx; }
.card-body .l { color: #666; }
.card-body .r { color: #333; }
.card-body .r.highlight { color: #2979ff; font-weight: 500; }
.card-actions { display: flex; justify-content: flex-end; margin-top: 12rpx; }
.btn-delete { color: #f56c6c; font-size: 28rpx; }
.no-data { text-align: center; padding: 60rpx 0; color: #999; font-size: 28rpx; }
.load-more-wrap { padding: 24rpx 24rpx 8rpx; }
</style>
src/pages/consumablesLogistics/dispatchLog/view.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<template>
  <view class="detail-page">
    <PageHeader title="出库详情" @back="goBack" />
    <view v-if="loading" class="loading-wrap">
      <text class="loading-text">加载中...</text>
    </view>
    <view v-else-if="detail" class="detail-wrap">
      <view class="section-card">
        <view class="section-head">
          <view class="section-dot"></view>
          <text class="section-title">基础信息</text>
        </view>
        <view class="section-body">
          <view class="detail-row">
            <text class="label">序号</text>
            <text class="value">{{ detail.index ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">出库批次</text>
            <text class="value value-strong">{{ detail.outboundBatches || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">出库时间</text>
            <text class="value">{{ detail.createTime || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">产品大类</text>
            <text class="value value-strong">{{ detail.productName || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">规格型号</text>
            <text class="value">{{ detail.model || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">单位</text>
            <text class="value">{{ detail.unit || '-' }}</text>
          </view>
          <view class="detail-row detail-row-highlight">
            <text class="label">出库数量</text>
            <text class="value value-num">{{ detail.stockOutNum ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">出库人</text>
            <text class="value">{{ detail.createBy || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">来源</text>
            <text class="value">{{ getRecordType(detail.recordType) || '-' }}</text>
          </view>
        </view>
      </view>
      <view class="section-card">
        <view class="section-head">
          <view class="section-dot"></view>
          <text class="section-title">出库信息</text>
        </view>
        <view class="section-body">
          <view class="detail-row">
            <text class="label">车牌号</text>
            <text class="value">{{ detail.licensePlateNo || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">毛重(吨)</text>
            <text class="value">{{ detail.grossWeight ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">皮重(吨)</text>
            <text class="value">{{ detail.tareWeight ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">净重(吨)</text>
            <text class="value">{{ detail.netWeight ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">过磅日期</text>
            <text class="value">{{ detail.weighingDate || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">过磅员</text>
            <text class="value">{{ detail.weighingOperator || '-' }}</text>
          </view>
        </view>
      </view>
    </view>
    <view v-else class="empty">
      <text class="empty-text">暂无详情数据</text>
    </view>
  </view>
</template>
<script setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import { findAllQualifiedStockOutRecordTypeOptions, findAllUnQualifiedStockOutRecordTypeOptions } from "@/api/basicData/enum.js";
const detail = ref(null);
const loading = ref(true);
const stockRecordTypeOptions = ref([]);
function normalizeDetail(raw) {
  if (!raw) return null;
  const d = typeof raw === "object" ? raw : {};
  return {
    index: d.index ?? 1,
    outboundBatches: d.outboundBatches,
    createTime: d.createTime,
    productName: d.productName,
    model: d.model,
    unit: d.unit,
    stockOutNum: d.stockOutNum,
    createBy: d.createBy,
    recordType: d.recordType,
    licensePlateNo: d.licensePlateNo,
    grossWeight: d.grossWeight,
    tareWeight: d.tareWeight,
    netWeight: d.netWeight,
    weighingDate: d.weighingDate,
    weighingOperator: d.weighingOperator,
  };
}
function getRecordType(recordType) {
  if (recordType == null || recordType === "") return "";
  return stockRecordTypeOptions.value.find((item) => item.value === recordType)?.label || "";
}
function fetchRecordTypeOptions(type) {
  const api =
    type === "1" ? findAllUnQualifiedStockOutRecordTypeOptions : findAllQualifiedStockOutRecordTypeOptions;
  api()
    .then((res) => {
      const data = res.data != null ? res.data : res;
      stockRecordTypeOptions.value = Array.isArray(data) ? data : [];
    })
    .catch(() => {
      stockRecordTypeOptions.value = [];
    });
}
onLoad(() => {
  const cached = uni.getStorageSync("dispatchDetailItem");
  if (cached) {
    try {
      const payload = typeof cached === "string" ? JSON.parse(cached) : cached;
      const item = payload && payload.item != null ? payload.item : payload;
      const type = payload && payload.type != null ? payload.type : "0";
      detail.value = normalizeDetail({ ...item, index: 1 });
      fetchRecordTypeOptions(type);
      uni.removeStorageSync("dispatchDetailItem");
    } catch (e) {
      uni.removeStorageSync("dispatchDetailItem");
    }
  }
  loading.value = false;
});
const goBack = () => uni.navigateBack();
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: linear-gradient(180deg, #e8eef7 0%, #f2f5fa 100%); padding-bottom: 48rpx; }
.loading-wrap { padding: 120rpx 48rpx; text-align: center; }
.loading-text { color: #8c9aa8; font-size: 28rpx; }
.empty { padding: 120rpx 48rpx; text-align: center; }
.empty-text { color: #8c9aa8; font-size: 28rpx; }
.detail-wrap { padding: 24rpx 24rpx 32rpx; }
.section-card { background: #fff; border-radius: 24rpx; overflow: hidden; margin-bottom: 28rpx; box-shadow: 0 8rpx 32rpx rgba(41, 121, 255, 0.06); border: 1rpx solid rgba(41, 121, 255, 0.06); }
.section-head { display: flex; align-items: center; padding: 28rpx 32rpx; background: linear-gradient(135deg, #f8fbff 0%, #f0f6ff 100%); border-bottom: 1rpx solid #eef3fa; }
.section-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #2979ff; margin-right: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 600; color: #1e3a5f; letter-spacing: 0.5rpx; }
.section-body { padding: 8rpx 32rpx 24rpx; }
.detail-row { display: flex; align-items: center; min-height: 96rpx; padding: 0 16rpx; border-radius: 12rpx; font-size: 28rpx; margin-bottom: 4rpx; }
.detail-row .label { width: 200rpx; flex-shrink: 0; color: #6b7c93; font-size: 26rpx; }
.detail-row .value { flex: 1; color: #2c3e50; text-align: right; word-break: break-all; font-size: 28rpx; }
.detail-row .value-strong { color: #1e3a5f; font-weight: 500; }
.detail-row .value-num { color: #2979ff; font-weight: 600; font-size: 32rpx; }
.detail-row-highlight { background: linear-gradient(90deg, rgba(41, 121, 255, 0.06) 0%, transparent 100%); margin: 12rpx -16rpx 4rpx; padding: 20rpx 16rpx; }
</style>
src/pages/consumablesLogistics/receiptManagement/Record.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,197 @@
<template>
  <div class="app-container">
    <div class="search_form">
      <div>
        <span class="search_title ml10">入库日期:</span>
        <el-date-picker
          v-model="searchForm.timeStr"
          type="date"
          placeholder="请选择日期"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          clearable
          @change="handleQuery"
        />
        <span class="search_title ml10">产品名称:</span>
        <el-input
          v-model="searchForm.productName"
          style="width: 240px"
          placeholder="请输入"
          clearable
          @keyup.enter="handleQuery"
        />
        <span class="search_title ml10">来源:</span>
        <el-select
          v-model="searchForm.recordType"
          style="width: 240px"
          placeholder="请选择"
          clearable
        >
          <el-option
            v-for="item in stockRecordTypeOptions"
            :key="item.value"
            :label="item.label"
            :value="item.value"
          />
        </el-select>
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
      <div>
        <el-button type="danger" plain @click="handleDelete">删除</el-button>
      </div>
    </div>
    <div class="table_list">
      <el-table
        :data="tableData"
        border
        v-loading="tableLoading"
        @selection-change="handleSelectionChange"
        style="width: 100%"
        height="calc(100vh - 18.5em)"
      >
        <el-table-column align="center" type="selection" width="55" />
        <el-table-column align="center" label="序号" type="index" width="60" />
        <el-table-column label="入库时间" prop="createTime" width="160" show-overflow-tooltip />
        <el-table-column label="产品名称" prop="productName" min-width="160" show-overflow-tooltip />
        <el-table-column label="规格型号" prop="model" min-width="160" show-overflow-tooltip />
        <el-table-column label="单位" prop="unit" width="100" show-overflow-tooltip />
        <el-table-column label="入库数量" prop="stockInNum" width="110" show-overflow-tooltip />
        <el-table-column label="入库人" prop="createBy" width="120" show-overflow-tooltip />
        <el-table-column label="来源" prop="recordType" width="140" show-overflow-tooltip>
          <template #default="scope">
            {{ getRecordType(scope.row.recordType) }}
          </template>
        </el-table-column>
      </el-table>
      <Pagination
        v-show="total > 0"
        :total="total"
        :page="page.current"
        :limit="page.size"
        @pagination="paginationChange"
      />
    </div>
  </div>
</template>
<script setup>
import { onMounted, reactive, ref, toRefs, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import Pagination from "@/components/PIMTable/Pagination.vue";
import {
  batchDeleteConsumablesInRecords,
  getConsumablesInRecordListPage,
} from "@/api/consumablesLogistics/consumablesInRecord.js";
import {
  findAllQualifiedStockInRecordTypeOptions,
  findAllUnQualifiedStockInRecordTypeOptions,
} from "@/api/basicData/enum.js";
const props = defineProps({
  type: {
    type: String,
    required: true,
    default: "0",
  },
});
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const stockRecordTypeOptions = ref([]);
const page = reactive({
  current: 1,
  size: 100,
});
const total = ref(0);
const data = reactive({
  searchForm: {
    timeStr: "",
    productName: "",
    recordType: "",
  },
});
const { searchForm } = toRefs(data);
const getRecordType = (recordType) => {
  return stockRecordTypeOptions.value.find((item) => item.value === recordType)?.label || "";
};
const fetchStockRecordTypeOptions = () => {
  const api =
    props.type === "1"
      ? findAllUnQualifiedStockInRecordTypeOptions
      : findAllQualifiedStockInRecordTypeOptions;
  api()
    .then((res) => {
      stockRecordTypeOptions.value = res.data || [];
    })
    .catch(() => {
      stockRecordTypeOptions.value = [];
    });
};
const handleQuery = () => {
  page.current = 1;
  getList();
};
const paginationChange = obj => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  getConsumablesInRecordListPage({ ...searchForm.value, ...page, type: props.type })
    .then(res => {
      tableData.value = res?.data?.records || [];
      total.value = res?.data?.total || 0;
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const handleSelectionChange = selection => {
  selectedRows.value = Array.isArray(selection) ? selection : [];
};
const handleDelete = () => {
  const ids = selectedRows.value.map(i => i.id).filter(Boolean);
  if (ids.length === 0) {
    ElMessage.warning("请选择数据");
    return;
  }
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "删除", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
    .then(() => batchDeleteConsumablesInRecords(ids))
    .then(() => {
      ElMessage.success("删除成功");
      getList();
    })
    .catch(() => {});
};
watch(
  () => props.type,
  () => {
    searchForm.value.recordType = "";
    fetchStockRecordTypeOptions();
    handleQuery();
  }
);
onMounted(() => {
  fetchStockRecordTypeOptions();
  getList();
});
</script>
src/pages/consumablesLogistics/receiptManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,231 @@
<template>
  <view class="receipt-page">
    <PageHeader title="入库管理" @back="goBack" />
    <view class="tabs-wrap">
      <view
        v-for="tab in tabs"
        :key="tab.name"
        class="tab-item"
        :class="{ active: activeTab === tab.name }"
        @click="activeTab = tab.name"
      >
        <text>{{ tab.label }}</text>
      </view>
    </view>
    <view class="search-section">
      <view class="search-row">
        <view class="search-input-wrap">
          <up-input v-model="searchForm.productName" placeholder="产品大类" clearable />
        </view>
        <view class="btn-search" @click="handleQuery">
          <view class="btn-search-inner">
            <up-icon name="search" size="22" color="#fff"></up-icon>
            <text>搜索</text>
          </view>
        </view>
      </view>
    </view>
    <view class="list-section">
      <view v-if="tableData.length > 0">
        <view v-for="(item, index) in tableData" :key="item.id || index" class="card-item">
          <view class="card-click" @click="goDetail(item)">
            <view class="card-header">
              <view class="header-main">
                <text class="product-name">{{ item.productName }}</text>
                <text class="inbound-date">{{ item.createTime || item.inboundDate }}</text>
              </view>
            </view>
            <up-divider />
            <view class="card-body">
              <view class="row"><text class="l">规格型号</text><text class="r">{{ item.model }}</text></view>
              <view class="row"><text class="l">单位</text><text class="r">{{ item.unit }}</text></view>
              <view class="row"><text class="l">入库数量</text><text class="r highlight">{{ item.stockInNum }}</text></view>
              <view class="row"><text class="l">入库人</text><text class="r">{{ item.createBy }}</text></view>
              <view class="row" v-if="item.recordType !== undefined"><text class="l">来源</text><text class="r">{{ getRecordType(item.recordType) || item.recordType }}</text></view>
            </view>
          </view>
          <view class="card-actions">
            <view class="btn-delete" @click.stop="handleDeleteSingle(item)">删除</view>
          </view>
        </view>
      </view>
      <view v-else class="no-data">暂无数据</view>
    </view>
    <view class="load-more-wrap" v-if="tableData.length > 0">
      <u-loadmore :status="loadStatus" @loadmore="loadMore" />
    </view>
  </view>
</template>
<script setup>
import { reactive, ref, toRefs, watch } from "vue";
import { onReachBottom, onShow } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import request from "@/utils/request";
import { findAllQualifiedStockInRecordTypeOptions, findAllUnQualifiedStockInRecordTypeOptions } from "@/api/basicData/enum.js";
const activeTab = ref("qualified");
const stockRecordTypeOptions = ref([]);
const tabs = [
  { label: "合格入库", name: "qualified", type: "0" },
  { label: "不合格入库", name: "unqualified", type: "1" },
];
const tableData = ref([]);
const total = ref(0);
const loadStatus = ref("loadmore");
const page = reactive({ current: 1, size: 4 });
const data = reactive({ searchForm: { productName: "" } });
const { searchForm } = toRefs(data);
const currentType = () => tabs.find((t) => t.name === activeTab.value)?.type || "0";
function getRecordType(recordType) {
  if (recordType == null || recordType === "") return "";
  return stockRecordTypeOptions.value.find((item) => item.value === recordType)?.label || "";
}
function fetchRecordTypeOptions() {
  const api =
    currentType() === "1"
      ? findAllUnQualifiedStockInRecordTypeOptions
      : findAllQualifiedStockInRecordTypeOptions;
  api()
    .then((res) => {
      const data = res.data != null ? res.data : res;
      stockRecordTypeOptions.value = Array.isArray(data) ? data : [];
    })
    .catch(() => {
      stockRecordTypeOptions.value = [];
    });
}
const getList = () => {
  const isFirstPage = page.current === 1;
  if (isFirstPage) {
    uni.showLoading({ title: "加载中...", mask: true });
  }
  request({
    url: "/consumablesInRecord/listPage",
    method: "get",
    params: {
      ...page,
      type: currentType(),
      productName: searchForm.value.productName,
    },
  })
    .then((res) => {
      uni.hideLoading();
      const records = res.data?.records || [];
      const totalCount = res.data?.total || 0;
      if (isFirstPage) {
        tableData.value = records;
        fetchRecordTypeOptions();
      } else {
        tableData.value = [...tableData.value, ...records];
      }
      total.value = totalCount;
      loadStatus.value = tableData.value.length >= totalCount || totalCount === 0 ? "nomore" : "loadmore";
    })
    .catch(() => {
      uni.hideLoading();
      loadStatus.value = "error";
      if (isFirstPage) {
        uni.showToast({ title: "加载失败", icon: "none" });
      }
    });
};
const loadMore = () => {
  if (loadStatus.value === "nomore" || loadStatus.value === "loading") return;
  loadStatus.value = "loading";
  page.current++;
  getList();
};
watch(activeTab, () => {
  page.current = 1;
  loadStatus.value = "loadmore";
  stockRecordTypeOptions.value = [];
  getList();
});
const handleQuery = () => {
  page.current = 1;
  loadStatus.value = "loadmore";
  getList();
};
const goDetail = (item) => {
  if (!item?.id) return;
  try {
    uni.setStorageSync(
      "receiptDetailItem",
      JSON.stringify({
        item,
        type: currentType(),
      })
    );
  } catch (e) {}
  uni.navigateTo({
    url: "/pages/consumablesLogistics/receiptManagement/view?id=" + item.id,
  });
};
const handleDeleteSingle = (item) => {
  if (!item?.id) return;
  uni.showModal({
    title: "删除",
    content: "确认删除该条入库记录?",
    success: (res) => {
      if (!res.confirm) return;
      request({
        url: "/consumablesInRecord",
        method: "delete",
        data: [item.id],
      })
        .then(() => {
          uni.showToast({ title: "删除成功", icon: "success" });
          getList();
        })
        .catch(() => {
          uni.showToast({ title: "删除失败", icon: "none" });
        });
    },
  });
};
const goBack = () => uni.navigateBack();
onShow(() => {
  getList();
});
onReachBottom(() => {
  loadMore();
});
</script>
<style lang="scss" scoped>
.receipt-page { min-height: 100vh; background: #f5f5f5; padding-bottom: 40rpx; }
.tabs-wrap { display: flex; background: #fff; padding: 24rpx; gap: 24rpx; }
.tab-item { flex: 1; text-align: center; padding: 20rpx; border-radius: 12rpx; background: #f0f0f0; font-size: 28rpx; color: #666; }
.tab-item.active { background: #2979ff; color: #fff; }
.search-section { background: #fff; margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.search-row { display: flex; align-items: center; }
.search-input-wrap { flex: 1; margin-right: 20rpx; min-width: 0; }
.btn-search { display: flex; align-items: center; justify-content: center; width: 160rpx; min-height: 72rpx; flex-shrink: 0; padding: 20rpx 24rpx; background: #2979ff; color: #fff; border-radius: 12rpx; font-size: 28rpx; box-sizing: border-box; text-align: center; }
.btn-search-inner { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 8rpx; }
.list-section { margin: 24rpx; }
.card-item { background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
.card-header { padding: 4rpx 0 12rpx; }
.header-main { display: flex; justify-content: space-between; gap: 16rpx; }
.product-name { font-size: 30rpx; font-weight: 500; color: #333; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inbound-date { font-size: 24rpx; color: #999; }
.card-body .row { display: flex; justify-content: space-between; padding: 6rpx 0; font-size: 26rpx; }
.card-body .l { color: #666; }
.card-body .r { color: #333; }
.card-body .r.highlight { color: #2979ff; font-weight: 500; }
.card-actions { display: flex; justify-content: flex-end; margin-top: 12rpx; }
.btn-delete { color: #f56c6c; font-size: 28rpx; }
.no-data { text-align: center; padding: 60rpx 0; color: #999; font-size: 28rpx; }
.load-more-wrap { padding: 24rpx 24rpx 8rpx; }
</style>
src/pages/consumablesLogistics/receiptManagement/view.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<template>
  <view class="detail-page">
    <PageHeader title="入库详情" @back="goBack" />
    <view v-if="loading" class="loading-wrap">
      <text class="loading-text">加载中...</text>
    </view>
    <view v-else-if="detail" class="detail-wrap">
      <view class="section-card">
        <view class="section-head">
          <view class="section-dot"></view>
          <text class="section-title">基础信息</text>
        </view>
        <view class="section-body">
          <view class="detail-row">
            <text class="label">序号</text>
            <text class="value">{{ detail.index ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">入库批次</text>
            <text class="value value-strong">{{ detail.inboundBatches || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">入库时间</text>
            <text class="value">{{ detail.createTime || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">产品大类</text>
            <text class="value value-strong">{{ detail.productName || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">规格型号</text>
            <text class="value">{{ detail.model || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">单位</text>
            <text class="value">{{ detail.unit || '-' }}</text>
          </view>
          <view class="detail-row detail-row-highlight">
            <text class="label">入库数量</text>
            <text class="value value-num">{{ detail.stockInNum ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">入库人</text>
            <text class="value">{{ detail.createBy || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">来源</text>
            <text class="value">{{ getRecordType(detail.recordType) || '-' }}</text>
          </view>
        </view>
      </view>
      <view class="section-card">
        <view class="section-head">
          <view class="section-dot"></view>
          <text class="section-title">入库信息</text>
        </view>
        <view class="section-body">
          <view class="detail-row">
            <text class="label">车牌号</text>
            <text class="value">{{ detail.licensePlateNo || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">毛重(吨)</text>
            <text class="value">{{ detail.grossWeight ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">皮重(吨)</text>
            <text class="value">{{ detail.tareWeight ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">净重(吨)</text>
            <text class="value">{{ detail.netWeight ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">过磅日期</text>
            <text class="value">{{ detail.weighingDate || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">过磅员</text>
            <text class="value">{{ detail.weighingOperator || '-' }}</text>
          </view>
        </view>
      </view>
    </view>
    <view v-else class="empty">
      <text class="empty-text">暂无详情数据</text>
    </view>
  </view>
</template>
<script setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import { findAllQualifiedStockInRecordTypeOptions, findAllUnQualifiedStockInRecordTypeOptions } from "@/api/basicData/enum.js";
const detail = ref(null);
const loading = ref(true);
const stockRecordTypeOptions = ref([]);
function normalizeDetail(raw) {
  if (!raw) return null;
  const d = typeof raw === "object" ? raw : {};
  return {
    index: d.index ?? 1,
    inboundBatches: d.inboundBatches,
    createTime: d.createTime,
    productName: d.productName,
    model: d.model,
    unit: d.unit,
    stockInNum: d.stockInNum,
    createBy: d.createBy,
    recordType: d.recordType,
    licensePlateNo: d.licensePlateNo,
    grossWeight: d.grossWeight,
    tareWeight: d.tareWeight,
    netWeight: d.netWeight,
    weighingDate: d.weighingDate,
    weighingOperator: d.weighingOperator,
  };
}
function getRecordType(recordType) {
  if (recordType == null || recordType === "") return "";
  return stockRecordTypeOptions.value.find((item) => item.value === recordType)?.label || "";
}
function fetchRecordTypeOptions(type) {
  const api =
    type === "1" ? findAllUnQualifiedStockInRecordTypeOptions : findAllQualifiedStockInRecordTypeOptions;
  api()
    .then((res) => {
      const data = res.data != null ? res.data : res;
      stockRecordTypeOptions.value = Array.isArray(data) ? data : [];
    })
    .catch(() => {
      stockRecordTypeOptions.value = [];
    });
}
onLoad(() => {
  const cached = uni.getStorageSync("receiptDetailItem");
  if (cached) {
    try {
      const payload = typeof cached === "string" ? JSON.parse(cached) : cached;
      const item = payload && payload.item != null ? payload.item : payload;
      const type = payload && payload.type != null ? payload.type : "0";
      detail.value = normalizeDetail({ ...item, index: 1 });
      fetchRecordTypeOptions(type);
      uni.removeStorageSync("receiptDetailItem");
    } catch (e) {
      uni.removeStorageSync("receiptDetailItem");
    }
  }
  loading.value = false;
});
const goBack = () => uni.navigateBack();
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: linear-gradient(180deg, #e8eef7 0%, #f2f5fa 100%); padding-bottom: 48rpx; }
.loading-wrap { padding: 120rpx 48rpx; text-align: center; }
.loading-text { color: #8c9aa8; font-size: 28rpx; }
.empty { padding: 120rpx 48rpx; text-align: center; }
.empty-text { color: #8c9aa8; font-size: 28rpx; }
.detail-wrap { padding: 24rpx 24rpx 32rpx; }
.section-card { background: #fff; border-radius: 24rpx; overflow: hidden; margin-bottom: 28rpx; box-shadow: 0 8rpx 32rpx rgba(41, 121, 255, 0.06); border: 1rpx solid rgba(41, 121, 255, 0.06); }
.section-head { display: flex; align-items: center; padding: 28rpx 32rpx; background: linear-gradient(135deg, #f8fbff 0%, #f0f6ff 100%); border-bottom: 1rpx solid #eef3fa; }
.section-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #2979ff; margin-right: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 600; color: #1e3a5f; letter-spacing: 0.5rpx; }
.section-body { padding: 8rpx 32rpx 24rpx; }
.detail-row { display: flex; align-items: center; min-height: 96rpx; padding: 0 16rpx; border-radius: 12rpx; font-size: 28rpx; margin-bottom: 4rpx; }
.detail-row .label { width: 200rpx; flex-shrink: 0; color: #6b7c93; font-size: 26rpx; }
.detail-row .value { flex: 1; color: #2c3e50; text-align: right; word-break: break-all; font-size: 28rpx; }
.detail-row .value-strong { color: #1e3a5f; font-weight: 500; }
.detail-row .value-num { color: #2979ff; font-weight: 600; font-size: 32rpx; }
.detail-row-highlight { background: linear-gradient(90deg, rgba(41, 121, 255, 0.06) 0%, transparent 100%); margin: 12rpx -16rpx 4rpx; padding: 20rpx 16rpx; }
</style>
src/pages/consumablesLogistics/stockManagement/Qualified.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
<template>
  <div class="app-container">
    <div class="search_form">
      <div>
        <span class="search_title ml10">产品名称:</span>
        <el-input
          v-model="searchForm.productName"
          style="width: 240px"
          placeholder="请输入"
          clearable
          @keyup.enter="handleQuery"
        />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
    </div>
    <div class="table_list">
      <el-table
        :data="tableData"
        border
        v-loading="tableLoading"
        style="width: 100%"
        height="calc(100vh - 18.5em)"
      >
        <el-table-column align="center" label="序号" type="index" width="60" />
        <el-table-column label="产品名称" prop="productName" min-width="180" show-overflow-tooltip />
        <el-table-column label="规格型号" prop="model" min-width="160" show-overflow-tooltip />
        <el-table-column label="单位" prop="unit" width="100" show-overflow-tooltip />
        <el-table-column label="库存数量" prop="qualitity" width="110" show-overflow-tooltip />
        <el-table-column label="冻结数量" prop="lockedQuantity" width="110" show-overflow-tooltip />
        <el-table-column label="最近更新时间" prop="updateTime" width="180" show-overflow-tooltip />
        <el-table-column label="备注" prop="remark" min-width="140" show-overflow-tooltip />
      </el-table>
      <Pagination
        v-show="total > 0"
        :total="total"
        :page="page.current"
        :limit="page.size"
        @pagination="paginationChange"
      />
    </div>
  </div>
</template>
<script setup>
import { reactive, ref, toRefs } from "vue";
import Pagination from "@/components/PIMTable/Pagination.vue";
import { getConsumablesInListPage } from "@/api/consumablesLogistics/consumablesIn.js";
const tableData = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
});
const total = ref(0);
const data = reactive({
  searchForm: {
    productName: "",
  },
});
const { searchForm } = toRefs(data);
const handleQuery = () => {
  page.current = 1;
  getList();
};
const paginationChange = obj => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  getConsumablesInListPage({ ...searchForm.value, ...page })
    .then(res => {
      tableData.value = res?.data?.records || [];
      total.value = res?.data?.total || 0;
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
getList();
</script>
src/pages/consumablesLogistics/stockManagement/Unqualified.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
<template>
  <div class="app-container">
    <div class="search_form">
      <div>
        <span class="search_title ml10">产品名称:</span>
        <el-input
          v-model="searchForm.productName"
          style="width: 240px"
          placeholder="请输入"
          clearable
          @keyup.enter="handleQuery"
        />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
    </div>
    <div class="table_list">
      <el-table
        :data="tableData"
        border
        v-loading="tableLoading"
        style="width: 100%"
        height="calc(100vh - 18.5em)"
      >
        <el-table-column align="center" label="序号" type="index" width="60" />
        <el-table-column label="产品名称" prop="productName" min-width="180" show-overflow-tooltip />
        <el-table-column label="规格型号" prop="model" min-width="160" show-overflow-tooltip />
        <el-table-column label="单位" prop="unit" width="100" show-overflow-tooltip />
        <el-table-column label="库存数量" prop="qualitity" width="110" show-overflow-tooltip />
        <el-table-column label="冻结数量" prop="lockedQuantity" width="110" show-overflow-tooltip />
        <el-table-column label="最近更新时间" prop="updateTime" width="180" show-overflow-tooltip />
        <el-table-column label="备注" prop="remark" min-width="140" show-overflow-tooltip />
      </el-table>
      <Pagination
        v-show="total > 0"
        :total="total"
        :page="page.current"
        :limit="page.size"
        @pagination="paginationChange"
      />
    </div>
  </div>
</template>
<script setup>
import { reactive, ref, toRefs } from "vue";
import Pagination from "@/components/PIMTable/Pagination.vue";
import { getConsumablesUninventoryListPage } from "@/api/consumablesLogistics/consumablesUninventory.js";
const tableData = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
});
const total = ref(0);
const data = reactive({
  searchForm: {
    productName: "",
  },
});
const { searchForm } = toRefs(data);
const handleQuery = () => {
  page.current = 1;
  getList();
};
const paginationChange = obj => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  getConsumablesUninventoryListPage({ ...searchForm.value, ...page })
    .then(res => {
      tableData.value = res?.data?.records || [];
      total.value = res?.data?.total || 0;
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
getList();
</script>
src/pages/consumablesLogistics/stockManagement/add.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,283 @@
<template>
  <view class="add-stock-page">
    <PageHeader title="新增库存" @back="goBack" />
    <scroll-view scroll-y class="content-scroll">
      <view class="form-section">
        <view class="form-row">
          <text class="form-label required">产品名称</text>
          <view class="selector-trigger" @click="openProductSelector">
            <text class="selector-text" :class="{ placeholder: !form.productName }">
              {{ form.productName || "请选择产品" }}
            </text>
            <up-icon name="arrow-right" size="16" color="#999"></up-icon>
          </view>
        </view>
        <view class="form-row">
          <text class="form-label">规格</text>
          <up-input v-model="form.productModelName" disabled placeholder="请选择产品后自动带出" />
        </view>
        <view class="form-row">
          <text class="form-label">单位</text>
          <up-input v-model="form.unit" disabled placeholder="请选择产品后自动带出" />
        </view>
      </view>
      <view v-if="isQualified" class="form-section">
        <view class="section-title">过磅信息</view>
        <view class="form-row">
          <text class="form-label">车牌号</text>
          <up-input v-model="form.licensePlateNo" placeholder="请输入车牌号" />
        </view>
        <view class="form-row">
          <text class="form-label">毛重(吨)</text>
          <up-input v-model="form.grossWeight" type="number" placeholder="请输入毛重" />
        </view>
        <view class="form-row">
          <text class="form-label">皮重(吨)</text>
          <up-input v-model="form.tareWeight" type="number" placeholder="请输入皮重" />
        </view>
        <view class="form-row">
          <text class="form-label">净重(吨)</text>
          <up-input v-model="form.netWeight" type="number" disabled placeholder="自动计算" />
        </view>
        <view class="form-row">
          <text class="form-label">过磅日期</text>
          <view class="selector-trigger" @click="openWeighingDatePicker">
            <text class="selector-text" :class="{ placeholder: !form.weighingDate }">
              {{ form.weighingDate || "请选择过磅日期" }}
            </text>
            <up-icon name="calendar" size="16" color="#999"></up-icon>
          </view>
        </view>
        <view class="form-row">
          <text class="form-label">过磅员</text>
          <up-input v-model="form.weighingOperator" placeholder="请输入过磅员" />
        </view>
      </view>
      <view class="form-section">
        <view class="form-row">
          <text class="form-label">备注</text>
          <up-input v-model="form.remark" type="textarea" placeholder="选填" />
        </view>
      </view>
    </scroll-view>
    <view class="bottom-bar">
      <view class="btn-submit" @click="handleSubmit">提交</view>
    </view>
    <up-popup :show="showProductPopup" mode="bottom" @close="showProductPopup = false">
      <view class="product-popup">
        <view class="popup-header">
          <text class="popup-title">选择产品</text>
        </view>
        <view class="popup-search">
          <up-input v-model="productQuery.productName" placeholder="产品大类" clearable />
          <up-input v-model="productQuery.model" placeholder="型号名称" clearable />
          <view class="popup-search-btn" @click="loadProductList">搜索</view>
        </view>
        <scroll-view scroll-y class="product-list">
          <view
            v-for="item in productList"
            :key="item.id"
            class="product-item"
            @click="selectProduct(item)"
          >
            <view class="product-name-row">
              <text class="product-name">{{ item.productName }}</text>
              <text class="product-unit">{{ item.unit }}</text>
            </view>
            <view class="product-model">型号:{{ item.model }}</view>
          </view>
          <view v-if="!productLoading && productList.length === 0" class="no-data">暂无数据</view>
        </scroll-view>
      </view>
    </up-popup>
    <up-popup :show="showWeighingDatePicker" mode="bottom" @close="showWeighingDatePicker = false">
      <up-datetime-picker
        :show="true"
        v-model="weighingDateValue"
        mode="datetime"
        @confirm="onWeighingDateConfirm"
        @cancel="showWeighingDatePicker = false"
      />
    </up-popup>
  </view>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import dayjs from "dayjs";
import PageHeader from "@/components/PageHeader.vue";
import { createConsumablesIn } from "@/api/consumablesLogistics/consumablesIn.js";
import { createConsumablesUnInventory } from "@/api/consumablesLogistics/consumablesUninventory.js";
import { productModelList } from "@/api/basicData/productModel.js";
const form = reactive({
  productId: undefined,
  productModelId: undefined,
  productName: "",
  productModelName: "",
  unit: "",
  productType: undefined,
  licensePlateNo: "",
  grossWeight: "",
  tareWeight: "",
  netWeight: "",
  weighingDate: "",
  weighingOperator: "",
  remark: "",
});
const type = ref("0");
const isQualified = computed(() => type.value === "0");
const showProductPopup = ref(false);
const productQuery = reactive({
  productName: "",
  model: "",
});
const productList = ref([]);
const productLoading = ref(false);
const showWeighingDatePicker = ref(false);
const weighingDateValue = ref(Date.now());
onLoad((options) => {
  if (options && options.type != null) {
    type.value = options.type;
  }
});
const openProductSelector = () => {
  showProductPopup.value = true;
  if (productList.value.length === 0) {
    loadProductList();
  }
};
const loadProductList = () => {
  productLoading.value = true;
  productModelList({
    productName: productQuery.productName || "",
    model: productQuery.model || "",
    current: 1,
    size: 20,
  })
    .then((res) => {
      const data = res?.records || res?.data?.records || [];
      productList.value = Array.isArray(data) ? data : [];
    })
    .finally(() => {
      productLoading.value = false;
    });
};
const selectProduct = (item) => {
  form.productId = item.productId || item.id;
  form.productModelId = item.id;
  form.productName = item.productName;
  form.productModelName = item.model;
  form.unit = item.unit;
  form.productType = item.productType;
  showProductPopup.value = false;
};
const computeNetWeight = () => {
  const gross = Number(form.grossWeight);
  const tare = Number(form.tareWeight);
  if (!isNaN(gross) && !isNaN(tare)) {
    const net = Number((gross - tare).toFixed(2));
    form.netWeight = net > 0 ? net : 0;
  } else {
    form.netWeight = "";
  }
};
watch(
  () => [form.grossWeight, form.tareWeight],
  () => {
    computeNetWeight();
  }
);
const openWeighingDatePicker = () => {
  weighingDateValue.value = form.weighingDate
    ? dayjs(form.weighingDate, "YYYY-MM-DD HH:mm:ss").valueOf()
    : Date.now();
  showWeighingDatePicker.value = true;
};
const onWeighingDateConfirm = (e) => {
  const ts = e?.value ?? weighingDateValue.value;
  form.weighingDate = dayjs(ts).format("YYYY-MM-DD HH:mm:ss");
  showWeighingDatePicker.value = false;
};
const handleSubmit = () => {
  if (!form.productName || !form.productModelId) {
    uni.showToast({ title: "请选择产品", icon: "none" });
    return;
  }
  const payload = {
    productId: form.productId,
    productModelId: form.productModelId,
    productName: form.productName,
    productModelName: form.productModelName,
    unit: form.unit,
    productType: form.productType,
    licensePlateNo: form.licensePlateNo,
    grossWeight: form.grossWeight,
    tareWeight: form.tareWeight,
    netWeight: form.netWeight,
    weighingDate: form.weighingDate,
    weighingOperator: form.weighingOperator,
    remark: form.remark,
  };
  const api = isQualified.value ? createConsumablesIn : createConsumablesUnInventory;
  api(payload)
    .then(() => {
      uni.showToast({ title: "新增成功", icon: "success" });
      setTimeout(() => {
        uni.navigateBack();
      }, 400);
    })
    .catch(() => {
      uni.showToast({ title: "新增失败", icon: "none" });
    });
};
const goBack = () => uni.navigateBack();
</script>
<style lang="scss" scoped>
.add-stock-page { min-height: 100vh; background: #f5f5f5; padding-bottom: 100rpx; }
.content-scroll { height: calc(100vh - 100rpx); }
.form-section { background: #fff; margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.section-title { font-size: 28rpx; font-weight: 500; color: #333; margin-bottom: 12rpx; }
.form-row { margin-bottom: 24rpx; }
.form-label { display: block; font-size: 26rpx; color: #666; margin-bottom: 12rpx; }
.form-label.required:before { content: "*"; color: #f56c6c; margin-right: 6rpx; }
.selector-trigger { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; background: #f5f5f5; border-radius: 12rpx; }
.selector-text { font-size: 28rpx; color: #333; }
.selector-text.placeholder { color: #999; }
.bottom-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom)); background: #fff; box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.04); }
.btn-submit { height: 88rpx; border-radius: 999rpx; background: #2979ff; color: #fff; font-size: 30rpx; display: flex; align-items: center; justify-content: center; }
.product-popup { background: #fff; border-top-left-radius: 20rpx; border-top-right-radius: 20rpx; padding: 24rpx; }
.popup-header { text-align: center; margin-bottom: 20rpx; }
.popup-title { font-size: 30rpx; font-weight: 500; color: #333; }
.popup-search { display: grid; grid-template-columns: 1fr 1fr 140rpx; gap: 16rpx; align-items: center; margin-bottom: 16rpx; }
.popup-search-btn { height: 72rpx; border-radius: 12rpx; background: #2979ff; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 28rpx; }
.product-list { max-height: 60vh; }
.product-item { padding: 20rpx 12rpx; border-bottom: 1rpx solid #eee; }
.product-name-row { display: flex; justify-content: space-between; align-items: center; }
.product-name { font-size: 28rpx; color: #333; font-weight: 500; }
.product-unit { font-size: 24rpx; color: #999; }
.product-model { font-size: 24rpx; color: #666; margin-top: 8rpx; }
.no-data { text-align: center; padding: 40rpx 0; color: #999; }
</style>
src/pages/consumablesLogistics/stockManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,319 @@
<template>
  <view class="stock-mgmt-page">
    <PageHeader title="库存管理" @back="goBack" />
    <view class="tabs-wrap">
      <view
        v-for="tab in tabs"
        :key="tab.name"
        class="tab-item"
        :class="{ active: activeTab === tab.name }"
        @click="activeTab = tab.name"
      >
        <text>{{ tab.label }}</text>
      </view>
    </view>
    <view class="search-section">
      <view class="search-row">
        <view class="search-input-wrap">
          <up-input
            v-model="searchForm.productName"
            placeholder="产品大类"
            clearable
          />
        </view>
        <view class="btn-search" @click="handleQuery">
          <view class="btn-search-inner">
            <up-icon name="search" size="22" color="#fff"></up-icon>
            <text>搜索</text>
          </view>
        </view>
      </view>
    </view>
    <view class="list-section">
      <view v-if="tableData.length > 0">
        <view
          v-for="(item, index) in tableData"
          :key="item.id || index"
          class="card-item"
        >
          <view class="card-click" @click="goDetail(item)">
            <view class="card-header">
              <view class="header-main">
                <text class="product-name">{{ item.productName }}</text>
                <text class="sub-title">{{ item.model || item.updateTime }}</text>
              </view>
            </view>
            <up-divider />
            <view class="card-body">
              <view class="row"><text class="l">规格型号</text><text class="r">{{ item.model }}</text></view>
              <view class="row"><text class="l">单位</text><text class="r">{{ item.unit }}</text></view>
              <view class="row"><text class="l">总库存数</text><text class="r highlight">{{ item.qualitity }}</text></view>
              <view class="row"><text class="l">冻结数量</text><text class="r">{{ item.lockedQuantity || 0 }}</text></view>
              <view class="row"><text class="l">可用库存</text><text class="r">{{ item.unLockedQuantity ?? (item.qualitity - (item.lockedQuantity || 0)) }}</text></view>
              <view class="row"><text class="l">最近更新时间</text><text class="r">{{ item.updateTime }}</text></view>
            </view>
          </view>
          <view class="card-actions">
            <view
              class="btn-link btn-link-primary"
              :class="{ disabled: !(item.unLockedQuantity > 0) }"
              @click="openSubtract(item)"
            >出库</view>
            <view
              class="btn-link btn-link-warn"
              v-if="item.unLockedQuantity > 0"
              @click="openFrozen(item)"
            >冻结</view>
            <view
              class="btn-link btn-link-plain"
              v-if="(item.lockedQuantity || 0) > 0"
              @click="openThaw(item)"
            >解冻</view>
          </view>
        </view>
        <view class="load-more-wrap">
          <u-loadmore :status="loadStatus" @loadmore="loadMore" />
        </view>
      </view>
      <view v-else class="no-data">暂无数据</view>
    </view>
    <up-popup :show="showQuantityPopup" mode="center" round="16" @close="closeQuantityPopup">
      <view class="popup-inner">
        <view class="popup-title">{{ quantityTitle }}</view>
        <view class="form-row">
          <text class="form-label">数量</text>
          <up-input v-model="quantityForm.num" type="number" :placeholder="'最大' + maxQuantity" />
        </view>
        <view class="popup-footer">
          <view class="btn-cancel" @click="closeQuantityPopup">取消</view>
          <view class="btn-ok" @click="submitQuantity">确定</view>
        </view>
      </view>
    </up-popup>
    <view class="fab-button" @click="goAdd">
      <up-icon name="plus" size="24" color="#ffffff"></up-icon>
    </view>
  </view>
</template>
<script setup>
import { computed, reactive, ref, toRefs, watch } from "vue";
import { onReachBottom, onShow } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import { frozenConsumablesIn, getConsumablesInListPage, thawConsumablesIn } from "@/api/consumablesLogistics/consumablesIn.js";
import { frozenConsumablesUninventory, getConsumablesUninventoryListPage, thawConsumablesUninventory } from "@/api/consumablesLogistics/consumablesUninventory.js";
const activeTab = ref("qualified");
const tabs = [
  { label: "合格库存", name: "qualified" },
  { label: "不合格库存", name: "unqualified" },
];
const tableData = ref([]);
const total = ref(0);
const loadStatus = ref("loadmore");
const showQuantityPopup = ref(false);
const quantityOp = ref("");
const currentRecord = ref(null);
const page = reactive({ current: 1, size: 20 });
const data = reactive({
  searchForm: { productName: "" },
  quantityForm: { num: "" },
});
const { searchForm, quantityForm } = toRefs(data);
const isQualified = () => activeTab.value === "qualified";
const getList = () => {
  const isFirstPage = page.current === 1;
  if (isFirstPage) {
    uni.showLoading({ title: "加载中...", mask: true });
  }
  const params = { ...page, productName: searchForm.value.productName };
  const api = isQualified() ? getConsumablesInListPage : getConsumablesUninventoryListPage;
  api(params)
    .then((res) => {
      uni.hideLoading();
      const records = res.data?.records || [];
      const totalCount = res.data?.total || 0;
      if (isFirstPage) {
        tableData.value = records;
      } else {
        tableData.value = [...tableData.value, ...records];
      }
      total.value = totalCount;
      loadStatus.value = tableData.value.length >= totalCount || totalCount === 0 ? "nomore" : "loadmore";
    })
    .catch(() => {
      uni.hideLoading();
      loadStatus.value = "error";
      if (isFirstPage) {
        uni.showToast({ title: "加载失败", icon: "none" });
      }
    });
};
const loadMore = () => {
  if (loadStatus.value === "nomore" || loadStatus.value === "loading") return;
  loadStatus.value = "loading";
  page.current++;
  getList();
};
watch(activeTab, () => {
  page.current = 1;
  loadStatus.value = "loadmore";
  getList();
});
const handleQuery = () => {
  page.current = 1;
  loadStatus.value = "loadmore";
  getList();
};
const goAdd = () => {
  const type = isQualified() ? "0" : "1";
  uni.navigateTo({
    url: `/pages/consumablesLogistics/stockManagement/add?type=${type}`,
  });
};
const quantityTitle = computed(() => {
  if (quantityOp.value === "frozen") return "冻结库存";
  if (quantityOp.value === "thaw") return "解冻库存";
  return "";
});
const maxQuantity = computed(() => {
  if (!currentRecord.value) return 0;
  if (quantityOp.value === "frozen") return currentRecord.value.unLockedQuantity || 0;
  if (quantityOp.value === "thaw") return currentRecord.value.lockedQuantity || 0;
  return 0;
});
const openSubtract = (row) => {
  if (!(row.unLockedQuantity > 0)) return;
  try {
    uni.setStorageSync(
      "stockSubtractRecord",
      JSON.stringify({
        item: row,
        type: isQualified() ? "0" : "1",
      })
    );
  } catch (e) {}
  const typeParam = isQualified() ? "0" : "1";
  uni.navigateTo({
    url: `/pages/consumablesLogistics/stockManagement/subtract?type=${typeParam}&id=${row.id}`,
  });
};
const openFrozen = (row) => {
  quantityOp.value = "frozen";
  currentRecord.value = row;
  quantityForm.value.num = "";
  showQuantityPopup.value = true;
};
const openThaw = (row) => {
  quantityOp.value = "thaw";
  currentRecord.value = row;
  quantityForm.value.num = "";
  showQuantityPopup.value = true;
};
const closeQuantityPopup = () => {
  showQuantityPopup.value = false;
  currentRecord.value = null;
  quantityOp.value = "";
};
const submitQuantity = () => {
  const num = Number(quantityForm.value.num);
  if (!num || num <= 0 || num > maxQuantity.value) {
    uni.showToast({ title: `请输入 1~${maxQuantity.value} ä¹‹é—´çš„æ•°é‡`, icon: "none" });
    return;
  }
  const id = currentRecord.value?.id;
  if (!id) return;
  const base = { id, lockedQuantity: num };
  let promise;
  if (quantityOp.value === "frozen") {
    promise = isQualified() ? frozenConsumablesIn(base) : frozenConsumablesUninventory(base);
  } else {
    promise = isQualified() ? thawConsumablesIn(base) : thawConsumablesUninventory(base);
  }
  promise
    .then(() => {
      uni.showToast({ title: "操作成功", icon: "success" });
      closeQuantityPopup();
      getList();
    })
    .catch(() => uni.showToast({ title: "操作失败", icon: "none" }));
};
const goDetail = (item) => {
  if (!item) return;
  try {
    uni.setStorageSync(
      "stockDetailItem",
      JSON.stringify({
        item,
        type: isQualified() ? "0" : "1",
      })
    );
  } catch (e) {}
  if (!item.id) {
    uni.navigateTo({ url: "/pages/consumablesLogistics/stockManagement/view" });
  } else {
    uni.navigateTo({ url: "/pages/consumablesLogistics/stockManagement/view?id=" + item.id });
  }
};
const goBack = () => uni.navigateBack();
onShow(() => getList());
onReachBottom(() => {
  loadMore();
});
</script>
<style lang="scss" scoped>
.stock-mgmt-page { min-height: 100vh; background: #f5f5f5; padding-bottom: 120rpx; }
.tabs-wrap { display: flex; background: #fff; padding: 24rpx; gap: 24rpx; }
.tab-item { flex: 1; text-align: center; padding: 20rpx; border-radius: 12rpx; background: #f0f0f0; font-size: 28rpx; color: #666; }
.tab-item.active { background: #2979ff; color: #fff; }
.search-section { background: #fff; margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.search-row { display: flex; align-items: center; }
.search-input-wrap { flex: 1; margin-right: 20rpx; min-width: 0; }
.btn-search { display: flex; align-items: center; justify-content: center; width: 180rpx; min-height: 72rpx; flex-shrink: 0; padding: 20rpx 24rpx; background: #2979ff; color: #fff; border-radius: 12rpx; font-size: 28rpx; box-sizing: border-box; text-align: center; }
.btn-search-inner { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 8rpx; }
.list-section { padding: 0 24rpx; }
.card-item { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
.card-header { padding: 8rpx 0; }
.header-main { display: flex; flex-direction: column; gap: 8rpx; }
.product-name { font-size: 30rpx; font-weight: 500; color: #333; }
.sub-title { font-size: 24rpx; color: #999; }
.card-body .row { display: flex; justify-content: space-between; padding: 12rpx 0; font-size: 26rpx; }
.card-body .l { color: #666; }
.card-body .r { color: #333; }
.card-body .r.highlight { color: #2979ff; font-weight: 500; }
.card-actions { display: flex; gap: 16rpx; margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid #eee; justify-content: flex-end; }
.btn-link { min-width: 120rpx; padding: 10rpx 20rpx; border-radius: 24rpx; font-size: 26rpx; text-align: center; border-width: 1rpx; border-style: solid; }
.btn-link-primary { color: #ffffff; background: #2979ff; border-color: #2979ff; }
.btn-link-warn { color: #ff9f1a; background: rgba(255, 159, 26, 0.08); border-color: rgba(255, 159, 26, 0.6); }
.btn-link-plain { color: #666666; background: #f5f5f5; border-color: #e0e0e0; }
.btn-link.disabled { color: #cccccc; background: #f5f5f5; border-color: #e0e0e0; }
.no-data { text-align: center; padding: 80rpx 0; color: #999; font-size: 28rpx; }
.fab-button { position: fixed; bottom: calc(30px + env(safe-area-inset-bottom)); right: 30px; width: 56px; height: 56px; background: #2979ff; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(41, 121, 255, 0.3); z-index: 1000; }
.popup-inner { padding: 40rpx; min-width: 560rpx; background: #fff; }
.popup-title { font-size: 32rpx; font-weight: 500; margin-bottom: 32rpx; }
.form-row { margin-bottom: 24rpx; }
.form-row .form-label { display: block; font-size: 26rpx; color: #666; margin-bottom: 12rpx; }
.popup-footer { display: flex; gap: 24rpx; margin-top: 40rpx; }
.btn-cancel { flex: 1; text-align: center; padding: 24rpx; background: #f0f0f0; border-radius: 12rpx; }
.btn-ok { flex: 1; text-align: center; padding: 24rpx; background: #2979ff; color: #fff; border-radius: 12rpx; }
</style>
src/pages/consumablesLogistics/stockManagement/subtract.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,209 @@
<template>
  <view class="subtract-stock-page">
    <PageHeader title="出库" @back="goBack" />
    <scroll-view scroll-y class="content-scroll">
      <view class="form-section">
        <view class="section-title">库存信息</view>
        <view class="info-row">
          <text class="label">产品大类</text>
          <text class="value">{{ stockRecord.productName }}</text>
        </view>
        <view class="info-row">
          <text class="label">规格型号</text>
          <text class="value">{{ stockRecord.model }}</text>
        </view>
        <view class="info-row">
          <text class="label">可用库存</text>
          <text class="value highlight">{{ stockRecord.unLockedQuantity }}</text>
        </view>
      </view>
      <view class="form-section">
        <view class="section-title">出库信息</view>
        <view class="form-row">
          <text class="form-label required">出库数量</text>
          <up-input v-model="form.stockOutNum" type="number" :placeholder="'最大' + stockRecord.unLockedQuantity" />
        </view>
        <view class="form-row" v-if="isQualified">
          <text class="form-label">车牌号</text>
          <up-input v-model="form.licensePlateNo" placeholder="请输入车牌号" />
        </view>
        <view class="form-row" v-if="isQualified">
          <text class="form-label">毛重(吨)</text>
          <up-input v-model="form.grossWeight" type="number" placeholder="请输入毛重" />
        </view>
        <view class="form-row" v-if="isQualified">
          <text class="form-label">皮重(吨)</text>
          <up-input v-model="form.tareWeight" type="number" placeholder="请输入皮重" />
        </view>
        <view class="form-row" v-if="isQualified">
          <text class="form-label">净重(吨)</text>
          <up-input v-model="form.netWeight" type="number" disabled placeholder="自动计算" />
        </view>
        <view class="form-row" v-if="isQualified">
          <text class="form-label">过磅日期</text>
          <view class="selector-trigger" @click="openWeighingDatePicker">
            <text class="selector-text" :class="{ placeholder: !form.weighingDate }">
              {{ form.weighingDate || "请选择过磅日期" }}
            </text>
            <up-icon name="calendar" size="16" color="#999"></up-icon>
          </view>
        </view>
        <view class="form-row" v-if="isQualified">
          <text class="form-label">过磅员</text>
          <up-input v-model="form.weighingOperator" placeholder="请输入过磅员" />
        </view>
        <view class="form-row">
          <text class="form-label">备注</text>
          <up-input v-model="form.remark" type="textarea" placeholder="选填" />
        </view>
      </view>
    </scroll-view>
    <view class="bottom-bar">
      <view class="btn-submit" @click="handleSubmit">提交</view>
    </view>
    <up-popup :show="showWeighingDatePicker" mode="bottom" @close="showWeighingDatePicker = false">
      <up-datetime-picker
        :show="true"
        v-model="weighingDateValue"
        mode="datetime"
        @confirm="onWeighingDateConfirm"
        @cancel="showWeighingDatePicker = false"
      />
    </up-popup>
  </view>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import dayjs from "dayjs";
import PageHeader from "@/components/PageHeader.vue";
import { subtractConsumablesIn } from "@/api/consumablesLogistics/consumablesIn.js";
import { subtractConsumablesUnInventory } from "@/api/consumablesLogistics/consumablesUninventory.js";
const type = ref("0");
const isQualified = computed(() => type.value === "0");
const stockRecord = reactive({
  id: "",
  productName: "",
  model: "",
  unLockedQuantity: 0,
});
const form = reactive({
  stockOutNum: "",
  licensePlateNo: "",
  grossWeight: "",
  tareWeight: "",
  netWeight: "",
  weighingDate: "",
  weighingOperator: "",
  remark: "",
});
const showWeighingDatePicker = ref(false);
const weighingDateValue = ref(Date.now());
onLoad((options) => {
  if (options && options.type != null) {
    type.value = options.type;
  }
  const cached = uni.getStorageSync("stockSubtractRecord");
  if (cached) {
    try {
      const payload = typeof cached === "string" ? JSON.parse(cached) : cached;
      const item = payload && payload.item != null ? payload.item : payload;
      stockRecord.id = item.id;
      stockRecord.productName = item.productName;
      stockRecord.model = item.model;
      stockRecord.unLockedQuantity = item.unLockedQuantity || 0;
      uni.removeStorageSync("stockSubtractRecord");
    } catch (e) {
      uni.removeStorageSync("stockSubtractRecord");
    }
  }
});
const computeNetWeight = () => {
  const gross = Number(form.grossWeight);
  const tare = Number(form.tareWeight);
  if (!isNaN(gross) && !isNaN(tare)) {
    const net = Number((gross - tare).toFixed(2));
    form.netWeight = net > 0 ? net : 0;
  } else {
    form.netWeight = "";
  }
};
watch(
  () => [form.grossWeight, form.tareWeight],
  () => computeNetWeight()
);
const openWeighingDatePicker = () => {
  weighingDateValue.value = form.weighingDate
    ? dayjs(form.weighingDate, "YYYY-MM-DD HH:mm:ss").valueOf()
    : Date.now();
  showWeighingDatePicker.value = true;
};
const onWeighingDateConfirm = (e) => {
  const ts = e?.value ?? weighingDateValue.value;
  form.weighingDate = dayjs(ts).format("YYYY-MM-DD HH:mm:ss");
  showWeighingDatePicker.value = false;
};
const handleSubmit = () => {
  const outNum = Number(form.stockOutNum);
  if (!outNum || outNum <= 0 || outNum > Number(stockRecord.unLockedQuantity)) {
    uni.showToast({ title: `请输入 1~${stockRecord.unLockedQuantity} ä¹‹é—´çš„æ•°é‡`, icon: "none" });
    return;
  }
  const api = isQualified.value ? subtractConsumablesIn : subtractConsumablesUnInventory;
  api({
    id: stockRecord.id,
    stockOutNum: outNum,
    licensePlateNo: form.licensePlateNo,
    grossWeight: form.grossWeight,
    tareWeight: form.tareWeight,
    netWeight: form.netWeight,
    weighingDate: form.weighingDate,
    weighingOperator: form.weighingOperator,
    remark: form.remark,
  })
    .then(() => {
      uni.showToast({ title: "出库成功", icon: "success" });
      setTimeout(() => uni.navigateBack(), 400);
    })
    .catch(() => {
      uni.showToast({ title: "出库失败", icon: "none" });
    });
};
const goBack = () => uni.navigateBack();
</script>
<style lang="scss" scoped>
.subtract-stock-page { min-height: 100vh; background: #f5f5f5; padding-bottom: 100rpx; }
.content-scroll { height: calc(100vh - 100rpx); }
.form-section { background: #fff; margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.section-title { font-size: 28rpx; font-weight: 500; color: #333; margin-bottom: 12rpx; }
.info-row { display: flex; justify-content: space-between; padding: 12rpx 0; font-size: 26rpx; }
.info-row .label { color: #666; }
.info-row .value { color: #333; }
.info-row .value.highlight { color: #2979ff; font-weight: 500; }
.form-row { margin-bottom: 24rpx; }
.form-label { display: block; font-size: 26rpx; color: #666; margin-bottom: 12rpx; }
.form-label.required:before { content: "*"; color: #f56c6c; margin-right: 6rpx; }
.selector-trigger { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; background: #f5f5f5; border-radius: 12rpx; }
.selector-text { font-size: 28rpx; color: #333; }
.selector-text.placeholder { color: #999; }
.bottom-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom)); background: #fff; box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.04); }
.btn-submit { height: 88rpx; border-radius: 999rpx; background: #2979ff; color: #fff; font-size: 30rpx; display: flex; align-items: center; justify-content: center; }
</style>
src/pages/consumablesLogistics/stockManagement/view.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,121 @@
<template>
  <view class="detail-page">
    <PageHeader title="库存详情" @back="goBack" />
    <view v-if="loading" class="loading-wrap">
      <text class="loading-text">加载中...</text>
    </view>
    <view v-else-if="detail" class="detail-wrap">
      <view class="section-card">
        <view class="section-head">
          <view class="section-dot"></view>
          <text class="section-title">基础信息</text>
        </view>
        <view class="section-body">
          <view class="detail-row">
            <text class="label">序号</text>
            <text class="value">{{ detail.index ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">产品大类</text>
            <text class="value value-strong">{{ detail.productName || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">规格型号</text>
            <text class="value">{{ detail.model || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">单位</text>
            <text class="value">{{ detail.unit || '-' }}</text>
          </view>
          <view class="detail-row detail-row-highlight">
            <text class="label">总库存</text>
            <text class="value value-num">{{ detail.qualitity ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">冻结数量</text>
            <text class="value">{{ detail.lockedQuantity ?? 0 }}</text>
          </view>
          <view class="detail-row">
            <text class="label">可用库存</text>
            <text class="value">{{ detail.unLockedQuantity ?? '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="label">库存类型</text>
            <text class="value">{{ detail.typeLabel }}</text>
          </view>
          <view class="detail-row">
            <text class="label">最近更新时间</text>
            <text class="value">{{ detail.updateTime || '-' }}</text>
          </view>
        </view>
      </view>
    </view>
    <view v-else class="empty">
      <text class="empty-text">暂无详情数据</text>
    </view>
  </view>
</template>
<script setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
const detail = ref(null);
const loading = ref(true);
function normalizeDetail(raw, type) {
  if (!raw) return null;
  const d = typeof raw === "object" ? raw : {};
  return {
    index: d.index ?? 1,
    productName: d.productName,
    model: d.model,
    unit: d.unit,
    qualitity: d.qualitity,
    lockedQuantity: d.lockedQuantity,
    unLockedQuantity: d.unLockedQuantity ?? (d.qualitity - (d.lockedQuantity || 0)),
    updateTime: d.updateTime,
    typeLabel: type === "1" ? "不合格库存" : "合格库存",
  };
}
onLoad(() => {
  const cached = uni.getStorageSync("stockDetailItem");
  if (cached) {
    try {
      const payload = typeof cached === "string" ? JSON.parse(cached) : cached;
      const item = payload && payload.item != null ? payload.item : payload;
      const type = payload && payload.type != null ? payload.type : "0";
      detail.value = normalizeDetail({ ...item, index: 1 }, type);
      uni.removeStorageSync("stockDetailItem");
    } catch (e) {
      uni.removeStorageSync("stockDetailItem");
    }
  }
  loading.value = false;
});
const goBack = () => uni.navigateBack();
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: linear-gradient(180deg, #e8eef7 0%, #f2f5fa 100%); padding-bottom: 48rpx; }
.loading-wrap { padding: 120rpx 48rpx; text-align: center; }
.loading-text { color: #8c9aa8; font-size: 28rpx; }
.empty { padding: 120rpx 48rpx; text-align: center; }
.empty-text { color: #8c9aa8; font-size: 28rpx; }
.detail-wrap { padding: 24rpx 24rpx 32rpx; }
.section-card { background: #fff; border-radius: 24rpx; overflow: hidden; margin-bottom: 28rpx; box-shadow: 0 8rpx 32rpx rgba(41, 121, 255, 0.06); border: 1rpx solid rgba(41, 121, 255, 0.06); }
.section-head { display: flex; align-items: center; padding: 28rpx 32rpx; background: linear-gradient(135deg, #f8fbff 0%, #f0f6ff 100%); border-bottom: 1rpx solid #eef3fa; }
.section-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #2979ff; margin-right: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 600; color: #1e3a5f; letter-spacing: 0.5rpx; }
.section-body { padding: 8rpx 32rpx 24rpx; }
.detail-row { display: flex; align-items: center; min-height: 96rpx; padding: 0 16rpx; border-radius: 12rpx; font-size: 28rpx; margin-bottom: 4rpx; }
.detail-row .label { width: 200rpx; flex-shrink: 0; color: #6b7c93; font-size: 26rpx; }
.detail-row .value { flex: 1; color: #2c3e50; text-align: right; word-break: break-all; font-size: 28rpx; }
.detail-row .value-strong { color: #1e3a5f; font-weight: 500; }
.detail-row .value-num { color: #2979ff; font-weight: 600; font-size: 32rpx; }
.detail-row-highlight { background: linear-gradient(90deg, rgba(41, 121, 255, 0.06) 0%, transparent 100%); margin: 12rpx -16rpx 4rpx; padding: 20rpx 16rpx; }
</style>
src/pages/consumablesLogistics/stockReport/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,242 @@
<template>
  <view class="report-page">
    <PageHeader title="库存报表" @back="goBack" />
    <view class="tabs-wrap">
      <view
        v-for="t in reportTypes"
        :key="t.value"
        class="tab-item"
        :class="{ active: searchForm.reportType === t.value }"
        @click="searchForm.reportType = t.value"
      >
        <text>{{ t.label }}</text>
      </view>
    </view>
    <view class="list-section">
      <view class="section-header">
        <text class="table-title">{{ tableTitle }}</text>
      </view>
      <view v-if="tableData.length > 0">
        <view v-for="(item, index) in tableData" :key="index" class="card-item">
          <view class="card-header">
            <view class="header-main">
              <text class="product-name">{{ item.productName }}</text>
              <text class="sub-title">{{ item.model }}</text>
            </view>
          </view>
          <up-divider />
          <view class="card-body">
            <view class="row"><text class="l">单位</text><text class="r">{{ item.unit }}</text></view>
            <view class="row" v-if="searchForm.reportType !== 'inout'"><text class="l">入库时间</text><text class="r">{{ item.createTime }}</text></view>
            <view class="row" v-if="searchForm.reportType !== 'inout'"><text class="l">入库批次</text><text class="r">{{ item.inboundBatches }}</text></view>
            <view class="row"><text class="l">入库数量</text><text class="r">{{ item.totalStockIn ?? item.stockInNum }}</text></view>
            <view class="row" v-if="searchForm.reportType === 'inout'"><text class="l">出库数量</text><text class="r">{{ item.totalStockOut }}</text></view>
            <view class="row"><text class="l">现在库存</text><text class="r highlight">{{ item.currentStock }}</text></view>
            <view class="row" v-if="item.createBy"><text class="l">入库人</text><text class="r">{{ item.createBy }}</text></view>
          </view>
        </view>
        <view class="load-more-wrap">
          <u-loadmore :status="loadStatus" @loadmore="loadMore" />
        </view>
      </view>
      <view v-else class="no-data">暂无数据</view>
    </view>
    <up-popup :show="showDatePicker" mode="bottom" @close="showDatePicker = false">
      <up-datetime-picker
        v-model="dateValue"
        :mode="datePickerMode"
        @confirm="onDateConfirm"
        @cancel="showDatePicker = false"
      />
    </up-popup>
  </view>
</template>
<script setup>
import { ref, reactive, toRefs, computed, watch } from "vue";
import dayjs from "dayjs";
import PageHeader from "@/components/PageHeader.vue";
import { formatDateToYMD } from "@/utils/ruoyi";
import { onShow, onReachBottom } from "@dcloudio/uni-app";
import { getConsumablesInReportList, getConsumablesInInAndOutReportList } from "@/api/consumablesLogistics/consumablesIn.js";
const reportTypes = [
  { label: "日报", value: "daily" },
  { label: "月报", value: "monthly" },
  { label: "进出存报表", value: "inout" },
];
const tableData = ref([]);
const showDatePicker = ref(false);
const dateValue = ref(Date.now());
const datePickerTarget = ref("");
const loadStatus = ref("loadmore");
const page = reactive({ current: 1, size: 20 });
const data = reactive({
  searchForm: {
    reportType: "daily",
    singleDate: "",
    startMonth: "",
    endMonth: "",
    startDate: "",
    endDate: "",
  },
});
const { searchForm } = toRefs(data);
const datePickerMode = computed(() => {
  if (datePickerTarget.value === "startMonth" || datePickerTarget.value === "endMonth") return "month";
  return "date";
});
const tableTitle = computed(() => {
  const m = { daily: "日报详细数据", monthly: "月报详细数据", inout: "进出存报表详细数据" };
  return m[searchForm.value.reportType] || "报表数据";
});
const getQueryParams = () => {
  const p = {
    reportType: searchForm.value.reportType,
    current: page.current,
    size: page.size,
  };
  if (searchForm.value.reportType === "daily") {
    p.reportDate = searchForm.value.singleDate;
  } else if (searchForm.value.reportType === "monthly") {
    p.startMonth = searchForm.value.startMonth;
    p.endMonth = searchForm.value.endMonth;
  } else if (searchForm.value.reportType === "monthly") {
    p.startMonth = searchForm.value.startMonth;
    p.endMonth = searchForm.value.endMonth;
  } else {
    p.startDate = searchForm.value.startDate;
    p.endDate = searchForm.value.endDate;
  }
  return p;
};
const getList = () => {
  const isFirstPage = page.current === 1;
  if (isFirstPage) {
    uni.showLoading({ title: "查询中...", mask: true });
  }
  const params = getQueryParams();
  const isInout = searchForm.value.reportType === "inout";
  const api = isInout ? getConsumablesInInAndOutReportList : getConsumablesInReportList;
  api(params)
    .then((res) => {
      uni.hideLoading();
      const records = res.data?.records || [];
      const total = res.data?.total || records.length;
      if (isFirstPage) {
        tableData.value = records;
      } else {
        tableData.value = [...tableData.value, ...records];
      }
      if (tableData.value.length >= total || total === 0) {
        loadStatus.value = "nomore";
      } else {
        loadStatus.value = "loadmore";
      }
    })
    .catch(() => {
      uni.hideLoading();
      loadStatus.value = "error";
      if (isFirstPage) {
        uni.showToast({ title: "查询失败", icon: "none" });
      }
    });
};
const handleQuery = () => {
  page.current = 1;
  loadStatus.value = "loadmore";
  getList();
};
const loadMore = () => {
  if (loadStatus.value === "nomore" || loadStatus.value === "loading") return;
  loadStatus.value = "loading";
  page.current++;
  getList();
};
const openDatePicker = (target) => {
  datePickerTarget.value = target;
  let val = "";
  if (target === "single") val = searchForm.value.singleDate;
  else if (target === "startMonth") val = searchForm.value.startMonth;
  else if (target === "endMonth") val = searchForm.value.endMonth;
  dateValue.value = val ? new Date(val).getTime() : Date.now();
  showDatePicker.value = true;
};
const onDateConfirm = (e) => {
  const isMonth = datePickerTarget.value === "startMonth" || datePickerTarget.value === "endMonth";
  const str = isMonth ? dayjs(e.value).format("YYYY-MM") : formatDateToYMD(e.value);
  if (datePickerTarget.value === "single") searchForm.value.singleDate = str;
  else if (datePickerTarget.value === "startMonth") searchForm.value.startMonth = str;
  else if (datePickerTarget.value === "endMonth") searchForm.value.endMonth = str;
  showDatePicker.value = false;
  handleQuery();
};
const initDefaultDates = () => {
  const today = dayjs();
  if (!searchForm.value.singleDate) {
    searchForm.value.singleDate = today.format("YYYY-MM-DD");
  }
  if (!searchForm.value.startMonth || !searchForm.value.endMonth) {
    const startOfMonth = today.startOf("month").format("YYYY-MM-DD");
    const endOfMonth = today.endOf("month").format("YYYY-MM-DD");
    searchForm.value.startMonth = startOfMonth;
    searchForm.value.endMonth = endOfMonth;
  }
  if (!searchForm.value.startDate || !searchForm.value.endDate) {
    searchForm.value.endDate = today.format("YYYY-MM-DD");
    searchForm.value.startDate = today.subtract(6, "day").format("YYYY-MM-DD");
  }
};
watch(
  () => searchForm.value.reportType,
  () => {
    handleQuery();
  }
);
onShow(() => {
  initDefaultDates();
  handleQuery();
});
onReachBottom(() => loadMore());
const goBack = () => uni.navigateBack();
</script>
<style lang="scss" scoped>
.report-page { min-height: 100vh; background: #f5f5f5; padding-bottom: 40rpx; }
.tabs-wrap { display: flex; background: #fff; padding: 24rpx; gap: 24rpx; }
.tab-item { flex: 1; text-align: center; padding: 20rpx; border-radius: 12rpx; background: #f0f0f0; font-size: 28rpx; color: #666; }
.tab-item.active { background: #2979ff; color: #fff; }
.search-section { background: #fff; margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.search-row { display: flex; align-items: center; margin-bottom: 0; flex-wrap: wrap; }
.search-row .label { width: 140rpx; font-size: 26rpx; color: #666; }
.search-row .label.end { margin-left: 24rpx; }
.date-picker { flex: 1; min-width: 200rpx; padding: 20rpx; background: #f5f5f5; border-radius: 12rpx; font-size: 28rpx; }
.btn-row { display: flex; gap: 24rpx; margin-top: 24rpx; }
.btn-query { flex: 1; text-align: center; padding: 24rpx; background: #2979ff; color: #fff; border-radius: 12rpx; }
.btn-reset { flex: 1; text-align: center; padding: 24rpx; background: #e0e0e0; border-radius: 12rpx; }
.list-section { margin: 24rpx; }
.section-header { margin-bottom: 16rpx; padding: 16rpx 20rpx; }
.table-title { font-size: 30rpx; font-weight: 500; color: #333; }
.card-item { background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
.card-header { padding: 4rpx 0 12rpx; }
.header-main { display: flex; flex-direction: column; gap: 6rpx; }
.product-name { font-size: 30rpx; font-weight: 500; color: #333; }
.sub-title { font-size: 24rpx; color: #999; }
.card-body .row { display: flex; justify-content: space-between; padding: 6rpx 0; font-size: 26rpx; }
.card-body .l { color: #666; } .card-body .r { color: #333; } .card-body .r.highlight { color: #2979ff; font-weight: 500; }
.no-data { text-align: center; padding: 60rpx 0; color: #999; font-size: 28rpx; }
.load-more-wrap { padding: 24rpx 0 8rpx; }
</style>
src/pages/index.vue
@@ -237,6 +237,31 @@
        </up-grid>
      </view>
    </view>
    <!-- è€—材物料模块 -->
    <view class="common-module material-module"
          v-if="hasMaterialItems">
      <view class="module-header">
        <view class="module-title-container">
          <text class="module-title">耗材物料</text>
        </view>
      </view>
      <view class="module-content">
        <up-grid :border="false"
                 col="4">
          <up-grid-item v-for="(item, index) in materialItems"
                        :key="index"
                        @click="handleCommonItemClick(item)">
            <view class="icon-container"
                  :style="{ background: item.bgColor }">
              <up-icon :name="item.icon"
                       :size="58"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-label">{{item.label}}</text>
          </up-grid-item>
        </up-grid>
      </view>
    </view>
  </view>
</template>
@@ -483,6 +508,29 @@
    {
      icon: "/static/images/icon/caigoutaizhang@2x.png",
      label: "库存报表",
    },
  ]);
  // è€—材物料功能数据
  const materialItems = reactive([
    {
      icon: "/static/images/icon/caigoutaizhang@2x.png",
      label: "入库管理",
      module: "material",
    },
    {
      icon: "/static/images/icon/caigoutaizhang@2x.png",
      label: "出库台账",
      module: "material",
    },
    {
      icon: "/static/images/icon/caigoutaizhang@2x.png",
      label: "库存管理",
      module: "material",
    },
    {
      icon: "/static/images/icon/caigoutaizhang@2x.png",
      label: "库存报表",
      module: "material",
    },
  ]);
@@ -795,22 +843,34 @@
        break;
      case "入库管理":
        uni.navigateTo({
          url: "/pages/inventoryManagement/receiptManagement/index",
          url:
            item.module === "material"
              ? "/pages/consumablesLogistics/receiptManagement/index"
              : "/pages/inventoryManagement/receiptManagement/index",
        });
        break;
      case "出库台账":
        uni.navigateTo({
          url: "/pages/inventoryManagement/dispatchLog/index",
          url:
            item.module === "material"
              ? "/pages/consumablesLogistics/dispatchLog/index"
              : "/pages/inventoryManagement/dispatchLog/index",
        });
        break;
      case "库存管理":
        uni.navigateTo({
          url: "/pages/inventoryManagement/stockManagement/index",
          url:
            item.module === "material"
              ? "/pages/consumablesLogistics/stockManagement/index"
              : "/pages/inventoryManagement/stockManagement/index",
        });
        break;
      case "库存报表":
        uni.navigateTo({
          url: "/pages/inventoryManagement/stockReport/index",
          url:
            item.module === "material"
              ? "/pages/consumablesLogistics/stockReport/index"
              : "/pages/inventoryManagement/stockReport/index",
        });
        break;
      default:
@@ -1136,10 +1196,26 @@
    // è¿‡æ»¤ä»“储物流菜单
    const originalWarehouseLogistics = [
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "入库管理" },
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "出库台账" },
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "库存管理" },
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "库存报表" },
      {
        icon: "/static/images/icon/caigoutaizhang@2x.png",
        label: "入库管理",
        module: "warehouse",
      },
      {
        icon: "/static/images/icon/caigoutaizhang@2x.png",
        label: "出库台账",
        module: "warehouse",
      },
      {
        icon: "/static/images/icon/caigoutaizhang@2x.png",
        label: "库存管理",
        module: "warehouse",
      },
      {
        icon: "/static/images/icon/caigoutaizhang@2x.png",
        label: "库存报表",
        module: "warehouse",
      },
    ];
    const filteredWarehouseLogistics = originalWarehouseLogistics.filter(
      item => allowedMenuTitles.has(item.label)
@@ -1149,6 +1225,25 @@
      warehouseLogisticsItems.length,
      ...filteredWarehouseLogistics
    );
    // è¿‡æ»¤è€—材物料菜单
    const materialCandidates = label => {
      const list = [label, `耗材${label}`];
      if (label.endsWith("管理")) {
        list.push(`耗材${label.replace("管理", "")}`);
      }
      return list;
    };
    const originalMaterial = [
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "入库管理", module: "material" },
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "出库台账", module: "material" },
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "库存管理", module: "material" },
      { icon: "/static/images/icon/caigoutaizhang@2x.png", label: "库存报表", module: "material" },
    ];
    const filteredMaterial = originalMaterial.filter(item => {
      return materialCandidates(item.label).some(t => allowedMenuTitles.has(t));
    });
    materialItems.splice(0, materialItems.length, ...filteredMaterial);
  };
  // æ£€æŸ¥æ¨¡å—是否有菜单项需要显示
@@ -1162,6 +1257,7 @@
  const hasWarehouseLogisticsItems = computed(
    () => warehouseLogisticsItems.length > 0
  );
  const hasMaterialItems = computed(() => materialItems.length > 0);
  onMounted(() => {
    // æ¯æ¬¡è¿›å…¥é¦–页都强制刷新用户信息和路由权限,不做本地缓存判断