<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 { message } from 'ant-design-vue';
|
|
import {
|
getSeedQualityPage,
|
getSeedQualityIndicators,
|
getSeedQualityActions,
|
retestSeedQuality,
|
approveSeedQuality,
|
approveSeedQualityBatch,
|
type SeedQualityRecord,
|
type SeedQualityIndicator,
|
type SeedQualityAction,
|
} 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 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 workflowLabels: Record<number, string> = {
|
10: '待复检', 20: '待加工部审批', 30: '待公司质检审批',
|
40: '已放行', 50: '待加工部不合格评审', 60: '待公司不合格审批', 70: '已报废',
|
};
|
const actionLabels: Record<string, string> = {
|
INITIAL: '初检', RETEST: '复检', PROCESS_APPROVE: '加工部通过',
|
PROCESS_REJECT: '加工部驳回', COMPANY_APPROVE: '公司质检通过',
|
COMPANY_REJECT: '公司质检驳回',
|
};
|
const indicatorLabels: Record<string, string> = {
|
purity: '纯度', cleanliness: '净度', germination_rate: '发芽率', moisture: '水分',
|
};
|
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 retestIndicators = ref<SeedQualityIndicator[]>([]);
|
const retestReason = ref('');
|
const reviewOpen = ref(false);
|
const reviewBusy = ref(false);
|
const reviewStage = ref<'process' | 'company'>('process');
|
const reviewApproved = ref(true);
|
const reviewOpinion = ref('');
|
const reviewIds = ref<number[]>([]);
|
const selectedIds = ref<number[]>([]);
|
const selectedRows = computed(() => records.value.filter((record) => selectedIds.value.includes(record.id)));
|
const selectableStage = computed(() => {
|
if (!selectedRows.value.length) return undefined;
|
if (!selectedRows.value.every((record) => record.workflowStatus === selectedRows.value[0]?.workflowStatus)) return undefined;
|
if (selectedRows.value.every((record) => [20, 50].includes(record.workflowStatus))) return 'process';
|
if (selectedRows.value.every((record) => [30, 60].includes(record.workflowStatus))) return 'company';
|
return undefined;
|
});
|
|
async function openRetest(record: SeedQualityRecord) {
|
activeRecord.value = record;
|
try {
|
retestIndicators.value = await getSeedQualityIndicators(record.id);
|
if (retestIndicators.value.length !== 4) throw new Error('质检指标不完整');
|
retestReason.value = '';
|
retestOpen.value = true;
|
} catch (error) {
|
message.error((error as Error).message || '读取质检指标失败');
|
}
|
}
|
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 {
|
await retestSeedQuality(record.id, {
|
code: record.code,
|
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(passed ? '复检合格,待加工部审批' : '复检不合格,待加工部评审');
|
retestOpen.value = false;
|
refreshAll();
|
} catch (error) {
|
message.error((error as Error).message || '复检提交失败');
|
} finally {
|
retestBusy.value = false;
|
}
|
}
|
function openReview(record: SeedQualityRecord, stage: 'process' | 'company') {
|
activeRecord.value = record;
|
reviewIds.value = [record.id];
|
reviewStage.value = stage;
|
reviewApproved.value = true;
|
reviewOpinion.value = '';
|
reviewOpen.value = true;
|
}
|
function openBatchReview() {
|
const stage = selectableStage.value;
|
if (!stage) return;
|
activeRecord.value = selectedRows.value[0];
|
reviewIds.value = selectedRows.value.map((row) => row.id);
|
reviewStage.value = stage;
|
reviewApproved.value = true;
|
reviewOpinion.value = '';
|
reviewOpen.value = true;
|
}
|
async function saveReview() {
|
if (!reviewIds.value.length) return;
|
if (!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;
|
selectedIds.value = [];
|
refreshAll();
|
} catch (error) {
|
message.error((error as Error).message || '审批失败');
|
} 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,
|
});
|
records.value = data.list;
|
selectedIds.value = [];
|
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;
|
void loadRecords();
|
}
|
|
function resetFilters() {
|
code.value = '';
|
batchCode.value = '';
|
result.value = undefined;
|
searchRecords();
|
}
|
|
function refreshAll() {
|
void Promise.all([loadRecords(), loadStats()]);
|
}
|
|
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 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="selectableStage && hasAccessByCodes([`mes:qc-seed-quality:${selectableStage === 'process' ? 'process-approve' : 'company-approve'}`])" @click="openBatchReview">批量{{ selectableStage === 'process' ? '加工部审批' : '公司质检审批' }}({{ selectedRows.length }})</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-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="refreshAll"><IconifyIcon icon="ep:refresh" /></a-button>
|
</a-tooltip>
|
</div>
|
</div>
|
|
<a-table
|
:data-source="records"
|
:row-selection="{ selectedRowKeys: selectedIds, onChange: (keys: (string | number)[]) => { selectedIds = keys.map(Number); } }"
|
:loading="loading"
|
:scroll="{ x: 1120 }"
|
: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="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="record.workflowStatus === 10 && hasAccessByCodes(['mes:qc-seed-quality:retest'])" type="link" @click="openRetest(record)">复检</a-button>
|
<a-button v-if="[20, 50].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:process-approve'])" type="link" @click="openReview(record, 'process')">加工部审批</a-button>
|
<a-button v-if="[30, 60].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:company-approve'])" type="link" @click="openReview(record, 'company')">公司质检审批</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" title="种子质检复检" :confirm-loading="retestBusy" ok-text="提交复检" @ok="saveRetest">
|
<p class="muted">袋码:{{ 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" :precision="3" placeholder="下限" />
|
<a-input-number v-model:value="item.maxValue" :min="0" :precision="3" placeholder="上限" />
|
</div>
|
<a-textarea v-model:value="retestReason" :rows="3" :maxlength="500" placeholder="复检不合格时填写原因" />
|
</a-modal>
|
<a-modal v-model:open="reviewOpen" :title="reviewStage === 'process' ? '加工部审批' : '公司质检审批'" :confirm-loading="reviewBusy" ok-text="提交审批" @ok="saveReview">
|
<p>{{ 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 === 'company' ? activeRecord?.workflowStatus === 30 ? '放行入库' : '报废处置' : '上报公司质检' }}</a-radio>
|
<a-radio :value="false">驳回重检</a-radio>
|
</a-radio-group>
|
<a-textarea v-model:value="reviewOpinion" :rows="3" :maxlength="500" placeholder="请填写审批意见" />
|
</a-modal>
|
<a-modal v-model:open="historyOpen" :title="`流程记录 · ${activeRecord?.code || ''}`" :footer="null">
|
<a-timeline v-if="historyActions.length">
|
<a-timeline-item v-for="action in historyActions" :key="action.id">
|
<strong>{{ actionLabels[action.action] || action.action }}</strong>
|
<span class="muted"> · {{ formatDateTime(action.createTime) }} · 操作人 {{ action.operatorId || '—' }}</span>
|
<div v-if="action.opinion">{{ action.opinion }}</div>
|
<div v-for="indicator in actionIndicators(action)" :key="indicator.code" class="muted">
|
{{ indicatorLabels[indicator.code] || indicator.code }}:{{ indicator.value }}({{ indicator.minValue }}—{{ indicator.maxValue }})
|
</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; }
|
|
/* ===== 明细卡片 ===== */
|
.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; }
|
.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%; }
|
.review-choice { display: flex; gap: 16px; margin: 10px 0 18px; }
|
|
/* ===== 单元格 ===== */
|
.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: 14px; }
|
.page-header { display: block; }
|
.header-badge { margin-top: 12px; }
|
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.card-toolbar { align-items: flex-start; flex-direction: column; }
|
.filters .ant-input { width: 100%; flex: 1; }
|
}
|
</style>
|