Files
admin-web/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
T
2026-09-30 14:01:58 +08:00

274 lines
16 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 '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Cable, RefreshCw } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage } from '@/shared/api/http'
import {
getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, submitLingxingVerificationCode, testLingxingConnection, updateMojiaSkuSync,
type LingxingConnectionTestResult, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus, type MojiaMeasurementKind,
} from '../api/mojiaSkuSync'
const permission = {
list: 'platform:mojia-sku-sync:list',
edit: 'platform:mojia-sku-sync:edit',
retry: 'platform:mojia-sku-sync:retry',
} as const
const access = useAccessPreviewStore()
const canEdit = computed(() => access.can(permission.edit))
const canRetry = computed(() => access.can(permission.retry))
const rows = ref<MojiaSkuSyncRecord[]>([])
const total = ref(0)
const loading = ref(false)
const autoSyncEnabled = ref(false)
const error = ref('')
const actionId = ref('')
const testingConnection = ref(false)
const testOpen = ref(false)
const testResult = ref<LingxingConnectionTestResult | null>(null)
const verificationOpen = ref(false)
const verificationCode = ref('')
const submittingVerification = ref(false)
const pendingVerificationAction = ref<{ type: 'test' } | { type: 'retry', id: string, revision: number } | null>(null)
const page = ref(1)
const size = ref(20)
const form = reactive({ code: '', measurementKind: '' as '' | MojiaMeasurementKind, syncStatus: '' as '' | MojiaSkuSyncStatus })
const editOpen = ref(false)
const editing = ref<MojiaSkuSyncRecord | null>(null)
const editForm = reactive({ skuCode: '', lengthCm: 0, widthCm: 0, heightCm: 0, weightKg: 0, revision: 0 })
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'sourceCode', label: '上传标识', minWidth: 180, showOverflowTooltip: true, slotName: 'sourceCode' },
{ key: 'kind', label: '类型', width: 110, slotName: 'kind' },
{ key: 'inboundCode', prop: 'inboundCode', label: '命中入仓码', minWidth: 150, showOverflowTooltip: true },
{ key: 'image', label: '测量图片', width: 110, slotName: 'image' },
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', width: 170, slotName: 'dimensions' },
{ key: 'weightKg', prop: 'weightKg', label: '重量(kg)', width: 110 },
{ key: 'syncStatus', label: '同步状态', width: 120, slotName: 'syncStatus' },
{ key: 'match', label: '领星 SKU', minWidth: 150, slotName: 'match' },
{ key: 'measuredAt', label: '测量时间', width: 170, slotName: 'measuredAt' },
{ key: 'failure', label: '失败原因', minWidth: 220, slotName: 'failure' },
]
const statusLabel: Record<MojiaSkuSyncStatus, string> = {
PENDING: '待同步', SUCCESS: '同步成功', FAILED: '同步失败',
}
const statusType: Record<MojiaSkuSyncStatus, 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning', SUCCESS: 'success', FAILED: 'danger',
}
async function load() {
loading.value = true
error.value = ''
try {
const result = await listMojiaSkuSync({ page: page.value, size: size.value, code: form.code || undefined, measurementKind: form.measurementKind || undefined, syncStatus: form.syncStatus || undefined })
rows.value = result.records
total.value = result.total
} catch (cause) {
error.value = getApiErrorMessage(cause, '称重机器上传记录加载失败')
} finally { loading.value = false }
}
async function loadConfig() {
try {
autoSyncEnabled.value = (await getMojiaSkuSyncConfig()).autoSyncEnabled
} catch (cause) {
error.value = getApiErrorMessage(cause, '自动同步配置加载失败')
}
}
function search() { page.value = 1; void load() }
function reset() { form.code = ''; form.measurementKind = ''; form.syncStatus = ''; search() }
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
function asRecord(row: unknown) { return row as MojiaSkuSyncRecord }
function syncLabel(row: unknown) {
const value = asRecord(row)
if (value.measurementKind === 'BOX_LABEL') return value.inboundCode ? '已命中入仓码' : '未命中'
return value.syncStatus ? statusLabel[value.syncStatus] : '—'
}
function syncTagType(row: unknown) {
const value = asRecord(row)
if (value.measurementKind === 'BOX_LABEL') return value.inboundCode ? 'success' : 'warning'
return value.syncStatus ? statusType[value.syncStatus] : 'info'
}
async function testConnection() {
const skuCode = form.code.trim()
if (!skuCode) { ElMessage.warning('请先输入要查询的 SKU'); return }
testingConnection.value = true
try {
testResult.value = await testLingxingConnection(skuCode)
testOpen.value = true
} catch (cause) {
const message = getApiErrorMessage(cause, '领星连接测试失败')
if (/二次验证码|验证码|安全验证/.test(message)) {
pendingVerificationAction.value = { type: 'test' }
verificationOpen.value = true
}
ElMessage.error(message)
}
finally { testingConnection.value = false }
}
async function submitVerification() {
if (!verificationCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
submittingVerification.value = true
try {
await submitLingxingVerificationCode(verificationCode.value.trim())
const pending = pendingVerificationAction.value
verificationCode.value = ''
verificationOpen.value = false
pendingVerificationAction.value = null
await load()
ElMessage.success('验证码已提交,正在继续原操作')
if (pending?.type === 'test') {
await testConnection()
} else if (pending?.type === 'retry') {
const row = rows.value.find((item) => item.id === pending.id)
if (row) await retry(row, false)
}
} catch (cause) { ElMessage.error(getApiErrorMessage(cause, '验证码提交失败')) }
finally { submittingVerification.value = false }
}
function openEdit(row: MojiaSkuSyncRecord) {
editing.value = row
Object.assign(editForm, { skuCode: row.skuCode || '', lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
editOpen.value = true
}
async function saveEdit() {
const row = editing.value
if (!row || !editForm.skuCode.trim() || [editForm.lengthCm, editForm.widthCm, editForm.heightCm, editForm.weightKg].some((value) => value <= 0)) {
ElMessage.warning('请填写有效的 SKU、尺寸和重量')
return
}
actionId.value = row.id
try {
await updateMojiaSkuSync(row.id, { ...editForm, skuCode: editForm.skuCode.trim() })
editOpen.value = false
ElMessage.success('已保存人工修正版,记录将重新进入同步队列')
await load()
} catch (cause) { ElMessage.error(getApiErrorMessage(cause, '保存修正版失败')) }
finally { actionId.value = '' }
}
async function retry(row: MojiaSkuSyncRecord, confirm = true) {
if (confirm) {
try {
await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
} catch { return }
}
actionId.value = row.id
try {
const result = await retryMojiaSkuSync(row.id, row.revision)
ElMessage.success(result.syncStatus === 'SUCCESS' ? '已成功推送到领星' : '推送失败,详情已记录')
await load()
}
catch (cause) {
const message = getApiErrorMessage(cause, '重新推送失败')
if (/二次验证码|验证码|安全验证/.test(message)) {
pendingVerificationAction.value = { type: 'retry', id: row.id, revision: row.revision }
verificationOpen.value = true
}
ElMessage.error(message)
}
finally { actionId.value = '' }
}
onMounted(() => { void Promise.all([loadConfig(), load()]) })
</script>
<template>
<AdminPage class="admin-density mojia-sku-sync-page">
<ListPageLayout aria-label="称重机器">
<DataTable v-model:page="page" v-model:page-size="size" :total="total" :page-sizes="[10, 20, 30, 40, 50, 100]" @update:page="load" @update:page-size="search" appearance="integrated" :stripe="false" column-settings-mode="show" preference-key="mojia-sku-sync-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无墨家机器上传记录">
<template #toolbar-start><h1>称重机器</h1></template>
<template #filters>
<div class="sync-filters query-toolbar">
<el-input v-model.trim="form.code" clearable placeholder="搜索箱码或 SKU" @keyup.enter="search" />
<el-select v-model="form.measurementKind" clearable placeholder="上传类型" style="width: 130px"><el-option label="箱码" value="BOX_LABEL" /><el-option label="SKU" value="SKU" /></el-select>
<el-select v-model="form.syncStatus" clearable placeholder="同步状态" style="width: 140px">
<el-option v-for="(label, value) in statusLabel" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button :loading="testingConnection" @click="testConnection"><Cable :size="16" />测试连接</el-button>
</div>
</template>
<template #notices><el-alert v-if="error" type="error" :title="error" show-icon :closable="false" /><el-alert
:type="autoSyncEnabled ? 'warning' : 'info'"
:title="autoSyncEnabled ? '领星自动同步已开启' : '领星自动同步已关闭,仅支持人工推送'"
description="开关由 Nacos 配置 firstleg.lingxing.auto-sync-enabled 控制。"
show-icon
:closable="false"
/></template>
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></template>
<template #actions="{ row }">
<div class="density-actions row-actions admin-text-actions">
<el-button v-if="canEdit" class="density-action" @click="openEdit(asRecord(row))">编辑</el-button>
<el-button v-if="canRetry && row.measurementKind === 'SKU' && row.syncStatus !== 'SUCCESS'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="retry(asRecord(row))">人工推送</el-button>
</div>
</template>
<template #sourceCode="{ row }">{{ row.measurementKind === 'SKU' ? row.skuCode : row.boxCode }}</template>
<template #kind="{ row }">{{ row.measurementKind === 'SKU' ? 'SKU测量' : '箱码测量' }}</template>
<template #image="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="sync-image" preview-teleported /><span v-else>—</span></template>
<template #dimensions="{ row }">{{ row.lengthCm }} × {{ row.widthCm }} × {{ row.heightCm }}</template>
<template #syncStatus="{ row }"><el-tag size="small" :type="syncTagType(row)">{{ syncLabel(row) }}</el-tag></template>
<template #match="{ row }">{{ row.lingxingSkuId || '未匹配' }}</template>
<template #measuredAt="{ row }">{{ formatTime(row.measuredAt) }}</template>
<template #failure="{ row }">{{ row.syncFailureReason || '—' }}</template>
</DataTable>
</ListPageLayout>
<AppDialog v-model="editOpen" class="admin-density-dialog" title="编辑 SKU 测量修正版" width="min(620px, calc(100vw - 28px))" confirm-text="保存并重新同步" :confirm-loading="Boolean(actionId)" @confirm="saveEdit">
<el-form label-position="top" class="sync-edit-form">
<el-form-item label="SKU"><el-input v-model.trim="editForm.skuCode" placeholder="请输入 SKU" /></el-form-item>
<div class="sync-edit-grid">
<el-form-item label="长(cm)"><el-input-number v-model="editForm.lengthCm" :min="0.001" :controls="false" placeholder="请输入长度" /></el-form-item>
<el-form-item label="宽(cm)"><el-input-number v-model="editForm.widthCm" :min="0.001" :controls="false" placeholder="请输入宽度" /></el-form-item>
<el-form-item label="高(cm)"><el-input-number v-model="editForm.heightCm" :min="0.001" :controls="false" placeholder="请输入高度" /></el-form-item>
<el-form-item label="重量(kg)"><el-input-number v-model="editForm.weightKg" :min="0.001" :controls="false" placeholder="请输入重量" /></el-form-item>
</div>
<el-alert type="info" :closable="false" title="原始墨家测量记录不可变,本次保存会生成新的人工修正版并进入同步队列。" />
</el-form>
</AppDialog>
<AppDialog v-model="testOpen" class="admin-density-dialog" title="领星连接测试" width="min(720px, calc(100vw - 28px))" :show-cancel="false" confirm-text="关闭" @confirm="testOpen = false">
<div v-if="testResult" class="connection-result">
<div class="connection-result__sku">查询 SKU:<strong>{{ testResult.skuCode }}</strong></div>
<el-table :data="testResult.warehouses" border>
<el-table-column prop="whCode" label="仓库" width="90" />
<el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'danger'">{{ row.matched ? '已命中' : '未命中' }}</el-tag></template></el-table-column>
<el-table-column label="维护状态" width="110"><template #default="{ row }">{{ row.matched ? (row.maintained ? '已维护' : '待维护') : '—' }}</template></el-table-column>
<el-table-column label="领星数据" min-width="220"><template #default="{ row }">{{ row.matched ? `${row.length ?? 0} × ${row.width ?? 0} × ${row.height ?? 0} cm / ${row.weight ?? 0} kg` : row.message }}</template></el-table-column>
</el-table>
</div>
</AppDialog>
<AppDialog v-model="verificationOpen" class="admin-density-dialog" title="领星二次验证" width="min(460px, calc(100vw - 28px))" confirm-text="提交验证码" :confirm-loading="submittingVerification" @confirm="submitVerification">
<el-alert type="warning" :closable="false" title="领星要求二次验证,请在验证码发送到手机、邮箱或验证器后输入。" />
<el-form label-position="top" class="verification-form"><el-form-item label="验证码"><el-input v-model.trim="verificationCode" maxlength="16" autocomplete="one-time-code" placeholder="请输入验证码" @keyup.enter="submitVerification" /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.sync-filters { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-2); }
.sync-filters .el-input { width: 220px; }
.density-actions { display: flex; gap: var(--admin-space-2); }
.sync-image { width: 52px; height: 40px; border-radius: 4px; background: var(--cc-surface-muted); }
.sync-pagination { display: flex; justify-content: flex-end; padding: var(--admin-space-4); }
.sync-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.sync-edit-grid :deep(.el-input-number) { width: 100%; }
.connection-result { display: grid; gap: var(--admin-space-4); }
.connection-result__sku { color: var(--cc-muted); }
.connection-result__sku strong { color: var(--cc-text); }
@media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }
</style>