重构:前端架构优化 - 消灭巨石文件,统一代码规范

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:
yml2213
2026-05-17 09:26:13 +08:00
parent d8c411d67f
commit a77198b218
81 changed files with 6498 additions and 5248 deletions
@@ -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,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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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))
@@ -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,
}
}
@@ -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,4 +1,4 @@
@import '../../../styles/admin-config-pages.css';
@import '../../../../styles/admin-config-pages.css';
/* ============================================================
AdminKuaishouCloudFulfillment 蓝色主题覆盖 & 双栏映射布局
@@ -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>
@@ -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=91kaquanplatform=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>
@@ -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>
@@ -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>
@@ -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'
@@ -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,
}
}
@@ -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,
}
}
@@ -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,
}
}