274 lines
16 KiB
Vue
274 lines
16 KiB
Vue
<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>
|