feat: add mojia sku sync admin page
This commit is contained in:
@@ -170,6 +170,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
||||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||||
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
||||||
|
{ key: 'mojia-sku-sync', title: '一件代发 SKU 同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export type MojiaSkuSyncStatus = 'PENDING' | 'SYNCING' | 'SUCCESS' | 'FAILED'
|
||||||
|
|
||||||
|
export interface MojiaSkuSyncRecord {
|
||||||
|
id: string
|
||||||
|
skuCode: string
|
||||||
|
lengthCm: number
|
||||||
|
widthCm: number
|
||||||
|
heightCm: number
|
||||||
|
weightKg: number
|
||||||
|
imageFileId?: string | null
|
||||||
|
imageUrl?: string | null
|
||||||
|
measuredAt?: string | null
|
||||||
|
receivedAt?: string | null
|
||||||
|
syncStatus: MojiaSkuSyncStatus
|
||||||
|
syncFailureCode?: string | null
|
||||||
|
syncFailureReason?: string | null
|
||||||
|
lingxingSkuId?: string | null
|
||||||
|
revision: number
|
||||||
|
updatedAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MojiaSkuSyncPage {
|
||||||
|
records: MojiaSkuSyncRecord[]
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MojiaSkuSyncQuery {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
skuCode?: string
|
||||||
|
syncStatus?: MojiaSkuSyncStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = '/platform/v1/firstleg/mojia-sku-sync'
|
||||||
|
|
||||||
|
export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) {
|
||||||
|
const response = await http.get<ApiEnvelope<MojiaSkuSyncPage>, ApiEnvelope<MojiaSkuSyncPage>>(
|
||||||
|
basePath, { params: query },
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMojiaSkuSync(id: string, data: {
|
||||||
|
skuCode: string
|
||||||
|
lengthCm: number
|
||||||
|
widthCm: number
|
||||||
|
heightCm: number
|
||||||
|
weightKg: number
|
||||||
|
revision: number
|
||||||
|
}) {
|
||||||
|
const response = await http.put<ApiEnvelope<MojiaSkuSyncRecord>, ApiEnvelope<MojiaSkuSyncRecord>>(
|
||||||
|
`${basePath}/${encodeURIComponent(id)}`, data,
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryMojiaSkuSync(id: string, revision: number) {
|
||||||
|
const response = await http.post<ApiEnvelope<MojiaSkuSyncRecord>, ApiEnvelope<MojiaSkuSyncRecord>>(
|
||||||
|
`${basePath}/${encodeURIComponent(id)}/retry`, { revision },
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminDensity.css'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Edit3, RefreshCw, RotateCw } 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 {
|
||||||
|
listMojiaSkuSync, retryMojiaSkuSync, updateMojiaSkuSync,
|
||||||
|
type MojiaSkuSyncRecord, type MojiaSkuSyncStatus,
|
||||||
|
} 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 error = ref('')
|
||||||
|
const actionId = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const size = ref(20)
|
||||||
|
const form = reactive({ skuCode: '', 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: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 180, 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: '待同步', SYNCING: '同步中', SUCCESS: '同步成功', FAILED: '同步失败',
|
||||||
|
}
|
||||||
|
const statusType: Record<MojiaSkuSyncStatus, 'success' | 'warning' | 'danger' | 'info'> = {
|
||||||
|
PENDING: 'warning', SYNCING: 'info', SUCCESS: 'success', FAILED: 'danger',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await listMojiaSkuSync({ page: page.value, size: size.value, skuCode: form.skuCode || undefined, syncStatus: form.syncStatus || undefined })
|
||||||
|
rows.value = result.records
|
||||||
|
total.value = result.total
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = getApiErrorMessage(cause, 'SKU同步记录加载失败')
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() { page.value = 1; void load() }
|
||||||
|
function reset() { form.skuCode = ''; 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) { return statusLabel[asRecord(row).syncStatus] }
|
||||||
|
function syncTagType(row: unknown) { return statusType[asRecord(row).syncStatus] }
|
||||||
|
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
|
||||||
|
} catch { return }
|
||||||
|
actionId.value = row.id
|
||||||
|
try { await retryMojiaSkuSync(row.id, row.revision); ElMessage.success('已进入待同步队列'); await load() }
|
||||||
|
catch (cause) { ElMessage.error(getApiErrorMessage(cause, '重新推送失败')) }
|
||||||
|
finally { actionId.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { void load() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminPage class="admin-density mojia-sku-sync-page">
|
||||||
|
<ListPageLayout aria-label="一件代发 SKU 同步">
|
||||||
|
<section class="surface-panel table-panel" :aria-busy="loading">
|
||||||
|
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
|
||||||
|
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无墨家 SKU 测量记录">
|
||||||
|
<template #toolbar-start>
|
||||||
|
<div class="sync-filters">
|
||||||
|
<el-input v-model.trim="form.skuCode" clearable placeholder="搜索 SKU" @keyup.enter="search" />
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<el-button v-if="canRetry && row.syncStatus === 'FAILED'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="retry(asRecord(row))"><RotateCw :size="15" />重新推送</el-button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<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" /></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" /></el-form-item>
|
||||||
|
<el-form-item label="宽(cm)"><el-input-number v-model="editForm.widthCm" :min="0.001" :controls="false" /></el-form-item>
|
||||||
|
<el-form-item label="高(cm)"><el-input-number v-model="editForm.heightCm" :min="0.001" :controls="false" /></el-form-item>
|
||||||
|
<el-form-item label="重量(kg)"><el-input-number v-model="editForm.weightKg" :min="0.001" :controls="false" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-alert type="info" :closable="false" title="原始墨家测量记录不可变,本次保存会生成新的人工修正版并进入同步队列。" />
|
||||||
|
</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%; }
|
||||||
|
@media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }
|
||||||
|
</style>
|
||||||
@@ -49,4 +49,7 @@ export const overseasWarehousePermissions = {
|
|||||||
productList: 'platform:overseas-warehouse:product:list',
|
productList: 'platform:overseas-warehouse:product:list',
|
||||||
productCreate: 'platform:overseas-warehouse:product:create',
|
productCreate: 'platform:overseas-warehouse:product:create',
|
||||||
productEdit: 'platform:overseas-warehouse:product:edit',
|
productEdit: 'platform:overseas-warehouse:product:edit',
|
||||||
|
mojiaSkuSyncList: 'platform:mojia-sku-sync:list',
|
||||||
|
mojiaSkuSyncEdit: 'platform:mojia-sku-sync:edit',
|
||||||
|
mojiaSkuSyncRetry: 'platform:mojia-sku-sync:retry',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -130,4 +130,10 @@ export const overseasWarehouseRoutes = [
|
|||||||
component: () => import('./pages/PalletDetailView.vue'),
|
component: () => import('./pages/PalletDetailView.vue'),
|
||||||
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
|
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'general/mojia-sku-sync',
|
||||||
|
name: 'mojia-sku-sync',
|
||||||
|
component: () => import('./pages/MojiaSkuSyncView.vue'),
|
||||||
|
meta: { title: '一件代发 SKU 同步', description: '查看墨家测量数据并同步领星 SKU 尺寸重量', permission: permission.mojiaSkuSyncList },
|
||||||
|
},
|
||||||
] satisfies RouteRecordRaw[]
|
] satisfies RouteRecordRaw[]
|
||||||
|
|||||||
Reference in New Issue
Block a user