<script setup lang="ts">
|
import { computed, onMounted, ref } from 'vue';
|
|
import type { TablePaginationConfig } from 'ant-design-vue';
|
import { IconifyIcon } from '@vben/icons';
|
import { useAccess } from '@vben/access';
|
import { MesWmWarehouseTypeEnum } from '@vben/constants';
|
import { message, Modal } from 'ant-design-vue';
|
import type { MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
|
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
|
|
import {
|
getSeedQualityPage,
|
getSeedQualityIndicators,
|
getSeedQualityActions,
|
getSeedIndicatorRanges,
|
retestSeedQuality,
|
retestSeedQualityBatch,
|
resolveSeedRetestScope,
|
submitSeedRetestScope,
|
approveSeedQuality,
|
approveSeedQualityBatch,
|
resolveSeedQuality,
|
submitSeedQualityGroup,
|
type SeedQualityRecord,
|
type SeedQualityScan,
|
type SeedQualityIndicator,
|
type SeedQualityAction,
|
type SeedRetestScope,
|
} from '#/api/mes/qc/seedQuality';
|
|
const records = ref<SeedQualityRecord[]>([]);
|
const loading = ref(false);
|
const page = ref(1);
|
const pageSize = ref(10);
|
|
const code = ref('');
|
const batchCode = ref('');
|
const result = ref<number>();
|
const workflowStatus = ref<number>();
|
|
const total = ref(0);
|
const statsTotal = ref(0);
|
const passTotal = ref(0);
|
const failTotal = ref(0);
|
|
const processingTotal = computed(() =>
|
Math.max(statsTotal.value - passTotal.value - failTotal.value, 0),
|
);
|
const { hasAccessByCodes } = useAccess();
|
const canScopeAction = computed(() => [
|
'mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest',
|
'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve',
|
].some((code) => hasAccessByCodes([code])));
|
const workflowLabels: Record<number, string> = {
|
10: '待复检', 20: '待初检审核', 25: '待初检不合格处理', 30: '待复检审核',
|
40: '已放行', 50: '初检驳回待重检', 60: '待复检不合格处理', 70: '已报废',
|
};
|
const actionLabels: Record<string, string> = {
|
INITIAL: '初检', INITIAL_RECHECK: '重新初检', RETEST: '复检', PROCESS_APPROVE: '初检审核通过',
|
PROCESS_REJECT: '初检审核驳回', COMPANY_APPROVE: '复检审核通过',
|
COMPANY_REJECT: '复检审核驳回',
|
};
|
const indicatorLabels: Record<string, string> = {
|
purity: '纯度', cleanliness: '净度', germination_rate: '发芽率', moisture: '水分',
|
};
|
function actionColor(action: string) {
|
if (action.includes('REJECT')) return 'red';
|
if (action.includes('APPROVE')) return 'green';
|
return 'blue';
|
}
|
function actionIndicators(action: SeedQualityAction) {
|
if (!action.indicatorsJson) return [];
|
try {
|
const parsed: unknown = JSON.parse(action.indicatorsJson);
|
return Array.isArray(parsed) ? parsed as Array<{ code: string; value: number; minValue: number; maxValue: number }> : [];
|
} catch {
|
return [];
|
}
|
}
|
const historyOpen = ref(false);
|
const historyActions = ref<SeedQualityAction[]>([]);
|
async function openHistory(record: SeedQualityRecord) {
|
activeRecord.value = record;
|
try {
|
historyActions.value = await getSeedQualityActions(record.id);
|
historyOpen.value = true;
|
} catch (error) {
|
message.error((error as Error).message || '读取流程记录失败');
|
}
|
}
|
const activeRecord = ref<SeedQualityRecord>();
|
const retestOpen = ref(false);
|
const retestBusy = ref(false);
|
const retestBatchIds = ref<number[]>([]);
|
const retestIndicators = ref<SeedQualityIndicator[]>([]);
|
const retestReason = ref('');
|
const scopeOpen = ref(false);
|
const scopeType = ref<'PALLET' | 'BATCH'>('PALLET');
|
const scopeCode = ref('');
|
const scopeError = ref('');
|
const initialScope = ref<SeedQualityScan>();
|
const initialIndicators = ref<SeedQualityIndicator[]>([]);
|
const initialReason = ref('');
|
const qualifiedWarehouses = ref<MesWmWarehouseApi.Warehouse[]>([]);
|
const qualifiedWarehouseId = ref<number>();
|
const retestScope = ref<SeedRetestScope>();
|
const scopeStatus = ref<10 | 50>(10);
|
const scopeBusy = ref(false);
|
const scopeIds = computed(() => scopeStatus.value === 10 ? retestScope.value?.retestIds || [] : retestScope.value?.initialRecheckIds || []);
|
const scopePendingCount = computed(() => (retestScope.value?.pendingProcessCount || 0) + (retestScope.value?.pendingCompanyCount || 0));
|
const initialIds = computed(() => initialScope.value?.pendingBagIds || []);
|
const initialSubmitIds = computed(() => initialIds.value.slice(0, 500));
|
const scopeSubmitIds = computed(() => scopeIds.value.slice(0, 500));
|
const initialPassed = computed(() => initialIndicators.value.length === 4 && initialIndicators.value.every((item) =>
|
item.value !== undefined && item.minValue !== undefined && item.maxValue !== undefined
|
&& Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue)));
|
const qualifiedWarehouseOptions = computed(() => qualifiedWarehouses.value.map((warehouse) => ({
|
label: warehouse.code ? `${warehouse.name}(${warehouse.code})` : warehouse.name || '',
|
value: warehouse.id ?? 0,
|
})).filter((item) => item.value));
|
const scopeReviewGroups = computed(() => {
|
const scope = retestScope.value;
|
if (!scope) return [];
|
return [
|
{ status: 20, stage: 'process' as const, ids: scope.pendingProcessIds || [] },
|
{ status: 25, stage: 'process' as const, ids: scope.pendingInitialFailIds || [] },
|
{ status: 30, stage: 'company' as const, ids: scope.pendingCompanyIds || [] },
|
{ status: 60, stage: 'company' as const, ids: scope.pendingCompanyFailIds || [] },
|
].filter((group) => group.ids.length && hasAccessByCodes([`mes:qc-seed-quality:${group.stage === 'process' ? 'process-approve' : 'company-approve'}`]));
|
});
|
const scopeHasReviewPermission = computed(() => {
|
const scope = retestScope.value;
|
if (!scope) return false;
|
return (scope.pendingProcessCount > 0 && hasAccessByCodes(['mes:qc-seed-quality:process-approve']))
|
|| (scope.pendingCompanyCount > 0 && hasAccessByCodes(['mes:qc-seed-quality:company-approve']));
|
});
|
const reviewOpen = ref(false);
|
const reviewBusy = ref(false);
|
const reviewFromScope = ref(false);
|
const reviewStage = ref<'process' | 'company'>('process');
|
const reviewApproved = ref(true);
|
const reviewOpinion = ref('');
|
const reviewIds = ref<number[]>([]);
|
const selectedIds = ref<number[]>([]);
|
const selectedRecordMap = ref(new Map<number, SeedQualityRecord>());
|
const selectedRows = computed(() => selectedIds.value.map((id) => selectedRecordMap.value.get(id)).filter((row): row is SeedQualityRecord => !!row));
|
const reviewGroups = computed(() => [20, 25, 30, 60].map((status) => ({
|
status,
|
rows: selectedRows.value.filter((row) => row.workflowStatus === status),
|
stage: status === 20 || status === 25 ? 'process' as const : 'company' as const,
|
})).filter((group) => group.rows.length && hasAccessByCodes([`mes:qc-seed-quality:${group.stage === 'process' ? 'process-approve' : 'company-approve'}`])));
|
const retestGroups = computed(() => {
|
if (!hasAccessByCodes(['mes:qc-seed-quality:retest'])) return [];
|
const groups = new Map<string, { status: 10 | 50; batchCode: string; rows: SeedQualityRecord[] }>();
|
for (const row of selectedRows.value) {
|
if (row.workflowStatus !== 10 && row.workflowStatus !== 50) continue;
|
if (!row.batchCode) continue;
|
const key = `${row.workflowStatus}:${row.batchCode}`;
|
if (!groups.has(key)) groups.set(key, { status: row.workflowStatus, batchCode: row.batchCode, rows: [] });
|
groups.get(key)!.rows.push(row);
|
}
|
return [...groups.values()];
|
});
|
function changeSelection(keys: (string | number)[]) {
|
const nextIds = keys.map(Number);
|
const nextMap = new Map(selectedRecordMap.value);
|
for (const row of records.value) if (nextIds.includes(row.id)) nextMap.set(row.id, row);
|
for (const id of nextMap.keys()) if (!nextIds.includes(id)) nextMap.delete(id);
|
selectedRecordMap.value = nextMap;
|
selectedIds.value = nextIds;
|
}
|
function clearSelection() {
|
selectedIds.value = [];
|
selectedRecordMap.value = new Map();
|
}
|
function removeSelection(ids: number[]) {
|
const processed = new Set(ids);
|
selectedIds.value = selectedIds.value.filter((id) => !processed.has(id));
|
selectedRecordMap.value = new Map([...selectedRecordMap.value].filter(([id]) => !processed.has(id)));
|
}
|
|
async function openRetest(record: SeedQualityRecord) {
|
retestBatchIds.value = [];
|
activeRecord.value = record;
|
try {
|
const previous = await getSeedQualityIndicators(record.id);
|
const ranges = await getSeedIndicatorRanges().catch(() => []);
|
retestIndicators.value = previous.map((item) => {
|
const range = ranges.find((row) => row.indicatorCode === item.indicatorCode);
|
return { ...item, minValue: range ? Number(range.minValue) : Number(item.minValue), maxValue: range ? Number(range.maxValue) : Number(item.maxValue) };
|
});
|
if (retestIndicators.value.length !== 4) throw new Error('质检指标不完整');
|
retestIndicators.value = retestIndicators.value.map((item) => ({ ...item, value: undefined }));
|
retestReason.value = '';
|
retestOpen.value = true;
|
} catch (error) {
|
message.error((error as Error).message || '读取质检指标失败');
|
}
|
}
|
async function openBatchRetest(group: { rows: SeedQualityRecord[] }) {
|
if (group.rows.length > 500) { message.warning('每次最多处理 500 条,请缩小选择范围'); return; }
|
const first = group.rows[0];
|
if (!first) return;
|
await openRetest(first);
|
if (retestOpen.value) retestBatchIds.value = group.rows.map((row) => row.id);
|
}
|
async function openScopeRetest() {
|
scopeOpen.value = true; retestScope.value = undefined; initialScope.value = undefined;
|
scopeCode.value = ''; scopeError.value = ''; retestReason.value = ''; initialReason.value = ''; qualifiedWarehouseId.value = undefined;
|
try {
|
qualifiedWarehouses.value = (await getWarehouseSimpleList()).filter((warehouse) =>
|
warehouse.type === MesWmWarehouseTypeEnum.QUALIFIED && !warehouse.frozen);
|
if (qualifiedWarehouses.value.length === 1) qualifiedWarehouseId.value = qualifiedWarehouses.value[0]?.id;
|
} catch { qualifiedWarehouses.value = []; }
|
}
|
function scopeErrorText(error: unknown) {
|
const response = error as { msg?: string; message?: string; data?: { msg?: string }; response?: { data?: { msg?: string } } };
|
return response.response?.data?.msg || response.data?.msg || response.msg || response.message || '识别失败,请核对码值后重试';
|
}
|
async function hydrateScopeReviewIds(scope: SeedRetestScope, scannedCode: string) {
|
const loadedCount = (scope.pendingProcessIds?.length || 0) + (scope.pendingInitialFailIds?.length || 0)
|
+ (scope.pendingCompanyIds?.length || 0) + (scope.pendingCompanyFailIds?.length || 0);
|
if (loadedCount >= scope.pendingProcessCount + scope.pendingCompanyCount) return;
|
|
const resolvedBatchCode = scope.batchCode || (scope.scanType === 'BATCH' ? scope.code : undefined);
|
if (!resolvedBatchCode) return;
|
const statuses = [20, 25, 30, 60] as const;
|
const pages = await Promise.all(statuses.map((status) => getSeedQualityPage({
|
pageNo: 1,
|
pageSize: 500,
|
batchCode: resolvedBatchCode,
|
workflowStatus: status,
|
}).catch(() => ({ list: [] as SeedQualityRecord[], total: 0 }))));
|
const scopeCodes = new Set([scope.code, scannedCode]);
|
const idsByStatus = pages.map(({ list }) => list
|
.filter((row) => scope.scanType === 'BATCH' || (!!row.palletCode && scopeCodes.has(row.palletCode)))
|
.map((row) => row.id));
|
scope.pendingProcessIds = idsByStatus[0];
|
scope.pendingInitialFailIds = idsByStatus[1];
|
scope.pendingCompanyIds = idsByStatus[2];
|
scope.pendingCompanyFailIds = idsByStatus[3];
|
}
|
async function identifyScope() {
|
scopeError.value = '';
|
if (!scopeCode.value.trim()) { scopeError.value = `请扫描或输入${scopeType.value === 'PALLET' ? '托盘码' : '生产批次码'}`; return; }
|
scopeBusy.value = true; retestScope.value = undefined; initialScope.value = undefined;
|
try {
|
const scannedCode = scopeCode.value.trim();
|
let data: SeedRetestScope;
|
try {
|
data = await resolveSeedRetestScope(scannedCode, scopeType.value);
|
} catch (error) {
|
if (!scopeErrorText(error).startsWith('未找到')) throw error;
|
const otherType = scopeType.value === 'PALLET' ? 'BATCH' : 'PALLET';
|
try {
|
data = await resolveSeedRetestScope(scannedCode, otherType);
|
scopeType.value = otherType;
|
} catch (otherError) {
|
if (scopeErrorText(otherError).startsWith('未找到')) {
|
throw new Error('未找到托盘码或生产批次码,请核对完整码值');
|
}
|
throw otherError;
|
}
|
}
|
await hydrateScopeReviewIds(data, scannedCode);
|
retestScope.value = data; scopeStatus.value = data.retestIds.length ? 10 : 50;
|
initialScope.value = await resolveSeedQuality(scannedCode, data.scanType);
|
const ranges = await getSeedIndicatorRanges().catch(() => []);
|
const emptyIndicators = () => Object.entries(indicatorLabels).map(([indicatorCode]) => {
|
const range = ranges.find((row) => row.indicatorCode === indicatorCode);
|
return { indicatorCode, value: undefined, minValue: range ? Number(range.minValue) : undefined as unknown as number,
|
maxValue: range ? Number(range.maxValue) : undefined as unknown as number };
|
});
|
retestIndicators.value = emptyIndicators();
|
initialIndicators.value = emptyIndicators();
|
retestReason.value = '';
|
initialReason.value = '';
|
} catch (error) { scopeError.value = scopeErrorText(error); }
|
finally { scopeBusy.value = false; }
|
}
|
async function saveScopeInitial() {
|
const target = initialScope.value;
|
if (!target || !target.inbound || !initialSubmitIds.value.length) return;
|
if (!qualifiedWarehouseId.value) { scopeError.value = '请选择检验合格后入库的合格库'; return; }
|
if (initialIndicators.value.some((item) => item.value === undefined || item.minValue === undefined || item.maxValue === undefined
|
|| Number(item.value) < 0 || Number(item.value) > 100 || Number(item.minValue) < 0 || Number(item.maxValue) > 100
|
|| Number(item.minValue) > Number(item.maxValue))) {
|
scopeError.value = '请填写四项指标的实测值和 0–100 范围内的合格上下限'; return;
|
}
|
if (!initialPassed.value && !initialReason.value.trim()) { scopeError.value = '初检不合格时请填写原因'; return; }
|
scopeError.value = '';
|
const confirmed = await new Promise<boolean>((resolve) => Modal.confirm({
|
title: `确认批量初检 ${initialSubmitIds.value.length} 袋?`,
|
content: `同一组指标与${initialPassed.value ? '合格' : '不合格'}结论将应用于本次袋码。完成后会自动加载剩余记录。`,
|
okText: '确认提交初检', cancelText: '返回核对',
|
onOk: () => resolve(true), onCancel: () => resolve(false),
|
}));
|
if (!confirmed) return;
|
scopeBusy.value = true;
|
try {
|
const submitted = await submitSeedQualityGroup({
|
code: target.code, batchCode: target.batchCode, scanType: target.scanType || scopeType.value,
|
bagIds: initialSubmitIds.value, qualifiedWarehouseId: qualifiedWarehouseId.value,
|
reason: initialReason.value.trim() || undefined,
|
indicators: initialIndicators.value.map((item) => ({ code: item.indicatorCode, value: Number(item.value),
|
minValue: Number(item.minValue), maxValue: Number(item.maxValue) })),
|
});
|
message.success(`已完成 ${submitted.processedCount} 袋初检`);
|
refreshAll();
|
await identifyScope();
|
} catch (error) { scopeError.value = `${scopeErrorText(error)}。请重新识别并核对待检清单`; initialScope.value = undefined; }
|
finally { scopeBusy.value = false; }
|
}
|
async function saveScopeRetest() {
|
if (!retestScope.value || !scopeSubmitIds.value.length) return;
|
if (retestIndicators.value.some(item => item.value === undefined || item.value === null || item.minValue === undefined || item.maxValue === undefined
|
|| Number(item.minValue) < 0 || Number(item.maxValue) > 100 || Number(item.minValue) > Number(item.maxValue))) {
|
message.warning('请填写四项指标的实测值与本次合格范围'); return;
|
}
|
const passed = retestIndicators.value.every(item => Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue));
|
if (!passed && !retestReason.value.trim()) { message.warning('不合格请填写原因'); return; }
|
scopeBusy.value = true;
|
try {
|
const result = await submitSeedRetestScope({ code: retestScope.value.code, scanType: retestScope.value.scanType, workflowStatus: scopeStatus.value,
|
qualityIds: scopeSubmitIds.value, reason: retestReason.value.trim(), indicators: retestIndicators.value.map(item => ({
|
code: item.indicatorCode, value: Number(item.value), minValue: Number(item.minValue), maxValue: Number(item.maxValue),
|
})) });
|
message.success(`已完成 ${result.processedCount} 袋质检`);
|
refreshAll();
|
await identifyScope();
|
} catch (error) { scopeError.value = `${scopeErrorText(error)}。请重新识别并核对待检清单`; retestScope.value = undefined; }
|
finally { scopeBusy.value = false; }
|
}
|
async function saveRetest() {
|
const record = activeRecord.value;
|
if (!record) return;
|
if (retestIndicators.value.some((item) => item.value === null || item.value === undefined
|
|| item.minValue === null || item.minValue === undefined || item.maxValue === null || item.maxValue === undefined
|
|| Number(item.minValue) > Number(item.maxValue))) {
|
message.warning('请填写完整的实测值');
|
return;
|
}
|
const passed = retestIndicators.value.every((item) =>
|
Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue));
|
if (!passed && !retestReason.value.trim()) {
|
message.warning('检验不合格请填写原因');
|
return;
|
}
|
retestBusy.value = true;
|
try {
|
const payload = {
|
reason: retestReason.value.trim(),
|
indicators: retestIndicators.value.map((item) => ({
|
code: item.indicatorCode, value: Number(item.value),
|
minValue: Number(item.minValue), maxValue: Number(item.maxValue),
|
})),
|
};
|
if (retestBatchIds.value.length) {
|
await retestSeedQualityBatch({ ids: retestBatchIds.value, ...payload });
|
} else {
|
await retestSeedQuality(record.id, { ...payload, code: record.code });
|
}
|
message.success(`${retestBatchIds.value.length || 1} 袋已提交${record.workflowStatus === 50 ? '重新初检' : '复检'}:${record.workflowStatus === 50
|
? passed ? '重新初检合格,待初检审核' : '重新初检不合格,待不合格处理'
|
: passed ? '复检合格,待复检审核' : '复检不合格,待不合格处理'}`);
|
retestOpen.value = false;
|
removeSelection(retestBatchIds.value.length ? retestBatchIds.value : [record.id]);
|
refreshAll();
|
} catch (error) {
|
message.error((error as Error).message || '复检提交失败');
|
} finally {
|
retestBusy.value = false;
|
}
|
}
|
function openReview(record: SeedQualityRecord, stage: 'process' | 'company') {
|
reviewFromScope.value = false;
|
activeRecord.value = record;
|
reviewIds.value = [record.id];
|
reviewStage.value = stage;
|
reviewApproved.value = true;
|
reviewOpinion.value = '';
|
reviewOpen.value = true;
|
}
|
function openBatchReview(group: { stage: 'process' | 'company'; rows: SeedQualityRecord[] }) {
|
if (group.rows.length > 500) { message.warning('每次最多处理 500 条,请缩小选择范围'); return; }
|
reviewFromScope.value = false;
|
activeRecord.value = group.rows[0];
|
reviewIds.value = group.rows.map((row) => row.id);
|
reviewStage.value = group.stage;
|
reviewApproved.value = true;
|
reviewOpinion.value = '';
|
reviewOpen.value = true;
|
}
|
function openScopeReview(group: { status: number; stage: 'process' | 'company'; ids: number[] }) {
|
const scope = retestScope.value;
|
if (!scope || !group.ids.length) return;
|
reviewFromScope.value = true;
|
activeRecord.value = { id: group.ids[0]!, code: scope.code, result: 0, workflowStatus: group.status };
|
reviewIds.value = group.ids.slice(0, 500);
|
reviewStage.value = group.stage;
|
reviewApproved.value = true;
|
reviewOpinion.value = '';
|
reviewOpen.value = true;
|
}
|
async function saveReview() {
|
if (!reviewIds.value.length) return;
|
if (!reviewApproved.value && !reviewOpinion.value.trim()) {
|
message.warning('驳回时请填写审批意见');
|
return;
|
}
|
reviewBusy.value = true;
|
try {
|
if (reviewIds.value.length === 1) {
|
await approveSeedQuality(reviewStage.value, {
|
id: reviewIds.value[0]!, approved: reviewApproved.value, opinion: reviewOpinion.value.trim(),
|
});
|
} else {
|
await approveSeedQualityBatch(reviewStage.value, {
|
ids: reviewIds.value, approved: reviewApproved.value, opinion: reviewOpinion.value.trim(),
|
});
|
}
|
message.success(`已完成 ${reviewIds.value.length} 条审批`);
|
reviewOpen.value = false;
|
removeSelection(reviewIds.value);
|
refreshAll();
|
if (reviewFromScope.value) await identifyScope();
|
reviewFromScope.value = false;
|
} catch (error) {
|
message.error(scopeErrorText(error));
|
} finally {
|
reviewBusy.value = false;
|
}
|
}
|
|
const resultLabel = (value: number) =>
|
value === 1 ? '合格' : value === 2 ? '不合格' : '处理中';
|
const resultColor = (value: number) =>
|
value === 1 ? '#16a34a' : value === 2 ? '#dc2626' : '#d97706';
|
const resultBg = (value: number) =>
|
value === 1 ? '#ecfdf3' : value === 2 ? '#fef2f2' : '#fffbeb';
|
const formatDateTime = (value?: string) =>
|
value ? new Date(value).toLocaleString() : '—';
|
|
async function loadRecords() {
|
loading.value = true;
|
try {
|
const data = await getSeedQualityPage({
|
pageNo: page.value,
|
pageSize: pageSize.value,
|
code: code.value.trim() || undefined,
|
batchCode: batchCode.value.trim() || undefined,
|
result: result.value,
|
workflowStatus: workflowStatus.value,
|
});
|
records.value = data.list;
|
total.value = data.total;
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function loadStats() {
|
try {
|
const [all, pass, fail] = await Promise.all([
|
getSeedQualityPage({ pageNo: 1, pageSize: 1 }),
|
getSeedQualityPage({ pageNo: 1, pageSize: 1, workflowStatus: 40 }),
|
getSeedQualityPage({ pageNo: 1, pageSize: 1, workflowStatus: 70 }),
|
]);
|
statsTotal.value = all.total;
|
passTotal.value = pass.total;
|
failTotal.value = fail.total;
|
} catch {
|
// 统计加载失败不阻塞列表
|
}
|
}
|
|
function searchRecords() {
|
page.value = 1;
|
clearSelection();
|
void loadRecords();
|
}
|
|
function resetFilters() {
|
code.value = '';
|
batchCode.value = '';
|
result.value = undefined;
|
workflowStatus.value = undefined;
|
searchRecords();
|
}
|
|
function refreshAll() {
|
void Promise.all([loadRecords(), loadStats()]);
|
}
|
function refreshAndClearSelection() {
|
clearSelection();
|
refreshAll();
|
}
|
|
function handleTableChange(pagination: TablePaginationConfig) {
|
page.value = pagination.current || 1;
|
pageSize.value = pagination.pageSize || 10;
|
void loadRecords();
|
}
|
|
onMounted(refreshAll);
|
</script>
|
|
<template>
|
<div class="record-page">
|
<!-- 页头 -->
|
<header class="page-header">
|
<div class="header-copy">
|
<div class="eyebrow">MES / 质量管理</div>
|
<h1>质检记录</h1>
|
<p>初检审核通过后进行复检,复检审核通过后才放行入库或处置不合格品</p>
|
</div>
|
<div class="header-badge">
|
<span class="badge-pulse" />
|
实时汇总
|
</div>
|
</header>
|
|
<!-- 结果统计 -->
|
<section class="stat-grid" aria-label="质检结果汇总">
|
<div class="stat-card total">
|
<div class="stat-icon"><IconifyIcon icon="ep:document" /></div>
|
<div class="stat-main"><span>记录总数</span><strong>{{ statsTotal.toLocaleString() }}</strong><small>按袋码计</small></div>
|
</div>
|
<div class="stat-card pass">
|
<div class="stat-icon"><IconifyIcon icon="ep:success-filled" /></div>
|
<div class="stat-main"><span>已放行</span><strong>{{ passTotal.toLocaleString() }}</strong><small>已转入合格库</small></div>
|
</div>
|
<div class="stat-card fail">
|
<div class="stat-icon"><IconifyIcon icon="ep:warning-filled" /></div>
|
<div class="stat-main"><span>已报废</span><strong>{{ failTotal.toLocaleString() }}</strong><small>审批完成后处置</small></div>
|
</div>
|
<div class="stat-card pending">
|
<div class="stat-icon"><IconifyIcon icon="ep:clock" /></div>
|
<div class="stat-main"><span>流程中</span><strong>{{ processingTotal.toLocaleString() }}</strong><small>复检或审批中</small></div>
|
</div>
|
</section>
|
|
<!-- 移动端将批量入口前置,避免隐藏在较长的筛选区中 -->
|
<section v-if="canScopeAction" class="mobile-batch-entry" aria-label="批量质检操作">
|
<div class="mobile-batch-copy">
|
<span class="mobile-batch-icon"><IconifyIcon icon="ep:full-screen" /></span>
|
<div>
|
<strong>批量检验与审核</strong>
|
<p>扫描或输入托盘码、生产批次码,一次处理当前范围内的质检记录。</p>
|
</div>
|
</div>
|
<a-button type="primary" size="large" block @click="openScopeRetest">
|
打开批量操作
|
</a-button>
|
</section>
|
|
<!-- 记录列表 -->
|
<section class="record-card">
|
<div class="card-toolbar">
|
<div class="card-title">
|
<IconifyIcon icon="ep:list" />
|
明细列表
|
<span class="count-chip">{{ total.toLocaleString() }} 条</span>
|
</div>
|
<div class="filters">
|
<a-button v-if="canScopeAction" class="desktop-scope-action" type="primary" @click="openScopeRetest">按托盘 / 批次批量处理</a-button>
|
<a-input v-model:value="code" allow-clear placeholder="袋码" @press-enter="searchRecords">
|
<template #prefix><IconifyIcon icon="ep:box" /></template>
|
</a-input>
|
<a-input v-model:value="batchCode" allow-clear placeholder="生产批次" @press-enter="searchRecords">
|
<template #prefix><IconifyIcon icon="ep:collection-tag" /></template>
|
</a-input>
|
<a-select
|
v-model:value="result"
|
allow-clear
|
placeholder="检验结果"
|
:options="[
|
{ label: '合格', value: 1 },
|
{ label: '不合格', value: 2 },
|
{ label: '处理中', value: 0 },
|
]"
|
/>
|
<a-select
|
v-model:value="workflowStatus"
|
allow-clear
|
placeholder="流程状态"
|
:options="Object.entries(workflowLabels).map(([value, label]) => ({ label, value: Number(value) }))"
|
/>
|
<a-button type="primary" @click="searchRecords"><IconifyIcon icon="ep:search" />查询</a-button>
|
<a-button @click="resetFilters"><IconifyIcon icon="ep:refresh-left" />重置</a-button>
|
<a-tooltip title="刷新数据">
|
<a-button :loading="loading" aria-label="刷新数据" @click="refreshAndClearSelection"><IconifyIcon icon="ep:refresh" /></a-button>
|
</a-tooltip>
|
</div>
|
</div>
|
|
<div v-if="selectedIds.length" class="selection-bar" aria-live="polite">
|
<div class="selection-summary">
|
<strong>已选 {{ selectedIds.length }} 条</strong>
|
<span>按流程状态和生产批次列出可执行操作;其他记录继续保留选择。</span>
|
<a-button type="link" @click="clearSelection">清空选择</a-button>
|
</div>
|
<div class="selection-actions">
|
<a-button
|
v-for="group in reviewGroups" :key="`review-${group.status}`"
|
@click="openBatchReview(group)"
|
>{{ workflowLabels[group.status] }} · 处理 {{ group.rows.length }} 条</a-button>
|
<a-button
|
v-for="group in retestGroups"
|
:key="`retest-${group.status}-${group.batchCode}`"
|
@click="openBatchRetest(group)"
|
>{{ group.status === 50 ? '重新初检' : '复检' }} · {{ group.batchCode }} · {{ group.rows.length }} 袋</a-button>
|
<span v-if="!reviewGroups.length && !retestGroups.length" class="muted">所选记录当前没有可批量处理的操作,请查看流程状态或单条操作。</span>
|
</div>
|
</div>
|
|
<a-table
|
:data-source="records"
|
:row-selection="{ selectedRowKeys: selectedIds, preserveSelectedRowKeys: true, onChange: changeSelection }"
|
:loading="loading"
|
:scroll="{ x: 1900 }"
|
:pagination="{
|
current: page,
|
pageSize,
|
total,
|
showSizeChanger: true,
|
showTotal: (t: number) => `共 ${t} 条`,
|
}"
|
row-key="id"
|
size="middle"
|
@change="handleTableChange"
|
>
|
<template #emptyText>
|
<div class="empty-state">
|
<IconifyIcon icon="ep:data-board" />
|
<p>暂无质检记录</p>
|
</div>
|
</template>
|
<a-table-column key="id" title="质检编号" data-index="id" width="100" align="right">
|
<template #default="{ record }"><span class="mono muted">{{ record.id }}</span></template>
|
</a-table-column>
|
<a-table-column key="code" title="袋码" data-index="code" min-width="220">
|
<template #default="{ record }"><span class="mono strong">{{ record.code }}</span></template>
|
</a-table-column>
|
<a-table-column key="palletCode" title="托盘码" data-index="palletCode" min-width="190">
|
<template #default="{ record }"><span class="mono">{{ record.palletCode || '—' }}</span></template>
|
</a-table-column>
|
<a-table-column key="batchCode" title="生产批次" data-index="batchCode" min-width="190">
|
<template #default="{ record }">{{ record.batchCode || '—' }}</template>
|
</a-table-column>
|
<a-table-column key="result" title="检验结果" width="130">
|
<template #default="{ record }">
|
<span class="result-tag" :style="{ color: resultColor(record.result), background: resultBg(record.result) }">
|
<i class="result-dot" :style="{ background: resultColor(record.result) }" />
|
{{ resultLabel(record.result) }}
|
</span>
|
</template>
|
</a-table-column>
|
<a-table-column key="workflowStatus" title="流程状态" width="190">
|
<template #default="{ record }"><a-tag color="blue">{{ workflowLabels[record.workflowStatus] || '未知状态' }}</a-tag></template>
|
</a-table-column>
|
<a-table-column key="reason" title="不合格原因" data-index="reason" min-width="220" ellipsis>
|
<template #default="{ record }">
|
<span v-if="record.reason" class="reason-text">{{ record.reason }}</span>
|
<span v-else class="muted">—</span>
|
</template>
|
</a-table-column>
|
<a-table-column key="inspectedAt" title="检验时间" width="190">
|
<template #default="{ record }"><span class="mono muted">{{ formatDateTime(record.inspectedAt) }}</span></template>
|
</a-table-column>
|
<a-table-column key="createTime" title="提交时间" width="190">
|
<template #default="{ record }"><span class="mono muted">{{ formatDateTime(record.createTime) }}</span></template>
|
</a-table-column>
|
<a-table-column key="actions" title="操作" width="260" fixed="right">
|
<template #default="{ record }">
|
<a-space>
|
<a-button v-if="[10, 50].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:retest'])" type="link" @click="openRetest(record)">{{ record.workflowStatus === 50 ? '重新初检' : '复检' }}</a-button>
|
<a-button v-if="[20, 25].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:process-approve'])" type="link" @click="openReview(record, 'process')">{{ record.workflowStatus === 25 ? '不合格处理' : '初检审核' }}</a-button>
|
<a-button v-if="[30, 60].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:company-approve'])" type="link" @click="openReview(record, 'company')">{{ record.workflowStatus === 60 ? '不合格处理' : '复检审核' }}</a-button>
|
<a-button v-if="hasAccessByCodes(['mes:qc-seed-quality:query'])" type="link" @click="openHistory(record)">流程记录</a-button>
|
</a-space>
|
</template>
|
</a-table-column>
|
</a-table>
|
</section>
|
<a-modal v-model:open="retestOpen" wrap-class-name="seed-quality-modal" :title="activeRecord?.workflowStatus === 50 ? '重新初检' : '种子质检复检'" :confirm-loading="retestBusy" :ok-text="activeRecord?.workflowStatus === 50 ? '提交初检' : '提交复检'" @ok="saveRetest">
|
<p class="muted">{{ retestBatchIds.length ? `本次处理 ${retestBatchIds.length} 袋,同一组指标与结论会应用于全部所选袋码。` : `袋码:${activeRecord?.code}。` }}请录入本次实测值并核对合格范围。</p>
|
<div v-for="item in retestIndicators" :key="item.indicatorCode" class="retest-row">
|
<strong>{{ indicatorLabels[item.indicatorCode] || item.indicatorCode }}</strong>
|
<a-input-number v-model:value="item.value" :min="0" :precision="3" placeholder="本次实测值" />
|
<a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
|
<a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
|
</div>
|
<a-textarea v-model:value="retestReason" :rows="3" :maxlength="500" placeholder="本次检验不合格时填写原因" />
|
</a-modal>
|
<a-modal v-model:open="scopeOpen" wrap-class-name="seed-quality-modal seed-quality-scope-modal" title="按托盘 / 生产批次批量质检与审核" width="min(760px, calc(100vw - 24px))" :footer="null" :body-style="{ maxHeight: '70vh', overflowY: 'auto' }">
|
<a-space class="scope-scan">
|
<a-select v-model:value="scopeType" style="width: 130px" :options="[{ label: '托盘码', value: 'PALLET' }, { label: '生产批次码', value: 'BATCH' }]" @change="retestScope = undefined; initialScope = undefined; scopeError = ''" />
|
<a-input-search v-model:value="scopeCode" :placeholder="scopeType === 'PALLET' ? '扫描或输入完整托盘码' : '扫描或输入完整生产批次码'" enter-button="识别" :loading="scopeBusy" @change="retestScope = undefined; initialScope = undefined; scopeError = ''" @search="identifyScope" />
|
</a-space>
|
<a-alert v-if="scopeError" class="scope-error" type="error" show-icon :message="scopeError" />
|
<template v-if="retestScope">
|
<p>已识别{{ retestScope.scanType === 'PALLET' ? '托盘码' : '生产批次码' }} {{ retestScope.code }} · 有效袋 {{ retestScope.totalCount }} · 生产批次 {{ retestScope.batchCode || '—' }}</p>
|
<section class="scope-section">
|
<h3>批量初检 <a-tag>{{ initialIds.length }} 袋待检</a-tag></h3>
|
<template v-if="initialIds.length">
|
<a-alert v-if="!initialScope?.inbound" type="warning" show-icon message="该范围的有效袋码尚未全部入库,暂不能提交初检" />
|
<template v-else-if="hasAccessByCodes(['mes:qc-seed-quality:submit'])">
|
<p class="muted">四项指标和结论会应用于本次 {{ initialSubmitIds.length }} 袋;超过 500 袋时系统会分批处理并自动加载剩余记录。</p>
|
<a-form-item label="检验合格后入库的合格库" required>
|
<a-select v-model:value="qualifiedWarehouseId" :options="qualifiedWarehouseOptions" placeholder="请选择合格库" style="width: 100%" />
|
</a-form-item>
|
<a-alert v-if="!qualifiedWarehouseOptions.length" type="warning" show-icon message="暂无可用合格库,请联系管理员配置" />
|
<div v-for="item in initialIndicators" :key="`initial-${item.indicatorCode}`" class="retest-row">
|
<strong>{{ indicatorLabels[item.indicatorCode] }}</strong>
|
<a-input-number v-model:value="item.value" :min="0" :max="100" :precision="3" placeholder="实测值" />
|
<a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
|
<a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
|
</div>
|
<a-textarea v-model:value="initialReason" :rows="2" :maxlength="500" :placeholder="initialPassed ? '合格结果无需填写原因' : '初检不合格时填写原因'" />
|
<div class="scope-actions"><a-button type="primary" :loading="scopeBusy" @click="saveScopeInitial">提交本次 {{ initialSubmitIds.length }} 袋初检</a-button></div>
|
</template>
|
<p v-else class="muted">当前账号没有初检提交权限。</p>
|
</template>
|
<p v-else class="muted">当前范围没有待初检袋码。</p>
|
</section>
|
<section v-if="scopePendingCount" class="scope-section">
|
<h3>批量审核 <a-tag color="orange">{{ scopePendingCount }} 条待处理</a-tag></h3>
|
<p class="muted">按处理结果分组审核,完成后会自动更新下方可复检袋数。</p>
|
<div v-if="scopeReviewGroups.length" class="scope-actions">
|
<a-button v-for="group in scopeReviewGroups" :key="`scope-review-${group.status}`" :disabled="scopeBusy" @click="openScopeReview(group)">
|
{{ workflowLabels[group.status] }} · 本次 {{ Math.min(group.ids.length, 500) }} 条<span v-if="group.ids.length > 500"> / 共 {{ group.ids.length }} 条</span>
|
</a-button>
|
</div>
|
<p v-else-if="scopeHasReviewPermission" class="muted">待审核清单暂未加载成功,请重新识别;也可以在下方列表勾选记录后批量审核。</p>
|
<p v-else class="muted">当前账号没有该阶段的审核权限,请由对应审核人处理。</p>
|
</section>
|
<section v-if="retestScope.retestIds.length || retestScope.initialRecheckIds.length" class="scope-section">
|
<h3>批量复检 / 重新初检</h3>
|
<template v-if="hasAccessByCodes(['mes:qc-seed-quality:retest'])">
|
<a-radio-group v-model:value="scopeStatus">
|
<a-radio-button v-if="retestScope.retestIds.length" :value="10">复检 {{ retestScope.retestIds.length }} 袋</a-radio-button>
|
<a-radio-button v-if="retestScope.initialRecheckIds.length" :value="50">重新初检 {{ retestScope.initialRecheckIds.length }} 袋</a-radio-button>
|
</a-radio-group>
|
<p class="muted">同一组指标将应用于本次 {{ scopeSubmitIds.length }} 袋;超过 500 袋时完成后继续处理剩余记录。模板范围可以修改。</p>
|
<div v-for="item in retestIndicators" :key="item.indicatorCode" class="retest-row">
|
<strong>{{ indicatorLabels[item.indicatorCode] }}</strong>
|
<a-input-number v-model:value="item.value" :min="0" :max="100" :precision="3" placeholder="实测值" />
|
<a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
|
<a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
|
</div>
|
<a-textarea v-model:value="retestReason" :rows="2" :maxlength="500" placeholder="不合格时填写原因" />
|
<div class="scope-actions"><a-button type="primary" :loading="scopeBusy" @click="saveScopeRetest">提交本次 {{ scopeSubmitIds.length }} 袋检验</a-button></div>
|
</template>
|
<p v-else class="muted">当前账号没有复检权限。</p>
|
</section>
|
<a-alert v-if="!initialIds.length && !scopePendingCount && !retestScope.retestIds.length && !retestScope.initialRecheckIds.length" type="success" show-icon message="当前范围没有待处理的质检记录" />
|
</template>
|
</a-modal>
|
<a-modal v-model:open="reviewOpen" wrap-class-name="seed-quality-modal" :title="[25, 60].includes(activeRecord?.workflowStatus ?? 0) ? '不合格处理' : reviewStage === 'process' ? '初检审核' : '复检审核'" :confirm-loading="reviewBusy" :ok-text="reviewApproved ? '确认处理' : '确认驳回'" @ok="saveReview">
|
<p>{{ reviewFromScope || reviewIds.length > 1 ? `共 ${reviewIds.length} 条记录` : activeRecord?.code }} · {{ workflowLabels[activeRecord?.workflowStatus ?? 0] }}</p>
|
<a-radio-group v-model:value="reviewApproved" class="review-choice">
|
<a-radio :value="true">{{ reviewStage === 'process' ? activeRecord?.workflowStatus === 25 ? '确认不合格,安排复检' : '通过,进入复检' : activeRecord?.workflowStatus === 30 ? '通过,放行入库' : '确认不合格,报废处置' }}</a-radio>
|
<a-radio :value="false">{{ reviewStage === 'process' ? '驳回,重新初检' : '驳回,重新复检' }}</a-radio>
|
</a-radio-group>
|
<a-alert v-if="reviewIds.length > 1" type="info" show-icon :message="reviewApproved ? reviewStage === 'company' ? activeRecord?.workflowStatus === 60 ? `确认后 ${reviewIds.length} 袋将报废` : `确认后 ${reviewIds.length} 袋将放行入库` : `确认后 ${reviewIds.length} 袋将进入复检` : `驳回后 ${reviewIds.length} 袋将返回检验`" />
|
<a-textarea v-model:value="reviewOpinion" :rows="3" :maxlength="500" :placeholder="reviewApproved ? '审批意见(选填)' : '请填写驳回意见'" />
|
</a-modal>
|
<a-modal v-model:open="historyOpen" wrap-class-name="seed-quality-modal seed-quality-history-modal"
|
:title="`流程记录 · ${activeRecord?.code || ''}`" :footer="null"
|
width="min(560px, calc(100vw - 24px))" :body-style="{ maxHeight: '70vh', overflowY: 'auto' }">
|
<a-timeline v-if="historyActions.length" class="history-timeline">
|
<a-timeline-item v-for="action in historyActions" :key="action.id">
|
<div class="history-head">
|
<a-tag :color="actionColor(action.action)">{{ actionLabels[action.action] || action.action }}</a-tag>
|
<span class="history-meta">{{ formatDateTime(action.createTime) }} · 操作人 {{ action.operatorName || action.operatorId || '—' }}</span>
|
</div>
|
<div v-if="action.opinion" class="history-opinion">{{ action.opinion }}</div>
|
<div v-if="actionIndicators(action).length" class="history-indicators">
|
<div v-for="indicator in actionIndicators(action)" :key="indicator.code" class="history-indicator">
|
<span class="indicator-name">{{ indicatorLabels[indicator.code] || indicator.code }}</span>
|
<strong class="indicator-value">{{ indicator.value }}</strong>
|
<span class="indicator-range">{{ indicator.minValue }}–{{ indicator.maxValue }}</span>
|
</div>
|
</div>
|
</a-timeline-item>
|
</a-timeline>
|
<a-empty v-else description="暂无流程记录" />
|
</a-modal>
|
</div>
|
</template>
|
|
<style scoped>
|
.record-page { min-height: 100%; padding: 20px 24px 32px; background: #f4f7fb; color: #172033; }
|
|
/* ===== 页头 ===== */
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
|
.header-copy .eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
|
.header-copy h1 { margin: 6px 0; font-size: 26px; line-height: 1.2; }
|
.header-copy p { margin: 0; color: #718096; }
|
.header-badge { display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px; border: 1px solid #cfe0fb; border-radius: 999px; color: #1677ff; background: #f0f6ff; font-size: 13px; font-weight: 600; }
|
.badge-pulse { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 0 0 rgba(34, 197, 94, .45); animation: pulse 2s infinite; }
|
@keyframes pulse { 70% { box-shadow: 0 0 0 7px rgba(34, 197, 94, 0); } 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } }
|
|
/* ===== 结果统计 ===== */
|
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
|
.stat-card { display: flex; align-items: center; gap: 13px; padding: 15px 17px; border: 1px solid #e8edf5; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #243b5310; }
|
.stat-icon { flex: 0 0 40px; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 10px; font-size: 21px; }
|
.stat-icon :deep(.iconify) { font-size: 21px; }
|
.stat-main span, .stat-main small { display: block; color: #7b8799; font-size: 12px; }
|
.stat-main small { margin-top: 2px; }
|
.stat-main strong { display: block; margin: 3px 0 0; font-size: 23px; line-height: 1; font-variant-numeric: tabular-nums; }
|
.stat-card.total .stat-icon { color: #1677ff; background: #eaf3ff; }
|
.stat-card.total .stat-main strong { color: #1677ff; }
|
.stat-card.pass .stat-icon { color: #16a34a; background: #ecfdf3; }
|
.stat-card.pass .stat-main strong { color: #16a34a; }
|
.stat-card.fail .stat-icon { color: #dc2626; background: #fef2f2; }
|
.stat-card.fail .stat-main strong { color: #dc2626; }
|
.stat-card.pending .stat-icon { color: #d97706; background: #fffbeb; }
|
.stat-card.pending .stat-main strong { color: #d97706; }
|
|
/* ===== 移动端批量入口 ===== */
|
.mobile-batch-entry { display: none; }
|
|
/* ===== 明细卡片 ===== */
|
.record-card { padding: 6px 16px 16px; border: 1px solid #e8edf5; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #243b5310; }
|
.card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px 0 15px; flex-wrap: wrap; border-bottom: 1px dashed #eef1f6; }
|
.card-title { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
|
.card-title :deep(.iconify) { color: #1677ff; }
|
.count-chip { padding: 1px 9px; border-radius: 999px; background: #f0f6ff; color: #1677ff; font-size: 12px; font-weight: 600; }
|
.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
.filters .ant-input { width: 150px; }
|
.filters .ant-select { width: 128px; }
|
.filters .ant-btn { display: inline-flex; align-items: center; gap: 5px; }
|
.filters :deep(.ant-btn .iconify) { font-size: 14px; }
|
.selection-bar { padding: 12px 0; border-bottom: 1px solid #eef1f6; }
|
.selection-summary, .selection-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
.selection-summary { margin-bottom: 8px; color: #59677b; }
|
.selection-summary strong { color: #172033; }
|
.selection-actions .ant-btn { white-space: normal; height: auto; min-height: 32px; text-align: left; }
|
.retest-row { display: grid; grid-template-columns: 60px repeat(3, 1fr); gap: 8px; align-items: center; margin-bottom: 10px; }
|
.retest-row :deep(.ant-input-number) { width: 100%; }
|
.scope-scan { display: flex; width: 100%; margin-bottom: 16px; }
|
.scope-scan :deep(.ant-space-item:last-child) { flex: 1; }
|
.scope-error { margin-bottom: 14px; }
|
.scope-section { padding: 14px 0; border-top: 1px solid #eef1f6; }
|
.scope-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 15px; font-weight: 700; }
|
.scope-section .muted { margin: 8px 0 12px; }
|
.scope-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
.review-choice { display: flex; gap: 16px; margin: 10px 0 18px; }
|
|
/* ===== 流程记录 ===== */
|
.history-timeline { padding-top: 6px; }
|
.history-timeline :deep(.ant-timeline-item) { padding-bottom: 22px; }
|
.history-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
.history-head .ant-tag { margin: 0; font-weight: 600; }
|
.history-meta { color: #5b6b80; font-size: 12px; line-height: 1.6; }
|
.history-opinion { margin-top: 8px; padding: 8px 12px; border-left: 3px solid #1677ff; border-radius: 4px; background: #f5f9ff; color: #3d4a5c; font-size: 13px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
.history-indicators { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
|
.history-indicator { display: flex; align-items: baseline; gap: 6px; padding: 7px 11px; border: 1px solid #e3e9f2; border-radius: 6px; background: #fff; font-size: 13px; }
|
.indicator-name { color: #5b6b80; }
|
.indicator-value { color: #172033; font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
.indicator-range { margin-left: auto; color: #667085; font-variant-numeric: tabular-nums; }
|
|
/* ===== 单元格 ===== */
|
.strong { font-weight: 600; color: #172033; }
|
.muted { color: #8793a5; }
|
.mono { font-variant-numeric: tabular-nums; }
|
.result-tag { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; line-height: 20px; }
|
.result-dot { width: 6px; height: 6px; border-radius: 50%; }
|
.reason-text { color: #59677b; }
|
.empty-state { padding: 34px 0; text-align: center; color: #b6c0cf; }
|
.empty-state :deep(.iconify) { font-size: 42px; color: #d5dce6; }
|
.empty-state p { margin: 10px 0 0; }
|
|
/* 状态感知行:不合格行轻高亮 */
|
:deep(.ant-table-row) { transition: background-color .18s ease; }
|
:deep(.ant-table-row:hover) { background: #f7faff !important; }
|
|
/* ===== 响应式 ===== */
|
@media (max-width: 1100px) { .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
@media (max-width: 760px) {
|
.record-page { padding: 12px; }
|
.page-header { display: block; }
|
.header-badge { margin-top: 12px; }
|
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.mobile-batch-entry { display: block; margin-bottom: 12px; padding: 14px; border: 1px solid #cfe0fb; border-radius: 12px; background: linear-gradient(135deg, #f5f9ff 0%, #fff 100%); box-shadow: 0 3px 12px #243b5310; }
|
.mobile-batch-copy { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 12px; }
|
.mobile-batch-copy strong { display: block; margin-bottom: 4px; color: #172033; font-size: 16px; }
|
.mobile-batch-copy p { margin: 0; color: #718096; font-size: 13px; line-height: 1.55; }
|
.mobile-batch-icon { display: flex; flex: 0 0 38px; width: 38px; height: 38px; align-items: center; justify-content: center; border-radius: 10px; color: #1677ff; background: #eaf3ff; font-size: 20px; }
|
.desktop-scope-action { display: none !important; }
|
.record-card { padding: 4px 12px 12px; }
|
.card-toolbar { align-items: flex-start; flex-direction: column; }
|
.filters { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.filters > :first-child,
|
.filters .ant-input,
|
.filters .ant-select { width: 100%; min-width: 0; }
|
.filters > :first-child { grid-column: 1 / -1; min-height: 44px; }
|
.filters .ant-input-affix-wrapper,
|
.filters .ant-select-selector,
|
.filters .ant-btn { min-height: 40px; }
|
.selection-summary { align-items: flex-start; }
|
.selection-summary span { flex-basis: 100%; }
|
.selection-actions { display: grid; grid-template-columns: 1fr; }
|
.selection-actions .ant-btn,
|
.scope-actions .ant-btn { width: 100%; min-height: 44px; }
|
.retest-row { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 10px 0; border-bottom: 1px solid #f0f2f5; }
|
.retest-row strong { grid-column: 1 / -1; }
|
.scope-scan { display: flex; flex-direction: column; gap: 8px; }
|
.scope-scan :deep(.ant-space-item) { width: 100%; }
|
.scope-scan :deep(.ant-select) { width: 100% !important; }
|
.scope-scan :deep(.ant-input-search-button) { min-width: 72px; min-height: 40px; }
|
.scope-section { padding: 16px 0; }
|
.scope-section h3 { align-items: flex-start; flex-wrap: wrap; }
|
.scope-actions { display: grid; grid-template-columns: 1fr; }
|
.review-choice { align-items: flex-start; flex-direction: column; gap: 12px; }
|
:deep(.ant-pagination) { justify-content: center; gap: 4px; }
|
:deep(.ant-pagination-options),
|
:deep(.ant-pagination-total-text) { display: none; }
|
}
|
@media (max-width: 480px) {
|
.header-copy h1 { font-size: 23px; }
|
.header-copy p { font-size: 13px; line-height: 1.6; }
|
.stat-grid { grid-template-columns: 1fr; gap: 8px; }
|
.stat-card { padding: 12px 14px; }
|
.filters { grid-template-columns: 1fr; }
|
.filters > :first-child { grid-column: auto; }
|
}
|
@media (max-width: 760px) {
|
:global(.seed-quality-modal) { padding-bottom: 0; }
|
:global(.seed-quality-modal .ant-modal) { top: 12px; width: calc(100vw - 24px) !important; max-width: calc(100vw - 24px); margin: 0 auto; padding-bottom: 12px; }
|
:global(.seed-quality-modal .ant-modal-content) { max-height: calc(100dvh - 24px); padding: 18px 16px; overflow: hidden; }
|
:global(.seed-quality-modal .ant-modal-header) { padding-right: 24px; }
|
:global(.seed-quality-modal .ant-modal-title) { white-space: normal; line-height: 1.4; }
|
:global(.seed-quality-modal .ant-modal-body) { max-height: calc(100dvh - 150px) !important; overflow-y: auto; overscroll-behavior: contain; }
|
:global(.seed-quality-modal .ant-modal-footer) { display: grid; grid-template-columns: 1fr 1fr; }
|
:global(.seed-quality-modal .ant-modal-footer .ant-btn) { min-height: 44px; margin-inline-start: 0 !important; }
|
}
|
</style>
|