清理快手前端无用入口

This commit is contained in:
yml
2026-05-25 20:59:57 +08:00
parent db22ff691b
commit dc099ae6f8
65 changed files with 86 additions and 7279 deletions
+2 -2
View File
@@ -3,8 +3,8 @@
<div class="card">
<p class="code">404</p>
<h1>页面不存在</h1>
<p>当前前端只保留腾讯浏览器兑换页这个地址没有对应页面</p>
<RouterLink to="/tx">前往兑换入口</RouterLink>
<p>当前快手轻量前端没有对应页面</p>
<RouterLink to="/admin/dashboard">前往后台</RouterLink>
</div>
</main>
</template>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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))
@@ -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,
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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"
@@ -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,
}
}
@@ -1,357 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
import { useClaimPage } from '@/composables/useClaimPage'
const route = useRoute()
const token = computed(() => String(route.params.token || '').trim())
const {
detailLoading,
roleConfirmLoading,
sessionLoading,
redeemLoading,
loginType,
loginTypeLabel,
loginTabs,
task,
order,
orderItem,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
session,
sessionNotice,
roleFacts,
resultFacts,
roleConfirmed,
roleReady,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
showScreenshot,
createSessionFlow,
reloadSessionPage,
closeSessionFlow,
switchLoginType,
confirmRoleNow,
redeemNow,
handleQrImageLoad,
} = useClaimPage(token.value)
const qrPreviewVisible = ref(false)
const screenshotPreviewVisible = ref(false)
const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
)
const helperText = computed(() => {
if (!task.value) {
return '正在加载领取任务'
}
if (task.value.requiresSupportReview) {
return `订单 ${order.value?.platformOrderId || '-'} · 登录后请联系人工客服继续确认和兑换`
}
return `订单 ${order.value?.platformOrderId || '-'} · 任务 ${task.value.taskNo}`
})
</script>
<template>
<main class="claim-page">
<section class="workspace">
<div class="page-toolbar">
<div>
<div class="brand-chip">订单领取</div>
<p class="helper-copy">{{ helperText }}</p>
</div>
<div class="toolbar-actions">
<div class="status-badge">{{ statusLabel }}</div>
<RouterLink class="back-link" to="/tx/browser">调试页</RouterLink>
</div>
</div>
<section class="order-card">
<div class="order-meta">
<article>
<span>订单号</span>
<strong>{{ order?.platformOrderId || '-' }}</strong>
</article>
<article>
<span>商品</span>
<strong>{{ orderItem?.skuName || '-' }}</strong>
</article>
<article>
<span>数量</span>
<strong>{{ orderItem?.quantity || 0 }}</strong>
</article>
<article>
<span>任务号</span>
<strong>{{ task?.taskNo || '-' }}</strong>
</article>
</div>
</section>
<div v-if="detailLoading" class="empty-block loading-block">
<strong>领取信息加载中</strong>
<p>正在校验链接并同步任务状态</p>
</div>
<template v-else>
<div 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-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"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="reloadSessionPage"
@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 && !task?.requiresSupportReview"
:confirm-readonly="true"
: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>
<TencentResultPanel
:result-facts="resultFacts"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-url="screenshotUrl"
:show-screenshot="showScreenshot"
@open-screenshot-preview="screenshotPreviewVisible = true"
/>
</template>
</section>
<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="`Claim ${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="Claim Screenshot Preview" />
</el-dialog>
</main>
</template>
<style scoped>
.claim-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(73, 136, 255, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(87, 194, 150, 0.12), transparent 28%),
linear-gradient(180deg, #f4f8fc 0%, #eef4fb 100%);
}
.workspace {
max-width: 1240px;
margin: 0 auto;
display: grid;
gap: 18px;
}
.page-toolbar,
.toolbar-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand-chip,
.status-badge,
.back-link {
display: inline-flex;
align-items: center;
min-height: 42px;
padding: 0 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #27405e;
box-shadow: 0 12px 32px rgba(30, 49, 78, 0.08);
}
.brand-chip {
font-weight: 800;
}
.status-badge {
font-weight: 700;
}
.back-link {
text-decoration: none;
}
.helper-copy {
margin: 8px 0 0;
color: #64748b;
}
.order-card {
padding: 18px 22px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 22px 60px rgba(30, 41, 59, 0.08);
}
.order-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.order-meta article {
padding: 14px 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.16);
}
.order-meta span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
.order-meta strong {
font-size: 16px;
color: #0f172a;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.95fr);
gap: 18px;
align-items: start;
}
.control-column {
display: grid;
gap: 18px;
}
.empty-block {
padding: 28px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
color: #475569;
}
.loading-block strong {
display: block;
margin-bottom: 6px;
color: #0f172a;
}
.preview-dialog :deep(.el-dialog__body) {
padding: 0;
}
.preview-image {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.qr-preview-body {
display: grid;
place-items: center;
padding: 20px;
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
}
.qr-preview-image {
max-width: 100%;
max-height: min(78vh, 920px);
}
@media (max-width: 980px) {
.claim-page {
padding: 16px;
}
.page-toolbar,
.toolbar-actions {
flex-direction: column;
align-items: stretch;
}
.order-meta,
.layout-grid {
grid-template-columns: 1fr;
}
}
</style>
+2 -79
View File
@@ -1,90 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { fetchClaimDetail } from '@/services/claim'
import ClaimTencentView from './ClaimTencentView.vue'
import KuaishouCloudClaimView from './kuaishou-cloud/KuaishouCloudClaimView.vue'
const route = useRoute()
const token = computed(() => String(route.params.token || '').trim())
const loading = ref(true)
const errorMessage = ref('')
const flowType = ref('tencent_claim')
async function bootstrap() {
loading.value = true
errorMessage.value = ''
try {
const response = await fetchClaimDetail(token.value)
flowType.value = String(
response.data.flowType || response.data.task.executorKey || 'tencent_claim',
).trim()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
} finally {
loading.value = false
}
}
const isKuaishouCloudFlow = computed(
() => flowType.value === 'kuaishou_cloud' || flowType.value === 'kuaishou_ct_assisted',
)
onMounted(() => {
void bootstrap()
})
</script>
<template>
<main v-if="loading" class="claim-shell loading-shell">
<section class="shell-card">
<strong>页面加载中</strong>
<p>正在识别领取流程类型</p>
</section>
</main>
<main v-else-if="errorMessage" class="claim-shell error-shell">
<section class="shell-card">
<strong>无法打开领取页</strong>
<p>{{ errorMessage }}</p>
</section>
</main>
<KuaishouCloudClaimView v-else-if="isKuaishouCloudFlow" :token="token" />
<ClaimTencentView v-else />
<KuaishouCloudClaimView :token="token" />
</template>
<style scoped>
.claim-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
}
.shell-card {
width: min(520px, 100%);
padding: 28px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
text-align: center;
}
.shell-card strong {
display: block;
margin-bottom: 10px;
color: #0f172a;
}
.shell-card p {
margin: 0;
color: #475569;
}
</style>
@@ -1,303 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
import { useTencentBrowserSessionPage } from '@/composables/useTencentBrowserSessionPage'
const {
activityInfo,
canRedeem,
createSessionFlow,
hasSession,
initButtonLabel,
loginTabs,
loginType,
loginTypeLabel,
maxAttempts,
qrImage,
redeemButtonLabel,
redeemBlockedReason,
redeemCode,
redeemLoading,
redeemNow,
reloadSessionPage,
resultFacts,
roleConfirmed,
roleFacts,
scanInstruction,
screenshotEmptyMessage,
screenshotEmptyTitle,
screenshotUrl,
session,
sessionLoading,
sessionNotice,
statusLabel,
switchLoginType,
teardownSession,
} = useTencentBrowserSessionPage()
const qrPreviewVisible = ref(false)
const screenshotPreviewVisible = ref(false)
const qrImageNaturalWidth = ref(0)
const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
)
watch(qrImage, () => {
qrImageNaturalWidth.value = 0
})
function handleQrImageLoad(event: Event) {
const target = event.target
if (!(target instanceof HTMLImageElement)) {
return
}
qrImageNaturalWidth.value = target.naturalWidth || 0
}
const qrDisplayWidth = computed(() => {
const naturalWidth = qrImageNaturalWidth.value
if (!naturalWidth) {
return 220
}
if (naturalWidth < 160) {
return Math.min(naturalWidth * 2, 220)
}
return Math.min(naturalWidth, 240)
})
const qrFigureStyle = computed(() => ({
width: `${qrDisplayWidth.value}px`,
maxWidth: '100%',
}))
const qrPreviewWidth = computed(
() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`,
)
const showScreenshot = computed(() => Boolean(screenshotUrl.value))
</script>
<template>
<main class="browser-page">
<section class="workspace">
<div class="page-toolbar">
<div class="brand-chip">腾讯浏览器兑换</div>
<div class="toolbar-actions">
<div class="status-badge">{{ statusLabel }}</div>
<RouterLink class="back-link" to="/">返回首页</RouterLink>
</div>
</div>
<div 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-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="teardownSession"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="reloadSessionPage"
@switch-login-type="switchLoginType"
/>
<aside class="control-column">
<TencentRedeemPanel
:can-redeem="canRedeem"
:login-type-label="loginTypeLabel"
:max-attempts="maxAttempts"
:redeem-blocked-reason="redeemBlockedReason"
:redeem-button-label="redeemButtonLabel"
:redeem-code="redeemCode"
:redeem-loading="redeemLoading"
:role-confirmed="roleConfirmed"
:role-facts="roleFacts"
:role-ready="Boolean(activityInfo?.role?.ready)"
:status-label="statusLabel"
@redeem="redeemNow"
@update:max-attempts="maxAttempts = $event"
@update:redeem-code="redeemCode = $event"
@update:role-confirmed="roleConfirmed = $event"
/>
</aside>
</div>
<TencentResultPanel
:result-facts="resultFacts"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-url="screenshotUrl"
:show-screenshot="showScreenshot"
@open-screenshot-preview="screenshotPreviewVisible = true"
/>
</section>
<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="`Tencent ${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="Tencent Screenshot Preview" />
</el-dialog>
</main>
</template>
<style scoped>
.browser-page {
min-height: 100vh;
padding: 22px 16px 34px;
font-family: 'Avenir Next', 'PingFang SC', 'Helvetica Neue', sans-serif;
background:
radial-gradient(circle at top left, rgba(116, 168, 255, 0.12), transparent 28%),
radial-gradient(circle at bottom right, rgba(248, 186, 103, 0.12), transparent 24%),
linear-gradient(180deg, #f4f7fb 0%, #edf2f8 100%);
}
.workspace {
width: min(1120px, 100%);
margin: 0 auto;
display: grid;
gap: 16px;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.brand-chip {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #2f425d;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.status-badge,
.back-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(86, 108, 138, 0.14);
background: rgba(255, 255, 255, 0.92);
color: #31445e;
text-decoration: none;
font-size: 13px;
}
.status-badge {
font-weight: 700;
color: #5f6f87;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(340px, 392px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.control-column {
display: grid;
gap: 0;
}
.preview-image {
display: block;
width: 100%;
height: auto;
}
:deep(.preview-dialog .el-dialog) {
border-radius: 28px;
overflow: hidden;
}
:deep(.preview-dialog .el-dialog__body) {
padding: 0;
background: #f5f7fb;
}
.qr-preview-body {
display: grid;
place-items: center;
padding: 24px;
}
.qr-preview-image {
width: auto;
max-width: 100%;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
@media (max-width: 1140px) {
.layout-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.browser-page {
padding: 16px;
}
.page-toolbar,
.toolbar-actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>