重构:前端架构优化 - 消灭巨石文件,统一代码规范
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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
actionLoading: boolean
|
||||
canManageTaskLifecycle: boolean
|
||||
canCloseTasks: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
regenerateClaimLink: []
|
||||
confirmRole: []
|
||||
supportRedeem: []
|
||||
prepareKuaishouCloud: []
|
||||
dispatchKuaishouCloud: []
|
||||
returnKuaishouCloud: []
|
||||
markManualReview: []
|
||||
submitManualDispatch: [outcome: 'delivered' | 'failed']
|
||||
closeTask: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="action-row">
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canRetry"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
重试任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canRegenerateClaimLink"
|
||||
:disabled="!detail.operations.canRegenerateClaimLink"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('regenerateClaimLink')"
|
||||
>
|
||||
重发链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canSupportConfirmRole"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="warning"
|
||||
@click="emit('confirmRole')"
|
||||
>
|
||||
客服确认角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canSupportRedeem"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('supportRedeem')"
|
||||
>
|
||||
客服开始兑换
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('prepareKuaishouCloud')"
|
||||
>
|
||||
准备绑定资源
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canDispatchKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('dispatchKuaishouCloud')"
|
||||
>
|
||||
确认绑定完成并发货
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canReturnKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="warning"
|
||||
@click="emit('returnKuaishouCloud')"
|
||||
>
|
||||
退还号码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canMarkManualReview"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('markManualReview')"
|
||||
>
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('submitManualDispatch', 'delivered')"
|
||||
>
|
||||
人工完成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('submitManualDispatch', 'failed')"
|
||||
>
|
||||
履约失败
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canCloseTasks"
|
||||
:disabled="!detail.operations.canClose"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
@click="emit('closeTask')"
|
||||
>
|
||||
关闭任务
|
||||
</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAutoDeliveryReason, formatAutoDeliveryTrigger } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
autoDeliveryStatus: string
|
||||
autoDeliverySummary: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>咸鱼自动发货</h3>
|
||||
<p class="section-copy">
|
||||
这里展示这条任务最近一次触发咸鱼自动发货的状态、原因和接口回执摘要。
|
||||
</p>
|
||||
|
||||
<div class="redeem-summary-grid">
|
||||
<article class="redeem-summary-card">
|
||||
<span>当前状态</span>
|
||||
<div class="auto-delivery-tag-line">
|
||||
<AdminStatusTag :status="autoDeliveryStatus" />
|
||||
</div>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>触发来源</span>
|
||||
<strong>{{
|
||||
formatAutoDeliveryTrigger(detail.task.agisoAutoDelivery?.trigger || '')
|
||||
}}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>最近执行</span>
|
||||
<strong>{{ formatAdminDateTime(detail.task.agisoAutoDelivery?.updatedAt) }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>接口回执</span>
|
||||
<strong>
|
||||
{{
|
||||
detail.task.agisoAutoDelivery?.requestId ||
|
||||
(detail.task.agisoAutoDelivery?.responseStatus
|
||||
? `HTTP ${detail.task.agisoAutoDelivery.responseStatus}`
|
||||
: '-')
|
||||
}}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="auto-delivery-note">
|
||||
<p>{{ autoDeliverySummary }}</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.reason">
|
||||
原因:{{ formatAutoDeliveryReason(detail.task.agisoAutoDelivery.reason) }}
|
||||
</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.responseStatus">
|
||||
HTTP:{{ detail.task.agisoAutoDelivery.responseStatus }}
|
||||
</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.requestId">
|
||||
请求 ID:{{ detail.task.agisoAutoDelivery.requestId }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatTaskEventPayload, formatTaskEventType } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>任务事件</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr v-for="event in detail.events" :key="event.eventId">
|
||||
<th>{{ formatTaskEventType(event.eventType) }}</th>
|
||||
<td>
|
||||
<div class="event-copy">
|
||||
{{ formatTaskEventPayload(event.payload) }}
|
||||
</div>
|
||||
<div class="event-time">
|
||||
{{ formatAdminDateTime(event.createdAt) }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
import type { SummaryCard } from '../composables/types'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
isKuaishouCloudTask: boolean
|
||||
summaryCards: SummaryCard[]
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Kuaishou Cloud hero card -->
|
||||
<section v-if="isKuaishouCloudTask && detail.kuaishouCloudFulfillment" class="hero-card">
|
||||
<div class="hero-copy">
|
||||
<span class="hero-eyebrow">{{ detail.task.executorKey || 'task' }}</span>
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
订单 {{ detail.order?.platformOrderId || '-' }} · 商品
|
||||
{{ detail.orderItem?.skuName || detail.orderItem?.skuCode || '-' }}
|
||||
· 店铺
|
||||
{{ detail.order?.shopName || detail.order?.shopId || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-meta-grid">
|
||||
<article
|
||||
v-for="card in summaryCards"
|
||||
:key="card.label"
|
||||
:class="['hero-metric', `hero-metric--${card.tone}`]"
|
||||
>
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div class="hero-inline">
|
||||
<span>系统绑定 <AdminStatusTag :status="detail.task.systemBindingStatus" /></span>
|
||||
<span>完整绑定 <AdminStatusTag :status="detail.task.userBindingStatus" /></span>
|
||||
<span v-if="detail.task.lastError">最近异常 {{ detail.task.lastError }}</span>
|
||||
</div>
|
||||
<div v-if="claimUrl" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</div>
|
||||
<div v-else-if="claimLinkInvalid" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<strong>已失效</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Simple info card for other task types -->
|
||||
<section v-else class="info-card">
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
状态:<AdminStatusTag :status="detail.task.status" /> · 订单:{{
|
||||
detail.order?.platformOrderId || '-'
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
执行器:{{ detail.task.executorKey || '-' }} · 履约:<AdminStatusTag
|
||||
:status="detail.task.deliveryStatus"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
系统绑定:<AdminStatusTag :status="detail.task.systemBindingStatus" /> ·
|
||||
完整绑定:<AdminStatusTag :status="detail.task.userBindingStatus" />
|
||||
</p>
|
||||
<p>
|
||||
角色:{{ detail.task.roleName || '-' }} /
|
||||
{{ detail.task.roleId || '-' }}
|
||||
</p>
|
||||
<p>最后错误:{{ detail.task.lastError || '-' }}</p>
|
||||
<p v-if="claimUrl">
|
||||
领取链接:
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</p>
|
||||
<p v-else-if="claimLinkInvalid">领取链接:已失效</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
canViewSensitiveTaskData: boolean
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>任务信息</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<td>{{ detail.orderItem?.skuName || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<td>{{ detail.orderItem?.skuCode || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>任务执行器</th>
|
||||
<td>{{ detail.task.executorKey || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>履约状态</th>
|
||||
<td>
|
||||
<AdminStatusTag :status="detail.task.deliveryStatus || ''" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>结果代码</th>
|
||||
<td>{{ detail.task.resultCode || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>结果说明</th>
|
||||
<td>{{ detail.task.resultMessage || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>库存凭据</th>
|
||||
<td>{{ detail.inventory?.displayValue || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>凭据类型</th>
|
||||
<td>{{ detail.inventory?.credentialType || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>库存状态</th>
|
||||
<td>
|
||||
<AdminStatusTag :status="detail.inventory?.status || ''" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>Claim Token</th>
|
||||
<td>{{ detail.claimToken?.token || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<th>库存 / Token</th>
|
||||
<td>客服账号不可查看库存凭据与原始领取 Token</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Token 状态</th>
|
||||
<td>
|
||||
<AdminStatusTag :status="detail.claimToken?.status || ''" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Token 过期</th>
|
||||
<td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>领取链接</th>
|
||||
<td>
|
||||
<a
|
||||
v-if="claimUrl"
|
||||
class="claim-link"
|
||||
:href="claimUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ claimUrl }}</a
|
||||
>
|
||||
<span v-else-if="claimLinkInvalid">已失效</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>浏览器会话</th>
|
||||
<td>{{ detail.task.browserSessionId || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>用户打开链接</th>
|
||||
<td>{{ formatAdminDateTime(detail.task.claimedAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>用户提交绑定</th>
|
||||
<td>{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>完整绑定成功</th>
|
||||
<td>{{ formatAdminDateTime(detail.task.redeemedAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>截图路径</th>
|
||||
<td>{{ detail.task.screenshotPath || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
canViewSensitiveTaskData: boolean
|
||||
actionLoading: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
releaseBinding: [bindingId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>库存绑定</h3>
|
||||
<p class="section-copy">
|
||||
这里展示任务当前和历史绑定过的库存项,便于核对多库存任务的真实履约轨迹。
|
||||
</p>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>绑定</th>
|
||||
<th>角色</th>
|
||||
<th>库存项</th>
|
||||
<th>状态</th>
|
||||
<th>凭据内容</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="detail.inventoryBindings.length === 0">
|
||||
<td colspan="7" class="table-empty">当前任务还没有库存绑定记录</td>
|
||||
</tr>
|
||||
<tr v-for="binding in detail.inventoryBindings" :key="binding.bindingId">
|
||||
<td>
|
||||
<div>#{{ binding.bindingId }}</div>
|
||||
<div v-if="binding.isPrimary" class="binding-primary">主库存项</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ binding.roleKey || '-' }}</div>
|
||||
<div class="binding-meta">数量 {{ binding.quantity }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>ID {{ binding.inventoryItemId }}</div>
|
||||
<div>{{ binding.skuCode || '-' }}</div>
|
||||
<div class="binding-meta">
|
||||
{{ binding.credentialType || '-' }} ·
|
||||
{{ binding.batchNo || '无批次' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="status-stack">
|
||||
<AdminStatusTag :status="binding.bindingStatus" />
|
||||
<AdminStatusTag :status="binding.inventoryStatus" />
|
||||
</div>
|
||||
<div v-if="binding.invalidReason" class="binding-meta">
|
||||
{{
|
||||
binding.inventoryStatus === 'invalid'
|
||||
? '作废原因'
|
||||
: binding.inventoryStatus === 'consumed'
|
||||
? '消耗原因'
|
||||
: '处理原因'
|
||||
}}:{{ binding.invalidReason }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ canViewSensitiveTaskData ? binding.displayValue || '-' : '客服不可见' }}
|
||||
</td>
|
||||
<td>
|
||||
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
|
||||
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
|
||||
<div class="binding-meta">释放 {{ formatAdminDateTime(binding.releasedAt) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin') && binding.canRelease"
|
||||
:loading="actionLoading"
|
||||
link
|
||||
@click="emit('releaseBinding', binding.bindingId)"
|
||||
>
|
||||
释放绑定
|
||||
</el-button>
|
||||
<span v-else class="binding-meta">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { ManualDispatchForm } from '../composables/types'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
form: ManualDispatchForm
|
||||
canManageTaskLifecycle: boolean
|
||||
actionLoading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:form': [form: ManualDispatchForm]
|
||||
submit: [outcome: 'delivered' | 'failed']
|
||||
}>()
|
||||
|
||||
function updateField(field: keyof ManualDispatchForm, value: string) {
|
||||
emit('update:form', { ...props.form, [field]: value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>人工履约回写</h3>
|
||||
<div class="manual-grid">
|
||||
<label class="field-block">
|
||||
<span>外部流水 / 单号</span>
|
||||
<el-input
|
||||
:model-value="form.deliveryReference"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="120"
|
||||
placeholder="例如快递单号、平台消息回执号"
|
||||
@update:model-value="updateField('deliveryReference', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>已交付内容</span>
|
||||
<el-input
|
||||
:model-value="form.deliveredCredential"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="500"
|
||||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
@update:model-value="updateField('deliveredCredential', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>处理备注</span>
|
||||
<el-input
|
||||
:model-value="form.resultMessage"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="500"
|
||||
placeholder="说明实际履约结果,失败时建议写清原因"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
@update:model-value="updateField('resultMessage', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="manual-hint">回写后会直接更新任务终态,不再走旧式领取链接流程。</p>
|
||||
<div v-if="detail.manualDispatch" class="manual-summary">
|
||||
<p>
|
||||
最近回写:<AdminStatusTag
|
||||
:status="detail.manualDispatch.outcome || detail.task.deliveryStatus"
|
||||
/>
|
||||
· {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}
|
||||
</p>
|
||||
<p>
|
||||
处理人:{{ detail.manualDispatch.completedBy?.username || '-' }} /
|
||||
{{ detail.manualDispatch.completedBy?.role || '-' }}
|
||||
</p>
|
||||
<p>流水号:{{ detail.manualDispatch.deliveryReference || '-' }}</p>
|
||||
<p>交付内容:{{ detail.manualDispatch.deliveredCredential || '-' }}</p>
|
||||
<p>备注:{{ detail.manualDispatch.resultMessage || '-' }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatRedeemOutcomeLabel, formatRedeemResolutionStatus } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>兑换重试链路</h3>
|
||||
<p class="section-copy">
|
||||
这里记录自动兑换时每次使用过的 CDK、判定结果,以及是否切换到了新的同类型凭据。
|
||||
</p>
|
||||
|
||||
<div class="redeem-summary-grid">
|
||||
<article class="redeem-summary-card">
|
||||
<span>处理结果</span>
|
||||
<strong>{{ formatRedeemResolutionStatus(detail.redeemResolution!.status) }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>任务收口状态</span>
|
||||
<strong>{{ detail.redeemResolution!.taskStatus || detail.task.status || '-' }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>更换新码次数</span>
|
||||
<strong>{{ detail.redeemResolution!.replacementCount }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>完成时间</span>
|
||||
<strong>{{ formatAdminDateTime(detail.redeemResolution!.finishedAt) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.redeemResolution!.attempts.length > 0" class="redeem-attempts">
|
||||
<article
|
||||
v-for="attempt in detail.redeemResolution!.attempts"
|
||||
:key="`${attempt.attempt}-${attempt.inventoryItemId || 'na'}`"
|
||||
class="redeem-attempt-card"
|
||||
>
|
||||
<div class="redeem-attempt-head">
|
||||
<strong>第 {{ attempt.attempt }} 次尝试</strong>
|
||||
<span class="redeem-outcome">{{ formatRedeemOutcomeLabel(attempt.outcome) }}</span>
|
||||
</div>
|
||||
<div class="redeem-attempt-grid">
|
||||
<div>
|
||||
<span>库存项</span>
|
||||
<strong>{{ attempt.inventoryItemId ? `#${attempt.inventoryItemId}` : '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>凭据</span>
|
||||
<strong>{{ attempt.codeMasked || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>类型</span>
|
||||
<strong>{{ attempt.credentialType || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>结果码</span>
|
||||
<strong>{{ attempt.resultCode || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="redeem-attempt-copy">
|
||||
{{ attempt.resultMessage || '-' }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
screenshotPreviewUrl: string
|
||||
sectionTitle: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>{{ sectionTitle }}</h3>
|
||||
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取。</p>
|
||||
<a
|
||||
v-else
|
||||
class="screenshot-link"
|
||||
:href="screenshotPreviewUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>打开原图</a
|
||||
>
|
||||
<img
|
||||
v-if="screenshotPreviewUrl"
|
||||
class="screenshot-preview"
|
||||
:src="screenshotPreviewUrl"
|
||||
:alt="sectionTitle"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
export type KuaishouCloudFlow = NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||
|
||||
export type SummaryCard = {
|
||||
label: string
|
||||
value: string
|
||||
tone: 'blue' | 'green' | 'amber' | 'slate'
|
||||
}
|
||||
|
||||
export type RoleInfoEntry = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ChecklistItem = {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type ManualDispatchForm = {
|
||||
deliveryReference: string
|
||||
deliveredCredential: string
|
||||
resultMessage: string
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
fetchAdminTaskScreenshot,
|
||||
fetchAdminTaskDetail,
|
||||
markAdminTaskManualReview,
|
||||
redeemAdminTaskAssisted,
|
||||
regenerateAdminTaskClaimLink,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
retryAdminTask,
|
||||
confirmAdminTaskAssistedRole,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import type { ManualDispatchForm } from './types'
|
||||
|
||||
export function useAdminTaskDetail() {
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<AdminTaskDetail | null>(null)
|
||||
const lastClaimUrl = ref('')
|
||||
const screenshotPreviewUrl = ref('')
|
||||
const manualDispatchForm = ref<ManualDispatchForm>({
|
||||
deliveryReference: '',
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
|
||||
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
|
||||
const canCloseTasks = computed(() => hasAdminRole('support'))
|
||||
const canViewSensitiveTaskData = computed(() =>
|
||||
Boolean(detail.value?.operations.canViewSensitiveTaskData),
|
||||
)
|
||||
|
||||
const claimUrl = computed(() => {
|
||||
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
||||
const taskStatus = String(detail.value?.task.status || '').trim()
|
||||
|
||||
if (tokenStatus !== 'active' || ['closed', 'expired'].includes(taskStatus)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return lastClaimUrl.value || detail.value?.claimToken?.claimUrl || ''
|
||||
})
|
||||
|
||||
const claimLinkInvalid = computed(() => {
|
||||
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
||||
return Boolean(detail.value?.claimToken?.claimUrl) && tokenStatus && tokenStatus !== 'active'
|
||||
})
|
||||
|
||||
const screenshotSectionTitle = computed(() => {
|
||||
if (detail.value?.task.status === 'redeemed') {
|
||||
return '结果截图'
|
||||
}
|
||||
|
||||
return detail.value?.review.required ? '客服复核截图' : '结果截图'
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
|
||||
detail.value = response.data
|
||||
syncManualDispatchForm(response.data)
|
||||
await loadScreenshotPreview(response.data)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) {
|
||||
try {
|
||||
await showConfirm(confirmText, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await action()
|
||||
if (response.data.claimUrl) {
|
||||
lastClaimUrl.value = response.data.claimUrl
|
||||
}
|
||||
showSuccess(successMessage)
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview(taskDetail: AdminTaskDetail) {
|
||||
clearScreenshotPreview()
|
||||
|
||||
if (!taskDetail.screenshotUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await fetchAdminTaskScreenshot(taskDetail.task.taskId)
|
||||
screenshotPreviewUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
if (screenshotPreviewUrl.value) {
|
||||
URL.revokeObjectURL(screenshotPreviewUrl.value)
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
|
||||
manualDispatchForm.value = {
|
||||
deliveryReference: taskDetail.manualDispatch?.deliveryReference || '',
|
||||
deliveredCredential: taskDetail.manualDispatch?.deliveredCredential || '',
|
||||
resultMessage:
|
||||
taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
|
||||
const confirmText =
|
||||
outcome === 'failed'
|
||||
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
|
||||
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
|
||||
|
||||
await runAction(
|
||||
() =>
|
||||
completeAdminTaskManualDispatch(detail.value!.task.taskId, {
|
||||
outcome,
|
||||
resultMessage: manualDispatchForm.value.resultMessage,
|
||||
deliveryReference: manualDispatchForm.value.deliveryReference,
|
||||
deliveredCredential: manualDispatchForm.value.deliveredCredential,
|
||||
}),
|
||||
actionLabel,
|
||||
confirmText,
|
||||
)
|
||||
}
|
||||
|
||||
async function copyText(value: string, successMessage: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
showError('当前没有可复制的内容')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess(successMessage)
|
||||
} catch {
|
||||
showError('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalLink(value: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
window.open(text, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function resolveTaskAutoDeliveryStatus(taskDetail: AdminTaskDetail) {
|
||||
if (taskDetail.task.agisoAutoDelivery?.status) {
|
||||
return taskDetail.task.agisoAutoDelivery.status
|
||||
}
|
||||
|
||||
return taskDetail.task.deliveryStatus === 'delivered' ? 'pending' : 'waiting'
|
||||
}
|
||||
|
||||
function resolveTaskAutoDeliverySummary(taskDetail: AdminTaskDetail) {
|
||||
const autoDelivery = taskDetail.task.agisoAutoDelivery
|
||||
|
||||
if (autoDelivery?.errorMessage) {
|
||||
return autoDelivery.errorMessage
|
||||
}
|
||||
|
||||
if (autoDelivery?.reason) {
|
||||
return autoDelivery.reason
|
||||
}
|
||||
|
||||
return taskDetail.task.deliveryStatus === 'delivered'
|
||||
? '当前任务已交付,等待订单下其他任务完成后统一自动发货'
|
||||
: '当前任务尚未完成交付,暂不会触发自动发货'
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearScreenshotPreview)
|
||||
|
||||
return {
|
||||
loading,
|
||||
actionLoading,
|
||||
errorMessage,
|
||||
detail,
|
||||
screenshotPreviewUrl,
|
||||
manualDispatchForm,
|
||||
canManageTaskLifecycle,
|
||||
canCloseTasks,
|
||||
canViewSensitiveTaskData,
|
||||
claimUrl,
|
||||
claimLinkInvalid,
|
||||
screenshotSectionTitle,
|
||||
loadDetail,
|
||||
runAction,
|
||||
submitManualDispatch,
|
||||
copyText,
|
||||
openExternalLink,
|
||||
resolveTaskAutoDeliveryStatus,
|
||||
resolveTaskAutoDeliverySummary,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatKuaishouRoleInfoLabel } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { ChecklistItem, RoleInfoEntry, SummaryCard } from './types'
|
||||
|
||||
export function useAdminTaskKuaishouCloud(
|
||||
detail: Ref<AdminTaskDetail | null>,
|
||||
runAction: (
|
||||
action: () => Promise<{ data: any }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const kuaishouCloudFlow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
||||
const kuaishouCloudRole = computed(() => kuaishouCloudFlow.value?.role || null)
|
||||
const isKuaishouCloudTask = computed(
|
||||
() => detail.value?.task.executorKey === 'kuaishou_ct_assisted',
|
||||
)
|
||||
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
if (!detail.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '任务状态',
|
||||
value: detail.value.task.status || '-',
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
label: '履约状态',
|
||||
value: detail.value.task.deliveryStatus || '-',
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
label: '当前角色',
|
||||
value: kuaishouCloudRole.value?.name || detail.value.task.roleName || '-',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: '角色 ID',
|
||||
value: kuaishouCloudRole.value?.rid || detail.value.task.roleId || '-',
|
||||
tone: 'slate',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const kuaishouRoleInfoEntries = computed<RoleInfoEntry[]>(() => {
|
||||
const rawInfo = kuaishouCloudRole.value?.rawInfo
|
||||
if (!rawInfo) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(rawInfo)
|
||||
.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== '')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: formatKuaishouRoleInfoLabel(key),
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
}))
|
||||
})
|
||||
|
||||
const kuaishouCloudChecklist = computed<ChecklistItem[]>(() => {
|
||||
const flow = kuaishouCloudFlow.value
|
||||
const role = kuaishouCloudRole.value
|
||||
if (!flow) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'ticket',
|
||||
label: '核销码校验',
|
||||
status: flow.ticket.status || 'pending',
|
||||
detail: flow.ticket.verifiedAt
|
||||
? `已于 ${formatAdminDateTime(flow.ticket.verifiedAt)} 校验`
|
||||
: '等待客户提交并校验核销码',
|
||||
},
|
||||
{
|
||||
key: 'bind',
|
||||
label: '绑定资源',
|
||||
status: flow.binding.prepareStatus || 'pending',
|
||||
detail: flow.binding.bindPreparedAt
|
||||
? `绑定资源已准备,VN ${flow.binding.vnId || '-'}`
|
||||
: '等待准备 Cloud 绑定资源',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色识别',
|
||||
status: role?.status || 'pending',
|
||||
detail:
|
||||
role?.name || role?.rid
|
||||
? `${role?.name || '-'} / ${role?.rid || '-'}`
|
||||
: '客户绑定后刷新角色信息',
|
||||
},
|
||||
{
|
||||
key: 'dispatch',
|
||||
label: '发货执行',
|
||||
status: flow.dispatch.status || 'pending',
|
||||
detail: flow.dispatch.dispatchAt
|
||||
? `已于 ${formatAdminDateTime(flow.dispatch.dispatchAt)} 发货`
|
||||
: '等待客服确认绑定并发货',
|
||||
},
|
||||
{
|
||||
key: 'return',
|
||||
label: '退还号码',
|
||||
status: flow.returnNumber.status || 'pending',
|
||||
detail: flow.returnNumber.returnedAt
|
||||
? `已于 ${formatAdminDateTime(flow.returnNumber.returnedAt)} 退号`
|
||||
: '发货后执行退还号码',
|
||||
},
|
||||
{
|
||||
key: 'consume',
|
||||
label: '快手核销',
|
||||
status: flow.consume.status || 'pending',
|
||||
detail: flow.consume.consumedAt
|
||||
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成核销`
|
||||
: flow.consume.errorMessage || '等待退号后核销收口',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
async function submitKuaishouCloudPrepare() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => prepareAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'绑定资源已准备完成',
|
||||
`确认开始为任务 ${detail.value.task.taskNo} 准备绑定资源吗?系统会自动检查背包、余额并申请虚拟号。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudDispatch() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'已完成绑定确认并发货',
|
||||
`确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudRefreshRoleInfo() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => refreshAdminTaskKuaishouCloudRoleInfo(detail.value!.task.taskId),
|
||||
'角色信息已刷新',
|
||||
`确认刷新任务 ${detail.value.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 cloudtentacles 的绑定结果。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudReturnNumber() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => returnNumberAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'号码已退还',
|
||||
`确认退还任务 ${detail.value.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
kuaishouCloudFlow,
|
||||
kuaishouCloudRole,
|
||||
isKuaishouCloudTask,
|
||||
summaryCards,
|
||||
kuaishouRoleInfoEntries,
|
||||
kuaishouCloudChecklist,
|
||||
submitKuaishouCloudPrepare,
|
||||
submitKuaishouCloudDispatch,
|
||||
submitKuaishouCloudRefreshRoleInfo,
|
||||
submitKuaishouCloudReturnNumber,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user