完整流程-调试

This commit is contained in:
yml
2026-05-03 18:44:44 +08:00
parent 063c3fd8a1
commit 90cb7aa154
25 changed files with 2337 additions and 27 deletions
@@ -65,6 +65,7 @@ type ValidationState = {
const PROFILE_OPTIONS = [
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
{ label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' },
{ label: '人工发货', value: 'manual_review' },
]
@@ -102,6 +103,7 @@ 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,
@@ -109,6 +111,7 @@ const ruleMetrics = computed(() => {
draftCount: Math.max(bindings.value.length - completedCount, 0),
manualCount,
assistedCount,
kuaishouCloudCount,
}
})
@@ -536,7 +539,7 @@ onMounted(loadConfigs)
<header class="panel-header">
<div class="panel-header-copy">
<h1>履约配置</h1>
<p>把外部商品统一映射到内部履约 SKU并指定最终走自动半自动还是人工处理</p>
<p>把外部商品统一映射到内部履约 SKU并指定最终走腾讯领取快手 Cloud 人工处理</p>
</div>
<div class="header-tags">
<span class="header-tag">外部条件命中 1 个即可</span>
@@ -603,6 +606,7 @@ onMounted(loadConfigs)
<span class="mini-stat-chip">待完善 {{ ruleMetrics.draftCount }}</span>
<span class="mini-stat-chip">人工 {{ ruleMetrics.manualCount }}</span>
<span class="mini-stat-chip">半自动 {{ ruleMetrics.assistedCount }}</span>
<span class="mini-stat-chip">快手 Cloud {{ ruleMetrics.kuaishouCloudCount }}</span>
</div>
<div class="section-action-group">
<el-button round @click="addBinding">新增规则</el-button>
@@ -0,0 +1,802 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
import {
fetchAdminKuaishouCloudFulfillmentConfig,
saveAdminKuaishouCloudFulfillmentConfig,
} from '@/services/admin'
import type {
AdminKuaishouCloudFulfillmentConfig,
AdminKuaishouCloudFulfillmentItem,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
type EditableItem = AdminKuaishouCloudFulfillmentItem & {
localId: string
}
type ValidationState = {
localId: string
message: string
} | null
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const validationState = ref<ValidationState>(null)
const filePath = ref('')
const enabled = ref(true)
const items = ref<EditableItem[]>([])
const collapsedIds = ref<string[]>([])
const metrics = computed(() => {
const readyCount = items.value.filter(isItemComplete).length
const enabledCount = items.value.filter((item) => item.enabled).length
return {
total: items.value.length,
readyCount,
enabledCount,
draftCount: Math.max(items.value.length - readyCount, 0),
}
})
function createEmptyItem(): EditableItem {
return {
localId: crypto.randomUUID(),
id: '',
enabled: true,
priority: 100,
provider: 'khhao',
platform: 'kuaishou',
shopId: '',
internalSkuCode: '',
internalSkuName: '',
externalSkuCode: '',
externalItemId: '',
externalSkuName: '',
resolvedSkuName: '',
cloudSourceKey: 'default',
cloudSkuId: 0,
cloudSkuName: '',
vnKey: '',
autoBuyEnabled: true,
minAssetReserve: 0,
autoReturnNumberAfterDispatch: false,
autoConsumeAfterDispatch: false,
kuaishouConsumeShopId: '',
notes: '',
}
}
function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem {
return {
localId: crypto.randomUUID(),
id: item.id || crypto.randomUUID(),
enabled: item.enabled !== false,
priority: item.priority || 100,
provider: item.provider || 'khhao',
platform: item.platform || 'kuaishou',
shopId: item.shopId || '',
internalSkuCode: item.internalSkuCode || '',
internalSkuName: item.internalSkuName || '',
externalSkuCode: item.externalSkuCode || '',
externalItemId: item.externalItemId || '',
externalSkuName: item.externalSkuName || '',
resolvedSkuName: item.resolvedSkuName || '',
cloudSourceKey: item.cloudSourceKey || 'default',
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
cloudSkuName: item.cloudSkuName || '',
vnKey: item.vnKey || '',
autoBuyEnabled: item.autoBuyEnabled !== false,
minAssetReserve: Number(item.minAssetReserve || 0) || 0,
autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true,
autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true,
kuaishouConsumeShopId: item.kuaishouConsumeShopId || '',
notes: item.notes || '',
}
}
function mapSaveItem(item: EditableItem): AdminKuaishouCloudFulfillmentItem {
return {
id: item.id.trim() || item.internalSkuCode.trim() || item.localId,
enabled: item.enabled,
priority: Number(item.priority || 100) || 100,
provider: item.provider.trim() || 'khhao',
platform: item.platform.trim() || 'kuaishou',
shopId: item.shopId.trim(),
internalSkuCode: item.internalSkuCode.trim(),
internalSkuName: item.internalSkuName.trim(),
externalSkuCode: item.externalSkuCode.trim(),
externalItemId: item.externalItemId.trim(),
externalSkuName: item.externalSkuName.trim(),
resolvedSkuName: item.resolvedSkuName.trim(),
cloudSourceKey: item.cloudSourceKey.trim() || 'default',
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
cloudSkuName: item.cloudSkuName.trim(),
vnKey: item.vnKey.trim(),
autoBuyEnabled: item.autoBuyEnabled,
minAssetReserve: Math.max(0, Number(item.minAssetReserve || 0) || 0),
autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch,
autoConsumeAfterDispatch: item.autoConsumeAfterDispatch,
kuaishouConsumeShopId: item.kuaishouConsumeShopId.trim(),
notes: item.notes.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() !== 'khhao'
|| item.platform.trim() !== 'kuaishou'
|| item.priority !== 100
|| item.autoBuyEnabled !== true
|| item.minAssetReserve !== 0
|| item.autoReturnNumberAfterDispatch
|| item.autoConsumeAfterDispatch
|| item.enabled !== true,
)
}
function isItemComplete(item: EditableItem) {
return Boolean(
item.internalSkuCode.trim()
&& Number(item.cloudSkuId || 0) > 0
&& item.vnKey.trim()
&& hasExternalMatch(item),
)
}
function resolveValidation(item: EditableItem, index: number): ValidationState {
if (!hasMeaningfulContent(item)) {
return null
}
const title = `${index + 1} 条规则`
if (!item.internalSkuCode.trim()) {
return { localId: item.localId, message: `${title} 缺少内部 SKU 编码` }
}
if (Number(item.cloudSkuId || 0) <= 0) {
return { localId: item.localId, message: `${title} 需要填写 cloud SKU ID` }
}
if (!item.vnKey.trim()) {
return { localId: item.localId, message: `${title} 需要填写虚拟号 VN Key` }
}
if (!hasExternalMatch(item)) {
return { localId: item.localId, message: `${title} 至少填写一种外部匹配条件` }
}
return null
}
function getCardTitle(item: EditableItem, index: number) {
return item.internalSkuName.trim() || item.externalSkuName.trim() || item.internalSkuCode.trim() || `规则 ${index + 1}`
}
function getCardSummary(item: EditableItem) {
const parts = [
item.provider.trim() || 'khhao',
item.platform.trim() || 'kuaishou',
item.shopId.trim() || '跨店铺',
item.cloudSkuId > 0 ? `cloud#${item.cloudSkuId}` : '待填 cloud SKU',
]
return parts.join(' / ')
}
function isCollapsed(localId: string) {
return collapsedIds.value.includes(localId)
}
function setCollapsed(localId: string, collapsed: boolean) {
const next = new Set(collapsedIds.value)
if (collapsed) {
next.add(localId)
} else {
next.delete(localId)
}
collapsedIds.value = Array.from(next)
}
function toggleCollapsed(localId: string) {
setCollapsed(localId, !isCollapsed(localId))
}
function rebuildCollapsedState() {
collapsedIds.value = items.value.filter(isItemComplete).map((item) => item.localId)
}
async function loadConfigs() {
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminKuaishouCloudFulfillmentConfig()
filePath.value = response.data.filePath
enabled.value = response.data.source.enabled !== false
items.value = (response.data.source.items || []).map(mapEditableItem)
rebuildCollapsedState()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取新履约配置失败'
} finally {
loading.value = false
}
}
function addItem() {
validationState.value = null
const next = createEmptyItem()
items.value.unshift(next)
setCollapsed(next.localId, false)
}
function removeItem(localId: string) {
if (validationState.value?.localId === localId) {
validationState.value = null
}
items.value = items.value.filter((item) => item.localId !== localId)
setCollapsed(localId, false)
}
async function saveConfigs() {
const invalid = items.value.reduce<ValidationState>((state, item, index) => state || resolveValidation(item, index), null)
if (invalid) {
validationState.value = invalid
setCollapsed(invalid.localId, false)
return
}
const payloadItems = items.value.filter(hasMeaningfulContent).map(mapSaveItem)
const payload: AdminKuaishouCloudFulfillmentConfig = {
enabled: enabled.value,
items: payloadItems,
}
saving.value = true
validationState.value = null
try {
const response = await saveAdminKuaishouCloudFulfillmentConfig(payload)
filePath.value = response.data.filePath
enabled.value = response.data.source.enabled !== false
items.value = (response.data.source.items || []).map(mapEditableItem)
rebuildCollapsedState()
showSuccess('新履约配置已保存')
} catch (error) {
const message = error instanceof Error ? error.message : '保存新履约配置失败'
validationState.value = { localId: '', message }
showError(message)
} finally {
saving.value = false
}
}
onMounted(loadConfigs)
</script>
<template>
<section class="admin-panel">
<header class="panel-header hero-card">
<div class="hero-copy">
<h1>新履约配置</h1>
<p>独立维护快手下单后经 cloudtentacles 完成绑定发货与退号的新链路不影响旧履约规则</p>
</div>
<div class="hero-chips">
<span>独立新流程</span>
<span>保存后同步到任务匹配</span>
<span>支持折叠维护</span>
</div>
<div class="hero-actions">
<el-switch v-model="enabled" active-text="启用整条配置" />
<el-button round @click="loadConfigs">刷新</el-button>
<el-button round type="primary" :loading="saving" @click="saveConfigs">保存配置</el-button>
</div>
</header>
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护新履约配置</div>
<template v-else>
<section class="overview-card">
<div class="overview-file">
<span>配置文件</span>
<code>{{ filePath || '-' }}</code>
</div>
<div class="overview-stats">
<article>
<span>规则总数</span>
<strong>{{ metrics.total }}</strong>
</article>
<article>
<span>已完成</span>
<strong>{{ metrics.readyCount }}</strong>
</article>
<article>
<span>草稿中</span>
<strong>{{ metrics.draftCount }}</strong>
</article>
<article>
<span>已启用</span>
<strong>{{ metrics.enabledCount }}</strong>
</article>
</div>
<div class="overview-notes">
<span>外部商品命中后会映射到 `kuaishou_ct_assisted`</span>
<span>客服只保留确认绑定完成并发货一个动作</span>
<span>退还号码作为最终收口动作</span>
</div>
</section>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<p v-if="validationState?.message" class="validation-copy">{{ validationState.message }}</p>
<div v-if="loading" class="empty-block">新履约配置加载中</div>
<template v-else>
<section class="toolbar-card">
<div>
<h3>规则列表</h3>
<p>每一条规则描述快手外部商品如何映射到内部 SKU + cloud 资源</p>
</div>
<div class="toolbar-actions">
<el-button round @click="addItem">新增规则</el-button>
<el-button round type="primary" :loading="saving" @click="saveConfigs">保存全部</el-button>
</div>
</section>
<div v-if="items.length === 0" class="empty-block">当前还没有新履约规则先新增一条</div>
<article
v-for="(item, index) in items"
:key="item.localId"
class="rule-card"
:data-invalid="validationState?.localId === item.localId"
>
<button class="rule-head" type="button" @click="toggleCollapsed(item.localId)">
<div>
<strong>{{ getCardTitle(item, index) }}</strong>
<p>{{ getCardSummary(item) }}</p>
</div>
<div class="rule-head-meta">
<span class="status-pill" :data-tone="isItemComplete(item) ? 'success' : 'warning'">
{{ isItemComplete(item) ? '可用' : '待完善' }}
</span>
<span class="collapse-indicator">{{ isCollapsed(item.localId) ? '展开' : '收起' }}</span>
</div>
</button>
<div v-if="!isCollapsed(item.localId)" class="rule-body">
<div class="grid cols-4">
<label class="field-block">
<span>内部 SKU</span>
<el-input v-model="item.internalSkuCode" maxlength="80" placeholder="必填,用于生成任务" />
</label>
<label class="field-block">
<span>内部商品名</span>
<el-input v-model="item.internalSkuName" maxlength="120" placeholder="展示用,可选但建议填" />
</label>
<label class="field-block">
<span>优先级</span>
<el-input-number v-model="item.priority" :min="1" :step="1" controls-position="right" />
</label>
<label class="field-block field-block--switch">
<span>规则开关</span>
<el-switch v-model="item.enabled" active-text="启用" inactive-text="停用" />
</label>
</div>
<div class="grid cols-4">
<label class="field-block">
<span>来源 provider</span>
<el-input v-model="item.provider" maxlength="32" placeholder="默认 khhao" />
</label>
<label class="field-block">
<span>来源 platform</span>
<el-input v-model="item.platform" maxlength="32" placeholder="默认 kuaishou" />
</label>
<label class="field-block">
<span>店铺 ID</span>
<el-input v-model="item.shopId" maxlength="80" placeholder="留空表示跨店铺共用" />
</label>
<label class="field-block">
<span>解析商品名</span>
<el-input v-model="item.resolvedSkuName" maxlength="120" placeholder="可作为商品名兜底" />
</label>
</div>
<div class="section-split">
<div>
<h4>外部匹配条件</h4>
<p>外部 SKU商品 ID商品名三选一即可命中建议尽量多填提高稳定性</p>
</div>
<div class="grid cols-3">
<label class="field-block">
<span>外部 SKU</span>
<el-input v-model="item.externalSkuCode" maxlength="120" placeholder="例如 khhao 的 skuCode" />
</label>
<label class="field-block">
<span>外部商品 ID</span>
<el-input v-model="item.externalItemId" maxlength="120" placeholder="平台商品 ID" />
</label>
<label class="field-block">
<span>外部商品名</span>
<el-input v-model="item.externalSkuName" maxlength="200" placeholder="用于名称匹配" />
</label>
</div>
</div>
<div class="section-split">
<div>
<h4>cloud 资源</h4>
<p>这里决定是否走背包自动购买申请虚拟号以及后续发货动作会使用哪一个 cloud SKU</p>
</div>
<div class="grid cols-4">
<label class="field-block">
<span>cloudSourceKey</span>
<el-input v-model="item.cloudSourceKey" maxlength="60" placeholder="默认 default" />
</label>
<label class="field-block">
<span>cloud SKU ID</span>
<el-input-number v-model="item.cloudSkuId" :min="0" :step="1" controls-position="right" />
</label>
<label class="field-block">
<span>cloud SKU 名称</span>
<el-input v-model="item.cloudSkuName" maxlength="120" placeholder="可选,方便辨认" />
</label>
<label class="field-block">
<span>虚拟号 VN Key</span>
<el-input v-model="item.vnKey" maxlength="40" placeholder="必填,例如 1" />
</label>
</div>
<div class="grid cols-4">
<label class="field-block field-block--switch">
<span>自动购买</span>
<el-switch v-model="item.autoBuyEnabled" active-text="开启" inactive-text="关闭" />
</label>
<label class="field-block">
<span>最低保留余额</span>
<el-input-number v-model="item.minAssetReserve" :min="0" :step="1" controls-position="right" />
</label>
<label class="field-block field-block--switch">
<span>发货后自动退号</span>
<el-switch v-model="item.autoReturnNumberAfterDispatch" active-text="开启" inactive-text="关闭" />
</label>
<label class="field-block field-block--switch">
<span>发货后自动核销</span>
<el-switch v-model="item.autoConsumeAfterDispatch" active-text="开启" inactive-text="关闭" />
</label>
</div>
</div>
<div class="grid cols-2">
<label class="field-block">
<span>快手核销店铺 ID</span>
<el-input v-model="item.kuaishouConsumeShopId" maxlength="80" placeholder="预留给后续自动核销" />
</label>
<label class="field-block">
<span>备注</span>
<el-input
v-model="item.notes"
maxlength="400"
placeholder="补充说明,例如绑定场景、客服注意事项"
type="textarea"
:rows="3"
/>
</label>
</div>
<div class="rule-footer">
<span>配置 ID{{ item.id || '保存后自动生成' }}</span>
<el-button round text type="danger" @click="removeItem(item.localId)">删除规则</el-button>
</div>
</div>
</article>
</template>
</template>
</section>
</template>
<style scoped>
.admin-panel {
display: grid;
gap: 16px;
}
.hero-card,
.overview-card,
.toolbar-card,
.rule-card,
.empty-block,
.error-copy,
.validation-copy {
border-radius: 24px;
border: 1px solid rgba(86, 108, 138, 0.12);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
}
.hero-card {
padding: 24px;
background:
radial-gradient(circle at top right, rgba(26, 127, 255, 0.12), transparent 24%),
radial-gradient(circle at bottom left, rgba(16, 185, 129, 0.12), transparent 28%),
rgba(255, 255, 255, 0.96);
}
.hero-copy h1,
.toolbar-card h3,
.section-split h4 {
margin: 0;
color: #17324d;
}
.hero-copy p,
.toolbar-card p,
.section-split p {
margin: 8px 0 0;
color: #64748b;
line-height: 1.6;
}
.hero-chips,
.hero-actions,
.overview-notes,
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hero-chips {
margin-top: 18px;
}
.hero-chips span,
.overview-notes span {
padding: 8px 12px;
border-radius: 999px;
background: rgba(17, 72, 153, 0.08);
color: #175cd3;
font-size: 12px;
font-weight: 700;
}
.hero-actions {
margin-top: 18px;
align-items: center;
justify-content: space-between;
}
.overview-card,
.toolbar-card,
.empty-block,
.error-copy,
.validation-copy {
padding: 18px 20px;
}
.overview-card {
display: grid;
gap: 16px;
}
.overview-file {
display: grid;
gap: 6px;
}
.overview-file span {
font-size: 12px;
color: #64748b;
}
.overview-file code {
color: #17324d;
word-break: break-all;
}
.overview-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.overview-stats article {
padding: 14px 16px;
border-radius: 18px;
background: #f8fbff;
border: 1px solid rgba(86, 108, 138, 0.08);
}
.overview-stats span {
display: block;
color: #64748b;
font-size: 12px;
}
.overview-stats strong {
display: block;
margin-top: 8px;
color: #17324d;
font-size: 28px;
}
.error-copy,
.validation-copy {
color: #b42318;
}
.toolbar-card {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.rule-card {
overflow: hidden;
}
.rule-card[data-invalid='true'] {
border-color: rgba(180, 35, 24, 0.25);
box-shadow: 0 18px 48px rgba(180, 35, 24, 0.08);
}
.rule-head {
width: 100%;
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 0;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(243, 247, 252, 0.98));
text-align: left;
cursor: pointer;
}
.rule-head strong {
display: block;
color: #17324d;
font-size: 16px;
}
.rule-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
.rule-head-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.status-pill {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-pill[data-tone='success'] {
color: #0f766e;
background: #ecfdf3;
}
.status-pill[data-tone='warning'] {
color: #b54708;
background: #fffaeb;
}
.collapse-indicator {
color: #175cd3;
font-size: 12px;
font-weight: 700;
}
.rule-body {
padding: 0 20px 20px;
display: grid;
gap: 18px;
}
.section-split {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 18px;
background: rgba(248, 250, 252, 0.9);
border: 1px solid rgba(86, 108, 138, 0.08);
}
.grid {
display: grid;
gap: 14px;
}
.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.field-block {
display: grid;
gap: 8px;
}
.field-block span {
color: #17324d;
font-size: 13px;
font-weight: 700;
}
.field-block--switch {
align-content: start;
}
.rule-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #64748b;
font-size: 12px;
}
@media (max-width: 1100px) {
.cols-4,
.cols-3,
.cols-2 {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.hero-actions,
.toolbar-card,
.rule-head,
.rule-footer {
display: grid;
}
.cols-4,
.cols-3,
.cols-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -40,6 +40,7 @@ const navItems = computed(() => {
baseItems.splice(1, 0, { to: '/admin/users', label: '用户' })
baseItems.push({ to: '/admin/platform-shops', label: '平台配置' })
baseItems.push({ to: '/admin/platform-fulfillment', label: '履约配置' })
baseItems.push({ to: '/admin/platform-kuaishou-cloud-fulfillment', label: '新履约配置' })
baseItems.push({ to: '/admin/audit-logs', label: '审计' })
}
@@ -133,6 +134,7 @@ async function submitLogout() {
margin-top: 28px;
display: grid;
gap: 10px;
align-content: start;
}
.admin-meta {
@@ -8,12 +8,15 @@ import {
closeAdminTask,
confirmAdminTaskAssistedRole,
completeAdminTaskManualDispatch,
dispatchAdminTaskKuaishouCloudFulfillment,
redeemAdminTaskAssisted,
fetchAdminTaskScreenshot,
fetchAdminTaskDetail,
markAdminTaskManualReview,
prepareAdminTaskKuaishouCloudFulfillment,
regenerateAdminTaskClaimLink,
releaseAdminTaskInventoryBinding,
returnNumberAdminTaskKuaishouCloudFulfillment,
retryAdminTask,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
@@ -33,9 +36,14 @@ const manualDispatchForm = reactive({
deliveredCredential: '',
resultMessage: '',
})
const kuaishouCloudForm = reactive({
ticketCode: '',
})
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
const canCloseTasks = computed(() => hasAdminRole('support'))
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
const kuaishouCloudFlow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
const isKuaishouCloudTask = computed(() => detail.value?.task.executorKey === 'kuaishou_ct_assisted')
const claimUrl = computed(() => {
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
const taskStatus = String(detail.value?.task.status || '').trim()
@@ -66,6 +74,7 @@ async function loadDetail() {
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
detail.value = response.data
syncManualDispatchForm(response.data)
syncKuaishouCloudForm(response.data)
await loadScreenshotPreview(response.data)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
@@ -133,6 +142,10 @@ function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
}
function syncKuaishouCloudForm(taskDetail: AdminTaskDetail) {
kuaishouCloudForm.ticketCode = taskDetail.kuaishouCloudFulfillment?.ticket.code || ''
}
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
if (!detail.value) {
return
@@ -155,6 +168,68 @@ async function submitManualDispatch(outcome: 'delivered' | 'failed') {
)
}
async function submitKuaishouCloudPrepare() {
if (!detail.value) {
return
}
await runAction(
() => prepareAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
'绑定资源已准备完成',
`确认开始为任务 ${detail.value.task.taskNo} 准备绑定资源吗?系统会自动检查背包、余额并申请虚拟号。`,
)
}
async function submitKuaishouCloudDispatch() {
if (!detail.value) {
return
}
await runAction(
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId, {
ticketCode: kuaishouCloudForm.ticketCode,
}),
'已完成绑定确认并发货',
`确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把“确认绑定完成”和“发货”合并为一步。`,
)
}
async function submitKuaishouCloudReturnNumber() {
if (!detail.value) {
return
}
await runAction(
() => returnNumberAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
'号码已退还',
`确认退还任务 ${detail.value.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`,
)
}
async function copyText(value: string, successMessage: string) {
const text = String(value || '').trim()
if (!text) {
showError('当前没有可复制的内容')
return
}
try {
await navigator.clipboard.writeText(text)
showSuccess(successMessage)
} catch {
showError('复制失败,请手动复制')
}
}
function openExternalLink(value: string) {
const text = String(value || '').trim()
if (!text) {
return
}
window.open(text, '_blank', 'noopener,noreferrer')
}
function formatTaskEventPayload(payload: Record<string, unknown>) {
const parts = [
payload.outcome ? `结果 ${payload.outcome}` : '',
@@ -166,11 +241,19 @@ function formatTaskEventPayload(payload: Record<string, unknown>) {
payload.inventoryItemId ? `库存项 #${payload.inventoryItemId}` : '',
payload.previousInventoryItemId ? `旧库存 #${payload.previousInventoryItemId}` : '',
payload.nextInventoryItemId ? `新库存 #${payload.nextInventoryItemId}` : '',
payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : '',
payload.vnId ? `虚拟号 #${payload.vnId}` : '',
payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : '',
payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : '',
payload.sendType ? `发送类型 ${payload.sendType}` : '',
payload.note ? `备注 ${payload.note}` : '',
payload.previousOutcome ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` : '',
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
payload.trigger ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ''))}` : '',
payload.reason ? `原因 ${payload.reason}` : '',
payload.purchaseTriggered === true ? '已触发购买' : '',
payload.usedKnapsack === true ? '使用背包库存' : '',
payload.responseStatus ? `HTTP ${payload.responseStatus}` : '',
payload.requestId ? `请求 ${payload.requestId}` : '',
payload.errorMessage ? `错误 ${payload.errorMessage}` : '',
@@ -349,6 +432,33 @@ onBeforeUnmount(clearScreenshotPreview)
>
客服开始兑换
</el-button>
<el-button
v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment"
:loading="actionLoading"
round
type="primary"
@click="submitKuaishouCloudPrepare"
>
准备绑定资源
</el-button>
<el-button
v-if="canManageTaskLifecycle && detail.operations.canDispatchKuaishouCloudFulfillment"
:loading="actionLoading"
round
type="success"
@click="submitKuaishouCloudDispatch"
>
确认绑定完成并发货
</el-button>
<el-button
v-if="canManageTaskLifecycle && detail.operations.canReturnKuaishouCloudFulfillment"
:loading="actionLoading"
round
type="warning"
@click="submitKuaishouCloudReturnNumber"
>
退还号码
</el-button>
<el-button
v-if="canManageTaskLifecycle"
:disabled="!detail.operations.canMarkManualReview"
@@ -423,6 +533,122 @@ onBeforeUnmount(clearScreenshotPreview)
</table>
</section>
<section v-if="isKuaishouCloudTask && kuaishouCloudFlow" class="table-card flow-card">
<div class="flow-card-head">
<div>
<h3>快手 Cloud 履约面板</h3>
<p class="section-copy">这里集中处理绑定链接客户卡券号发货动作与退号收口</p>
</div>
<div class="flow-head-actions">
<el-button
v-if="kuaishouCloudFlow.binding.bindUrl"
round
@click="copyText(kuaishouCloudFlow.binding.bindUrl, '绑定链接已复制')"
>
复制绑定链接
</el-button>
<el-button
v-if="kuaishouCloudFlow.binding.bindUrl"
round
type="primary"
plain
@click="openExternalLink(kuaishouCloudFlow.binding.bindUrl)"
>
打开绑定页
</el-button>
</div>
</div>
<div class="redeem-summary-grid">
<article class="redeem-summary-card">
<span>准备状态</span>
<div class="auto-delivery-tag-line">
<AdminStatusTag :status="kuaishouCloudFlow.binding.prepareStatus" />
</div>
</article>
<article class="redeem-summary-card">
<span>发货状态</span>
<div class="auto-delivery-tag-line">
<AdminStatusTag :status="kuaishouCloudFlow.dispatch.status" />
</div>
</article>
<article class="redeem-summary-card">
<span>退号状态</span>
<div class="auto-delivery-tag-line">
<AdminStatusTag :status="kuaishouCloudFlow.returnNumber.status" />
</div>
</article>
<article class="redeem-summary-card">
<span>当前任务状态</span>
<div class="auto-delivery-tag-line">
<AdminStatusTag :status="detail.task.status" />
</div>
</article>
</div>
<div class="flow-grid">
<article class="flow-block">
<h4>客户绑定</h4>
<p>客户下单后需要先完成外部绑定客服只需要把链接发给客户并在完成后点一次发货按钮</p>
<div class="flow-meta-list">
<div><span>绑定链接</span><strong>{{ kuaishouCloudFlow.binding.bindUrl || '-' }}</strong></div>
<div><span>准备时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.binding.bindPreparedAt) }}</strong></div>
<div><span>虚拟号 ID</span><strong>{{ kuaishouCloudFlow.binding.vnId || '-' }}</strong></div>
<div><span>虚拟号码</span><strong>{{ kuaishouCloudFlow.binding.vnPhone || '-' }}</strong></div>
</div>
</article>
<article class="flow-block">
<h4>cloud 资源</h4>
<div class="flow-meta-list">
<div><span>cloudSourceKey</span><strong>{{ kuaishouCloudFlow.binding.cloudSourceKey || '-' }}</strong></div>
<div><span>cloud SKU</span><strong>{{ kuaishouCloudFlow.binding.skuId ? `${kuaishouCloudFlow.binding.skuId} / ${kuaishouCloudFlow.binding.skuName || '-'}` : '-' }}</strong></div>
<div><span>VN Key</span><strong>{{ kuaishouCloudFlow.binding.vnKey || '-' }}</strong></div>
<div><span>自动购买</span><strong>{{ kuaishouCloudFlow.purchase.autoBuyEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>背包命中</span><strong>{{ kuaishouCloudFlow.purchase.usedKnapsack ? '是' : '否' }}</strong></div>
<div><span>触发购买</span><strong>{{ kuaishouCloudFlow.purchase.purchaseTriggered ? '是' : '否' }}</strong></div>
</div>
</article>
</div>
<div class="flow-form-card">
<label class="field-block">
<span>客户提供的卡券号</span>
<el-input
v-model="kuaishouCloudForm.ticketCode"
:disabled="!canManageTaskLifecycle"
maxlength="120"
placeholder="客户下单后提供的卡券号,可在发货时一并记录"
/>
</label>
<p class="manual-hint">确认绑定完成并发货已经合并为一个按钮不再拆成两步</p>
</div>
<div class="flow-grid">
<article class="flow-block">
<h4>发货结果</h4>
<div class="flow-meta-list">
<div><span>最近卡券号</span><strong>{{ kuaishouCloudFlow.ticket.code || '-' }}</strong></div>
<div><span>记录时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}</strong></div>
<div><span>发货时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.dispatch.dispatchAt) }}</strong></div>
<div><span>发送类型</span><strong>{{ kuaishouCloudFlow.dispatch.sendType || '-' }}</strong></div>
<div><span>结果说明</span><strong>{{ kuaishouCloudFlow.dispatch.note || detail.task.resultMessage || '-' }}</strong></div>
</div>
</article>
<article class="flow-block">
<h4>流程收口</h4>
<div class="flow-meta-list">
<div><span>退号时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.returnNumber.returnedAt) }}</strong></div>
<div><span>自动退号</span><strong>{{ kuaishouCloudFlow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>自动核销</span><strong>{{ kuaishouCloudFlow.consume.autoConsumeEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>核销店铺</span><strong>{{ kuaishouCloudFlow.consume.shopId || '-' }}</strong></div>
<div><span>备注</span><strong>{{ kuaishouCloudFlow.notes || '-' }}</strong></div>
</div>
</article>
</div>
</section>
<section
v-if="detail.order?.provider === 'agiso' && detail.order?.platform === 'xianyu'"
class="table-card"
@@ -740,6 +966,63 @@ onBeforeUnmount(clearScreenshotPreview)
.manual-summary { margin-top: 16px; padding: 14px; border-radius: 16px; background: rgba(248,250,252,.9); }
.manual-summary p { margin: 0 0 8px; color: #334155; }
.manual-summary p:last-child { margin-bottom: 0; }
.flow-card {
display: grid;
gap: 16px;
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 28%),
linear-gradient(180deg, rgba(255,255,255,.96), rgba(248,250,252,.96));
}
.flow-card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.flow-head-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.flow-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.flow-block,
.flow-form-card {
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(86,108,138,.1);
background: rgba(248,250,252,.9);
}
.flow-block h4 {
margin: 0;
color: #1d3555;
}
.flow-block p {
margin: 10px 0 0;
color: #64748b;
line-height: 1.6;
}
.flow-meta-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.flow-meta-list div {
display: grid;
gap: 4px;
}
.flow-meta-list span {
color: #64748b;
font-size: 12px;
}
.flow-meta-list strong {
color: #1d3555;
word-break: break-all;
}
.event-copy { color: #334155; }
.event-time { margin-top: 6px; color: #64748b; font-size: 12px; }
.screenshot-link { display: inline-block; margin-bottom: 12px; color: #175cd3; text-decoration: none; }
@@ -751,4 +1034,11 @@ onBeforeUnmount(clearScreenshotPreview)
border: 1px solid rgba(86,108,138,.12);
box-shadow: 0 20px 48px rgba(15,23,42,.08);
}
@media (max-width: 720px) {
.flow-card-head,
.flow-head-actions {
display: grid;
}
}
</style>