Files
admin-web/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
T

275 lines
16 KiB
Vue
Raw Normal View History

2026-09-21 23:19:17 +08:00
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
2026-09-22 11:44:07 +08:00
import { Cable, Edit3, RefreshCw, RotateCw } from '@lucide/vue'
2026-09-21 23:19:17 +08:00
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 {
2026-09-23 08:59:17 +08:00
getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, submitLingxingVerificationCode, testLingxingConnection, updateMojiaSkuSync,
2026-09-24 17:02:36 +08:00
type LingxingConnectionTestResult, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus, type MojiaMeasurementKind,
2026-09-21 23:19:17 +08:00
} 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)
2026-09-22 11:33:08 +08:00
const autoSyncEnabled = ref(false)
2026-09-21 23:19:17 +08:00
const error = ref('')
const actionId = ref('')
2026-09-22 11:44:07 +08:00
const testingConnection = ref(false)
const testOpen = ref(false)
const testResult = ref<LingxingConnectionTestResult | null>(null)
2026-09-23 08:59:17 +08:00
const verificationOpen = ref(false)
const verificationCode = ref('')
const submittingVerification = ref(false)
const pendingVerificationAction = ref<{ type: 'test' } | { type: 'retry', id: string, revision: number } | null>(null)
2026-09-21 23:19:17 +08:00
const page = ref(1)
const size = ref(20)
2026-09-24 17:02:36 +08:00
const form = reactive({ code: '', measurementKind: '' as '' | MojiaMeasurementKind, syncStatus: '' as '' | MojiaSkuSyncStatus })
2026-09-21 23:19:17 +08:00
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' },
2026-09-24 17:02:36 +08:00
{ 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 },
2026-09-21 23:19:17 +08:00
{ 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: '同步失败',
2026-09-21 23:19:17 +08:00
}
const statusType: Record<MojiaSkuSyncStatus, 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning', SUCCESS: 'success', FAILED: 'danger',
2026-09-21 23:19:17 +08:00
}
async function load() {
loading.value = true
error.value = ''
try {
2026-09-24 17:02:36 +08:00
const result = await listMojiaSkuSync({ page: page.value, size: size.value, code: form.code || undefined, measurementKind: form.measurementKind || undefined, syncStatus: form.syncStatus || undefined })
2026-09-21 23:19:17 +08:00
rows.value = result.records
total.value = result.total
} catch (cause) {
2026-09-24 17:02:36 +08:00
error.value = getApiErrorMessage(cause, '称重机器上传记录加载失败')
2026-09-21 23:19:17 +08:00
} finally { loading.value = false }
}
2026-09-22 11:33:08 +08:00
async function loadConfig() {
try {
autoSyncEnabled.value = (await getMojiaSkuSyncConfig()).autoSyncEnabled
} catch (cause) {
error.value = getApiErrorMessage(cause, '自动同步配置加载失败')
}
}
2026-09-21 23:19:17 +08:00
function search() { page.value = 1; void load() }
2026-09-24 17:02:36 +08:00
function reset() { form.code = ''; form.measurementKind = ''; form.syncStatus = ''; search() }
2026-09-21 23:19:17 +08:00
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
function asRecord(row: unknown) { return row as MojiaSkuSyncRecord }
2026-09-24 17:02:36 +08:00
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'
}
2026-09-21 23:19:17 +08:00
2026-09-22 11:44:07 +08:00
async function testConnection() {
2026-09-24 17:02:36 +08:00
const skuCode = form.code.trim()
2026-09-22 11:44:07 +08:00
if (!skuCode) { ElMessage.warning('请先输入要查询的 SKU'); return }
testingConnection.value = true
try {
testResult.value = await testLingxingConnection(skuCode)
testOpen.value = true
2026-09-23 08:59:17 +08:00
} catch (cause) {
const message = getApiErrorMessage(cause, '领星连接测试失败')
if (/二次验证码|验证码|安全验证/.test(message)) {
pendingVerificationAction.value = { type: 'test' }
verificationOpen.value = true
}
ElMessage.error(message)
}
2026-09-22 11:44:07 +08:00
finally { testingConnection.value = false }
}
2026-09-23 08:59:17 +08:00
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 }
}
2026-09-21 23:19:17 +08:00
function openEdit(row: MojiaSkuSyncRecord) {
editing.value = row
2026-09-24 17:02:36 +08:00
Object.assign(editForm, { skuCode: row.skuCode || '', lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
2026-09-21 23:19:17 +08:00
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 = '' }
}
2026-09-23 08:59:17 +08:00
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 }
}
2026-09-21 23:19:17 +08:00
actionId.value = row.id
2026-09-23 08:59:17 +08:00
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)
}
2026-09-21 23:19:17 +08:00
finally { actionId.value = '' }
}
2026-09-22 11:33:08 +08:00
onMounted(() => { void Promise.all([loadConfig(), load()]) })
2026-09-21 23:19:17 +08:00
</script>
<template>
<AdminPage class="admin-density mojia-sku-sync-page">
2026-09-24 17:02:36 +08:00
<ListPageLayout aria-label="称重机器">
2026-09-21 23:19:17 +08:00
<section class="surface-panel table-panel" :aria-busy="loading">
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
2026-09-22 11:33:08 +08:00
<el-alert
:type="autoSyncEnabled ? 'warning' : 'info'"
:title="autoSyncEnabled ? '领星自动同步已开启' : '领星自动同步已关闭,仅支持人工推送'"
description="开关由 Nacos 配置 firstleg.lingxing.auto-sync-enabled 控制。"
show-icon
:closable="false"
/>
2026-09-24 17:02:36 +08:00
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无墨家机器上传记录">
2026-09-21 23:19:17 +08:00
<template #toolbar-start>
<div class="sync-filters">
2026-09-24 17:02:36 +08:00
<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>
2026-09-21 23:19:17 +08:00
<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>
2026-09-22 11:44:07 +08:00
<el-button :loading="testingConnection" @click="testConnection"><Cable :size="16" />测试连接</el-button>
2026-09-21 23:19:17 +08:00
</div>
</template>
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></template>
<template #actions="{ row }">
<div class="density-actions">
<el-button v-if="canEdit" class="density-action" @click="openEdit(asRecord(row))"><Edit3 :size="15" />编辑</el-button>
2026-09-24 17:02:36 +08:00
<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))"><RotateCw :size="15" />人工推送</el-button>
2026-09-21 23:19:17 +08:00
</div>
</template>
2026-09-24 17:02:36 +08:00
<template #sourceCode="{ row }">{{ row.measurementKind === 'SKU' ? row.skuCode : row.boxCode }}</template>
<template #kind="{ row }">{{ row.measurementKind === 'SKU' ? 'SKU测量' : '箱码测量' }}</template>
2026-09-21 23:19:17 +08:00
<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>
<div class="sync-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" background layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" /></div>
</section>
</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>
2026-09-21 23:19:17 +08:00
<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>
2026-09-21 23:19:17 +08:00
</div>
<el-alert type="info" :closable="false" title="原始墨家测量记录不可变,本次保存会生成新的人工修正版并进入同步队列。" />
</el-form>
</AppDialog>
2026-09-22 11:44:07 +08:00
<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>
2026-09-23 08:59:17 +08:00
<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>
2026-09-23 08:59:17 +08:00
</AppDialog>
2026-09-21 23:19:17 +08:00
</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%; }
2026-09-22 11:44:07 +08:00
.connection-result { display: grid; gap: var(--admin-space-4); }
.connection-result__sku { color: var(--cc-muted); }
.connection-result__sku strong { color: var(--cc-text); }
2026-09-21 23:19:17 +08:00
@media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }
</style>