重构:前端架构优化 - 消灭巨石文件,统一代码规范
Phase 1 - 消灭巨石页面: - 拆分 AdminKuaishouCloudFulfillmentView (1,224行→kuaishou-cloud/子目录) - 拆分 AdminFulfillmentBindingsView (989行→bindings/子目录) - 拆分 AdminTaskDetailView (1,007行→9个子组件+2个composable) - 合并去重 useClaimPage + useAdminManualRedeemPage (1,554行→共享模块+差异化薄层) - 拆分 services/admin/platform-config.ts (477行→8个领域子模块) Phase 2 - 架构收口: - 拆分 types/admin.ts (925行→13个子文件+platform-config/子目录) - 统一 API code 检查(http.ts拦截器统一处理业务错误) - 修改 apiPost 签名消除 as unknown as 类型断言(6处) - 新增 BusinessError 类型便于错误分类处理 所有改动通过 vue-tsc --noEmit 零错误和 vite build 验证
This commit is contained in:
@@ -1,989 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminFulfillmentBindingConfigs,
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
saveAdminFulfillmentBindingConfigs,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminFulfillmentLookupItem,
|
||||
AdminFulfillmentLookupResult,
|
||||
AdminObservedProductItem,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type EditableBinding = {
|
||||
id: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
resolvedSkuName: string
|
||||
}
|
||||
|
||||
type SaveBindingPayload = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
match: {
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
config: {
|
||||
resolvedSkuName: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId'
|
||||
|
||||
type ValidationState = {
|
||||
bindingId: string
|
||||
field: ValidationField
|
||||
message: string
|
||||
} | null
|
||||
|
||||
const PROFILE_OPTIONS = [
|
||||
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
|
||||
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
|
||||
{ label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' },
|
||||
{ label: '人工发货', value: 'manual_review' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const filePath = ref('')
|
||||
const bindings = ref<EditableBinding[]>([])
|
||||
const observedProducts = ref<AdminObservedProductItem[]>([])
|
||||
const lookupLoading = ref(false)
|
||||
const lookupErrorMessage = ref('')
|
||||
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
|
||||
const importedLookupLineIds = ref<string[]>([])
|
||||
const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
|
||||
const validationState = ref<ValidationState>(null)
|
||||
const collapsedBindingIds = ref<string[]>([])
|
||||
const lookupForm = reactive({
|
||||
shopId: '',
|
||||
platformOrderId: '',
|
||||
})
|
||||
|
||||
const ruleMetrics = computed(() => {
|
||||
const completedCount = bindings.value.filter(isEditableBindingComplete).length
|
||||
const manualCount = bindings.value.filter(
|
||||
(item) => item.profileKey.trim() === 'manual_review',
|
||||
).length
|
||||
const assistedCount = bindings.value.filter(
|
||||
(item) => item.profileKey.trim() === 'tencent_claim_assisted',
|
||||
).length
|
||||
const kuaishouCloudCount = bindings.value.filter(
|
||||
(item) => item.profileKey.trim() === 'kuaishou_ct_assisted',
|
||||
).length
|
||||
|
||||
return {
|
||||
totalCount: bindings.value.length,
|
||||
completedCount,
|
||||
draftCount: Math.max(bindings.value.length - completedCount, 0),
|
||||
manualCount,
|
||||
assistedCount,
|
||||
kuaishouCloudCount,
|
||||
}
|
||||
})
|
||||
|
||||
const pendingObservedCount = computed(
|
||||
() => observedProducts.value.filter((item) => !item.configured).length,
|
||||
)
|
||||
|
||||
type ImportableProductCandidate = Pick<
|
||||
AdminObservedProductItem,
|
||||
| 'provider'
|
||||
| 'platform'
|
||||
| 'shopId'
|
||||
| 'shopName'
|
||||
| 'externalSkuCode'
|
||||
| 'externalItemId'
|
||||
| 'externalSkuName'
|
||||
> & {}
|
||||
|
||||
function createEmptyBinding(): EditableBinding {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
provider: 'agiso',
|
||||
platform: '',
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
skuCode: '',
|
||||
skuName: '',
|
||||
profileKey: 'manual_review',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
externalSkuCode: '',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
resolvedSkuName: '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
shopId: item.shopId,
|
||||
shopName: item.shopName || '',
|
||||
skuCode: item.skuCode,
|
||||
skuName: item.skuName,
|
||||
profileKey: item.profileKey || 'manual_review',
|
||||
enabled: item.enabled !== false,
|
||||
priority: item.priority || 100,
|
||||
externalSkuCode: item.match.externalSkuCode,
|
||||
externalItemId: item.match.externalItemId,
|
||||
externalSkuName: item.match.externalSkuName,
|
||||
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
|
||||
}
|
||||
}
|
||||
|
||||
function hasMatchCondition(
|
||||
item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>,
|
||||
) {
|
||||
return Boolean(
|
||||
item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function isEditableBindingComplete(item: EditableBinding) {
|
||||
return Boolean(
|
||||
item.skuCode.trim() &&
|
||||
hasMatchCondition(item) &&
|
||||
VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'),
|
||||
)
|
||||
}
|
||||
|
||||
function buildCollapsedIds(items: EditableBinding[]) {
|
||||
return items.filter(isEditableBindingComplete).map((item) => item.id)
|
||||
}
|
||||
|
||||
function isBindingCollapsed(bindingId: string) {
|
||||
return collapsedBindingIds.value.includes(bindingId)
|
||||
}
|
||||
|
||||
function setBindingCollapsed(bindingId: string, collapsed: boolean) {
|
||||
const next = new Set(collapsedBindingIds.value)
|
||||
|
||||
if (collapsed) {
|
||||
next.add(bindingId)
|
||||
} else {
|
||||
next.delete(bindingId)
|
||||
}
|
||||
|
||||
collapsedBindingIds.value = Array.from(next)
|
||||
}
|
||||
|
||||
function toggleBindingCollapsed(bindingId: string) {
|
||||
setBindingCollapsed(bindingId, !isBindingCollapsed(bindingId))
|
||||
}
|
||||
|
||||
function getBindingTitle(binding: EditableBinding, index: number) {
|
||||
const internalName = binding.skuName.trim()
|
||||
const externalName = binding.externalSkuName.trim()
|
||||
const skuCode = binding.skuCode.trim()
|
||||
|
||||
return internalName || externalName || skuCode || `规则 ${index + 1}`
|
||||
}
|
||||
|
||||
function getBindingStatusLabel(binding: EditableBinding) {
|
||||
return isEditableBindingComplete(binding) ? '已完成' : '待完善'
|
||||
}
|
||||
|
||||
function getBindingSummary(binding: EditableBinding) {
|
||||
const parts = [
|
||||
binding.provider.trim() || 'agiso',
|
||||
binding.platform.trim() || '未选平台',
|
||||
binding.shopName.trim() || binding.shopId.trim() || '跨店铺',
|
||||
binding.profileKey.trim() || 'manual_review',
|
||||
]
|
||||
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminFulfillmentBindingConfigs()
|
||||
filePath.value = response.data.filePath
|
||||
const nextBindings = response.data.bindings.map(mapEditableBinding)
|
||||
bindings.value = nextBindings
|
||||
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
|
||||
observedProducts.value = response.data.observedProducts
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取履约配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addBinding() {
|
||||
validationState.value = null
|
||||
const next = createEmptyBinding()
|
||||
setBindingCollapsed(next.id, false)
|
||||
bindings.value.unshift(next)
|
||||
}
|
||||
|
||||
function removeBinding(id: string) {
|
||||
if (validationState.value?.bindingId === id) {
|
||||
validationState.value = null
|
||||
}
|
||||
setBindingCollapsed(id, false)
|
||||
bindings.value = bindings.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding {
|
||||
const binding = {
|
||||
id: crypto.randomUUID(),
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
shopId: item.shopId,
|
||||
shopName: item.shopName,
|
||||
skuCode: '',
|
||||
skuName: item.externalSkuName,
|
||||
profileKey: 'manual_review',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
externalSkuCode: item.externalSkuCode,
|
||||
externalItemId: item.externalItemId,
|
||||
externalSkuName: item.externalSkuName,
|
||||
resolvedSkuName: item.externalSkuName,
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
function importObservedProduct(item: ImportableProductCandidate) {
|
||||
validationState.value = null
|
||||
const nextBinding = createBindingFromProductCandidate(item)
|
||||
setBindingCollapsed(nextBinding.id, false)
|
||||
bindings.value.unshift(nextBinding)
|
||||
}
|
||||
|
||||
function importLookupProduct(item: AdminFulfillmentLookupItem) {
|
||||
importObservedProduct(item)
|
||||
if (!importedLookupLineIds.value.includes(item.lineId)) {
|
||||
importedLookupLineIds.value = [...importedLookupLineIds.value, item.lineId]
|
||||
}
|
||||
}
|
||||
|
||||
function isLookupProductImported(lineId: string) {
|
||||
return importedLookupLineIds.value.includes(lineId)
|
||||
}
|
||||
|
||||
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
|
||||
return {
|
||||
provider: item.provider.trim() || 'agiso',
|
||||
platform: item.platform.trim(),
|
||||
shopId: item.shopId.trim(),
|
||||
shopName: item.shopName.trim(),
|
||||
skuCode: item.skuCode.trim(),
|
||||
skuName: item.skuName.trim(),
|
||||
profileKey: item.profileKey.trim() || 'manual_review',
|
||||
enabled: item.enabled,
|
||||
priority: Number(item.priority || 100),
|
||||
match: {
|
||||
externalSkuCode: item.externalSkuCode.trim(),
|
||||
externalItemId: item.externalItemId.trim(),
|
||||
externalSkuName: item.externalSkuName.trim(),
|
||||
config: {
|
||||
resolvedSkuName: item.resolvedSkuName.trim(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hasBindingContent(item: SaveBindingPayload) {
|
||||
return Boolean(
|
||||
item.platform ||
|
||||
item.shopId ||
|
||||
item.shopName ||
|
||||
item.skuCode ||
|
||||
item.skuName ||
|
||||
item.match.externalSkuCode ||
|
||||
item.match.externalItemId ||
|
||||
item.match.externalSkuName ||
|
||||
item.match.config.resolvedSkuName ||
|
||||
item.provider !== 'agiso' ||
|
||||
item.profileKey !== 'manual_review' ||
|
||||
item.priority !== 100 ||
|
||||
item.enabled !== true,
|
||||
)
|
||||
}
|
||||
|
||||
function resolveBindingValidationState(
|
||||
item: SaveBindingPayload,
|
||||
index: number,
|
||||
bindingId: string,
|
||||
): ValidationState {
|
||||
const label = `第 ${index + 1} 条规则`
|
||||
|
||||
if (!item.skuCode) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'skuCode',
|
||||
message: `${label} 还没填写“内部履约 SKU”`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.match.externalSkuCode && !item.match.externalItemId && !item.match.externalSkuName) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'match',
|
||||
message: `${label} 至少填写一种外部匹配条件`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!VALID_PROFILE_KEYS.has(item.profileKey)) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'profileKey',
|
||||
message: `${label} 的履约方式无效`,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function focusValidationTarget(state: ValidationState) {
|
||||
if (!state?.bindingId) {
|
||||
return
|
||||
}
|
||||
|
||||
setBindingCollapsed(state.bindingId, false)
|
||||
await nextTick()
|
||||
|
||||
const card = document.querySelector<HTMLElement>(`[data-binding-id="${state.bindingId}"]`)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
const selectors: Record<ValidationField, string> = {
|
||||
shopId: '[data-field="shopId"]',
|
||||
skuCode: '[data-field="skuCode"]',
|
||||
match: '[data-field="externalSkuCode"]',
|
||||
profileKey: '[data-field="profileKey"]',
|
||||
}
|
||||
|
||||
const target = card.querySelector<HTMLInputElement | HTMLSelectElement>(selectors[state.field])
|
||||
target?.focus()
|
||||
}
|
||||
|
||||
function clearValidationState() {
|
||||
validationState.value = null
|
||||
}
|
||||
|
||||
function isBindingInvalid(bindingId: string) {
|
||||
return validationState.value?.bindingId === bindingId
|
||||
}
|
||||
|
||||
function isFieldInvalid(
|
||||
bindingId: string,
|
||||
field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName',
|
||||
) {
|
||||
if (validationState.value?.bindingId !== bindingId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (validationState.value.field === 'match') {
|
||||
return ['externalSkuCode', 'externalItemId', 'externalSkuName'].includes(field)
|
||||
}
|
||||
|
||||
return validationState.value.field === field
|
||||
}
|
||||
|
||||
async function saveConfigs() {
|
||||
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
|
||||
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
|
||||
const invalidState = normalizedBindings.reduce<ValidationState>((state, item, index) => {
|
||||
if (state || !hasBindingContent(item)) {
|
||||
return state
|
||||
}
|
||||
|
||||
return resolveBindingValidationState(item, index, bindings.value[index]?.id || '')
|
||||
}, null)
|
||||
|
||||
if (invalidState) {
|
||||
validationState.value = invalidState
|
||||
await focusValidationTarget(invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
if (bindings.value.length > 0 && nonEmptyBindings.length === 0) {
|
||||
validationState.value = {
|
||||
bindingId: '',
|
||||
field: 'skuCode',
|
||||
message: '当前没有可保存的规则,请至少填写内部履约 SKU 和外部匹配条件',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload = nonEmptyBindings
|
||||
|
||||
saving.value = true
|
||||
validationState.value = null
|
||||
|
||||
try {
|
||||
const response = await saveAdminFulfillmentBindingConfigs({ bindings: payload })
|
||||
filePath.value = response.data.filePath
|
||||
bindings.value = response.data.bindings.map(mapEditableBinding)
|
||||
showSuccess('履约配置已保存')
|
||||
await loadConfigs()
|
||||
} catch (error) {
|
||||
validationState.value = {
|
||||
bindingId: '',
|
||||
field: 'skuCode',
|
||||
message: error instanceof Error ? error.message : '保存履约配置失败',
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupOrderProducts() {
|
||||
const shopId = lookupForm.shopId.trim()
|
||||
const platformOrderId = lookupForm.platformOrderId.trim()
|
||||
|
||||
if (!shopId || !platformOrderId) {
|
||||
lookupErrorMessage.value = '请先填写店铺 ID 和平台订单号'
|
||||
return
|
||||
}
|
||||
|
||||
lookupLoading.value = true
|
||||
lookupErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await lookupAdminFulfillmentBindingOrder({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId,
|
||||
platformOrderId,
|
||||
})
|
||||
lookupResult.value = response.data
|
||||
importedLookupLineIds.value = []
|
||||
} catch (error) {
|
||||
lookupResult.value = null
|
||||
lookupErrorMessage.value = error instanceof Error ? error.message : '订单商品查询失败'
|
||||
} finally {
|
||||
lookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConfigs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护履约配置。</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="overview-card">
|
||||
<div class="overview-file">
|
||||
<span class="overview-label">配置文件</span>
|
||||
<code>{{ filePath || '-' }}</code>
|
||||
</div>
|
||||
|
||||
<div class="overview-stats">
|
||||
<article class="overview-stat">
|
||||
<span>规则总数</span>
|
||||
<strong>{{ ruleMetrics.totalCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>完整规则</span>
|
||||
<strong>{{ ruleMetrics.completedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待完善</span>
|
||||
<strong>{{ ruleMetrics.draftCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待补商品</span>
|
||||
<strong>{{ pendingObservedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已识别商品</span>
|
||||
<strong>{{ observedProducts.length }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-notes">
|
||||
<span class="overview-note">外部 SKU / 商品 ID / 商品名命中任一即可</span>
|
||||
<span class="overview-note">内部 SKU 决定库存绑定与履约链路</span>
|
||||
<span class="overview-note">同类商品跨平台尽量共用同一内部 SKU</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">履约配置加载中</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>已配置规则</h3>
|
||||
<p>正式规则集中维护在这里,新补查到的商品也会导入到这个区域继续补全。</p>
|
||||
</div>
|
||||
<div class="section-header-tools">
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">完整 {{ ruleMetrics.completedCount }}</span>
|
||||
<span class="mini-stat-chip">待完善 {{ ruleMetrics.draftCount }}</span>
|
||||
<span class="mini-stat-chip">人工 {{ ruleMetrics.manualCount }}</span>
|
||||
<span class="mini-stat-chip">半自动 {{ ruleMetrics.assistedCount }}</span>
|
||||
<span class="mini-stat-chip">快手 Cloud {{ ruleMetrics.kuaishouCloudCount }}</span>
|
||||
</div>
|
||||
<div class="section-action-group">
|
||||
<el-button round @click="addBinding">新增规则</el-button>
|
||||
<el-button round @click="loadConfigs">刷新</el-button>
|
||||
<el-button :loading="saving" round type="primary" @click="saveConfigs"
|
||||
>保存配置</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.message" class="validation-banner">
|
||||
{{ validationState.message }}
|
||||
</p>
|
||||
|
||||
<div v-if="bindings.length === 0" class="empty-inline">
|
||||
当前还没有履约规则,先新增一条。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(binding, index) in bindings"
|
||||
:key="binding.id"
|
||||
:data-binding-id="binding.id"
|
||||
:class="['binding-card', { 'binding-card--invalid': isBindingInvalid(binding.id) }]"
|
||||
>
|
||||
<div class="binding-header">
|
||||
<div class="binding-summary">
|
||||
<div class="binding-title-row">
|
||||
<strong>{{ getBindingTitle(binding, index) }}</strong>
|
||||
<span
|
||||
:class="[
|
||||
'binding-status-chip',
|
||||
{ 'binding-status-chip--done': isEditableBindingComplete(binding) },
|
||||
]"
|
||||
>
|
||||
{{ getBindingStatusLabel(binding) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="binding-subtle">{{ getBindingSummary(binding) }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="toggleBindingCollapsed(binding.id)">
|
||||
{{ isBindingCollapsed(binding.id) ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isBindingCollapsed(binding.id)" class="binding-collapsed-preview">
|
||||
<span
|
||||
>外部商品:{{
|
||||
binding.externalSkuName ||
|
||||
binding.externalSkuCode ||
|
||||
binding.externalItemId ||
|
||||
'-'
|
||||
}}</span
|
||||
>
|
||||
<span>内部履约 SKU:{{ binding.skuCode || '-' }}</span>
|
||||
<span
|
||||
>履约方式:{{
|
||||
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
|
||||
binding.profileKey
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else class="binding-grid">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input
|
||||
v-model="binding.provider"
|
||||
class="text-input"
|
||||
placeholder="agiso"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input
|
||||
v-model="binding.platform"
|
||||
class="text-input"
|
||||
placeholder="xianyu / taobao / pdd"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="binding.shopId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
|
||||
class="text-input"
|
||||
data-field="shopId"
|
||||
placeholder="留空表示跨店铺通用"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺名称</span>
|
||||
<el-input
|
||||
v-model="binding.shopName"
|
||||
class="text-input"
|
||||
placeholder="用于保存和识别店铺"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>优先级</span>
|
||||
<el-input-number
|
||||
v-model="binding.priority"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
@change="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部 SKU 编码</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuCode"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuCode"
|
||||
placeholder="例如 6046726460016"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部商品 ID</span>
|
||||
<el-input
|
||||
v-model="binding.externalItemId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') }]"
|
||||
class="text-input"
|
||||
data-field="externalItemId"
|
||||
placeholder="例如 1033324289962"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block field-wide">
|
||||
<span>外部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuName"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuName"
|
||||
placeholder="例如 奥利奥动作"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部履约 SKU</span>
|
||||
<el-input
|
||||
v-model="binding.skuCode"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') }]"
|
||||
class="text-input"
|
||||
data-field="skuCode"
|
||||
placeholder="例如 sjz_hdl_test01"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
<small class="field-help"
|
||||
>填你们系统内部统一 SKU。相同履约商品,尽量跨平台共用同一个内部 SKU。</small
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.skuName"
|
||||
class="text-input"
|
||||
placeholder="用于后台展示"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>履约方式</span>
|
||||
<el-select
|
||||
v-model="binding.profileKey"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') }]"
|
||||
class="text-input"
|
||||
data-field="profileKey"
|
||||
@change="clearValidationState"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in PROFILE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>匹配后展示名</span>
|
||||
<el-input
|
||||
v-model="binding.resolvedSkuName"
|
||||
class="text-input"
|
||||
placeholder="例如 三角洲行动-自动领取"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">
|
||||
{{ validationState?.message }}
|
||||
</p>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-checkbox v-model="binding.enabled">启用规则</el-checkbox>
|
||||
<el-button link type="danger" @click="removeBinding(binding.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>手动补查订单商品</h3>
|
||||
<p>新商品还没配规则时,可以按店铺 ID 和平台订单号临时补查,查完直接导入规则草稿。</p>
|
||||
</div>
|
||||
<span class="section-note-chip">仅支持 Agiso 咸鱼订单</span>
|
||||
</div>
|
||||
|
||||
<div class="lookup-toolbar">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input class="text-input" model-value="agiso" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input class="text-input" model-value="xianyu" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="lookupForm.shopId"
|
||||
class="text-input"
|
||||
placeholder="例如 252609"
|
||||
@keydown.enter.prevent="lookupOrderProducts"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台订单号</span>
|
||||
<el-input
|
||||
v-model="lookupForm.platformOrderId"
|
||||
class="text-input"
|
||||
placeholder="输入需要补查的订单号"
|
||||
@keydown.enter.prevent="lookupOrderProducts"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="field-block field-block--action">
|
||||
<span>操作</span>
|
||||
<el-button :loading="lookupLoading" round type="primary" @click="lookupOrderProducts"
|
||||
>查询订单商品</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="toolbar-hint">查询结果只用于补配置,不会写入订单库,也不会影响现有订单数据。</p>
|
||||
|
||||
<p v-if="lookupErrorMessage" class="error-copy lookup-error">{{ lookupErrorMessage }}</p>
|
||||
<div v-else-if="lookupLoading" class="empty-inline">正在查询订单详情…</div>
|
||||
<template v-else-if="lookupResult">
|
||||
<div class="lookup-summary">
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">订单</span>
|
||||
<strong>{{ lookupResult.order.platformOrderId }}</strong>
|
||||
<span class="cell-subtle">{{
|
||||
lookupResult.order.shopName || lookupResult.order.shopId || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">买家</span>
|
||||
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>支付时间:{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">金额</span>
|
||||
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>补查结果:{{
|
||||
lookupResult.order.enriched
|
||||
? '已补全'
|
||||
: lookupResult.order.enrichReason || '已返回'
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="lookupResult.items"
|
||||
class="data-table element-data-table"
|
||||
empty-text="暂无订单商品"
|
||||
>
|
||||
<el-table-column label="外部商品" min-width="260">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{
|
||||
item.externalSkuName ||
|
||||
item.itemTitle ||
|
||||
item.externalSkuCode ||
|
||||
item.externalItemId ||
|
||||
'-'
|
||||
}}</strong>
|
||||
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
|
||||
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="90" />
|
||||
<el-table-column label="识别结果" min-width="220">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.configured ? '已配置' : '未配置' }}</strong>
|
||||
<span v-if="item.matchedBinding" class="cell-subtle">
|
||||
{{ item.matchedBinding.skuName || item.matchedBinding.skuCode || '-' }} /
|
||||
{{ item.matchedBinding.profileKey || '-' }}
|
||||
</span>
|
||||
<span v-else class="cell-subtle">可直接导入成新规则草稿</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row: item }">
|
||||
<el-button
|
||||
v-if="!item.configured && !isLookupProductImported(item.lineId)"
|
||||
link
|
||||
type="primary"
|
||||
@click="importLookupProduct(item)"
|
||||
>
|
||||
导入到规则
|
||||
</el-button>
|
||||
<span v-else-if="item.configured" class="cell-subtle">已存在</span>
|
||||
<span v-else class="cell-subtle">已导入草稿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>近期识别到的外部商品</h3>
|
||||
<p>从已入库订单里提取的外部商品信息,优先处理未配置商品,导入后再补齐内部 SKU。</p>
|
||||
</div>
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">待补 {{ pendingObservedCount }}</span>
|
||||
<span class="mini-stat-chip">总计 {{ observedProducts.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="observedProducts"
|
||||
class="data-table element-data-table"
|
||||
empty-text="最近还没有识别到可用的外部商品。"
|
||||
>
|
||||
<el-table-column label="来源" min-width="180">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.platform || '-' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="外部商品" min-width="280">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{
|
||||
item.externalSkuName || item.externalSkuCode || item.externalItemId || '-'
|
||||
}}</strong>
|
||||
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
|
||||
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orderItemCount" label="订单商品数" width="120" />
|
||||
<el-table-column label="最近出现时间" width="180">
|
||||
<template #default="{ row: item }">
|
||||
{{ formatAdminDateTime(item.latestSeenAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row: item }">
|
||||
{{ item.configured ? '已配置' : '未配置' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row: item }">
|
||||
<el-button
|
||||
v-if="!item.configured"
|
||||
link
|
||||
type="primary"
|
||||
@click="importObservedProduct(item)"
|
||||
>
|
||||
导入到规则
|
||||
</el-button>
|
||||
<span v-else class="cell-subtle">已存在</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminFulfillmentBindings.css"></style>
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AdminFulfillmentBindingsView from './AdminFulfillmentBindingsView.vue'
|
||||
import AdminKuaishouCloudFulfillmentView from './AdminKuaishouCloudFulfillmentView.vue'
|
||||
import AdminFulfillmentBindingsView from './bindings/AdminFulfillmentBindingsView.vue'
|
||||
import AdminKuaishouCloudFulfillmentView from './kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue'
|
||||
|
||||
type FulfillmentTabKey = 'legacy' | 'kuaishou-cloud'
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/admin-config-pages.css';
|
||||
@import '../../../../styles/admin-config-pages.css';
|
||||
|
||||
/* ============================================================
|
||||
AdminFulfillmentBindings — 绿色主题覆盖 & 页面特有样式
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import AdminBindingsLookupSection from './components/AdminBindingsLookupSection.vue'
|
||||
import AdminBindingsObservedSection from './components/AdminBindingsObservedSection.vue'
|
||||
import AdminBindingsOverviewSection from './components/AdminBindingsOverviewSection.vue'
|
||||
import AdminBindingsRuleSection from './components/AdminBindingsRuleSection.vue'
|
||||
import { useFulfillmentBindings } from './composables/useFulfillmentBindings'
|
||||
import { useFulfillmentBindingsLookup } from './composables/useFulfillmentBindingsLookup'
|
||||
|
||||
const bindings = useFulfillmentBindings()
|
||||
const lookup = useFulfillmentBindingsLookup(bindings)
|
||||
|
||||
onMounted(bindings.loadConfigs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护履约配置。</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminBindingsOverviewSection
|
||||
:file-path="bindings.filePath.value"
|
||||
:rule-metrics="bindings.ruleMetrics.value"
|
||||
:pending-observed-count="bindings.pendingObservedCount.value"
|
||||
:observed-products-length="bindings.observedProducts.value.length"
|
||||
/>
|
||||
|
||||
<p v-if="bindings.errorMessage.value" class="error-copy">{{ bindings.errorMessage.value }}</p>
|
||||
<div v-if="bindings.loading.value" class="empty-block">履约配置加载中</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminBindingsRuleSection
|
||||
:saving="bindings.saving.value"
|
||||
:validation-state="bindings.validationState.value"
|
||||
:bindings="bindings.bindings.value"
|
||||
:rule-metrics="bindings.ruleMetrics.value"
|
||||
:is-binding-collapsed="bindings.isBindingCollapsed"
|
||||
:is-editable-binding-complete="bindings.isEditableBindingComplete"
|
||||
:get-binding-title="bindings.getBindingTitle"
|
||||
:get-binding-status-label="bindings.getBindingStatusLabel"
|
||||
:get-binding-summary="bindings.getBindingSummary"
|
||||
:is-binding-invalid="bindings.isBindingInvalid"
|
||||
:is-field-invalid="bindings.isFieldInvalid"
|
||||
@add-binding="bindings.addBinding()"
|
||||
@load-configs="bindings.loadConfigs()"
|
||||
@save-configs="bindings.saveConfigs()"
|
||||
@toggle-collapsed="bindings.toggleBindingCollapsed($event)"
|
||||
@clear-validation="bindings.clearValidationState()"
|
||||
@remove-binding="bindings.removeBinding($event)"
|
||||
/>
|
||||
|
||||
<AdminBindingsLookupSection
|
||||
:lookup-loading="lookup.lookupLoading.value"
|
||||
:lookup-error-message="lookup.lookupErrorMessage.value"
|
||||
:lookup-result="lookup.lookupResult.value"
|
||||
:lookup-form="lookup.lookupForm"
|
||||
:is-lookup-product-imported="lookup.isLookupProductImported"
|
||||
@lookup="lookup.lookupOrderProducts()"
|
||||
@import-product="lookup.importLookupProduct($event)"
|
||||
/>
|
||||
|
||||
<AdminBindingsObservedSection
|
||||
:observed-products="bindings.observedProducts.value"
|
||||
:pending-observed-count="bindings.pendingObservedCount.value"
|
||||
@import-product="bindings.importObservedProduct($event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminFulfillmentBindings.css"></style>
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { PROFILE_OPTIONS } from '../composables/types'
|
||||
import type { EditableBinding, ValidationField, ValidationState } from '../composables/types'
|
||||
|
||||
defineProps<{
|
||||
binding: EditableBinding
|
||||
index: number
|
||||
validationState: ValidationState
|
||||
isBindingCollapsed: boolean
|
||||
isComplete: boolean
|
||||
title: string
|
||||
statusLabel: string
|
||||
summary: string
|
||||
isInvalid: boolean
|
||||
isFieldInvalid: (field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName') => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleCollapsed: []
|
||||
clearValidation: []
|
||||
remove: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-binding-id="binding.id"
|
||||
:class="['binding-card', { 'binding-card--invalid': isInvalid }]"
|
||||
>
|
||||
<div class="binding-header">
|
||||
<div class="binding-summary">
|
||||
<div class="binding-title-row">
|
||||
<strong>{{ title }}</strong>
|
||||
<span
|
||||
:class="[
|
||||
'binding-status-chip',
|
||||
{ 'binding-status-chip--done': isComplete },
|
||||
]"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="binding-subtle">{{ summary }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="emit('toggleCollapsed')">
|
||||
{{ isBindingCollapsed ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isBindingCollapsed" class="binding-collapsed-preview">
|
||||
<span
|
||||
>外部商品:{{
|
||||
binding.externalSkuName ||
|
||||
binding.externalSkuCode ||
|
||||
binding.externalItemId ||
|
||||
'-'
|
||||
}}</span
|
||||
>
|
||||
<span>内部履约 SKU:{{ binding.skuCode || '-' }}</span>
|
||||
<span
|
||||
>履约方式:{{
|
||||
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
|
||||
binding.profileKey
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else class="binding-grid">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input
|
||||
v-model="binding.provider"
|
||||
class="text-input"
|
||||
placeholder="agiso"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input
|
||||
v-model="binding.platform"
|
||||
class="text-input"
|
||||
placeholder="xianyu / taobao / pdd"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="binding.shopId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('shopId') }]"
|
||||
class="text-input"
|
||||
data-field="shopId"
|
||||
placeholder="留空表示跨店铺通用"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺名称</span>
|
||||
<el-input
|
||||
v-model="binding.shopName"
|
||||
class="text-input"
|
||||
placeholder="用于保存和识别店铺"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>优先级</span>
|
||||
<el-input-number
|
||||
v-model="binding.priority"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
@change="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部 SKU 编码</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuCode"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid('externalSkuCode') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuCode"
|
||||
placeholder="例如 6046726460016"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部商品 ID</span>
|
||||
<el-input
|
||||
v-model="binding.externalItemId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('externalItemId') }]"
|
||||
class="text-input"
|
||||
data-field="externalItemId"
|
||||
placeholder="例如 1033324289962"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block field-wide">
|
||||
<span>外部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuName"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid('externalSkuName') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuName"
|
||||
placeholder="例如 奥利奥动作"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部履约 SKU</span>
|
||||
<el-input
|
||||
v-model="binding.skuCode"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('skuCode') }]"
|
||||
class="text-input"
|
||||
data-field="skuCode"
|
||||
placeholder="例如 sjz_hdl_test01"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
<small class="field-help"
|
||||
>填你们系统内部统一 SKU。相同履约商品,尽量跨平台共用同一个内部 SKU。</small
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.skuName"
|
||||
class="text-input"
|
||||
placeholder="用于后台展示"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>履约方式</span>
|
||||
<el-select
|
||||
v-model="binding.profileKey"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('profileKey') }]"
|
||||
class="text-input"
|
||||
data-field="profileKey"
|
||||
@change="emit('clearValidation')"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in PROFILE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>匹配后展示名</span>
|
||||
<el-input
|
||||
v-model="binding.resolvedSkuName"
|
||||
class="text-input"
|
||||
placeholder="例如 三角洲行动-自动领取"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="isInvalid" class="binding-inline-error">
|
||||
{{ validationState?.message }}
|
||||
</p>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-checkbox v-model="binding.enabled">启用规则</el-checkbox>
|
||||
<el-button link type="danger" @click="emit('remove')">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
defineProps<{
|
||||
lookupLoading: boolean
|
||||
lookupErrorMessage: string
|
||||
lookupResult: AdminFulfillmentLookupResult | null
|
||||
lookupForm: {
|
||||
shopId: string
|
||||
platformOrderId: string
|
||||
}
|
||||
isLookupProductImported: (lineId: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
lookup: []
|
||||
importProduct: [item: AdminFulfillmentLookupItem]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>手动补查订单商品</h3>
|
||||
<p>新商品还没配规则时,可以按店铺 ID 和平台订单号临时补查,查完直接导入规则草稿。</p>
|
||||
</div>
|
||||
<span class="section-note-chip">仅支持 Agiso 咸鱼订单</span>
|
||||
</div>
|
||||
|
||||
<div class="lookup-toolbar">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input class="text-input" model-value="agiso" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input class="text-input" model-value="xianyu" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="lookupForm.shopId"
|
||||
class="text-input"
|
||||
placeholder="例如 252609"
|
||||
@keydown.enter.prevent="emit('lookup')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台订单号</span>
|
||||
<el-input
|
||||
v-model="lookupForm.platformOrderId"
|
||||
class="text-input"
|
||||
placeholder="输入需要补查的订单号"
|
||||
@keydown.enter.prevent="emit('lookup')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="field-block field-block--action">
|
||||
<span>操作</span>
|
||||
<el-button :loading="lookupLoading" round type="primary" @click="emit('lookup')"
|
||||
>查询订单商品</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="toolbar-hint">查询结果只用于补配置,不会写入订单库,也不会影响现有订单数据。</p>
|
||||
|
||||
<p v-if="lookupErrorMessage" class="error-copy lookup-error">{{ lookupErrorMessage }}</p>
|
||||
<div v-else-if="lookupLoading" class="empty-inline">正在查询订单详情…</div>
|
||||
<template v-else-if="lookupResult">
|
||||
<div class="lookup-summary">
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">订单</span>
|
||||
<strong>{{ lookupResult.order.platformOrderId }}</strong>
|
||||
<span class="cell-subtle">{{
|
||||
lookupResult.order.shopName || lookupResult.order.shopId || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">买家</span>
|
||||
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>支付时间:{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">金额</span>
|
||||
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>补查结果:{{
|
||||
lookupResult.order.enriched
|
||||
? '已补全'
|
||||
: lookupResult.order.enrichReason || '已返回'
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="lookupResult.items"
|
||||
class="data-table element-data-table"
|
||||
empty-text="暂无订单商品"
|
||||
>
|
||||
<el-table-column label="外部商品" min-width="260">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{
|
||||
item.externalSkuName ||
|
||||
item.itemTitle ||
|
||||
item.externalSkuCode ||
|
||||
item.externalItemId ||
|
||||
'-'
|
||||
}}</strong>
|
||||
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
|
||||
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="90" />
|
||||
<el-table-column label="识别结果" min-width="220">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.configured ? '已配置' : '未配置' }}</strong>
|
||||
<span v-if="item.matchedBinding" class="cell-subtle">
|
||||
{{ item.matchedBinding.skuName || item.matchedBinding.skuCode || '-' }} /
|
||||
{{ item.matchedBinding.profileKey || '-' }}
|
||||
</span>
|
||||
<span v-else class="cell-subtle">可直接导入成新规则草稿</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row: item }">
|
||||
<el-button
|
||||
v-if="!item.configured && !isLookupProductImported(item.lineId)"
|
||||
link
|
||||
type="primary"
|
||||
@click="emit('importProduct', item)"
|
||||
>
|
||||
导入到规则
|
||||
</el-button>
|
||||
<span v-else-if="item.configured" class="cell-subtle">已存在</span>
|
||||
<span v-else class="cell-subtle">已导入草稿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminObservedProductItem } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { ImportableProductCandidate } from '../composables/types'
|
||||
|
||||
defineProps<{
|
||||
observedProducts: AdminObservedProductItem[]
|
||||
pendingObservedCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
importProduct: [item: ImportableProductCandidate]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>近期识别到的外部商品</h3>
|
||||
<p>从已入库订单里提取的外部商品信息,优先处理未配置商品,导入后再补齐内部 SKU。</p>
|
||||
</div>
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">待补 {{ pendingObservedCount }}</span>
|
||||
<span class="mini-stat-chip">总计 {{ observedProducts.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="observedProducts"
|
||||
class="data-table element-data-table"
|
||||
empty-text="最近还没有识别到可用的外部商品。"
|
||||
>
|
||||
<el-table-column label="来源" min-width="180">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.platform || '-' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="外部商品" min-width="280">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{
|
||||
item.externalSkuName || item.externalSkuCode || item.externalItemId || '-'
|
||||
}}</strong>
|
||||
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
|
||||
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orderItemCount" label="订单商品数" width="120" />
|
||||
<el-table-column label="最近出现时间" width="180">
|
||||
<template #default="{ row: item }">
|
||||
{{ formatAdminDateTime(item.latestSeenAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row: item }">
|
||||
{{ item.configured ? '已配置' : '未配置' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row: item }">
|
||||
<el-button
|
||||
v-if="!item.configured"
|
||||
link
|
||||
type="primary"
|
||||
@click="emit('importProduct', item)"
|
||||
>
|
||||
导入到规则
|
||||
</el-button>
|
||||
<span v-else class="cell-subtle">已存在</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
filePath: string
|
||||
ruleMetrics: {
|
||||
totalCount: number
|
||||
completedCount: number
|
||||
draftCount: number
|
||||
manualCount: number
|
||||
assistedCount: number
|
||||
kuaishouCloudCount: number
|
||||
}
|
||||
pendingObservedCount: number
|
||||
observedProductsLength: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-card">
|
||||
<div class="overview-file">
|
||||
<span class="overview-label">配置文件</span>
|
||||
<code>{{ filePath || '-' }}</code>
|
||||
</div>
|
||||
|
||||
<div class="overview-stats">
|
||||
<article class="overview-stat">
|
||||
<span>规则总数</span>
|
||||
<strong>{{ ruleMetrics.totalCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>完整规则</span>
|
||||
<strong>{{ ruleMetrics.completedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待完善</span>
|
||||
<strong>{{ ruleMetrics.draftCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待补商品</span>
|
||||
<strong>{{ pendingObservedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已识别商品</span>
|
||||
<strong>{{ observedProductsLength }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-notes">
|
||||
<span class="overview-note">外部 SKU / 商品 ID / 商品名命中任一即可</span>
|
||||
<span class="overview-note">内部 SKU 决定库存绑定与履约链路</span>
|
||||
<span class="overview-note">同类商品跨平台尽量共用同一内部 SKU</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditableBinding, ValidationField, ValidationState } from '../composables/types'
|
||||
|
||||
import AdminBindingRuleCard from './AdminBindingRuleCard.vue'
|
||||
|
||||
defineProps<{
|
||||
saving: boolean
|
||||
validationState: ValidationState
|
||||
bindings: EditableBinding[]
|
||||
ruleMetrics: {
|
||||
completedCount: number
|
||||
draftCount: number
|
||||
manualCount: number
|
||||
assistedCount: number
|
||||
kuaishouCloudCount: number
|
||||
}
|
||||
isBindingCollapsed: (bindingId: string) => boolean
|
||||
isEditableBindingComplete: (binding: EditableBinding) => boolean
|
||||
getBindingTitle: (binding: EditableBinding, index: number) => string
|
||||
getBindingStatusLabel: (binding: EditableBinding) => string
|
||||
getBindingSummary: (binding: EditableBinding) => string
|
||||
isBindingInvalid: (bindingId: string) => boolean
|
||||
isFieldInvalid: (bindingId: string, field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName') => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
addBinding: []
|
||||
loadConfigs: []
|
||||
saveConfigs: []
|
||||
toggleCollapsed: [bindingId: string]
|
||||
clearValidation: []
|
||||
removeBinding: [bindingId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>已配置规则</h3>
|
||||
<p>正式规则集中维护在这里,新补查到的商品也会导入到这个区域继续补全。</p>
|
||||
</div>
|
||||
<div class="section-header-tools">
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">完整 {{ ruleMetrics.completedCount }}</span>
|
||||
<span class="mini-stat-chip">待完善 {{ ruleMetrics.draftCount }}</span>
|
||||
<span class="mini-stat-chip">人工 {{ ruleMetrics.manualCount }}</span>
|
||||
<span class="mini-stat-chip">半自动 {{ ruleMetrics.assistedCount }}</span>
|
||||
<span class="mini-stat-chip">快手 Cloud {{ ruleMetrics.kuaishouCloudCount }}</span>
|
||||
</div>
|
||||
<div class="section-action-group">
|
||||
<el-button round @click="emit('addBinding')">新增规则</el-button>
|
||||
<el-button round @click="emit('loadConfigs')">刷新</el-button>
|
||||
<el-button :loading="saving" round type="primary" @click="emit('saveConfigs')"
|
||||
>保存配置</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.message" class="validation-banner">
|
||||
{{ validationState.message }}
|
||||
</p>
|
||||
|
||||
<div v-if="bindings.length === 0" class="empty-inline">
|
||||
当前还没有履约规则,先新增一条。
|
||||
</div>
|
||||
|
||||
<AdminBindingRuleCard
|
||||
v-for="(binding, index) in bindings"
|
||||
:key="binding.id"
|
||||
:binding="binding"
|
||||
:index="index"
|
||||
:validation-state="validationState"
|
||||
:is-binding-collapsed="isBindingCollapsed(binding.id)"
|
||||
:is-complete="isEditableBindingComplete(binding)"
|
||||
:title="getBindingTitle(binding, index)"
|
||||
:status-label="getBindingStatusLabel(binding)"
|
||||
:summary="getBindingSummary(binding)"
|
||||
:is-invalid="isBindingInvalid(binding.id)"
|
||||
:is-field-invalid="(field) => isFieldInvalid(binding.id, field)"
|
||||
@toggle-collapsed="emit('toggleCollapsed', binding.id)"
|
||||
@clear-validation="emit('clearValidation')"
|
||||
@remove="emit('removeBinding', binding.id)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AdminFulfillmentBindingConfigItem, AdminObservedProductItem } from '@/types/admin'
|
||||
|
||||
export type EditableBinding = {
|
||||
id: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
resolvedSkuName: string
|
||||
}
|
||||
|
||||
export type SaveBindingPayload = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
match: {
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
config: {
|
||||
resolvedSkuName: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId'
|
||||
|
||||
export type ValidationState = {
|
||||
bindingId: string
|
||||
field: ValidationField
|
||||
message: string
|
||||
} | null
|
||||
|
||||
export type ImportableProductCandidate = Pick<
|
||||
AdminObservedProductItem,
|
||||
| 'provider'
|
||||
| 'platform'
|
||||
| 'shopId'
|
||||
| 'shopName'
|
||||
| 'externalSkuCode'
|
||||
| 'externalItemId'
|
||||
| 'externalSkuName'
|
||||
> & {}
|
||||
|
||||
export const PROFILE_OPTIONS = [
|
||||
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
|
||||
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
|
||||
{ label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' },
|
||||
{ label: '人工发货', value: 'manual_review' },
|
||||
]
|
||||
|
||||
export const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminFulfillmentBindingConfigs,
|
||||
saveAdminFulfillmentBindingConfigs,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminObservedProductItem,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import {
|
||||
VALID_PROFILE_KEYS,
|
||||
type EditableBinding,
|
||||
type ImportableProductCandidate,
|
||||
type SaveBindingPayload,
|
||||
type ValidationField,
|
||||
type ValidationState,
|
||||
} from './types'
|
||||
|
||||
export function useFulfillmentBindings() {
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const filePath = ref('')
|
||||
const bindings = ref<EditableBinding[]>([])
|
||||
const observedProducts = ref<AdminObservedProductItem[]>([])
|
||||
const validationState = ref<ValidationState>(null)
|
||||
const collapsedBindingIds = ref<string[]>([])
|
||||
|
||||
// ── computed ──────────────────────────────────────────
|
||||
|
||||
const ruleMetrics = computed(() => {
|
||||
const completedCount = bindings.value.filter(isEditableBindingComplete).length
|
||||
const manualCount = bindings.value.filter(
|
||||
(item) => item.profileKey.trim() === 'manual_review',
|
||||
).length
|
||||
const assistedCount = bindings.value.filter(
|
||||
(item) => item.profileKey.trim() === 'tencent_claim_assisted',
|
||||
).length
|
||||
const kuaishouCloudCount = bindings.value.filter(
|
||||
(item) => item.profileKey.trim() === 'kuaishou_ct_assisted',
|
||||
).length
|
||||
|
||||
return {
|
||||
totalCount: bindings.value.length,
|
||||
completedCount,
|
||||
draftCount: Math.max(bindings.value.length - completedCount, 0),
|
||||
manualCount,
|
||||
assistedCount,
|
||||
kuaishouCloudCount,
|
||||
}
|
||||
})
|
||||
|
||||
const pendingObservedCount = computed(
|
||||
() => observedProducts.value.filter((item) => !item.configured).length,
|
||||
)
|
||||
|
||||
// ── factories ────────────────────────────────────────
|
||||
|
||||
function createEmptyBinding(): EditableBinding {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
provider: 'agiso',
|
||||
platform: '',
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
skuCode: '',
|
||||
skuName: '',
|
||||
profileKey: 'manual_review',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
externalSkuCode: '',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
resolvedSkuName: '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
shopId: item.shopId,
|
||||
shopName: item.shopName || '',
|
||||
skuCode: item.skuCode,
|
||||
skuName: item.skuName,
|
||||
profileKey: item.profileKey || 'manual_review',
|
||||
enabled: item.enabled !== false,
|
||||
priority: item.priority || 100,
|
||||
externalSkuCode: item.match.externalSkuCode,
|
||||
externalItemId: item.match.externalItemId,
|
||||
externalSkuName: item.match.externalSkuName,
|
||||
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
|
||||
}
|
||||
}
|
||||
|
||||
// ── validation ────────────────────────────────────────
|
||||
|
||||
function hasMatchCondition(
|
||||
item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>,
|
||||
) {
|
||||
return Boolean(
|
||||
item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function isEditableBindingComplete(item: EditableBinding) {
|
||||
return Boolean(
|
||||
item.skuCode.trim() &&
|
||||
hasMatchCondition(item) &&
|
||||
VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'),
|
||||
)
|
||||
}
|
||||
|
||||
// ── collapse state ────────────────────────────────────
|
||||
|
||||
function buildCollapsedIds(items: EditableBinding[]) {
|
||||
return items.filter(isEditableBindingComplete).map((item) => item.id)
|
||||
}
|
||||
|
||||
function isBindingCollapsed(bindingId: string) {
|
||||
return collapsedBindingIds.value.includes(bindingId)
|
||||
}
|
||||
|
||||
function setBindingCollapsed(bindingId: string, collapsed: boolean) {
|
||||
const next = new Set(collapsedBindingIds.value)
|
||||
|
||||
if (collapsed) {
|
||||
next.add(bindingId)
|
||||
} else {
|
||||
next.delete(bindingId)
|
||||
}
|
||||
|
||||
collapsedBindingIds.value = Array.from(next)
|
||||
}
|
||||
|
||||
function toggleBindingCollapsed(bindingId: string) {
|
||||
setBindingCollapsed(bindingId, !isBindingCollapsed(bindingId))
|
||||
}
|
||||
|
||||
// ── display helpers ───────────────────────────────────
|
||||
|
||||
function getBindingTitle(binding: EditableBinding, index: number) {
|
||||
const internalName = binding.skuName.trim()
|
||||
const externalName = binding.externalSkuName.trim()
|
||||
const skuCode = binding.skuCode.trim()
|
||||
|
||||
return internalName || externalName || skuCode || `规则 ${index + 1}`
|
||||
}
|
||||
|
||||
function getBindingStatusLabel(binding: EditableBinding) {
|
||||
return isEditableBindingComplete(binding) ? '已完成' : '待完善'
|
||||
}
|
||||
|
||||
function getBindingSummary(binding: EditableBinding) {
|
||||
const parts = [
|
||||
binding.provider.trim() || 'agiso',
|
||||
binding.platform.trim() || '未选平台',
|
||||
binding.shopName.trim() || binding.shopId.trim() || '跨店铺',
|
||||
binding.profileKey.trim() || 'manual_review',
|
||||
]
|
||||
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
// ── data loading ──────────────────────────────────────
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminFulfillmentBindingConfigs()
|
||||
filePath.value = response.data.filePath
|
||||
const nextBindings = response.data.bindings.map(mapEditableBinding)
|
||||
bindings.value = nextBindings
|
||||
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
|
||||
observedProducts.value = response.data.observedProducts
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取履约配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── mutation ──────────────────────────────────────────
|
||||
|
||||
function addBinding() {
|
||||
validationState.value = null
|
||||
const next = createEmptyBinding()
|
||||
setBindingCollapsed(next.id, false)
|
||||
bindings.value.unshift(next)
|
||||
}
|
||||
|
||||
function removeBinding(id: string) {
|
||||
if (validationState.value?.bindingId === id) {
|
||||
validationState.value = null
|
||||
}
|
||||
setBindingCollapsed(id, false)
|
||||
bindings.value = bindings.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
// ── import from observed products ────────────────────
|
||||
|
||||
function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding {
|
||||
const binding = {
|
||||
id: crypto.randomUUID(),
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
shopId: item.shopId,
|
||||
shopName: item.shopName,
|
||||
skuCode: '',
|
||||
skuName: item.externalSkuName,
|
||||
profileKey: 'manual_review',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
externalSkuCode: item.externalSkuCode,
|
||||
externalItemId: item.externalItemId,
|
||||
externalSkuName: item.externalSkuName,
|
||||
resolvedSkuName: item.externalSkuName,
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
function importObservedProduct(item: ImportableProductCandidate) {
|
||||
validationState.value = null
|
||||
const nextBinding = createBindingFromProductCandidate(item)
|
||||
setBindingCollapsed(nextBinding.id, false)
|
||||
bindings.value.unshift(nextBinding)
|
||||
}
|
||||
|
||||
// ── save ──────────────────────────────────────────────
|
||||
|
||||
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
|
||||
return {
|
||||
provider: item.provider.trim() || 'agiso',
|
||||
platform: item.platform.trim(),
|
||||
shopId: item.shopId.trim(),
|
||||
shopName: item.shopName.trim(),
|
||||
skuCode: item.skuCode.trim(),
|
||||
skuName: item.skuName.trim(),
|
||||
profileKey: item.profileKey.trim() || 'manual_review',
|
||||
enabled: item.enabled,
|
||||
priority: Number(item.priority || 100),
|
||||
match: {
|
||||
externalSkuCode: item.externalSkuCode.trim(),
|
||||
externalItemId: item.externalItemId.trim(),
|
||||
externalSkuName: item.externalSkuName.trim(),
|
||||
config: {
|
||||
resolvedSkuName: item.resolvedSkuName.trim(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hasBindingContent(item: SaveBindingPayload) {
|
||||
return Boolean(
|
||||
item.platform ||
|
||||
item.shopId ||
|
||||
item.shopName ||
|
||||
item.skuCode ||
|
||||
item.skuName ||
|
||||
item.match.externalSkuCode ||
|
||||
item.match.externalItemId ||
|
||||
item.match.externalSkuName ||
|
||||
item.match.config.resolvedSkuName ||
|
||||
item.provider !== 'agiso' ||
|
||||
item.profileKey !== 'manual_review' ||
|
||||
item.priority !== 100 ||
|
||||
item.enabled !== true,
|
||||
)
|
||||
}
|
||||
|
||||
function resolveBindingValidationState(
|
||||
item: SaveBindingPayload,
|
||||
index: number,
|
||||
bindingId: string,
|
||||
): ValidationState {
|
||||
const label = `第 ${index + 1} 条规则`
|
||||
|
||||
if (!item.skuCode) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'skuCode',
|
||||
message: `${label} 还没填写"内部履约 SKU"`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.match.externalSkuCode && !item.match.externalItemId && !item.match.externalSkuName) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'match',
|
||||
message: `${label} 至少填写一种外部匹配条件`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!VALID_PROFILE_KEYS.has(item.profileKey)) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'profileKey',
|
||||
message: `${label} 的履约方式无效`,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function focusValidationTarget(state: ValidationState) {
|
||||
if (!state?.bindingId) {
|
||||
return
|
||||
}
|
||||
|
||||
setBindingCollapsed(state.bindingId, false)
|
||||
await nextTick()
|
||||
|
||||
const card = document.querySelector<HTMLElement>(`[data-binding-id="${state.bindingId}"]`)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
const selectors: Record<ValidationField, string> = {
|
||||
shopId: '[data-field="shopId"]',
|
||||
skuCode: '[data-field="skuCode"]',
|
||||
match: '[data-field="externalSkuCode"]',
|
||||
profileKey: '[data-field="profileKey"]',
|
||||
}
|
||||
|
||||
const target = card.querySelector<HTMLInputElement | HTMLSelectElement>(selectors[state.field])
|
||||
target?.focus()
|
||||
}
|
||||
|
||||
function clearValidationState() {
|
||||
validationState.value = null
|
||||
}
|
||||
|
||||
function isBindingInvalid(bindingId: string) {
|
||||
return validationState.value?.bindingId === bindingId
|
||||
}
|
||||
|
||||
function isFieldInvalid(
|
||||
bindingId: string,
|
||||
field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName',
|
||||
) {
|
||||
if (validationState.value?.bindingId !== bindingId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (validationState.value.field === 'match') {
|
||||
return ['externalSkuCode', 'externalItemId', 'externalSkuName'].includes(field)
|
||||
}
|
||||
|
||||
return validationState.value.field === field
|
||||
}
|
||||
|
||||
async function saveConfigs() {
|
||||
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
|
||||
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
|
||||
const invalidState = normalizedBindings.reduce<ValidationState>((state, item, index) => {
|
||||
if (state || !hasBindingContent(item)) {
|
||||
return state
|
||||
}
|
||||
|
||||
return resolveBindingValidationState(item, index, bindings.value[index]?.id || '')
|
||||
}, null)
|
||||
|
||||
if (invalidState) {
|
||||
validationState.value = invalidState
|
||||
await focusValidationTarget(invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
if (bindings.value.length > 0 && nonEmptyBindings.length === 0) {
|
||||
validationState.value = {
|
||||
bindingId: '',
|
||||
field: 'skuCode',
|
||||
message: '当前没有可保存的规则,请至少填写内部履约 SKU 和外部匹配条件',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload = nonEmptyBindings
|
||||
|
||||
saving.value = true
|
||||
validationState.value = null
|
||||
|
||||
try {
|
||||
const response = await saveAdminFulfillmentBindingConfigs({ bindings: payload })
|
||||
filePath.value = response.data.filePath
|
||||
bindings.value = response.data.bindings.map(mapEditableBinding)
|
||||
showSuccess('履约配置已保存')
|
||||
await loadConfigs()
|
||||
} catch (error) {
|
||||
validationState.value = {
|
||||
bindingId: '',
|
||||
field: 'skuCode',
|
||||
message: error instanceof Error ? error.message : '保存履约配置失败',
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
loading,
|
||||
saving,
|
||||
errorMessage,
|
||||
filePath,
|
||||
bindings,
|
||||
observedProducts,
|
||||
validationState,
|
||||
collapsedBindingIds,
|
||||
// computed
|
||||
ruleMetrics,
|
||||
pendingObservedCount,
|
||||
// factories
|
||||
createEmptyBinding,
|
||||
mapEditableBinding,
|
||||
// validation
|
||||
isEditableBindingComplete,
|
||||
clearValidationState,
|
||||
isBindingInvalid,
|
||||
isFieldInvalid,
|
||||
// collapse
|
||||
isBindingCollapsed,
|
||||
setBindingCollapsed,
|
||||
toggleBindingCollapsed,
|
||||
// display
|
||||
getBindingTitle,
|
||||
getBindingStatusLabel,
|
||||
getBindingSummary,
|
||||
// data
|
||||
loadConfigs,
|
||||
addBinding,
|
||||
removeBinding,
|
||||
importObservedProduct,
|
||||
createBindingFromProductCandidate,
|
||||
normalizeBindingForSave,
|
||||
saveConfigs,
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import { lookupAdminFulfillmentBindingOrder } from '@/services/admin'
|
||||
import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin'
|
||||
|
||||
import type { ImportableProductCandidate } from './types'
|
||||
import type { useFulfillmentBindings } from './useFulfillmentBindings'
|
||||
|
||||
export function useFulfillmentBindingsLookup(
|
||||
bindings: ReturnType<typeof useFulfillmentBindings>,
|
||||
) {
|
||||
const lookupLoading = ref(false)
|
||||
const lookupErrorMessage = ref('')
|
||||
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
|
||||
const importedLookupLineIds = ref<string[]>([])
|
||||
const lookupForm = reactive({
|
||||
shopId: '',
|
||||
platformOrderId: '',
|
||||
})
|
||||
|
||||
function importLookupProduct(item: AdminFulfillmentLookupItem) {
|
||||
bindings.importObservedProduct(item)
|
||||
if (!importedLookupLineIds.value.includes(item.lineId)) {
|
||||
importedLookupLineIds.value = [...importedLookupLineIds.value, item.lineId]
|
||||
}
|
||||
}
|
||||
|
||||
function isLookupProductImported(lineId: string) {
|
||||
return importedLookupLineIds.value.includes(lineId)
|
||||
}
|
||||
|
||||
async function lookupOrderProducts() {
|
||||
const shopId = lookupForm.shopId.trim()
|
||||
const platformOrderId = lookupForm.platformOrderId.trim()
|
||||
|
||||
if (!shopId || !platformOrderId) {
|
||||
lookupErrorMessage.value = '请先填写店铺 ID 和平台订单号'
|
||||
return
|
||||
}
|
||||
|
||||
lookupLoading.value = true
|
||||
lookupErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await lookupAdminFulfillmentBindingOrder({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId,
|
||||
platformOrderId,
|
||||
})
|
||||
lookupResult.value = response.data
|
||||
importedLookupLineIds.value = []
|
||||
} catch (error) {
|
||||
lookupResult.value = null
|
||||
lookupErrorMessage.value = error instanceof Error ? error.message : '订单商品查询失败'
|
||||
} finally {
|
||||
lookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lookupLoading,
|
||||
lookupErrorMessage,
|
||||
lookupResult,
|
||||
importedLookupLineIds,
|
||||
lookupForm,
|
||||
importLookupProduct,
|
||||
isLookupProductImported,
|
||||
lookupOrderProducts,
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/admin-config-pages.css';
|
||||
@import '../../../../styles/admin-config-pages.css';
|
||||
|
||||
/* ============================================================
|
||||
AdminKuaishouCloudFulfillment — 蓝色主题覆盖 & 双栏映射布局
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import AdminKuaishouCloudNinetyoneSection from './components/AdminKuaishouCloudNinetyoneSection.vue'
|
||||
import AdminKuaishouCloudOverviewSection from './components/AdminKuaishouCloudOverviewSection.vue'
|
||||
import AdminKuaishouCloudRuleSection from './components/AdminKuaishouCloudRuleSection.vue'
|
||||
import { useKuaishouCloudConfig } from './composables/useKuaishouCloudConfig'
|
||||
import { useKuaishouCloudNinetyone } from './composables/useKuaishouCloudNinetyone'
|
||||
import { useKuaishouCloudSku } from './composables/useKuaishouCloudSku'
|
||||
|
||||
const config = useKuaishouCloudConfig()
|
||||
const ninetyone = useKuaishouCloudNinetyone(config)
|
||||
const cloudSku = useKuaishouCloudSku()
|
||||
|
||||
onMounted(config.loadConfigs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护新履约配置。</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminKuaishouCloudOverviewSection
|
||||
:file-path="config.filePath.value"
|
||||
:metrics="config.metrics.value"
|
||||
/>
|
||||
|
||||
<p v-if="config.errorMessage.value" class="error-copy">{{ config.errorMessage.value }}</p>
|
||||
<div v-if="config.loading.value" class="empty-block">新履约配置加载中</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminKuaishouCloudNinetyoneSection
|
||||
:ninetyone-lookup-loading="ninetyone.ninetyoneLookupLoading.value"
|
||||
:ninetyone-lookup-error-message="ninetyone.ninetyoneLookupErrorMessage.value"
|
||||
:ninetyone-lookup-results="ninetyone.ninetyoneLookupResults.value"
|
||||
:ninetyone-lookup-metrics="ninetyone.ninetyoneLookupMetrics.value"
|
||||
:ninetyone-lookup-form="ninetyone.ninetyoneLookupForm"
|
||||
:is-imported="ninetyone.isNinetyoneProductImported"
|
||||
@lookup="ninetyone.lookupNinetyoneProducts()"
|
||||
@import-product="ninetyone.importNinetyoneProduct($event)"
|
||||
/>
|
||||
|
||||
<AdminKuaishouCloudRuleSection
|
||||
:enabled="config.enabled.value"
|
||||
:saving="config.saving.value"
|
||||
:cloud-sku-catalog-loading="cloudSku.cloudSkuCatalogLoading.value"
|
||||
:validation-state="config.validationState.value"
|
||||
:items="config.items.value"
|
||||
:filtered-items="config.filteredItems.value"
|
||||
:rule-filter="config.ruleFilter.value"
|
||||
:rule-filter-options="config.ruleFilterOptions.value"
|
||||
:is-collapsed="config.isCollapsed"
|
||||
:is-item-complete="config.isItemComplete"
|
||||
:get-card-title="config.getCardTitle"
|
||||
:get-card-summary="config.getCardSummary"
|
||||
:get-rule-state="config.getRuleState"
|
||||
:get-external-match-summary="config.getExternalMatchSummary"
|
||||
:get-cloud-sku-summary="config.getCloudSkuSummary"
|
||||
:get-consume-shop-summary="config.getConsumeShopSummary"
|
||||
:get-cloud-sku-options="cloudSku.getCloudSkuOptions"
|
||||
:format-cloud-sku-option-label="cloudSku.formatCloudSkuOptionLabel"
|
||||
:get-kuaishou-consume-shop-options="config.getKuaishouConsumeShopOptions"
|
||||
:format-kuaishou-consume-shop-option="config.formatKuaishouConsumeShopOption"
|
||||
:cloud-sku-catalog-error-message="cloudSku.cloudSkuCatalogErrorMessage.value"
|
||||
@update:enabled="config.enabled.value = $event"
|
||||
@update:rule-filter="config.ruleFilter.value = $event"
|
||||
@expand-all="config.expandAllRules()"
|
||||
@collapse-ready="config.collapseReadyRules()"
|
||||
@refresh-cloud-sku="cloudSku.refreshCloudSkuCatalog()"
|
||||
@add-item="config.addItem()"
|
||||
@save-configs="config.saveConfigs()"
|
||||
@toggle-collapsed="config.toggleCollapsed($event)"
|
||||
@handle-cloud-sku-selected="cloudSku.handleCloudSkuSelected($event.item, $event.value)"
|
||||
@handle-cloud-sku-dropdown-visible="cloudSku.handleCloudSkuDropdownVisible($event.item, $event.visible)"
|
||||
@handle-kuaishou-consume-shop-selected="config.handleKuaishouConsumeShopSelected($event)"
|
||||
@remove-item="config.removeItem($event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminKuaishouCloudFulfillment.css"></style>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNinetyoneOrderItem } from '@/types/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
ninetyoneLookupLoading: boolean
|
||||
ninetyoneLookupErrorMessage: string
|
||||
ninetyoneLookupResults: AdminNinetyoneOrderItem[]
|
||||
ninetyoneLookupMetrics: {
|
||||
total: number
|
||||
importedCount: number
|
||||
availableCount: number
|
||||
pendingCount: number
|
||||
}
|
||||
ninetyoneLookupForm: {
|
||||
status: 'pending_config' | 'all' | 'manual_failed'
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
isImported: (item: AdminNinetyoneOrderItem) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
lookup: []
|
||||
importProduct: [item: AdminNinetyoneOrderItem]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>91卡券订单取样导入</h3>
|
||||
<p>
|
||||
查询已接收的 91卡券订单,把 productNo 导入为规则草稿,再补齐内部 SKU 和 cloud 资源。
|
||||
</p>
|
||||
</div>
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">结果 {{ ninetyoneLookupMetrics.total }}</span>
|
||||
<span class="mini-stat-chip">待补 {{ ninetyoneLookupMetrics.pendingCount }}</span>
|
||||
<span class="mini-stat-chip">待导入 {{ ninetyoneLookupMetrics.availableCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lookup-toolbar">
|
||||
<label class="field-block">
|
||||
<span>订单状态</span>
|
||||
<el-select v-model="ninetyoneLookupForm.status" class="text-input">
|
||||
<el-option label="待补全" value="pending_config" />
|
||||
<el-option label="已失败" value="manual_failed" />
|
||||
<el-option label="全部" value="all" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>页码</span>
|
||||
<el-input-number
|
||||
v-model="ninetyoneLookupForm.page"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>每页条数</span>
|
||||
<el-input-number
|
||||
v-model="ninetyoneLookupForm.pageSize"
|
||||
class="text-input"
|
||||
:max="100"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
<div class="field-block field-block--action">
|
||||
<span>操作</span>
|
||||
<el-button
|
||||
:loading="ninetyoneLookupLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('lookup')"
|
||||
>查询 91卡券订单</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="toolbar-hint">
|
||||
导入后会创建 provider=91kaquan、platform=kuaishou、外部 SKU=productNo
|
||||
的规则草稿;保存规则后,回到"平台配置 -> 91卡券接入"重试订单。
|
||||
</p>
|
||||
|
||||
<p v-if="ninetyoneLookupErrorMessage" class="error-copy lookup-error">
|
||||
{{ ninetyoneLookupErrorMessage }}
|
||||
</p>
|
||||
<div v-else-if="ninetyoneLookupLoading" class="empty-inline">正在查询 91卡券订单…</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="ninetyoneLookupResults"
|
||||
class="data-table element-data-table"
|
||||
empty-text="还没有 91卡券查询结果。"
|
||||
>
|
||||
<el-table-column label="订单" min-width="180">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.orderNo || '-' }}</strong>
|
||||
<span class="cell-subtle">{{ item.outTradeNo || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" min-width="180">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.shopName || '91卡券' }}</strong>
|
||||
<span class="cell-subtle">provider: 91kaquan</span>
|
||||
<span class="cell-subtle">shopId: {{ item.shopId || '91kaquan' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="240">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.productName || item.productNo || '-' }}</strong>
|
||||
<span class="cell-subtle">productNo: {{ item.productNo || '-' }}</span>
|
||||
<span class="cell-subtle">数量:{{ item.buyNum || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="120">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.orderStatus || '-' }}</strong>
|
||||
<span class="cell-subtle">任务 {{ item.taskCount || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row: item }">
|
||||
<el-button
|
||||
v-if="!isImported(item)"
|
||||
link
|
||||
type="primary"
|
||||
@click="emit('importProduct', item)"
|
||||
>
|
||||
导入到规则
|
||||
</el-button>
|
||||
<span v-else class="cell-subtle">已导入草稿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
filePath: string
|
||||
metrics: {
|
||||
total: number
|
||||
readyCount: number
|
||||
draftCount: number
|
||||
enabledCount: number
|
||||
disabledCount: number
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-card">
|
||||
<div class="overview-file">
|
||||
<span class="overview-label">配置文件</span>
|
||||
<code>{{ filePath || '-' }}</code>
|
||||
</div>
|
||||
|
||||
<div class="overview-stats">
|
||||
<article class="overview-stat">
|
||||
<span>规则总数</span>
|
||||
<strong>{{ metrics.total }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>可投产</span>
|
||||
<strong>{{ metrics.readyCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待完善</span>
|
||||
<strong>{{ metrics.draftCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已启用</span>
|
||||
<strong>{{ metrics.enabledCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已停用</span>
|
||||
<strong>{{ metrics.disabledCount }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-notes">
|
||||
<span class="overview-note">91卡券订单来自已接收的待补全队列</span>
|
||||
<span class="overview-note">内部 SKU 决定最终任务与库存绑定</span>
|
||||
<span class="overview-note">cloud SKU 决定自动购买、发货与退号资源</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminCloudtentaclesSkuItem, AdminKuaishouEticketShopConfigItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem, ValidationState } from '../composables/types'
|
||||
|
||||
defineProps<{
|
||||
item: EditableItem
|
||||
index: number
|
||||
validationState: ValidationState
|
||||
cloudSkuCatalogErrorMessage: string
|
||||
cloudSkuCatalogLoading: boolean
|
||||
isCollapsed: boolean
|
||||
isItemComplete: (item: EditableItem) => boolean
|
||||
getCardTitle: (item: EditableItem, index: number) => string
|
||||
getCardSummary: (item: EditableItem) => string
|
||||
getRuleState: (item: EditableItem) => { label: string; tone: string }
|
||||
getExternalMatchSummary: (item: EditableItem) => string
|
||||
getCloudSkuSummary: (item: EditableItem) => string
|
||||
getConsumeShopSummary: (item: EditableItem) => string
|
||||
getCloudSkuOptions: (item: EditableItem) => AdminCloudtentaclesSkuItem[]
|
||||
formatCloudSkuOptionLabel: (sku: AdminCloudtentaclesSkuItem) => string
|
||||
getKuaishouConsumeShopOptions: () => AdminKuaishouEticketShopConfigItem[]
|
||||
formatKuaishouConsumeShopOption: (shop: AdminKuaishouEticketShopConfigItem) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleCollapsed: []
|
||||
handleCloudSkuSelected: [value: number | string | undefined]
|
||||
handleCloudSkuDropdownVisible: [visible: boolean]
|
||||
handleKuaishouConsumeShopSelected: []
|
||||
remove: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-local-id="item.localId"
|
||||
:class="[
|
||||
'binding-card',
|
||||
{ 'binding-card--invalid': validationState?.localId === item.localId },
|
||||
]"
|
||||
>
|
||||
<div class="binding-header">
|
||||
<div class="binding-summary">
|
||||
<div class="binding-title-row">
|
||||
<strong>{{ getCardTitle(item, index) }}</strong>
|
||||
<span
|
||||
:class="[
|
||||
'binding-status-chip',
|
||||
{ 'binding-status-chip--done': isItemComplete(item) },
|
||||
]"
|
||||
>
|
||||
{{ getRuleState(item).label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="binding-subtle">{{ getCardSummary(item) }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="emit('toggleCollapsed')">
|
||||
{{ isCollapsed ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isCollapsed" class="binding-collapsed-preview">
|
||||
<span>外部商品:{{ getExternalMatchSummary(item) }}</span>
|
||||
<span>内部 SKU:{{ item.internalSkuCode || '-' }}</span>
|
||||
<span>cloud 资源:{{ getCloudSkuSummary(item) }}</span>
|
||||
<span>核销店铺:{{ getConsumeShopSummary(item) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="mapping-editor">
|
||||
<section class="mapping-panel mapping-panel--external">
|
||||
<div class="mapping-panel-head">
|
||||
<strong>外部商品</strong>
|
||||
<span>来源平台 / 快手侧命中条件</span>
|
||||
</div>
|
||||
|
||||
<div class="mapping-grid">
|
||||
<label class="field-block">
|
||||
<span>来源 provider</span>
|
||||
<el-input
|
||||
v-model="item.provider"
|
||||
class="text-input"
|
||||
maxlength="32"
|
||||
placeholder="默认 91kaquan"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>来源 platform</span>
|
||||
<el-input
|
||||
v-model="item.platform"
|
||||
class="text-input"
|
||||
maxlength="32"
|
||||
placeholder="默认 kuaishou"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="item.shopId"
|
||||
class="text-input"
|
||||
maxlength="80"
|
||||
placeholder="留空表示跨店铺共用"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>外部 SKU</span>
|
||||
<el-input
|
||||
v-model="item.externalSkuCode"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="例如 91卡券 productNo"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>外部商品 ID</span>
|
||||
<el-input
|
||||
v-model="item.externalItemId"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="平台商品 ID"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>外部商品名</span>
|
||||
<el-input
|
||||
v-model="item.externalSkuName"
|
||||
class="text-input"
|
||||
maxlength="200"
|
||||
placeholder="用于名称匹配"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>解析商品名</span>
|
||||
<el-input
|
||||
v-model="item.resolvedSkuName"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="可作为商品名兜底"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mapping-arrow" aria-hidden="true">
|
||||
<span>映射到</span>
|
||||
</div>
|
||||
|
||||
<section class="mapping-panel mapping-panel--internal">
|
||||
<div class="mapping-panel-head">
|
||||
<strong>内部履约</strong>
|
||||
<span>内部 SKU + cloud 资源</span>
|
||||
</div>
|
||||
|
||||
<div class="mapping-grid">
|
||||
<label class="field-block">
|
||||
<span>内部 SKU</span>
|
||||
<el-input
|
||||
v-model="item.internalSkuCode"
|
||||
class="text-input"
|
||||
maxlength="80"
|
||||
placeholder="必填,用于生成任务"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>优先级</span>
|
||||
<el-input-number
|
||||
v-model="item.priority"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>内部商品名</span>
|
||||
<el-input
|
||||
v-model="item.internalSkuName"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="展示用,可选但建议填"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>cloudSourceKey</span>
|
||||
<el-input
|
||||
v-model="item.cloudSourceKey"
|
||||
class="text-input"
|
||||
maxlength="60"
|
||||
placeholder="默认 default"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>cloud SKU ID</span>
|
||||
<el-select
|
||||
v-model="item.cloudSkuId"
|
||||
class="text-input"
|
||||
@change="emit('handleCloudSkuSelected', item.cloudSkuId)"
|
||||
@visible-change="emit('handleCloudSkuDropdownVisible', $event)"
|
||||
>
|
||||
<el-option label="请选择 cloud SKU" :value="0" />
|
||||
<el-option
|
||||
v-for="sku in getCloudSkuOptions(item)"
|
||||
:key="sku.id"
|
||||
:label="formatCloudSkuOptionLabel(sku)"
|
||||
:value="sku.id"
|
||||
/>
|
||||
</el-select>
|
||||
<small class="field-help">先填内部 SKU / 商品名,再选择会更容易匹配。</small>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>cloud SKU 名称</span>
|
||||
<el-input
|
||||
:model-value="item.cloudSkuName"
|
||||
class="text-input text-input--readonly"
|
||||
placeholder="选择 cloud SKU 后自动带出"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>虚拟号 VN Key</span>
|
||||
<el-input class="text-input text-input--readonly" model-value="1" readonly />
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>最低保留余额</span>
|
||||
<el-input-number
|
||||
v-model="item.minAssetReserve"
|
||||
class="text-input"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCollapsed" class="mapping-extra">
|
||||
<label class="field-block field-wide">
|
||||
<span>快手核销店铺</span>
|
||||
<el-select
|
||||
v-model="item.kuaishouConsumeShopId"
|
||||
class="text-input"
|
||||
placeholder="请选择已配置 Cookie 的快手小店"
|
||||
@change="emit('handleKuaishouConsumeShopSelected')"
|
||||
>
|
||||
<el-option label="请选择已配置 Cookie 的快手小店" value="" />
|
||||
<el-option
|
||||
v-for="shop in getKuaishouConsumeShopOptions()"
|
||||
:key="shop.shopId"
|
||||
:label="formatKuaishouConsumeShopOption(shop)"
|
||||
:value="shop.shopId"
|
||||
/>
|
||||
</el-select>
|
||||
<small class="field-help"
|
||||
>来自"平台店铺 -> 快手小店核销"配置,保存时会自动带出店铺名。</small
|
||||
>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>核销店铺 ID</span>
|
||||
<el-input
|
||||
:model-value="item.kuaishouConsumeShopId || '-'"
|
||||
class="text-input text-input--readonly"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>核销店铺名</span>
|
||||
<el-input
|
||||
:model-value="item.kuaishouConsumeShopName || '-'"
|
||||
class="text-input text-input--readonly"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>备注</span>
|
||||
<el-input
|
||||
v-model="item.notes"
|
||||
class="text-input textarea-input"
|
||||
maxlength="400"
|
||||
placeholder="绑定场景、客服注意事项等"
|
||||
type="textarea"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.localId === item.localId" class="binding-inline-error">
|
||||
{{ validationState?.message }}
|
||||
</p>
|
||||
<p v-if="cloudSkuCatalogErrorMessage" class="binding-inline-error">
|
||||
{{ cloudSkuCatalogErrorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-checkbox v-model="item.enabled">启用规则</el-checkbox>
|
||||
<el-checkbox v-model="item.autoBuyEnabled">自动购买</el-checkbox>
|
||||
<el-checkbox v-model="item.autoReturnNumberAfterDispatch">发货后退号</el-checkbox>
|
||||
<el-checkbox v-model="item.autoConsumeAfterDispatch">发货后核销</el-checkbox>
|
||||
<el-button link type="danger" @click="emit('remove')">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminCloudtentaclesSkuItem, AdminKuaishouEticketShopConfigItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem, RuleFilter, ValidationState } from '../composables/types'
|
||||
|
||||
import AdminKuaishouCloudRuleCard from './AdminKuaishouCloudRuleCard.vue'
|
||||
|
||||
defineProps<{
|
||||
enabled: boolean
|
||||
saving: boolean
|
||||
cloudSkuCatalogLoading: boolean
|
||||
validationState: ValidationState
|
||||
items: EditableItem[]
|
||||
filteredItems: EditableItem[]
|
||||
ruleFilter: RuleFilter
|
||||
ruleFilterOptions: Array<{ value: RuleFilter; label: string; count: number }>
|
||||
isCollapsed: (localId: string) => boolean
|
||||
isItemComplete: (item: EditableItem) => boolean
|
||||
getCardTitle: (item: EditableItem, index: number) => string
|
||||
getCardSummary: (item: EditableItem) => string
|
||||
getRuleState: (item: EditableItem) => { label: string; tone: string }
|
||||
getExternalMatchSummary: (item: EditableItem) => string
|
||||
getCloudSkuSummary: (item: EditableItem) => string
|
||||
getConsumeShopSummary: (item: EditableItem) => string
|
||||
getCloudSkuOptions: (item: EditableItem) => AdminCloudtentaclesSkuItem[]
|
||||
formatCloudSkuOptionLabel: (sku: AdminCloudtentaclesSkuItem) => string
|
||||
getKuaishouConsumeShopOptions: () => AdminKuaishouEticketShopConfigItem[]
|
||||
formatKuaishouConsumeShopOption: (shop: AdminKuaishouEticketShopConfigItem) => string
|
||||
cloudSkuCatalogErrorMessage: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean]
|
||||
'update:ruleFilter': [value: RuleFilter]
|
||||
expandAll: []
|
||||
collapseReady: []
|
||||
refreshCloudSku: []
|
||||
addItem: []
|
||||
saveConfigs: []
|
||||
toggleCollapsed: [localId: string]
|
||||
handleCloudSkuSelected: [item: EditableItem, value: number | string | undefined]
|
||||
handleCloudSkuDropdownVisible: [item: EditableItem, visible: boolean]
|
||||
handleKuaishouConsumeShopSelected: [item: EditableItem]
|
||||
removeItem: [localId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>规则编辑</h3>
|
||||
<p>每一条规则描述"快手外部商品"如何映射到"内部 SKU + cloud 资源"。</p>
|
||||
</div>
|
||||
<div class="section-header-tools">
|
||||
<div class="section-mini-stats">
|
||||
<el-button
|
||||
v-for="option in ruleFilterOptions"
|
||||
:key="option.value"
|
||||
round
|
||||
size="small"
|
||||
:type="ruleFilter === option.value ? 'primary' : 'default'"
|
||||
@click="emit('update:ruleFilter', option.value)"
|
||||
>
|
||||
{{ option.label }} {{ option.count }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="section-action-group">
|
||||
<el-button round @click="emit('expandAll')">全部展开</el-button>
|
||||
<el-checkbox :model-value="enabled" @update:model-value="emit('update:enabled', $event)">启用整条配置</el-checkbox>
|
||||
<el-button round @click="emit('collapseReady')">收起已完成</el-button>
|
||||
<el-button round :loading="cloudSkuCatalogLoading" @click="emit('refreshCloudSku')"
|
||||
>刷新 cloud SKU</el-button
|
||||
>
|
||||
<el-button round @click="emit('addItem')">新增规则</el-button>
|
||||
<el-button round type="primary" :loading="saving" @click="emit('saveConfigs')"
|
||||
>保存全部</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.message" class="validation-banner">
|
||||
{{ validationState.message }}
|
||||
</p>
|
||||
<div v-if="items.length === 0" class="empty-inline">
|
||||
当前还没有新履约规则,先新增一条。
|
||||
</div>
|
||||
<div v-else-if="filteredItems.length === 0" class="empty-inline">
|
||||
当前筛选下没有规则。
|
||||
</div>
|
||||
|
||||
<AdminKuaishouCloudRuleCard
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.localId"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:validation-state="validationState"
|
||||
:cloud-sku-catalog-error-message="cloudSkuCatalogErrorMessage"
|
||||
:cloud-sku-catalog-loading="cloudSkuCatalogLoading"
|
||||
:is-collapsed="isCollapsed(item.localId)"
|
||||
:is-item-complete="isItemComplete"
|
||||
:get-card-title="getCardTitle"
|
||||
:get-card-summary="getCardSummary"
|
||||
:get-rule-state="getRuleState"
|
||||
:get-external-match-summary="getExternalMatchSummary"
|
||||
:get-cloud-sku-summary="getCloudSkuSummary"
|
||||
:get-consume-shop-summary="getConsumeShopSummary"
|
||||
:get-cloud-sku-options="getCloudSkuOptions"
|
||||
:format-cloud-sku-option-label="formatCloudSkuOptionLabel"
|
||||
:get-kuaishou-consume-shop-options="getKuaishouConsumeShopOptions"
|
||||
:format-kuaishou-consume-shop-option="formatKuaishouConsumeShopOption"
|
||||
@toggle-collapsed="emit('toggleCollapsed', item.localId)"
|
||||
@handle-cloud-sku-selected="emit('handleCloudSkuSelected', item, $event)"
|
||||
@handle-cloud-sku-dropdown-visible="emit('handleCloudSkuDropdownVisible', item, $event)"
|
||||
@handle-kuaishou-consume-shop-selected="emit('handleKuaishouConsumeShopSelected', item)"
|
||||
@remove="emit('removeItem', item.localId)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AdminKuaishouCloudFulfillmentItem } from '@/types/admin'
|
||||
|
||||
export type EditableItem = AdminKuaishouCloudFulfillmentItem & {
|
||||
localId: string
|
||||
}
|
||||
|
||||
export type ValidationState = {
|
||||
localId: string
|
||||
message: string
|
||||
} | null
|
||||
|
||||
export type RuleFilter = 'all' | 'draft' | 'ready' | 'disabled'
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminKuaishouCloudFulfillmentConfig,
|
||||
fetchAdminKuaishouEticketSourceConfig,
|
||||
saveAdminKuaishouCloudFulfillmentConfig,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminKuaishouCloudFulfillmentConfig,
|
||||
AdminKuaishouCloudFulfillmentItem,
|
||||
AdminKuaishouEticketShopConfigItem,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import type { EditableItem, RuleFilter, ValidationState } from './types'
|
||||
|
||||
export function useKuaishouCloudConfig() {
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const validationState = ref<ValidationState>(null)
|
||||
const filePath = ref('')
|
||||
const enabled = ref(true)
|
||||
const items = ref<EditableItem[]>([])
|
||||
const collapsedIds = ref<string[]>([])
|
||||
const ruleFilter = ref<RuleFilter>('all')
|
||||
const kuaishouConsumeShops = ref<AdminKuaishouEticketShopConfigItem[]>([])
|
||||
|
||||
// ── computed ──────────────────────────────────────────
|
||||
|
||||
const metrics = computed(() => {
|
||||
const readyCount = items.value.filter(isItemComplete).length
|
||||
const enabledCount = items.value.filter((item) => item.enabled).length
|
||||
const disabledCount = items.value.filter((item) => !item.enabled).length
|
||||
return {
|
||||
total: items.value.length,
|
||||
readyCount,
|
||||
enabledCount,
|
||||
disabledCount,
|
||||
draftCount: items.value.filter((item) => item.enabled && !isItemComplete(item)).length,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredItems = computed(() =>
|
||||
items.value.filter((item) => matchesRuleFilter(item, ruleFilter.value)),
|
||||
)
|
||||
|
||||
const ruleFilterOptions = computed<Array<{ value: RuleFilter; label: string; count: number }>>(
|
||||
() => [
|
||||
{ value: 'all', label: '全部', count: metrics.value.total },
|
||||
{ value: 'draft', label: '待完善', count: metrics.value.draftCount },
|
||||
{ value: 'ready', label: '可投产', count: metrics.value.readyCount },
|
||||
{ value: 'disabled', label: '已停用', count: metrics.value.disabledCount },
|
||||
],
|
||||
)
|
||||
|
||||
// ── item factories ───────────────────────────────────
|
||||
|
||||
function createEmptyItem(): EditableItem {
|
||||
const defaultShop = getDefaultKuaishouConsumeShop()
|
||||
return {
|
||||
localId: crypto.randomUUID(),
|
||||
id: '',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: '',
|
||||
internalSkuCode: '',
|
||||
internalSkuName: '',
|
||||
externalSkuCode: '',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
resolvedSkuName: '',
|
||||
cloudSourceKey: 'default',
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '',
|
||||
vnKey: '1',
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
autoReturnNumberAfterDispatch: false,
|
||||
autoConsumeAfterDispatch: false,
|
||||
kuaishouConsumeShopId: defaultShop?.shopId || '',
|
||||
kuaishouConsumeShopName: defaultShop?.kshopName || '',
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem {
|
||||
const matchedShop = findKuaishouConsumeShop(
|
||||
item.kuaishouConsumeShopId,
|
||||
item.kuaishouConsumeShopName,
|
||||
)
|
||||
return {
|
||||
localId: crypto.randomUUID(),
|
||||
id: item.id || crypto.randomUUID(),
|
||||
enabled: item.enabled !== false,
|
||||
priority: item.priority || 100,
|
||||
provider: item.provider || '91kaquan',
|
||||
platform: item.platform || 'kuaishou',
|
||||
shopId: item.shopId || '',
|
||||
internalSkuCode: item.internalSkuCode || '',
|
||||
internalSkuName: item.internalSkuName || '',
|
||||
externalSkuCode: item.externalSkuCode || '',
|
||||
externalItemId: item.externalItemId || '',
|
||||
externalSkuName: item.externalSkuName || '',
|
||||
resolvedSkuName: item.resolvedSkuName || '',
|
||||
cloudSourceKey: item.cloudSourceKey || 'default',
|
||||
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
|
||||
cloudSkuName: item.cloudSkuName || '',
|
||||
vnKey: item.vnKey || '',
|
||||
autoBuyEnabled: item.autoBuyEnabled !== false,
|
||||
minAssetReserve: Number(item.minAssetReserve || 0) || 0,
|
||||
autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true,
|
||||
autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true,
|
||||
kuaishouConsumeShopId: matchedShop?.shopId || item.kuaishouConsumeShopId || '',
|
||||
kuaishouConsumeShopName: matchedShop?.kshopName || item.kuaishouConsumeShopName || '',
|
||||
notes: item.notes || '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapSaveItem(item: EditableItem): AdminKuaishouCloudFulfillmentItem {
|
||||
const matchedShop = findKuaishouConsumeShop(
|
||||
item.kuaishouConsumeShopId,
|
||||
item.kuaishouConsumeShopName,
|
||||
)
|
||||
return {
|
||||
id: item.id.trim() || item.internalSkuCode.trim() || item.localId,
|
||||
enabled: item.enabled,
|
||||
priority: Number(item.priority || 100) || 100,
|
||||
provider: item.provider.trim() || '91kaquan',
|
||||
platform: item.platform.trim() || 'kuaishou',
|
||||
shopId: item.shopId.trim(),
|
||||
internalSkuCode: item.internalSkuCode.trim(),
|
||||
internalSkuName: item.internalSkuName.trim(),
|
||||
externalSkuCode: item.externalSkuCode.trim(),
|
||||
externalItemId: item.externalItemId.trim(),
|
||||
externalSkuName: item.externalSkuName.trim(),
|
||||
resolvedSkuName: item.resolvedSkuName.trim(),
|
||||
cloudSourceKey: item.cloudSourceKey.trim() || 'default',
|
||||
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
|
||||
cloudSkuName: item.cloudSkuName.trim(),
|
||||
vnKey: item.vnKey.trim(),
|
||||
autoBuyEnabled: item.autoBuyEnabled,
|
||||
minAssetReserve: Math.max(0, Number(item.minAssetReserve || 0) || 0),
|
||||
autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch,
|
||||
autoConsumeAfterDispatch: item.autoConsumeAfterDispatch,
|
||||
kuaishouConsumeShopId: String(matchedShop?.shopId || item.kuaishouConsumeShopId).trim(),
|
||||
kuaishouConsumeShopName: String(matchedShop?.kshopName || item.kuaishouConsumeShopName).trim(),
|
||||
notes: item.notes.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── validation helpers ────────────────────────────────
|
||||
|
||||
function hasExternalMatch(
|
||||
item: Pick<EditableItem, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>,
|
||||
) {
|
||||
return Boolean(
|
||||
item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function hasMeaningfulContent(item: EditableItem) {
|
||||
return Boolean(
|
||||
item.internalSkuCode.trim() ||
|
||||
item.internalSkuName.trim() ||
|
||||
item.externalSkuCode.trim() ||
|
||||
item.externalItemId.trim() ||
|
||||
item.externalSkuName.trim() ||
|
||||
item.resolvedSkuName.trim() ||
|
||||
item.cloudSourceKey.trim() !== 'default' ||
|
||||
Number(item.cloudSkuId || 0) > 0 ||
|
||||
item.cloudSkuName.trim() ||
|
||||
item.vnKey.trim() ||
|
||||
item.shopId.trim() ||
|
||||
item.notes.trim() ||
|
||||
item.provider.trim() !== '91kaquan' ||
|
||||
item.platform.trim() !== 'kuaishou' ||
|
||||
item.priority !== 100 ||
|
||||
item.autoBuyEnabled !== true ||
|
||||
item.minAssetReserve !== 0 ||
|
||||
item.autoReturnNumberAfterDispatch ||
|
||||
item.autoConsumeAfterDispatch ||
|
||||
item.kuaishouConsumeShopId.trim() ||
|
||||
item.kuaishouConsumeShopName.trim() ||
|
||||
item.enabled !== true,
|
||||
)
|
||||
}
|
||||
|
||||
function isItemComplete(item: EditableItem) {
|
||||
return Boolean(
|
||||
item.internalSkuCode.trim() &&
|
||||
Number(item.cloudSkuId || 0) > 0 &&
|
||||
(!item.autoConsumeAfterDispatch || Boolean(item.kuaishouConsumeShopId.trim())) &&
|
||||
hasExternalMatch(item),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveValidation(item: EditableItem, index: number): ValidationState {
|
||||
if (!hasMeaningfulContent(item)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title = `第 ${index + 1} 条规则`
|
||||
|
||||
if (!item.internalSkuCode.trim()) {
|
||||
return { localId: item.localId, message: `${title} 缺少内部 SKU 编码` }
|
||||
}
|
||||
|
||||
if (Number(item.cloudSkuId || 0) <= 0) {
|
||||
return { localId: item.localId, message: `${title} 需要填写 cloud SKU ID` }
|
||||
}
|
||||
|
||||
if (!hasExternalMatch(item)) {
|
||||
return { localId: item.localId, message: `${title} 至少填写一种外部匹配条件` }
|
||||
}
|
||||
|
||||
if (item.autoConsumeAfterDispatch && !item.kuaishouConsumeShopId.trim()) {
|
||||
return { localId: item.localId, message: `${title} 已开启发货后核销,需要选择快手核销店铺` }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── display helpers ───────────────────────────────────
|
||||
|
||||
function getCardTitle(item: EditableItem, index: number) {
|
||||
return (
|
||||
item.internalSkuName.trim() ||
|
||||
item.externalSkuName.trim() ||
|
||||
item.internalSkuCode.trim() ||
|
||||
`规则 ${index + 1}`
|
||||
)
|
||||
}
|
||||
|
||||
function getCardSummary(item: EditableItem) {
|
||||
const parts = [
|
||||
item.provider.trim() || '91kaquan',
|
||||
item.platform.trim() || 'kuaishou',
|
||||
item.shopId.trim() || '跨店铺',
|
||||
item.cloudSkuId > 0 ? `cloud#${item.cloudSkuId}` : '待填 cloud SKU',
|
||||
]
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
function getRuleState(item: EditableItem) {
|
||||
if (!item.enabled) {
|
||||
return { label: '已停用', tone: 'muted' }
|
||||
}
|
||||
|
||||
if (isItemComplete(item)) {
|
||||
return { label: '可投产', tone: 'success' }
|
||||
}
|
||||
|
||||
return { label: '草稿待完善', tone: 'warning' }
|
||||
}
|
||||
|
||||
function matchesRuleFilter(item: EditableItem, filter: RuleFilter) {
|
||||
if (filter === 'ready') {
|
||||
return item.enabled && isItemComplete(item)
|
||||
}
|
||||
|
||||
if (filter === 'draft') {
|
||||
return item.enabled && !isItemComplete(item)
|
||||
}
|
||||
|
||||
if (filter === 'disabled') {
|
||||
return !item.enabled
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getExternalMatchSummary(item: EditableItem) {
|
||||
const parts = [
|
||||
item.externalSkuCode.trim() ? `SKU ${item.externalSkuCode.trim()}` : '',
|
||||
item.externalItemId.trim() ? `Item ${item.externalItemId.trim()}` : '',
|
||||
item.externalSkuName.trim() ? item.externalSkuName.trim() : '',
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length > 0 ? parts.join(' / ') : '未设置外部命中条件'
|
||||
}
|
||||
|
||||
// ── kuaishou consume shop helpers ─────────────────────
|
||||
|
||||
function getKuaishouConsumeShopOptions() {
|
||||
return kuaishouConsumeShops.value.filter((shop) => shop.enabled !== false && shop.hasCookie)
|
||||
}
|
||||
|
||||
function findKuaishouConsumeShop(shopId = '', shopName = '') {
|
||||
const normalizedShopId = String(shopId || '').trim()
|
||||
const normalizedShopName = String(shopName || '').trim()
|
||||
return (
|
||||
getKuaishouConsumeShopOptions().find(
|
||||
(shop) =>
|
||||
(normalizedShopId && shop.shopId === normalizedShopId) ||
|
||||
(normalizedShopName && shop.kshopName === normalizedShopName),
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
function getDefaultKuaishouConsumeShop() {
|
||||
return getKuaishouConsumeShopOptions()[0] || null
|
||||
}
|
||||
|
||||
function formatKuaishouConsumeShopOption(shop: AdminKuaishouEticketShopConfigItem) {
|
||||
return `${shop.kshopName || '未命名快手小店'} · ${shop.shopId}`
|
||||
}
|
||||
|
||||
function handleKuaishouConsumeShopSelected(item: EditableItem) {
|
||||
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId)
|
||||
item.kuaishouConsumeShopName = matchedShop?.kshopName || ''
|
||||
}
|
||||
|
||||
function getConsumeShopSummary(item: EditableItem) {
|
||||
const matchedShop = findKuaishouConsumeShop(
|
||||
item.kuaishouConsumeShopId,
|
||||
item.kuaishouConsumeShopName,
|
||||
)
|
||||
const name = String(matchedShop?.kshopName || item.kuaishouConsumeShopName).trim()
|
||||
const id = String(matchedShop?.shopId || item.kuaishouConsumeShopId).trim()
|
||||
|
||||
if (name && id) {
|
||||
return `${name} · ${id}`
|
||||
}
|
||||
|
||||
return name || id || '未绑定核销店铺'
|
||||
}
|
||||
|
||||
function getCloudSkuSummary(item: EditableItem) {
|
||||
if (item.cloudSkuId > 0 && item.cloudSkuName.trim()) {
|
||||
return `${item.cloudSkuName.trim()} · #${item.cloudSkuId}`
|
||||
}
|
||||
|
||||
if (item.cloudSkuId > 0) {
|
||||
return `cloud SKU #${item.cloudSkuId}`
|
||||
}
|
||||
|
||||
return '待选择 cloud SKU'
|
||||
}
|
||||
|
||||
// ── collapse state ────────────────────────────────────
|
||||
|
||||
function isCollapsed(localId: string) {
|
||||
return collapsedIds.value.includes(localId)
|
||||
}
|
||||
|
||||
function setCollapsed(localId: string, collapsed: boolean) {
|
||||
const next = new Set(collapsedIds.value)
|
||||
if (collapsed) {
|
||||
next.add(localId)
|
||||
} else {
|
||||
next.delete(localId)
|
||||
}
|
||||
collapsedIds.value = Array.from(next)
|
||||
}
|
||||
|
||||
function toggleCollapsed(localId: string) {
|
||||
setCollapsed(localId, !isCollapsed(localId))
|
||||
}
|
||||
|
||||
function rebuildCollapsedState() {
|
||||
collapsedIds.value = items.value.filter(isItemComplete).map((item) => item.localId)
|
||||
}
|
||||
|
||||
function expandAllRules() {
|
||||
collapsedIds.value = []
|
||||
}
|
||||
|
||||
function collapseReadyRules() {
|
||||
collapsedIds.value = items.value
|
||||
.filter((item) => isItemComplete(item) || !item.enabled)
|
||||
.map((item) => item.localId)
|
||||
}
|
||||
|
||||
// ── data loading ──────────────────────────────────────
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [response, eticketResponse] = await Promise.all([
|
||||
fetchAdminKuaishouCloudFulfillmentConfig(),
|
||||
fetchAdminKuaishouEticketSourceConfig(),
|
||||
])
|
||||
kuaishouConsumeShops.value = Array.isArray(eticketResponse.data.source.shops)
|
||||
? eticketResponse.data.source.shops
|
||||
: []
|
||||
filePath.value = response.data.filePath
|
||||
enabled.value = response.data.source.enabled !== false
|
||||
items.value = (response.data.source.items || []).map(mapEditableItem)
|
||||
rebuildCollapsedState()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取新履约配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── mutation ──────────────────────────────────────────
|
||||
|
||||
function addItem() {
|
||||
validationState.value = null
|
||||
const next = createEmptyItem()
|
||||
items.value.unshift(next)
|
||||
setCollapsed(next.localId, false)
|
||||
}
|
||||
|
||||
function removeItem(localId: string) {
|
||||
if (validationState.value?.localId === localId) {
|
||||
validationState.value = null
|
||||
}
|
||||
items.value = items.value.filter((item) => item.localId !== localId)
|
||||
setCollapsed(localId, false)
|
||||
}
|
||||
|
||||
async function focusValidationTarget(localId: string) {
|
||||
if (!localId) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const card = document.querySelector<HTMLElement>(`[data-local-id="${localId}"]`)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
// ── save ──────────────────────────────────────────────
|
||||
|
||||
async function saveConfigs() {
|
||||
const invalid = items.value.reduce<ValidationState>(
|
||||
(state, item, index) => state || resolveValidation(item, index),
|
||||
null,
|
||||
)
|
||||
if (invalid) {
|
||||
validationState.value = invalid
|
||||
setCollapsed(invalid.localId, false)
|
||||
showError(invalid.message)
|
||||
await focusValidationTarget(invalid.localId)
|
||||
return
|
||||
}
|
||||
|
||||
const payloadItems = items.value.filter(hasMeaningfulContent).map(mapSaveItem)
|
||||
const payload: AdminKuaishouCloudFulfillmentConfig = {
|
||||
enabled: enabled.value,
|
||||
items: payloadItems,
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
validationState.value = null
|
||||
|
||||
try {
|
||||
const response = await saveAdminKuaishouCloudFulfillmentConfig(payload)
|
||||
filePath.value = response.data.filePath
|
||||
enabled.value = response.data.source.enabled !== false
|
||||
items.value = (response.data.source.items || []).map(mapEditableItem)
|
||||
rebuildCollapsedState()
|
||||
showSuccess('新履约配置已保存')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存新履约配置失败'
|
||||
validationState.value = { localId: '', message }
|
||||
showError(message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
loading,
|
||||
saving,
|
||||
errorMessage,
|
||||
validationState,
|
||||
filePath,
|
||||
enabled,
|
||||
items,
|
||||
collapsedIds,
|
||||
ruleFilter,
|
||||
kuaishouConsumeShops,
|
||||
// computed
|
||||
metrics,
|
||||
filteredItems,
|
||||
ruleFilterOptions,
|
||||
// factories
|
||||
createEmptyItem,
|
||||
mapEditableItem,
|
||||
// validation
|
||||
isItemComplete,
|
||||
resolveValidation,
|
||||
hasMeaningfulContent,
|
||||
// display
|
||||
getCardTitle,
|
||||
getCardSummary,
|
||||
getRuleState,
|
||||
getExternalMatchSummary,
|
||||
getCloudSkuSummary,
|
||||
getConsumeShopSummary,
|
||||
// consume shops
|
||||
getKuaishouConsumeShopOptions,
|
||||
findKuaishouConsumeShop,
|
||||
formatKuaishouConsumeShopOption,
|
||||
handleKuaishouConsumeShopSelected,
|
||||
// collapse
|
||||
isCollapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
expandAllRules,
|
||||
collapseReadyRules,
|
||||
// data
|
||||
loadConfigs,
|
||||
addItem,
|
||||
removeItem,
|
||||
saveConfigs,
|
||||
focusValidationTarget,
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { fetchAdminNinetyoneOrders } from '@/services/admin'
|
||||
import type { AdminNinetyoneOrderItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem } from './types'
|
||||
|
||||
import type { useKuaishouCloudConfig } from './useKuaishouCloudConfig'
|
||||
|
||||
export function useKuaishouCloudNinetyone(
|
||||
config: ReturnType<typeof useKuaishouCloudConfig>,
|
||||
) {
|
||||
const ninetyoneLookupLoading = ref(false)
|
||||
const ninetyoneLookupErrorMessage = ref('')
|
||||
const ninetyoneLookupResults = ref<AdminNinetyoneOrderItem[]>([])
|
||||
const importedNinetyoneOrderKeys = ref<string[]>([])
|
||||
const ninetyoneLookupForm = reactive({
|
||||
status: 'pending_config' as 'pending_config' | 'all' | 'manual_failed',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
const ninetyoneLookupMetrics = computed(() => {
|
||||
const importedCount = ninetyoneLookupResults.value.filter((item) =>
|
||||
isNinetyoneProductImported(item),
|
||||
).length
|
||||
const pendingCount = ninetyoneLookupResults.value.filter(
|
||||
(item) => item.orderStatus === 'pending_config',
|
||||
).length
|
||||
return {
|
||||
total: ninetyoneLookupResults.value.length,
|
||||
importedCount,
|
||||
availableCount: Math.max(ninetyoneLookupResults.value.length - importedCount, 0),
|
||||
pendingCount,
|
||||
}
|
||||
})
|
||||
|
||||
function createItemFromNinetyoneOrder(item: AdminNinetyoneOrderItem): EditableItem {
|
||||
const productNo = String(item.productNo || '').trim()
|
||||
const productName = String(item.productName || productNo).trim()
|
||||
const defaultShop = config.getDefaultKuaishouConsumeShop ? config.findKuaishouConsumeShop() : null
|
||||
const shopOpts = config.getKuaishouConsumeShopOptions()
|
||||
const shop = defaultShop || (shopOpts.length > 0 ? shopOpts[0] : null)
|
||||
return {
|
||||
localId: crypto.randomUUID(),
|
||||
id: '',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: item.shopId || '91kaquan',
|
||||
internalSkuCode: '',
|
||||
internalSkuName: productName,
|
||||
externalSkuCode: productNo,
|
||||
externalItemId: productNo,
|
||||
externalSkuName: productName,
|
||||
resolvedSkuName: productName,
|
||||
cloudSourceKey: 'default',
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '',
|
||||
vnKey: '1',
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
autoReturnNumberAfterDispatch: false,
|
||||
autoConsumeAfterDispatch: false,
|
||||
kuaishouConsumeShopId: shop?.shopId || '',
|
||||
kuaishouConsumeShopName: shop?.kshopName || '',
|
||||
notes: `从 91卡券订单 ${item.orderNo} 导入`,
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingItemFromNinetyoneOrder(order: AdminNinetyoneOrderItem) {
|
||||
const productNo = String(order.productNo || '').trim()
|
||||
const productName = String(order.productName || '').trim()
|
||||
const shopId = String(order.shopId || '91kaquan').trim()
|
||||
|
||||
return (
|
||||
config.items.value.find((item) => {
|
||||
const sameSource = item.provider.trim() === '91kaquan' && item.platform.trim() === 'kuaishou'
|
||||
const sameShop = !shopId || item.shopId.trim() === shopId
|
||||
const sameProductNo =
|
||||
productNo &&
|
||||
(item.externalSkuCode.trim() === productNo || item.externalItemId.trim() === productNo)
|
||||
const sameName =
|
||||
productName &&
|
||||
[item.externalSkuName, item.internalSkuName, item.resolvedSkuName].some(
|
||||
(value) => value.trim() === productName,
|
||||
)
|
||||
return sameSource && sameShop && (sameProductNo || sameName)
|
||||
}) || null
|
||||
)
|
||||
}
|
||||
|
||||
async function importNinetyoneProduct(item: AdminNinetyoneOrderItem) {
|
||||
config.validationState.value = null
|
||||
const importKey = getNinetyoneOrderImportKey(item)
|
||||
const existing = findExistingItemFromNinetyoneOrder(item)
|
||||
if (existing) {
|
||||
config.setCollapsed(existing.localId, false)
|
||||
if (!importedNinetyoneOrderKeys.value.includes(importKey)) {
|
||||
importedNinetyoneOrderKeys.value = [...importedNinetyoneOrderKeys.value, importKey]
|
||||
}
|
||||
showSuccess('已定位到现有 91卡券规则草稿,直接继续完善即可')
|
||||
await config.focusValidationTarget(existing.localId)
|
||||
return
|
||||
}
|
||||
|
||||
const next = createItemFromNinetyoneOrder(item)
|
||||
config.items.value.unshift(next)
|
||||
config.setCollapsed(next.localId, false)
|
||||
|
||||
if (!importedNinetyoneOrderKeys.value.includes(importKey)) {
|
||||
importedNinetyoneOrderKeys.value = [...importedNinetyoneOrderKeys.value, importKey]
|
||||
}
|
||||
|
||||
await config.focusValidationTarget(next.localId)
|
||||
}
|
||||
|
||||
function getNinetyoneOrderImportKey(item: AdminNinetyoneOrderItem) {
|
||||
return [item.orderNo, item.productNo, item.shopId || '91kaquan']
|
||||
.map((value) => String(value || '').trim())
|
||||
.join(':')
|
||||
}
|
||||
|
||||
function isNinetyoneProductImported(item: AdminNinetyoneOrderItem) {
|
||||
return importedNinetyoneOrderKeys.value.includes(getNinetyoneOrderImportKey(item))
|
||||
}
|
||||
|
||||
async function lookupNinetyoneProducts() {
|
||||
ninetyoneLookupLoading.value = true
|
||||
ninetyoneLookupErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminNinetyoneOrders({
|
||||
page: Number(ninetyoneLookupForm.page || 1),
|
||||
pageSize: Number(ninetyoneLookupForm.pageSize || 20),
|
||||
status: ninetyoneLookupForm.status,
|
||||
})
|
||||
ninetyoneLookupResults.value = response.data.items
|
||||
importedNinetyoneOrderKeys.value = []
|
||||
} catch (error) {
|
||||
ninetyoneLookupResults.value = []
|
||||
ninetyoneLookupErrorMessage.value =
|
||||
error instanceof Error ? error.message : '91卡券订单查询失败'
|
||||
} finally {
|
||||
ninetyoneLookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ninetyoneLookupLoading,
|
||||
ninetyoneLookupErrorMessage,
|
||||
ninetyoneLookupResults,
|
||||
importedNinetyoneOrderKeys,
|
||||
ninetyoneLookupForm,
|
||||
ninetyoneLookupMetrics,
|
||||
importNinetyoneProduct,
|
||||
isNinetyoneProductImported,
|
||||
lookupNinetyoneProducts,
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { fetchAdminCloudtentaclesSkuList } from '@/services/admin'
|
||||
import type { AdminCloudtentaclesSkuItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem } from './types'
|
||||
|
||||
export function useKuaishouCloudSku() {
|
||||
const cloudSkuCatalogLoading = ref(false)
|
||||
const cloudSkuCatalogErrorMessage = ref('')
|
||||
const cloudSkuCatalog = ref<AdminCloudtentaclesSkuItem[]>([])
|
||||
|
||||
async function ensureCloudSkuCatalogLoaded(force = false) {
|
||||
if (!force && cloudSkuCatalog.value.length > 0) {
|
||||
return cloudSkuCatalog.value
|
||||
}
|
||||
|
||||
cloudSkuCatalogLoading.value = true
|
||||
cloudSkuCatalogErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesSkuList({})
|
||||
cloudSkuCatalog.value = Array.isArray(response.data.items) ? response.data.items : []
|
||||
return cloudSkuCatalog.value
|
||||
} catch (error) {
|
||||
cloudSkuCatalog.value = []
|
||||
cloudSkuCatalogErrorMessage.value =
|
||||
error instanceof Error ? error.message : 'cloud SKU 列表查询失败'
|
||||
throw error
|
||||
} finally {
|
||||
cloudSkuCatalogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCloudSkuKeyword(item: EditableItem) {
|
||||
return (
|
||||
[item.internalSkuCode, item.internalSkuName, item.resolvedSkuName, item.externalSkuName]
|
||||
.map((value) => String(value || '').trim())
|
||||
.find(Boolean) || ''
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function scoreCloudSkuMatch(item: AdminCloudtentaclesSkuItem, keyword: string) {
|
||||
const normalizedKeyword = normalizeSearchText(keyword)
|
||||
if (!normalizedKeyword) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const name = normalizeSearchText(item.name)
|
||||
const description = normalizeSearchText(item.description)
|
||||
|
||||
if (name === normalizedKeyword) {
|
||||
return 120
|
||||
}
|
||||
|
||||
if (name.startsWith(normalizedKeyword)) {
|
||||
return 100
|
||||
}
|
||||
|
||||
if (name.includes(normalizedKeyword)) {
|
||||
return 80
|
||||
}
|
||||
|
||||
if (description.includes(normalizedKeyword)) {
|
||||
return 40
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function getCloudSkuOptions(item: EditableItem) {
|
||||
const keyword = getCloudSkuKeyword(item)
|
||||
const scored = cloudSkuCatalog.value
|
||||
.map((sku) => ({ sku, score: scoreCloudSkuMatch(sku, keyword) }))
|
||||
.filter((entry) => entry.score > 0 || entry.sku.id === Number(item.cloudSkuId || 0))
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score
|
||||
}
|
||||
|
||||
return left.sku.name.localeCompare(right.sku.name, 'zh-CN')
|
||||
})
|
||||
.map((entry) => entry.sku)
|
||||
|
||||
if (scored.length > 0) {
|
||||
return scored.slice(0, 80)
|
||||
}
|
||||
|
||||
return cloudSkuCatalog.value.slice(0, 80)
|
||||
}
|
||||
|
||||
function formatCloudSkuOptionLabel(item: AdminCloudtentaclesSkuItem) {
|
||||
const price = Number(item.price || 0)
|
||||
const inventory = Number(item.inventory || 0)
|
||||
return `${item.name} · ID ${item.id} · 库存 ${inventory} · 价格 ${price}`
|
||||
}
|
||||
|
||||
function handleCloudSkuSelected(item: EditableItem, value: number | string | undefined) {
|
||||
const skuId = Number(value || 0)
|
||||
item.cloudSkuId = Number.isInteger(skuId) && skuId > 0 ? skuId : 0
|
||||
|
||||
if (!item.cloudSkuId) {
|
||||
item.cloudSkuName = ''
|
||||
return
|
||||
}
|
||||
|
||||
const matched = cloudSkuCatalog.value.find((sku) => sku.id === item.cloudSkuId)
|
||||
item.cloudSkuName = matched?.name || item.cloudSkuName || ''
|
||||
}
|
||||
|
||||
async function handleCloudSkuDropdownVisible(item: EditableItem, visible: boolean) {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCloudSkuCatalogLoaded()
|
||||
if (item.cloudSkuId && !item.cloudSkuName) {
|
||||
handleCloudSkuSelected(item, item.cloudSkuId)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : 'cloud SKU 列表查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCloudSkuCatalog() {
|
||||
try {
|
||||
await ensureCloudSkuCatalogLoaded(true)
|
||||
showSuccess(`cloud SKU 列表已刷新,共 ${cloudSkuCatalog.value.length} 条`)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : 'cloud SKU 列表查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cloudSkuCatalogLoading,
|
||||
cloudSkuCatalogErrorMessage,
|
||||
cloudSkuCatalog,
|
||||
getCloudSkuOptions,
|
||||
formatCloudSkuOptionLabel,
|
||||
handleCloudSkuSelected,
|
||||
handleCloudSkuDropdownVisible,
|
||||
refreshCloudSkuCatalog,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user