Files
admin-web/src/modules/inbounds/review/MeasurementReviewPanel.vue
T
2026-08-14 17:38:06 +08:00

358 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, CircleAlert, History, RotateCcw, ShieldAlert, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { inboundReviewStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../status'
import {
approveMeasurement,
getMeasurementReviewDetail,
listMeasurementReviewHistory,
rejectMeasurement,
releaseRejectedMeasurement,
} from './api'
import type { MeasurementReviewDetail, MeasurementReviewItem, MeasurementReviewRecord } from './types'
const props = defineProps<{
customerId?: string
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
const history = ref<MeasurementReviewRecord[]>([])
const loading = ref(false)
const historyLoading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const historyError = ref<ApiErrorDetails | null>(null)
const reasonDialog = ref<'reject' | 'release' | null>(null)
const reason = ref('')
const requestSerial = ref(0)
const reasonDialogOpen = computed({
get: () => reasonDialog.value !== null,
set: (open: boolean) => { if (!open) reasonDialog.value = null },
})
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const canRelease = computed(() => access.can('firstleg:inbound:exception-release'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.netWeightKg !== null).length ?? 0)
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
const measurementColumns: DataTableColumn[] = [
{ key: 'boxCode', label: '扫描箱号', minWidth: 190, slotName: 'boxCode' },
{ key: 'packageCount', label: '确认箱数', width: 90, slotName: 'packageCount' },
{ key: 'dimensions', label: '实测长 × 宽 × 高(cm)', minWidth: 190, slotName: 'dimensions' },
{ key: 'unitWeightKg', label: '实测重量(kg)', minWidth: 115, slotName: 'unitWeightKg' },
{ key: 'machineNo', label: '机器编号', minWidth: 130, slotName: 'machineNo' },
{ key: 'measuredAt', label: '测量时间', minWidth: 150, slotName: 'measuredAt' },
{ key: 'intelligentImageFileId', label: '当前图片', minWidth: 110, slotName: 'image' },
{ key: 'unitVolumeCbm', label: '每箱体积(m³)', minWidth: 125, slotName: 'unitVolumeCbm' },
{ key: 'totalWeightKg', label: '总毛重(kg)', minWidth: 110, slotName: 'totalWeightKg' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 110, slotName: 'totalVolumeCbm' },
{ key: 'volumeWeightKg', label: '材积重(kg)', minWidth: 110, slotName: 'volumeWeightKg' },
{ key: 'note', label: '超限提示 / 备注', minWidth: 200, slotName: 'note' },
]
const weightColumns: DataTableColumn[] = [
{ key: 'file', label: '称重文件 / OCR 原文', minWidth: 260, slotName: 'file' },
{ key: 'pallet', label: '托盘', minWidth: 170, slotName: 'pallet' },
{ key: 'status', label: '识别状态', width: 100, slotName: 'status' },
{ key: 'ocrWeight', label: 'OCR 含托盘毛重', minWidth: 140, slotName: 'ocrWeight' },
{ key: 'adjustedWeight', label: '人工修正毛重', minWidth: 130, slotName: 'adjustedWeight' },
{ key: 'grossWeight', label: '服务端毛重', minWidth: 115, slotName: 'grossWeight' },
{ key: 'tareWeight', label: '托盘皮重', minWidth: 110, slotName: 'tareWeight' },
{ key: 'netWeight', label: '货物重量', minWidth: 110, slotName: 'netWeight' },
{ key: 'confidence', label: '置信度', width: 90, slotName: 'confidence' },
{ key: 'reason', label: '调整原因', minWidth: 180, slotName: 'reason' },
]
function formatNumber(value: number | null | undefined, unit = '') {
if (value === null || value === undefined || Number.isNaN(value)) return '-'
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '-'
}
function resultLabel(value: string | null | undefined) {
if (value === 'APPROVED') return '审核通过'
if (value === 'REJECTED') return '审核驳回'
return '未知结果'
}
function recognitionStatusLabel(value: string | null | undefined) {
return inboundStatusLabel(value, weightRecognitionStatusLabels, '待确认')
}
function measurementSourceLabel(value: string | null | undefined) {
if (!value) return '-'
return ({ DEVICE: '设备采集', THIRD_PARTY: '第三方设备', MANUAL: '人工录入' } as Record<string, string>)[value] || '其他来源'
}
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return details.message.includes('版本已变化') || details.message.includes('并发修改')
}
async function loadReview() {
const serial = ++requestSerial.value
loadError.value = null
loading.value = true
try {
const result = await getMeasurementReviewDetail(customerId.value, props.inboundId)
if (serial === requestSerial.value) detail.value = result
} catch (error) {
if (serial === requestSerial.value) {
detail.value = null
loadError.value = getApiErrorDetails(error, '测量审核数据加载失败')
}
} finally {
if (serial === requestSerial.value) loading.value = false
}
}
async function loadHistory() {
historyError.value = null
historyLoading.value = true
try {
const result = await listMeasurementReviewHistory(customerId.value, props.inboundId)
history.value = result.records
} catch (error) {
history.value = []
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
} finally {
historyLoading.value = false
}
}
async function reload() {
await Promise.all([loadReview(), loadHistory()])
}
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
try {
await ElMessageBox.confirm(
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
'确认测量审核通过',
{ confirmButtonText: '通过并进入待分柜', cancelButtonText: '返回', type: 'warning' },
)
} catch {
return
}
actionLoading.value = 'approve'
try {
const result = await approveMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
})
if (result.initialPricingSucceeded) {
ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
} else {
ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
}
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
function openReasonDialog(kind: 'reject' | 'release') {
reason.value = ''
reasonDialog.value = kind
}
async function submitReason() {
if (!detail.value || !reasonDialog.value) return
const value = reason.value.trim()
if (!value) {
ElMessage.warning(reasonDialog.value === 'reject' ? '请填写驳回原因' : '请填写异常放行原因')
return
}
const kind = reasonDialog.value
actionLoading.value = kind
try {
if (kind === 'reject') {
await rejectMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
reason: value,
})
ElMessage.success('测量已驳回,已退回测量修正')
} else {
await releaseRejectedMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
reason: value,
})
ElMessage.success('异常放行已提交,后续计费入库将继续处理')
}
reasonDialog.value = null
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重试' : getApiErrorDetails(error, kind === 'reject' ? '测量驳回失败' : '异常放行失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
watch(() => [customerId.value, props.inboundId], () => {
if (customerId.value && props.inboundId) void reload()
}, { immediate: true })
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
</script>
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
<header class="review-header">
<h2 id="measurement-review-title">测量信息</h2>
<div class="review-header-status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</header>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="reload">重新加载</el-button>
</el-alert>
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<template v-else-if="detail">
<dl class="review-fields">
<div><dt>入库单号</dt><dd>{{ detail.inboundCode || '-' }}</dd></div>
<div><dt>客户名称</dt><dd>{{ detail.companyName || '-' }}</dd></div>
<div><dt>原单申报</dt><dd>{{ detail.originalDocumentNo || '-' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.channelName || detail.channelCode || '-' }}</dd></div>
<div><dt>申报箱数</dt><dd>{{ formatNumber(detail.originalPackageCount, ' 箱') }}</dd></div>
<div><dt>实测箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</dd></div>
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
<div><dt>货物总重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
<div><dt>设备实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
<div><dt>测量来源</dt><dd>{{ measurementSourceLabel(detail.sourceType) }}</dd></div>
<div><dt>已确认称重图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
<div><dt>审核状态</dt><dd><StatusBadge :status="reviewStatusLabel" /></dd></div>
</dl>
<div v-if="detail.reviewReason" class="review-reason">
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
</div>
<div class="review-section">
<div class="section-heading"><h3>测量明细</h3><span class="section-count">{{ detail.items.length }} 条</span></div>
<DataTable :data="detail.items" :columns="measurementColumns" empty-text="暂无设备测量明细" border stripe>
<template #boxCode="{ row }">{{ (row as MeasurementReviewItem).boxCode || (row as MeasurementReviewItem).itemNo || '-' }}</template>
<template #packageCount="{ row }">{{ (row as MeasurementReviewItem).packageCount ?? '-' }}</template>
<template #dimensions="{ row }">{{ [(row as MeasurementReviewItem).lengthCm, (row as MeasurementReviewItem).widthCm, (row as MeasurementReviewItem).heightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
<template #outerDimensions="{ row }">{{ [(row as MeasurementReviewItem).outerLengthCm, (row as MeasurementReviewItem).outerWidthCm, (row as MeasurementReviewItem).outerHeightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
<template #girthCm="{ row }">{{ formatNumber((row as MeasurementReviewItem).girthCm) }}</template>
<template #unitWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitWeightKg) }}</template>
<template #machineNo="{ row }">{{ (row as MeasurementReviewItem).machineNo || '-' }}</template>
<template #measuredAt="{ row }">{{ formatDate((row as MeasurementReviewItem).measuredAt) }}</template>
<template #image="{ row }">{{ (row as MeasurementReviewItem).intelligentImageFileId ? '已关联' : '暂无' }}</template>
<template #unitVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitVolumeCbm) }}</template>
<template #totalWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalWeightKg) }}</template>
<template #totalVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalVolumeCbm) }}</template>
<template #volumeWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).volumeWeightKg) }}</template>
<template #note="{ row }"><div class="measurement-note"><strong v-if="(row as MeasurementReviewItem).overLimitWarning">{{ (row as MeasurementReviewItem).overLimitWarning }}</strong><span>{{ (row as MeasurementReviewItem).remark || ((row as MeasurementReviewItem).overLimitWarning ? '' : '-') }}</span></div></template>
<template #empty><el-empty description="暂无设备测量明细" :image-size="48" /></template>
</DataTable>
</div>
<div class="review-section">
<div class="section-heading"><h3>称重图片识别结果</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
<DataTable :data="detail.weightResults" :columns="weightColumns" empty-text="暂无称重图片识别结果" border stripe>
<template #file="{ row }"><div class="table-wrap"><strong>{{ row.fileId || '未关联文件' }}</strong><span v-if="row.ocrRawText">OCR 原文:{{ row.ocrRawText }}</span></div></template>
<template #pallet="{ row }"><div class="table-wrap"><strong>{{ row.palletCode }}</strong><span>{{ row.palletType }} · {{ formatNumber(row.palletCapacityCbm, ' m³') }}</span></div></template>
<template #status="{ row }">{{ recognitionStatusLabel(row.recognitionStatus) }}</template>
<template #ocrWeight="{ row }">{{ formatNumber(row.ocrWeightValue, ` ${row.ocrWeightUnit || ''}`) }}</template>
<template #adjustedWeight="{ row }">{{ formatNumber(row.adjustedWeightValue, ` ${row.adjustedWeightUnit || ''}`) }}</template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg, ' kg') }}</template>
<template #tareWeight="{ row }">{{ formatNumber(row.palletTareWeightKg, ' kg') }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg, ' kg') }}</template>
<template #confidence="{ row }">{{ row.confidence === null ? '-' : `${Math.round(row.confidence * 100)}%` }}</template>
<template #reason="{ row }">{{ row.adjustmentReason ? `调整原因:${row.adjustmentReason}` : '-' }}</template>
</DataTable>
</div>
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜;费用仅做初算,初算失败不阻断审核。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
<div class="action-buttons">
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
<el-button v-if="isRejected && canRelease" type="warning" plain :loading="actionLoading === 'release'" @click="openReasonDialog('release')"><ShieldAlert :size="16" />异常放行</el-button>
</div>
</div>
<div class="review-section history-section">
<div class="section-heading"><div><h3><History :size="17" />审核历史</h3><p>每次审核均保留操作人、原因和放行开关快照。</p></div></div>
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
<ol v-else class="history-list">
<li v-for="item in history" :key="item.id">
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }} · {{ item.allowRejectedConfig ? '放行开关已开启' : '放行开关未开启' }}</span></div>
</li>
</ol>
</div>
</template>
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="min(520px, calc(100vw - 28px))" destroy-on-close>
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
<template #footer><el-button :disabled="Boolean(actionLoading)" @click="reasonDialog = null">取消</el-button><el-button :type="reasonDialog === 'reject' ? 'danger' : 'warning'" :loading="Boolean(actionLoading)" @click="submitReason">确认{{ reasonDialog === 'reject' ? '驳回' : '放行' }}</el-button></template>
</el-dialog>
</section>
</template>
<style scoped>
.review-panel { display: grid; gap: 20px; color: var(--cc-text); }
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
.review-header h2 { margin: 0 0 5px; font-size: 22px; line-height: 1.25; }
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.review-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 32px; margin: 0; border-top: 1px solid var(--cc-border); }
.review-fields > div { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; min-width: 0; padding: 10px 0; border-bottom: 1px solid var(--cc-border); font-size: 13px; }
.review-fields dt { color: var(--cc-muted); }.review-fields dd { min-width: 0; margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.measurement-note { overflow-wrap: anywhere; }.measurement-note strong, .measurement-note span { display: block; }.measurement-note strong { color: var(--el-color-warning-dark-2); }.measurement-note strong + span { margin-top: 4px; color: var(--cc-muted); }
.table-wrap { display: grid; gap: 4px; min-width: 0; }.table-wrap span { color: var(--cc-muted); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.review-fields { grid-template-columns: 1fr; gap: 0; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.review-fields > div { grid-template-columns: 100px minmax(0, 1fr); }.review-actions { padding: 13px; } }
</style>