清理快手前端无用入口
This commit is contained in:
@@ -1,45 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AdminFulfillmentBindingsView from './bindings/AdminFulfillmentBindingsView.vue'
|
||||
import AdminKuaishouCloudFulfillmentView from './kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue'
|
||||
|
||||
type FulfillmentTabKey = 'legacy' | 'kuaishou-cloud'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = computed<FulfillmentTabKey>({
|
||||
get() {
|
||||
const value = String(route.query.tab || '').trim()
|
||||
return value === 'kuaishou-cloud' ? 'kuaishou-cloud' : 'legacy'
|
||||
},
|
||||
set(value) {
|
||||
const next = value === 'kuaishou-cloud' ? 'kuaishou-cloud' : 'legacy'
|
||||
void router.replace({
|
||||
path: '/admin/platform-fulfillment',
|
||||
query: next === 'legacy' ? {} : { tab: next },
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hub-panel">
|
||||
<AdminPageHeader
|
||||
title="履约配置中心"
|
||||
description="维护外部商品到内部 SKU 的映射规则,旧规则和快手 Cloud 新流程都在这里配置。"
|
||||
description="维护 91 卡券快手商品到 Cloud 履约资源的映射规则。"
|
||||
/>
|
||||
|
||||
<el-tabs v-model="activeTab" class="hub-tabs">
|
||||
<el-tab-pane label="旧履约规则" name="legacy">
|
||||
<AdminFulfillmentBindingsView />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="快手 Cloud 新履约" name="kuaishou-cloud">
|
||||
<AdminKuaishouCloudFulfillmentView />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<AdminKuaishouCloudFulfillmentView />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -48,21 +18,4 @@ const activeTab = computed<FulfillmentTabKey>({
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
:deep(.hub-tabs .el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.hub-tabs .el-tabs__content) {
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
:deep(.hub-tabs .el-tabs__item) {
|
||||
font-weight: 500;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
:deep(.hub-tabs .el-tabs__item.is-active) {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.section-card :deep(.el-card__header) {
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.section-card :deep(.el-card__body) {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.card-header--split {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.overview-file {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.overview-file code {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.overview-stat {
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.overview-stat :deep(.el-statistic__head) {
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.overview-stat :deep(.el-statistic__content) {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overview-notes,
|
||||
.section-mini-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.lookup-toolbar,
|
||||
.lookup-summary {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.lookup-toolbar {
|
||||
grid-template-columns: 96px 110px minmax(140px, 1fr) minmax(180px, 1.2fr) 136px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.lookup-summary {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.field-help {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.text-input--invalid .el-input__wrapper),
|
||||
.admin-panel :deep(.text-input--invalid .el-select__wrapper) {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 1px var(--color-danger) inset;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item__label) {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.cell-subtle {
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cell-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.binding-card {
|
||||
margin-top: var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.binding-card :deep(.el-card__header) {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.binding-card :deep(.el-card__body) {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.binding-card--invalid {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
.binding-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.binding-summary {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.binding-title-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.binding-subtle {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.binding-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.binding-collapsed-preview {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.binding-collapsed-preview :deep(.el-descriptions__label) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card-header--split,
|
||||
.binding-actions,
|
||||
.binding-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.overview-stats,
|
||||
.lookup-toolbar,
|
||||
.binding-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.binding-collapsed-preview :deep(.el-descriptions__body .el-descriptions__table) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-card :deep(.el-card__header),
|
||||
.section-card :deep(.el-card__body) {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.binding-card :deep(.el-card__header),
|
||||
.binding-card :deep(.el-card__body) {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.data-table {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.data-table :deep(.el-table__header th) {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.data-table :deep(.el-table__body td) {
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<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 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">
|
||||
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以维护履约配置" />
|
||||
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="bindings.errorMessage.value"
|
||||
:title="bindings.errorMessage.value"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-skeleton v-if="bindings.loading.value" :rows="12" animated />
|
||||
|
||||
<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>
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
<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>
|
||||
<el-card
|
||||
:data-binding-id="binding.id"
|
||||
:class="['binding-card', { 'binding-card--invalid': isInvalid }]"
|
||||
shadow="never"
|
||||
>
|
||||
<template #header>
|
||||
<div class="binding-header">
|
||||
<div class="binding-summary">
|
||||
<div class="binding-title-row">
|
||||
<strong>{{ title }}</strong>
|
||||
<el-tag :type="isComplete ? 'success' : 'warning'" effect="plain">
|
||||
{{ statusLabel }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span class="binding-subtle">{{ summary }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="emit('toggleCollapsed')">
|
||||
{{ isBindingCollapsed ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions v-if="isBindingCollapsed" :column="3" border class="binding-collapsed-preview">
|
||||
<el-descriptions-item label="外部商品">
|
||||
{{ binding.externalSkuName || binding.externalSkuCode || binding.externalItemId || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="内部履约 SKU">
|
||||
{{ binding.skuCode || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="履约方式">
|
||||
{{
|
||||
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
|
||||
binding.profileKey
|
||||
}}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-form v-else label-position="top" class="binding-grid">
|
||||
<el-form-item label="渠道">
|
||||
<el-input
|
||||
v-model="binding.provider"
|
||||
class="text-input"
|
||||
placeholder="agiso"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="平台">
|
||||
<el-input
|
||||
v-model="binding.platform"
|
||||
class="text-input"
|
||||
placeholder="xianyu / taobao / pdd"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="店铺 ID">
|
||||
<el-input
|
||||
v-model="binding.shopId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('shopId') }]"
|
||||
class="text-input"
|
||||
data-field="shopId"
|
||||
placeholder="留空表示跨店铺通用"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="店铺名称">
|
||||
<el-input
|
||||
v-model="binding.shopName"
|
||||
class="text-input"
|
||||
placeholder="用于保存和识别店铺"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number
|
||||
v-model="binding.priority"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
@change="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="外部 SKU 编码">
|
||||
<el-input
|
||||
v-model="binding.externalSkuCode"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('externalSkuCode') }]"
|
||||
class="text-input"
|
||||
data-field="externalSkuCode"
|
||||
placeholder="例如 6046726460016"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="外部商品 ID">
|
||||
<el-input
|
||||
v-model="binding.externalItemId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('externalItemId') }]"
|
||||
class="text-input"
|
||||
data-field="externalItemId"
|
||||
placeholder="例如 1033324289962"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="外部商品名" class="field-wide">
|
||||
<el-input
|
||||
v-model="binding.externalSkuName"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('externalSkuName') }]"
|
||||
class="text-input"
|
||||
data-field="externalSkuName"
|
||||
placeholder="例如 奥利奥动作"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内部履约 SKU" class="field-with-help">
|
||||
<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
|
||||
>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内部商品名">
|
||||
<el-input
|
||||
v-model="binding.skuName"
|
||||
class="text-input"
|
||||
placeholder="用于后台展示"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="履约方式">
|
||||
<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>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="匹配后展示名">
|
||||
<el-input
|
||||
v-model="binding.resolvedSkuName"
|
||||
class="text-input"
|
||||
placeholder="例如 三角洲行动-自动领取"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="isInvalid"
|
||||
:title="validationState?.message"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-checkbox v-model="binding.enabled">启用规则</el-checkbox>
|
||||
<el-button link type="danger" @click="emit('remove')">删除</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminFulfillmentBindings.css"></style>
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
<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>
|
||||
<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"
|
||||
>新商品还没配规则时,可以按店铺 ID 和平台订单号临时补查,查完直接导入规则草稿。</span
|
||||
>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small">仅支持 Agiso 咸鱼订单</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-position="top" class="lookup-toolbar">
|
||||
<el-form-item label="渠道">
|
||||
<el-input class="text-input" model-value="agiso" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="平台">
|
||||
<el-input class="text-input" model-value="xianyu" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="店铺 ID">
|
||||
<el-input
|
||||
v-model="lookupForm.shopId"
|
||||
class="text-input"
|
||||
placeholder="例如 252609"
|
||||
@keydown.enter.prevent="emit('lookup')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="平台订单号">
|
||||
<el-input
|
||||
v-model="lookupForm.platformOrderId"
|
||||
class="text-input"
|
||||
placeholder="输入需要补查的订单号"
|
||||
@keydown.enter.prevent="emit('lookup')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="操作">
|
||||
<el-button :loading="lookupLoading" type="primary" class="w-full" @click="emit('lookup')">
|
||||
查询订单商品
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="lookupErrorMessage"
|
||||
:title="lookupErrorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-skeleton v-else-if="lookupLoading" :rows="4" animated />
|
||||
<template v-else-if="lookupResult">
|
||||
<div class="lookup-summary">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="订单">
|
||||
<strong>{{ lookupResult.order.platformOrderId }}</strong>
|
||||
<span class="cell-subtle">{{
|
||||
lookupResult.order.shopName || lookupResult.order.shopId || '-'
|
||||
}}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="买家">
|
||||
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>支付时间:{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span
|
||||
>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="金额">
|
||||
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
|
||||
<span class="cell-subtle">
|
||||
补查结果:{{
|
||||
lookupResult.order.enriched ? '已补全' : lookupResult.order.enrichReason || '已返回'
|
||||
}}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</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>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminFulfillmentBindings.css"></style>
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
<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>
|
||||
<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。</span
|
||||
>
|
||||
</div>
|
||||
<el-space wrap>
|
||||
<el-tag type="warning" effect="plain" size="small"
|
||||
>待补 {{ pendingObservedCount }}</el-tag
|
||||
>
|
||||
<el-tag effect="plain" size="small">总计 {{ observedProducts.length }}</el-tag>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminFulfillmentBindings.css"></style>
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
<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>
|
||||
<el-card shadow="never" class="section-card overview-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">旧履约规则概览</span>
|
||||
<span class="card-desc">按外部商品信息匹配内部履约 SKU 和交付方式。</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="ruleMetrics.totalCount" />
|
||||
<el-statistic class="overview-stat" title="完整规则" :value="ruleMetrics.completedCount" />
|
||||
<el-statistic class="overview-stat" title="待完善" :value="ruleMetrics.draftCount" />
|
||||
<el-statistic class="overview-stat" title="待补商品" :value="pendingObservedCount" />
|
||||
<el-statistic class="overview-stat" title="已忽略商品" :value="observedProductsLength" />
|
||||
</div>
|
||||
|
||||
<div class="overview-notes">
|
||||
<el-tag effect="plain" size="small">外部 SKU / 商品 ID / 商品名命中任一即可</el-tag>
|
||||
<el-tag effect="plain" size="small">内部 SKU 决定库存绑定与履约链路</el-tag>
|
||||
<el-tag effect="plain" size="small">同类商品跨平台尽量共用同一内部 SKU</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminFulfillmentBindings.css"></style>
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
<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>
|
||||
<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"
|
||||
>正式规则集中维护在这里,新补查到的商品也会导入到这个区域继续补全。</span
|
||||
>
|
||||
</div>
|
||||
<el-space wrap>
|
||||
<el-button @click="emit('addBinding')">新增规则</el-button>
|
||||
<el-button @click="emit('loadConfigs')">刷新</el-button>
|
||||
<el-button :loading="saving" type="primary" @click="emit('saveConfigs')">
|
||||
保存配置
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="section-mini-stats">
|
||||
<el-tag type="success" effect="plain" size="small"
|
||||
>完整 {{ ruleMetrics.completedCount }}</el-tag
|
||||
>
|
||||
<el-tag type="warning" effect="plain" size="small"
|
||||
>待完善 {{ ruleMetrics.draftCount }}</el-tag
|
||||
>
|
||||
<el-tag effect="plain" size="small">人工 {{ ruleMetrics.manualCount }}</el-tag>
|
||||
<el-tag effect="plain" size="small">半自动 {{ ruleMetrics.assistedCount }}</el-tag>
|
||||
<el-tag effect="plain" size="small">快手 Cloud {{ ruleMetrics.kuaishouCloudCount }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="validationState?.message"
|
||||
:title="validationState.message"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<el-empty
|
||||
v-if="bindings.length === 0"
|
||||
description="当前还没有履约规则,先新增一条。"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<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)"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminFulfillmentBindings.css"></style>
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { AdminObservedProductItem } from '@/types/admin'
|
||||
|
||||
export type EditableBinding = {
|
||||
id: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
resolvedSkuName: string
|
||||
}
|
||||
|
||||
export type SaveBindingPayload = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
match: {
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
config: {
|
||||
resolvedSkuName: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId'
|
||||
|
||||
export type ValidationState = {
|
||||
bindingId: string
|
||||
field: ValidationField
|
||||
message: string
|
||||
} | null
|
||||
|
||||
export type ImportableProductCandidate = Pick<
|
||||
AdminObservedProductItem,
|
||||
| 'provider'
|
||||
| 'platform'
|
||||
| 'shopId'
|
||||
| 'shopName'
|
||||
| 'externalSkuCode'
|
||||
| 'externalItemId'
|
||||
| 'externalSkuName'
|
||||
> & {}
|
||||
|
||||
export const PROFILE_OPTIONS = [
|
||||
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
|
||||
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
|
||||
{ label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' },
|
||||
{ label: '人工发货', value: 'manual_review' },
|
||||
]
|
||||
|
||||
export const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
|
||||
-453
@@ -1,453 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import { lookupAdminFulfillmentBindingOrder } from '@/services/admin'
|
||||
import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Fold,
|
||||
List,
|
||||
Message,
|
||||
Service,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
Tickets,
|
||||
@@ -63,10 +62,7 @@ const navItems = computed(() => {
|
||||
items.push({ to: '/admin/inventory', label: '库存', icon: Box })
|
||||
}
|
||||
|
||||
items.push(
|
||||
{ to: '/admin/manual-redeem', label: '人工兑换', icon: Service },
|
||||
{ to: '/admin/message-deliveries', label: '消息发送', icon: Message },
|
||||
)
|
||||
items.push({ to: '/admin/message-deliveries', label: '消息发送', icon: Message })
|
||||
|
||||
if (isAdmin.value || isOperator.value) {
|
||||
items.push({ to: '/admin/webhook-events', label: 'Webhook', icon: Connection })
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
|
||||
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
|
||||
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
|
||||
import { useAdminManualRedeemPage } from '@/composables/useAdminManualRedeemPage'
|
||||
import AdminManualRedeemCreateCard from './components/AdminManualRedeemCreateCard.vue'
|
||||
import AdminManualRedeemSummaryCard from './components/AdminManualRedeemSummaryCard.vue'
|
||||
import AdminManualRedeemReviewCard from './components/AdminManualRedeemReviewCard.vue'
|
||||
|
||||
const {
|
||||
form,
|
||||
suggestions,
|
||||
detail,
|
||||
detailLoading,
|
||||
createLoading,
|
||||
sessionLoading,
|
||||
roleConfirmLoading,
|
||||
redeemLoading,
|
||||
closeLoading,
|
||||
loginType,
|
||||
loginTypeLabel,
|
||||
loginTabs,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
manualRequest,
|
||||
hasSession,
|
||||
qrImage,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
statusLabel,
|
||||
session,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
canConfirmRole,
|
||||
canRedeem,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
initButtonLabel,
|
||||
scanInstruction,
|
||||
screenshotEmptyTitle,
|
||||
screenshotEmptyMessage,
|
||||
screenshotUrl,
|
||||
screenshotLoading,
|
||||
reviewReady,
|
||||
resultReady,
|
||||
canCreateTask,
|
||||
hasActiveTask,
|
||||
createTaskNow,
|
||||
createSessionFlow,
|
||||
refreshSessionFlow,
|
||||
closeSessionFlow,
|
||||
switchLoginType,
|
||||
confirmRoleNow,
|
||||
redeemNow,
|
||||
closeTaskNow,
|
||||
resetCurrentTask,
|
||||
handleQrImageLoad,
|
||||
copyQrImage,
|
||||
copyScreenshot,
|
||||
downloadScreenshot,
|
||||
} = useAdminManualRedeemPage()
|
||||
|
||||
const qrPreviewVisible = ref(false)
|
||||
const screenshotPreviewVisible = ref(false)
|
||||
|
||||
const currentLoginTabLabel = computed(
|
||||
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
|
||||
)
|
||||
|
||||
const reviewCardTitle = computed(() =>
|
||||
task.value?.status === 'redeemed' ? '兑换截图' : '角色复核图',
|
||||
)
|
||||
const canStartNextTask = computed(() =>
|
||||
Boolean(task.value && ['redeemed', 'closed'].includes(task.value.status)),
|
||||
)
|
||||
const taskDetailLink = computed(() =>
|
||||
task.value?.taskId ? `/admin/tasks/${task.value.taskId}` : '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel manual-redeem-panel">
|
||||
<AdminPageHeader
|
||||
title="人工兑换"
|
||||
description="客服在后台创建唯一任务、发送二维码、复核角色并完成兑换。"
|
||||
>
|
||||
<template v-if="taskDetailLink">
|
||||
<RouterLink :to="taskDetailLink" class="task-link">查看任务详情</RouterLink>
|
||||
</template>
|
||||
<el-button
|
||||
v-if="hasActiveTask"
|
||||
:loading="closeLoading"
|
||||
plain
|
||||
type="danger"
|
||||
@click="closeTaskNow"
|
||||
>
|
||||
关闭当前任务
|
||||
</el-button>
|
||||
<el-button v-if="canStartNextTask" plain type="primary" @click="resetCurrentTask">
|
||||
开始下一单
|
||||
</el-button>
|
||||
</AdminPageHeader>
|
||||
|
||||
<AdminManualRedeemCreateCard
|
||||
:form="form"
|
||||
:suggestions="suggestions"
|
||||
:can-create-task="canCreateTask"
|
||||
:create-loading="createLoading"
|
||||
:create-task-now="createTaskNow"
|
||||
/>
|
||||
|
||||
<AdminManualRedeemSummaryCard
|
||||
:task="task"
|
||||
:order="order"
|
||||
:order-item="orderItem"
|
||||
:manual-request="manualRequest"
|
||||
:status-label="statusLabel"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="detailLoading && !detail" :rows="8" animated />
|
||||
|
||||
<div v-else-if="detail" class="layout-grid">
|
||||
<TencentAuthCard
|
||||
:current-login-tab-label="currentLoginTabLabel"
|
||||
:has-session="hasSession"
|
||||
:init-button-label="initButtonLabel"
|
||||
:login-tabs="loginTabs"
|
||||
:login-type="loginType"
|
||||
:login-type-label="loginTypeLabel"
|
||||
qr-copy-button-label="复制当前二维码"
|
||||
:qr-copy-enabled="true"
|
||||
:qr-figure-style="qrFigureStyle"
|
||||
:qr-image="qrImage"
|
||||
:scan-instruction="scanInstruction"
|
||||
:session-status="session?.status || ''"
|
||||
:session-id="session?.sessionId || ''"
|
||||
:session-loading="sessionLoading"
|
||||
:session-notice="sessionNotice"
|
||||
@create-session="createSessionFlow"
|
||||
@close-session="closeSessionFlow"
|
||||
@copy-qr="copyQrImage"
|
||||
@open-qr-preview="qrPreviewVisible = true"
|
||||
@qr-image-load="handleQrImageLoad"
|
||||
@reload-session="refreshSessionFlow"
|
||||
@switch-login-type="switchLoginType"
|
||||
/>
|
||||
|
||||
<div class="control-column">
|
||||
<TencentRedeemPanel
|
||||
:can-redeem="canRedeem"
|
||||
:can-confirm-role="canConfirmRole"
|
||||
confirm-action-label="确认当前角色"
|
||||
:confirm-action-loading="roleConfirmLoading"
|
||||
:confirm-action-visible="!roleConfirmed"
|
||||
:confirm-readonly="false"
|
||||
:hide-redeem-form="true"
|
||||
:login-type-label="loginTypeLabel"
|
||||
:max-attempts="6"
|
||||
:redeem-blocked-reason="redeemBlockedReason"
|
||||
:redeem-button-label="redeemButtonLabel"
|
||||
:redeem-code="''"
|
||||
:redeem-loading="redeemLoading"
|
||||
:role-confirmed="roleConfirmed"
|
||||
:role-facts="roleFacts"
|
||||
:role-ready="roleReady"
|
||||
:status-label="statusLabel"
|
||||
@confirm-role="confirmRoleNow"
|
||||
@redeem="redeemNow"
|
||||
@update:max-attempts="() => undefined"
|
||||
@update:redeem-code="() => undefined"
|
||||
@update:role-confirmed="() => undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminManualRedeemReviewCard
|
||||
v-if="detail"
|
||||
:review-card-title="reviewCardTitle"
|
||||
:review-ready="reviewReady"
|
||||
:result-ready="resultReady"
|
||||
:screenshot-empty-title="screenshotEmptyTitle"
|
||||
:screenshot-empty-message="screenshotEmptyMessage"
|
||||
:screenshot-url="screenshotUrl"
|
||||
:screenshot-loading="screenshotLoading"
|
||||
:copy-screenshot="copyScreenshot"
|
||||
:download-screenshot="downloadScreenshot"
|
||||
:open-preview="() => (screenshotPreviewVisible = true)"
|
||||
/>
|
||||
|
||||
<TencentResultPanel
|
||||
v-if="detail && task?.status === 'redeemed'"
|
||||
:result-facts="resultFacts"
|
||||
:screenshot-empty-message="screenshotEmptyMessage"
|
||||
:screenshot-empty-title="screenshotEmptyTitle"
|
||||
:screenshot-url="screenshotUrl"
|
||||
:show-screenshot="Boolean(screenshotUrl)"
|
||||
@open-screenshot-preview="screenshotPreviewVisible = true"
|
||||
/>
|
||||
|
||||
<el-empty
|
||||
v-else-if="!detail && !detailLoading"
|
||||
description="先在上方输入唯一凭据和内部 SKU,系统会立即校验唯一性并预占库存。"
|
||||
>
|
||||
<template #default>
|
||||
<strong>还没有进行中的人工兑换任务</strong>
|
||||
</template>
|
||||
</el-empty>
|
||||
|
||||
<el-dialog
|
||||
v-model="qrPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
:width="qrPreviewWidth"
|
||||
>
|
||||
<div class="qr-preview-body">
|
||||
<img
|
||||
class="preview-image qr-preview-image"
|
||||
:src="qrImage"
|
||||
:alt="`Admin Manual Redeem ${loginTypeLabel} QR Preview`"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="screenshotPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
width="1080px"
|
||||
>
|
||||
<img class="preview-image" :src="screenshotUrl" alt="Manual redeem screenshot preview" />
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manual-redeem-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.task-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 392px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-column {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type Props = {
|
||||
form: {
|
||||
proofValue: string
|
||||
skuCode: string
|
||||
remark: string
|
||||
}
|
||||
suggestions: {
|
||||
credentialType: string
|
||||
inventoryGroupCode: string
|
||||
skuCode: string
|
||||
availableCount: number
|
||||
totalCount: number
|
||||
}[]
|
||||
canCreateTask: boolean
|
||||
createLoading: boolean
|
||||
createTaskNow: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<span class="card-title">1. 创建人工兑换任务</span>
|
||||
<span class="card-desc"
|
||||
>先录入唯一凭据并锁定库存,后续二维码、角色确认和兑换都基于这条任务继续。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-position="top" class="form-grid">
|
||||
<el-form-item label="唯一凭据">
|
||||
<el-input v-model="form.proofValue" placeholder="输入订单号、客户名或其他唯一可追踪信息" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内部履约 SKU">
|
||||
<el-input
|
||||
v-model="form.skuCode"
|
||||
list="manual-redeem-sku-suggestions"
|
||||
placeholder="选择或输入内部履约 SKU"
|
||||
/>
|
||||
<datalist id="manual-redeem-sku-suggestions">
|
||||
<option
|
||||
v-for="item in suggestions"
|
||||
:key="`${item.credentialType}-${item.inventoryGroupCode}-${item.skuCode}`"
|
||||
:value="item.skuCode"
|
||||
:label="`${item.skuCode} · ${item.inventoryGroupCode || '未分组'} · 可用 ${item.availableCount} / 共 ${item.totalCount}`"
|
||||
/>
|
||||
</datalist>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" class="field-remark">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可选:记录客户补充信息、兑换要求或客服备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button
|
||||
:disabled="!canCreateTask"
|
||||
:loading="createLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="createTaskNow"
|
||||
>
|
||||
创建任务并预占库存
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.section-card :deep(.el-form-item__label) {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-remark {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type Props = {
|
||||
reviewCardTitle: string
|
||||
reviewReady: boolean
|
||||
resultReady: boolean
|
||||
screenshotEmptyTitle: string
|
||||
screenshotEmptyMessage: string
|
||||
screenshotUrl: string
|
||||
screenshotLoading: boolean
|
||||
copyScreenshot: () => void
|
||||
downloadScreenshot: () => void
|
||||
openPreview: () => void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<span class="card-title">2. {{ reviewCardTitle }}</span>
|
||||
<span v-if="reviewReady" class="card-desc">
|
||||
客户登录后,系统会把当前角色截图回传到这里。客服复制后可直接发给客户做二次确认。
|
||||
</span>
|
||||
<span v-else-if="resultReady" class="card-desc">
|
||||
最终兑换完成后,客服可以把结果截图直接发给客户确认。
|
||||
</span>
|
||||
<span v-else class="card-desc">
|
||||
{{ screenshotEmptyMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<el-space wrap>
|
||||
<el-button :disabled="!screenshotUrl || screenshotLoading" plain @click="copyScreenshot">
|
||||
复制图片
|
||||
</el-button>
|
||||
<el-button
|
||||
:disabled="!screenshotUrl || screenshotLoading"
|
||||
plain
|
||||
@click="downloadScreenshot"
|
||||
>
|
||||
下载图片
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="screenshotUrl" class="review-stage">
|
||||
<el-image
|
||||
class="screenshot-preview"
|
||||
:src="screenshotUrl"
|
||||
alt="manual redeem screenshot"
|
||||
fit="contain"
|
||||
@click="openPreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-empty v-else :description="screenshotEmptyMessage">
|
||||
<template #default>
|
||||
<strong>{{ screenshotEmptyTitle || '' }}</strong>
|
||||
</template>
|
||||
</el-empty>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.review-stage {
|
||||
margin-top: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.screenshot-preview {
|
||||
width: 100%;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type TaskDetail = {
|
||||
taskNo?: string
|
||||
taskId?: string | number
|
||||
status?: string
|
||||
}
|
||||
|
||||
type Order = {
|
||||
platformOrderId?: string
|
||||
}
|
||||
|
||||
type OrderItem = {
|
||||
skuName?: string
|
||||
}
|
||||
|
||||
type ManualRequest = {
|
||||
proofValue?: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
task: TaskDetail | null
|
||||
order: Order | null
|
||||
orderItem: OrderItem | null
|
||||
manualRequest: ManualRequest | null
|
||||
statusLabel: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card v-if="task" shadow="never" class="section-card">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="唯一凭据">
|
||||
{{ manualRequest?.proofValue || order?.platformOrderId || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务号">
|
||||
{{ task.taskNo || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商品">
|
||||
{{ orderItem?.skuName || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
{{ statusLabel }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 900px) {
|
||||
.section-card :deep(.el-descriptions__body .el-descriptions__table) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RefreshRight } from '@element-plus/icons-vue'
|
||||
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
import {
|
||||
fetchAdminAgisoShopConfigs,
|
||||
fetchAdminCloudtentaclesSourceConfig,
|
||||
fetchAdminKuaishouEticketSourceConfig,
|
||||
fetchAdminNotificationConfig,
|
||||
fetchAdminScheduledJobsConfig,
|
||||
} from '@/services/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import AdminPlatformAgisoSection from './components/AdminPlatformAgisoSection.vue'
|
||||
import AdminPlatformCloudtentaclesSection from './components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue'
|
||||
import AdminPlatformNinetyoneSection from './components/AdminPlatformNinetyoneSection.vue'
|
||||
import AdminPlatformNotificationSection from './components/notification/AdminPlatformNotificationSection.vue'
|
||||
import AdminPlatformKuaishouEticketSection from './components/kuaishou-eticket/AdminPlatformKuaishouEticketSection.vue'
|
||||
import { useAdminAgisoPlatform } from './composables/useAdminAgisoPlatform'
|
||||
import { useAdminCloudtentaclesPlatform } from './composables/useAdminCloudtentaclesPlatform'
|
||||
import { useAdminNinetyonePlatform } from './composables/useAdminNinetyonePlatform'
|
||||
import { useAdminNotificationPlatform } from './composables/useAdminNotificationPlatform'
|
||||
@@ -26,26 +23,7 @@ import type { PlatformTab } from './composables/types'
|
||||
const loading = ref(true)
|
||||
const isDevMode = import.meta.env.DEV
|
||||
const errorMessage = ref('')
|
||||
const activePlatform = ref<PlatformTab>('agiso')
|
||||
|
||||
const {
|
||||
saving,
|
||||
agisoFilePath,
|
||||
defaults,
|
||||
shops,
|
||||
observedShops,
|
||||
agisoStats,
|
||||
hydrateAgisoConfig,
|
||||
addShop,
|
||||
removeShop,
|
||||
importObservedShop,
|
||||
isShopExpanded,
|
||||
toggleShop,
|
||||
expandAllShops,
|
||||
collapseAllShops,
|
||||
describeShop,
|
||||
saveAgisoConfigs: persistAgisoConfigs,
|
||||
} = useAdminAgisoPlatform()
|
||||
const activePlatform = ref<PlatformTab>('ninetyone')
|
||||
|
||||
const {
|
||||
ninetyoneLoading,
|
||||
@@ -164,6 +142,55 @@ const {
|
||||
handleCloudtentaclesRunFullFlow,
|
||||
} = useAdminCloudtentaclesPlatform()
|
||||
|
||||
const overviewCards = computed(() => {
|
||||
const cards = [
|
||||
{
|
||||
key: 'notifications',
|
||||
label: '内部通知',
|
||||
tag: 'Bark / WPush',
|
||||
value:
|
||||
notificationStats.value.barkEnabledRecipientCount +
|
||||
notificationStats.value.wpushEnabledRecipientCount,
|
||||
unit: '启用接收人',
|
||||
meta: `Bark ${notificationStats.value.barkConfiguredRecipientCount} 人 · WPush ${notificationStats.value.wpushConfiguredRecipientCount} 人 · 最近成功 ${notificationStats.value.lastSuccessCount} 个`,
|
||||
},
|
||||
{
|
||||
key: 'ninetyone',
|
||||
label: '91卡券',
|
||||
tag: '接入 / 待补全',
|
||||
value: ninetyoneStats.value.pendingCount,
|
||||
unit: '待补全订单',
|
||||
meta: `当前 ${ninetyoneStats.value.currentCount} 条 · 已生成任务 ${ninetyoneStats.value.taskReadyCount} 条`,
|
||||
},
|
||||
{
|
||||
key: 'kuaishouEticket',
|
||||
label: '快手小店核销',
|
||||
tag: 'Cookie / 券核销',
|
||||
value: kuaishouEticketStats.value.configuredShopCount,
|
||||
unit: '已配置店铺',
|
||||
meta: `Cookie 就绪 ${kuaishouEticketStats.value.cookieReadyCount} 家 · 当前店铺 ${
|
||||
kuaishouEticketStats.value.selectedShopReady ? '可调试' : '待补全'
|
||||
}`,
|
||||
},
|
||||
...(isDevMode
|
||||
? [
|
||||
{
|
||||
key: 'cloudtentacles',
|
||||
label: 'cloudtentacles',
|
||||
tag: '履约 / 登录',
|
||||
value: cloudtentaclesStats.value.hasCredential ? '已配置' : '未配置',
|
||||
unit: '履约凭据',
|
||||
meta: `短信 ${cloudtentaclesStats.value.smsSent ? '已发送' : '未发送'} · 会话 ${
|
||||
cloudtentaclesStats.value.validated ? '已验证' : '未验证'
|
||||
}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return cards
|
||||
})
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
@@ -175,20 +202,17 @@ async function loadConfigs() {
|
||||
|
||||
try {
|
||||
const [
|
||||
agisoResponse,
|
||||
notificationResponse,
|
||||
scheduledJobsResponse,
|
||||
kuaishouEticketResponse,
|
||||
cloudtentaclesResponse,
|
||||
] = await Promise.all([
|
||||
fetchAdminAgisoShopConfigs(),
|
||||
fetchAdminNotificationConfig(),
|
||||
fetchAdminScheduledJobsConfig(),
|
||||
fetchAdminKuaishouEticketSourceConfig(),
|
||||
fetchAdminCloudtentaclesSourceConfig(),
|
||||
])
|
||||
|
||||
hydrateAgisoConfig(agisoResponse.data)
|
||||
hydrateNotificationConfig(notificationResponse.data)
|
||||
hydrateScheduledJobsConfig(scheduledJobsResponse.data)
|
||||
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
||||
@@ -201,10 +225,6 @@ async function loadConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAgisoConfigs() {
|
||||
await persistAgisoConfigs(loadConfigs)
|
||||
}
|
||||
|
||||
function switchTab(tab: string) {
|
||||
activePlatform.value = tab as PlatformTab
|
||||
}
|
||||
@@ -221,9 +241,6 @@ onMounted(loadConfigs)
|
||||
><el-icon><RefreshRight /></el-icon
|
||||
></template>
|
||||
</el-button>
|
||||
<el-button v-if="activePlatform === 'agiso'" type="primary" @click="addShop">
|
||||
新增 Agiso 店铺
|
||||
</el-button>
|
||||
</template>
|
||||
</AdminPageHeader>
|
||||
|
||||
@@ -253,58 +270,7 @@ onMounted(loadConfigs)
|
||||
<!-- 概览卡片 -->
|
||||
<div class="overview-grid">
|
||||
<el-card
|
||||
v-for="card in [
|
||||
{
|
||||
key: 'agiso',
|
||||
label: 'Agiso',
|
||||
tag: 'Webhook / 店铺',
|
||||
value: agisoStats.configuredShopCount,
|
||||
unit: '已配置店铺',
|
||||
meta: `识别到 ${agisoStats.observedShopCount} 家 · 待补 ${agisoStats.pendingObservedCount} 家`,
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
label: '内部通知',
|
||||
tag: 'Bark / WPush',
|
||||
value:
|
||||
notificationStats.barkEnabledRecipientCount +
|
||||
notificationStats.wpushEnabledRecipientCount,
|
||||
unit: '启用接收人',
|
||||
meta: `Bark ${notificationStats.barkConfiguredRecipientCount} 人 · WPush ${notificationStats.wpushConfiguredRecipientCount} 人 · 最近成功 ${notificationStats.lastSuccessCount} 个`,
|
||||
},
|
||||
{
|
||||
key: 'ninetyone',
|
||||
label: '91卡券',
|
||||
tag: '接入 / 待补全',
|
||||
value: ninetyoneStats.pendingCount,
|
||||
unit: '待补全订单',
|
||||
meta: `当前 ${ninetyoneStats.currentCount} 条 · 已生成任务 ${ninetyoneStats.taskReadyCount} 条`,
|
||||
},
|
||||
{
|
||||
key: 'kuaishouEticket',
|
||||
label: '快手小店核销',
|
||||
tag: 'Cookie / 券核销',
|
||||
value: kuaishouEticketStats.configuredShopCount,
|
||||
unit: '已配置店铺',
|
||||
meta: `Cookie 就绪 ${kuaishouEticketStats.cookieReadyCount} 家 · 当前店铺 ${
|
||||
kuaishouEticketStats.selectedShopReady ? '可调试' : '待补全'
|
||||
}`,
|
||||
},
|
||||
...(isDevMode
|
||||
? [
|
||||
{
|
||||
key: 'cloudtentacles',
|
||||
label: 'cloudtentacles',
|
||||
tag: '履约 / 登录',
|
||||
value: cloudtentaclesStats.hasCredential ? '已配置' : '未配置',
|
||||
unit: '履约凭据',
|
||||
meta: `短信 ${cloudtentaclesStats.smsSent ? '已发送' : '未发送'} · 会话 ${
|
||||
cloudtentaclesStats.validated ? '已验证' : '未验证'
|
||||
}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]"
|
||||
v-for="card in overviewCards"
|
||||
:key="card.key"
|
||||
:class="['overview-card', { 'is-active': activePlatform === card.key }]"
|
||||
shadow="hover"
|
||||
@@ -322,24 +288,6 @@ onMounted(loadConfigs)
|
||||
|
||||
<!-- 平台切换 Tab -->
|
||||
<el-tabs v-model="activePlatform" type="card" class="platform-tabs">
|
||||
<el-tab-pane label="Agiso 店铺与消息" name="agiso">
|
||||
<AdminPlatformAgisoSection
|
||||
:agiso-file-path="agisoFilePath"
|
||||
:defaults="defaults"
|
||||
:shops="shops"
|
||||
:observed-shops="observedShops"
|
||||
:saving="saving"
|
||||
:is-shop-expanded="isShopExpanded"
|
||||
:toggle-shop="toggleShop"
|
||||
:expand-all-shops="expandAllShops"
|
||||
:collapse-all-shops="collapseAllShops"
|
||||
:describe-shop="describeShop"
|
||||
:remove-shop="removeShop"
|
||||
:import-observed-shop="importObservedShop"
|
||||
:save-agiso-configs="saveAgisoConfigs"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="91卡券接入" name="ninetyone">
|
||||
<AdminPlatformNinetyoneSection
|
||||
:ninetyone-loading="ninetyoneLoading"
|
||||
|
||||
-264
@@ -1,264 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminAgisoObservedShopItem } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { EditableDefaults, EditableShop } from '../composables/types'
|
||||
|
||||
type Props = {
|
||||
agisoFilePath: string
|
||||
defaults: EditableDefaults
|
||||
shops: EditableShop[]
|
||||
observedShops: AdminAgisoObservedShopItem[]
|
||||
saving: boolean
|
||||
isShopExpanded: (id: string) => boolean
|
||||
toggleShop: (id: string) => void
|
||||
expandAllShops: () => void
|
||||
collapseAllShops: () => void
|
||||
describeShop: (shop: EditableShop) => string
|
||||
removeShop: (id: string) => void
|
||||
importObservedShop: (item: AdminAgisoObservedShopItem) => void
|
||||
saveAgisoConfigs: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="platform-section">
|
||||
<!-- 配置信息 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="card-title">配置信息</span>
|
||||
</template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="配置文件">
|
||||
<code>{{ agisoFilePath || '-' }}</code>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="职责">
|
||||
管理 Agiso 默认消息模板、店铺 AccessToken 以及店铺级模板覆盖。
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 默认消息模板 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">默认消息模板</span>
|
||||
<p class="card-desc">
|
||||
Agiso 店铺未单独配置时,统一使用这里的领取消息与自动发货消息模板。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-width="auto" label-position="top">
|
||||
<el-form-item label="默认领取链接消息模板">
|
||||
<el-input
|
||||
v-model="defaults.messageTemplate"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="例如:您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl}"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认自动发货消息模板">
|
||||
<el-input
|
||||
v-model="defaults.autoDeliveryMessageTemplate"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="例如:您的订单 {platformOrderId} 已完成自动发货,请注意查收。"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 店铺列表 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">Agiso 店铺列表</span>
|
||||
<p class="card-desc">
|
||||
每家店铺可单独配置 AccessToken 和模板覆盖。建议先用"近期识别到的店铺"导入,再补
|
||||
AccessToken。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button :disabled="shops.length === 0" @click="expandAllShops">全部展开</el-button>
|
||||
<el-button :disabled="shops.length === 0" @click="collapseAllShops">全部折叠</el-button>
|
||||
<el-button :loading="saving" type="primary" @click="saveAgisoConfigs"
|
||||
>保存 Agiso 配置</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="shops.length === 0" description="当前还没有 Agiso 店铺配置,先新增一条。" />
|
||||
|
||||
<el-collapse v-else :model-value="shops.filter((s) => isShopExpanded(s.id)).map((s) => s.id)">
|
||||
<el-collapse-item v-for="shop in shops" :key="shop.id" :name="shop.id">
|
||||
<template #title>
|
||||
<div class="shop-title">
|
||||
<strong>{{ shop.shopName || shop.shopId || '未命名店铺' }}</strong>
|
||||
<span class="shop-subtitle">ID: {{ shop.shopId || '未填写' }}</span>
|
||||
<el-tag
|
||||
:type="shop.enabled ? 'success' : 'info'"
|
||||
size="small"
|
||||
class="shop-status-tag"
|
||||
>
|
||||
{{ shop.enabled ? '已启用' : '已停用' }}
|
||||
</el-tag>
|
||||
<span class="shop-brief">{{ describeShop(shop) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-width="auto" label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="店铺 ID">
|
||||
<el-input v-model="shop.shopId" placeholder="例如 693760716" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="店铺名称">
|
||||
<el-input v-model="shop.shopName" placeholder="例如 大锤商行" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="AccessToken">
|
||||
<el-input v-model="shop.accessToken" placeholder="AldsIdle..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="领取链接消息模板">
|
||||
<el-input
|
||||
v-model="shop.messageTemplate"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="留空则沿用默认领取链接模板"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动发货消息模板">
|
||||
<el-input
|
||||
v-model="shop.autoDeliveryMessageTemplate"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="留空则沿用默认自动发货模板"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="shop-footer">
|
||||
<el-switch v-model="shop.enabled" active-text="启用店铺级覆盖" />
|
||||
<el-button type="danger" size="small" @click="removeShop(shop.id)">删除</el-button>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
|
||||
<!-- 近期识别到的店铺 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<span class="card-title">近期识别到的 Agiso 店铺</span>
|
||||
<p class="card-desc">从 webhook 自动识别出的店铺,可一键导入到上方店铺列表。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="observedShops" stripe size="small">
|
||||
<el-table-column label="店铺" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div>{{ row.displayShopName || row.shopId }}</div>
|
||||
<div class="text-muted-xs">ID: {{ row.shopId }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="webhookEventCount" label="Webhook 次数" width="120" align="center" />
|
||||
<el-table-column label="最近出现时间" width="180">
|
||||
<template #default="{ row }">{{ formatAdminDateTime(row.latestSeenAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.configured ? 'success' : 'warning'" size="small">
|
||||
{{ row.configured ? '已配置' : '未配置' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="!row.configured"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="importObservedShop(row)"
|
||||
>
|
||||
导入到店铺
|
||||
</el-button>
|
||||
<span v-else class="text-muted">已存在</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="最近还没有识别到可用店铺。" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shop-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
.shop-title strong {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shop-subtitle {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.shop-status-tag {
|
||||
margin-left: auto;
|
||||
margin-right: var(--space-3);
|
||||
}
|
||||
|
||||
.shop-brief {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.shop-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,9 @@
|
||||
export type PlatformTab =
|
||||
| 'agiso'
|
||||
| 'notifications'
|
||||
| 'ninetyone'
|
||||
| 'kuaishouEticket'
|
||||
| 'cloudtentacles'
|
||||
|
||||
export type EditableDefaults = {
|
||||
messageTemplate: string
|
||||
autoDeliveryMessageTemplate: string
|
||||
}
|
||||
|
||||
export type EditableShop = {
|
||||
id: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
accessToken: string
|
||||
enabled: boolean
|
||||
messageTemplate: string
|
||||
autoDeliveryMessageTemplate: string
|
||||
}
|
||||
|
||||
export type EditableKuaishouEticketShop = {
|
||||
id: string
|
||||
shopId: string
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { saveAdminAgisoShopConfigs } from '@/services/admin'
|
||||
import type {
|
||||
AdminAgisoMessagingDefaults,
|
||||
AdminAgisoObservedShopItem,
|
||||
AdminAgisoShopConfigItem,
|
||||
} from '@/types/admin'
|
||||
|
||||
import type { EditableDefaults, EditableShop } from './types'
|
||||
|
||||
export function useAdminAgisoPlatform() {
|
||||
const saving = ref(false)
|
||||
const agisoFilePath = ref('')
|
||||
const defaults = ref<EditableDefaults>(createEmptyDefaults())
|
||||
const shops = ref<EditableShop[]>([])
|
||||
const observedShops = ref<AdminAgisoObservedShopItem[]>([])
|
||||
const expandedShopIds = ref<string[]>([])
|
||||
|
||||
const agisoStats = computed(() => ({
|
||||
configuredShopCount: shops.value.length,
|
||||
observedShopCount: observedShops.value.length,
|
||||
pendingObservedCount: observedShops.value.filter((item) => !item.configured).length,
|
||||
}))
|
||||
|
||||
function hydrateAgisoConfig(data: {
|
||||
filePath: string
|
||||
defaults: AdminAgisoMessagingDefaults
|
||||
shops: AdminAgisoShopConfigItem[]
|
||||
observedShops: AdminAgisoObservedShopItem[]
|
||||
}) {
|
||||
agisoFilePath.value = data.filePath
|
||||
defaults.value = mapEditableDefaults(data.defaults)
|
||||
shops.value = data.shops.map(mapEditableShop)
|
||||
observedShops.value = data.observedShops
|
||||
expandedShopIds.value = []
|
||||
}
|
||||
|
||||
function addShop() {
|
||||
const shop = createEmptyShop()
|
||||
shops.value.unshift(shop)
|
||||
expandShop(shop.id)
|
||||
}
|
||||
|
||||
function removeShop(id: string) {
|
||||
shops.value = shops.value.filter((item) => item.id !== id)
|
||||
collapseShop(id)
|
||||
}
|
||||
|
||||
function importObservedShop(item: AdminAgisoObservedShopItem) {
|
||||
const shop: EditableShop = {
|
||||
id: crypto.randomUUID(),
|
||||
shopId: item.shopId,
|
||||
shopName: item.detectedShopName || item.displayShopName || '',
|
||||
accessToken: '',
|
||||
enabled: true,
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
}
|
||||
shops.value.unshift(shop)
|
||||
expandShop(shop.id)
|
||||
}
|
||||
|
||||
function isShopExpanded(id: string) {
|
||||
return expandedShopIds.value.includes(id)
|
||||
}
|
||||
|
||||
function expandShop(id: string) {
|
||||
if (expandedShopIds.value.includes(id)) {
|
||||
return
|
||||
}
|
||||
expandedShopIds.value = [id, ...expandedShopIds.value]
|
||||
}
|
||||
|
||||
function collapseShop(id: string) {
|
||||
expandedShopIds.value = expandedShopIds.value.filter((item) => item !== id)
|
||||
}
|
||||
|
||||
function toggleShop(id: string) {
|
||||
if (isShopExpanded(id)) {
|
||||
collapseShop(id)
|
||||
return
|
||||
}
|
||||
expandShop(id)
|
||||
}
|
||||
|
||||
function expandAllShops() {
|
||||
expandedShopIds.value = shops.value.map((item) => item.id)
|
||||
}
|
||||
|
||||
function collapseAllShops() {
|
||||
expandedShopIds.value = []
|
||||
}
|
||||
|
||||
function describeShop(shop: EditableShop) {
|
||||
const overrides: string[] = []
|
||||
|
||||
if (shop.messageTemplate.trim()) {
|
||||
overrides.push('领取模板')
|
||||
}
|
||||
if (shop.autoDeliveryMessageTemplate.trim()) {
|
||||
overrides.push('发货模板')
|
||||
}
|
||||
|
||||
return overrides.length === 0 ? '使用默认模板' : `已覆盖 ${overrides.join('、')}`
|
||||
}
|
||||
|
||||
async function saveAgisoConfigs(reloadConfigs: () => Promise<void>) {
|
||||
const payloadDefaults = {
|
||||
messageTemplate: defaults.value.messageTemplate.trim(),
|
||||
autoDeliveryMessageTemplate: defaults.value.autoDeliveryMessageTemplate.trim(),
|
||||
}
|
||||
const payload = shops.value
|
||||
.map((item) => ({
|
||||
shopId: item.shopId.trim(),
|
||||
shopName: item.shopName.trim(),
|
||||
accessToken: item.accessToken.trim(),
|
||||
enabled: item.enabled,
|
||||
messageTemplate: item.messageTemplate.trim(),
|
||||
autoDeliveryMessageTemplate: item.autoDeliveryMessageTemplate.trim(),
|
||||
}))
|
||||
.filter((item) => item.shopId && item.accessToken)
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const response = await saveAdminAgisoShopConfigs({
|
||||
defaults: payloadDefaults,
|
||||
shops: payload,
|
||||
})
|
||||
agisoFilePath.value = response.data.filePath
|
||||
defaults.value = mapEditableDefaults(response.data.defaults)
|
||||
shops.value = response.data.shops.map(mapEditableShop)
|
||||
showSuccess('Agiso 配置已保存')
|
||||
await reloadConfigs()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '保存 Agiso 配置失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saving,
|
||||
agisoFilePath,
|
||||
defaults,
|
||||
shops,
|
||||
observedShops,
|
||||
expandedShopIds,
|
||||
agisoStats,
|
||||
hydrateAgisoConfig,
|
||||
addShop,
|
||||
removeShop,
|
||||
importObservedShop,
|
||||
isShopExpanded,
|
||||
expandShop,
|
||||
collapseShop,
|
||||
toggleShop,
|
||||
expandAllShops,
|
||||
collapseAllShops,
|
||||
describeShop,
|
||||
saveAgisoConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyDefaults(): EditableDefaults {
|
||||
return {
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyShop(): EditableShop {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
accessToken: '',
|
||||
enabled: true,
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapEditableDefaults(item?: AdminAgisoMessagingDefaults): EditableDefaults {
|
||||
return {
|
||||
messageTemplate: item?.messageTemplate ?? '',
|
||||
autoDeliveryMessageTemplate: item?.autoDeliveryMessageTemplate ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapEditableShop(item: AdminAgisoShopConfigItem): EditableShop {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
shopId: item.shopId,
|
||||
shopName: item.shopName,
|
||||
accessToken: item.AccessToken,
|
||||
enabled: item.enabled !== false,
|
||||
messageTemplate: item.messageTemplate,
|
||||
autoDeliveryMessageTemplate: item.autoDeliveryMessageTemplate,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user