Browse Source

feat(s5): add IQC inspection detail editing UI

在既有「来料检验单详情」抽屉内新增「检验项目明细」区块,接通 Batch 3 的项目级
读写契约(detail.lines / lineSummary / canEdit + S5IqcInspection/save-detail)。
不新增路由、不新增菜单、不改后端。

- 汇总计数条 + 项目表(序号/检验项目/检验标准/检验方法·工具/限值/录入类型/判定/
  来源/结果·测量/结果备注/操作),展开行内含检规快照与录入区
- NUMERIC 走逐样本实测值(新增/删除/全量替换),NON_NUMERIC 走项目级合格/不合格数,
  两者互斥渲染,杜绝双真源入参
- 人工判定仅「自动判定/人工判合格/人工判不合格」,不提供 INVALID;
  判定与样本判定一律展示后端结果,前端不复算
- 录入类型切换双向确认,取消即回退到库中现值
- canEdit 为唯一编辑 authority:false 时全区只读、无保存/新增/删除入口
- itemCount=0(历史单/未命中检规/检规无明细)显示 LEGACY 空态说明
- 保存成功后重新 GET detail;失败由响应拦截器原样透出后端消息,不叠加技术文案
- 快照字段空值统一显示 '-',含脏写入的 'null'/'undefined' 字面量
- id 类字段按 number | string 声明(后端 long 序列化为 JSON 字符串),消除
  prop 类型校验告警与隐式转换风险
YY968XX 1 month ago
parent
commit
a049b539d5

+ 78 - 0
Web/src/views/aidop/s5/api/iqcInspBill.ts

