删除旧履约配置逻辑

This commit is contained in:
yml
2026-05-27 13:12:07 +08:00
parent dd6776c4e4
commit c7e2a225bc
34 changed files with 13 additions and 3215 deletions
-5
View File
@@ -68,11 +68,6 @@ const router = createRouter({
meta: { allowedRoles: ['admin'] },
component: () => import('@/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue'),
},
{
path: 'platform-kuaishou-cloud-fulfillment',
meta: { allowedRoles: ['admin'] },
redirect: '/admin/platform-fulfillment?tab=kuaishou-cloud',
},
{
path: 'audit-logs',
meta: { allowedRoles: ['admin'] },
@@ -3,4 +3,3 @@ export * from './scheduled-jobs'
export * from './ninetyone'
export * from './kuaishou-eticket'
export * from './cloudtentacles'
export * from './kuaishou-cloud-fulfillment'
@@ -1,18 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminKuaishouCloudFulfillmentConfig } from '@/types/admin'
export function fetchAdminKuaishouCloudFulfillmentConfig() {
return apiGet<{
filePath: string
source: AdminKuaishouCloudFulfillmentConfig
}>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment')
}
export function saveAdminKuaishouCloudFulfillmentConfig(
payload: AdminKuaishouCloudFulfillmentConfig,
) {
return apiPost<{
filePath: string
source: AdminKuaishouCloudFulfillmentConfig
}>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment', payload)
}
-3
View File
@@ -66,7 +66,4 @@ export type {
AdminCloudtentaclesSkuListResult,
AdminCloudtentaclesDeliveryRecordItem,
AdminCloudtentaclesDeliveryRecordListResult,
AdminKuaishouCloudDeliveryItem,
AdminKuaishouCloudFulfillmentItem,
AdminKuaishouCloudFulfillmentConfig,
} from './platform-config'
@@ -47,9 +47,3 @@ export type {
AdminCloudtentaclesDeliveryRecordItem,
AdminCloudtentaclesDeliveryRecordListResult,
} from './cloudtentacles'
export type {
AdminKuaishouCloudDeliveryItem,
AdminKuaishouCloudFulfillmentItem,
AdminKuaishouCloudFulfillmentConfig,
} from './kuaishou-cloud-fulfillment'
@@ -1,37 +0,0 @@
export interface AdminKuaishouCloudDeliveryItem {
cloudSkuId: number
cloudSkuName: string
quantity: number
}
export interface AdminKuaishouCloudFulfillmentItem {
id: string
enabled: boolean
priority: number
provider: string
platform: string
shopId: string
internalSkuCode: string
internalSkuName: string
externalSkuCode: string
externalItemId: string
externalSkuName: string
resolvedSkuName: string
cloudSourceKeys: string[]
cloudSkuId: number
cloudSkuName: string
deliveryItems: AdminKuaishouCloudDeliveryItem[]
vnKey: string
autoBuyEnabled: boolean
minAssetReserve: number
autoReturnNumberAfterDispatch: boolean
autoConsumeAfterDispatch: boolean
kuaishouConsumeShopId: string
kuaishouConsumeShopName: string
notes: string
}
export interface AdminKuaishouCloudFulfillmentConfig {
enabled: boolean
items: AdminKuaishouCloudFulfillmentItem[]
}
@@ -1,165 +0,0 @@
<script setup lang="ts">
import type { AdminNinetyoneOrderItem } from '@/types/admin'
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>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header card-header--split">
<div>
<span class="card-title">91卡券订单取样导入</span>
<span class="card-desc"
>查询已接收的 91卡券订单 productNo 导入为规则草稿再补齐内部 SKU cloud
资源</span
>
</div>
<el-space wrap>
<el-tag effect="plain">结果 {{ ninetyoneLookupMetrics.total }}</el-tag>
<el-tag type="warning" effect="plain"
>待补 {{ ninetyoneLookupMetrics.pendingCount }}</el-tag
>
<el-tag type="success" effect="plain"
>待导入 {{ ninetyoneLookupMetrics.availableCount }}</el-tag
>
</el-space>
</div>
</template>
<div class="lookup-panel">
<el-form label-position="top" class="lookup-toolbar">
<el-form-item label="订单状态" class="lookup-field lookup-field--status">
<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>
</el-form-item>
<el-form-item label="页码" class="lookup-field lookup-field--number">
<el-input-number
v-model="ninetyoneLookupForm.page"
class="text-input"
:min="1"
controls-position="right"
/>
</el-form-item>
<el-form-item label="每页条数" class="lookup-field lookup-field--number">
<el-input-number
v-model="ninetyoneLookupForm.pageSize"
class="text-input"
:max="100"
:min="1"
controls-position="right"
/>
</el-form-item>
<el-form-item label="操作" class="lookup-field lookup-field--action">
<el-button
:loading="ninetyoneLookupLoading"
type="primary"
class="lookup-action"
@click="emit('lookup')"
>
查询 91卡券订单
</el-button>
</el-form-item>
</el-form>
<el-alert
title="导入后会创建 provider=91kaquan、platform=kuaishou、外部 SKU=productNo 的规则草稿;保存规则后,回到“平台配置 -> 91卡券接入”重试订单。"
type="info"
show-icon
:closable="false"
class="lookup-note"
/>
</div>
<el-alert
v-if="ninetyoneLookupErrorMessage"
:title="ninetyoneLookupErrorMessage"
type="error"
show-icon
:closable="false"
class="mt-4"
/>
<el-skeleton v-else-if="ninetyoneLookupLoading" :rows="4" animated class="mt-4" />
<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>
</el-card>
</template>
<style scoped src="../AdminKuaishouCloudFulfillment.css"></style>
@@ -1,45 +0,0 @@
<script setup lang="ts">
defineProps<{
filePath: string
metrics: {
total: number
readyCount: number
draftCount: number
enabledCount: number
disabledCount: number
}
}>()
</script>
<template>
<el-card shadow="never" class="section-card overview-card">
<template #header>
<div class="card-header">
<span class="card-title">快手 Cloud 新履约概览</span>
<span class="card-desc">维护 91卡券商品到内部 SKUcloud 资源和核销店铺的映射</span>
</div>
</template>
<el-descriptions :column="1" border class="overview-file">
<el-descriptions-item label="配置文件">
<code>{{ filePath || '-' }}</code>
</el-descriptions-item>
</el-descriptions>
<div class="overview-stats">
<el-statistic class="overview-stat" title="规则总数" :value="metrics.total" />
<el-statistic class="overview-stat" title="可投产" :value="metrics.readyCount" />
<el-statistic class="overview-stat" title="待完善" :value="metrics.draftCount" />
<el-statistic class="overview-stat" title="已启用" :value="metrics.enabledCount" />
<el-statistic class="overview-stat" title="已停用" :value="metrics.disabledCount" />
</div>
<div class="overview-notes">
<el-tag effect="plain">91卡券订单来自已接收的待补全队列</el-tag>
<el-tag effect="plain">内部 SKU 决定任务与云资源匹配</el-tag>
<el-tag effect="plain">cloud SKU 决定自动购买发货与退号资源</el-tag>
</div>
</el-card>
</template>
<style scoped src="../AdminKuaishouCloudFulfillment.css"></style>
@@ -1,405 +0,0 @@
<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
cloudtentaclesSourceOptions: { key: string; label: string }[]
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
getCloudSourceSummary: (item: EditableItem) => string
getCloudSkuSummary: (item: EditableItem) => string
getConsumeShopSummary: (item: EditableItem) => string
getDeliveryPlanSummary: (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]
handleDeliveryItemCloudSkuSelected: [index: number, value: number | string | undefined]
handleCloudSkuDropdownVisible: [visible: boolean]
handleKuaishouConsumeShopSelected: []
handleCloudSourceKeysChanged: []
addDeliveryItem: []
removeDeliveryItem: [index: number]
remove: []
}>()
function isAdvancedDeliveryConfigured(item: EditableItem) {
return (
item.deliveryItems.length > 1 ||
item.deliveryItems.some((deliveryItem) => Number(deliveryItem.quantity || 1) > 1)
)
}
</script>
<template>
<el-card
:data-local-id="item.localId"
:class="[
'binding-card',
{ 'binding-card--invalid': validationState?.localId === item.localId },
]"
shadow="never"
>
<template #header>
<div class="binding-header">
<div class="binding-summary">
<div class="binding-title-row">
<strong>{{ getCardTitle(item, index) }}</strong>
<el-tag :type="isItemComplete(item) ? 'success' : 'warning'" effect="plain">
{{ getRuleState(item).label }}
</el-tag>
</div>
<span class="binding-subtle">{{ getCardSummary(item) }}</span>
</div>
<el-button link type="primary" @click="emit('toggleCollapsed')">
{{ isCollapsed ? '展开' : '折叠' }}
</el-button>
</div>
</template>
<el-descriptions v-if="isCollapsed" :column="4" border class="binding-collapsed-preview">
<el-descriptions-item label="外部商品">{{
getExternalMatchSummary(item)
}}</el-descriptions-item>
<el-descriptions-item label="内部 SKU">{{
item.internalSkuCode || '-'
}}</el-descriptions-item>
<el-descriptions-item label="cloud 账号">{{
getCloudSourceSummary(item)
}}</el-descriptions-item>
<el-descriptions-item label="cloud 资源">{{ getCloudSkuSummary(item) }}</el-descriptions-item>
<el-descriptions-item label="核销店铺">{{
getConsumeShopSummary(item)
}}</el-descriptions-item>
</el-descriptions>
<div v-else class="rule-editor">
<div class="rule-editor-main">
<section class="rule-panel rule-panel--match">
<div class="rule-panel-head">
<div class="rule-panel-title">
<span class="rule-step">01</span>
<strong>商品匹配</strong>
</div>
<span>订单进来后先命中这里</span>
</div>
<el-form label-position="top" class="mapping-grid mapping-grid--core">
<el-form-item label="外部 SKU / productNo" class="field-wide">
<el-input
v-model="item.externalSkuCode"
class="text-input"
maxlength="120"
placeholder="优先填写 91 卡券 productNo,例如 183999074512936"
/>
<small class="field-help">最推荐的命中条件没有特殊情况时只填这一项即可</small>
</el-form-item>
<el-form-item label="内部 SKU">
<el-input
v-model="item.internalSkuCode"
class="text-input"
maxlength="80"
placeholder="必填,例如 romantic-destiny-pack"
/>
</el-form-item>
<el-form-item label="内部商品名">
<el-input
v-model="item.internalSkuName"
class="text-input"
maxlength="120"
placeholder="后台识别用,建议填清楚"
/>
</el-form-item>
</el-form>
</section>
<section class="rule-panel rule-panel--delivery">
<div class="rule-panel-head">
<div class="rule-panel-title">
<span class="rule-step">02</span>
<strong>履约资源</strong>
</div>
<span>账号cloud SKU 和核销店铺</span>
</div>
<el-form label-position="top" class="mapping-grid mapping-grid--core">
<el-form-item label="cloud 账号优先级" class="field-wide">
<el-select
v-model="item.cloudSourceKeys"
class="text-input"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="按顺序选择可用于履约的 cloud 账号"
@change="emit('handleCloudSourceKeysChanged')"
>
<el-option
v-for="opt in cloudtentaclesSourceOptions"
:key="opt.key"
:label="opt.label || opt.key"
:value="opt.key"
/>
</el-select>
<small class="field-help">执行时从左到右尝试SKU 列表使用第一个账号加载</small>
</el-form-item>
<el-form-item label="cloud SKU">
<el-select
v-model="item.cloudSkuId"
class="text-input"
placeholder="请选择 cloud SKU"
:disabled="isAdvancedDeliveryConfigured(item)"
@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>
</el-form-item>
<el-form-item label="cloud SKU 名称">
<el-input
:model-value="item.cloudSkuName"
class="text-input"
placeholder="选择后自动带出"
disabled
readonly
/>
</el-form-item>
<el-form-item label="快手核销店铺" class="field-wide">
<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>
</el-form-item>
</el-form>
</section>
</div>
<section class="rule-panel rule-panel--strategy">
<div class="rule-panel-head">
<div class="rule-panel-title">
<span class="rule-step">03</span>
<strong>执行策略</strong>
</div>
<span>一般保持默认即可</span>
</div>
<el-form label-position="top" class="strategy-grid">
<el-form-item label="优先级">
<el-input-number
v-model="item.priority"
class="text-input"
:min="1"
controls-position="right"
/>
</el-form-item>
<el-form-item label="最低保留余额">
<el-input-number
v-model="item.minAssetReserve"
class="text-input"
:min="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="自动动作" class="field-wide">
<div class="strategy-checks">
<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>
</div>
</el-form-item>
</el-form>
</section>
<details class="advanced-fields">
<summary>
<span>高级履约配置</span>
<small>{{ getDeliveryPlanSummary(item) }}</small>
</summary>
<div class="delivery-items-editor">
<div class="delivery-items-head">
<span>发货物品明细</span>
<el-button size="small" @click="emit('addDeliveryItem')">添加物品</el-button>
</div>
<div
v-for="(deliveryItem, deliveryIndex) in item.deliveryItems"
:key="`${item.localId}-${deliveryIndex}`"
class="delivery-item-row"
>
<el-select
v-model="deliveryItem.cloudSkuId"
class="delivery-item-sku"
placeholder="请选择 cloud SKU"
@change="emit('handleDeliveryItemCloudSkuSelected', deliveryIndex, deliveryItem.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>
<el-input
:model-value="deliveryItem.cloudSkuName || '-'"
class="delivery-item-name"
readonly
/>
<el-input-number
v-model="deliveryItem.quantity"
class="delivery-item-count"
:min="1"
:max="999"
controls-position="right"
/>
<el-button
link
type="danger"
:disabled="item.deliveryItems.length <= 1"
@click="emit('removeDeliveryItem', deliveryIndex)"
>
删除
</el-button>
</div>
<small class="field-help">同一个用户只绑定一次角色系统会按这里的物品和数量依次发货</small>
</div>
</details>
<details class="advanced-fields">
<summary>
<span>高级匹配与备注</span>
<small>多店铺多平台或人工排查时再展开</small>
</summary>
<el-form label-position="top" class="mapping-grid advanced-grid">
<el-form-item label="来源 provider">
<el-input
v-model="item.provider"
class="text-input"
maxlength="32"
placeholder="默认 91kaquan"
/>
</el-form-item>
<el-form-item label="来源 platform">
<el-input
v-model="item.platform"
class="text-input"
maxlength="32"
placeholder="默认 kuaishou"
/>
</el-form-item>
<el-form-item label="店铺 ID">
<el-input
v-model="item.shopId"
class="text-input"
maxlength="80"
placeholder="留空表示跨店铺共用"
/>
</el-form-item>
<el-form-item label="外部商品 ID">
<el-input
v-model="item.externalItemId"
class="text-input"
maxlength="120"
placeholder="平台商品 ID,可选"
/>
</el-form-item>
<el-form-item label="外部商品名" class="field-wide">
<el-input
v-model="item.externalSkuName"
class="text-input"
maxlength="200"
placeholder="只有需要按名称匹配时再填"
/>
</el-form-item>
<el-form-item label="解析商品名">
<el-input
v-model="item.resolvedSkuName"
class="text-input"
maxlength="120"
placeholder="商品名兜底,可选"
/>
</el-form-item>
<el-form-item label="虚拟号 VN Key">
<el-input class="text-input" model-value="1" readonly />
</el-form-item>
<el-form-item label="核销店铺 ID">
<el-input
:model-value="item.kuaishouConsumeShopId || '-'"
class="text-input"
readonly
/>
</el-form-item>
<el-form-item label="核销店铺名">
<el-input
:model-value="item.kuaishouConsumeShopName || '-'"
class="text-input"
readonly
/>
</el-form-item>
<el-form-item label="备注" class="field-wide">
<el-input
v-model="item.notes"
class="text-input textarea-input"
maxlength="400"
placeholder="绑定场景、客服注意事项等"
type="textarea"
/>
</el-form-item>
</el-form>
</details>
</div>
<el-alert
v-if="validationState?.localId === item.localId"
:title="validationState?.message"
type="error"
show-icon
:closable="false"
/>
<el-alert
v-if="cloudSkuCatalogErrorMessage"
:title="cloudSkuCatalogErrorMessage"
type="error"
show-icon
:closable="false"
/>
<div v-if="!isCollapsed" class="binding-actions">
<el-button link type="danger" @click="emit('remove')">删除</el-button>
</div>
</el-card>
</template>
<style scoped src="../AdminKuaishouCloudFulfillment.css"></style>
@@ -1,157 +0,0 @@
<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
cloudtentaclesSourceOptions: { key: string; label: string }[]
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
getCloudSourceSummary: (item: EditableItem) => string
getCloudSkuSummary: (item: EditableItem) => string
getConsumeShopSummary: (item: EditableItem) => string
getDeliveryPlanSummary: (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]
handleDeliveryItemCloudSkuSelected: [item: EditableItem, index: number, value: number | string | undefined]
handleCloudSkuDropdownVisible: [item: EditableItem, visible: boolean]
handleKuaishouConsumeShopSelected: [item: EditableItem]
handleCloudSourceKeysChanged: [item: EditableItem]
addDeliveryItem: [item: EditableItem]
removeDeliveryItem: [item: EditableItem, index: number]
removeItem: [localId: string]
}>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header card-header--split">
<div>
<span class="card-title">规则编辑</span>
<span class="card-desc"
>每一条规则描述快手外部商品如何映射到内部 SKU + cloud 资源</span
>
</div>
<el-space wrap>
<el-button
v-for="option in ruleFilterOptions"
:key="option.value"
size="small"
:type="ruleFilter === option.value ? 'primary' : 'default'"
@click="emit('update:ruleFilter', option.value)"
>
{{ option.label }} {{ option.count }}
</el-button>
</el-space>
</div>
</template>
<div class="rule-action-row">
<el-space wrap>
<el-button @click="emit('expandAll')">全部展开</el-button>
<el-button @click="emit('collapseReady')">收起已完成</el-button>
<el-button :loading="cloudSkuCatalogLoading" @click="emit('refreshCloudSku')">
刷新 cloud SKU
</el-button>
<el-button @click="emit('addItem')">新增规则</el-button>
<el-button type="primary" :loading="saving" @click="emit('saveConfigs')">
保存全部
</el-button>
</el-space>
<div class="rule-enabled-control">
<el-switch
:model-value="enabled"
active-text="启用整条配置"
@update:model-value="emit('update:enabled', Boolean($event))"
/>
</div>
</div>
<el-alert
v-if="validationState?.message"
:title="validationState.message"
type="error"
show-icon
:closable="false"
class="mt-4"
/>
<el-empty
v-if="items.length === 0"
description="当前还没有新履约规则,先新增一条。"
:image-size="60"
/>
<el-empty
v-else-if="filteredItems.length === 0"
description="当前筛选下没有规则。"
:image-size="60"
/>
<AdminKuaishouCloudRuleCard
v-for="(item, index) in filteredItems"
:key="item.localId"
:item="item"
:index="index"
:validation-state="validationState"
:cloudtentacles-source-options="cloudtentaclesSourceOptions"
: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-source-summary="getCloudSourceSummary"
:get-cloud-sku-summary="getCloudSkuSummary"
:get-consume-shop-summary="getConsumeShopSummary"
:get-delivery-plan-summary="getDeliveryPlanSummary"
: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-delivery-item-cloud-sku-selected="
(index, value) => emit('handleDeliveryItemCloudSkuSelected', item, index, value)
"
@handle-cloud-sku-dropdown-visible="emit('handleCloudSkuDropdownVisible', item, $event)"
@handle-kuaishou-consume-shop-selected="emit('handleKuaishouConsumeShopSelected', item)"
@handle-cloud-source-keys-changed="emit('handleCloudSourceKeysChanged', item)"
@add-delivery-item="emit('addDeliveryItem', item)"
@remove-delivery-item="emit('removeDeliveryItem', item, $event)"
@remove="emit('removeItem', item.localId)"
/>
</el-card>
</template>
<style scoped src="../AdminKuaishouCloudFulfillment.css"></style>
@@ -1,13 +0,0 @@
import type { AdminKuaishouCloudFulfillmentItem } from '@/types/admin'
export type EditableItem = AdminKuaishouCloudFulfillmentItem & {
localId: string
cloudSourceKeys: string[]
}
export type ValidationState = {
localId: string
message: string
} | null
export type RuleFilter = 'all' | 'draft' | 'ready' | 'disabled'
@@ -1,735 +0,0 @@
import { computed, nextTick, ref } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
import {
fetchAdminKuaishouCloudFulfillmentConfig,
fetchAdminKuaishouEticketSourceConfig,
fetchAdminCloudtentaclesSourceConfig,
saveAdminKuaishouCloudFulfillmentConfig,
} from '@/services/admin'
import type {
AdminKuaishouCloudDeliveryItem,
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[]>([])
const cloudtentaclesSourceOptions = ref<{ key: string; label: string }[]>([])
// ── 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()
const sourceKeys = getDefaultCloudSourceKeys()
return {
localId: crypto.randomUUID(),
id: '',
enabled: true,
priority: 100,
provider: '91kaquan',
platform: 'kuaishou',
shopId: '',
internalSkuCode: '',
internalSkuName: '',
externalSkuCode: '',
externalItemId: '',
externalSkuName: '',
resolvedSkuName: '',
cloudSourceKeys: sourceKeys,
cloudSkuId: 0,
cloudSkuName: '',
deliveryItems: [],
vnKey: '1',
autoBuyEnabled: true,
minAssetReserve: 0,
autoReturnNumberAfterDispatch: true,
autoConsumeAfterDispatch: true,
kuaishouConsumeShopId: defaultShop?.shopId || '',
kuaishouConsumeShopName: defaultShop?.kshopName || '',
notes: '',
}
}
function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem {
const matchedShop = findKuaishouConsumeShop(
item.kuaishouConsumeShopId,
item.kuaishouConsumeShopName,
)
const sourceKeys = normalizeCloudSourceKeys(item.cloudSourceKeys)
const deliveryItems = normalizeDeliveryItems(item)
const primaryDeliveryItem = deliveryItems[0]
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 || '',
cloudSourceKeys: sourceKeys,
cloudSkuId: primaryDeliveryItem?.cloudSkuId || Number(item.cloudSkuId || 0) || 0,
cloudSkuName: primaryDeliveryItem?.cloudSkuName || item.cloudSkuName || '',
deliveryItems,
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,
)
const sourceKeys = normalizeCloudSourceKeys(item.cloudSourceKeys)
const deliveryItems = normalizeDeliveryItems(item)
const primaryDeliveryItem = deliveryItems[0]
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(),
cloudSourceKeys: sourceKeys,
cloudSkuId: primaryDeliveryItem?.cloudSkuId || Number(item.cloudSkuId || 0) || 0,
cloudSkuName: primaryDeliveryItem?.cloudSkuName || item.cloudSkuName.trim(),
deliveryItems,
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(),
}
}
function normalizeCloudSourceKeys(values: unknown[] = []) {
const keys = values
.map((value) => String(value || '').trim())
.filter(Boolean)
return Array.from(new Set(keys))
}
function getDefaultCloudSourceKeys() {
return normalizeCloudSourceKeys([cloudtentaclesSourceOptions.value[0]?.key])
}
function normalizeDeliveryItems(
item: Pick<EditableItem, 'deliveryItems' | 'cloudSkuId' | 'cloudSkuName'> | AdminKuaishouCloudFulfillmentItem,
): AdminKuaishouCloudDeliveryItem[] {
const rawItems = Array.isArray(item.deliveryItems) ? item.deliveryItems : []
const normalizedItems = rawItems
.map((value) => normalizeDeliveryItem(value))
.filter((value): value is AdminKuaishouCloudDeliveryItem => Boolean(value))
if (normalizedItems.length > 0) {
return mergeDeliveryItems(normalizedItems)
}
const cloudSkuId = Number(item.cloudSkuId || 0) || 0
if (cloudSkuId <= 0) {
return []
}
return [
{
cloudSkuId,
cloudSkuName: String(item.cloudSkuName || '').trim(),
quantity: 1,
},
]
}
function normalizeDeliveryItem(value: unknown): AdminKuaishouCloudDeliveryItem | null {
const source = value && typeof value === 'object' ? (value as Partial<AdminKuaishouCloudDeliveryItem>) : {}
const cloudSkuId = Number(source.cloudSkuId || 0) || 0
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) {
return null
}
const quantity = Number(source.quantity || 1) || 1
return {
cloudSkuId,
cloudSkuName: String(source.cloudSkuName || '').trim(),
quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
}
}
function mergeDeliveryItems(items: AdminKuaishouCloudDeliveryItem[]) {
const merged = new Map<number, AdminKuaishouCloudDeliveryItem>()
for (const item of items) {
const existing = merged.get(item.cloudSkuId)
if (existing) {
existing.quantity += item.quantity
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
continue
}
merged.set(item.cloudSkuId, { ...item })
}
return Array.from(merged.values())
}
// ── 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.cloudSourceKeys.length > 0 ||
Number(item.cloudSkuId || 0) > 0 ||
item.cloudSkuName.trim() ||
normalizeDeliveryItems(item).length > 0 ||
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() &&
item.cloudSourceKeys.length > 0 &&
normalizeDeliveryItems(item).length > 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 (item.cloudSourceKeys.length === 0) {
return { localId: item.localId, message: `${title} 需要选择至少一个 cloud 账号` }
}
const deliveryItems = normalizeDeliveryItems(item)
if (deliveryItems.length === 0) {
return { localId: item.localId, message: `${title} 需要至少配置 1 个 cloud 发货物品` }
}
const invalidDeliveryItem = deliveryItems.find(
(deliveryItem) => Number(deliveryItem.quantity || 0) <= 0,
)
if (invalidDeliveryItem) {
return { localId: item.localId, message: `${title} 的发货数量必须大于 0` }
}
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() || '跨店铺',
getDeliveryPlanSummary(item),
]
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 getCloudSourceSummary(item: EditableItem) {
const keys = normalizeCloudSourceKeys(item.cloudSourceKeys)
if (keys.length === 0) {
return '未选择 cloud 账号'
}
return keys
.map((key, index) => {
const option = cloudtentaclesSourceOptions.value.find((source) => source.key === key)
return `${index + 1}. ${option?.label || key}`
})
.join(' / ')
}
function getCloudSkuSummary(item: EditableItem) {
const deliveryItems = normalizeDeliveryItems(item)
if (deliveryItems.length > 1) {
return `${deliveryItems.length} 个物品 / 共 ${getDeliveryTotalQuantity(item)}`
}
const primaryItem = deliveryItems[0]
if (primaryItem?.cloudSkuId && primaryItem.cloudSkuName.trim()) {
const suffix = primaryItem.quantity > 1 ? ` × ${primaryItem.quantity}` : ''
return `${primaryItem.cloudSkuName.trim()} · #${primaryItem.cloudSkuId}${suffix}`
}
if (primaryItem?.cloudSkuId) {
const suffix = primaryItem.quantity > 1 ? ` × ${primaryItem.quantity}` : ''
return `cloud SKU #${primaryItem.cloudSkuId}${suffix}`
}
return '待选择 cloud SKU'
}
function getDeliveryPlanSummary(item: EditableItem) {
const deliveryItems = normalizeDeliveryItems(item)
if (deliveryItems.length === 0) {
return '待填发货物品'
}
if (deliveryItems.length === 1) {
const firstItem = deliveryItems[0]
return firstItem.quantity > 1
? `cloud#${firstItem.cloudSkuId} × ${firstItem.quantity}`
: `cloud#${firstItem.cloudSkuId}`
}
return `${deliveryItems.length} 个物品 / ${getDeliveryTotalQuantity(item)}`
}
function getDeliveryTotalQuantity(item: EditableItem) {
return normalizeDeliveryItems(item).reduce((sum, deliveryItem) => sum + deliveryItem.quantity, 0)
}
// ── 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, cloudtentaclesSourceResponse] = await Promise.all([
fetchAdminKuaishouCloudFulfillmentConfig(),
fetchAdminKuaishouEticketSourceConfig(),
fetchAdminCloudtentaclesSourceConfig(),
])
kuaishouConsumeShops.value = Array.isArray(eticketResponse.data.source.shops)
? eticketResponse.data.source.shops
: []
const sources = Array.isArray(cloudtentaclesSourceResponse.data.sources)
? cloudtentaclesSourceResponse.data.sources
: []
cloudtentaclesSourceOptions.value = sources.map((s) => ({
key: s.key,
label: s.label || s.key,
}))
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)
}
function handleCloudSourceKeysChanged(item: EditableItem) {
const sourceKeys = normalizeCloudSourceKeys(item.cloudSourceKeys)
item.cloudSourceKeys = sourceKeys
item.cloudSkuId = 0
item.cloudSkuName = ''
item.deliveryItems = []
}
function addDeliveryItem(item: EditableItem) {
item.deliveryItems.push({
cloudSkuId: 0,
cloudSkuName: '',
quantity: 1,
})
}
function removeDeliveryItem(item: EditableItem, index: number) {
item.deliveryItems.splice(index, 1)
syncPrimaryCloudSkuFromDeliveryItems(item)
}
function syncPrimaryCloudSkuFromDeliveryItems(item: EditableItem) {
const primaryItem = normalizeDeliveryItems(item)[0]
item.cloudSkuId = primaryItem?.cloudSkuId || 0
item.cloudSkuName = primaryItem?.cloudSkuName || ''
}
function syncDeliveryItemsFromPrimaryCloudSku(item: EditableItem) {
if (Number(item.cloudSkuId || 0) <= 0) {
item.deliveryItems = []
return
}
if (item.deliveryItems.length === 0) {
item.deliveryItems.push({
cloudSkuId: item.cloudSkuId,
cloudSkuName: item.cloudSkuName,
quantity: 1,
})
return
}
item.deliveryItems[0] = {
...item.deliveryItems[0],
cloudSkuId: item.cloudSkuId,
cloudSkuName: item.cloudSkuName,
quantity: Math.max(1, Number(item.deliveryItems[0].quantity || 1) || 1),
}
}
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,
getCloudSourceSummary,
getConsumeShopSummary,
getDeliveryPlanSummary,
// consume shops
getKuaishouConsumeShopOptions,
findKuaishouConsumeShop,
getDefaultKuaishouConsumeShop,
formatKuaishouConsumeShopOption,
handleKuaishouConsumeShopSelected,
handleCloudSourceKeysChanged,
addDeliveryItem,
removeDeliveryItem,
syncPrimaryCloudSkuFromDeliveryItems,
syncDeliveryItemsFromPrimaryCloudSku,
// collapse
isCollapsed,
setCollapsed,
toggleCollapsed,
expandAllRules,
collapseReadyRules,
// data
loadConfigs,
addItem,
removeItem,
saveConfigs,
focusValidationTarget,
// cloudtentacles sources
cloudtentaclesSourceOptions,
}
}
@@ -1,166 +0,0 @@
import { computed, reactive, ref } from 'vue'
import { 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()
const shopOpts = config.getKuaishouConsumeShopOptions()
const shop = defaultShop || (shopOpts.length > 0 ? shopOpts[0] : null)
const sourceKeys = config.cloudtentaclesSourceOptions.value[0]?.key
? [config.cloudtentaclesSourceOptions.value[0].key]
: []
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,
cloudSourceKeys: sourceKeys,
cloudSkuId: 0,
cloudSkuName: '',
deliveryItems: [],
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,
}
}
@@ -1,205 +0,0 @@
import { ref } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
import { fetchAdminCloudtentaclesSkuList } from '@/services/admin'
import type { AdminCloudtentaclesSkuItem, AdminKuaishouCloudDeliveryItem } from '@/types/admin'
import type { EditableItem } from './types'
export function useKuaishouCloudSku(cloudtentaclesSourceOptions?: { value: { key: string }[] }) {
const cloudSkuCatalogLoadingBySourceKey = ref<Record<string, boolean>>({})
const cloudSkuCatalogErrorMessage = ref('')
const cloudSkuCatalogBySourceKey = ref<Record<string, AdminCloudtentaclesSkuItem[]>>({})
function getSourceKey(item: EditableItem) {
return String(item.cloudSourceKeys[0] || '').trim()
}
async function ensureCloudSkuCatalogLoaded(item: EditableItem, force = false) {
const sourceKey = getSourceKey(item)
if (!force && (cloudSkuCatalogBySourceKey.value[sourceKey]?.length ?? 0) > 0) {
return cloudSkuCatalogBySourceKey.value[sourceKey]
}
cloudSkuCatalogLoadingBySourceKey.value[sourceKey] = true
cloudSkuCatalogErrorMessage.value = ''
try {
const response = await fetchAdminCloudtentaclesSkuList({ sourceKey })
cloudSkuCatalogBySourceKey.value[sourceKey] = Array.isArray(response.data.items)
? response.data.items
: []
return cloudSkuCatalogBySourceKey.value[sourceKey]
} catch (error) {
cloudSkuCatalogBySourceKey.value[sourceKey] = []
cloudSkuCatalogErrorMessage.value =
error instanceof Error ? error.message : 'cloud SKU 列表查询失败'
throw error
} finally {
cloudSkuCatalogLoadingBySourceKey.value[sourceKey] = 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 sourceKey = getSourceKey(item)
const catalog = cloudSkuCatalogBySourceKey.value[sourceKey] || []
const keyword = getCloudSkuKeyword(item)
return catalog
.map((sku) => ({ sku, score: scoreCloudSkuMatch(sku, keyword) }))
.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)
}
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 sourceKey = getSourceKey(item)
const catalog = cloudSkuCatalogBySourceKey.value[sourceKey] || []
const matched = catalog.find((sku) => sku.id === item.cloudSkuId)
item.cloudSkuName = matched?.name || item.cloudSkuName || ''
}
function handleDeliveryItemCloudSkuSelected(
item: EditableItem,
deliveryItem: AdminKuaishouCloudDeliveryItem,
value: number | string | undefined,
) {
const skuId = Number(value || 0)
deliveryItem.cloudSkuId = Number.isInteger(skuId) && skuId > 0 ? skuId : 0
if (!deliveryItem.cloudSkuId) {
deliveryItem.cloudSkuName = ''
return
}
const sourceKey = getSourceKey(item)
const catalog = cloudSkuCatalogBySourceKey.value[sourceKey] || []
const matched = catalog.find((sku) => sku.id === deliveryItem.cloudSkuId)
deliveryItem.cloudSkuName = matched?.name || deliveryItem.cloudSkuName || ''
}
async function handleCloudSkuDropdownVisible(item: EditableItem, visible: boolean) {
if (!visible) {
return
}
try {
await ensureCloudSkuCatalogLoaded(item)
if (item.cloudSkuId && !item.cloudSkuName) {
handleCloudSkuSelected(item, item.cloudSkuId)
}
} catch (error) {
showError(error instanceof Error ? error.message : 'cloud SKU 列表查询失败')
}
}
async function refreshCloudSkuCatalog(item?: EditableItem) {
try {
if (item) {
await ensureCloudSkuCatalogLoaded(item, true)
const sourceKey = getSourceKey(item)
const count = cloudSkuCatalogBySourceKey.value[sourceKey]?.length ?? 0
showSuccess(`cloud SKU 列表(${sourceKey})已刷新,共 ${count}`)
} else {
const cachedKeys = Object.keys(cloudSkuCatalogBySourceKey.value)
const configuredKeys = cloudtentaclesSourceOptions?.value?.map((s) => s.key) || []
const keysToRefresh = cachedKeys.length > 0 ? cachedKeys : configuredKeys
for (const key of keysToRefresh) {
await ensureCloudSkuCatalogLoaded(
{ cloudSourceKeys: [key], localId: '' } as EditableItem,
true,
)
}
const total = Object.values(cloudSkuCatalogBySourceKey.value).reduce(
(sum, list) => sum + list.length,
0,
)
showSuccess(`cloud SKU 列表已全部刷新,共 ${total}`)
}
} catch (error) {
showError(error instanceof Error ? error.message : 'cloud SKU 列表查询失败')
}
}
const cloudSkuCatalogLoading = ref(false)
function syncCloudSkuCatalogLoading() {
cloudSkuCatalogLoading.value = Object.values(cloudSkuCatalogLoadingBySourceKey.value).some(
Boolean,
)
}
return {
cloudSkuCatalogLoading,
cloudSkuCatalogLoadingBySourceKey,
cloudSkuCatalogErrorMessage,
cloudSkuCatalogBySourceKey,
getCloudSkuOptions,
formatCloudSkuOptionLabel,
handleCloudSkuSelected,
handleDeliveryItemCloudSkuSelected,
handleCloudSkuDropdownVisible,
refreshCloudSkuCatalog,
syncCloudSkuCatalogLoading,
}
}