<script setup lang="ts">
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
|
import { message, Modal } from 'ant-design-vue';
|
import { IconifyIcon } from '@vben/icons';
|
import { MesWmWarehouseTypeEnum } from '@vben/constants';
|
import { gsap } from 'gsap';
|
|
import type { SeedQualityScan } from '#/api/mes/qc/seedQuality';
|
import type { MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
|
import {
|
resolveSeedQuality,
|
submitSeedQualityGroup,
|
} from '#/api/mes/qc/seedQuality';
|
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
|
|
/** 扫码类型:袋码 / 托盘码 / 批次码(与生产线扫码入库保持一致) */
|
type ScanType = 'BAG' | 'PALLET' | 'BATCH';
|
|
const SCAN_TYPE_LABEL: Record<ScanType, string> = {
|
BAG: '袋码',
|
PALLET: '托盘码',
|
BATCH: '批次码',
|
};
|
const SCAN_TYPE_OPTIONS = [
|
{ label: '袋码', value: 'BAG' },
|
{ label: '托盘码', value: 'PALLET' },
|
{ label: '批次码', value: 'BATCH' },
|
] as const;
|
|
interface IndicatorForm {
|
code: string;
|
label: string;
|
unit: string;
|
value?: number;
|
minValue?: number;
|
maxValue?: number;
|
}
|
|
const pageRoot = ref<HTMLElement>();
|
const scanType = ref<ScanType>('BAG');
|
const code = ref('');
|
/** 已识别的质检对象:带出有效袋数、已检袋数与本次待检袋清单 */
|
const target = ref<SeedQualityScan>();
|
const reason = ref('');
|
const resolving = ref(false);
|
const loading = ref(false);
|
/** 最近一次成功提交的袋数(用于结果反馈) */
|
const submittedCount = ref<number>();
|
let animationContext: gsap.Context | undefined;
|
|
/** 可选合格仓库(仓库类型=20 合格库) */
|
const qualifiedWarehouses = ref<MesWmWarehouseApi.Warehouse[]>([]);
|
const qualifiedWarehouseId = ref<number>();
|
|
const indicators = ref<IndicatorForm[]>([
|
{ code: 'purity', label: '纯度', unit: '%', minValue: 0, maxValue: 100 },
|
{ code: 'cleanliness', label: '净度', unit: '%', minValue: 0, maxValue: 100 },
|
{ code: 'germination_rate', label: '发芽率', unit: '%', minValue: 0, maxValue: 100 },
|
{ code: 'moisture', label: '水分', unit: '%', minValue: 0, maxValue: 100 },
|
]);
|
|
const pendingCount = computed(() => target.value?.pendingBagIds?.length ?? 0);
|
const completedCount = computed(() => target.value?.completedBagIds?.length ?? 0);
|
const enteredCount = computed(() => indicators.value.filter((item) => item.value !== undefined && item.value !== null).length);
|
const passedCount = computed(() => indicators.value.filter(isPassed).length);
|
const allEntered = computed(() => enteredCount.value === indicators.value.length);
|
const overallPassed = computed(() => allEntered.value && passedCount.value === indicators.value.length);
|
const isGroupScan = computed(() => scanType.value !== 'BAG');
|
const qualifiedWarehouseOptions = computed(() =>
|
qualifiedWarehouses.value.map((item) => ({
|
label: item.code ? `${item.name}(${item.code})` : (item.name ?? ''),
|
value: item.id ?? 0,
|
})),
|
);
|
|
function isPassed(item: IndicatorForm) {
|
return item.value !== undefined && item.value !== null
|
&& item.minValue !== undefined && item.maxValue !== undefined
|
&& item.value >= item.minValue && item.value <= item.maxValue;
|
}
|
|
/** 统一提取后端业务错误信息 */
|
function errorText(error: unknown) {
|
const response = error as {
|
message?: string;
|
msg?: string;
|
response?: { data?: { message?: string; msg?: string } };
|
};
|
return response.response?.data?.message
|
?? response.response?.data?.msg
|
?? response.message
|
?? response.msg
|
?? '操作失败,请稍后重试';
|
}
|
|
/** 清空指标与不合格原因 */
|
function resetIndicators() {
|
reason.value = '';
|
indicators.value.forEach((item) => {
|
item.value = undefined;
|
item.minValue = 0;
|
item.maxValue = 100;
|
});
|
}
|
|
/** 切换扫码类型:码值与已识别对象同时失效,避免用错类型的码值提交 */
|
function onScanTypeChange() {
|
code.value = '';
|
target.value = undefined;
|
submittedCount.value = undefined;
|
resetIndicators();
|
}
|
|
function resetForm() {
|
code.value = '';
|
target.value = undefined;
|
submittedCount.value = undefined;
|
resetIndicators();
|
}
|
|
/** 识别码值:确定本次质检范围(袋码 1 袋 / 托盘已装袋 / 整批袋数) */
|
async function identify() {
|
const value = code.value.trim();
|
if (!value) {
|
message.warning(`请输入或扫描${SCAN_TYPE_LABEL[scanType.value]}`);
|
return;
|
}
|
resolving.value = true;
|
submittedCount.value = undefined;
|
try {
|
const data = await resolveSeedQuality(value, scanType.value);
|
if (data.canceled) {
|
target.value = undefined;
|
message.error('该质检对象已作废,不能质检');
|
return;
|
}
|
if (!data.inbound) {
|
target.value = undefined;
|
message.error('该对象的有效袋码尚未全部入库,暂不能质检');
|
return;
|
}
|
if (!data.pendingBagIds?.length) {
|
target.value = undefined;
|
message.error('该对象没有待质检袋码,请在质检记录中查看已有结果');
|
return;
|
}
|
target.value = data;
|
resetIndicators();
|
message.success(`${SCAN_TYPE_LABEL[scanType.value]} ${data.code} 已识别,本次待检 ${data.pendingBagIds.length} 袋`);
|
} catch (error) {
|
target.value = undefined;
|
message.error(errorText(error));
|
} finally {
|
resolving.value = false;
|
}
|
}
|
|
async function submit() {
|
const current = target.value;
|
if (!current) {
|
message.warning(`请先识别${SCAN_TYPE_LABEL[scanType.value]}`);
|
return;
|
}
|
if (!allEntered.value) {
|
message.warning('请先填写四项指标的实测值');
|
return;
|
}
|
if (indicators.value.some((item) => item.minValue === undefined || item.maxValue === undefined || item.minValue > item.maxValue)) {
|
message.warning('指标下限不能大于上限');
|
return;
|
}
|
if (!qualifiedWarehouseId.value) {
|
message.warning('请选择入库合格库');
|
return;
|
}
|
if (!overallPassed.value && !reason.value.trim()) {
|
message.warning('检验不合格时必须填写不合格原因');
|
return;
|
}
|
|
const count = current.pendingBagIds?.length ?? 0;
|
const scopeNote = isGroupScan.value
|
? `指标与检验结论将应用于本次全部 ${count} 袋,请确认抽样结果适用于该范围。`
|
: '';
|
const confirmed = await new Promise<boolean>((resolve) => {
|
Modal.confirm({
|
title: overallPassed.value ? '确认提交合格结果?' : '确认提交不合格结果?',
|
content: overallPassed.value
|
? `本次 ${count} 袋将统一判定合格,并从暂存库存转入所选合格库。${scopeNote}`
|
: `本次 ${count} 袋将从暂存库存中清除并标记不合格,同时保留不合格原因用于质量分析。${scopeNote}`,
|
okText: '确认提交',
|
cancelText: '返回修改',
|
okButtonProps: { danger: !overallPassed.value },
|
onOk: () => resolve(true),
|
onCancel: () => resolve(false),
|
});
|
});
|
if (!confirmed) return;
|
|
loading.value = true;
|
const passed = overallPassed.value;
|
try {
|
const result = await submitSeedQualityGroup({
|
code: current.code,
|
batchCode: current.batchCode || undefined,
|
scanType: scanType.value,
|
bagIds: current.pendingBagIds ?? [],
|
qualifiedWarehouseId: qualifiedWarehouseId.value,
|
reason: reason.value.trim() || undefined,
|
indicators: indicators.value.map(({ code: indicatorCode, value, minValue, maxValue }) => ({
|
code: indicatorCode,
|
value: value as number,
|
minValue: minValue as number,
|
maxValue: maxValue as number,
|
})),
|
});
|
submittedCount.value = result.processedCount;
|
message.success(`本次 ${result.processedCount} 袋已提交:${passed ? '质检合格,已转入所选合格库' : '质检不合格,已记录原因并移除暂存库存'}`);
|
if (pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
gsap.fromTo('.result-card', { scale: 0.97, autoAlpha: 0.5 }, { scale: 1, autoAlpha: 1, duration: 0.35, ease: 'power2.out' });
|
}
|
// 检验范围已消费,需重新识别后才能提交下一批,避免误把上一批的清单再次提交
|
code.value = '';
|
target.value = undefined;
|
resetIndicators();
|
} catch (error) {
|
message.error(errorText(error));
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
onMounted(async () => {
|
try {
|
const warehouses = await getWarehouseSimpleList();
|
qualifiedWarehouses.value = (warehouses ?? []).filter(
|
(item) => item.type === MesWmWarehouseTypeEnum.QUALIFIED && !item.frozen,
|
);
|
} catch {
|
qualifiedWarehouses.value = [];
|
}
|
await nextTick();
|
if (!pageRoot.value) return;
|
animationContext = gsap.context(() => {
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
gsap.from('.page-header, .summary-card, .form-card, .result-card', {
|
autoAlpha: reduceMotion ? 1 : 0,
|
y: reduceMotion ? 0 : 14,
|
duration: reduceMotion ? 0 : 0.4,
|
stagger: reduceMotion ? 0 : 0.06,
|
ease: 'power2.out',
|
});
|
}, pageRoot.value);
|
});
|
|
onUnmounted(() => animationContext?.revert());
|
</script>
|
|
<template>
|
<div ref="pageRoot" class="quality-page">
|
<header class="page-header">
|
<div>
|
<div class="eyebrow">MES / 质量管理</div>
|
<h1>种子质检</h1>
|
<p>选择袋码、托盘码或批次码识别待检范围,录入纯度、净度、发芽率和水分,系统自动判定检验结论</p>
|
</div>
|
<div class="header-badge"><IconifyIcon icon="ep:checked" /> 袋码 / 托盘码 / 批次码</div>
|
</header>
|
|
<section class="summary-grid" aria-label="质检摘要">
|
<div class="summary-card blue"><IconifyIcon icon="ep:document-checked" /><div><span>检验项目</span><strong>4</strong><small>纯度、净度、发芽率、水分</small></div></div>
|
<div class="summary-card" :class="target ? 'green' : 'blue'"><IconifyIcon icon="ep:box" /><div><span>本次待检</span><strong>{{ target ? `${pendingCount} 袋` : '待识别' }}</strong><small>{{ target ? `${SCAN_TYPE_LABEL[scanType]} ${target.code}` : `请选择类型后识别${SCAN_TYPE_LABEL[scanType]}` }}</small></div></div>
|
<div class="summary-card" :class="overallPassed ? 'green' : allEntered ? 'red' : 'blue'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><div><span>总体结论</span><strong>{{ overallPassed ? '合格' : allEntered ? '不合格' : '待录入' }}</strong><small>{{ overallPassed ? '可提交合格结果' : allEntered ? '请填写不合格原因' : `已录入 ${enteredCount} / 4 项` }}</small></div></div>
|
</section>
|
|
<section class="content-grid">
|
<a-card class="form-card" :bordered="false">
|
<template #title><div class="card-title"><IconifyIcon icon="ep:edit-pen" />质检信息</div></template>
|
<a-form layout="vertical">
|
<a-form-item label="扫码类型" required>
|
<div class="scan-row">
|
<a-select v-model:value="scanType" class="scan-type" size="large" :options="SCAN_TYPE_OPTIONS" :disabled="loading" @change="onScanTypeChange" />
|
<a-input-search v-model:value="code" class="scan-input" size="large" :disabled="loading" :loading="resolving" enter-button="识别" :placeholder="`请输入或扫描${SCAN_TYPE_LABEL[scanType]}`" @search="identify" />
|
</div>
|
<div class="scan-hint"><IconifyIcon icon="ep:info-filled" /> 袋码按 1 袋、托盘码按已装袋数、批次码按批次袋数确定本次待检范围,已检与作废袋码不重复处理</div>
|
</a-form-item>
|
|
<div v-if="target" class="target-panel">
|
<div class="target-head">
|
<span class="target-type">{{ SCAN_TYPE_LABEL[scanType] }}已识别</span>
|
<span class="target-code">{{ target.code }}</span>
|
<a-tag v-if="target.businessCode" color="blue">业务码 {{ target.businessCode }}</a-tag>
|
<a-tag v-if="target.batchCode" color="geekblue">批次 {{ target.batchCode }}</a-tag>
|
</div>
|
<div class="target-stats">
|
<div><span>有效袋数</span><strong>{{ target.totalCount ?? 0 }}</strong></div>
|
<div><span>已检袋数</span><strong>{{ completedCount }}</strong></div>
|
<div class="highlight"><span>本次待检</span><strong>{{ pendingCount }} 袋</strong></div>
|
</div>
|
<p v-if="isGroupScan" class="target-warning">以下指标与检验结论将应用于本次全部 {{ pendingCount }} 袋,请确认抽样结果适用于该范围。</p>
|
</div>
|
<a-alert v-else class="target-empty" type="info" show-icon message="尚未识别质检对象" description="先选择扫码类型并识别码值,系统会带出该对象的有效袋数、已检袋数与本次待检袋数。" />
|
|
<div class="base-grid">
|
<a-form-item label="生产批次"><a-input :value="target?.batchCode ?? ''" size="large" disabled placeholder="识别后自动带出" /></a-form-item>
|
<a-form-item label="入库合格库" required>
|
<a-select
|
v-model:value="qualifiedWarehouseId"
|
size="large"
|
:disabled="loading"
|
:options="qualifiedWarehouseOptions"
|
placeholder="请选择质检合格后入库的合格库"
|
/>
|
</a-form-item>
|
</div>
|
<p v-if="!qualifiedWarehouses.length" class="target-warning">暂无可用合格库,请联系管理员配置后再提交质检。</p>
|
|
<a-divider orientation="left">指标结果</a-divider>
|
<div class="indicator-list">
|
<div v-for="item in indicators" :key="item.code" class="indicator-row" :class="{ failed: !isPassed(item) }">
|
<div class="indicator-name"><span class="indicator-dot" :class="isPassed(item) ? 'pass' : 'fail'" /><strong>{{ item.label }}</strong><small>{{ item.unit }}</small></div>
|
<a-input-number v-model:value="item.value" class="indicator-input" :disabled="loading" :min="0" :precision="3" placeholder="实测值" />
|
<span class="range-separator">范围</span>
|
<a-input-number v-model:value="item.minValue" class="range-input" :disabled="loading" :min="0" :precision="3" placeholder="下限" />
|
<span>—</span>
|
<a-input-number v-model:value="item.maxValue" class="range-input" :disabled="loading" :min="0" :precision="3" placeholder="上限" />
|
<a-tag :color="isPassed(item) ? 'success' : 'error'">{{ isPassed(item) ? '合格' : '超出范围' }}</a-tag>
|
</div>
|
</div>
|
|
<a-form-item label="不合格原因" :required="!overallPassed" class="reason-item"><a-textarea v-model:value="reason" :disabled="loading" :rows="3" :maxlength="500" show-count :placeholder="overallPassed ? '合格结果无需填写' : '请说明不合格原因'" /></a-form-item>
|
<div class="form-actions"><a-button :disabled="loading" @click="resetForm">重置</a-button><a-button type="primary" size="large" :loading="loading" :disabled="!target" @click="submit"><IconifyIcon icon="ep:upload-filled" />提交质检</a-button></div>
|
</a-form>
|
</a-card>
|
|
<a-card class="result-card" :bordered="false">
|
<template #title><div class="card-title"><IconifyIcon icon="ep:data-analysis" />判定说明</div></template>
|
<div class="result-hero" :class="overallPassed ? 'passed' : allEntered ? 'failed-result' : 'pending-result'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><strong>{{ overallPassed ? '当前结果合格' : allEntered ? '当前存在不合格项' : '等待录入指标' }}</strong><span>{{ allEntered ? '实测值需处于最小值与最大值之间' : `已录入 ${enteredCount} / 4 项实测值` }}</span></div>
|
<div class="rule-list"><div><IconifyIcon icon="ep:check" /><span>先识别码值再录入指标,四项指标必须全部提交</span></div><div><IconifyIcon icon="ep:check" /><span>边界值按合格处理</span></div><div><IconifyIcon icon="ep:check" /><span>托盘码、批次码的结论应用于本次全部待检袋</span></div><div><IconifyIcon icon="ep:check" /><span>不合格结果需保留原因</span></div><div><IconifyIcon icon="ep:check" /><span>质检员与检验时间由服务端记录</span></div></div>
|
<a-alert message="库存流转由后端处理" description="合格袋将从暂存库存转入所选合格库;不合格袋将直接清除暂存库存并保留不合格原因。提交时服务端会重新核对本次待检袋清单,清单变化需重新识别。" type="info" show-icon />
|
<div v-if="submittedCount" class="success-note"><IconifyIcon icon="ep:success-filled" /><span>最近提交质检:<strong>{{ submittedCount }} 袋</strong></span></div>
|
</a-card>
|
</section>
|
</div>
|
</template>
|
|
<style scoped>
|
.quality-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; }
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; }.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }.page-header h1 { margin: 6px 0; font-size: 28px; }.page-header p { margin: 0; color: #718096; }.header-badge { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid #cfe0fb; border-radius: 7px; color: #1677ff; background: #f0f6ff; font-size: 13px; font-weight: 600; }
|
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }.summary-card { display: flex; align-items: flex-start; gap: 14px; padding: 17px 19px; border: 1px solid #e8edf5; border-radius: 10px; background: #fff; box-shadow: 0 3px 12px #243b5310; }.summary-card > .iconify { flex: 0 0 38px; width: 38px; height: 38px; padding: 9px; border-radius: 8px; font-size: 20px; }.summary-card.blue > .iconify { color: #1677ff; background: #eaf3ff; }.summary-card.green > .iconify { color: #17a673; background: #e6f8f0; }.summary-card.red > .iconify { color: #e05252; background: #fff0f0; }.summary-card span, .summary-card small { display: block; color: #7b8799; font-size: 12px; }.summary-card small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.summary-card strong { display: block; margin: 4px 0; font-size: 23px; }.content-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .75fr); gap: 18px; }.form-card, .result-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 9px; font-weight: 700; }.card-title :deep(.iconify) { color: #1677ff; }.base-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.full-width { width: 100%; }
|
.scan-row { display: flex; align-items: stretch; gap: 10px; }.scan-type { flex: 0 0 140px; }.scan-input { flex: 1 1 auto; min-width: 0; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint :deep(.iconify) { flex: 0 0 auto; color: #1677ff; }
|
.target-panel { margin-bottom: 18px; padding: 14px 16px; border: 1px solid #d6e8ff; border-radius: 8px; background: #f7fbff; }.target-head { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }.target-type { color: #1677ff; font-size: 13px; font-weight: 700; }.target-code { font-size: 16px; font-weight: 700; letter-spacing: .02em; }.target-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 12px; }.target-stats div { padding: 9px 12px; border: 1px solid #e8edf5; border-radius: 6px; background: #fff; }.target-stats span { display: block; color: #7b8799; font-size: 12px; }.target-stats strong { display: block; margin-top: 3px; font-size: 19px; }.target-stats .highlight { border-color: #b7ebd2; background: #f2fcf7; }.target-stats .highlight strong { color: #16835b; }.target-warning { margin: 12px 0 0; color: #c07a17; font-size: 12px; line-height: 1.6; }.target-empty { margin-bottom: 18px; }
|
.indicator-list { display: flex; flex-direction: column; gap: 10px; }.indicator-row { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border: 1px solid #e8edf5; border-radius: 7px; background: #fbfcfe; }.indicator-row.failed { border-color: #ffd6d6; background: #fffafa; }.indicator-name { display: flex; align-items: center; gap: 8px; min-width: 82px; }.indicator-name small { color: #8793a5; font-size: 12px; }.indicator-dot { width: 8px; height: 8px; border-radius: 50%; }.indicator-dot.pass { background: #20b878; }.indicator-dot.fail { background: #e05252; }.indicator-input { width: 105px; }.range-input { width: 88px; }.range-separator { margin-left: auto; color: #8793a5; font-size: 12px; }.reason-item { margin-top: 18px; }.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px; }.form-actions :deep(.iconify) { margin-right: 5px; }
|
.result-hero { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 8px 0 22px; padding: 25px 15px; border-radius: 8px; text-align: center; }.result-hero :deep(.iconify) { font-size: 35px; }.result-hero strong { font-size: 18px; }.result-hero span { color: #718096; font-size: 12px; }.result-hero.passed { color: #16835b; background: #effaf5; }.result-hero.failed-result { color: #c53e3e; background: #fff3f3; }.result-hero.pending-result { color: #1677ff; background: #f0f6ff; }.rule-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: 22px; }.rule-list div { display: flex; align-items: center; gap: 9px; color: #59677b; font-size: 13px; }.rule-list :deep(.iconify) { color: #20b878; }.success-note { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 11px 12px; border-radius: 7px; color: #16835b; background: #effaf5; font-size: 13px; }
|
@media (max-width: 1050px) { .content-grid { grid-template-columns: 1fr; }.base-grid { grid-template-columns: repeat(2, 1fr); } }
|
@media (max-width: 760px) { .quality-page { padding: 14px; }.page-header { display: block; }.header-badge { margin-top: 14px; }.summary-grid, .base-grid, .target-stats { grid-template-columns: 1fr; }.scan-row { flex-wrap: wrap; }.scan-type { flex-basis: 100%; }.indicator-row { flex-wrap: wrap; }.range-separator { margin-left: 0; }.indicator-input { flex: 1; min-width: 105px; }.range-input { flex: 1; min-width: 75px; } }
|
</style>
|