优化仓库分类管理,新增兼容历史分类编码的支持,重构相关服务和控制器以提升系统的可维护性和扩展性。同时,增强原材料卡片和库区管理的查询逻辑,确保数据展示的准确性和一致性。

This commit is contained in:
geht
2026-05-15 11:19:12 +08:00
parent ffc390f3de
commit 5d7335d1a7
23 changed files with 1568 additions and 12 deletions

View File

@@ -0,0 +1,8 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
board = '/xslmes/mesXslRawMaterialWarehouseBoard/board',
}
export const fetchBoard = (params: { warehouseId?: string; keyword?: string; measureType?: string }) =>
defHttp.get({ url: Api.board, params });

View File

@@ -0,0 +1,725 @@
<template>
<div class="rmb-page">
<div class="rmb-toolbar card-surface">
<div class="rmb-toolbar-row">
<div class="rmb-title">
<span class="rmb-title-icon" />
<div>
<div class="rmb-title-text">原材料库区看板</div>
<div class="rmb-title-sub">按库区聚合条码卡片点击查看明细</div>
</div>
</div>
<div class="rmb-actions">
<a-button type="primary" ghost @click="loadBoard">
<Icon icon="ant-design:reload-outlined" />
刷新
</a-button>
</div>
</div>
<div class="rmb-filter" role="search">
<div class="rmb-filter-row">
<div class="rmb-inline-field">
<span class="rmb-inline-label">所属仓库</span>
<a-select
v-model:value="warehouseId"
allow-clear
placeholder="全部仓库"
class="rmb-filter-control"
:loading="warehouseLoading"
:options="warehouseOptions"
/>
</div>
<div class="rmb-inline-field">
<span class="rmb-inline-label">物料 / 条码 / 批次</span>
<a-input
v-model:value="keyword"
placeholder="关键字模糊筛选"
class="rmb-filter-control"
allow-clear
@press-enter="loadBoard"
/>
</div>
<div class="rmb-inline-field">
<span class="rmb-inline-label">占用率口径</span>
<a-radio-group v-model:value="measureType" button-style="solid" @change="loadBoard">
<a-radio-button value="quantity">剩余数量</a-radio-button>
<a-radio-button value="weight">剩余重量</a-radio-button>
</a-radio-group>
</div>
<div class="rmb-inline-field rmb-inline-actions">
<a-button type="primary" @click="loadBoard">查询</a-button>
<a-button @click="resetFilter">重置</a-button>
</div>
</div>
</div>
</div>
<a-spin :spinning="loading">
<div v-if="!bands.length && !loading" class="rmb-empty card-surface">
<a-empty description="暂无启用库区或无匹配数据" />
</div>
<div v-for="band in bands" :key="band.bandKey" class="rmb-band card-surface">
<div class="rmb-band-head">
<span class="rmb-band-label">{{ band.bandLabel }}</span>
<span class="rmb-band-count">{{ band.areas?.length || 0 }} 个库区</span>
</div>
<div class="rmb-card-row">
<div
v-for="area in band.areas"
:key="area.areaId"
class="rmb-card"
:class="'rmb-card--' + (area.alertLevel || 'unknown')"
@click="openDetail(area)"
>
<div class="rmb-card-head">
<span class="rmb-card-code">{{ area.areaCode }}</span>
<a-tag v-if="area.warehouseName" color="processing" class="rmb-card-wh">{{ area.warehouseName }}</a-tag>
</div>
<div class="rmb-card-name">{{ area.areaName || area.areaCode }}</div>
<div class="rmb-card-stats">
<div>
<div class="rmb-stat-label">当前数量</div>
<div class="rmb-stat-value">{{ area.currentQuantity ?? 0 }}</div>
</div>
<div>
<div class="rmb-stat-label">当前重量</div>
<div class="rmb-stat-value">{{ formatWeight(area.currentWeight) }}</div>
</div>
<div>
<div class="rmb-stat-label">上限</div>
<div class="rmb-stat-value">{{ area.maxCapacity ?? '—' }}</div>
</div>
</div>
<a-progress
:percent="progressPercent(area)"
:show-info="true"
:stroke-color="progressStroke(area)"
:trail-color="'rgba(0,0,0,0.06)'"
size="small"
/>
<div class="rmb-card-foot">
<span class="rmb-meta">卡片 {{ area.cardCount ?? 0 }} · 物料 {{ area.materialKindCount ?? 0 }} </span>
</div>
<div class="rmb-tags">
<template v-for="(m, idx) in (area.topMaterialNames || []).slice(0, 4)" :key="idx">
<a-tooltip :title="m">
<a-tag class="rmb-tag">{{ truncate(m, 10) }}</a-tag>
</a-tooltip>
</template>
<a-tag v-if="(area.topMaterialNames?.length || 0) > 4" class="rmb-tag rmb-tag-more">+{{ (area.topMaterialNames?.length || 0) - 4 }}</a-tag>
</div>
</div>
</div>
</div>
</a-spin>
<a-drawer
v-model:open="detailOpen"
:title="detailTitle"
placement="right"
width="min(96vw, 1080px)"
destroy-on-close
@close="onDetailClose"
@after-open-change="onDrawerAfterOpenChange"
>
<BasicTable @register="registerDetailTable" />
</a-drawer>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialWarehouseBoard" setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { BasicTable, useTable } from '/@/components/Table';
import type { BasicColumn } from '/@/components/Table';
import type { FormSchema } from '/@/components/Form';
import Icon from '/@/components/Icon';
import { fetchBoard } from './MesXslRawMaterialWarehouseBoard.api';
import { list as cardList } from '/@/views/xslmes/mesXslRawMaterialCard/MesXslRawMaterialCard.api';
import { list as warehouseList } from '/@/views/xslmes/mesXslWarehouse/MesXslWarehouse.api';
import { useMessage } from '/@/hooks/web/useMessage';
import { getTenantId } from '/@/utils/auth';
const { createMessage } = useMessage();
/** 上次选择的「所属仓库」持久化键(区分租户) */
const LS_RM_BOARD_WAREHOUSE = 'MES_XSL_RM_BOARD_WAREHOUSE_ID';
function warehouseBoardStorageKey() {
const tid = getTenantId();
if (tid === undefined || tid === null || tid === '') {
return LS_RM_BOARD_WAREHOUSE;
}
return `${LS_RM_BOARD_WAREHOUSE}_${tid}`;
}
/** 写入 localStorage空串/undefined 则清除 */
function persistWarehouseId(id: string | undefined | null) {
const k = warehouseBoardStorageKey();
if (id === undefined || id === null || String(id).trim() === '') {
localStorage.removeItem(k);
return;
}
localStorage.setItem(k, String(id).trim());
}
/** 读取上次选择的仓库 id若无或非法由调用方忽略 */
function readPersistedWarehouseId(): string | undefined {
const v = localStorage.getItem(warehouseBoardStorageKey());
const t = v?.trim();
return t || undefined;
}
interface BoardArea {
areaId: string;
areaCode: string;
areaName?: string;
warehouseId?: string;
warehouseName?: string;
maxCapacity?: number | null;
actualCapacity?: number | null;
cardCount?: number;
materialKindCount?: number;
currentQuantity?: number;
currentWeight?: number | string | null;
topMaterialNames?: string[];
usagePercent?: number | null;
alertLevel?: string;
}
interface BoardBand {
bandKey: string;
bandLabel: string;
bandSort?: number;
areas: BoardArea[];
}
const loading = ref(false);
const warehouseLoading = ref(false);
const measureType = ref<'quantity' | 'weight'>('quantity');
const warehouseId = ref<string | undefined>(undefined);
const keyword = ref('');
const bands = ref<BoardBand[]>([]);
const warehouseOptions = ref<{ label: string; value: string }[]>([]);
const detailOpen = ref(false);
const detailAreaCode = ref('');
const detailTitle = computed(() => (detailAreaCode.value ? `库区明细 · ${detailAreaCode.value}` : '库区明细'));
const detailQuery = reactive({ warehouseArea: '' });
/** 库区明细抽屉:条码/批次/物料 合并关键字 + 剩余数量筛选(单行紧凑布局) */
const detailFormColResponsive = {
xs: 24,
sm: 24,
md: 9,
lg: 9,
xl: 9,
xxl: 9,
span: 9,
} as const;
const detailQtyColResponsive = {
xs: 24,
sm: 24,
md: 7,
lg: 7,
xl: 7,
xxl: 7,
span: 7,
} as const;
const detailSearchFormSchema: FormSchema[] = [
{
label: '条码/批次/物料',
field: 'mixKeyword',
component: 'Input',
componentProps: {
placeholder: '条码/批次/物料模糊',
allowClear: true,
},
colProps: { ...detailFormColResponsive },
},
{
label: '剩余数量',
field: 'remainQtyFilter',
component: 'Select',
defaultValue: '',
componentProps: {
placeholder: '全部',
allowClear: true,
style: { width: '100%', maxWidth: 200 },
options: [
{ label: '全部', value: '' },
{ label: '有剩余', value: 'has' },
{ label: '无剩余', value: 'none' },
],
},
colProps: { ...detailQtyColResponsive },
},
];
/** 查询、重置与本行输入框并排:前两列占 16 栅格,操作列占 8合计 24 */
const detailFormActionCol = { xs: 24, sm: 24, md: 8, lg: 8, xl: 8, xxl: 8, span: 8 };
const detailColumns: BasicColumn[] = [
{ title: '条码', dataIndex: 'barcode', width: 190 },
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
{ title: '入场日期', dataIndex: 'entryDate', width: 110 },
{ title: '物料名称', dataIndex: 'materialName', width: 140 },
{ title: '剩余数量', dataIndex: 'remainingQuantity', width: 90 },
{ title: '剩余重量', dataIndex: 'remainingWeight', width: 90 },
{ title: '检测结果', dataIndex: 'testResult_dictText', width: 90 },
{ title: '库区', dataIndex: 'warehouseArea', width: 100 },
];
const [registerDetailTable, { reload }] = useTable({
title: '原材料卡片明细',
api: cardList,
columns: detailColumns,
useSearchForm: true,
formConfig: {
labelWidth: 108,
layout: 'horizontal',
compact: true,
schemas: detailSearchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
submitButtonOptions: { text: '查询' },
resetButtonOptions: { text: '重置' },
actionColOptions: {
...detailFormActionCol,
style: { textAlign: 'left', whiteSpace: 'nowrap', paddingLeft: '4px' },
},
},
showTableSetting: true,
canResize: true,
immediate: false,
pagination: { pageSize: 20 },
beforeFetch: (params) => {
const merged = Object.assign({}, params, {
warehouseArea: (detailQuery.warehouseArea || '').trim(),
});
const v = merged.remainQtyFilter;
// 后端只认 remainQtyFilter=has/none空串不传避免误筛
if (v === '' || v === undefined || v === null) {
delete merged.remainQtyFilter;
}
const kw = merged.mixKeyword;
if (kw === '' || kw === undefined || kw === null || String(kw).trim() === '') {
delete merged.mixKeyword;
} else if (typeof kw === 'string') {
merged.mixKeyword = kw.trim();
}
return merged;
},
});
async function loadWarehouses() {
warehouseLoading.value = true;
try {
const res = await warehouseList({ pageNo: 1, pageSize: 500 });
const records = res?.records ?? [];
warehouseOptions.value = records.map((r: Record<string, string>) => ({
label: r.warehouseName || r.warehouseCode || r.id,
value: r.id,
}));
// 恢复上次选择的仓库(仅当仍在列表中)
const savedId = readPersistedWarehouseId();
if (savedId) {
const ok = warehouseOptions.value.some((o) => o.value === savedId);
if (ok) {
warehouseId.value = savedId;
} else {
localStorage.removeItem(warehouseBoardStorageKey());
}
}
} catch {
createMessage.warning('加载仓库列表失败');
} finally {
warehouseLoading.value = false;
}
}
async function loadBoard() {
loading.value = true;
try {
const data = await fetchBoard({
warehouseId: warehouseId.value,
keyword: keyword.value?.trim(),
measureType: measureType.value,
});
bands.value = (data?.bands as BoardBand[]) || [];
if (!(data?.bands?.length ?? 0)) {
bands.value = [];
}
} catch (e: unknown) {
createMessage.error(e instanceof Error ? e.message : '加载看板失败');
bands.value = [];
} finally {
loading.value = false;
}
}
function resetFilter() {
warehouseId.value = undefined;
persistWarehouseId(undefined);
keyword.value = '';
measureType.value = 'quantity';
loadBoard();
}
function progressPercent(area: BoardArea): number {
const p = area.usagePercent;
if (p == null || Number.isNaN(p)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(p)));
}
function progressStroke(area: BoardArea) {
const level = area.alertLevel;
const map: Record<string, string> = {
empty: '#bfbfbf',
low: '#597ef7',
normal: 'var(--j-global-primary-color, #1677ff)',
high: '#fa8c16',
full: '#f5222d',
unknown: '#8c8c8c',
};
return map[level || 'unknown'] || map.unknown;
}
function formatWeight(w: BoardArea['currentWeight']) {
if (w == null || w === '') {
return '—';
}
const n = Number(w);
if (Number.isFinite(n)) {
return n.toFixed(3);
}
return String(w);
}
function truncate(s: string, n: number) {
return s.length > n ? `${s.slice(0, n)}` : s;
}
function openDetail(area: BoardArea) {
const code = String(area.areaCode ?? '').trim();
detailQuery.warehouseArea = code;
detailAreaCode.value = code;
detailOpen.value = true;
// 不在此处 reload抽屉 destroy-on-close 时子表格可能尚未挂载,会导致请求未带上条件或无实例
}
/** 抽屉打开动画完成后再拉取明细,确保 BasicTable 已 register */
async function onDrawerAfterOpenChange(open: boolean) {
if (!open) {
return;
}
const code = String(detailQuery.warehouseArea || '').trim();
if (!code) {
return;
}
await nextTick();
await reload();
}
function onDetailClose() {
detailQuery.warehouseArea = '';
}
// 用户切换「所属仓库」时持久化(无 immediate避免挂载前误清空缓存
watch(warehouseId, (v) => {
persistWarehouseId(v);
});
onMounted(async () => {
await loadWarehouses();
await loadBoard();
});
</script>
<style scoped lang="less">
.rmb-page {
padding: 0 8px 16px;
min-height: calc(100vh - 120px);
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, transparent 480px),
radial-gradient(1200px 400px at 10% -10%, rgba(82, 196, 26, 0.08), transparent);
}
.card-surface {
background: rgba(255, 255, 255, 0.92);
border-radius: 14px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
border: 1px solid rgba(15, 23, 42, 0.06);
backdrop-filter: blur(8px);
}
.rmb-toolbar {
padding: 16px 20px 12px;
margin-bottom: 16px;
}
.rmb-toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.rmb-title {
display: flex;
align-items: center;
gap: 12px;
}
.rmb-title-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, var(--j-global-primary-color, #1677ff), #722ed1);
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.35);
flex-shrink: 0;
}
.rmb-title-text {
font-size: 18px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
letter-spacing: 0.02em;
}
.rmb-title-sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
.rmb-filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
}
.rmb-inline-field {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 32px;
}
/* 标签右对齐 + 固定宽度,与控件纵向居中对齐 */
.rmb-inline-label {
flex: 0 0 148px;
width: 148px;
text-align: right;
font-size: 14px;
line-height: 32px;
color: rgba(0, 0, 0, 0.88);
}
.rmb-filter-control {
width: 220px !important;
}
/* 按钮组与其它字段同一中线,不靠虚构标签占位 */
.rmb-inline-actions {
gap: 8px;
padding-left: 4px;
margin-left: 4px;
border-left: 1px solid rgba(15, 23, 42, 0.08);
}
@media (max-width: 768px) {
.rmb-inline-label {
flex: 0 0 100%;
width: 100%;
text-align: left;
line-height: 1.4;
}
.rmb-inline-field {
flex-wrap: wrap;
width: 100%;
}
.rmb-filter-control {
width: 100% !important;
}
.rmb-inline-actions {
width: 100%;
margin-left: 0;
padding-left: 0;
border-left: none;
}
}
.rmb-empty {
padding: 48px;
margin-bottom: 16px;
}
.rmb-band {
padding: 16px 16px 12px;
margin-bottom: 16px;
}
.rmb-band-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
.rmb-band-label {
font-size: 22px;
font-weight: 800;
color: rgba(0, 0, 0, 0.85);
letter-spacing: 0.06em;
}
.rmb-band-count {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.rmb-card-row {
display: flex;
flex-wrap: nowrap;
gap: 14px;
overflow-x: auto;
padding-bottom: 6px;
scroll-snap-type: x proximity;
}
.rmb-card {
flex: 0 0 280px;
scroll-snap-align: start;
padding: 14px 14px 12px;
border-radius: 12px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease;
border: 1px solid rgba(22, 119, 255, 0.12);
background: linear-gradient(145deg, #ffffff 0%, #f6faff 100%);
}
.rmb-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--j-global-primary-color, #1677ff);
opacity: 0.85;
}
.rmb-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(22, 119, 255, 0.18);
}
.rmb-card--empty::before {
background: #bfbfbf;
}
.rmb-card--low::before {
background: #597ef7;
}
.rmb-card--normal::before {
background: var(--j-global-primary-color, #1677ff);
}
.rmb-card--high::before {
background: #fa8c16;
}
.rmb-card--full::before {
background: #f5222d;
}
.rmb-card--unknown::before {
background: #8c8c8c;
}
.rmb-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.rmb-card-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-weight: 700;
font-size: 15px;
color: rgba(0, 0, 0, 0.88);
}
.rmb-card-wh {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.rmb-card-name {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 10px;
min-height: 18px;
}
.rmb-card-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-bottom: 10px;
}
.rmb-stat-label {
font-size: 11px;
color: rgba(0, 0, 0, 0.45);
}
.rmb-stat-value {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.rmb-card-foot {
margin-top: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rmb-meta {
display: inline-block;
}
.rmb-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rmb-tag {
margin: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.rmb-tag-more {
border-style: dashed;
}
</style>