feat(frontend): add P2 quality gates

This commit is contained in:
yml2213
2026-05-15 17:35:50 +08:00
parent a65c0713fb
commit 787b01749c
62 changed files with 4907 additions and 1355 deletions
@@ -88,9 +88,15 @@ const lookupForm = reactive({
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
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,
@@ -102,13 +108,20 @@ const ruleMetrics = computed(() => {
}
})
const pendingObservedCount = computed(() => observedProducts.value.filter((item) => !item.configured).length)
const pendingObservedCount = computed(
() => observedProducts.value.filter((item) => !item.configured).length,
)
type ImportableProductCandidate = Pick<
AdminObservedProductItem,
'provider' | 'platform' | 'shopId' | 'shopName' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
> & {
}
| 'provider'
| 'platform'
| 'shopId'
| 'shopName'
| 'externalSkuCode'
| 'externalItemId'
| 'externalSkuName'
> & {}
function createEmptyBinding(): EditableBinding {
return {
@@ -148,12 +161,20 @@ function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBi
}
}
function hasMatchCondition(item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
return Boolean(item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim())
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'))
return Boolean(
item.skuCode.trim() &&
hasMatchCondition(item) &&
VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'),
)
}
function buildCollapsedIds(items: EditableBinding[]) {
@@ -304,23 +325,27 @@ function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
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,
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 {
function resolveBindingValidationState(
item: SaveBindingPayload,
index: number,
bindingId: string,
): ValidationState {
const label = `${index + 1} 条规则`
if (!item.skuCode) {
@@ -384,7 +409,10 @@ function isBindingInvalid(bindingId: string) {
return validationState.value?.bindingId === bindingId
}
function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName') {
function isFieldInvalid(
bindingId: string,
field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName',
) {
if (validationState.value?.bindingId !== bindingId) {
return false
}
@@ -552,14 +580,20 @@ onMounted(loadConfigs)
</div>
<div class="section-action-group">
<el-button round @click="addBinding">新增规则</el-button>
<el-button :loading="saving" round type="primary" @click="saveConfigs">保存配置</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>
<p v-if="validationState?.message" class="validation-banner">
{{ validationState.message }}
</p>
<div v-if="bindings.length === 0" class="empty-inline">当前还没有履约规则先新增一条</div>
<div v-if="bindings.length === 0" class="empty-inline">
当前还没有履约规则先新增一条
</div>
<div
v-for="(binding, index) in bindings"
@@ -571,21 +605,42 @@ onMounted(loadConfigs)
<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) }]">
<span
:class="[
'binding-status-chip',
{ 'binding-status-chip--done': isEditableBindingComplete(binding) },
]"
>
{{ getBindingStatusLabel(binding) }}
</span>
</div>
<span class="binding-subtle">{{ getBindingSummary(binding) }}</span>
</div>
<button class="collapse-button" type="button" @click="toggleBindingCollapsed(binding.id)">
<button
class="collapse-button"
type="button"
@click="toggleBindingCollapsed(binding.id)"
>
{{ isBindingCollapsed(binding.id) ? '展开' : '折叠' }}
</button>
</div>
<div v-if="isBindingCollapsed(binding.id)" class="binding-collapsed-preview">
<span>外部商品{{ binding.externalSkuName || binding.externalSkuCode || binding.externalItemId || '-' }}</span>
<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>
<span
>履约方式{{
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
binding.profileKey
}}</span
>
</div>
<div v-else class="binding-grid">
@@ -613,7 +668,10 @@ onMounted(loadConfigs)
<span>店铺 ID</span>
<input
v-model="binding.shopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') },
]"
data-field="shopId"
placeholder="留空表示跨店铺通用"
@input="clearValidationState"
@@ -632,14 +690,23 @@ onMounted(loadConfigs)
<label class="field-block">
<span>优先级</span>
<input v-model.number="binding.priority" class="text-input" type="number" min="1" @input="clearValidationState" />
<input
v-model.number="binding.priority"
class="text-input"
type="number"
min="1"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>外部 SKU 编码</span>
<input
v-model="binding.externalSkuCode"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') },
]"
data-field="externalSkuCode"
placeholder="例如 6046726460016"
@input="clearValidationState"
@@ -650,7 +717,10 @@ onMounted(loadConfigs)
<span>外部商品 ID</span>
<input
v-model="binding.externalItemId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') },
]"
data-field="externalItemId"
placeholder="例如 1033324289962"
@input="clearValidationState"
@@ -661,7 +731,10 @@ onMounted(loadConfigs)
<span>外部商品名</span>
<input
v-model="binding.externalSkuName"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') },
]"
data-field="externalSkuName"
placeholder="例如 奥利奥动作"
@input="clearValidationState"
@@ -672,24 +745,37 @@ onMounted(loadConfigs)
<span>内部履约 SKU</span>
<input
v-model="binding.skuCode"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') },
]"
data-field="skuCode"
placeholder="例如 sjz_hdl_test01"
@input="clearValidationState"
/>
<small class="field-help">填你们系统内部统一 SKU相同履约商品尽量跨平台共用同一个内部 SKU</small>
<small class="field-help"
>填你们系统内部统一 SKU相同履约商品尽量跨平台共用同一个内部 SKU</small
>
</label>
<label class="field-block">
<span>内部商品名</span>
<input v-model="binding.skuName" class="text-input" placeholder="用于后台展示" @input="clearValidationState" />
<input
v-model="binding.skuName"
class="text-input"
placeholder="用于后台展示"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>履约方式</span>
<select
v-model="binding.profileKey"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') },
]"
data-field="profileKey"
@change="clearValidationState"
>
@@ -710,7 +796,9 @@ onMounted(loadConfigs)
</label>
</div>
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">{{ validationState?.message }}</p>
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">
{{ validationState?.message }}
</p>
<div class="binding-actions">
<label class="checkbox-line">
@@ -764,7 +852,9 @@ onMounted(loadConfigs)
<div class="field-block field-block--action">
<span>操作</span>
<el-button :loading="lookupLoading" round type="primary" @click="lookupOrderProducts">查询订单商品</el-button>
<el-button :loading="lookupLoading" round type="primary" @click="lookupOrderProducts"
>查询订单商品</el-button
>
</div>
</div>
@@ -778,17 +868,27 @@ onMounted(loadConfigs)
<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>
<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>
<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>
<span class="cell-subtle"
>补查结果{{
lookupResult.order.enriched
? '已补全'
: lookupResult.order.enrichReason || '已返回'
}}</span
>
</div>
</div>
@@ -805,7 +905,13 @@ onMounted(loadConfigs)
<tr v-for="item in lookupResult.items" :key="item.lineId">
<td>
<div class="cell-stack">
<strong>{{ item.externalSkuName || item.itemTitle || item.externalSkuCode || item.externalItemId || '-' }}</strong>
<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>
@@ -815,7 +921,8 @@ onMounted(loadConfigs)
<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 || '-' }}
{{ item.matchedBinding.skuName || item.matchedBinding.skuCode || '-' }} /
{{ item.matchedBinding.profileKey || '-' }}
</span>
<span v-else class="cell-subtle">可直接导入成新规则草稿</span>
</div>
@@ -862,16 +969,23 @@ onMounted(loadConfigs)
</tr>
</thead>
<tbody>
<tr v-for="item in observedProducts" :key="`${item.provider}:${item.platform}:${item.shopId}:${item.externalSkuCode}:${item.externalItemId}:${item.externalSkuName}`">
<tr
v-for="item in observedProducts"
:key="`${item.provider}:${item.platform}:${item.shopId}:${item.externalSkuCode}:${item.externalItemId}:${item.externalSkuName}`"
>
<td>
<div class="cell-stack">
<strong>{{ item.platform || '-' }}</strong>
<span class="cell-subtle">{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span>
<span class="cell-subtle"
>{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span
>
</div>
</td>
<td>
<div class="cell-stack">
<strong>{{ item.externalSkuName || item.externalSkuCode || item.externalItemId || '-' }}</strong>
<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>
@@ -724,7 +724,9 @@
background: #fff;
color: #1d3555;
outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.text-input:focus {
@@ -969,4 +971,4 @@
.field-wide {
grid-column: auto;
}
}
}
@@ -65,18 +65,26 @@ const metrics = computed(() => {
}
})
const filteredItems = computed(() => items.value.filter((item) => matchesRuleFilter(item, ruleFilter.value)))
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 },
])
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 },
],
)
const ninetyoneLookupMetrics = computed(() => {
const importedCount = ninetyoneLookupResults.value.filter((item) => isNinetyoneProductImported(item)).length
const pendingCount = ninetyoneLookupResults.value.filter((item) => item.orderStatus === 'pending_config').length
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,
@@ -116,7 +124,10 @@ function createEmptyItem(): EditableItem {
}
function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem {
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId, item.kuaishouConsumeShopName)
const matchedShop = findKuaishouConsumeShop(
item.kuaishouConsumeShopId,
item.kuaishouConsumeShopName,
)
return {
localId: crypto.randomUUID(),
id: item.id || crypto.randomUUID(),
@@ -146,7 +157,10 @@ function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem
}
function mapSaveItem(item: EditableItem): AdminKuaishouCloudFulfillmentItem {
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId, item.kuaishouConsumeShopName)
const matchedShop = findKuaishouConsumeShop(
item.kuaishouConsumeShopId,
item.kuaishouConsumeShopName,
)
return {
id: item.id.trim() || item.internalSkuCode.trim() || item.localId,
enabled: item.enabled,
@@ -174,43 +188,47 @@ function mapSaveItem(item: EditableItem): AdminKuaishouCloudFulfillmentItem {
}
}
function hasExternalMatch(item: Pick<EditableItem, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
return Boolean(item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim())
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,
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),
item.internalSkuCode.trim() &&
Number(item.cloudSkuId || 0) > 0 &&
(!item.autoConsumeAfterDispatch || Boolean(item.kuaishouConsumeShopId.trim())) &&
hasExternalMatch(item),
)
}
@@ -241,7 +259,12 @@ function resolveValidation(item: EditableItem, index: number): ValidationState {
}
function getCardTitle(item: EditableItem, index: number) {
return item.internalSkuName.trim() || item.externalSkuName.trim() || item.internalSkuCode.trim() || `规则 ${index + 1}`
return (
item.internalSkuName.trim() ||
item.externalSkuName.trim() ||
item.internalSkuCode.trim() ||
`规则 ${index + 1}`
)
}
function getCardSummary(item: EditableItem) {
@@ -299,10 +322,13 @@ function getKuaishouConsumeShopOptions() {
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
return (
getKuaishouConsumeShopOptions().find(
(shop) =>
(normalizedShopId && shop.shopId === normalizedShopId) ||
(normalizedShopName && shop.kshopName === normalizedShopName),
) || null
)
}
function getDefaultKuaishouConsumeShop() {
@@ -319,7 +345,10 @@ function handleKuaishouConsumeShopSelected(item: EditableItem) {
}
function getConsumeShopSummary(item: EditableItem) {
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId, item.kuaishouConsumeShopName)
const matchedShop = findKuaishouConsumeShop(
item.kuaishouConsumeShopId,
item.kuaishouConsumeShopName,
)
const name = String(matchedShop?.kshopName || item.kuaishouConsumeShopName).trim()
const id = String(matchedShop?.shopId || item.kuaishouConsumeShopId).trim()
@@ -369,7 +398,9 @@ function expandAllRules() {
}
function collapseReadyRules() {
collapsedIds.value = items.value.filter((item) => isItemComplete(item) || !item.enabled).map((item) => item.localId)
collapsedIds.value = items.value
.filter((item) => isItemComplete(item) || !item.enabled)
.map((item) => item.localId)
}
async function loadConfigs() {
@@ -386,7 +417,9 @@ async function loadConfigs() {
fetchAdminKuaishouCloudFulfillmentConfig(),
fetchAdminKuaishouEticketSourceConfig(),
])
kuaishouConsumeShops.value = Array.isArray(eticketResponse.data.source.shops) ? eticketResponse.data.source.shops : []
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)
@@ -450,16 +483,21 @@ function findExistingItemFromNinetyoneOrder(order: AdminNinetyoneOrderItem) {
const productName = String(order.productName || '').trim()
const shopId = String(order.shopId || '91kaquan').trim()
return 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
return (
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) {
@@ -488,11 +526,7 @@ async function importNinetyoneProduct(item: AdminNinetyoneOrderItem) {
}
function getNinetyoneOrderImportKey(item: AdminNinetyoneOrderItem) {
return [
item.orderNo,
item.productNo,
item.shopId || '91kaquan',
]
return [item.orderNo, item.productNo, item.shopId || '91kaquan']
.map((value) => String(value || '').trim())
.join(':')
}
@@ -515,7 +549,8 @@ async function ensureCloudSkuCatalogLoaded(force = false) {
return cloudSkuCatalog.value
} catch (error) {
cloudSkuCatalog.value = []
cloudSkuCatalogErrorMessage.value = error instanceof Error ? error.message : 'cloud SKU 列表查询失败'
cloudSkuCatalogErrorMessage.value =
error instanceof Error ? error.message : 'cloud SKU 列表查询失败'
throw error
} finally {
cloudSkuCatalogLoading.value = false
@@ -523,18 +558,17 @@ async function ensureCloudSkuCatalogLoaded(force = false) {
}
function getCloudSkuKeyword(item: EditableItem) {
return [
item.internalSkuCode,
item.internalSkuName,
item.resolvedSkuName,
item.externalSkuName,
]
.map((value) => String(value || '').trim())
.find(Boolean) || ''
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()
return String(value || '')
.trim()
.toLowerCase()
}
function scoreCloudSkuMatch(item: AdminCloudtentaclesSkuItem, keyword: string) {
@@ -644,7 +678,10 @@ async function focusValidationTarget(localId: string) {
}
async function saveConfigs() {
const invalid = items.value.reduce<ValidationState>((state, item, index) => state || resolveValidation(item, index), null)
const invalid = items.value.reduce<ValidationState>(
(state, item, index) => state || resolveValidation(item, index),
null,
)
if (invalid) {
validationState.value = invalid
setCollapsed(invalid.localId, false)
@@ -692,7 +729,8 @@ async function lookupNinetyoneProducts() {
importedNinetyoneOrderKeys.value = []
} catch (error) {
ninetyoneLookupResults.value = []
ninetyoneLookupErrorMessage.value = error instanceof Error ? error.message : '91卡券订单查询失败'
ninetyoneLookupErrorMessage.value =
error instanceof Error ? error.message : '91卡券订单查询失败'
} finally {
ninetyoneLookupLoading.value = false
}
@@ -770,7 +808,9 @@ onMounted(loadConfigs)
<div class="section-title-row section-title-row--tight">
<div>
<h3>91卡券订单取样导入</h3>
<p>查询已接收的 91卡券订单 productNo 导入为规则草稿再补齐内部 SKU cloud 资源</p>
<p>
查询已接收的 91卡券订单 productNo 导入为规则草稿再补齐内部 SKU cloud 资源
</p>
</div>
<div class="section-mini-stats">
<span class="mini-stat-chip">结果 {{ ninetyoneLookupMetrics.total }}</span>
@@ -790,21 +830,43 @@ onMounted(loadConfigs)
</label>
<label class="field-block">
<span>页码</span>
<input v-model.number="ninetyoneLookupForm.page" class="text-input" min="1" type="number" />
<input
v-model.number="ninetyoneLookupForm.page"
class="text-input"
min="1"
type="number"
/>
</label>
<label class="field-block">
<span>每页条数</span>
<input v-model.number="ninetyoneLookupForm.pageSize" class="text-input" max="100" min="1" type="number" />
<input
v-model.number="ninetyoneLookupForm.pageSize"
class="text-input"
max="100"
min="1"
type="number"
/>
</label>
<div class="field-block field-block--action">
<span>操作</span>
<el-button :loading="ninetyoneLookupLoading" round type="primary" @click="lookupNinetyoneProducts">查询 91卡券订单</el-button>
<el-button
:loading="ninetyoneLookupLoading"
round
type="primary"
@click="lookupNinetyoneProducts"
>查询 91卡券订单</el-button
>
</div>
</div>
<p class="toolbar-hint">导入后会创建 provider=91kaquanplatform=kuaishou外部 SKU=productNo 的规则草稿保存规则后回到平台配置 -> 91卡券接入重试订单</p>
<p class="toolbar-hint">
导入后会创建 provider=91kaquanplatform=kuaishou外部 SKU=productNo
的规则草稿保存规则后回到平台配置 -> 91卡券接入重试订单
</p>
<p v-if="ninetyoneLookupErrorMessage" class="error-copy lookup-error">{{ ninetyoneLookupErrorMessage }}</p>
<p v-if="ninetyoneLookupErrorMessage" class="error-copy lookup-error">
{{ ninetyoneLookupErrorMessage }}
</p>
<div v-else-if="ninetyoneLookupLoading" class="empty-inline">正在查询 91卡券订单</div>
<table v-else class="data-table">
@@ -875,7 +937,11 @@ onMounted(loadConfigs)
<button
v-for="option in ruleFilterOptions"
:key="option.value"
:class="['mini-stat-chip', 'mini-stat-chip--button', { 'mini-stat-chip--active': ruleFilter === option.value }]"
:class="[
'mini-stat-chip',
'mini-stat-chip--button',
{ 'mini-stat-chip--active': ruleFilter === option.value },
]"
type="button"
@click="ruleFilter = option.value"
>
@@ -885,28 +951,46 @@ onMounted(loadConfigs)
<div class="section-action-group">
<el-button round @click="expandAllRules">全部展开</el-button>
<el-button round @click="collapseReadyRules">收起已完成</el-button>
<el-button round :loading="cloudSkuCatalogLoading" @click="refreshCloudSkuCatalog">刷新 cloud SKU</el-button>
<el-button round :loading="cloudSkuCatalogLoading" @click="refreshCloudSkuCatalog"
>刷新 cloud SKU</el-button
>
<el-button round @click="addItem">新增规则</el-button>
<el-button round type="primary" :loading="saving" @click="saveConfigs">保存全部</el-button>
<el-button round type="primary" :loading="saving" @click="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>
<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>
<div
v-for="(item, index) in filteredItems"
:key="item.localId"
:data-local-id="item.localId"
:class="['binding-card', { 'binding-card--invalid': validationState?.localId === 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) }]">
<span
:class="[
'binding-status-chip',
{ 'binding-status-chip--done': isItemComplete(item) },
]"
>
{{ getRuleState(item).label }}
</span>
</div>
@@ -934,31 +1018,66 @@ onMounted(loadConfigs)
<div class="mapping-grid">
<label class="field-block">
<span>来源 provider</span>
<input v-model="item.provider" class="text-input" maxlength="32" placeholder="默认 91kaquan" />
<input
v-model="item.provider"
class="text-input"
maxlength="32"
placeholder="默认 91kaquan"
/>
</label>
<label class="field-block">
<span>来源 platform</span>
<input v-model="item.platform" class="text-input" maxlength="32" placeholder="默认 kuaishou" />
<input
v-model="item.platform"
class="text-input"
maxlength="32"
placeholder="默认 kuaishou"
/>
</label>
<label class="field-block field-wide">
<span>店铺 ID</span>
<input v-model="item.shopId" class="text-input" maxlength="80" placeholder="留空表示跨店铺共用" />
<input
v-model="item.shopId"
class="text-input"
maxlength="80"
placeholder="留空表示跨店铺共用"
/>
</label>
<label class="field-block">
<span>外部 SKU</span>
<input v-model="item.externalSkuCode" class="text-input" maxlength="120" placeholder="例如 91卡券 productNo" />
<input
v-model="item.externalSkuCode"
class="text-input"
maxlength="120"
placeholder="例如 91卡券 productNo"
/>
</label>
<label class="field-block">
<span>外部商品 ID</span>
<input v-model="item.externalItemId" class="text-input" maxlength="120" placeholder="平台商品 ID" />
<input
v-model="item.externalItemId"
class="text-input"
maxlength="120"
placeholder="平台商品 ID"
/>
</label>
<label class="field-block field-wide">
<span>外部商品名</span>
<input v-model="item.externalSkuName" class="text-input" maxlength="200" placeholder="用于名称匹配" />
<input
v-model="item.externalSkuName"
class="text-input"
maxlength="200"
placeholder="用于名称匹配"
/>
</label>
<label class="field-block field-wide">
<span>解析商品名</span>
<input v-model="item.resolvedSkuName" class="text-input" maxlength="120" placeholder="可作为商品名兜底" />
<input
v-model="item.resolvedSkuName"
class="text-input"
maxlength="120"
placeholder="可作为商品名兜底"
/>
</label>
</div>
</section>
@@ -976,19 +1095,39 @@ onMounted(loadConfigs)
<div class="mapping-grid">
<label class="field-block">
<span>内部 SKU</span>
<input v-model="item.internalSkuCode" class="text-input" maxlength="80" placeholder="必填,用于生成任务" />
<input
v-model="item.internalSkuCode"
class="text-input"
maxlength="80"
placeholder="必填,用于生成任务"
/>
</label>
<label class="field-block">
<span>优先级</span>
<input v-model.number="item.priority" class="text-input" min="1" type="number" />
<input
v-model.number="item.priority"
class="text-input"
min="1"
type="number"
/>
</label>
<label class="field-block field-wide">
<span>内部商品名</span>
<input v-model="item.internalSkuName" class="text-input" maxlength="120" placeholder="展示用,可选但建议填" />
<input
v-model="item.internalSkuName"
class="text-input"
maxlength="120"
placeholder="展示用,可选但建议填"
/>
</label>
<label class="field-block">
<span>cloudSourceKey</span>
<input v-model="item.cloudSourceKey" class="text-input" maxlength="60" placeholder="默认 default" />
<input
v-model="item.cloudSourceKey"
class="text-input"
maxlength="60"
placeholder="默认 default"
/>
</label>
<label class="field-block">
<span>cloud SKU ID</span>
@@ -1007,7 +1146,12 @@ onMounted(loadConfigs)
</label>
<label class="field-block field-wide">
<span>cloud SKU 名称</span>
<input :value="item.cloudSkuName" class="text-input text-input--readonly" placeholder="选择 cloud SKU 后自动带出" readonly />
<input
:value="item.cloudSkuName"
class="text-input text-input--readonly"
placeholder="选择 cloud SKU 后自动带出"
readonly
/>
</label>
<label class="field-block">
<span>虚拟号 VN Key</span>
@@ -1015,7 +1159,12 @@ onMounted(loadConfigs)
</label>
<label class="field-block">
<span>最低保留余额</span>
<input v-model.number="item.minAssetReserve" class="text-input" min="0" type="number" />
<input
v-model.number="item.minAssetReserve"
class="text-input"
min="0"
type="number"
/>
</label>
</div>
</section>
@@ -1038,24 +1187,43 @@ onMounted(loadConfigs)
{{ formatKuaishouConsumeShopOption(shop) }}
</option>
</select>
<small class="field-help">来自平台店铺 -> 快手小店核销配置保存时会自动带出店铺名</small>
<small class="field-help"
>来自平台店铺 -> 快手小店核销配置保存时会自动带出店铺名</small
>
</label>
<label class="field-block">
<span>核销店铺 ID</span>
<input :value="item.kuaishouConsumeShopId || '-'" class="text-input text-input--readonly" readonly />
<input
:value="item.kuaishouConsumeShopId || '-'"
class="text-input text-input--readonly"
readonly
/>
</label>
<label class="field-block">
<span>核销店铺名</span>
<input :value="item.kuaishouConsumeShopName || '-'" class="text-input text-input--readonly" readonly />
<input
:value="item.kuaishouConsumeShopName || '-'"
class="text-input text-input--readonly"
readonly
/>
</label>
<label class="field-block field-wide">
<span>备注</span>
<textarea v-model="item.notes" class="text-input textarea-input" maxlength="400" placeholder="绑定场景、客服注意事项等" />
<textarea
v-model="item.notes"
class="text-input textarea-input"
maxlength="400"
placeholder="绑定场景、客服注意事项等"
/>
</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>
<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">
<label class="checkbox-line">