@@ -113,6 +113,84 @@ export interface IqcInspBillDetail {
 	bizId?: number;
 	bizCode?: string;
 	flowHint?: string;
+	// H 项目级检验结果(后端 Batch 3 纯加法;N2/N3/终态同样返回,只读与否由 canEdit 表达)
+	/** 项目实例(检规快照 + 项目级结果 + 逐样本);itemCount=0 时为空数组 */
+	lines?: IqcInspBillItemLine[];
+	/** 项目级结果汇总(后端实时聚合) */
+	lineSummary?: IqcInspBillLineSummary;
+	/** 当前用户此刻是否可编辑项目级结果。**后端唯一 authority**,前端不得按节点自行推断 */
+	canEdit?: boolean;
+}
+
+// ── S5 IQC 项目级检验结果(读契约,随 detail 一起返回)──
+// 派生/审计字段(sampleNumeric / judgement / judgementSource / inspectorId / inspectionTime /
+// lastResultBy / lastResultAt)全部由后端写入,前端只读展示,不得 author。
+export type IqcItemResultType = 'NUMERIC' | 'NON_NUMERIC';
+
+// ⚠️ id 类 字段声明为 number | string:后端 long 超出 JS 安全整数范围,
+// Admin.NET 序列化为 JSON 字符串(实测 detail 返回 "847745575465029")。按 number 声明会触发
+// Vue prop 类型校验告警,且等值比较会踩隐式转换。
+
+/** 逐样本实测值(仅 NUMERIC 使用;NON_NUMERIC 恒为空数组) */
+export interface IqcInspBillItemSample {
+	id?: number | string;
+	sampleIndex?: number;
+	/** 检验员录入原文(后端原样保留) */
+	sampleValueRaw?: string | null;
+	/** 后端解析值;不可解析 = null */
+	sampleNumeric?: number | null;
+	/** PASS / FAIL / INVALID,后端唯一写入方 */
+	judgement?: string | null;
+	remark?: string | null;
+	inspectorId?: number | null;
+	inspectionTime?: string | null;
+}
+
+/** 检验单项目实例(检规快照不可变 + 项目级结果) */
+export interface IqcInspBillItemLine {
+	/** 项目实例主键(save-detail 回传用;后端 long → JSON 字符串) */
+	id: number | string;
+	/** 展示序号(快照自检规,可空可重复,不作键) */
+	seq?: number | null;
+	// 来源追溯(仅 lineage,不展示)
+	sourceSpecId?: number;
+	sourceSpecItemId?: number;
+	// 检规快照(只读)
+	inspectionItem?: string | null;
+	inspectionStandard?: string | null;
+	methodTool?: string | null;
+	imageCategory?: string | null;
+	samplingScheme?: string | null;
+	specRemark?: string | null;
+	upperLimit?: string | null;
+	lowerLimit?: string | null;
+	// 项目级结果
+	/** NUMERIC / NON_NUMERIC;null = 未定(合法初态) */
+	resultType?: IqcItemResultType | null;
+	/** PASS / FAIL / INVALID;null = 未录 */
+	itemJudgement?: string | null;
+	/** AUTO / MANUAL */
+	judgementSource?: string | null;
+	/** NON_NUMERIC 测量真源之一;NUMERIC 恒为 null */
+	qualifiedQty?: number | null;
+	/** NON_NUMERIC 测量真源之一;NUMERIC 恒为 null */
+	unqualifiedQty?: number | null;
+	resultRemark?: string | null;
+	lastResultBy?: number | null;
+	lastResultAt?: string | null;
+	/** NUMERIC 测量真源 */
+	samples?: IqcInspBillItemSample[];
+}
+
+/** 项目级结果汇总(后端实时聚合,不落表);itemCount=0 ⇒ LEGACY MODE */
+export interface IqcInspBillLineSummary {
+	itemCount?: number;
+	/** 已给出 PASS/FAIL 的项目数(可提交口径) */
+	judgedCount?: number;
+	passCount?: number;
+	failCount?: number;
+	invalidCount?: number;
+	unjudgedCount?: number;
 }
 
 export function fetchIqcInspBillDetail(id: number) {

+ 58 - 0
Web/src/views/aidop/s5/api/iqcInspection.ts

@@ -0,0 +1,58 @@
+import service from '/@/utils/request';
+import type { IqcInspBillItemLine, IqcInspBillLineSummary, IqcItemResultType } from './iqcInspBill';
+
+// ── S5 IQC 项目级检验结果录入(写接口)──
+// 后端 api/S5IqcInspection/save-detail:只保存测量事实与备注,**不推进流程**(提交仍走 S5IqcInspBillFlow)。
+//
+// 前端一律不得 author 的字段(传了后端也忽略/拒绝):
+//   tenantId · itemJudgement · judgementSource · sampleNumeric · sample.judgement
+//   · inspectorId · inspectionTime · lastResultBy · lastResultAt
+// 判定由后端依据「快照冻结的上下限 + 实录测量事实」唯一计算,前端不复算。
+
+/** 单个样本入参(仅 NUMERIC)。录入原文必填——空值语义 = 该样本不存在,应从数组中移除 */
+export interface IqcSaveDetailSamplePayload {
+	sampleIndex: number;
+	sampleValueRaw: string;
+	remark?: string | null;
+}
+
+/** 单个项目的结果入参 */
+export interface IqcSaveDetailItemPayload {
+	/** 后端 long → JSON 字符串,原样回传即可 */
+	itemId: number | string;
+	/** 不传 = 沿用库中现值;与库中现值不同即类型切换,后端会清空该项目全部旧测量事实 */
+	resultType?: IqcItemResultType | null;
+	/** NON_NUMERIC 测量真源;NUMERIC 携带 → 400 */
+	qualifiedQty?: number | null;
+	/** NON_NUMERIC 测量真源;NUMERIC 携带 → 400 */
+	unqualifiedQty?: number | null;
+	resultRemark?: string | null;
+	/** 人工判定,仅 PASS / FAIL(不得人工设 INVALID);不传 ⇒ 后端按测量真源 AUTO 重算 */
+	manualJudgement?: 'PASS' | 'FAIL' | null;
+	/** NUMERIC 测量真源,**该项目的完整新集合**(未出现的旧样本会被删除);NON_NUMERIC 携带 → 400 */
+	samples?: IqcSaveDetailSamplePayload[];
+}
+
+/** save-detail 入参。item 级增量:未出现在 items 中的项目完全不动 */
+export interface IqcSaveDetailPayload {
+	billId: number | string;
+	items: IqcSaveDetailItemPayload[];
+}
+
+/** save-detail 出参 */
+export interface IqcSaveDetailResult {
+	ok?: boolean;
+	savedItems?: number;
+	savedSamples?: number;
+	deletedSamples?: number;
+	/** 因类型切换而清空过测量事实的项目 id */
+	clearedByTypeChange?: number[];
+	/** 因测量事实变更而被作废的旧 MANUAL 判定所属项目 id */
+	manualInvalidated?: number[];
+	lineSummary?: IqcInspBillLineSummary;
+	items?: IqcInspBillItemLine[];
+}
+
+export function saveIqcInspDetail(payload: IqcSaveDetailPayload) {
+	return service.post<IqcSaveDetailResult>('/api/S5IqcInspection/save-detail', payload).then((r) => r.data);
+}

+ 23 - 1
Web/src/views/aidop/s5/iqc/components/IqcInspBillDetailDrawer.vue

@@ -1,5 +1,7 @@
 <template>
-	<el-drawer v-model="innerVisible" :size="620" direction="rtl" @closed="onClosed">
+	<!-- size 由 620 放宽至 1200:项目明细表 10 列总宽约 1170px,窄于此会出现横向滚动,
+	     而展开行的录入区会随表体一起横向偏移。不新增路由/页面。 -->
+	<el-drawer v-model="innerVisible" :size="1200" direction="rtl" @closed="onClosed">
 		<template #header>
 			<div class="drawer-title">
 				<span class="t1">来料检验单详情</span>
@@ -69,6 +71,17 @@
 					<el-descriptions-item label="审核时间">{{ fmtDateTime(detail.auditDate) }}</el-descriptions-item>
 				</el-descriptions>
 
+				<!-- 项目级检验结果(后端 lines/lineSummary/canEdit 驱动;canEdit 为唯一编辑 authority)-->
+				<IqcInspBillItemPanel
+					class="mt12"
+					:bill-id="detail.id"
+					:lines="detail.lines"
+					:summary="detail.lineSummary"
+					:can-edit="detail.canEdit === true"
+					:saving-item-id="savingItemId"
+					@save="onSaveItemDetail"
+				/>
+
 				<el-descriptions title="流程信息" :column="2" border size="small" class="mt12" v-loading="flowLoading">
 					<el-descriptions-item label="BizType">{{ dash(detail.bizType) }}</el-descriptions-item>
 					<el-descriptions-item label="BizCode">{{ dash(detail.bizCode) }}</el-descriptions-item>
@@ -163,6 +176,8 @@ import { computed, reactive, watch } from 'vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import type { IqcInspBillDetail } from '../../api/iqcInspBill';
 import type { IqcFlowState, IqcSubmitResultPayload, IqcSqeDispositionPayload } from '../../api/iqcInspBillFlow';
+import type { IqcSaveDetailPayload } from '../../api/iqcInspection';
+import IqcInspBillItemPanel from './IqcInspBillItemPanel.vue';
 
 const props = defineProps<{
 	modelValue: boolean;
@@ -171,6 +186,8 @@ const props = defineProps<{
 	flowState?: IqcFlowState | null;
 	flowLoading?: boolean;
 	submitting?: boolean;
+	/** 正在保存的项目实例 id(父组件持有,用于单项 loading);后端 long → JSON 字符串 */
+	savingItemId?: number | string | null;
 }>();
 const emit = defineEmits<{
 	(e: 'update:modelValue', v: boolean): void;
@@ -179,6 +196,7 @@ const emit = defineEmits<{
 	(e: 'approve'): void;
 	(e: 'reject', comment: string): void;
 	(e: 'sqe-submit', payload: IqcSqeDispositionPayload): void;
+	(e: 'save-item-detail', payload: IqcSaveDetailPayload, itemId: number | string): void;
 }>();
 
 const innerVisible = computed({
@@ -282,6 +300,10 @@ function onSubmitResult() {
 	});
 }
 
+function onSaveItemDetail(payload: IqcSaveDetailPayload, itemId: number | string) {
+	emit('save-item-detail', payload, itemId);
+}
+
 function onApprove() {
 	emit('approve');
 }

+ 500 - 0
Web/src/views/aidop/s5/iqc/components/IqcInspBillItemPanel.vue

@@ -0,0 +1,500 @@
+<template>
+	<div class="item-panel">
+		<div class="panel-head">
+			<span class="panel-title">检验项目明细</span>
+			<el-tag v-if="canEdit" type="success" size="small" effect="plain">可录入</el-tag>
+			<el-tag v-else type="info" size="small" effect="plain">只读(当前节点不允许修改检验明细)</el-tag>
+		</div>
+
+		<!-- LEGACY MODE:历史单 / 未命中检规 / 命中检规但无明细,三者同一语义 -->
+		<el-alert
+			v-if="itemCount === 0"
+			class="mt12"
+			type="info"
+			:closable="false"
+			show-icon
+			title="该检验单没有项目实例"
+			description="历史单、未命中检规、命中检规但检规无明细,均为此状态。此类检验单按整单结论录入(见上方「检验员录入检验结果」),不做项目级录入。"
+		/>
+
+		<template v-else>
+			<div class="summary mt12">
+				<el-tag size="small" type="info" effect="plain">项目数 {{ summaryVal.itemCount }}</el-tag>
+				<el-tag size="small" type="info" effect="plain">已判定 {{ summaryVal.judgedCount }}</el-tag>
+				<el-tag size="small" type="success" effect="plain">合格 {{ summaryVal.passCount }}</el-tag>
+				<el-tag size="small" type="danger" effect="plain">不合格 {{ summaryVal.failCount }}</el-tag>
+				<el-tag size="small" type="warning" effect="plain">无效 {{ summaryVal.invalidCount }}</el-tag>
+				<el-tag size="small" effect="plain">未录 {{ summaryVal.unjudgedCount }}</el-tag>
+			</div>
+
+			<el-table ref="tableRef" :data="lines" row-key="id" border stripe size="small" class="mt12">
+				<el-table-column type="expand">
+					<template #default="{ row }">
+						<div class="expand-body">
+							<el-descriptions :column="2" border size="small" title="检规快照(生成时冻结,不回查检规)">
+								<el-descriptions-item label="检验标准" :span="2">
+									<span class="pre">{{ snap(row.inspectionStandard) }}</span>
+								</el-descriptions-item>
+								<el-descriptions-item label="检验方法·工具" :span="2">
+									<span class="pre">{{ snap(row.methodTool) }}</span>
+								</el-descriptions-item>
+								<el-descriptions-item label="下限">{{ snap(row.lowerLimit) }}</el-descriptions-item>
+								<el-descriptions-item label="上限">{{ snap(row.upperLimit) }}</el-descriptions-item>
+								<el-descriptions-item label="抽样方案">{{ snap(row.samplingScheme) }}</el-descriptions-item>
+								<el-descriptions-item label="图片分类">{{ snap(row.imageCategory) }}</el-descriptions-item>
+								<el-descriptions-item label="检规备注" :span="2">
+									<span class="pre">{{ snap(row.specRemark) }}</span>
+								</el-descriptions-item>
+							</el-descriptions>
+
+							<div v-if="drafts[String(row.id)]" class="editor mt12">
+								<el-form label-width="104px" size="small" @submit.prevent>
+									<el-form-item label="录入类型">
+										<el-select
+											v-model="drafts[String(row.id)].resultType"
+											:disabled="!canEdit"
+											placeholder="请选择"
+											style="width: 150px"
+											@change="(v: any) => onTypeChange(row, v)"
+										>
+											<el-option value="NUMERIC" label="数值型" />
+											<el-option value="NON_NUMERIC" label="非数值型" />
+										</el-select>
+										<el-tag v-if="isTypeSwitching(row)" class="ml8" type="warning" size="small" effect="plain">
+											保存后将清空该项目原有测量事实
+										</el-tag>
+										<span v-else-if="!drafts[String(row.id)].resultType" class="hint ml8">未选择录入类型,无法录入测量结果</span>
+									</el-form-item>
+
+									<!-- NUMERIC:测量真源 = 逐样本实测值 -->
+									<template v-if="drafts[String(row.id)].resultType === 'NUMERIC'">
+										<el-form-item label="样本实测值">
+											<div class="sample-wrap">
+												<el-table :data="drafts[String(row.id)].samples" border size="small" class="sample-table">
+													<el-table-column label="样本" width="64" align="center">
+														<template #default="{ row: s }">{{ s.sampleIndex }}</template>
+													</el-table-column>
+													<el-table-column label="实测值" min-width="130">
+														<template #default="{ row: s }">
+															<el-input v-if="canEdit" v-model="s.sampleValueRaw" size="small" placeholder="录入实测值" />
+															<span v-else>{{ snap(s.sampleValueRaw) }}</span>
+														</template>
+													</el-table-column>
+													<el-table-column label="判定(已保存)" width="110" align="center">
+														<template #default="{ row: s }">
+															<el-tag :type="judgeTagType(savedSampleJudgement(row, s.sampleIndex))" size="small" effect="plain">
+																{{ judgeText(savedSampleJudgement(row, s.sampleIndex)) }}
+															</el-tag>
+														</template>
+													</el-table-column>
+													<el-table-column label="备注" min-width="120">
+														<template #default="{ row: s }">
+															<el-input v-if="canEdit" v-model="s.remark" size="small" />
+															<span v-else>{{ snap(s.remark) }}</span>
+														</template>
+													</el-table-column>
+													<el-table-column v-if="canEdit" label="操作" width="64" align="center">
+														<template #default="{ $index }">
+															<el-button type="danger" link @click="removeSample(row.id, $index)">删除</el-button>
+														</template>
+													</el-table-column>
+													<template #empty>
+														<span class="hint">暂无样本,判定为「未录」</span>
+													</template>
+												</el-table>
+												<div v-if="canEdit" class="mt8">
+													<el-button size="small" @click="addSample(row.id)">新增样本</el-button>
+													<span class="hint ml8">样本值为该项目的完整集合;判定由后端按快照上下限重算,保存后刷新</span>
+												</div>
+											</div>
+										</el-form-item>
+									</template>
+
+									<!-- NON_NUMERIC:测量真源 = 项目级合格/不合格数 -->
+									<template v-else-if="drafts[String(row.id)].resultType === 'NON_NUMERIC'">
+										<el-form-item label="合格数">
+											<el-input-number
+												v-model="drafts[String(row.id)].qualifiedQty"
+												:min="0"
+												:controls="false"
+												:value-on-clear="null"
+												:disabled="!canEdit"
+												style="width: 150px"
+											/>
+										</el-form-item>
+										<el-form-item label="不合格数">
+											<el-input-number
+												v-model="drafts[String(row.id)].unqualifiedQty"
+												:min="0"
+												:controls="false"
+												:value-on-clear="null"
+												:disabled="!canEdit"
+												style="width: 150px"
+											/>
+											<span class="hint ml8">合格数与不合格数均需录入才会形成判定</span>
+										</el-form-item>
+									</template>
+
+									<el-form-item label="人工判定">
+										<el-select v-model="drafts[String(row.id)].manualJudgement" :disabled="!canEdit" style="width: 150px">
+											<el-option value="AUTO" label="自动判定" />
+											<el-option value="PASS" label="人工判合格" />
+											<el-option value="FAIL" label="人工判不合格" />
+										</el-select>
+										<span class="hint ml8">人工判定仅合格/不合格;「无效」为系统派生结论,不可人工设置</span>
+									</el-form-item>
+
+									<el-form-item label="结果备注">
+										<el-input
+											v-model="drafts[String(row.id)].resultRemark"
+											type="textarea"
+											:rows="2"
+											:disabled="!canEdit"
+											style="width: 360px"
+										/>
+									</el-form-item>
+
+									<el-form-item label="最近录入">
+										<span class="hint">
+											{{ dash(row.lastResultBy) }} · {{ fmtDateTime(row.lastResultAt) }}
+										</span>
+									</el-form-item>
+
+									<el-form-item v-if="canEdit">
+										<el-button type="primary" :loading="isSaving(row)" @click="onSaveItem(row)">保存本项目</el-button>
+									</el-form-item>
+								</el-form>
+							</div>
+						</div>
+					</template>
+				</el-table-column>
+
+				<el-table-column label="序号" width="62" align="center">
+					<template #default="{ row }">{{ dash(row.seq) }}</template>
+				</el-table-column>
+				<el-table-column label="检验项目" min-width="120" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.inspectionItem) }}</template>
+				</el-table-column>
+				<el-table-column label="检验标准" min-width="120" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.inspectionStandard) }}</template>
+				</el-table-column>
+				<el-table-column label="检验方法·工具" min-width="130" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.methodTool) }}</template>
+				</el-table-column>
+				<el-table-column label="限值" width="120" align="center">
+					<template #default="{ row }">{{ limitText(row) }}</template>
+				</el-table-column>
+				<el-table-column label="录入类型" width="86" align="center">
+					<template #default="{ row }">{{ resultTypeText(row.resultType) }}</template>
+				</el-table-column>
+				<el-table-column label="判定" width="104" align="center">
+					<template #default="{ row }">
+						<el-tag :type="judgeTagType(row.itemJudgement)" size="small" effect="plain">{{ judgeText(row.itemJudgement) }}</el-tag>
+					</template>
+				</el-table-column>
+				<el-table-column label="来源" width="62" align="center">
+					<template #default="{ row }">{{ sourceText(row.judgementSource) }}</template>
+				</el-table-column>
+				<el-table-column label="结果·测量" min-width="130">
+					<template #default="{ row }">{{ measureText(row) }}</template>
+				</el-table-column>
+				<el-table-column label="结果备注" min-width="104" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.resultRemark) }}</template>
+				</el-table-column>
+				<el-table-column label="操作" width="78" align="center" fixed="right">
+					<template #default="{ row }">
+						<el-button type="primary" link @click="toggleExpand(row)">{{ canEdit ? '录入' : '查看' }}</el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+		</template>
+	</div>
+</template>
+
+<script setup lang="ts" name="IqcInspBillItemPanel">
+import { computed, ref, watch } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import type { IqcInspBillItemLine, IqcInspBillLineSummary, IqcItemResultType } from '../../api/iqcInspBill';
+import type { IqcSaveDetailItemPayload, IqcSaveDetailPayload } from '../../api/iqcInspection';
+
+// 项目级检验结果录入面板(嵌在来料检验单详情抽屉内,不单独路由)。
+//
+// 【授权】canEdit 由后端 detail 给出,是**唯一** UI 编辑 authority;
+//        本组件不按 currentNodeCode 自行推断是否可编辑。
+// 【判定】一律由后端依据「快照冻结的上下限 + 实录测量事实」计算;
+//        本组件只展示后端已保存的判定,**不做任何前端复算/预判**。
+// id 类一律 number | string:后端 long 序列化为 JSON 字符串(见 api/iqcInspBill.ts 注释)
+const props = defineProps<{
+	billId?: number | string | null;
+	lines?: IqcInspBillItemLine[];
+	summary?: IqcInspBillLineSummary | null;
+	canEdit?: boolean;
+	savingItemId?: number | string | null;
+}>();
+const emit = defineEmits<{
+	(e: 'save', payload: IqcSaveDetailPayload, itemId: number | string): void;
+}>();
+
+const tableRef = ref<any>(null);
+
+interface SampleDraft {
+	sampleIndex: number;
+	sampleValueRaw: string;
+	remark: string;
+}
+interface ItemDraft {
+	resultType: IqcItemResultType | null;
+	qualifiedQty: number | null;
+	unqualifiedQty: number | null;
+	resultRemark: string;
+	/** AUTO = 不提交 manualJudgement,由后端按测量真源重算 */
+	manualJudgement: 'AUTO' | 'PASS' | 'FAIL';
+	samples: SampleDraft[];
+}
+
+// 以 String(id) 作 key:id 可能是字符串形态的 long
+const drafts = ref<Record<string, ItemDraft>>({});
+
+const lines = computed<IqcInspBillItemLine[]>(() => props.lines ?? []);
+const itemCount = computed(() => props.summary?.itemCount ?? lines.value.length);
+const canEdit = computed(() => props.canEdit === true);
+const summaryVal = computed(() => ({
+	itemCount: props.summary?.itemCount ?? 0,
+	judgedCount: props.summary?.judgedCount ?? 0,
+	passCount: props.summary?.passCount ?? 0,
+	failCount: props.summary?.failCount ?? 0,
+	invalidCount: props.summary?.invalidCount ?? 0,
+	unjudgedCount: props.summary?.unjudgedCount ?? 0,
+}));
+
+// 每次 detail 重新拉取(打开 / 保存后 / 流程动作后)都按后端最新值重建草稿,
+// 避免旧的本地编辑残留覆盖服务端事实。
+watch(
+	() => props.lines,
+	(list) => {
+		const next: Record<string, ItemDraft> = {};
+		for (const l of list ?? []) {
+			next[String(l.id)] = {
+				resultType: (l.resultType as IqcItemResultType) ?? null,
+				qualifiedQty: l.qualifiedQty ?? null,
+				unqualifiedQty: l.unqualifiedQty ?? null,
+				resultRemark: l.resultRemark ?? '',
+				manualJudgement:
+					l.judgementSource === 'MANUAL' && (l.itemJudgement === 'PASS' || l.itemJudgement === 'FAIL')
+						? (l.itemJudgement as 'PASS' | 'FAIL')
+						: 'AUTO',
+				samples: (l.samples ?? []).map((s) => ({
+					sampleIndex: s.sampleIndex ?? 0,
+					sampleValueRaw: s.sampleValueRaw ?? '',
+					remark: s.remark ?? '',
+				})),
+			};
+		}
+		drafts.value = next;
+	},
+	{ immediate: true }
+);
+
+/** 单项保存中(id 可能是字符串形态的 long,统一按字符串比较) */
+function isSaving(row: IqcInspBillItemLine) {
+	const s = props.savingItemId;
+	return s !== null && s !== undefined && String(s) === String(row.id);
+}
+
+function toggleExpand(row: IqcInspBillItemLine) {
+	tableRef.value?.toggleRowExpansion(row);
+}
+
+/** 草稿类型与库中现值不同 ⇒ 保存时后端会清空该项目原有测量事实 */
+function isTypeSwitching(row: IqcInspBillItemLine) {
+	const d = drafts.value[String(row.id)];
+	if (!d || !d.resultType) return false;
+	return d.resultType !== (row.resultType ?? null);
+}
+
+/** 该项目在**服务端**是否已存在测量事实(样本行 或 计数真源) */
+function hasSavedFacts(row: IqcInspBillItemLine) {
+	if ((row.samples?.length ?? 0) > 0) return true;
+	if (row.qualifiedQty !== null && row.qualifiedQty !== undefined) return true;
+	return row.unqualifiedQty !== null && row.unqualifiedQty !== undefined;
+}
+
+async function onTypeChange(row: IqcInspBillItemLine, next: IqcItemResultType) {
+	const d = drafts.value[String(row.id)];
+	if (!d) return;
+	const dbType = row.resultType ?? null;
+	if (next === dbType) return; // 切回库中现值,无破坏性
+	if (!hasSavedFacts(row)) return; // 尚无已保存的测量事实,无需确认
+	try {
+		await ElMessageBox.confirm(
+			dbType === 'NUMERIC'
+				? '该项目已录入样本实测值。切换为「非数值型」并保存后,这些样本将被全部清空,判定随之重算。是否继续?'
+				: '该项目已录入合格/不合格数。切换为「数值型」并保存后,这些计数将被清空,判定随之重算。是否继续?',
+			'确认切换录入类型',
+			{ type: 'warning', confirmButtonText: '继续切换', cancelButtonText: '取消' }
+		);
+	} catch {
+		d.resultType = dbType; // 取消 → 回退到库中现值
+	}
+}
+
+function addSample(itemId: number | string) {
+	const d = drafts.value[String(itemId)];
+	if (!d) return;
+	const maxIdx = d.samples.reduce((m, s) => (s.sampleIndex > m ? s.sampleIndex : m), 0);
+	d.samples.push({ sampleIndex: maxIdx + 1, sampleValueRaw: '', remark: '' });
+}
+
+function removeSample(itemId: number | string, index: number) {
+	const d = drafts.value[String(itemId)];
+	if (!d) return;
+	d.samples.splice(index, 1);
+}
+
+function onSaveItem(row: IqcInspBillItemLine) {
+	const billId = props.billId;
+	if (!billId) return ElMessage.error('检验单 id 缺失');
+	const d = drafts.value[String(row.id)];
+	if (!d) return ElMessage.error('检验项目数据缺失,请重新打开详情');
+
+	const item: IqcSaveDetailItemPayload = { itemId: row.id };
+	if (d.resultType) item.resultType = d.resultType;
+	item.resultRemark = d.resultRemark && d.resultRemark.trim() ? d.resultRemark.trim() : null;
+	if (d.manualJudgement !== 'AUTO') item.manualJudgement = d.manualJudgement;
+
+	if (d.resultType === 'NUMERIC') {
+		const seen = new Set<number>();
+		for (const s of d.samples) {
+			if (!s.sampleValueRaw || !s.sampleValueRaw.trim())
+				return ElMessage.error(`样本 ${s.sampleIndex} 未填实测值(如需删除请点该行「删除」)`);
+			if (s.sampleIndex < 1) return ElMessage.error('样本序号必须大于 0');
+			if (seen.has(s.sampleIndex)) return ElMessage.error(`样本序号 ${s.sampleIndex} 重复`);
+			seen.add(s.sampleIndex);
+		}
+		item.samples = d.samples.map((s) => ({
+			sampleIndex: s.sampleIndex,
+			sampleValueRaw: s.sampleValueRaw.trim(),
+			remark: s.remark && s.remark.trim() ? s.remark.trim() : null,
+		}));
+	} else if (d.resultType === 'NON_NUMERIC') {
+		item.qualifiedQty = d.qualifiedQty;
+		item.unqualifiedQty = d.unqualifiedQty;
+	}
+
+	emit('save', { billId, items: [item] }, row.id);
+}
+
+// ───────────────────────── 展示辅助(只格式化,不判定) ─────────────────────────
+
+/** 取**已保存**的样本判定(后端唯一写入方)。草稿里改过的值在保存前不重算,刻意保持 stale 并在列头标注 */
+function savedSampleJudgement(row: IqcInspBillItemLine, sampleIndex: number) {
+	return row.samples?.find((s) => s.sampleIndex === sampleIndex)?.judgement ?? null;
+}
+
+function judgeText(j?: string | null) {
+	if (j === 'PASS') return '合格';
+	if (j === 'FAIL') return '不合格';
+	if (j === 'INVALID') return '无效';
+	return '未录';
+}
+function judgeTagType(j?: string | null) {
+	if (j === 'PASS') return 'success';
+	if (j === 'FAIL') return 'danger';
+	if (j === 'INVALID') return 'warning';
+	return 'info';
+}
+function sourceText(s?: string | null) {
+	if (s === 'MANUAL') return '人工';
+	if (s === 'AUTO') return '自动';
+	return '-';
+}
+function resultTypeText(t?: string | null) {
+	if (t === 'NUMERIC') return '数值型';
+	if (t === 'NON_NUMERIC') return '非数值型';
+	return '未选';
+}
+function limitText(row: IqcInspBillItemLine) {
+	const lo = snap(row.lowerLimit);
+	const up = snap(row.upperLimit);
+	if (lo === '-' && up === '-') return '-';
+	return `${lo} ~ ${up}`;
+}
+function measureText(row: IqcInspBillItemLine) {
+	if (row.resultType === 'NUMERIC') {
+		const n = row.samples?.length ?? 0;
+		return n === 0 ? '样本 0 条' : `样本 ${n} 条`;
+	}
+	if (row.resultType === 'NON_NUMERIC') {
+		return `合格 ${dashNum(row.qualifiedQty)} / 不合格 ${dashNum(row.unqualifiedQty)}`;
+	}
+	return '-';
+}
+
+function dash(v: unknown) {
+	if (v === null || v === undefined || v === '') return '-';
+	return String(v);
+}
+function dashNum(v?: number | null) {
+	if (v === null || v === undefined) return '-';
+	return String(v);
+}
+/** 快照/文本字段展示:空值与脏写入的 'null'/'undefined' 字面量一律显示 '-' */
+function snap(v: unknown) {
+	if (v === null || v === undefined) return '-';
+	const s = String(v).trim();
+	if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'undefined') return '-';
+	return s;
+}
+function fmtDateTime(v?: string | null) {
+	if (!v) return '-';
+	return String(v).replace('T', ' ').slice(0, 19);
+}
+</script>
+
+<style scoped lang="scss">
+.item-panel {
+	padding: 12px;
+	border: 1px solid var(--el-border-color-lighter);
+	border-radius: 4px;
+}
+.panel-head {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+.panel-title {
+	font-weight: bold;
+}
+.summary {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+.expand-body {
+	padding: 8px 12px 4px;
+	background: var(--el-fill-color-lighter);
+}
+.sample-wrap {
+	width: 100%;
+}
+.sample-table {
+	width: 100%;
+}
+.hint {
+	color: var(--el-text-color-secondary);
+	font-size: 12px;
+}
+.pre {
+	white-space: pre-wrap;
+	word-break: break-word;
+}
+.mt8 {
+	margin-top: 8px;
+}
+.mt12 {
+	margin-top: 12px;
+}
+.ml8 {
+	margin-left: 8px;
+}
+</style>

+ 28 - 0
Web/src/views/aidop/s5/iqc/iqcTaskList.vue

@@ -143,10 +143,12 @@
 			:flow-state="currentFlowState"
 			:flow-loading="flowLoading"
 			:submitting="flowSubmitting"
+			:saving-item-id="savingItemId"
 			@submit-result="onSubmitResult"
 			@approve="onApprove"
 			@reject="onReject"
 			@sqe-submit="onSqeSubmit"
+			@save-item-detail="onSaveItemDetail"
 		/>
 
 		<el-dialog v-model="assignVisible" title="检验员调配" width="420px">
@@ -191,6 +193,7 @@ import {
 	type IqcSubmitResultPayload,
 	type IqcSqeDispositionPayload,
 } from '../api/iqcInspBillFlow';
+import { saveIqcInspDetail, type IqcSaveDetailPayload } from '../api/iqcInspection';
 import {
 	fetchIqcTaskEntryList,
 	claimIqcTaskEntries,
@@ -253,6 +256,7 @@ const currentFlowState = ref<IqcFlowState | null>(null);
 const flowLoading = ref(false);
 const flowSubmitting = ref(false);
 const currentId = ref<number | null>(null);
+const savingItemId = ref<number | string | null>(null);
 
 function onTabChange(name: string | number) {
 	if (name === 'entry') loadEntryList();
@@ -387,6 +391,30 @@ async function refreshAfterAction() {
 	loadEntryList();
 }
 
+async function reloadDetailOnly() {
+	if (!currentId.value) return;
+	const d = await fetchIqcInspBillDetail(currentId.value);
+	if (d) currentDetail.value = d;
+}
+
+// 项目级结果保存:save-detail 不推进流程,故只重新 GET detail(lines/lineSummary/canEdit 均以后端为准),
+// 不刷新流程态与列表(Header 未变)。失败时后端消息由 request.ts 响应拦截器原样弹出,
+// 此处刻意不再二次 ElMessage —— 否则会叠加 axios 的 "Request failed with status code 400" 技术文案。
+async function onSaveItemDetail(payload: IqcSaveDetailPayload, itemId: number | string) {
+	savingItemId.value = itemId;
+	try {
+		const res = await saveIqcInspDetail(payload);
+		if (res?.clearedByTypeChange?.length) ElMessage.warning('录入类型已切换,该项目原有测量事实已清空');
+		if (res?.manualInvalidated?.length) ElMessage.warning('测量结果已变更,原人工判定已复位为自动判定');
+		ElMessage.success('检验项目结果已保存');
+		await reloadDetailOnly();
+	} catch {
+		/* 后端消息已由响应拦截器弹出 */
+	} finally {
+		savingItemId.value = null;
+	}
+}
+
 async function openDetailById(id: number) {
 	currentId.value = id;
 	detailLoading.value = true;