前面的ok了, 绑定与角色

This commit is contained in:
yml
2026-05-03 20:58:18 +08:00
parent dd2192b6ae
commit 0f0253bffe
30 changed files with 1900 additions and 409 deletions
@@ -5,6 +5,7 @@ import { showSuccess } from '@/lib/feedback'
import {
fetchAdminFulfillmentBindingConfigs,
fetchAdminKhhaoSourceConfig,
fetchAdminKuaishouEticketSourceConfig,
queryAdminKhhaoOrders,
lookupAdminFulfillmentBindingOrder,
saveAdminFulfillmentBindingConfigs,
@@ -14,6 +15,7 @@ import type {
AdminFulfillmentLookupItem,
AdminFulfillmentLookupResult,
AdminKhhaoOrderPreview,
AdminKuaishouEticketShopConfigItem,
AdminObservedProductItem,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
@@ -24,6 +26,8 @@ type EditableBinding = {
provider: string
platform: string
shopId: string
shopName: string
khhaoShopId: string
skuCode: string
skuName: string
profileKey: string
@@ -39,6 +43,8 @@ type SaveBindingPayload = {
provider: string
platform: string
shopId: string
shopName: string
khhaoShopId: string
skuCode: string
skuName: string
profileKey: string
@@ -54,7 +60,7 @@ type SaveBindingPayload = {
}
}
type ValidationField = 'skuCode' | 'match' | 'profileKey'
type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId' | 'khhaoShopId'
type ValidationState = {
bindingId: string
@@ -75,6 +81,7 @@ const errorMessage = ref('')
const filePath = ref('')
const bindings = ref<EditableBinding[]>([])
const observedProducts = ref<AdminObservedProductItem[]>([])
const kuaishouShopOptions = ref<AdminKuaishouEticketShopConfigItem[]>([])
const lookupLoading = ref(false)
const lookupErrorMessage = ref('')
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
@@ -119,8 +126,10 @@ const pendingObservedCount = computed(() => observedProducts.value.filter((item)
type ImportableProductCandidate = Pick<
AdminObservedProductItem,
'provider' | 'platform' | 'shopId' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
>
'provider' | 'platform' | 'shopId' | 'shopName' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
> & {
khhaoShopId?: string
}
function createEmptyBinding(): EditableBinding {
return {
@@ -128,6 +137,8 @@ function createEmptyBinding(): EditableBinding {
provider: 'agiso',
platform: '',
shopId: '',
shopName: '',
khhaoShopId: '',
skuCode: '',
skuName: '',
profileKey: 'manual_review',
@@ -141,11 +152,14 @@ function createEmptyBinding(): EditableBinding {
}
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
return {
const matchedShop = resolveKuaishouShopById(item.shopId) || resolveKuaishouShopByName(item.shopName || '')
const binding = {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
shopId: matchedShop?.shopId || item.shopId,
shopName: item.shopName || matchedShop?.kshopName || '',
khhaoShopId: item.khhaoShopId || '',
skuCode: item.skuCode,
skuName: item.skuName,
profileKey: item.profileKey || 'manual_review',
@@ -156,6 +170,9 @@ function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBi
externalSkuName: item.match.externalSkuName,
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
}
syncBindingShopSelection(binding)
return binding
}
function hasMatchCondition(item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
@@ -203,16 +220,64 @@ function getBindingStatusLabel(binding: EditableBinding) {
}
function getBindingSummary(binding: EditableBinding) {
const shopLabel = isKhhaoKuaishouBinding(binding)
? [
binding.shopName.trim() || binding.shopId.trim() || '未选快手店铺',
binding.khhaoShopId.trim() ? `khhao#${binding.khhaoShopId.trim()}` : '',
].filter(Boolean).join(' / ')
: (binding.shopName.trim() || binding.shopId.trim() || '跨店铺')
const parts = [
binding.provider.trim() || 'agiso',
binding.platform.trim() || '未选平台',
binding.shopId.trim() || '跨店铺',
shopLabel,
binding.profileKey.trim() || 'manual_review',
]
return parts.join(' / ')
}
function isKhhaoKuaishouBinding(binding: Pick<EditableBinding, 'provider' | 'platform'>) {
return binding.provider.trim() === 'khhao' && binding.platform.trim() === 'kuaishou'
}
function resolveKuaishouShopById(shopId: string) {
const normalizedShopId = shopId.trim()
return kuaishouShopOptions.value.find((item) => item.shopId === normalizedShopId) || null
}
function resolveKuaishouShopByName(shopName: string) {
const normalizedShopName = shopName.trim()
return kuaishouShopOptions.value.find((item) => item.kshopName === normalizedShopName) || null
}
function getKuaishouShopOptionLabel(item: AdminKuaishouEticketShopConfigItem) {
return item.kshopName.trim()
? `${item.kshopName} (${item.shopId})`
: item.shopId
}
function syncBindingShopSelection(binding: EditableBinding) {
if (!isKhhaoKuaishouBinding(binding)) {
return
}
const matchedById = resolveKuaishouShopById(binding.shopId)
if (matchedById) {
binding.shopId = matchedById.shopId
binding.shopName = matchedById.kshopName
return
}
const matchedByName = resolveKuaishouShopByName(binding.shopName)
if (matchedByName) {
binding.shopId = matchedByName.shopId
binding.shopName = matchedByName.kshopName
return
}
binding.shopId = ''
}
async function loadConfigs() {
if (!hasAdminRole('admin')) {
loading.value = false
@@ -223,11 +288,13 @@ async function loadConfigs() {
errorMessage.value = ''
try {
const [response, khhaoSourceResponse] = await Promise.all([
const [response, khhaoSourceResponse, kuaishouSourceResponse] = await Promise.all([
fetchAdminFulfillmentBindingConfigs(),
fetchAdminKhhaoSourceConfig(),
fetchAdminKuaishouEticketSourceConfig(),
])
filePath.value = response.data.filePath
kuaishouShopOptions.value = kuaishouSourceResponse.data.source.shops.filter((item) => item.enabled !== false)
const nextBindings = response.data.bindings.map(mapEditableBinding)
bindings.value = nextBindings
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
@@ -259,11 +326,13 @@ function removeBinding(id: string) {
}
function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding {
return {
const binding = {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
shopName: item.shopName,
khhaoShopId: item.khhaoShopId || '',
skuCode: '',
skuName: item.externalSkuName,
profileKey: 'manual_review',
@@ -274,6 +343,9 @@ function createBindingFromProductCandidate(item: ImportableProductCandidate): Ed
externalSkuName: item.externalSkuName,
resolvedSkuName: item.externalSkuName,
}
syncBindingShopSelection(binding)
return binding
}
function importObservedProduct(item: ImportableProductCandidate) {
@@ -298,7 +370,11 @@ function importKhhaoProduct(item: AdminKhhaoOrderPreview) {
importObservedProduct({
provider: item.provider || 'khhao',
platform: item.platform || 'unknown',
shopId: item.shopId,
shopId: isKhhaoKuaishouBinding({ provider: item.provider || 'khhao', platform: item.platform || 'unknown' })
? (item.kuaishouShopId || resolveKuaishouShopByName(item.shopName)?.shopId || '')
: item.shopId,
shopName: item.shopName,
khhaoShopId: item.khhaoShopId || item.shopId,
externalSkuCode: item.skuCode,
externalItemId: item.itemId,
externalSkuName: item.itemTitle,
@@ -314,10 +390,14 @@ function isKhhaoProductImported(platformOrderId: string) {
}
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
syncBindingShopSelection(item)
return {
provider: item.provider.trim() || 'agiso',
platform: item.platform.trim(),
shopId: item.shopId.trim(),
shopName: item.shopName.trim(),
khhaoShopId: item.khhaoShopId.trim(),
skuCode: item.skuCode.trim(),
skuName: item.skuName.trim(),
profileKey: item.profileKey.trim() || 'manual_review',
@@ -338,6 +418,8 @@ function hasBindingContent(item: SaveBindingPayload) {
return Boolean(
item.platform
|| item.shopId
|| item.shopName
|| item.khhaoShopId
|| item.skuCode
|| item.skuName
|| item.match.externalSkuCode
@@ -353,6 +435,24 @@ function hasBindingContent(item: SaveBindingPayload) {
function resolveBindingValidationState(item: SaveBindingPayload, index: number, bindingId: string): ValidationState {
const label = `${index + 1} 条规则`
const provider = item.provider.trim() || 'agiso'
const platform = item.platform.trim()
if (provider === 'khhao' && platform === 'kuaishou' && (!item.shopId || !item.shopName)) {
return {
bindingId,
field: 'shopId',
message: `${label} 需要选择已配置的快手官方店铺`,
}
}
if (provider === 'khhao' && platform === 'kuaishou' && !item.khhaoShopId) {
return {
bindingId,
field: 'khhaoShopId',
message: `${label} 需要填写 khhao 店铺 ID`,
}
}
if (!item.skuCode) {
return {
@@ -397,6 +497,8 @@ async function focusValidationTarget(state: ValidationState) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
const selectors: Record<ValidationField, string> = {
khhaoShopId: '[data-field="khhaoShopId"]',
shopId: '[data-field="shopId"]',
skuCode: '[data-field="skuCode"]',
match: '[data-field="externalSkuCode"]',
profileKey: '[data-field="profileKey"]',
@@ -426,6 +528,16 @@ function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSku
return validationState.value.field === field
}
function handleBindingProviderPlatformChange(binding: EditableBinding) {
syncBindingShopSelection(binding)
clearValidationState()
}
function handleKuaishouShopChange(binding: EditableBinding) {
syncBindingShopSelection(binding)
clearValidationState()
}
async function saveConfigs() {
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
@@ -649,18 +761,90 @@ onMounted(loadConfigs)
<div v-else class="binding-grid">
<label class="field-block">
<span>渠道</span>
<input v-model="binding.provider" class="text-input" placeholder="agiso" @input="clearValidationState" />
<input
v-model="binding.provider"
class="text-input"
placeholder="agiso"
@input="handleBindingProviderPlatformChange(binding)"
/>
</label>
<label class="field-block">
<span>平台</span>
<input v-model="binding.platform" class="text-input" placeholder="xianyu / taobao / pdd" @input="clearValidationState" />
<input
v-model="binding.platform"
class="text-input"
placeholder="xianyu / taobao / pdd"
@input="handleBindingProviderPlatformChange(binding)"
/>
</label>
<label class="field-block">
<span>店铺 ID</span>
<input v-model="binding.shopId" class="text-input" placeholder="留空表示跨店铺通用" @input="clearValidationState" />
</label>
<template v-if="isKhhaoKuaishouBinding(binding)">
<label class="field-block">
<span>khhao 店铺 ID</span>
<input
v-model="binding.khhaoShopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'khhaoShopId') }]"
data-field="khhaoShopId"
placeholder="例如 12 / 13 / 14"
@input="clearValidationState"
/>
<small class="field-help">这里填 khhao 系统里的店铺编号专门用于同步订单命中规则</small>
</label>
<label class="field-block">
<span>快手店铺</span>
<select
v-model="binding.shopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
data-field="shopId"
@change="handleKuaishouShopChange(binding)"
>
<option value="">请选择已配置的快手官方店铺</option>
<option
v-for="shop in kuaishouShopOptions"
:key="shop.shopId"
:value="shop.shopId"
>
{{ getKuaishouShopOptionLabel(shop) }}
</option>
</select>
<small class="field-help">这里会直接写入快手官方店铺 ID不再使用 khhao 内部店铺编号</small>
</label>
<label class="field-block">
<span>快手店铺名</span>
<input
:value="binding.shopName || ''"
class="text-input text-input--readonly"
placeholder="选择店铺后自动带出"
readonly
/>
</label>
</template>
<template v-else>
<label class="field-block">
<span>店铺 ID</span>
<input
v-model="binding.shopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
data-field="shopId"
placeholder="留空表示跨店铺通用"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>店铺名称</span>
<input
v-model="binding.shopName"
class="text-input"
placeholder="用于保存和识别店铺"
@input="clearValidationState"
/>
</label>
</template>
<label class="field-block">
<span>优先级</span>
@@ -825,7 +1009,8 @@ onMounted(loadConfigs)
<td>
<div class="cell-stack">
<strong>{{ item.shopName || item.shopId || '-' }}</strong>
<span class="cell-subtle">ID: {{ item.shopId || '-' }}</span>
<span class="cell-subtle">khhao ID: {{ item.khhaoShopId || item.shopId || '-' }}</span>
<span v-if="item.kuaishouShopId" class="cell-subtle">快手 ID: {{ item.kuaishouShopId }}</span>
</div>
</td>
<td>
@@ -1379,6 +1564,11 @@ onMounted(loadConfigs)
box-shadow: 0 0 0 3px rgba(254, 226, 226, 0.95);
}
.text-input--readonly {
color: #475467;
background: rgba(148, 163, 184, 0.08);
}
.binding-inline-error {
margin: 10px 0 0;
color: #b42318;
@@ -36,9 +36,6 @@ const manualDispatchForm = reactive({
deliveredCredential: '',
resultMessage: '',
})
const kuaishouCloudForm = reactive({
ticketCode: '',
})
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
const canCloseTasks = computed(() => hasAdminRole('support'))
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
@@ -74,7 +71,6 @@ async function loadDetail() {
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
detail.value = response.data
syncManualDispatchForm(response.data)
syncKuaishouCloudForm(response.data)
await loadScreenshotPreview(response.data)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
@@ -142,10 +138,6 @@ function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
}
function syncKuaishouCloudForm(taskDetail: AdminTaskDetail) {
kuaishouCloudForm.ticketCode = taskDetail.kuaishouCloudFulfillment?.ticket.code || ''
}
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
if (!detail.value) {
return
@@ -186,9 +178,7 @@ async function submitKuaishouCloudDispatch() {
}
await runAction(
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId, {
ticketCode: kuaishouCloudForm.ticketCode,
}),
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
'已完成绑定确认并发货',
`确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把“确认绑定完成”和“发货”合并为一步。`,
)
@@ -611,17 +601,28 @@ onBeforeUnmount(clearScreenshotPreview)
</article>
</div>
<div class="flow-form-card">
<label class="field-block">
<span>客户提供的卡券号</span>
<el-input
v-model="kuaishouCloudForm.ticketCode"
:disabled="!canManageTaskLifecycle"
maxlength="120"
placeholder="客户下单后提供的卡券号,可在发货时一并记录"
/>
</label>
<p class="manual-hint">确认绑定完成并发货已经合并为一个按钮不再拆成两步</p>
<div class="flow-grid">
<article class="flow-block">
<h4>客户领取页</h4>
<div class="flow-meta-list">
<div><span>客户链接</span><strong>{{ claimUrl || '-' }}</strong></div>
<div><span>Token 状态</span><strong>{{ detail.claimToken?.status || '-' }}</strong></div>
<div><span>首次提交</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}</strong></div>
<div><span>校验时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.verifiedAt) }}</strong></div>
</div>
<p class="manual-hint">客户会先在这个页面查看图文指引并提交核销码校验通过后才会自动跳转绑定链接</p>
</article>
<article class="flow-block">
<h4>核销码状态</h4>
<div class="flow-meta-list">
<div><span>核销码</span><strong>{{ kuaishouCloudFlow.ticket.code || '-' }}</strong></div>
<div><span>校验状态</span><strong>{{ kuaishouCloudFlow.ticket.status || '-' }}</strong></div>
<div><span>商品标题</span><strong>{{ kuaishouCloudFlow.ticket.goodsTitle || '-' }}</strong></div>
<div><span>剩余次数</span><strong>{{ kuaishouCloudFlow.ticket.leftCount || 0 }}</strong></div>
</div>
<p class="manual-hint">确认绑定完成并发货已经合并为一个按钮只有客户提交并校验过核销码后才能继续</p>
</article>
</div>
<div class="flow-grid">
@@ -643,6 +644,8 @@ onBeforeUnmount(clearScreenshotPreview)
<div><span>自动退号</span><strong>{{ kuaishouCloudFlow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>自动核销</span><strong>{{ kuaishouCloudFlow.consume.autoConsumeEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>核销店铺</span><strong>{{ kuaishouCloudFlow.consume.shopId || '-' }}</strong></div>
<div><span>核销时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.consume.consumedAt) }}</strong></div>
<div><span>核销异常</span><strong>{{ kuaishouCloudFlow.consume.errorMessage || '-' }}</strong></div>
<div><span>备注</span><strong>{{ kuaishouCloudFlow.notes || '-' }}</strong></div>
</div>
</article>