重构:前端架构优化 - 消灭巨石文件,统一代码规范
Phase 1 - 消灭巨石页面: - 拆分 AdminKuaishouCloudFulfillmentView (1,224行→kuaishou-cloud/子目录) - 拆分 AdminFulfillmentBindingsView (989行→bindings/子目录) - 拆分 AdminTaskDetailView (1,007行→9个子组件+2个composable) - 合并去重 useClaimPage + useAdminManualRedeemPage (1,554行→共享模块+差异化薄层) - 拆分 services/admin/platform-config.ts (477行→8个领域子模块) Phase 2 - 架构收口: - 拆分 types/admin.ts (925行→13个子文件+platform-config/子目录) - 统一 API code 检查(http.ts拦截器统一处理业务错误) - 修改 apiPost 签名消除 as unknown as 类型断言(6处) - 新增 BusinessError 类型便于错误分类处理 所有改动通过 vue-tsc --noEmit 零错误和 vite build 验证
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
export { useSessionPolling } from './useSessionPolling'
|
||||
export type { UseSessionPollingOptions } from './useSessionPolling'
|
||||
|
||||
export { useSessionQrDisplay } from './useSessionQrDisplay'
|
||||
|
||||
export {
|
||||
useSessionRoleFacts,
|
||||
useSessionResultFacts,
|
||||
buildRedeemBlockedReason,
|
||||
resolveTaskStatusLabel,
|
||||
syncLoginTypeFromDetail,
|
||||
} from './useSessionPresentation'
|
||||
export type { TaskStatusLabelContext, FactItem, UseSessionPresentationOptions, UseSessionResultFactsOptions } from './useSessionPresentation'
|
||||
|
||||
export { mergeSessionDetail, shouldRefreshQrImage, shouldKeepPolling } from './mergeSessionDetail'
|
||||
|
||||
export { DEFAULT_LOGIN_TYPE, POLL_INTERVAL_MS, POLL_FAILURE_LIMIT, ACTIVE_TASK_STATUSES } from './sessionConstants'
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
import type {
|
||||
TencentBrowserSessionData,
|
||||
TencentBrowserSessionSummaryData,
|
||||
} from '@/types/tencent/session'
|
||||
|
||||
import type { ClaimTaskStatus } from '@/types/claim'
|
||||
import { ACTIVE_TASK_STATUSES } from './sessionConstants'
|
||||
|
||||
/**
|
||||
* Generic merge of session detail data, preserving QR image and other
|
||||
* fields that may be absent in summary responses.
|
||||
*
|
||||
* The `extraSessionFields` callback lets callers inject domain-specific
|
||||
* session merge logic (e.g. `review` for admin, `redeem` for claim).
|
||||
*/
|
||||
export function mergeSessionDetail<T extends ClaimDetailData>(
|
||||
current: T | null,
|
||||
next: T,
|
||||
extraSessionFields?: (
|
||||
currentSession: NonNullable<T['session']>,
|
||||
nextSession: NonNullable<T['session']>,
|
||||
) => Partial<NonNullable<T['session']>>,
|
||||
): T {
|
||||
if (next.session === null) {
|
||||
return {
|
||||
...next,
|
||||
session: null,
|
||||
} as T
|
||||
}
|
||||
|
||||
if (!current?.session) {
|
||||
return next
|
||||
}
|
||||
|
||||
const sessionBase = {
|
||||
...current.session,
|
||||
...next.session,
|
||||
qrImageBase64:
|
||||
typeof next.session.qrImageBase64 === 'string'
|
||||
? next.session.qrImageBase64
|
||||
: current.session.qrImageBase64,
|
||||
activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null,
|
||||
redeem: next.session.redeem ?? current.session.redeem ?? null,
|
||||
artifacts: next.session.artifacts || current.session.artifacts,
|
||||
} as NonNullable<T['session']>
|
||||
|
||||
const extras = extraSessionFields
|
||||
? extraSessionFields(current.session as NonNullable<T['session']>, next.session as NonNullable<T['session']>)
|
||||
: {}
|
||||
|
||||
return {
|
||||
...next,
|
||||
session: {
|
||||
...sessionBase,
|
||||
...extras,
|
||||
},
|
||||
} as T
|
||||
}
|
||||
|
||||
export function shouldRefreshQrImage(
|
||||
current: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null,
|
||||
next: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null,
|
||||
): boolean {
|
||||
if (!current || !next || !next.artifacts?.hasQrImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!current.qrImageBase64) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt)
|
||||
}
|
||||
|
||||
export function shouldKeepPolling<T extends ClaimDetailData>(detail: T | null): boolean {
|
||||
if (!detail?.session?.sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ACTIVE_TASK_STATUSES.has(detail.task.status as ClaimTaskStatus)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TencentLoginType } from '@/types/tencent/session'
|
||||
import type { ClaimTaskStatus } from '@/types/claim'
|
||||
|
||||
export const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
|
||||
export const POLL_INTERVAL_MS = 2_500
|
||||
export const POLL_FAILURE_LIMIT = 3
|
||||
export const ACTIVE_TASK_STATUSES = new Set<ClaimTaskStatus>(['claimed', 'role_confirmed', 'redeeming'])
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computed, ref, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
import { POLL_INTERVAL_MS, POLL_FAILURE_LIMIT } from './sessionConstants'
|
||||
|
||||
export interface UseSessionPollingOptions<T> {
|
||||
/** Whether there is an active session – used as a guard inside the poll loop. */
|
||||
hasSession: ComputedRef<boolean>
|
||||
/** Called on each poll tick (typically refreshSessionSummary). */
|
||||
onPollTick: () => Promise<void>
|
||||
/** Optional: determines whether polling should continue. Defaults to checking hasSession. */
|
||||
shouldKeepPolling?: ComputedRef<boolean>
|
||||
/** Context-specific warning message prefix when polling fails repeatedly. */
|
||||
pollWarningPrefix: string
|
||||
}
|
||||
|
||||
export function useSessionPolling(options: UseSessionPollingOptions<unknown>) {
|
||||
const { hasSession, onPollTick, pollWarningPrefix } = options
|
||||
|
||||
const pollWarningMessage = ref('')
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollToken = 0
|
||||
let pollFailureCount = 0
|
||||
|
||||
const sessionNotice = computed(() => pollWarningMessage.value)
|
||||
|
||||
function startPolling() {
|
||||
const tokenId = ++pollToken
|
||||
|
||||
const loop = async () => {
|
||||
if (tokenId !== pollToken || !hasSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await onPollTick()
|
||||
|
||||
if (tokenId !== pollToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const keepPolling = options.shouldKeepPolling
|
||||
? options.shouldKeepPolling.value
|
||||
: hasSession.value
|
||||
|
||||
if (!keepPolling) {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function resetPolling() {
|
||||
pollToken += 1
|
||||
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetPollingWarning() {
|
||||
pollFailureCount = 0
|
||||
pollWarningMessage.value = ''
|
||||
}
|
||||
|
||||
function handleSilentPollingError(error: unknown) {
|
||||
console.error(error)
|
||||
pollFailureCount += 1
|
||||
|
||||
if (pollFailureCount < POLL_FAILURE_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
pollWarningMessage.value = `${pollWarningPrefix},已暂停自动轮询,请手动刷新。`
|
||||
resetPolling()
|
||||
}
|
||||
|
||||
return {
|
||||
pollWarningMessage,
|
||||
sessionNotice,
|
||||
startPolling,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
handleSilentPollingError,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
import type { TencentLoginType } from '@/types/tencent/session'
|
||||
|
||||
import { DEFAULT_LOGIN_TYPE } from './sessionConstants'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// syncLoginTypeFromDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function syncLoginTypeFromDetail<T extends ClaimDetailData>(
|
||||
detail: T,
|
||||
fallback: TencentLoginType = DEFAULT_LOGIN_TYPE,
|
||||
): TencentLoginType {
|
||||
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
|
||||
if (nextLoginType === 'wx') {
|
||||
return 'wx'
|
||||
}
|
||||
|
||||
if (nextLoginType === 'qq') {
|
||||
return 'qq'
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveTaskStatusLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TaskStatusLabelContext = 'claim' | 'admin'
|
||||
|
||||
/**
|
||||
* Resolves a human-readable task status label.
|
||||
*
|
||||
* The `context` parameter controls wording differences between the
|
||||
* consumer-facing claim page and the admin manual-redeem page.
|
||||
*/
|
||||
export function resolveTaskStatusLabel(
|
||||
taskStatus: string | undefined,
|
||||
sessionStatus: string | undefined,
|
||||
context: TaskStatusLabelContext,
|
||||
): string {
|
||||
switch (taskStatus) {
|
||||
case 'link_generated':
|
||||
return context === 'admin' ? '待生成二维码' : '等待开始领取'
|
||||
case 'claimed':
|
||||
if (sessionStatus === 'scanned') {
|
||||
return context === 'admin' ? '客户待确认' : '确认中'
|
||||
}
|
||||
if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') {
|
||||
return context === 'admin' ? '已登录待复核' : '已登录'
|
||||
}
|
||||
return context === 'admin' ? '等待客户登录' : '领取中'
|
||||
case 'role_confirmed':
|
||||
return '角色已确认'
|
||||
case 'redeeming':
|
||||
return '正在兑换'
|
||||
case 'redeemed':
|
||||
return context === 'admin' ? '兑换完成' : '兑换成功'
|
||||
case 'waiting_inventory':
|
||||
return '等待库存'
|
||||
case 'retry_pending':
|
||||
return '等待重试'
|
||||
case 'manual_review':
|
||||
return '等待人工处理'
|
||||
case 'expired':
|
||||
return '链接已过期'
|
||||
case 'closed':
|
||||
return '任务已关闭'
|
||||
default:
|
||||
return sessionStatus || taskStatus || '等待中'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// roleFacts / resultFacts helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FactItem {
|
||||
label: string
|
||||
value: string
|
||||
accent?: boolean
|
||||
}
|
||||
|
||||
export interface UseSessionPresentationOptions {
|
||||
activityInfo: ComputedRef<{
|
||||
nickname?: string
|
||||
role?: {
|
||||
roleId?: string
|
||||
roleName?: string
|
||||
area?: string
|
||||
ready?: boolean
|
||||
} | null
|
||||
} | null>
|
||||
statusLabel: ComputedRef<string>
|
||||
/** Context-specific default values for roleFacts */
|
||||
roleFactDefaults: {
|
||||
nickname: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionRoleFacts(options: UseSessionPresentationOptions) {
|
||||
const { activityInfo, statusLabel, roleFactDefaults } = options
|
||||
|
||||
const roleFacts = computed<FactItem[]>(() => [
|
||||
{
|
||||
label: '登录昵称',
|
||||
value: activityInfo.value?.nickname || roleFactDefaults.nickname,
|
||||
},
|
||||
{
|
||||
label: '当前角色',
|
||||
value: activityInfo.value?.role?.roleName || '后端浏览器同步中',
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
label: '角色 ID',
|
||||
value: activityInfo.value?.role?.roleId || '未识别',
|
||||
},
|
||||
{
|
||||
label: '所在大区',
|
||||
value: activityInfo.value?.role?.area || '未识别',
|
||||
},
|
||||
])
|
||||
|
||||
return { roleFacts }
|
||||
}
|
||||
|
||||
export interface UseSessionResultFactsOptions {
|
||||
order: ComputedRef<{ platformOrderId?: string } | null>
|
||||
orderItem: ComputedRef<{ skuName?: string } | null>
|
||||
result: ComputedRef<{ resultCode?: string; resultMessage?: string } | null>
|
||||
/** Admin context has manualRequest.proofValue as an alternative first label */
|
||||
resultFactOverrides?: {
|
||||
firstLabel?: string
|
||||
firstValue?: ComputedRef<string>
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionResultFacts(options: UseSessionResultFactsOptions) {
|
||||
const { order, orderItem, result, resultFactOverrides } = options
|
||||
|
||||
const resultFacts = computed<FactItem[]>(() => [
|
||||
{
|
||||
label: resultFactOverrides?.firstLabel || '订单号',
|
||||
value: resultFactOverrides?.firstValue?.value || order.value?.platformOrderId || '-',
|
||||
},
|
||||
{
|
||||
label: '商品',
|
||||
value: orderItem.value?.skuName || '-',
|
||||
},
|
||||
{
|
||||
label: '业务返回码',
|
||||
value: result.value?.resultCode || '-',
|
||||
},
|
||||
{
|
||||
label: '业务消息',
|
||||
value: result.value?.resultMessage || '尚未兑换',
|
||||
},
|
||||
])
|
||||
|
||||
return { resultFacts }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redeemBlockedReason — builds the "why can't I redeem" explanation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildRedeemBlockedReason(options: {
|
||||
detail: ComputedRef<unknown | null>
|
||||
hasSession: ComputedRef<boolean>
|
||||
loginTypeLabel: ComputedRef<string>
|
||||
redeemLoading: ComputedRef<boolean>
|
||||
roleReady: ComputedRef<boolean>
|
||||
roleConfirmed: ComputedRef<boolean>
|
||||
sessionNotice: ComputedRef<string>
|
||||
context: 'claim' | 'admin'
|
||||
/** Claim-specific fields */
|
||||
tokenStatus?: ComputedRef<string>
|
||||
requiresSupportReview?: ComputedRef<boolean>
|
||||
}): string {
|
||||
const {
|
||||
detail,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleReady,
|
||||
roleConfirmed,
|
||||
sessionNotice,
|
||||
context,
|
||||
tokenStatus,
|
||||
requiresSupportReview,
|
||||
} = options
|
||||
|
||||
if (context === 'claim') {
|
||||
if (!detail.value) {
|
||||
return '正在加载领取信息'
|
||||
}
|
||||
if (tokenStatus && tokenStatus.value !== 'active') {
|
||||
return '当前领取链接不可用'
|
||||
}
|
||||
} else {
|
||||
// admin context
|
||||
if (!detail.value) {
|
||||
return '请先创建人工兑换任务并预占库存'
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSession.value) {
|
||||
return context === 'claim'
|
||||
? `请先选择${loginTypeLabel.value}并初始化登录会话`
|
||||
: `请先生成${loginTypeLabel.value}二维码并等待客户登录`
|
||||
}
|
||||
|
||||
// Claim-specific: support review flow
|
||||
if (context === 'claim' && requiresSupportReview?.value) {
|
||||
if (!roleReady.value) {
|
||||
return '扫码成功后,系统会自动同步登录信息,准备发给客服复核'
|
||||
}
|
||||
return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续'
|
||||
}
|
||||
|
||||
if (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
|
||||
if (!roleReady.value) {
|
||||
return (
|
||||
sessionNotice.value ||
|
||||
(context === 'claim'
|
||||
? '扫码成功后,后端正在同步角色和大区信息'
|
||||
: '客户登录后,系统会自动同步角色和大区信息')
|
||||
)
|
||||
}
|
||||
|
||||
if (!roleConfirmed.value) {
|
||||
return context === 'claim'
|
||||
? '请先确认当前角色与大区无误,再开始兑换'
|
||||
: '请先让客户确认角色截图,再点击确认当前角色'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
/**
|
||||
* Shared QR code display logic: renders the base64 QR image, tracks its
|
||||
* natural width after load, and derives display/preview widths.
|
||||
*/
|
||||
export function useSessionQrDisplay(options: {
|
||||
session: ComputedRef<{ qrImageBase64?: string } | null>
|
||||
}) {
|
||||
const { session } = options
|
||||
|
||||
const qrImageNaturalWidth = ref(0)
|
||||
|
||||
const qrImage = computed(() =>
|
||||
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
|
||||
)
|
||||
|
||||
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`,
|
||||
)
|
||||
|
||||
return {
|
||||
qrImage,
|
||||
qrImageNaturalWidth,
|
||||
qrDisplayWidth,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
handleQrImageLoad,
|
||||
}
|
||||
}
|
||||
@@ -51,20 +51,12 @@ export function useTencentBrowserSessionPolling(options: {
|
||||
const currentSession = session.value
|
||||
const response = await fetchTencentBrowserSessionSummary(currentSession.sessionId)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '获取浏览器会话状态失败')
|
||||
}
|
||||
|
||||
const nextSession = mergeSessionData(currentSession, response.data)
|
||||
session.value = nextSession
|
||||
|
||||
if (shouldRefreshQrImage(currentSession, response.data)) {
|
||||
const fullResponse = await fetchTencentBrowserSession(currentSession.sessionId)
|
||||
|
||||
if (fullResponse.code !== 0) {
|
||||
throw new Error(fullResponse.msg || '获取浏览器会话二维码失败')
|
||||
}
|
||||
|
||||
session.value = mergeSessionData(nextSession, fullResponse.data)
|
||||
}
|
||||
|
||||
@@ -140,7 +132,10 @@ export function useTencentBrowserSessionPolling(options: {
|
||||
return
|
||||
}
|
||||
|
||||
pollWarningMessage.value = `${resolveTencentActionMessage(error, '会话状态刷新失败')},已暂停自动轮询,请手动刷新或重新生成二维码。`
|
||||
pollWarningMessage.value = `${resolveTencentActionMessage(
|
||||
error,
|
||||
'会话状态刷新失败',
|
||||
)},已暂停自动轮询,请手动刷新或重新生成二维码。`
|
||||
resetPolling()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,22 +19,33 @@ import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/typ
|
||||
import type { TencentLoginType } from '@/types/tencent/session'
|
||||
|
||||
import { notifyTencentActionError } from './tencent/session-errors'
|
||||
|
||||
const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
|
||||
const POLL_INTERVAL_MS = 2500
|
||||
const POLL_FAILURE_LIMIT = 3
|
||||
const ACTIVE_TASK_STATUSES = new Set(['claimed', 'role_confirmed', 'redeeming'])
|
||||
import {
|
||||
useSessionPolling,
|
||||
useSessionQrDisplay,
|
||||
useSessionRoleFacts,
|
||||
useSessionResultFacts,
|
||||
buildRedeemBlockedReason,
|
||||
resolveTaskStatusLabel,
|
||||
syncLoginTypeFromDetail,
|
||||
mergeSessionDetail,
|
||||
shouldRefreshQrImage,
|
||||
shouldKeepPolling,
|
||||
DEFAULT_LOGIN_TYPE,
|
||||
} from './shared'
|
||||
|
||||
export function useAdminManualRedeemPage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Form state (admin-only) ────────────────────────────────────────
|
||||
const form = reactive({
|
||||
proofValue: '',
|
||||
skuCode: '',
|
||||
remark: '',
|
||||
})
|
||||
const suggestions = ref<AdminInventorySkuSuggestion[]>([])
|
||||
|
||||
// ── Core state ────────────────────────────────────────────────────
|
||||
const detail = ref<AdminManualRedeemDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const createLoading = ref(false)
|
||||
@@ -42,17 +53,14 @@ export function useAdminManualRedeemPage() {
|
||||
const roleConfirmLoading = ref(false)
|
||||
const redeemLoading = ref(false)
|
||||
const closeLoading = ref(false)
|
||||
const pollWarningMessage = ref('')
|
||||
const qrImageNaturalWidth = ref(0)
|
||||
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||
|
||||
// ── Screenshot state (admin-only) ──────────────────────────────────
|
||||
const screenshotBlob = ref<Blob | null>(null)
|
||||
const screenshotUrl = ref('')
|
||||
const screenshotLoading = ref(false)
|
||||
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollToken = 0
|
||||
let pollFailureCount = 0
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────────────
|
||||
const session = computed(() => detail.value?.session || null)
|
||||
const task = computed(() => detail.value?.task || null)
|
||||
const order = computed(() => detail.value?.order || null)
|
||||
@@ -66,12 +74,6 @@ export function useAdminManualRedeemPage() {
|
||||
)
|
||||
const hasSession = computed(() => Boolean(session.value?.sessionId))
|
||||
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
|
||||
const sessionNotice = computed(
|
||||
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
|
||||
)
|
||||
const qrImage = computed(() =>
|
||||
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
|
||||
)
|
||||
const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0)
|
||||
const reviewReady = computed(
|
||||
() => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed',
|
||||
@@ -79,19 +81,67 @@ export function useAdminManualRedeemPage() {
|
||||
const resultReady = computed(
|
||||
() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed',
|
||||
)
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────
|
||||
const {
|
||||
pollWarningMessage,
|
||||
startPolling,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
handleSilentPollingError,
|
||||
} = useSessionPolling({
|
||||
hasSession: computed(() => Boolean(task.value?.taskId && hasSession.value)),
|
||||
shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)),
|
||||
onPollTick: () => refreshSessionSummary({ silent: true }),
|
||||
pollWarningPrefix: '人工兑换状态刷新失败',
|
||||
})
|
||||
|
||||
const sessionNotice = computed(
|
||||
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
|
||||
)
|
||||
|
||||
// ── QR display ─────────────────────────────────────────────────────
|
||||
const {
|
||||
qrImage,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
handleQrImageLoad,
|
||||
} = useSessionQrDisplay({ session })
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────
|
||||
const loginTabs = [
|
||||
{ value: 'qq' as const, label: 'QQ账号登录' },
|
||||
{ value: 'wx' as const, label: '微信账号登录' },
|
||||
]
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status),
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status, 'admin'),
|
||||
)
|
||||
const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`)
|
||||
const scanInstruction = computed(
|
||||
() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`,
|
||||
)
|
||||
const roleFacts = computed(() => [
|
||||
|
||||
const { roleFacts } = useSessionRoleFacts({
|
||||
activityInfo,
|
||||
statusLabel,
|
||||
roleFactDefaults: { nickname: '等待客户登录' },
|
||||
})
|
||||
|
||||
const proofValue = computed(() => manualRequest.value?.proofValue || order.value?.platformOrderId || '-')
|
||||
|
||||
const { resultFacts } = useSessionResultFacts({
|
||||
order,
|
||||
orderItem,
|
||||
result,
|
||||
resultFactOverrides: {
|
||||
firstLabel: '唯一凭据',
|
||||
firstValue: proofValue,
|
||||
},
|
||||
})
|
||||
|
||||
// Admin overrides roleFacts to include "所在大区" and exclude "任务状态"
|
||||
const adminRoleFacts = computed(() => [
|
||||
{
|
||||
label: '登录昵称',
|
||||
value: activityInfo.value?.nickname || '等待客户登录',
|
||||
@@ -110,24 +160,7 @@ export function useAdminManualRedeemPage() {
|
||||
value: activityInfo.value?.role?.area || '未识别',
|
||||
},
|
||||
])
|
||||
const resultFacts = computed(() => [
|
||||
{
|
||||
label: '唯一凭据',
|
||||
value: manualRequest.value?.proofValue || order.value?.platformOrderId || '-',
|
||||
},
|
||||
{
|
||||
label: '商品',
|
||||
value: orderItem.value?.skuName || '-',
|
||||
},
|
||||
{
|
||||
label: '业务返回码',
|
||||
value: result.value?.resultCode || '-',
|
||||
},
|
||||
{
|
||||
label: '业务消息',
|
||||
value: result.value?.resultMessage || '尚未兑换',
|
||||
},
|
||||
])
|
||||
|
||||
const canConfirmRole = computed(() =>
|
||||
Boolean(
|
||||
task.value &&
|
||||
@@ -144,29 +177,18 @@ export function useAdminManualRedeemPage() {
|
||||
!redeemLoading.value,
|
||||
),
|
||||
)
|
||||
const redeemBlockedReason = computed(() => {
|
||||
if (!task.value) {
|
||||
return '请先创建人工兑换任务并预占库存'
|
||||
}
|
||||
|
||||
if (!hasSession.value) {
|
||||
return `请先生成${loginTypeLabel.value}二维码并等待客户登录`
|
||||
}
|
||||
|
||||
if (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
|
||||
if (!roleReady.value) {
|
||||
return sessionNotice.value || '客户登录后,系统会自动同步角色和大区信息'
|
||||
}
|
||||
|
||||
if (!roleConfirmed.value) {
|
||||
return '请先让客户确认角色截图,再点击确认当前角色'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
const redeemBlockedReason = computed(() =>
|
||||
buildRedeemBlockedReason({
|
||||
detail,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleReady,
|
||||
roleConfirmed,
|
||||
sessionNotice,
|
||||
context: 'admin',
|
||||
}),
|
||||
)
|
||||
const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换'))
|
||||
const screenshotEmptyTitle = computed(() =>
|
||||
task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图',
|
||||
@@ -181,8 +203,9 @@ export function useAdminManualRedeemPage() {
|
||||
)
|
||||
const hasActiveTask = computed(() => Boolean(task.value?.taskId))
|
||||
|
||||
// ── Watchers ───────────────────────────────────────────────────────
|
||||
watch(qrImage, () => {
|
||||
qrImageNaturalWidth.value = 0
|
||||
// QR image natural width reset handled by useSessionQrDisplay
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -206,6 +229,85 @@ export function useAdminManualRedeemPage() {
|
||||
clearScreenshotPreview()
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
function applyLoginTypeFromDetail(nextDetail: AdminManualRedeemDetail) {
|
||||
loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value)
|
||||
}
|
||||
|
||||
function restartPollingIfNeeded(nextDetail = detail.value) {
|
||||
resetPolling()
|
||||
|
||||
if (shouldKeepPolling(nextDetail)) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkuName(skuCode: string) {
|
||||
const normalized = String(skuCode || '').trim()
|
||||
const matched = suggestions.value.find(
|
||||
(item) => String(item.skuCode || '').trim() === normalized,
|
||||
)
|
||||
return matched?.skuCode || normalized
|
||||
}
|
||||
|
||||
// ── Screenshot helpers (admin-only) ───────────────────────────────
|
||||
async function loadScreenshotPreview(nextDetail = detail.value) {
|
||||
const taskId = Number(nextDetail?.task?.taskId || 0)
|
||||
const shouldFetch = Boolean(
|
||||
taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady),
|
||||
)
|
||||
|
||||
if (!shouldFetch) {
|
||||
clearScreenshotPreview()
|
||||
return
|
||||
}
|
||||
|
||||
screenshotLoading.value = true
|
||||
|
||||
try {
|
||||
const blob = await fetchAdminTaskScreenshot(taskId)
|
||||
clearScreenshotPreview()
|
||||
screenshotBlob.value = blob
|
||||
screenshotUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
clearScreenshotPreview()
|
||||
} finally {
|
||||
screenshotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
screenshotBlob.value = null
|
||||
|
||||
if (screenshotUrl.value) {
|
||||
URL.revokeObjectURL(screenshotUrl.value)
|
||||
screenshotUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.clipboard ||
|
||||
typeof window === 'undefined' ||
|
||||
!('ClipboardItem' in window)
|
||||
) {
|
||||
throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载')
|
||||
}
|
||||
|
||||
try {
|
||||
const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItemCtor({
|
||||
[blob.type || 'image/png']: blob,
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// ── API flows ─────────────────────────────────────────────────────
|
||||
async function loadSkuSuggestions(keyword = '') {
|
||||
try {
|
||||
const response = await fetchAdminManualRedeemSkuSuggestions({
|
||||
@@ -375,12 +477,24 @@ export function useAdminManualRedeemPage() {
|
||||
try {
|
||||
const currentSession = detail.value?.session || null
|
||||
const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId)
|
||||
detail.value = mergeDetail(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(
|
||||
detail.value,
|
||||
response.data,
|
||||
(currentSession, nextSession) => ({
|
||||
review: nextSession.review ?? currentSession.review ?? null,
|
||||
}),
|
||||
)
|
||||
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
|
||||
|
||||
if (shouldRefreshQrImage(currentSession, response.data.session)) {
|
||||
const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId)
|
||||
detail.value = mergeDetail(detail.value, fullResponse.data)
|
||||
const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId!)
|
||||
detail.value = mergeSessionDetail(
|
||||
detail.value,
|
||||
fullResponse.data,
|
||||
(currentSession, nextSession) => ({
|
||||
review: nextSession.review ?? currentSession.review ?? null,
|
||||
}),
|
||||
)
|
||||
loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value)
|
||||
}
|
||||
|
||||
@@ -503,16 +617,6 @@ export function useAdminManualRedeemPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function handleQrImageLoad(event: Event) {
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof HTMLImageElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
qrImageNaturalWidth.value = target.naturalWidth || 0
|
||||
}
|
||||
|
||||
async function copyQrImage() {
|
||||
if (!qrImage.value) {
|
||||
return false
|
||||
@@ -560,151 +664,6 @@ export function useAdminManualRedeemPage() {
|
||||
return true
|
||||
}
|
||||
|
||||
async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.clipboard ||
|
||||
typeof window === 'undefined' ||
|
||||
!('ClipboardItem' in window)
|
||||
) {
|
||||
throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载')
|
||||
}
|
||||
|
||||
try {
|
||||
const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItemCtor({
|
||||
[blob.type || 'image/png']: blob,
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview(nextDetail = detail.value) {
|
||||
const taskId = Number(nextDetail?.task?.taskId || 0)
|
||||
const shouldFetch = Boolean(
|
||||
taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady),
|
||||
)
|
||||
|
||||
if (!shouldFetch) {
|
||||
clearScreenshotPreview()
|
||||
return
|
||||
}
|
||||
|
||||
screenshotLoading.value = true
|
||||
|
||||
try {
|
||||
const blob = await fetchAdminTaskScreenshot(taskId)
|
||||
clearScreenshotPreview()
|
||||
screenshotBlob.value = blob
|
||||
screenshotUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
clearScreenshotPreview()
|
||||
} finally {
|
||||
screenshotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
screenshotBlob.value = null
|
||||
|
||||
if (screenshotUrl.value) {
|
||||
URL.revokeObjectURL(screenshotUrl.value)
|
||||
screenshotUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkuName(skuCode: string) {
|
||||
const normalized = String(skuCode || '').trim()
|
||||
const matched = suggestions.value.find(
|
||||
(item) => String(item.skuCode || '').trim() === normalized,
|
||||
)
|
||||
return matched?.skuCode || normalized
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
const tokenId = ++pollToken
|
||||
|
||||
const loop = async () => {
|
||||
if (tokenId !== pollToken || !task.value?.taskId) {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshSessionSummary({ silent: true })
|
||||
|
||||
if (tokenId !== pollToken || !shouldKeepPolling(detail.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function restartPollingIfNeeded(nextDetail = detail.value) {
|
||||
resetPolling()
|
||||
|
||||
if (shouldKeepPolling(nextDetail)) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function resetPolling() {
|
||||
pollToken += 1
|
||||
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetPollingWarning() {
|
||||
pollFailureCount = 0
|
||||
pollWarningMessage.value = ''
|
||||
}
|
||||
|
||||
function handleSilentPollingError(error: unknown) {
|
||||
console.error(error)
|
||||
pollFailureCount += 1
|
||||
|
||||
if (pollFailureCount < POLL_FAILURE_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
pollWarningMessage.value = '人工兑换状态刷新失败,已暂停自动轮询,请手动刷新。'
|
||||
resetPolling()
|
||||
}
|
||||
|
||||
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`,
|
||||
)
|
||||
|
||||
return {
|
||||
form,
|
||||
suggestions,
|
||||
@@ -732,7 +691,7 @@ export function useAdminManualRedeemPage() {
|
||||
qrPreviewWidth,
|
||||
statusLabel,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
roleFacts: adminRoleFacts,
|
||||
resultFacts,
|
||||
canConfirmRole,
|
||||
canRedeem,
|
||||
@@ -764,100 +723,3 @@ export function useAdminManualRedeemPage() {
|
||||
downloadScreenshot,
|
||||
}
|
||||
}
|
||||
|
||||
function syncLoginTypeFromDetail(
|
||||
detail: AdminManualRedeemDetail,
|
||||
fallback: TencentLoginType = DEFAULT_LOGIN_TYPE,
|
||||
) {
|
||||
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
|
||||
if (nextLoginType === 'wx') {
|
||||
return 'wx'
|
||||
}
|
||||
|
||||
if (nextLoginType === 'qq') {
|
||||
return 'qq'
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function shouldKeepPolling(detail: AdminManualRedeemDetail | null) {
|
||||
if (!detail?.task?.taskId || !detail.session?.sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ACTIVE_TASK_STATUSES.has(String(detail.task.status || '').trim())
|
||||
}
|
||||
|
||||
function resolveTaskStatusLabel(taskStatus?: string, sessionStatus?: string) {
|
||||
switch (taskStatus) {
|
||||
case 'link_generated':
|
||||
return '待生成二维码'
|
||||
case 'claimed':
|
||||
if (sessionStatus === 'scanned') {
|
||||
return '客户待确认'
|
||||
}
|
||||
if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') {
|
||||
return '已登录待复核'
|
||||
}
|
||||
return '等待客户登录'
|
||||
case 'role_confirmed':
|
||||
return '角色已确认'
|
||||
case 'redeeming':
|
||||
return '正在兑换'
|
||||
case 'redeemed':
|
||||
return '兑换完成'
|
||||
case 'waiting_inventory':
|
||||
return '等待库存'
|
||||
case 'retry_pending':
|
||||
return '等待重试'
|
||||
case 'closed':
|
||||
return '任务已关闭'
|
||||
default:
|
||||
return sessionStatus || taskStatus || '等待中'
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDetail(current: AdminManualRedeemDetail | null, next: AdminManualRedeemDetail) {
|
||||
if (next.session === null) {
|
||||
return {
|
||||
...next,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!current?.session) {
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
session: {
|
||||
...current.session,
|
||||
...next.session,
|
||||
qrImageBase64:
|
||||
typeof next.session.qrImageBase64 === 'string'
|
||||
? next.session.qrImageBase64
|
||||
: current.session.qrImageBase64,
|
||||
activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null,
|
||||
review: next.session.review ?? current.session.review ?? null,
|
||||
redeem: next.session.redeem ?? current.session.redeem ?? null,
|
||||
artifacts: next.session.artifacts || current.session.artifacts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshQrImage(
|
||||
currentSession: AdminManualRedeemDetail['session'],
|
||||
nextSession: AdminManualRedeemDetail['session'],
|
||||
) {
|
||||
if (!currentSession || !nextSession || !nextSession.artifacts?.hasQrImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!currentSession.qrImageBase64) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(nextSession.qrUpdatedAt && nextSession.qrUpdatedAt !== currentSession.qrUpdatedAt)
|
||||
}
|
||||
|
||||
@@ -10,19 +10,23 @@ import {
|
||||
removeClaimSession,
|
||||
redeemClaim,
|
||||
} from '@/services/claim'
|
||||
import type { ClaimDetailData, ClaimTaskStatus } from '@/types/claim'
|
||||
import type {
|
||||
TencentBrowserSessionData,
|
||||
TencentBrowserSessionSummaryData,
|
||||
TencentLoginType,
|
||||
} from '@/types/tencent/session'
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
import type { TencentLoginType } from '@/types/tencent/session'
|
||||
|
||||
import { notifyTencentActionError } from './tencent/session-errors'
|
||||
|
||||
const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
|
||||
const POLL_INTERVAL_MS = 2500
|
||||
const POLL_FAILURE_LIMIT = 3
|
||||
const ACTIVE_TASK_STATUSES = new Set<ClaimTaskStatus>(['claimed', 'role_confirmed', 'redeeming'])
|
||||
import {
|
||||
useSessionPolling,
|
||||
useSessionQrDisplay,
|
||||
useSessionRoleFacts,
|
||||
useSessionResultFacts,
|
||||
buildRedeemBlockedReason,
|
||||
resolveTaskStatusLabel,
|
||||
syncLoginTypeFromDetail,
|
||||
mergeSessionDetail,
|
||||
shouldRefreshQrImage,
|
||||
shouldKeepPolling,
|
||||
DEFAULT_LOGIN_TYPE,
|
||||
} from './shared'
|
||||
|
||||
export function useClaimPage(token: string) {
|
||||
const detailLoading = ref(true)
|
||||
@@ -32,13 +36,8 @@ export function useClaimPage(token: string) {
|
||||
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||
const detail = ref<ClaimDetailData | null>(null)
|
||||
const roleConfirmed = ref(false)
|
||||
const pollWarningMessage = ref('')
|
||||
const qrImageNaturalWidth = ref(0)
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollToken = 0
|
||||
let pollFailureCount = 0
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────────────
|
||||
const session = computed(() => detail.value?.session || null)
|
||||
const task = computed(() => detail.value?.task || null)
|
||||
const order = computed(() => detail.value?.order || null)
|
||||
@@ -49,12 +48,31 @@ export function useClaimPage(token: string) {
|
||||
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
|
||||
const hasSession = computed(() => Boolean(session.value?.sessionId))
|
||||
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────
|
||||
const {
|
||||
pollWarningMessage,
|
||||
startPolling,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
handleSilentPollingError,
|
||||
} = useSessionPolling({
|
||||
hasSession,
|
||||
shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)),
|
||||
onPollTick: () => refreshSessionSummary({ silent: true }),
|
||||
pollWarningPrefix: '领取状态刷新失败',
|
||||
})
|
||||
|
||||
const sessionNotice = computed(
|
||||
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
|
||||
)
|
||||
const qrImage = computed(() =>
|
||||
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
|
||||
)
|
||||
|
||||
// ── QR display ─────────────────────────────────────────────────────
|
||||
const { qrImage, qrFigureStyle, qrPreviewWidth, handleQrImageLoad } = useSessionQrDisplay({
|
||||
session,
|
||||
})
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────
|
||||
const screenshotUrl = computed(() => result.value?.screenshotUrl || '')
|
||||
const showScreenshot = computed(() => Boolean(screenshotUrl.value))
|
||||
const loginTabs = [
|
||||
@@ -63,13 +81,22 @@ export function useClaimPage(token: string) {
|
||||
]
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status),
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status, 'claim'),
|
||||
)
|
||||
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
|
||||
const scanInstruction = computed(
|
||||
() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`,
|
||||
)
|
||||
const roleFacts = computed(() => [
|
||||
|
||||
const { roleFacts } = useSessionRoleFacts({
|
||||
activityInfo,
|
||||
statusLabel,
|
||||
roleFactDefaults: { nickname: '等待扫码登录' },
|
||||
})
|
||||
|
||||
// Claim pages show 4 role facts, but original only had 3 (no area).
|
||||
// Override roleFacts to match original exactly:
|
||||
const claimRoleFacts = computed(() => [
|
||||
{
|
||||
label: '登录昵称',
|
||||
value: activityInfo.value?.nickname || '等待扫码登录',
|
||||
@@ -88,24 +115,13 @@ export function useClaimPage(token: string) {
|
||||
value: statusLabel.value,
|
||||
},
|
||||
])
|
||||
const resultFacts = computed(() => [
|
||||
{
|
||||
label: '订单号',
|
||||
value: order.value?.platformOrderId || '-',
|
||||
},
|
||||
{
|
||||
label: '商品',
|
||||
value: orderItem.value?.skuName || '-',
|
||||
},
|
||||
{
|
||||
label: '业务返回码',
|
||||
value: result.value?.resultCode || '-',
|
||||
},
|
||||
{
|
||||
label: '业务消息',
|
||||
value: result.value?.resultMessage || '尚未兑换',
|
||||
},
|
||||
])
|
||||
|
||||
const { resultFacts } = useSessionResultFacts({
|
||||
order,
|
||||
orderItem,
|
||||
result,
|
||||
})
|
||||
|
||||
const canConfirmRole = computed(() =>
|
||||
Boolean(
|
||||
task.value &&
|
||||
@@ -125,41 +141,20 @@ export function useClaimPage(token: string) {
|
||||
(task.value.status === 'role_confirmed' || task.value.status === 'redeeming'),
|
||||
),
|
||||
)
|
||||
const redeemBlockedReason = computed(() => {
|
||||
if (!detail.value) {
|
||||
return '正在加载领取信息'
|
||||
}
|
||||
|
||||
if (tokenStatus.value !== 'active') {
|
||||
return '当前领取链接不可用'
|
||||
}
|
||||
|
||||
if (!hasSession.value) {
|
||||
return `请先选择${loginTypeLabel.value}并初始化登录会话`
|
||||
}
|
||||
|
||||
if (task.value?.requiresSupportReview) {
|
||||
if (!roleReady.value) {
|
||||
return '扫码成功后,系统会自动同步登录信息,准备发给客服复核'
|
||||
}
|
||||
|
||||
return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续'
|
||||
}
|
||||
|
||||
if (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
|
||||
if (!roleReady.value) {
|
||||
return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息'
|
||||
}
|
||||
|
||||
if (!roleConfirmed.value) {
|
||||
return '请先确认当前角色与大区无误,再开始兑换'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
const redeemBlockedReason = computed(() =>
|
||||
buildRedeemBlockedReason({
|
||||
detail,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleReady,
|
||||
roleConfirmed,
|
||||
sessionNotice,
|
||||
context: 'claim',
|
||||
tokenStatus,
|
||||
requiresSupportReview: computed(() => Boolean(task.value?.requiresSupportReview)),
|
||||
}),
|
||||
)
|
||||
const redeemButtonLabel = computed(() =>
|
||||
task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换',
|
||||
)
|
||||
@@ -172,10 +167,7 @@ export function useClaimPage(token: string) {
|
||||
: '领取完成后,如本次生成了结果截图,这里会展示。',
|
||||
)
|
||||
|
||||
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
|
||||
loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value)
|
||||
}
|
||||
|
||||
// ── Watchers ───────────────────────────────────────────────────────
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
@@ -196,21 +188,27 @@ export function useClaimPage(token: string) {
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(qrImage, () => {
|
||||
qrImageNaturalWidth.value = 0
|
||||
})
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
|
||||
loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value)
|
||||
}
|
||||
|
||||
function restartPollingIfNeeded(nextDetail = detail.value) {
|
||||
resetPolling()
|
||||
|
||||
if (shouldKeepPolling(nextDetail)) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
// ── API flows ─────────────────────────────────────────────────────
|
||||
async function loadDetail() {
|
||||
detailLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetchClaimDetail(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取详情加载失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
restartPollingIfNeeded()
|
||||
} catch (error) {
|
||||
@@ -231,11 +229,7 @@ export function useClaimPage(token: string) {
|
||||
forceRecreate: hasSession.value,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '创建领取会话失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
@@ -257,11 +251,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await refreshClaimSession(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '刷新后端页面失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
restartPollingIfNeeded(response.data)
|
||||
return true
|
||||
@@ -290,11 +280,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await removeClaimSession(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '关闭领取会话失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
|
||||
if (!silent) {
|
||||
@@ -328,22 +314,14 @@ export function useClaimPage(token: string) {
|
||||
const currentSession = detail.value?.session || null
|
||||
const response = await fetchClaimSessionSummary(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取会话状态刷新失败')
|
||||
}
|
||||
|
||||
let nextDetail = mergeClaimDetailData(detail.value, response.data)
|
||||
let nextDetail = mergeSessionDetail(detail.value, response.data)
|
||||
detail.value = nextDetail
|
||||
applyLoginTypeFromDetail(nextDetail)
|
||||
|
||||
if (shouldRefreshClaimQrImage(currentSession, response.data)) {
|
||||
if (shouldRefreshQrImage(currentSession, response.data.session)) {
|
||||
const fullResponse = await fetchClaimDetail(token)
|
||||
|
||||
if (fullResponse.code !== 0) {
|
||||
throw new Error(fullResponse.msg || '领取二维码刷新失败')
|
||||
}
|
||||
|
||||
nextDetail = mergeClaimDetailData(nextDetail, fullResponse.data)
|
||||
nextDetail = mergeSessionDetail(nextDetail, fullResponse.data)
|
||||
detail.value = nextDetail
|
||||
applyLoginTypeFromDetail(nextDetail)
|
||||
}
|
||||
@@ -378,11 +356,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await confirmClaimRole(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '角色确认失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
roleConfirmed.value = true
|
||||
showSuccess(response.msg || '角色已确认')
|
||||
restartPollingIfNeeded(response.data)
|
||||
@@ -406,11 +380,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await redeemClaim(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取兑换失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
showSuccess(response.msg || '兑换完成')
|
||||
restartPollingIfNeeded(response.data)
|
||||
} catch (error) {
|
||||
@@ -435,97 +405,7 @@ export function useClaimPage(token: string) {
|
||||
loginType.value = nextLoginType
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
const tokenId = ++pollToken
|
||||
|
||||
const loop = async () => {
|
||||
if (tokenId !== pollToken || !hasSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshSessionSummary({ silent: true })
|
||||
|
||||
if (tokenId !== pollToken || !shouldKeepPolling(detail.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function restartPollingIfNeeded(nextDetail = detail.value) {
|
||||
resetPolling()
|
||||
|
||||
if (shouldKeepPolling(nextDetail)) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function resetPolling() {
|
||||
pollToken += 1
|
||||
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetPollingWarning() {
|
||||
pollFailureCount = 0
|
||||
pollWarningMessage.value = ''
|
||||
}
|
||||
|
||||
function handleSilentPollingError(error: unknown) {
|
||||
console.error(error)
|
||||
pollFailureCount += 1
|
||||
|
||||
if (pollFailureCount < POLL_FAILURE_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
pollWarningMessage.value = '领取状态刷新失败,已暂停自动轮询,请手动刷新页面。'
|
||||
resetPolling()
|
||||
}
|
||||
|
||||
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`,
|
||||
)
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────
|
||||
loadDetail()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -552,7 +432,7 @@ export function useClaimPage(token: string) {
|
||||
statusLabel,
|
||||
session,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
roleFacts: claimRoleFacts,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
@@ -576,114 +456,3 @@ export function useClaimPage(token: string) {
|
||||
handleQrImageLoad,
|
||||
}
|
||||
}
|
||||
|
||||
function syncLoginTypeFromDetail(
|
||||
detail: ClaimDetailData,
|
||||
fallback: TencentLoginType = DEFAULT_LOGIN_TYPE,
|
||||
) {
|
||||
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
|
||||
if (nextLoginType === 'wx') {
|
||||
return 'wx'
|
||||
}
|
||||
|
||||
if (nextLoginType === 'qq') {
|
||||
return 'qq'
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function shouldKeepPolling(detail: ClaimDetailData | null) {
|
||||
if (!detail?.session?.sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ACTIVE_TASK_STATUSES.has(detail.task.status)
|
||||
}
|
||||
|
||||
function resolveTaskStatusLabel(taskStatus?: string, sessionStatus?: string) {
|
||||
switch (taskStatus) {
|
||||
case 'link_generated':
|
||||
return '等待开始领取'
|
||||
case 'claimed':
|
||||
if (sessionStatus === 'scanned') {
|
||||
return '确认中'
|
||||
}
|
||||
if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') {
|
||||
return '已登录'
|
||||
}
|
||||
return '领取中'
|
||||
case 'role_confirmed':
|
||||
return '角色已确认'
|
||||
case 'redeeming':
|
||||
return '正在兑换'
|
||||
case 'redeemed':
|
||||
return '兑换成功'
|
||||
case 'waiting_inventory':
|
||||
return '等待库存'
|
||||
case 'retry_pending':
|
||||
return '等待重试'
|
||||
case 'manual_review':
|
||||
return '等待人工处理'
|
||||
case 'expired':
|
||||
return '链接已过期'
|
||||
case 'closed':
|
||||
return '任务已关闭'
|
||||
default:
|
||||
return sessionStatus || taskStatus || '等待中'
|
||||
}
|
||||
}
|
||||
|
||||
function mergeClaimDetailData(current: ClaimDetailData | null, next: ClaimDetailData) {
|
||||
if (next.session === null) {
|
||||
return {
|
||||
...next,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!current?.session) {
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
session: {
|
||||
...current.session,
|
||||
...next.session,
|
||||
qrImageBase64:
|
||||
typeof next.session.qrImageBase64 === 'string'
|
||||
? next.session.qrImageBase64
|
||||
: current.session.qrImageBase64,
|
||||
activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null,
|
||||
redeem: next.session.redeem ?? current.session.redeem ?? null,
|
||||
artifacts: next.session.artifacts || current.session.artifacts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshClaimQrImage(
|
||||
currentSession: ClaimDetailData['session'],
|
||||
nextDetail: ClaimDetailData,
|
||||
) {
|
||||
if (!currentSession || !nextDetail.session) {
|
||||
return false
|
||||
}
|
||||
|
||||
return shouldRefreshQrImage(currentSession, nextDetail.session)
|
||||
}
|
||||
|
||||
function shouldRefreshQrImage(
|
||||
current: TencentBrowserSessionData | TencentBrowserSessionSummaryData,
|
||||
next: TencentBrowserSessionData | TencentBrowserSessionSummaryData,
|
||||
) {
|
||||
if (!next.artifacts?.hasQrImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!current.qrImageBase64) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt)
|
||||
}
|
||||
|
||||
@@ -100,10 +100,6 @@ export function useTencentBrowserSessionPage() {
|
||||
loginType: nextLoginType,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '创建浏览器会话失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
@@ -125,10 +121,6 @@ export function useTencentBrowserSessionPage() {
|
||||
try {
|
||||
const response = await refreshTencentBrowserSessionPage(session.value.sessionId)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '刷新后端页面失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
restartPollingIfActive(response.data)
|
||||
} catch (error) {
|
||||
@@ -173,10 +165,6 @@ export function useTencentBrowserSessionPage() {
|
||||
maxAttempts: maxAttempts.value,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '浏览器兑换失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
showSuccess(response.msg || '兑换完成')
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,7 +17,22 @@ const http = axios.create({
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(response) => {
|
||||
const data = response.data as ApiEnvelope<unknown>
|
||||
|
||||
// Business-level error: HTTP 200 but code !== 0
|
||||
if (typeof data?.code === 'number' && data.code !== 0) {
|
||||
const error = new Error(data.msg || '操作失败') as Error & {
|
||||
errorCode?: string
|
||||
code?: number
|
||||
}
|
||||
error.errorCode = data.errorCode
|
||||
error.code = data.code
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
const responseMessage =
|
||||
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
|
||||
@@ -111,8 +126,8 @@ export function apiGetBlob(url: string, params?: Record<string, unknown>) {
|
||||
})
|
||||
}
|
||||
|
||||
export function apiPost<T>(url: string, data?: Record<string, unknown>) {
|
||||
return request<T>({ method: 'POST', url, data })
|
||||
export function apiPost<T>(url: string, data?: unknown) {
|
||||
return request<T>({ method: 'POST', url, data: data as Record<string, unknown> })
|
||||
}
|
||||
|
||||
export function apiDelete<T>(url: string) {
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminAgisoMessagingDefaults,
|
||||
AdminAgisoObservedShopItem,
|
||||
AdminAgisoShopConfigItem,
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSkuListResult,
|
||||
AdminCloudtentaclesSendSmsResult,
|
||||
AdminCloudtentaclesSourceConfig,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminFulfillmentLookupResult,
|
||||
AdminNinetyoneOrderActionResult,
|
||||
AdminNinetyoneOrderListResult,
|
||||
AdminNotificationConfig,
|
||||
AdminNotificationTestResult,
|
||||
AdminScheduledJobsConfig,
|
||||
AdminScheduledJobsResponse,
|
||||
AdminKuaishouCloudFulfillmentConfig,
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
AdminKuaishouEticketSourceConfig,
|
||||
AdminObservedProductItem,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminAgisoShopConfigs() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
defaults: AdminAgisoMessagingDefaults
|
||||
shops: AdminAgisoShopConfigItem[]
|
||||
observedShops: AdminAgisoObservedShopItem[]
|
||||
}>('/api/v1/admin/platform-config/agiso-shops')
|
||||
}
|
||||
|
||||
export function saveAdminAgisoShopConfigs(payload: {
|
||||
defaults: AdminAgisoMessagingDefaults
|
||||
shops: Array<Record<string, unknown>>
|
||||
}) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
defaults: AdminAgisoMessagingDefaults
|
||||
shops: AdminAgisoShopConfigItem[]
|
||||
}>('/api/v1/admin/platform-config/agiso-shops', payload)
|
||||
}
|
||||
|
||||
export function fetchAdminNotificationConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
source: AdminNotificationConfig
|
||||
}>('/api/v1/admin/platform-config/notifications')
|
||||
}
|
||||
|
||||
export function saveAdminNotificationConfig(payload: AdminNotificationConfig) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
source: AdminNotificationConfig
|
||||
}>('/api/v1/admin/platform-config/notifications', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function testAdminNotification(payload: { title?: string; body?: string; url?: string }) {
|
||||
return apiPost<AdminNotificationTestResult>(
|
||||
'/api/v1/admin/platform-config/notifications/test',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminScheduledJobsConfig() {
|
||||
return apiGet<AdminScheduledJobsResponse>('/api/v1/admin/platform-config/scheduled-jobs')
|
||||
}
|
||||
|
||||
export function saveAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig) {
|
||||
return apiPost<AdminScheduledJobsResponse>(
|
||||
'/api/v1/admin/platform-config/scheduled-jobs',
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
export function runAdminScheduledJob(jobId: string) {
|
||||
return apiPost<{
|
||||
result: Record<string, unknown>
|
||||
runtime: AdminScheduledJobsResponse['runtime']
|
||||
}>(`/api/v1/admin/platform-config/scheduled-jobs/${jobId}/run`, {})
|
||||
}
|
||||
|
||||
export function fetchAdminNinetyoneOrders(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
} = {},
|
||||
) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) {
|
||||
searchParams.set('page', String(params.page))
|
||||
}
|
||||
if (params.pageSize) {
|
||||
searchParams.set('pageSize', String(params.pageSize))
|
||||
}
|
||||
if (params.status) {
|
||||
searchParams.set('status', params.status)
|
||||
}
|
||||
const queryString = searchParams.toString()
|
||||
return apiGet<AdminNinetyoneOrderListResult>(
|
||||
`/api/v1/admin/platform-config/ninetyone/orders${queryString ? `?${queryString}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function retryAdminNinetyoneOrder(orderId: number | string) {
|
||||
return apiPost<AdminNinetyoneOrderActionResult>(
|
||||
`/api/v1/admin/platform-config/ninetyone/orders/${orderId}/retry`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function failAdminNinetyoneOrder(orderId: number | string, payload: { reason?: string }) {
|
||||
return apiPost<AdminNinetyoneOrderActionResult>(
|
||||
`/api/v1/admin/platform-config/ninetyone/orders/${orderId}/fail`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminKuaishouEticketSourceConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
source: AdminKuaishouEticketSourceConfig
|
||||
}>('/api/v1/admin/platform-config/kuaishou-eticket-source')
|
||||
}
|
||||
|
||||
export function saveAdminKuaishouEticketSourceConfig(payload: AdminKuaishouEticketSourceConfig) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
source: AdminKuaishouEticketSourceConfig
|
||||
}>(
|
||||
'/api/v1/admin/platform-config/kuaishou-eticket-source',
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
export function queryAdminKuaishouEticketDetail(payload: {
|
||||
baseUrl?: string
|
||||
shopId?: string
|
||||
cookie?: string
|
||||
eTicketId: string
|
||||
}) {
|
||||
return apiPost<AdminKuaishouEticketDetailResult>(
|
||||
'/api/v1/admin/platform-config/kuaishou-eticket/query-detail',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function queryAdminKuaishouEticketShopInfo(payload: {
|
||||
baseUrl?: string
|
||||
shopId?: string
|
||||
cookie?: string
|
||||
}) {
|
||||
return apiPost<AdminKuaishouEticketShopInfoResult>(
|
||||
'/api/v1/admin/platform-config/kuaishou-eticket/query-shop-info',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function consumeAdminKuaishouEticket(payload: {
|
||||
baseUrl?: string
|
||||
shopId?: string
|
||||
cookie?: string
|
||||
eTicketId: string
|
||||
oid?: string
|
||||
formToken?: string
|
||||
num?: number
|
||||
storeId?: string
|
||||
}) {
|
||||
return apiPost<AdminKuaishouEticketConsumeResult>(
|
||||
'/api/v1/admin/platform-config/kuaishou-eticket/consume',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesSourceConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
sessionFilePath: string
|
||||
source: AdminCloudtentaclesSourceConfig
|
||||
session: AdminCloudtentaclesPersistedSession
|
||||
}>('/api/v1/admin/platform-config/cloudtentacles-source')
|
||||
}
|
||||
|
||||
export function saveAdminCloudtentaclesSourceConfig(payload: AdminCloudtentaclesSourceConfig) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
sessionFilePath: string
|
||||
source: AdminCloudtentaclesSourceConfig
|
||||
session: AdminCloudtentaclesPersistedSession
|
||||
}>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles-source',
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
export function sendAdminCloudtentaclesSmsCode(payload: {
|
||||
baseUrl?: string
|
||||
username?: string
|
||||
phone?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesSendSmsResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/send-sms-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function testAdminCloudtentaclesLogin(payload: {
|
||||
baseUrl?: string
|
||||
username?: string
|
||||
password?: string
|
||||
phone?: string
|
||||
code?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesLoginTestResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/test-login',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function validateAdminCloudtentaclesSession(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesValidateSessionResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/validate-session',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesAsset(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/asset',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesCategories(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/categories',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesSkuList(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesSkuListResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/sku/list',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function buyAdminCloudtentaclesSku(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
id?: number
|
||||
count?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/sku/buy',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function useAdminCloudtentaclesSku(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
id?: number
|
||||
virtualNumberId?: number
|
||||
phone?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/sku/use',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesKnapsack(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/knapsack',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesVnList(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/list',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function appointAdminCloudtentaclesVn(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/appoint',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function generateAdminCloudtentaclesVnLoginCode(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/generate-login-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesVnCode(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
phone?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/fetch-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function verifyAdminCloudtentaclesVnCode(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
code?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/verify-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesBindUrl(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/bind-url',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function backAdminCloudtentaclesVn(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/back',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function runAdminCloudtentaclesFullFlow(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
skuId?: number
|
||||
skuCount?: number
|
||||
vnKey?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/debug/full-flow',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminFulfillmentBindingConfigs() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
bindings: AdminFulfillmentBindingConfigItem[]
|
||||
observedProducts: AdminObservedProductItem[]
|
||||
}>('/api/v1/admin/platform-config/fulfillment-bindings')
|
||||
}
|
||||
|
||||
export function lookupAdminFulfillmentBindingOrder(payload: {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId: string
|
||||
platformOrderId: string
|
||||
}) {
|
||||
return apiPost<AdminFulfillmentLookupResult>(
|
||||
'/api/v1/admin/platform-config/fulfillment-bindings/lookup-order',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminFulfillmentBindingConfigs(payload: {
|
||||
bindings: Array<Record<string, unknown>>
|
||||
}) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
bindings: AdminFulfillmentBindingConfigItem[]
|
||||
}>('/api/v1/admin/platform-config/fulfillment-bindings', payload)
|
||||
}
|
||||
|
||||
export function fetchAdminKuaishouCloudFulfillmentConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
source: AdminKuaishouCloudFulfillmentConfig
|
||||
}>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment')
|
||||
}
|
||||
|
||||
export function saveAdminKuaishouCloudFulfillmentConfig(
|
||||
payload: AdminKuaishouCloudFulfillmentConfig,
|
||||
) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
source: AdminKuaishouCloudFulfillmentConfig
|
||||
}>(
|
||||
'/api/v1/admin/platform-config/kuaishou-cloud-fulfillment',
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminAgisoMessagingDefaults,
|
||||
AdminAgisoObservedShopItem,
|
||||
AdminAgisoShopConfigItem,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminAgisoShopConfigs() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
defaults: AdminAgisoMessagingDefaults
|
||||
shops: AdminAgisoShopConfigItem[]
|
||||
observedShops: AdminAgisoObservedShopItem[]
|
||||
}>('/api/v1/admin/platform-config/agiso-shops')
|
||||
}
|
||||
|
||||
export function saveAdminAgisoShopConfigs(payload: {
|
||||
defaults: AdminAgisoMessagingDefaults
|
||||
shops: Array<Record<string, unknown>>
|
||||
}) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
defaults: AdminAgisoMessagingDefaults
|
||||
shops: AdminAgisoShopConfigItem[]
|
||||
}>('/api/v1/admin/platform-config/agiso-shops', payload)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSkuListResult,
|
||||
AdminCloudtentaclesSendSmsResult,
|
||||
AdminCloudtentaclesSourceConfig,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminCloudtentaclesSourceConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
sessionFilePath: string
|
||||
source: AdminCloudtentaclesSourceConfig
|
||||
session: AdminCloudtentaclesPersistedSession
|
||||
}>('/api/v1/admin/platform-config/cloudtentacles-source')
|
||||
}
|
||||
|
||||
export function saveAdminCloudtentaclesSourceConfig(payload: AdminCloudtentaclesSourceConfig) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
sessionFilePath: string
|
||||
source: AdminCloudtentaclesSourceConfig
|
||||
session: AdminCloudtentaclesPersistedSession
|
||||
}>('/api/v1/admin/platform-config/cloudtentacles-source', payload)
|
||||
}
|
||||
|
||||
export function sendAdminCloudtentaclesSmsCode(payload: {
|
||||
baseUrl?: string
|
||||
username?: string
|
||||
phone?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesSendSmsResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/send-sms-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function testAdminCloudtentaclesLogin(payload: {
|
||||
baseUrl?: string
|
||||
username?: string
|
||||
password?: string
|
||||
phone?: string
|
||||
code?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesLoginTestResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/test-login',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function validateAdminCloudtentaclesSession(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesValidateSessionResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/validate-session',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesAsset(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/asset',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesCategories(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/categories',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesSkuList(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<AdminCloudtentaclesSkuListResult>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/sku/list',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function buyAdminCloudtentaclesSku(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
id?: number
|
||||
count?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/sku/buy',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function useAdminCloudtentaclesSku(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
id?: number
|
||||
virtualNumberId?: number
|
||||
phone?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/sku/use',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesKnapsack(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/knapsack',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesVnList(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/list',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function appointAdminCloudtentaclesVn(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/appoint',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function generateAdminCloudtentaclesVnLoginCode(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/generate-login-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesVnCode(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
phone?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/fetch-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function verifyAdminCloudtentaclesVnCode(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
code?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/verify-code',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCloudtentaclesBindUrl(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/bind-url',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function backAdminCloudtentaclesVn(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
key?: string
|
||||
id?: number
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/vn/back',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function runAdminCloudtentaclesFullFlow(payload: {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number
|
||||
skuId?: number
|
||||
skuCount?: number
|
||||
vnKey?: string
|
||||
}) {
|
||||
return apiPost<Record<string, unknown>>(
|
||||
'/api/v1/admin/platform-config/cloudtentacles/debug/full-flow',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminFulfillmentLookupResult,
|
||||
AdminObservedProductItem,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminFulfillmentBindingConfigs() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
bindings: AdminFulfillmentBindingConfigItem[]
|
||||
observedProducts: AdminObservedProductItem[]
|
||||
}>('/api/v1/admin/platform-config/fulfillment-bindings')
|
||||
}
|
||||
|
||||
export function lookupAdminFulfillmentBindingOrder(payload: {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId: string
|
||||
platformOrderId: string
|
||||
}) {
|
||||
return apiPost<AdminFulfillmentLookupResult>(
|
||||
'/api/v1/admin/platform-config/fulfillment-bindings/lookup-order',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminFulfillmentBindingConfigs(payload: {
|
||||
bindings: Array<Record<string, unknown>>
|
||||
}) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
bindings: AdminFulfillmentBindingConfigItem[]
|
||||
}>('/api/v1/admin/platform-config/fulfillment-bindings', payload)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './agiso'
|
||||
export * from './notifications'
|
||||
export * from './scheduled-jobs'
|
||||
export * from './ninetyone'
|
||||
export * from './kuaishou-eticket'
|
||||
export * from './cloudtentacles'
|
||||
export * from './fulfillment-bindings'
|
||||
export * from './kuaishou-cloud-fulfillment'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { AdminKuaishouCloudFulfillmentConfig } from '@/types/admin'
|
||||
|
||||
export function fetchAdminKuaishouCloudFulfillmentConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
source: AdminKuaishouCloudFulfillmentConfig
|
||||
}>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment')
|
||||
}
|
||||
|
||||
export function saveAdminKuaishouCloudFulfillmentConfig(
|
||||
payload: AdminKuaishouCloudFulfillmentConfig,
|
||||
) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
source: AdminKuaishouCloudFulfillmentConfig
|
||||
}>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment', payload)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
AdminKuaishouEticketSourceConfig,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminKuaishouEticketSourceConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
source: AdminKuaishouEticketSourceConfig
|
||||
}>('/api/v1/admin/platform-config/kuaishou-eticket-source')
|
||||
}
|
||||
|
||||
export function saveAdminKuaishouEticketSourceConfig(payload: AdminKuaishouEticketSourceConfig) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
source: AdminKuaishouEticketSourceConfig
|
||||
}>('/api/v1/admin/platform-config/kuaishou-eticket-source', payload)
|
||||
}
|
||||
|
||||
export function queryAdminKuaishouEticketDetail(payload: {
|
||||
baseUrl?: string
|
||||
shopId?: string
|
||||
cookie?: string
|
||||
eTicketId: string
|
||||
}) {
|
||||
return apiPost<AdminKuaishouEticketDetailResult>(
|
||||
'/api/v1/admin/platform-config/kuaishou-eticket/query-detail',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function queryAdminKuaishouEticketShopInfo(payload: {
|
||||
baseUrl?: string
|
||||
shopId?: string
|
||||
cookie?: string
|
||||
}) {
|
||||
return apiPost<AdminKuaishouEticketShopInfoResult>(
|
||||
'/api/v1/admin/platform-config/kuaishou-eticket/query-shop-info',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function consumeAdminKuaishouEticket(payload: {
|
||||
baseUrl?: string
|
||||
shopId?: string
|
||||
cookie?: string
|
||||
eTicketId: string
|
||||
oid?: string
|
||||
formToken?: string
|
||||
num?: number
|
||||
storeId?: string
|
||||
}) {
|
||||
return apiPost<AdminKuaishouEticketConsumeResult>(
|
||||
'/api/v1/admin/platform-config/kuaishou-eticket/consume',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminNinetyoneOrderActionResult,
|
||||
AdminNinetyoneOrderListResult,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminNinetyoneOrders(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
} = {},
|
||||
) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) {
|
||||
searchParams.set('page', String(params.page))
|
||||
}
|
||||
if (params.pageSize) {
|
||||
searchParams.set('pageSize', String(params.pageSize))
|
||||
}
|
||||
if (params.status) {
|
||||
searchParams.set('status', params.status)
|
||||
}
|
||||
const queryString = searchParams.toString()
|
||||
return apiGet<AdminNinetyoneOrderListResult>(
|
||||
`/api/v1/admin/platform-config/ninetyone/orders${queryString ? `?${queryString}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function retryAdminNinetyoneOrder(orderId: number | string) {
|
||||
return apiPost<AdminNinetyoneOrderActionResult>(
|
||||
`/api/v1/admin/platform-config/ninetyone/orders/${orderId}/retry`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function failAdminNinetyoneOrder(orderId: number | string, payload: { reason?: string }) {
|
||||
return apiPost<AdminNinetyoneOrderActionResult>(
|
||||
`/api/v1/admin/platform-config/ninetyone/orders/${orderId}/fail`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { AdminNotificationConfig, AdminNotificationTestResult } from '@/types/admin'
|
||||
|
||||
export function fetchAdminNotificationConfig() {
|
||||
return apiGet<{
|
||||
filePath: string
|
||||
source: AdminNotificationConfig
|
||||
}>('/api/v1/admin/platform-config/notifications')
|
||||
}
|
||||
|
||||
export function saveAdminNotificationConfig(payload: AdminNotificationConfig) {
|
||||
return apiPost<{
|
||||
filePath: string
|
||||
source: AdminNotificationConfig
|
||||
}>('/api/v1/admin/platform-config/notifications', payload)
|
||||
}
|
||||
|
||||
export function testAdminNotification(payload: { title?: string; body?: string; url?: string }) {
|
||||
return apiPost<AdminNotificationTestResult>(
|
||||
'/api/v1/admin/platform-config/notifications/test',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { AdminScheduledJobsConfig, AdminScheduledJobsResponse } from '@/types/admin'
|
||||
|
||||
export function fetchAdminScheduledJobsConfig() {
|
||||
return apiGet<AdminScheduledJobsResponse>('/api/v1/admin/platform-config/scheduled-jobs')
|
||||
}
|
||||
|
||||
export function saveAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig) {
|
||||
return apiPost<AdminScheduledJobsResponse>(
|
||||
'/api/v1/admin/platform-config/scheduled-jobs',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function runAdminScheduledJob(jobId: string) {
|
||||
return apiPost<{
|
||||
result: Record<string, unknown>
|
||||
runtime: AdminScheduledJobsResponse['runtime']
|
||||
}>(`/api/v1/admin/platform-config/scheduled-jobs/${jobId}/run`, {})
|
||||
}
|
||||
@@ -12,6 +12,6 @@ export function redeemTencentBrowserSession(
|
||||
) {
|
||||
return apiPost<TencentBrowserSessionData>(
|
||||
`/api/v1/tencent/browser/session/${sessionId}/redeem`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,924 +0,0 @@
|
||||
import type { ClaimDetailData } from './claim'
|
||||
|
||||
export interface AdminPagination {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type AdminRole = 'admin' | 'operator' | 'support'
|
||||
export type AdminUserStatus = 'active' | 'disabled'
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
token: string
|
||||
expiresAt: string
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
role: AdminRole
|
||||
inventoryGroupCodes?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminSessionSummary {
|
||||
authenticated: boolean
|
||||
expiresAt: string
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
role: AdminRole
|
||||
inventoryGroupCodes?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminUserListItem {
|
||||
userId: number
|
||||
username: string
|
||||
role: AdminRole
|
||||
status: AdminUserStatus
|
||||
inventoryGroupCodes: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminAuditLogItem {
|
||||
logId: number
|
||||
actorUserId: number
|
||||
actorUsername: string
|
||||
actorRole: AdminRole
|
||||
action: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminAgisoShopConfigItem {
|
||||
shopId: string
|
||||
shopName: string
|
||||
accessToken: string
|
||||
accessTokenMasked: string
|
||||
enabled: boolean | null
|
||||
messageTemplate: string
|
||||
autoDeliveryMessageTemplate: string
|
||||
appSecretConfigured: boolean
|
||||
apiVersion: string
|
||||
sendMessageEndpoint: string
|
||||
}
|
||||
|
||||
export interface AdminAgisoMessagingDefaults {
|
||||
messageTemplate: string
|
||||
autoDeliveryMessageTemplate: string
|
||||
}
|
||||
|
||||
export interface AdminAgisoObservedShopItem {
|
||||
shopId: string
|
||||
detectedShopName: string
|
||||
displayShopName: string
|
||||
latestSeenAt: string | null
|
||||
webhookEventCount: number
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketSourceConfig {
|
||||
enabled: boolean
|
||||
baseUrl: string
|
||||
shops: AdminKuaishouEticketShopConfigItem[]
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketShopConfigItem {
|
||||
shopId: string
|
||||
kshopName: string
|
||||
cookie: string
|
||||
cookieMasked: string
|
||||
hasCookie: boolean
|
||||
userAvatar: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketShopInfoResult {
|
||||
baseUrl: string
|
||||
ok: boolean
|
||||
result: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
shop: {
|
||||
shopId: string
|
||||
kshopName: string
|
||||
userAvatar: string
|
||||
settleStatus: number
|
||||
hasCookie: boolean
|
||||
}
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketDetailResult {
|
||||
baseUrl: string
|
||||
eTicketId: string
|
||||
ok: boolean
|
||||
alreadyConsumed: boolean
|
||||
result: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
serverTimestamp: string
|
||||
detail: {
|
||||
uid: string
|
||||
fulfillDetailId: string
|
||||
sellerId: string
|
||||
formToken: string
|
||||
validEndTime: string
|
||||
validStartTime: string
|
||||
leftReverseCount: number
|
||||
eTicketId: string
|
||||
oid: string
|
||||
totalCount: number
|
||||
leftCount: number
|
||||
status: string
|
||||
} | null
|
||||
goods: {
|
||||
itemId: string
|
||||
itemPicUrl: string
|
||||
itemTitle: string
|
||||
price: string
|
||||
skuDesc: string
|
||||
skuId: string
|
||||
} | null
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketConsumeResult {
|
||||
baseUrl: string
|
||||
eTicketId: string
|
||||
oid: string
|
||||
formToken: string
|
||||
num: number
|
||||
storeId: string
|
||||
ok: boolean
|
||||
consumed: boolean
|
||||
alreadyConsumed: boolean
|
||||
result: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
serverTimestamp: string
|
||||
detail: AdminKuaishouEticketDetailResult['detail']
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminNotificationBarkRecipient {
|
||||
id: string
|
||||
name: string
|
||||
deviceKey: string
|
||||
deviceKeyMasked: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AdminNotificationConfig {
|
||||
enabled: boolean
|
||||
channels: {
|
||||
bark: {
|
||||
enabled: boolean
|
||||
serverUrl: string
|
||||
recipients: AdminNotificationBarkRecipient[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminNotificationSendResult {
|
||||
recipientId: string
|
||||
recipientName: string
|
||||
recipientKeyMasked: string
|
||||
ok: boolean
|
||||
status: number
|
||||
errorMessage: string
|
||||
response: unknown
|
||||
}
|
||||
|
||||
export interface AdminNotificationTestResult {
|
||||
enabled: boolean
|
||||
channel: string
|
||||
successCount: number
|
||||
failedCount: number
|
||||
skippedCount: number
|
||||
results: AdminNotificationSendResult[]
|
||||
}
|
||||
|
||||
export interface AdminScheduledJobItem {
|
||||
id: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
intervalSeconds: number
|
||||
cooldownSeconds: number
|
||||
config: {
|
||||
assetThreshold: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminScheduledJobsConfig {
|
||||
enabled: boolean
|
||||
jobs: AdminScheduledJobItem[]
|
||||
}
|
||||
|
||||
export interface AdminScheduledJobRuntimeState {
|
||||
id: string
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
lastRunAt: string
|
||||
lastFinishedAt: string
|
||||
nextRunAt: string
|
||||
lastStatus: string
|
||||
lastMessage: string
|
||||
lastAsset: number | null
|
||||
lastThreshold: number | null
|
||||
lastManual: boolean
|
||||
}
|
||||
|
||||
export interface AdminScheduledJobsResponse {
|
||||
filePath: string
|
||||
source: AdminScheduledJobsConfig
|
||||
runtime: AdminScheduledJobRuntimeState[]
|
||||
}
|
||||
|
||||
export interface AdminNinetyoneOrderItem {
|
||||
orderId: number
|
||||
orderNo: string
|
||||
outTradeNo: string
|
||||
orderStatus: string
|
||||
payStatus: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
productNo: string
|
||||
productName: string
|
||||
buyNum: number
|
||||
taskCount: number
|
||||
failReason: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface AdminNinetyoneOrderListResult {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
items: AdminNinetyoneOrderItem[]
|
||||
}
|
||||
|
||||
export interface AdminNinetyoneOrderActionResult {
|
||||
orderId: number
|
||||
orderNo: string
|
||||
orderStatus: string
|
||||
orderItemCount: number
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSourceConfig {
|
||||
enabled: boolean
|
||||
baseUrl: string
|
||||
username: string
|
||||
password: string
|
||||
phone: string
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesPersistedSession {
|
||||
token: string
|
||||
tokenMasked: string
|
||||
baseUrl: string
|
||||
username: string
|
||||
phoneMasked: string
|
||||
loggedInAt: string
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSendSmsResult {
|
||||
baseUrl: string
|
||||
username: string
|
||||
phone: string
|
||||
phoneMasked: string
|
||||
sentAt: string
|
||||
responseMessage: string
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSessionSummary {
|
||||
tokenMasked: string
|
||||
permissionCount: number
|
||||
permissions: string[]
|
||||
persisted?: boolean
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesLoginTestResult {
|
||||
baseUrl: string
|
||||
username: string
|
||||
phoneMasked: string
|
||||
loggedInAt: string
|
||||
responseMessage: string
|
||||
token: string
|
||||
session: AdminCloudtentaclesSessionSummary
|
||||
userInfo: Record<string, unknown>
|
||||
asset: number
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesValidateSessionResult {
|
||||
baseUrl: string
|
||||
loggedInAt: string
|
||||
session: AdminCloudtentaclesSessionSummary
|
||||
userInfo: Record<string, unknown>
|
||||
asset: number
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSkuItem {
|
||||
id: number
|
||||
categoriesId: number
|
||||
name: string
|
||||
description: string
|
||||
image: string
|
||||
inventory: number
|
||||
price: number
|
||||
listingTime: string
|
||||
delistingTime: string
|
||||
buyLimitMin: number
|
||||
buyLimitMax: number
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSkuListResult {
|
||||
baseUrl: string
|
||||
itemCount: number
|
||||
items: AdminCloudtentaclesSkuItem[]
|
||||
rawItems: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentBindingConfigItem {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
config: Record<string, unknown>
|
||||
match: {
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminKuaishouCloudFulfillmentItem {
|
||||
id: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
internalSkuCode: string
|
||||
internalSkuName: string
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
resolvedSkuName: string
|
||||
cloudSourceKey: string
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
vnKey: string
|
||||
autoBuyEnabled: boolean
|
||||
minAssetReserve: number
|
||||
autoReturnNumberAfterDispatch: boolean
|
||||
autoConsumeAfterDispatch: boolean
|
||||
kuaishouConsumeShopId: string
|
||||
kuaishouConsumeShopName: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface AdminKuaishouCloudFulfillmentConfig {
|
||||
enabled: boolean
|
||||
items: AdminKuaishouCloudFulfillmentItem[]
|
||||
}
|
||||
|
||||
export interface AdminObservedProductItem {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
externalItemId: string
|
||||
externalSkuCode: string
|
||||
externalSkuName: string
|
||||
latestSeenAt: string | null
|
||||
orderItemCount: number
|
||||
configured: boolean
|
||||
matchedBinding: null | {
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentLookupOrder {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
buyerName: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
paidAt: string | null
|
||||
enriched: boolean
|
||||
enrichReason: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentLookupItem extends AdminObservedProductItem {
|
||||
lineId: string
|
||||
itemTitle: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentLookupResult {
|
||||
order: AdminFulfillmentLookupOrder
|
||||
items: AdminFulfillmentLookupItem[]
|
||||
}
|
||||
|
||||
export interface AdminDashboardSummary {
|
||||
todayOrders: number
|
||||
paidPendingClaim: number
|
||||
claimingTasks: number
|
||||
redeemedToday: number
|
||||
abnormalTasks: number
|
||||
skuWithInventory: number
|
||||
}
|
||||
|
||||
export interface AdminTaskBindingSummary {
|
||||
totalBindingCount: number
|
||||
reservedBindingCount: number
|
||||
consumedBindingCount: number
|
||||
releasedBindingCount: number
|
||||
roleKeys: string[]
|
||||
}
|
||||
|
||||
export interface AdminOrderBindingSummary {
|
||||
totalTaskCount: number
|
||||
systemBoundTaskCount: number
|
||||
completedBindingTaskCount: number
|
||||
totalBindingCount: number
|
||||
reservedBindingCount: number
|
||||
consumedBindingCount: number
|
||||
releasedBindingCount: number
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
}
|
||||
|
||||
export interface AdminAgisoAutoDeliveryStatus {
|
||||
status: string
|
||||
trigger: string
|
||||
reason: string
|
||||
platformOrderId: string
|
||||
responseStatus: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminOrderAgisoAutoDeliverySummary extends AdminAgisoAutoDeliveryStatus {
|
||||
totalTaskCount: number
|
||||
deliveredTaskCount: number
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
}
|
||||
|
||||
export interface AdminOrderListItem {
|
||||
orderId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
orderStatus: string
|
||||
payStatus: string
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
receiverContact: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
currency: string
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
itemCount: number
|
||||
totalQuantity: number
|
||||
itemSummary: string
|
||||
taskCount: number
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
systemBoundTaskCount: number
|
||||
completedBindingTaskCount: number
|
||||
totalBindingCount: number
|
||||
reservedBindingCount: number
|
||||
consumedBindingCount: number
|
||||
releasedBindingCount: number
|
||||
}
|
||||
|
||||
export interface AdminTaskListItem {
|
||||
taskId: number
|
||||
taskNo: string
|
||||
platformOrderId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
status: string
|
||||
executorKey: string
|
||||
deliveryStatus: string
|
||||
resultCode: string
|
||||
resultMessage: string
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
loginType: string
|
||||
roleName: string
|
||||
roleId: string
|
||||
browserSessionId: string
|
||||
claimedAt: string | null
|
||||
roleConfirmedAt: string | null
|
||||
redeemedAt: string | null
|
||||
retryCount: number
|
||||
bindingSummary: AdminTaskBindingSummary
|
||||
lastError: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
inventoryDisplayMasked: string
|
||||
inventoryCredentialType: string
|
||||
claimToken: string
|
||||
screenshotPath: string
|
||||
agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null
|
||||
}
|
||||
|
||||
export interface AdminTaskOperations {
|
||||
canRetry: boolean
|
||||
canReleaseInventory: boolean
|
||||
canRegenerateClaimLink: boolean
|
||||
canClose: boolean
|
||||
canMarkManualReview: boolean
|
||||
canCompleteManualDispatch: boolean
|
||||
canPrepareKuaishouCloudFulfillment: boolean
|
||||
canRefreshKuaishouCloudRoleInfo: boolean
|
||||
canDispatchKuaishouCloudFulfillment: boolean
|
||||
canReturnKuaishouCloudFulfillment: boolean
|
||||
canSupportConfirmRole: boolean
|
||||
canSupportRedeem: boolean
|
||||
canViewSensitiveTaskData: boolean
|
||||
}
|
||||
|
||||
export interface AdminTaskActionResponse {
|
||||
task: {
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: string
|
||||
deliveryStatus: string
|
||||
resultCode: string
|
||||
resultMessage: string
|
||||
inventoryItemId: number | null
|
||||
primaryClaimTokenId: number | null
|
||||
lastError: string
|
||||
updatedAt: string
|
||||
}
|
||||
claimUrl?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface AdminManualRedeemDetail extends ClaimDetailData {
|
||||
manualRequest: {
|
||||
sourceType: string
|
||||
proofValue: string
|
||||
remark: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
order: {
|
||||
orderId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
orderStatus: string
|
||||
payStatus: string
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
receiverContact: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
currency: string
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
itemSummary: string
|
||||
rawPayload: Record<string, unknown>
|
||||
bindingSummary: AdminOrderBindingSummary
|
||||
agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null
|
||||
}
|
||||
items: Array<{
|
||||
orderItemId: number
|
||||
skuCode: string
|
||||
skuName: string
|
||||
itemTitle: string
|
||||
quantity: number
|
||||
deliveryMode: string
|
||||
spec: Record<string, unknown>
|
||||
}>
|
||||
tasks: AdminTaskListItem[]
|
||||
webhookEvents: Array<{
|
||||
eventId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
eventType: string
|
||||
eventKey: string
|
||||
signatureValid: boolean
|
||||
processed: boolean
|
||||
processError: string
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AdminTaskDetail {
|
||||
task: AdminTaskListItem
|
||||
order: null | {
|
||||
orderId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
payStatus: string
|
||||
orderStatus: string
|
||||
}
|
||||
orderItem: null | {
|
||||
orderItemId: number
|
||||
skuCode: string
|
||||
skuName: string
|
||||
quantity: number
|
||||
}
|
||||
claimToken: null | {
|
||||
primaryClaimTokenId: number
|
||||
token: string
|
||||
status: string
|
||||
expiredAt: string
|
||||
claimUrl: string
|
||||
}
|
||||
inventory: null | {
|
||||
inventoryItemId: number
|
||||
skuCode: string
|
||||
batchNo: string
|
||||
credentialType: string
|
||||
displayValue: string
|
||||
status: string
|
||||
}
|
||||
inventoryBindings: Array<{
|
||||
bindingId: number
|
||||
inventoryItemId: number
|
||||
roleKey: string
|
||||
quantity: number
|
||||
bindingStatus: string
|
||||
inventoryStatus: string
|
||||
skuCode: string
|
||||
batchNo: string
|
||||
credentialType: string
|
||||
displayValue: string
|
||||
invalidReason: string
|
||||
consumedAt: string | null
|
||||
releasedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
metadata: Record<string, unknown>
|
||||
isPrimary: boolean
|
||||
canRelease: boolean
|
||||
}>
|
||||
artifacts: Record<string, unknown>
|
||||
screenshotUrl: string
|
||||
review: {
|
||||
required: boolean
|
||||
screenshotCapturedAt: string | null
|
||||
roleId: string
|
||||
roleName: string
|
||||
}
|
||||
redeemResolution: null | {
|
||||
status: string
|
||||
taskStatus: string
|
||||
replacementCount: number
|
||||
finishedAt: string | null
|
||||
attempts: Array<{
|
||||
attempt: number
|
||||
inventoryItemId: number | null
|
||||
codeMasked: string
|
||||
credentialType: string
|
||||
outcome: string
|
||||
resultCode: string
|
||||
resultMessage: string
|
||||
}>
|
||||
}
|
||||
kuaishouCloudFulfillment: null | {
|
||||
flowType: string
|
||||
configId: string
|
||||
internalSkuCode: string
|
||||
internalSkuName: string
|
||||
ticket: {
|
||||
code: string
|
||||
capturedAt: string | null
|
||||
status: string
|
||||
verifiedAt: string | null
|
||||
goodsTitle: string
|
||||
leftCount: number
|
||||
}
|
||||
binding: {
|
||||
prepareStatus: string
|
||||
cloudSourceKey: string
|
||||
skuId: number
|
||||
skuName: string
|
||||
vnKey: string
|
||||
vnId: number
|
||||
vnPhone: string
|
||||
bindUrl: string
|
||||
bindPreparedAt: string | null
|
||||
bindExpiresAt: string | null
|
||||
bindProbeAt: string | null
|
||||
bindProbeStatus: string
|
||||
bindProbeMessage: string
|
||||
}
|
||||
role: {
|
||||
status: string
|
||||
name: string
|
||||
rid: string
|
||||
refreshedAt: string | null
|
||||
errorMessage: string
|
||||
rawInfo: Record<string, unknown> | null
|
||||
}
|
||||
purchase: {
|
||||
autoBuyEnabled: boolean
|
||||
minAssetReserve: number
|
||||
usedKnapsack: boolean
|
||||
purchaseTriggered: boolean
|
||||
assetBefore: number
|
||||
assetAfter: number
|
||||
purchaseAt: string | null
|
||||
}
|
||||
dispatch: {
|
||||
status: string
|
||||
dispatchAt: string | null
|
||||
sendType: number
|
||||
note: string
|
||||
}
|
||||
returnNumber: {
|
||||
status: string
|
||||
returnedAt: string | null
|
||||
autoReturnEnabled: boolean
|
||||
}
|
||||
consume: {
|
||||
status: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
autoConsumeEnabled: boolean
|
||||
consumedAt: string | null
|
||||
errorMessage: string
|
||||
}
|
||||
notes: string
|
||||
}
|
||||
manualDispatch: null | {
|
||||
outcome: string
|
||||
deliveryReference: string
|
||||
deliveredCredential: string
|
||||
resultMessage: string
|
||||
completedAt: string | null
|
||||
completedBy: null | {
|
||||
userId: number
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
events: Array<{
|
||||
eventId: number
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}>
|
||||
operations: AdminTaskOperations
|
||||
}
|
||||
|
||||
export interface AdminInventorySkuSuggestion {
|
||||
skuCode: string
|
||||
credentialType: string
|
||||
inventoryGroupCode: string
|
||||
totalCount: number
|
||||
availableCount: number
|
||||
latestUpdatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminInventoryItemListItem {
|
||||
inventoryItemId: number
|
||||
skuCode: string
|
||||
batchNo: string
|
||||
credentialType: string
|
||||
inventoryGroupCode: string
|
||||
displayValue: string
|
||||
status: string
|
||||
reservedByTaskId: number | null
|
||||
reservedByTaskNo: string
|
||||
platformOrderId: string
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
invalidReason: string
|
||||
deliveredAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminMessageDeliveryListItem {
|
||||
deliveryId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
channel: string
|
||||
orderId: number | null
|
||||
taskId: number | null
|
||||
taskNo: string
|
||||
taskStatus: string
|
||||
platformOrderId: string
|
||||
recipientKey: string
|
||||
messageContent: string
|
||||
claimUrl: string
|
||||
status: string
|
||||
requestUrl: string
|
||||
responseStatus: number
|
||||
response: Record<string, unknown>
|
||||
errorMessage: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminWebhookEventListItem {
|
||||
eventId: number
|
||||
provider: string
|
||||
platform: string
|
||||
platformRaw: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
eventType: string
|
||||
eventKey: string
|
||||
signatureValid: boolean
|
||||
processed: boolean
|
||||
processError: string
|
||||
visibilityLevel: string
|
||||
relatedOrderId: number | null
|
||||
platformOrderId: string
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
currency: string
|
||||
itemCount: number
|
||||
taskCount: number
|
||||
messageText: string
|
||||
requestTimestamp: string
|
||||
requestSign: string
|
||||
aopic: string
|
||||
sourceHost: string
|
||||
sourceIp: string
|
||||
userAgent: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminWebhookEventDetail extends AdminWebhookEventListItem {
|
||||
headers: Record<string, unknown>
|
||||
query: Record<string, unknown>
|
||||
body: Record<string, unknown>
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminWebhookReplayResponse {
|
||||
eventId: number
|
||||
replayed: boolean
|
||||
result: {
|
||||
accepted: boolean
|
||||
provider: string
|
||||
platform: string
|
||||
platformRaw: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
eventType: string
|
||||
platformOrderId: string
|
||||
orderId: number
|
||||
taskCount: number
|
||||
tasks: Array<{
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AdminRole } from './auth'
|
||||
|
||||
export interface AdminAuditLogItem {
|
||||
logId: number
|
||||
actorUserId: number
|
||||
actorUsername: string
|
||||
actorRole: AdminRole
|
||||
action: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type AdminRole = 'admin' | 'operator' | 'support'
|
||||
export type AdminUserStatus = 'active' | 'disabled'
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
token: string
|
||||
expiresAt: string
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
role: AdminRole
|
||||
inventoryGroupCodes?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminSessionSummary {
|
||||
authenticated: boolean
|
||||
expiresAt: string
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
role: AdminRole
|
||||
inventoryGroupCodes?: string[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface AdminPagination {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface AdminDashboardSummary {
|
||||
todayOrders: number
|
||||
paidPendingClaim: number
|
||||
claimingTasks: number
|
||||
redeemedToday: number
|
||||
abnormalTasks: number
|
||||
skuWithInventory: number
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AdminObservedProductItem } from './platform-config/fulfillment-bindings'
|
||||
|
||||
export interface AdminFulfillmentLookupOrder {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
buyerName: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
paidAt: string | null
|
||||
enriched: boolean
|
||||
enrichReason: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentLookupItem extends AdminObservedProductItem {
|
||||
lineId: string
|
||||
itemTitle: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentLookupResult {
|
||||
order: AdminFulfillmentLookupOrder
|
||||
items: AdminFulfillmentLookupItem[]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Common types
|
||||
export type { AdminPagination } from './common'
|
||||
|
||||
// Auth types
|
||||
export type {
|
||||
AdminRole,
|
||||
AdminUserStatus,
|
||||
AdminLoginResponse,
|
||||
AdminSessionSummary,
|
||||
} from './auth'
|
||||
|
||||
// Users types
|
||||
export type { AdminUserListItem } from './users'
|
||||
|
||||
// Audit logs types
|
||||
export type { AdminAuditLogItem } from './audit-logs'
|
||||
|
||||
// Dashboard types
|
||||
export type { AdminDashboardSummary } from './dashboard'
|
||||
|
||||
// Orders types
|
||||
export type {
|
||||
AdminOrderBindingSummary,
|
||||
AdminOrderAgisoAutoDeliverySummary,
|
||||
AdminOrderListItem,
|
||||
AdminOrderDetail,
|
||||
} from './orders'
|
||||
|
||||
// Tasks types
|
||||
export type {
|
||||
AdminTaskBindingSummary,
|
||||
AdminAgisoAutoDeliveryStatus,
|
||||
AdminTaskListItem,
|
||||
AdminTaskOperations,
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskDetail,
|
||||
} from './tasks'
|
||||
|
||||
// Inventory types
|
||||
export type {
|
||||
AdminInventorySkuSuggestion,
|
||||
AdminInventoryItemListItem,
|
||||
} from './inventory'
|
||||
|
||||
// Manual redeem types
|
||||
export type { AdminManualRedeemDetail } from './manual-redeem'
|
||||
|
||||
// Webhook events types
|
||||
export type {
|
||||
AdminWebhookEventListItem,
|
||||
AdminWebhookEventDetail,
|
||||
AdminWebhookReplayResponse,
|
||||
} from './webhook-events'
|
||||
|
||||
// Message deliveries types
|
||||
export type { AdminMessageDeliveryListItem } from './message-deliveries'
|
||||
|
||||
// Fulfillment types
|
||||
export type {
|
||||
AdminFulfillmentLookupOrder,
|
||||
AdminFulfillmentLookupItem,
|
||||
AdminFulfillmentLookupResult,
|
||||
} from './fulfillment'
|
||||
|
||||
// Platform config types
|
||||
export type {
|
||||
AdminAgisoShopConfigItem,
|
||||
AdminAgisoMessagingDefaults,
|
||||
AdminAgisoObservedShopItem,
|
||||
AdminNotificationBarkRecipient,
|
||||
AdminNotificationConfig,
|
||||
AdminNotificationSendResult,
|
||||
AdminNotificationTestResult,
|
||||
AdminScheduledJobItem,
|
||||
AdminScheduledJobsConfig,
|
||||
AdminScheduledJobRuntimeState,
|
||||
AdminScheduledJobsResponse,
|
||||
AdminNinetyoneOrderItem,
|
||||
AdminNinetyoneOrderListResult,
|
||||
AdminNinetyoneOrderActionResult,
|
||||
AdminKuaishouEticketSourceConfig,
|
||||
AdminKuaishouEticketShopConfigItem,
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
AdminCloudtentaclesSourceConfig,
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSendSmsResult,
|
||||
AdminCloudtentaclesSessionSummary,
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
AdminCloudtentaclesSkuItem,
|
||||
AdminCloudtentaclesSkuListResult,
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminObservedProductItem,
|
||||
AdminKuaishouCloudFulfillmentItem,
|
||||
AdminKuaishouCloudFulfillmentConfig,
|
||||
} from './platform-config'
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface AdminInventorySkuSuggestion {
|
||||
skuCode: string
|
||||
credentialType: string
|
||||
inventoryGroupCode: string
|
||||
totalCount: number
|
||||
availableCount: number
|
||||
latestUpdatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminInventoryItemListItem {
|
||||
inventoryItemId: number
|
||||
skuCode: string
|
||||
batchNo: string
|
||||
credentialType: string
|
||||
inventoryGroupCode: string
|
||||
displayValue: string
|
||||
status: string
|
||||
reservedByTaskId: number | null
|
||||
reservedByTaskNo: string
|
||||
platformOrderId: string
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
invalidReason: string
|
||||
deliveredAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ClaimDetailData } from '../claim'
|
||||
|
||||
export interface AdminManualRedeemDetail extends ClaimDetailData {
|
||||
manualRequest: {
|
||||
sourceType: string
|
||||
proofValue: string
|
||||
remark: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface AdminMessageDeliveryListItem {
|
||||
deliveryId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
channel: string
|
||||
orderId: number | null
|
||||
taskId: number | null
|
||||
taskNo: string
|
||||
taskStatus: string
|
||||
platformOrderId: string
|
||||
recipientKey: string
|
||||
messageContent: string
|
||||
claimUrl: string
|
||||
status: string
|
||||
requestUrl: string
|
||||
responseStatus: number
|
||||
response: Record<string, unknown>
|
||||
errorMessage: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { AdminTaskListItem } from './tasks'
|
||||
import type { AdminAgisoAutoDeliveryStatus } from './tasks'
|
||||
|
||||
export interface AdminOrderBindingSummary {
|
||||
totalTaskCount: number
|
||||
systemBoundTaskCount: number
|
||||
completedBindingTaskCount: number
|
||||
totalBindingCount: number
|
||||
reservedBindingCount: number
|
||||
consumedBindingCount: number
|
||||
releasedBindingCount: number
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
}
|
||||
|
||||
export interface AdminOrderAgisoAutoDeliverySummary extends AdminAgisoAutoDeliveryStatus {
|
||||
totalTaskCount: number
|
||||
deliveredTaskCount: number
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
}
|
||||
|
||||
export interface AdminOrderListItem {
|
||||
orderId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
orderStatus: string
|
||||
payStatus: string
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
receiverContact: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
currency: string
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
itemCount: number
|
||||
totalQuantity: number
|
||||
itemSummary: string
|
||||
taskCount: number
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
systemBoundTaskCount: number
|
||||
completedBindingTaskCount: number
|
||||
totalBindingCount: number
|
||||
reservedBindingCount: number
|
||||
consumedBindingCount: number
|
||||
releasedBindingCount: number
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
order: {
|
||||
orderId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
orderStatus: string
|
||||
payStatus: string
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
receiverContact: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
currency: string
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
itemSummary: string
|
||||
rawPayload: Record<string, unknown>
|
||||
bindingSummary: AdminOrderBindingSummary
|
||||
agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null
|
||||
}
|
||||
items: Array<{
|
||||
orderItemId: number
|
||||
skuCode: string
|
||||
skuName: string
|
||||
itemTitle: string
|
||||
quantity: number
|
||||
deliveryMode: string
|
||||
spec: Record<string, unknown>
|
||||
}>
|
||||
tasks: AdminTaskListItem[]
|
||||
webhookEvents: Array<{
|
||||
eventId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
eventType: string
|
||||
eventKey: string
|
||||
signatureValid: boolean
|
||||
processed: boolean
|
||||
processError: string
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface AdminAgisoShopConfigItem {
|
||||
shopId: string
|
||||
shopName: string
|
||||
AccessToken: string
|
||||
accessTokenMasked: string
|
||||
enabled: boolean | null
|
||||
messageTemplate: string
|
||||
autoDeliveryMessageTemplate: string
|
||||
appSecretConfigured: boolean
|
||||
apiVersion: string
|
||||
sendMessageEndpoint: string
|
||||
}
|
||||
|
||||
export interface AdminAgisoMessagingDefaults {
|
||||
messageTemplate: string
|
||||
autoDeliveryMessageTemplate: string
|
||||
}
|
||||
|
||||
export interface AdminAgisoObservedShopItem {
|
||||
shopId: string
|
||||
detectedShopName: string
|
||||
displayShopName: string
|
||||
latestSeenAt: string | null
|
||||
webhookEventCount: number
|
||||
configured: boolean
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface AdminCloudtentaclesSourceConfig {
|
||||
enabled: boolean
|
||||
baseUrl: string
|
||||
username: string
|
||||
password: string
|
||||
phone: string
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesPersistedSession {
|
||||
token: string
|
||||
tokenMasked: string
|
||||
baseUrl: string
|
||||
username: string
|
||||
phoneMasked: string
|
||||
loggedInAt: string
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSendSmsResult {
|
||||
baseUrl: string
|
||||
username: string
|
||||
phone: string
|
||||
phoneMasked: string
|
||||
sentAt: string
|
||||
responseMessage: string
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSessionSummary {
|
||||
tokenMasked: string
|
||||
permissionCount: number
|
||||
permissions: string[]
|
||||
persisted?: boolean
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesLoginTestResult {
|
||||
baseUrl: string
|
||||
username: string
|
||||
phoneMasked: string
|
||||
loggedInAt: string
|
||||
responseMessage: string
|
||||
token: string
|
||||
session: AdminCloudtentaclesSessionSummary
|
||||
userInfo: Record<string, unknown>
|
||||
asset: number
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesValidateSessionResult {
|
||||
baseUrl: string
|
||||
loggedInAt: string
|
||||
session: AdminCloudtentaclesSessionSummary
|
||||
userInfo: Record<string, unknown>
|
||||
asset: number
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSkuItem {
|
||||
id: number
|
||||
categoriesId: number
|
||||
name: string
|
||||
description: string
|
||||
image: string
|
||||
inventory: number
|
||||
price: number
|
||||
listingTime: string
|
||||
delistingTime: string
|
||||
buyLimitMin: number
|
||||
buyLimitMax: number
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminCloudtentaclesSkuListResult {
|
||||
baseUrl: string
|
||||
itemCount: number
|
||||
items: AdminCloudtentaclesSkuItem[]
|
||||
rawItems: Record<string, unknown>[]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface AdminFulfillmentBindingConfigItem {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
config: Record<string, unknown>
|
||||
match: {
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminObservedProductItem {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
externalItemId: string
|
||||
externalSkuCode: string
|
||||
externalSkuName: string
|
||||
latestSeenAt: string | null
|
||||
orderItemCount: number
|
||||
configured: boolean
|
||||
matchedBinding: null | {
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type {
|
||||
AdminAgisoShopConfigItem,
|
||||
AdminAgisoMessagingDefaults,
|
||||
AdminAgisoObservedShopItem,
|
||||
} from './agiso'
|
||||
|
||||
export type {
|
||||
AdminNotificationBarkRecipient,
|
||||
AdminNotificationConfig,
|
||||
AdminNotificationSendResult,
|
||||
AdminNotificationTestResult,
|
||||
} from './notifications'
|
||||
|
||||
export type {
|
||||
AdminScheduledJobItem,
|
||||
AdminScheduledJobsConfig,
|
||||
AdminScheduledJobRuntimeState,
|
||||
AdminScheduledJobsResponse,
|
||||
} from './scheduled-jobs'
|
||||
|
||||
export type {
|
||||
AdminNinetyoneOrderItem,
|
||||
AdminNinetyoneOrderListResult,
|
||||
AdminNinetyoneOrderActionResult,
|
||||
} from './ninetyone'
|
||||
|
||||
export type {
|
||||
AdminKuaishouEticketSourceConfig,
|
||||
AdminKuaishouEticketShopConfigItem,
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
} from './kuaishou-eticket'
|
||||
|
||||
export type {
|
||||
AdminCloudtentaclesSourceConfig,
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSendSmsResult,
|
||||
AdminCloudtentaclesSessionSummary,
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
AdminCloudtentaclesSkuItem,
|
||||
AdminCloudtentaclesSkuListResult,
|
||||
} from './cloudtentacles'
|
||||
|
||||
export type {
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminObservedProductItem,
|
||||
} from './fulfillment-bindings'
|
||||
|
||||
export type {
|
||||
AdminKuaishouCloudFulfillmentItem,
|
||||
AdminKuaishouCloudFulfillmentConfig,
|
||||
} from './kuaishou-cloud-fulfillment'
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface AdminKuaishouCloudFulfillmentItem {
|
||||
id: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
internalSkuCode: string
|
||||
internalSkuName: string
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
resolvedSkuName: string
|
||||
cloudSourceKey: string
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
vnKey: string
|
||||
autoBuyEnabled: boolean
|
||||
minAssetReserve: number
|
||||
autoReturnNumberAfterDispatch: boolean
|
||||
autoConsumeAfterDispatch: boolean
|
||||
kuaishouConsumeShopId: string
|
||||
kuaishouConsumeShopName: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface AdminKuaishouCloudFulfillmentConfig {
|
||||
enabled: boolean
|
||||
items: AdminKuaishouCloudFulfillmentItem[]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
export interface AdminKuaishouEticketShopConfigItem {
|
||||
shopId: string
|
||||
kshopName: string
|
||||
cookie: string
|
||||
cookieMasked: string
|
||||
hasCookie: boolean
|
||||
userAvatar: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketSourceConfig {
|
||||
enabled: boolean
|
||||
baseUrl: string
|
||||
shops: AdminKuaishouEticketShopConfigItem[]
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketShopInfoResult {
|
||||
baseUrl: string
|
||||
ok: boolean
|
||||
result: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
shop: {
|
||||
shopId: string
|
||||
kshopName: string
|
||||
userAvatar: string
|
||||
settleStatus: number
|
||||
hasCookie: boolean
|
||||
}
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketDetailResult {
|
||||
baseUrl: string
|
||||
eTicketId: string
|
||||
ok: boolean
|
||||
alreadyConsumed: boolean
|
||||
result: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
serverTimestamp: string
|
||||
detail: {
|
||||
uid: string
|
||||
fulfillDetailId: string
|
||||
sellerId: string
|
||||
formToken: string
|
||||
validEndTime: string
|
||||
validStartTime: string
|
||||
leftReverseCount: number
|
||||
eTicketId: string
|
||||
oid: string
|
||||
totalCount: number
|
||||
leftCount: number
|
||||
status: string
|
||||
} | null
|
||||
goods: {
|
||||
itemId: string
|
||||
itemPicUrl: string
|
||||
itemTitle: string
|
||||
price: string
|
||||
skuDesc: string
|
||||
skuId: string
|
||||
} | null
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminKuaishouEticketConsumeResult {
|
||||
baseUrl: string
|
||||
eTicketId: string
|
||||
oid: string
|
||||
formToken: string
|
||||
num: number
|
||||
storeId: string
|
||||
ok: boolean
|
||||
consumed: boolean
|
||||
alreadyConsumed: boolean
|
||||
result: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
serverTimestamp: string
|
||||
detail: AdminKuaishouEticketDetailResult['detail']
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface AdminNinetyoneOrderItem {
|
||||
orderId: number
|
||||
orderNo: string
|
||||
outTradeNo: string
|
||||
orderStatus: string
|
||||
payStatus: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
productNo: string
|
||||
productName: string
|
||||
buyNum: number
|
||||
taskCount: number
|
||||
failReason: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface AdminNinetyoneOrderListResult {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
items: AdminNinetyoneOrderItem[]
|
||||
}
|
||||
|
||||
export interface AdminNinetyoneOrderActionResult {
|
||||
orderId: number
|
||||
orderNo: string
|
||||
orderStatus: string
|
||||
orderItemCount: number
|
||||
taskCount: number
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface AdminNotificationBarkRecipient {
|
||||
id: string
|
||||
name: string
|
||||
deviceKey: string
|
||||
deviceKeyMasked: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AdminNotificationConfig {
|
||||
enabled: boolean
|
||||
channels: {
|
||||
bark: {
|
||||
enabled: boolean
|
||||
serverUrl: string
|
||||
recipients: AdminNotificationBarkRecipient[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminNotificationSendResult {
|
||||
recipientId: string
|
||||
recipientName: string
|
||||
recipientKeyMasked: string
|
||||
ok: boolean
|
||||
status: number
|
||||
errorMessage: string
|
||||
response: unknown
|
||||
}
|
||||
|
||||
export interface AdminNotificationTestResult {
|
||||
enabled: boolean
|
||||
channel: string
|
||||
successCount: number
|
||||
failedCount: number
|
||||
skippedCount: number
|
||||
results: AdminNotificationSendResult[]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface AdminScheduledJobItem {
|
||||
id: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
intervalSeconds: number
|
||||
cooldownSeconds: number
|
||||
config: {
|
||||
assetThreshold: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminScheduledJobsConfig {
|
||||
enabled: boolean
|
||||
jobs: AdminScheduledJobItem[]
|
||||
}
|
||||
|
||||
export interface AdminScheduledJobRuntimeState {
|
||||
id: string
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
lastRunAt: string
|
||||
lastFinishedAt: string
|
||||
nextRunAt: string
|
||||
lastStatus: string
|
||||
lastMessage: string
|
||||
lastAsset: number | null
|
||||
lastThreshold: number | null
|
||||
lastManual: boolean
|
||||
}
|
||||
|
||||
export interface AdminScheduledJobsResponse {
|
||||
filePath: string
|
||||
source: AdminScheduledJobsConfig
|
||||
runtime: AdminScheduledJobRuntimeState[]
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
export interface AdminTaskBindingSummary {
|
||||
totalBindingCount: number
|
||||
reservedBindingCount: number
|
||||
consumedBindingCount: number
|
||||
releasedBindingCount: number
|
||||
roleKeys: string[]
|
||||
}
|
||||
|
||||
export interface AdminAgisoAutoDeliveryStatus {
|
||||
status: string
|
||||
trigger: string
|
||||
reason: string
|
||||
platformOrderId: string
|
||||
responseStatus: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminTaskListItem {
|
||||
taskId: number
|
||||
taskNo: string
|
||||
platformOrderId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
status: string
|
||||
executorKey: string
|
||||
deliveryStatus: string
|
||||
resultCode: string
|
||||
resultMessage: string
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
loginType: string
|
||||
roleName: string
|
||||
roleId: string
|
||||
browserSessionId: string
|
||||
claimedAt: string | null
|
||||
roleConfirmedAt: string | null
|
||||
redeemedAt: string | null
|
||||
retryCount: number
|
||||
bindingSummary: AdminTaskBindingSummary
|
||||
lastError: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
inventoryDisplayMasked: string
|
||||
inventoryCredentialType: string
|
||||
claimToken: string
|
||||
screenshotPath: string
|
||||
agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null
|
||||
}
|
||||
|
||||
export interface AdminTaskOperations {
|
||||
canRetry: boolean
|
||||
canReleaseInventory: boolean
|
||||
canRegenerateClaimLink: boolean
|
||||
canClose: boolean
|
||||
canMarkManualReview: boolean
|
||||
canCompleteManualDispatch: boolean
|
||||
canPrepareKuaishouCloudFulfillment: boolean
|
||||
canRefreshKuaishouCloudRoleInfo: boolean
|
||||
canDispatchKuaishouCloudFulfillment: boolean
|
||||
canReturnKuaishouCloudFulfillment: boolean
|
||||
canSupportConfirmRole: boolean
|
||||
canSupportRedeem: boolean
|
||||
canViewSensitiveTaskData: boolean
|
||||
}
|
||||
|
||||
export interface AdminTaskActionResponse {
|
||||
task: {
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: string
|
||||
deliveryStatus: string
|
||||
resultCode: string
|
||||
resultMessage: string
|
||||
inventoryItemId: number | null
|
||||
primaryClaimTokenId: number | null
|
||||
lastError: string
|
||||
updatedAt: string
|
||||
}
|
||||
claimUrl?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface AdminTaskDetail {
|
||||
task: AdminTaskListItem
|
||||
order: null | {
|
||||
orderId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
payStatus: string
|
||||
orderStatus: string
|
||||
}
|
||||
orderItem: null | {
|
||||
orderItemId: number
|
||||
skuCode: string
|
||||
skuName: string
|
||||
quantity: number
|
||||
}
|
||||
claimToken: null | {
|
||||
primaryClaimTokenId: number
|
||||
token: string
|
||||
status: string
|
||||
expiredAt: string
|
||||
claimUrl: string
|
||||
}
|
||||
inventory: null | {
|
||||
inventoryItemId: number
|
||||
skuCode: string
|
||||
batchNo: string
|
||||
credentialType: string
|
||||
displayValue: string
|
||||
status: string
|
||||
}
|
||||
inventoryBindings: Array<{
|
||||
bindingId: number
|
||||
inventoryItemId: number
|
||||
roleKey: string
|
||||
quantity: number
|
||||
bindingStatus: string
|
||||
inventoryStatus: string
|
||||
skuCode: string
|
||||
batchNo: string
|
||||
credentialType: string
|
||||
displayValue: string
|
||||
invalidReason: string
|
||||
consumedAt: string | null
|
||||
releasedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
metadata: Record<string, unknown>
|
||||
isPrimary: boolean
|
||||
canRelease: boolean
|
||||
}>
|
||||
artifacts: Record<string, unknown>
|
||||
screenshotUrl: string
|
||||
review: {
|
||||
required: boolean
|
||||
screenshotCapturedAt: string | null
|
||||
roleId: string
|
||||
roleName: string
|
||||
}
|
||||
redeemResolution: null | {
|
||||
status: string
|
||||
taskStatus: string
|
||||
replacementCount: number
|
||||
finishedAt: string | null
|
||||
attempts: Array<{
|
||||
attempt: number
|
||||
inventoryItemId: number | null
|
||||
codeMasked: string
|
||||
credentialType: string
|
||||
outcome: string
|
||||
resultCode: string
|
||||
resultMessage: string
|
||||
}>
|
||||
}
|
||||
kuaishouCloudFulfillment: null | {
|
||||
flowType: string
|
||||
configId: string
|
||||
internalSkuCode: string
|
||||
internalSkuName: string
|
||||
ticket: {
|
||||
code: string
|
||||
capturedAt: string | null
|
||||
status: string
|
||||
verifiedAt: string | null
|
||||
goodsTitle: string
|
||||
leftCount: number
|
||||
}
|
||||
binding: {
|
||||
prepareStatus: string
|
||||
cloudSourceKey: string
|
||||
skuId: number
|
||||
skuName: string
|
||||
vnKey: string
|
||||
vnId: number
|
||||
vnPhone: string
|
||||
bindUrl: string
|
||||
bindPreparedAt: string | null
|
||||
bindExpiresAt: string | null
|
||||
bindProbeAt: string | null
|
||||
bindProbeStatus: string
|
||||
bindProbeMessage: string
|
||||
}
|
||||
role: {
|
||||
status: string
|
||||
name: string
|
||||
rid: string
|
||||
refreshedAt: string | null
|
||||
errorMessage: string
|
||||
rawInfo: Record<string, unknown> | null
|
||||
}
|
||||
purchase: {
|
||||
autoBuyEnabled: boolean
|
||||
minAssetReserve: number
|
||||
usedKnapsack: boolean
|
||||
purchaseTriggered: boolean
|
||||
assetBefore: number
|
||||
assetAfter: number
|
||||
purchaseAt: string | null
|
||||
}
|
||||
dispatch: {
|
||||
status: string
|
||||
dispatchAt: string | null
|
||||
sendType: number
|
||||
note: string
|
||||
}
|
||||
returnNumber: {
|
||||
status: string
|
||||
returnedAt: string | null
|
||||
autoReturnEnabled: boolean
|
||||
}
|
||||
consume: {
|
||||
status: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
autoConsumeEnabled: boolean
|
||||
consumedAt: string | null
|
||||
errorMessage: string
|
||||
}
|
||||
notes: string
|
||||
}
|
||||
manualDispatch: null | {
|
||||
outcome: string
|
||||
deliveryReference: string
|
||||
deliveredCredential: string
|
||||
resultMessage: string
|
||||
completedAt: string | null
|
||||
completedBy: null | {
|
||||
userId: number
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
events: Array<{
|
||||
eventId: number
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}>
|
||||
operations: AdminTaskOperations
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AdminRole, AdminUserStatus } from './auth'
|
||||
|
||||
export interface AdminUserListItem {
|
||||
userId: number
|
||||
username: string
|
||||
role: AdminRole
|
||||
status: AdminUserStatus
|
||||
inventoryGroupCodes: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface AdminWebhookEventListItem {
|
||||
eventId: number
|
||||
provider: string
|
||||
platform: string
|
||||
platformRaw: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
eventType: string
|
||||
eventKey: string
|
||||
signatureValid: boolean
|
||||
processed: boolean
|
||||
processError: string
|
||||
visibilityLevel: string
|
||||
relatedOrderId: number | null
|
||||
platformOrderId: string
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
totalAmount: string
|
||||
totalAmountFen: number
|
||||
currency: string
|
||||
itemCount: number
|
||||
taskCount: number
|
||||
messageText: string
|
||||
requestTimestamp: string
|
||||
requestSign: string
|
||||
aopic: string
|
||||
sourceHost: string
|
||||
sourceIp: string
|
||||
userAgent: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminWebhookEventDetail extends AdminWebhookEventListItem {
|
||||
headers: Record<string, unknown>
|
||||
query: Record<string, unknown>
|
||||
body: Record<string, unknown>
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminWebhookReplayResponse {
|
||||
eventId: number
|
||||
replayed: boolean
|
||||
result: {
|
||||
accepted: boolean
|
||||
provider: string
|
||||
platform: string
|
||||
platformRaw: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
eventType: string
|
||||
platformOrderId: string
|
||||
orderId: number
|
||||
taskCount: number
|
||||
tasks: Array<{
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
@@ -1,989 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminFulfillmentBindingConfigs,
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
saveAdminFulfillmentBindingConfigs,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminFulfillmentLookupItem,
|
||||
AdminFulfillmentLookupResult,
|
||||
AdminObservedProductItem,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId'
|
||||
|
||||
type ValidationState = {
|
||||
bindingId: string
|
||||
field: ValidationField
|
||||
message: string
|
||||
} | null
|
||||
|
||||
const PROFILE_OPTIONS = [
|
||||
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
|
||||
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
|
||||
{ label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' },
|
||||
{ label: '人工发货', value: 'manual_review' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const filePath = ref('')
|
||||
const bindings = ref<EditableBinding[]>([])
|
||||
const observedProducts = ref<AdminObservedProductItem[]>([])
|
||||
const lookupLoading = ref(false)
|
||||
const lookupErrorMessage = ref('')
|
||||
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
|
||||
const importedLookupLineIds = ref<string[]>([])
|
||||
const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
|
||||
const validationState = ref<ValidationState>(null)
|
||||
const collapsedBindingIds = ref<string[]>([])
|
||||
const lookupForm = reactive({
|
||||
shopId: '',
|
||||
platformOrderId: '',
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
type ImportableProductCandidate = Pick<
|
||||
AdminObservedProductItem,
|
||||
| 'provider'
|
||||
| 'platform'
|
||||
| 'shopId'
|
||||
| 'shopName'
|
||||
| 'externalSkuCode'
|
||||
| 'externalItemId'
|
||||
| 'externalSkuName'
|
||||
> & {}
|
||||
|
||||
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 || ''),
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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(' / ')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function importLookupProduct(item: AdminFulfillmentLookupItem) {
|
||||
importObservedProduct(item)
|
||||
if (!importedLookupLineIds.value.includes(item.lineId)) {
|
||||
importedLookupLineIds.value = [...importedLookupLineIds.value, item.lineId]
|
||||
}
|
||||
}
|
||||
|
||||
function isLookupProductImported(lineId: string) {
|
||||
return importedLookupLineIds.value.includes(lineId)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConfigs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护履约配置。</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="overview-card">
|
||||
<div class="overview-file">
|
||||
<span class="overview-label">配置文件</span>
|
||||
<code>{{ filePath || '-' }}</code>
|
||||
</div>
|
||||
|
||||
<div class="overview-stats">
|
||||
<article class="overview-stat">
|
||||
<span>规则总数</span>
|
||||
<strong>{{ ruleMetrics.totalCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>完整规则</span>
|
||||
<strong>{{ ruleMetrics.completedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待完善</span>
|
||||
<strong>{{ ruleMetrics.draftCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待补商品</span>
|
||||
<strong>{{ pendingObservedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已识别商品</span>
|
||||
<strong>{{ observedProducts.length }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-notes">
|
||||
<span class="overview-note">外部 SKU / 商品 ID / 商品名命中任一即可</span>
|
||||
<span class="overview-note">内部 SKU 决定库存绑定与履约链路</span>
|
||||
<span class="overview-note">同类商品跨平台尽量共用同一内部 SKU</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">履约配置加载中</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>已配置规则</h3>
|
||||
<p>正式规则集中维护在这里,新补查到的商品也会导入到这个区域继续补全。</p>
|
||||
</div>
|
||||
<div class="section-header-tools">
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">完整 {{ ruleMetrics.completedCount }}</span>
|
||||
<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>
|
||||
<el-button round @click="loadConfigs">刷新</el-button>
|
||||
<el-button :loading="saving" round type="primary" @click="saveConfigs"
|
||||
>保存配置</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.message" class="validation-banner">
|
||||
{{ validationState.message }}
|
||||
</p>
|
||||
|
||||
<div v-if="bindings.length === 0" class="empty-inline">
|
||||
当前还没有履约规则,先新增一条。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(binding, index) in bindings"
|
||||
:key="binding.id"
|
||||
:data-binding-id="binding.id"
|
||||
:class="['binding-card', { 'binding-card--invalid': isBindingInvalid(binding.id) }]"
|
||||
>
|
||||
<div class="binding-header">
|
||||
<div class="binding-summary">
|
||||
<div class="binding-title-row">
|
||||
<strong>{{ getBindingTitle(binding, index) }}</strong>
|
||||
<span
|
||||
:class="[
|
||||
'binding-status-chip',
|
||||
{ 'binding-status-chip--done': isEditableBindingComplete(binding) },
|
||||
]"
|
||||
>
|
||||
{{ getBindingStatusLabel(binding) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="binding-subtle">{{ getBindingSummary(binding) }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="toggleBindingCollapsed(binding.id)">
|
||||
{{ isBindingCollapsed(binding.id) ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isBindingCollapsed(binding.id)" class="binding-collapsed-preview">
|
||||
<span
|
||||
>外部商品:{{
|
||||
binding.externalSkuName ||
|
||||
binding.externalSkuCode ||
|
||||
binding.externalItemId ||
|
||||
'-'
|
||||
}}</span
|
||||
>
|
||||
<span>内部履约 SKU:{{ binding.skuCode || '-' }}</span>
|
||||
<span
|
||||
>履约方式:{{
|
||||
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
|
||||
binding.profileKey
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else class="binding-grid">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input
|
||||
v-model="binding.provider"
|
||||
class="text-input"
|
||||
placeholder="agiso"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input
|
||||
v-model="binding.platform"
|
||||
class="text-input"
|
||||
placeholder="xianyu / taobao / pdd"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="binding.shopId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
|
||||
class="text-input"
|
||||
data-field="shopId"
|
||||
placeholder="留空表示跨店铺通用"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺名称</span>
|
||||
<el-input
|
||||
v-model="binding.shopName"
|
||||
class="text-input"
|
||||
placeholder="用于保存和识别店铺"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>优先级</span>
|
||||
<el-input-number
|
||||
v-model="binding.priority"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
@change="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部 SKU 编码</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuCode"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuCode"
|
||||
placeholder="例如 6046726460016"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部商品 ID</span>
|
||||
<el-input
|
||||
v-model="binding.externalItemId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') }]"
|
||||
class="text-input"
|
||||
data-field="externalItemId"
|
||||
placeholder="例如 1033324289962"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block field-wide">
|
||||
<span>外部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuName"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuName"
|
||||
placeholder="例如 奥利奥动作"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部履约 SKU</span>
|
||||
<el-input
|
||||
v-model="binding.skuCode"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') }]"
|
||||
class="text-input"
|
||||
data-field="skuCode"
|
||||
placeholder="例如 sjz_hdl_test01"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
<small class="field-help"
|
||||
>填你们系统内部统一 SKU。相同履约商品,尽量跨平台共用同一个内部 SKU。</small
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.skuName"
|
||||
class="text-input"
|
||||
placeholder="用于后台展示"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>履约方式</span>
|
||||
<el-select
|
||||
v-model="binding.profileKey"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') }]"
|
||||
class="text-input"
|
||||
data-field="profileKey"
|
||||
@change="clearValidationState"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in PROFILE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>匹配后展示名</span>
|
||||
<el-input
|
||||
v-model="binding.resolvedSkuName"
|
||||
class="text-input"
|
||||
placeholder="例如 三角洲行动-自动领取"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">
|
||||
{{ validationState?.message }}
|
||||
</p>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-checkbox v-model="binding.enabled">启用规则</el-checkbox>
|
||||
<el-button link type="danger" @click="removeBinding(binding.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>手动补查订单商品</h3>
|
||||
<p>新商品还没配规则时,可以按店铺 ID 和平台订单号临时补查,查完直接导入规则草稿。</p>
|
||||
</div>
|
||||
<span class="section-note-chip">仅支持 Agiso 咸鱼订单</span>
|
||||
</div>
|
||||
|
||||
<div class="lookup-toolbar">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input class="text-input" model-value="agiso" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input class="text-input" model-value="xianyu" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="lookupForm.shopId"
|
||||
class="text-input"
|
||||
placeholder="例如 252609"
|
||||
@keydown.enter.prevent="lookupOrderProducts"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台订单号</span>
|
||||
<el-input
|
||||
v-model="lookupForm.platformOrderId"
|
||||
class="text-input"
|
||||
placeholder="输入需要补查的订单号"
|
||||
@keydown.enter.prevent="lookupOrderProducts"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="field-block field-block--action">
|
||||
<span>操作</span>
|
||||
<el-button :loading="lookupLoading" round type="primary" @click="lookupOrderProducts"
|
||||
>查询订单商品</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="toolbar-hint">查询结果只用于补配置,不会写入订单库,也不会影响现有订单数据。</p>
|
||||
|
||||
<p v-if="lookupErrorMessage" class="error-copy lookup-error">{{ lookupErrorMessage }}</p>
|
||||
<div v-else-if="lookupLoading" class="empty-inline">正在查询订单详情…</div>
|
||||
<template v-else-if="lookupResult">
|
||||
<div class="lookup-summary">
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">订单</span>
|
||||
<strong>{{ lookupResult.order.platformOrderId }}</strong>
|
||||
<span class="cell-subtle">{{
|
||||
lookupResult.order.shopName || lookupResult.order.shopId || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">买家</span>
|
||||
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>支付时间:{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">金额</span>
|
||||
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>补查结果:{{
|
||||
lookupResult.order.enriched
|
||||
? '已补全'
|
||||
: lookupResult.order.enrichReason || '已返回'
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</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="importLookupProduct(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>
|
||||
</section>
|
||||
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>近期识别到的外部商品</h3>
|
||||
<p>从已入库订单里提取的外部商品信息,优先处理未配置商品,导入后再补齐内部 SKU。</p>
|
||||
</div>
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">待补 {{ pendingObservedCount }}</span>
|
||||
<span class="mini-stat-chip">总计 {{ observedProducts.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="importObservedProduct(item)"
|
||||
>
|
||||
导入到规则
|
||||
</el-button>
|
||||
<span v-else class="cell-subtle">已存在</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminFulfillmentBindings.css"></style>
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AdminFulfillmentBindingsView from './AdminFulfillmentBindingsView.vue'
|
||||
import AdminKuaishouCloudFulfillmentView from './AdminKuaishouCloudFulfillmentView.vue'
|
||||
import AdminFulfillmentBindingsView from './bindings/AdminFulfillmentBindingsView.vue'
|
||||
import AdminKuaishouCloudFulfillmentView from './kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue'
|
||||
|
||||
type FulfillmentTabKey = 'legacy' | 'kuaishou-cloud'
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/admin-config-pages.css';
|
||||
@import '../../../../styles/admin-config-pages.css';
|
||||
|
||||
/* ============================================================
|
||||
AdminFulfillmentBindings — 绿色主题覆盖 & 页面特有样式
|
||||
@@ -0,0 +1,76 @@
|
||||
<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 AdminBindingsOverviewSection from './components/AdminBindingsOverviewSection.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">
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护履约配置。</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminBindingsOverviewSection
|
||||
:file-path="bindings.filePath.value"
|
||||
:rule-metrics="bindings.ruleMetrics.value"
|
||||
:pending-observed-count="bindings.pendingObservedCount.value"
|
||||
:observed-products-length="bindings.observedProducts.value.length"
|
||||
/>
|
||||
|
||||
<p v-if="bindings.errorMessage.value" class="error-copy">{{ bindings.errorMessage.value }}</p>
|
||||
<div v-if="bindings.loading.value" class="empty-block">履约配置加载中</div>
|
||||
|
||||
<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>
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
<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>
|
||||
<div
|
||||
:data-binding-id="binding.id"
|
||||
:class="['binding-card', { 'binding-card--invalid': isInvalid }]"
|
||||
>
|
||||
<div class="binding-header">
|
||||
<div class="binding-summary">
|
||||
<div class="binding-title-row">
|
||||
<strong>{{ title }}</strong>
|
||||
<span
|
||||
:class="[
|
||||
'binding-status-chip',
|
||||
{ 'binding-status-chip--done': isComplete },
|
||||
]"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="binding-subtle">{{ summary }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="emit('toggleCollapsed')">
|
||||
{{ isBindingCollapsed ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isBindingCollapsed" class="binding-collapsed-preview">
|
||||
<span
|
||||
>外部商品:{{
|
||||
binding.externalSkuName ||
|
||||
binding.externalSkuCode ||
|
||||
binding.externalItemId ||
|
||||
'-'
|
||||
}}</span
|
||||
>
|
||||
<span>内部履约 SKU:{{ binding.skuCode || '-' }}</span>
|
||||
<span
|
||||
>履约方式:{{
|
||||
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
|
||||
binding.profileKey
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else class="binding-grid">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input
|
||||
v-model="binding.provider"
|
||||
class="text-input"
|
||||
placeholder="agiso"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input
|
||||
v-model="binding.platform"
|
||||
class="text-input"
|
||||
placeholder="xianyu / taobao / pdd"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="binding.shopId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('shopId') }]"
|
||||
class="text-input"
|
||||
data-field="shopId"
|
||||
placeholder="留空表示跨店铺通用"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺名称</span>
|
||||
<el-input
|
||||
v-model="binding.shopName"
|
||||
class="text-input"
|
||||
placeholder="用于保存和识别店铺"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>优先级</span>
|
||||
<el-input-number
|
||||
v-model="binding.priority"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
@change="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部 SKU 编码</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuCode"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid('externalSkuCode') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuCode"
|
||||
placeholder="例如 6046726460016"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>外部商品 ID</span>
|
||||
<el-input
|
||||
v-model="binding.externalItemId"
|
||||
:class="[{ 'text-input--invalid': isFieldInvalid('externalItemId') }]"
|
||||
class="text-input"
|
||||
data-field="externalItemId"
|
||||
placeholder="例如 1033324289962"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block field-wide">
|
||||
<span>外部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.externalSkuName"
|
||||
:class="[
|
||||
{ 'text-input--invalid': isFieldInvalid('externalSkuName') },
|
||||
]"
|
||||
class="text-input"
|
||||
data-field="externalSkuName"
|
||||
placeholder="例如 奥利奥动作"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部履约 SKU</span>
|
||||
<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
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>内部商品名</span>
|
||||
<el-input
|
||||
v-model="binding.skuName"
|
||||
class="text-input"
|
||||
placeholder="用于后台展示"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>履约方式</span>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>匹配后展示名</span>
|
||||
<el-input
|
||||
v-model="binding.resolvedSkuName"
|
||||
class="text-input"
|
||||
placeholder="例如 三角洲行动-自动领取"
|
||||
@input="emit('clearValidation')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="isInvalid" class="binding-inline-error">
|
||||
{{ validationState?.message }}
|
||||
</p>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-checkbox v-model="binding.enabled">启用规则</el-checkbox>
|
||||
<el-button link type="danger" @click="emit('remove')">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<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>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>手动补查订单商品</h3>
|
||||
<p>新商品还没配规则时,可以按店铺 ID 和平台订单号临时补查,查完直接导入规则草稿。</p>
|
||||
</div>
|
||||
<span class="section-note-chip">仅支持 Agiso 咸鱼订单</span>
|
||||
</div>
|
||||
|
||||
<div class="lookup-toolbar">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<el-input class="text-input" model-value="agiso" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<el-input class="text-input" model-value="xianyu" disabled />
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="lookupForm.shopId"
|
||||
class="text-input"
|
||||
placeholder="例如 252609"
|
||||
@keydown.enter.prevent="emit('lookup')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台订单号</span>
|
||||
<el-input
|
||||
v-model="lookupForm.platformOrderId"
|
||||
class="text-input"
|
||||
placeholder="输入需要补查的订单号"
|
||||
@keydown.enter.prevent="emit('lookup')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="field-block field-block--action">
|
||||
<span>操作</span>
|
||||
<el-button :loading="lookupLoading" round type="primary" @click="emit('lookup')"
|
||||
>查询订单商品</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="toolbar-hint">查询结果只用于补配置,不会写入订单库,也不会影响现有订单数据。</p>
|
||||
|
||||
<p v-if="lookupErrorMessage" class="error-copy lookup-error">{{ lookupErrorMessage }}</p>
|
||||
<div v-else-if="lookupLoading" class="empty-inline">正在查询订单详情…</div>
|
||||
<template v-else-if="lookupResult">
|
||||
<div class="lookup-summary">
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">订单</span>
|
||||
<strong>{{ lookupResult.order.platformOrderId }}</strong>
|
||||
<span class="cell-subtle">{{
|
||||
lookupResult.order.shopName || lookupResult.order.shopId || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">买家</span>
|
||||
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>支付时间:{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="lookup-summary-item">
|
||||
<span class="summary-label">金额</span>
|
||||
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
|
||||
<span class="cell-subtle"
|
||||
>补查结果:{{
|
||||
lookupResult.order.enriched
|
||||
? '已补全'
|
||||
: lookupResult.order.enrichReason || '已返回'
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<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>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>近期识别到的外部商品</h3>
|
||||
<p>从已入库订单里提取的外部商品信息,优先处理未配置商品,导入后再补齐内部 SKU。</p>
|
||||
</div>
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">待补 {{ pendingObservedCount }}</span>
|
||||
<span class="mini-stat-chip">总计 {{ observedProducts.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</template>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<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>
|
||||
<section class="overview-card">
|
||||
<div class="overview-file">
|
||||
<span class="overview-label">配置文件</span>
|
||||
<code>{{ filePath || '-' }}</code>
|
||||
</div>
|
||||
|
||||
<div class="overview-stats">
|
||||
<article class="overview-stat">
|
||||
<span>规则总数</span>
|
||||
<strong>{{ ruleMetrics.totalCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>完整规则</span>
|
||||
<strong>{{ ruleMetrics.completedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待完善</span>
|
||||
<strong>{{ ruleMetrics.draftCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待补商品</span>
|
||||
<strong>{{ pendingObservedCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已识别商品</span>
|
||||
<strong>{{ observedProductsLength }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-notes">
|
||||
<span class="overview-note">外部 SKU / 商品 ID / 商品名命中任一即可</span>
|
||||
<span class="overview-note">内部 SKU 决定库存绑定与履约链路</span>
|
||||
<span class="overview-note">同类商品跨平台尽量共用同一内部 SKU</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<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>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>已配置规则</h3>
|
||||
<p>正式规则集中维护在这里,新补查到的商品也会导入到这个区域继续补全。</p>
|
||||
</div>
|
||||
<div class="section-header-tools">
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">完整 {{ ruleMetrics.completedCount }}</span>
|
||||
<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="emit('addBinding')">新增规则</el-button>
|
||||
<el-button round @click="emit('loadConfigs')">刷新</el-button>
|
||||
<el-button :loading="saving" round type="primary" @click="emit('saveConfigs')"
|
||||
>保存配置</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.message" class="validation-banner">
|
||||
{{ validationState.message }}
|
||||
</p>
|
||||
|
||||
<div v-if="bindings.length === 0" class="empty-inline">
|
||||
当前还没有履约规则,先新增一条。
|
||||
</div>
|
||||
|
||||
<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)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AdminFulfillmentBindingConfigItem, AdminObservedProductItem } from '@/types/admin'
|
||||
|
||||
export type EditableBinding = {
|
||||
id: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
resolvedSkuName: string
|
||||
}
|
||||
|
||||
export type SaveBindingPayload = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
match: {
|
||||
externalSkuCode: string
|
||||
externalItemId: string
|
||||
externalSkuName: string
|
||||
config: {
|
||||
resolvedSkuName: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId'
|
||||
|
||||
export type ValidationState = {
|
||||
bindingId: string
|
||||
field: ValidationField
|
||||
message: string
|
||||
} | null
|
||||
|
||||
export type ImportableProductCandidate = Pick<
|
||||
AdminObservedProductItem,
|
||||
| 'provider'
|
||||
| 'platform'
|
||||
| 'shopId'
|
||||
| 'shopName'
|
||||
| 'externalSkuCode'
|
||||
| 'externalItemId'
|
||||
| 'externalSkuName'
|
||||
> & {}
|
||||
|
||||
export const PROFILE_OPTIONS = [
|
||||
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
|
||||
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
|
||||
{ label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' },
|
||||
{ label: '人工发货', value: 'manual_review' },
|
||||
]
|
||||
|
||||
export const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import { lookupAdminFulfillmentBindingOrder } from '@/services/admin'
|
||||
import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin'
|
||||
|
||||
import type { ImportableProductCandidate } from './types'
|
||||
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,
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/admin-config-pages.css';
|
||||
@import '../../../../styles/admin-config-pages.css';
|
||||
|
||||
/* ============================================================
|
||||
AdminKuaishouCloudFulfillment — 蓝色主题覆盖 & 双栏映射布局
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import AdminKuaishouCloudNinetyoneSection from './components/AdminKuaishouCloudNinetyoneSection.vue'
|
||||
import AdminKuaishouCloudOverviewSection from './components/AdminKuaishouCloudOverviewSection.vue'
|
||||
import AdminKuaishouCloudRuleSection from './components/AdminKuaishouCloudRuleSection.vue'
|
||||
import { useKuaishouCloudConfig } from './composables/useKuaishouCloudConfig'
|
||||
import { useKuaishouCloudNinetyone } from './composables/useKuaishouCloudNinetyone'
|
||||
import { useKuaishouCloudSku } from './composables/useKuaishouCloudSku'
|
||||
|
||||
const config = useKuaishouCloudConfig()
|
||||
const ninetyone = useKuaishouCloudNinetyone(config)
|
||||
const cloudSku = useKuaishouCloudSku()
|
||||
|
||||
onMounted(config.loadConfigs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护新履约配置。</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminKuaishouCloudOverviewSection
|
||||
:file-path="config.filePath.value"
|
||||
:metrics="config.metrics.value"
|
||||
/>
|
||||
|
||||
<p v-if="config.errorMessage.value" class="error-copy">{{ config.errorMessage.value }}</p>
|
||||
<div v-if="config.loading.value" class="empty-block">新履约配置加载中</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminKuaishouCloudNinetyoneSection
|
||||
:ninetyone-lookup-loading="ninetyone.ninetyoneLookupLoading.value"
|
||||
:ninetyone-lookup-error-message="ninetyone.ninetyoneLookupErrorMessage.value"
|
||||
:ninetyone-lookup-results="ninetyone.ninetyoneLookupResults.value"
|
||||
:ninetyone-lookup-metrics="ninetyone.ninetyoneLookupMetrics.value"
|
||||
:ninetyone-lookup-form="ninetyone.ninetyoneLookupForm"
|
||||
:is-imported="ninetyone.isNinetyoneProductImported"
|
||||
@lookup="ninetyone.lookupNinetyoneProducts()"
|
||||
@import-product="ninetyone.importNinetyoneProduct($event)"
|
||||
/>
|
||||
|
||||
<AdminKuaishouCloudRuleSection
|
||||
:enabled="config.enabled.value"
|
||||
:saving="config.saving.value"
|
||||
:cloud-sku-catalog-loading="cloudSku.cloudSkuCatalogLoading.value"
|
||||
:validation-state="config.validationState.value"
|
||||
:items="config.items.value"
|
||||
:filtered-items="config.filteredItems.value"
|
||||
:rule-filter="config.ruleFilter.value"
|
||||
:rule-filter-options="config.ruleFilterOptions.value"
|
||||
:is-collapsed="config.isCollapsed"
|
||||
:is-item-complete="config.isItemComplete"
|
||||
:get-card-title="config.getCardTitle"
|
||||
:get-card-summary="config.getCardSummary"
|
||||
:get-rule-state="config.getRuleState"
|
||||
:get-external-match-summary="config.getExternalMatchSummary"
|
||||
:get-cloud-sku-summary="config.getCloudSkuSummary"
|
||||
:get-consume-shop-summary="config.getConsumeShopSummary"
|
||||
:get-cloud-sku-options="cloudSku.getCloudSkuOptions"
|
||||
:format-cloud-sku-option-label="cloudSku.formatCloudSkuOptionLabel"
|
||||
:get-kuaishou-consume-shop-options="config.getKuaishouConsumeShopOptions"
|
||||
:format-kuaishou-consume-shop-option="config.formatKuaishouConsumeShopOption"
|
||||
:cloud-sku-catalog-error-message="cloudSku.cloudSkuCatalogErrorMessage.value"
|
||||
@update:enabled="config.enabled.value = $event"
|
||||
@update:rule-filter="config.ruleFilter.value = $event"
|
||||
@expand-all="config.expandAllRules()"
|
||||
@collapse-ready="config.collapseReadyRules()"
|
||||
@refresh-cloud-sku="cloudSku.refreshCloudSkuCatalog()"
|
||||
@add-item="config.addItem()"
|
||||
@save-configs="config.saveConfigs()"
|
||||
@toggle-collapsed="config.toggleCollapsed($event)"
|
||||
@handle-cloud-sku-selected="cloudSku.handleCloudSkuSelected($event.item, $event.value)"
|
||||
@handle-cloud-sku-dropdown-visible="cloudSku.handleCloudSkuDropdownVisible($event.item, $event.visible)"
|
||||
@handle-kuaishou-consume-shop-selected="config.handleKuaishouConsumeShopSelected($event)"
|
||||
@remove-item="config.removeItem($event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminKuaishouCloudFulfillment.css"></style>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNinetyoneOrderItem } from '@/types/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
ninetyoneLookupLoading: boolean
|
||||
ninetyoneLookupErrorMessage: string
|
||||
ninetyoneLookupResults: AdminNinetyoneOrderItem[]
|
||||
ninetyoneLookupMetrics: {
|
||||
total: number
|
||||
importedCount: number
|
||||
availableCount: number
|
||||
pendingCount: number
|
||||
}
|
||||
ninetyoneLookupForm: {
|
||||
status: 'pending_config' | 'all' | 'manual_failed'
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
isImported: (item: AdminNinetyoneOrderItem) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
lookup: []
|
||||
importProduct: [item: AdminNinetyoneOrderItem]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>91卡券订单取样导入</h3>
|
||||
<p>
|
||||
查询已接收的 91卡券订单,把 productNo 导入为规则草稿,再补齐内部 SKU 和 cloud 资源。
|
||||
</p>
|
||||
</div>
|
||||
<div class="section-mini-stats">
|
||||
<span class="mini-stat-chip">结果 {{ ninetyoneLookupMetrics.total }}</span>
|
||||
<span class="mini-stat-chip">待补 {{ ninetyoneLookupMetrics.pendingCount }}</span>
|
||||
<span class="mini-stat-chip">待导入 {{ ninetyoneLookupMetrics.availableCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lookup-toolbar">
|
||||
<label class="field-block">
|
||||
<span>订单状态</span>
|
||||
<el-select v-model="ninetyoneLookupForm.status" class="text-input">
|
||||
<el-option label="待补全" value="pending_config" />
|
||||
<el-option label="已失败" value="manual_failed" />
|
||||
<el-option label="全部" value="all" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>页码</span>
|
||||
<el-input-number
|
||||
v-model="ninetyoneLookupForm.page"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>每页条数</span>
|
||||
<el-input-number
|
||||
v-model="ninetyoneLookupForm.pageSize"
|
||||
class="text-input"
|
||||
:max="100"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
<div class="field-block field-block--action">
|
||||
<span>操作</span>
|
||||
<el-button
|
||||
:loading="ninetyoneLookupLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('lookup')"
|
||||
>查询 91卡券订单</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="toolbar-hint">
|
||||
导入后会创建 provider=91kaquan、platform=kuaishou、外部 SKU=productNo
|
||||
的规则草稿;保存规则后,回到"平台配置 -> 91卡券接入"重试订单。
|
||||
</p>
|
||||
|
||||
<p v-if="ninetyoneLookupErrorMessage" class="error-copy lookup-error">
|
||||
{{ ninetyoneLookupErrorMessage }}
|
||||
</p>
|
||||
<div v-else-if="ninetyoneLookupLoading" class="empty-inline">正在查询 91卡券订单…</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="ninetyoneLookupResults"
|
||||
class="data-table element-data-table"
|
||||
empty-text="还没有 91卡券查询结果。"
|
||||
>
|
||||
<el-table-column label="订单" min-width="180">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.orderNo || '-' }}</strong>
|
||||
<span class="cell-subtle">{{ item.outTradeNo || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" min-width="180">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.shopName || '91卡券' }}</strong>
|
||||
<span class="cell-subtle">provider: 91kaquan</span>
|
||||
<span class="cell-subtle">shopId: {{ item.shopId || '91kaquan' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="240">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.productName || item.productNo || '-' }}</strong>
|
||||
<span class="cell-subtle">productNo: {{ item.productNo || '-' }}</span>
|
||||
<span class="cell-subtle">数量:{{ item.buyNum || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="120">
|
||||
<template #default="{ row: item }">
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.orderStatus || '-' }}</strong>
|
||||
<span class="cell-subtle">任务 {{ item.taskCount || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row: item }">
|
||||
<el-button
|
||||
v-if="!isImported(item)"
|
||||
link
|
||||
type="primary"
|
||||
@click="emit('importProduct', item)"
|
||||
>
|
||||
导入到规则
|
||||
</el-button>
|
||||
<span v-else class="cell-subtle">已导入草稿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
filePath: string
|
||||
metrics: {
|
||||
total: number
|
||||
readyCount: number
|
||||
draftCount: number
|
||||
enabledCount: number
|
||||
disabledCount: number
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-card">
|
||||
<div class="overview-file">
|
||||
<span class="overview-label">配置文件</span>
|
||||
<code>{{ filePath || '-' }}</code>
|
||||
</div>
|
||||
|
||||
<div class="overview-stats">
|
||||
<article class="overview-stat">
|
||||
<span>规则总数</span>
|
||||
<strong>{{ metrics.total }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>可投产</span>
|
||||
<strong>{{ metrics.readyCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>待完善</span>
|
||||
<strong>{{ metrics.draftCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已启用</span>
|
||||
<strong>{{ metrics.enabledCount }}</strong>
|
||||
</article>
|
||||
<article class="overview-stat">
|
||||
<span>已停用</span>
|
||||
<strong>{{ metrics.disabledCount }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-notes">
|
||||
<span class="overview-note">91卡券订单来自已接收的待补全队列</span>
|
||||
<span class="overview-note">内部 SKU 决定最终任务与库存绑定</span>
|
||||
<span class="overview-note">cloud SKU 决定自动购买、发货与退号资源</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminCloudtentaclesSkuItem, AdminKuaishouEticketShopConfigItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem, ValidationState } from '../composables/types'
|
||||
|
||||
defineProps<{
|
||||
item: EditableItem
|
||||
index: number
|
||||
validationState: ValidationState
|
||||
cloudSkuCatalogErrorMessage: string
|
||||
cloudSkuCatalogLoading: boolean
|
||||
isCollapsed: boolean
|
||||
isItemComplete: (item: EditableItem) => boolean
|
||||
getCardTitle: (item: EditableItem, index: number) => string
|
||||
getCardSummary: (item: EditableItem) => string
|
||||
getRuleState: (item: EditableItem) => { label: string; tone: string }
|
||||
getExternalMatchSummary: (item: EditableItem) => string
|
||||
getCloudSkuSummary: (item: EditableItem) => string
|
||||
getConsumeShopSummary: (item: EditableItem) => string
|
||||
getCloudSkuOptions: (item: EditableItem) => AdminCloudtentaclesSkuItem[]
|
||||
formatCloudSkuOptionLabel: (sku: AdminCloudtentaclesSkuItem) => string
|
||||
getKuaishouConsumeShopOptions: () => AdminKuaishouEticketShopConfigItem[]
|
||||
formatKuaishouConsumeShopOption: (shop: AdminKuaishouEticketShopConfigItem) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleCollapsed: []
|
||||
handleCloudSkuSelected: [value: number | string | undefined]
|
||||
handleCloudSkuDropdownVisible: [visible: boolean]
|
||||
handleKuaishouConsumeShopSelected: []
|
||||
remove: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-local-id="item.localId"
|
||||
:class="[
|
||||
'binding-card',
|
||||
{ 'binding-card--invalid': validationState?.localId === item.localId },
|
||||
]"
|
||||
>
|
||||
<div class="binding-header">
|
||||
<div class="binding-summary">
|
||||
<div class="binding-title-row">
|
||||
<strong>{{ getCardTitle(item, index) }}</strong>
|
||||
<span
|
||||
:class="[
|
||||
'binding-status-chip',
|
||||
{ 'binding-status-chip--done': isItemComplete(item) },
|
||||
]"
|
||||
>
|
||||
{{ getRuleState(item).label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="binding-subtle">{{ getCardSummary(item) }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="emit('toggleCollapsed')">
|
||||
{{ isCollapsed ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isCollapsed" class="binding-collapsed-preview">
|
||||
<span>外部商品:{{ getExternalMatchSummary(item) }}</span>
|
||||
<span>内部 SKU:{{ item.internalSkuCode || '-' }}</span>
|
||||
<span>cloud 资源:{{ getCloudSkuSummary(item) }}</span>
|
||||
<span>核销店铺:{{ getConsumeShopSummary(item) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="mapping-editor">
|
||||
<section class="mapping-panel mapping-panel--external">
|
||||
<div class="mapping-panel-head">
|
||||
<strong>外部商品</strong>
|
||||
<span>来源平台 / 快手侧命中条件</span>
|
||||
</div>
|
||||
|
||||
<div class="mapping-grid">
|
||||
<label class="field-block">
|
||||
<span>来源 provider</span>
|
||||
<el-input
|
||||
v-model="item.provider"
|
||||
class="text-input"
|
||||
maxlength="32"
|
||||
placeholder="默认 91kaquan"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>来源 platform</span>
|
||||
<el-input
|
||||
v-model="item.platform"
|
||||
class="text-input"
|
||||
maxlength="32"
|
||||
placeholder="默认 kuaishou"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>店铺 ID</span>
|
||||
<el-input
|
||||
v-model="item.shopId"
|
||||
class="text-input"
|
||||
maxlength="80"
|
||||
placeholder="留空表示跨店铺共用"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>外部 SKU</span>
|
||||
<el-input
|
||||
v-model="item.externalSkuCode"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="例如 91卡券 productNo"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>外部商品 ID</span>
|
||||
<el-input
|
||||
v-model="item.externalItemId"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="平台商品 ID"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>外部商品名</span>
|
||||
<el-input
|
||||
v-model="item.externalSkuName"
|
||||
class="text-input"
|
||||
maxlength="200"
|
||||
placeholder="用于名称匹配"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>解析商品名</span>
|
||||
<el-input
|
||||
v-model="item.resolvedSkuName"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="可作为商品名兜底"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mapping-arrow" aria-hidden="true">
|
||||
<span>映射到</span>
|
||||
</div>
|
||||
|
||||
<section class="mapping-panel mapping-panel--internal">
|
||||
<div class="mapping-panel-head">
|
||||
<strong>内部履约</strong>
|
||||
<span>内部 SKU + cloud 资源</span>
|
||||
</div>
|
||||
|
||||
<div class="mapping-grid">
|
||||
<label class="field-block">
|
||||
<span>内部 SKU</span>
|
||||
<el-input
|
||||
v-model="item.internalSkuCode"
|
||||
class="text-input"
|
||||
maxlength="80"
|
||||
placeholder="必填,用于生成任务"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>优先级</span>
|
||||
<el-input-number
|
||||
v-model="item.priority"
|
||||
class="text-input"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>内部商品名</span>
|
||||
<el-input
|
||||
v-model="item.internalSkuName"
|
||||
class="text-input"
|
||||
maxlength="120"
|
||||
placeholder="展示用,可选但建议填"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>cloudSourceKey</span>
|
||||
<el-input
|
||||
v-model="item.cloudSourceKey"
|
||||
class="text-input"
|
||||
maxlength="60"
|
||||
placeholder="默认 default"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>cloud SKU ID</span>
|
||||
<el-select
|
||||
v-model="item.cloudSkuId"
|
||||
class="text-input"
|
||||
@change="emit('handleCloudSkuSelected', item.cloudSkuId)"
|
||||
@visible-change="emit('handleCloudSkuDropdownVisible', $event)"
|
||||
>
|
||||
<el-option label="请选择 cloud SKU" :value="0" />
|
||||
<el-option
|
||||
v-for="sku in getCloudSkuOptions(item)"
|
||||
:key="sku.id"
|
||||
:label="formatCloudSkuOptionLabel(sku)"
|
||||
:value="sku.id"
|
||||
/>
|
||||
</el-select>
|
||||
<small class="field-help">先填内部 SKU / 商品名,再选择会更容易匹配。</small>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>cloud SKU 名称</span>
|
||||
<el-input
|
||||
:model-value="item.cloudSkuName"
|
||||
class="text-input text-input--readonly"
|
||||
placeholder="选择 cloud SKU 后自动带出"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>虚拟号 VN Key</span>
|
||||
<el-input class="text-input text-input--readonly" model-value="1" readonly />
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>最低保留余额</span>
|
||||
<el-input-number
|
||||
v-model="item.minAssetReserve"
|
||||
class="text-input"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCollapsed" class="mapping-extra">
|
||||
<label class="field-block field-wide">
|
||||
<span>快手核销店铺</span>
|
||||
<el-select
|
||||
v-model="item.kuaishouConsumeShopId"
|
||||
class="text-input"
|
||||
placeholder="请选择已配置 Cookie 的快手小店"
|
||||
@change="emit('handleKuaishouConsumeShopSelected')"
|
||||
>
|
||||
<el-option label="请选择已配置 Cookie 的快手小店" value="" />
|
||||
<el-option
|
||||
v-for="shop in getKuaishouConsumeShopOptions()"
|
||||
:key="shop.shopId"
|
||||
:label="formatKuaishouConsumeShopOption(shop)"
|
||||
:value="shop.shopId"
|
||||
/>
|
||||
</el-select>
|
||||
<small class="field-help"
|
||||
>来自"平台店铺 -> 快手小店核销"配置,保存时会自动带出店铺名。</small
|
||||
>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>核销店铺 ID</span>
|
||||
<el-input
|
||||
:model-value="item.kuaishouConsumeShopId || '-'"
|
||||
class="text-input text-input--readonly"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>核销店铺名</span>
|
||||
<el-input
|
||||
:model-value="item.kuaishouConsumeShopName || '-'"
|
||||
class="text-input text-input--readonly"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block field-wide">
|
||||
<span>备注</span>
|
||||
<el-input
|
||||
v-model="item.notes"
|
||||
class="text-input textarea-input"
|
||||
maxlength="400"
|
||||
placeholder="绑定场景、客服注意事项等"
|
||||
type="textarea"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.localId === item.localId" class="binding-inline-error">
|
||||
{{ validationState?.message }}
|
||||
</p>
|
||||
<p v-if="cloudSkuCatalogErrorMessage" class="binding-inline-error">
|
||||
{{ cloudSkuCatalogErrorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-checkbox v-model="item.enabled">启用规则</el-checkbox>
|
||||
<el-checkbox v-model="item.autoBuyEnabled">自动购买</el-checkbox>
|
||||
<el-checkbox v-model="item.autoReturnNumberAfterDispatch">发货后退号</el-checkbox>
|
||||
<el-checkbox v-model="item.autoConsumeAfterDispatch">发货后核销</el-checkbox>
|
||||
<el-button link type="danger" @click="emit('remove')">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminCloudtentaclesSkuItem, AdminKuaishouEticketShopConfigItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem, RuleFilter, ValidationState } from '../composables/types'
|
||||
|
||||
import AdminKuaishouCloudRuleCard from './AdminKuaishouCloudRuleCard.vue'
|
||||
|
||||
defineProps<{
|
||||
enabled: boolean
|
||||
saving: boolean
|
||||
cloudSkuCatalogLoading: boolean
|
||||
validationState: ValidationState
|
||||
items: EditableItem[]
|
||||
filteredItems: EditableItem[]
|
||||
ruleFilter: RuleFilter
|
||||
ruleFilterOptions: Array<{ value: RuleFilter; label: string; count: number }>
|
||||
isCollapsed: (localId: string) => boolean
|
||||
isItemComplete: (item: EditableItem) => boolean
|
||||
getCardTitle: (item: EditableItem, index: number) => string
|
||||
getCardSummary: (item: EditableItem) => string
|
||||
getRuleState: (item: EditableItem) => { label: string; tone: string }
|
||||
getExternalMatchSummary: (item: EditableItem) => string
|
||||
getCloudSkuSummary: (item: EditableItem) => string
|
||||
getConsumeShopSummary: (item: EditableItem) => string
|
||||
getCloudSkuOptions: (item: EditableItem) => AdminCloudtentaclesSkuItem[]
|
||||
formatCloudSkuOptionLabel: (sku: AdminCloudtentaclesSkuItem) => string
|
||||
getKuaishouConsumeShopOptions: () => AdminKuaishouEticketShopConfigItem[]
|
||||
formatKuaishouConsumeShopOption: (shop: AdminKuaishouEticketShopConfigItem) => string
|
||||
cloudSkuCatalogErrorMessage: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean]
|
||||
'update:ruleFilter': [value: RuleFilter]
|
||||
expandAll: []
|
||||
collapseReady: []
|
||||
refreshCloudSku: []
|
||||
addItem: []
|
||||
saveConfigs: []
|
||||
toggleCollapsed: [localId: string]
|
||||
handleCloudSkuSelected: [item: EditableItem, value: number | string | undefined]
|
||||
handleCloudSkuDropdownVisible: [item: EditableItem, visible: boolean]
|
||||
handleKuaishouConsumeShopSelected: [item: EditableItem]
|
||||
removeItem: [localId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-card table-card--dense">
|
||||
<div class="section-title-row section-title-row--tight">
|
||||
<div>
|
||||
<h3>规则编辑</h3>
|
||||
<p>每一条规则描述"快手外部商品"如何映射到"内部 SKU + cloud 资源"。</p>
|
||||
</div>
|
||||
<div class="section-header-tools">
|
||||
<div class="section-mini-stats">
|
||||
<el-button
|
||||
v-for="option in ruleFilterOptions"
|
||||
:key="option.value"
|
||||
round
|
||||
size="small"
|
||||
:type="ruleFilter === option.value ? 'primary' : 'default'"
|
||||
@click="emit('update:ruleFilter', option.value)"
|
||||
>
|
||||
{{ option.label }} {{ option.count }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="section-action-group">
|
||||
<el-button round @click="emit('expandAll')">全部展开</el-button>
|
||||
<el-checkbox :model-value="enabled" @update:model-value="emit('update:enabled', $event)">启用整条配置</el-checkbox>
|
||||
<el-button round @click="emit('collapseReady')">收起已完成</el-button>
|
||||
<el-button round :loading="cloudSkuCatalogLoading" @click="emit('refreshCloudSku')"
|
||||
>刷新 cloud SKU</el-button
|
||||
>
|
||||
<el-button round @click="emit('addItem')">新增规则</el-button>
|
||||
<el-button round type="primary" :loading="saving" @click="emit('saveConfigs')"
|
||||
>保存全部</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="validationState?.message" class="validation-banner">
|
||||
{{ validationState.message }}
|
||||
</p>
|
||||
<div v-if="items.length === 0" class="empty-inline">
|
||||
当前还没有新履约规则,先新增一条。
|
||||
</div>
|
||||
<div v-else-if="filteredItems.length === 0" class="empty-inline">
|
||||
当前筛选下没有规则。
|
||||
</div>
|
||||
|
||||
<AdminKuaishouCloudRuleCard
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.localId"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:validation-state="validationState"
|
||||
:cloud-sku-catalog-error-message="cloudSkuCatalogErrorMessage"
|
||||
:cloud-sku-catalog-loading="cloudSkuCatalogLoading"
|
||||
:is-collapsed="isCollapsed(item.localId)"
|
||||
:is-item-complete="isItemComplete"
|
||||
:get-card-title="getCardTitle"
|
||||
:get-card-summary="getCardSummary"
|
||||
:get-rule-state="getRuleState"
|
||||
:get-external-match-summary="getExternalMatchSummary"
|
||||
:get-cloud-sku-summary="getCloudSkuSummary"
|
||||
:get-consume-shop-summary="getConsumeShopSummary"
|
||||
:get-cloud-sku-options="getCloudSkuOptions"
|
||||
:format-cloud-sku-option-label="formatCloudSkuOptionLabel"
|
||||
:get-kuaishou-consume-shop-options="getKuaishouConsumeShopOptions"
|
||||
:format-kuaishou-consume-shop-option="formatKuaishouConsumeShopOption"
|
||||
@toggle-collapsed="emit('toggleCollapsed', item.localId)"
|
||||
@handle-cloud-sku-selected="emit('handleCloudSkuSelected', item, $event)"
|
||||
@handle-cloud-sku-dropdown-visible="emit('handleCloudSkuDropdownVisible', item, $event)"
|
||||
@handle-kuaishou-consume-shop-selected="emit('handleKuaishouConsumeShopSelected', item)"
|
||||
@remove="emit('removeItem', item.localId)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AdminKuaishouCloudFulfillmentItem } from '@/types/admin'
|
||||
|
||||
export type EditableItem = AdminKuaishouCloudFulfillmentItem & {
|
||||
localId: string
|
||||
}
|
||||
|
||||
export type ValidationState = {
|
||||
localId: string
|
||||
message: string
|
||||
} | null
|
||||
|
||||
export type RuleFilter = 'all' | 'draft' | 'ready' | 'disabled'
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminKuaishouCloudFulfillmentConfig,
|
||||
fetchAdminKuaishouEticketSourceConfig,
|
||||
saveAdminKuaishouCloudFulfillmentConfig,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminKuaishouCloudFulfillmentConfig,
|
||||
AdminKuaishouCloudFulfillmentItem,
|
||||
AdminKuaishouEticketShopConfigItem,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import type { EditableItem, RuleFilter, ValidationState } from './types'
|
||||
|
||||
export function useKuaishouCloudConfig() {
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const validationState = ref<ValidationState>(null)
|
||||
const filePath = ref('')
|
||||
const enabled = ref(true)
|
||||
const items = ref<EditableItem[]>([])
|
||||
const collapsedIds = ref<string[]>([])
|
||||
const ruleFilter = ref<RuleFilter>('all')
|
||||
const kuaishouConsumeShops = ref<AdminKuaishouEticketShopConfigItem[]>([])
|
||||
|
||||
// ── computed ──────────────────────────────────────────
|
||||
|
||||
const metrics = computed(() => {
|
||||
const readyCount = items.value.filter(isItemComplete).length
|
||||
const enabledCount = items.value.filter((item) => item.enabled).length
|
||||
const disabledCount = items.value.filter((item) => !item.enabled).length
|
||||
return {
|
||||
total: items.value.length,
|
||||
readyCount,
|
||||
enabledCount,
|
||||
disabledCount,
|
||||
draftCount: items.value.filter((item) => item.enabled && !isItemComplete(item)).length,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredItems = computed(() =>
|
||||
items.value.filter((item) => matchesRuleFilter(item, ruleFilter.value)),
|
||||
)
|
||||
|
||||
const ruleFilterOptions = computed<Array<{ value: RuleFilter; label: string; count: number }>>(
|
||||
() => [
|
||||
{ value: 'all', label: '全部', count: metrics.value.total },
|
||||
{ value: 'draft', label: '待完善', count: metrics.value.draftCount },
|
||||
{ value: 'ready', label: '可投产', count: metrics.value.readyCount },
|
||||
{ value: 'disabled', label: '已停用', count: metrics.value.disabledCount },
|
||||
],
|
||||
)
|
||||
|
||||
// ── item factories ───────────────────────────────────
|
||||
|
||||
function createEmptyItem(): EditableItem {
|
||||
const defaultShop = getDefaultKuaishouConsumeShop()
|
||||
return {
|
||||
localId: crypto.randomUUID(),
|
||||
id: '',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: '',
|
||||
internalSkuCode: '',
|
||||
internalSkuName: '',
|
||||
externalSkuCode: '',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
resolvedSkuName: '',
|
||||
cloudSourceKey: 'default',
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '',
|
||||
vnKey: '1',
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
autoReturnNumberAfterDispatch: false,
|
||||
autoConsumeAfterDispatch: false,
|
||||
kuaishouConsumeShopId: defaultShop?.shopId || '',
|
||||
kuaishouConsumeShopName: defaultShop?.kshopName || '',
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem {
|
||||
const matchedShop = findKuaishouConsumeShop(
|
||||
item.kuaishouConsumeShopId,
|
||||
item.kuaishouConsumeShopName,
|
||||
)
|
||||
return {
|
||||
localId: crypto.randomUUID(),
|
||||
id: item.id || crypto.randomUUID(),
|
||||
enabled: item.enabled !== false,
|
||||
priority: item.priority || 100,
|
||||
provider: item.provider || '91kaquan',
|
||||
platform: item.platform || 'kuaishou',
|
||||
shopId: item.shopId || '',
|
||||
internalSkuCode: item.internalSkuCode || '',
|
||||
internalSkuName: item.internalSkuName || '',
|
||||
externalSkuCode: item.externalSkuCode || '',
|
||||
externalItemId: item.externalItemId || '',
|
||||
externalSkuName: item.externalSkuName || '',
|
||||
resolvedSkuName: item.resolvedSkuName || '',
|
||||
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: matchedShop?.shopId || item.kuaishouConsumeShopId || '',
|
||||
kuaishouConsumeShopName: matchedShop?.kshopName || item.kuaishouConsumeShopName || '',
|
||||
notes: item.notes || '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapSaveItem(item: EditableItem): AdminKuaishouCloudFulfillmentItem {
|
||||
const matchedShop = findKuaishouConsumeShop(
|
||||
item.kuaishouConsumeShopId,
|
||||
item.kuaishouConsumeShopName,
|
||||
)
|
||||
return {
|
||||
id: item.id.trim() || item.internalSkuCode.trim() || item.localId,
|
||||
enabled: item.enabled,
|
||||
priority: Number(item.priority || 100) || 100,
|
||||
provider: item.provider.trim() || '91kaquan',
|
||||
platform: item.platform.trim() || 'kuaishou',
|
||||
shopId: item.shopId.trim(),
|
||||
internalSkuCode: item.internalSkuCode.trim(),
|
||||
internalSkuName: item.internalSkuName.trim(),
|
||||
externalSkuCode: item.externalSkuCode.trim(),
|
||||
externalItemId: item.externalItemId.trim(),
|
||||
externalSkuName: item.externalSkuName.trim(),
|
||||
resolvedSkuName: item.resolvedSkuName.trim(),
|
||||
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: String(matchedShop?.shopId || item.kuaishouConsumeShopId).trim(),
|
||||
kuaishouConsumeShopName: String(matchedShop?.kshopName || item.kuaishouConsumeShopName).trim(),
|
||||
notes: item.notes.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── validation helpers ────────────────────────────────
|
||||
|
||||
function hasExternalMatch(
|
||||
item: Pick<EditableItem, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>,
|
||||
) {
|
||||
return Boolean(
|
||||
item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function hasMeaningfulContent(item: EditableItem) {
|
||||
return Boolean(
|
||||
item.internalSkuCode.trim() ||
|
||||
item.internalSkuName.trim() ||
|
||||
item.externalSkuCode.trim() ||
|
||||
item.externalItemId.trim() ||
|
||||
item.externalSkuName.trim() ||
|
||||
item.resolvedSkuName.trim() ||
|
||||
item.cloudSourceKey.trim() !== 'default' ||
|
||||
Number(item.cloudSkuId || 0) > 0 ||
|
||||
item.cloudSkuName.trim() ||
|
||||
item.vnKey.trim() ||
|
||||
item.shopId.trim() ||
|
||||
item.notes.trim() ||
|
||||
item.provider.trim() !== '91kaquan' ||
|
||||
item.platform.trim() !== 'kuaishou' ||
|
||||
item.priority !== 100 ||
|
||||
item.autoBuyEnabled !== true ||
|
||||
item.minAssetReserve !== 0 ||
|
||||
item.autoReturnNumberAfterDispatch ||
|
||||
item.autoConsumeAfterDispatch ||
|
||||
item.kuaishouConsumeShopId.trim() ||
|
||||
item.kuaishouConsumeShopName.trim() ||
|
||||
item.enabled !== true,
|
||||
)
|
||||
}
|
||||
|
||||
function isItemComplete(item: EditableItem) {
|
||||
return Boolean(
|
||||
item.internalSkuCode.trim() &&
|
||||
Number(item.cloudSkuId || 0) > 0 &&
|
||||
(!item.autoConsumeAfterDispatch || Boolean(item.kuaishouConsumeShopId.trim())) &&
|
||||
hasExternalMatch(item),
|
||||
)
|
||||
}
|
||||
|
||||
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 (!hasExternalMatch(item)) {
|
||||
return { localId: item.localId, message: `${title} 至少填写一种外部匹配条件` }
|
||||
}
|
||||
|
||||
if (item.autoConsumeAfterDispatch && !item.kuaishouConsumeShopId.trim()) {
|
||||
return { localId: item.localId, message: `${title} 已开启发货后核销,需要选择快手核销店铺` }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── display helpers ───────────────────────────────────
|
||||
|
||||
function getCardTitle(item: EditableItem, index: number) {
|
||||
return (
|
||||
item.internalSkuName.trim() ||
|
||||
item.externalSkuName.trim() ||
|
||||
item.internalSkuCode.trim() ||
|
||||
`规则 ${index + 1}`
|
||||
)
|
||||
}
|
||||
|
||||
function getCardSummary(item: EditableItem) {
|
||||
const parts = [
|
||||
item.provider.trim() || '91kaquan',
|
||||
item.platform.trim() || 'kuaishou',
|
||||
item.shopId.trim() || '跨店铺',
|
||||
item.cloudSkuId > 0 ? `cloud#${item.cloudSkuId}` : '待填 cloud SKU',
|
||||
]
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
function getRuleState(item: EditableItem) {
|
||||
if (!item.enabled) {
|
||||
return { label: '已停用', tone: 'muted' }
|
||||
}
|
||||
|
||||
if (isItemComplete(item)) {
|
||||
return { label: '可投产', tone: 'success' }
|
||||
}
|
||||
|
||||
return { label: '草稿待完善', tone: 'warning' }
|
||||
}
|
||||
|
||||
function matchesRuleFilter(item: EditableItem, filter: RuleFilter) {
|
||||
if (filter === 'ready') {
|
||||
return item.enabled && isItemComplete(item)
|
||||
}
|
||||
|
||||
if (filter === 'draft') {
|
||||
return item.enabled && !isItemComplete(item)
|
||||
}
|
||||
|
||||
if (filter === 'disabled') {
|
||||
return !item.enabled
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getExternalMatchSummary(item: EditableItem) {
|
||||
const parts = [
|
||||
item.externalSkuCode.trim() ? `SKU ${item.externalSkuCode.trim()}` : '',
|
||||
item.externalItemId.trim() ? `Item ${item.externalItemId.trim()}` : '',
|
||||
item.externalSkuName.trim() ? item.externalSkuName.trim() : '',
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length > 0 ? parts.join(' / ') : '未设置外部命中条件'
|
||||
}
|
||||
|
||||
// ── kuaishou consume shop helpers ─────────────────────
|
||||
|
||||
function getKuaishouConsumeShopOptions() {
|
||||
return kuaishouConsumeShops.value.filter((shop) => shop.enabled !== false && shop.hasCookie)
|
||||
}
|
||||
|
||||
function findKuaishouConsumeShop(shopId = '', shopName = '') {
|
||||
const normalizedShopId = String(shopId || '').trim()
|
||||
const normalizedShopName = String(shopName || '').trim()
|
||||
return (
|
||||
getKuaishouConsumeShopOptions().find(
|
||||
(shop) =>
|
||||
(normalizedShopId && shop.shopId === normalizedShopId) ||
|
||||
(normalizedShopName && shop.kshopName === normalizedShopName),
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
function getDefaultKuaishouConsumeShop() {
|
||||
return getKuaishouConsumeShopOptions()[0] || null
|
||||
}
|
||||
|
||||
function formatKuaishouConsumeShopOption(shop: AdminKuaishouEticketShopConfigItem) {
|
||||
return `${shop.kshopName || '未命名快手小店'} · ${shop.shopId}`
|
||||
}
|
||||
|
||||
function handleKuaishouConsumeShopSelected(item: EditableItem) {
|
||||
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId)
|
||||
item.kuaishouConsumeShopName = matchedShop?.kshopName || ''
|
||||
}
|
||||
|
||||
function getConsumeShopSummary(item: EditableItem) {
|
||||
const matchedShop = findKuaishouConsumeShop(
|
||||
item.kuaishouConsumeShopId,
|
||||
item.kuaishouConsumeShopName,
|
||||
)
|
||||
const name = String(matchedShop?.kshopName || item.kuaishouConsumeShopName).trim()
|
||||
const id = String(matchedShop?.shopId || item.kuaishouConsumeShopId).trim()
|
||||
|
||||
if (name && id) {
|
||||
return `${name} · ${id}`
|
||||
}
|
||||
|
||||
return name || id || '未绑定核销店铺'
|
||||
}
|
||||
|
||||
function getCloudSkuSummary(item: EditableItem) {
|
||||
if (item.cloudSkuId > 0 && item.cloudSkuName.trim()) {
|
||||
return `${item.cloudSkuName.trim()} · #${item.cloudSkuId}`
|
||||
}
|
||||
|
||||
if (item.cloudSkuId > 0) {
|
||||
return `cloud SKU #${item.cloudSkuId}`
|
||||
}
|
||||
|
||||
return '待选择 cloud SKU'
|
||||
}
|
||||
|
||||
// ── collapse state ────────────────────────────────────
|
||||
|
||||
function isCollapsed(localId: string) {
|
||||
return collapsedIds.value.includes(localId)
|
||||
}
|
||||
|
||||
function setCollapsed(localId: string, collapsed: boolean) {
|
||||
const next = new Set(collapsedIds.value)
|
||||
if (collapsed) {
|
||||
next.add(localId)
|
||||
} else {
|
||||
next.delete(localId)
|
||||
}
|
||||
collapsedIds.value = Array.from(next)
|
||||
}
|
||||
|
||||
function toggleCollapsed(localId: string) {
|
||||
setCollapsed(localId, !isCollapsed(localId))
|
||||
}
|
||||
|
||||
function rebuildCollapsedState() {
|
||||
collapsedIds.value = items.value.filter(isItemComplete).map((item) => item.localId)
|
||||
}
|
||||
|
||||
function expandAllRules() {
|
||||
collapsedIds.value = []
|
||||
}
|
||||
|
||||
function collapseReadyRules() {
|
||||
collapsedIds.value = items.value
|
||||
.filter((item) => isItemComplete(item) || !item.enabled)
|
||||
.map((item) => item.localId)
|
||||
}
|
||||
|
||||
// ── data loading ──────────────────────────────────────
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [response, eticketResponse] = await Promise.all([
|
||||
fetchAdminKuaishouCloudFulfillmentConfig(),
|
||||
fetchAdminKuaishouEticketSourceConfig(),
|
||||
])
|
||||
kuaishouConsumeShops.value = Array.isArray(eticketResponse.data.source.shops)
|
||||
? eticketResponse.data.source.shops
|
||||
: []
|
||||
filePath.value = response.data.filePath
|
||||
enabled.value = response.data.source.enabled !== false
|
||||
items.value = (response.data.source.items || []).map(mapEditableItem)
|
||||
rebuildCollapsedState()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取新履约配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── mutation ──────────────────────────────────────────
|
||||
|
||||
function addItem() {
|
||||
validationState.value = null
|
||||
const next = createEmptyItem()
|
||||
items.value.unshift(next)
|
||||
setCollapsed(next.localId, false)
|
||||
}
|
||||
|
||||
function removeItem(localId: string) {
|
||||
if (validationState.value?.localId === localId) {
|
||||
validationState.value = null
|
||||
}
|
||||
items.value = items.value.filter((item) => item.localId !== localId)
|
||||
setCollapsed(localId, false)
|
||||
}
|
||||
|
||||
async function focusValidationTarget(localId: string) {
|
||||
if (!localId) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const card = document.querySelector<HTMLElement>(`[data-local-id="${localId}"]`)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
// ── save ──────────────────────────────────────────────
|
||||
|
||||
async function saveConfigs() {
|
||||
const invalid = items.value.reduce<ValidationState>(
|
||||
(state, item, index) => state || resolveValidation(item, index),
|
||||
null,
|
||||
)
|
||||
if (invalid) {
|
||||
validationState.value = invalid
|
||||
setCollapsed(invalid.localId, false)
|
||||
showError(invalid.message)
|
||||
await focusValidationTarget(invalid.localId)
|
||||
return
|
||||
}
|
||||
|
||||
const payloadItems = items.value.filter(hasMeaningfulContent).map(mapSaveItem)
|
||||
const payload: AdminKuaishouCloudFulfillmentConfig = {
|
||||
enabled: enabled.value,
|
||||
items: payloadItems,
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
validationState.value = null
|
||||
|
||||
try {
|
||||
const response = await saveAdminKuaishouCloudFulfillmentConfig(payload)
|
||||
filePath.value = response.data.filePath
|
||||
enabled.value = response.data.source.enabled !== false
|
||||
items.value = (response.data.source.items || []).map(mapEditableItem)
|
||||
rebuildCollapsedState()
|
||||
showSuccess('新履约配置已保存')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存新履约配置失败'
|
||||
validationState.value = { localId: '', message }
|
||||
showError(message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
loading,
|
||||
saving,
|
||||
errorMessage,
|
||||
validationState,
|
||||
filePath,
|
||||
enabled,
|
||||
items,
|
||||
collapsedIds,
|
||||
ruleFilter,
|
||||
kuaishouConsumeShops,
|
||||
// computed
|
||||
metrics,
|
||||
filteredItems,
|
||||
ruleFilterOptions,
|
||||
// factories
|
||||
createEmptyItem,
|
||||
mapEditableItem,
|
||||
// validation
|
||||
isItemComplete,
|
||||
resolveValidation,
|
||||
hasMeaningfulContent,
|
||||
// display
|
||||
getCardTitle,
|
||||
getCardSummary,
|
||||
getRuleState,
|
||||
getExternalMatchSummary,
|
||||
getCloudSkuSummary,
|
||||
getConsumeShopSummary,
|
||||
// consume shops
|
||||
getKuaishouConsumeShopOptions,
|
||||
findKuaishouConsumeShop,
|
||||
formatKuaishouConsumeShopOption,
|
||||
handleKuaishouConsumeShopSelected,
|
||||
// collapse
|
||||
isCollapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
expandAllRules,
|
||||
collapseReadyRules,
|
||||
// data
|
||||
loadConfigs,
|
||||
addItem,
|
||||
removeItem,
|
||||
saveConfigs,
|
||||
focusValidationTarget,
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { fetchAdminNinetyoneOrders } from '@/services/admin'
|
||||
import type { AdminNinetyoneOrderItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem } from './types'
|
||||
|
||||
import type { useKuaishouCloudConfig } from './useKuaishouCloudConfig'
|
||||
|
||||
export function useKuaishouCloudNinetyone(
|
||||
config: ReturnType<typeof useKuaishouCloudConfig>,
|
||||
) {
|
||||
const ninetyoneLookupLoading = ref(false)
|
||||
const ninetyoneLookupErrorMessage = ref('')
|
||||
const ninetyoneLookupResults = ref<AdminNinetyoneOrderItem[]>([])
|
||||
const importedNinetyoneOrderKeys = ref<string[]>([])
|
||||
const ninetyoneLookupForm = reactive({
|
||||
status: 'pending_config' as 'pending_config' | 'all' | 'manual_failed',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
const ninetyoneLookupMetrics = computed(() => {
|
||||
const importedCount = ninetyoneLookupResults.value.filter((item) =>
|
||||
isNinetyoneProductImported(item),
|
||||
).length
|
||||
const pendingCount = ninetyoneLookupResults.value.filter(
|
||||
(item) => item.orderStatus === 'pending_config',
|
||||
).length
|
||||
return {
|
||||
total: ninetyoneLookupResults.value.length,
|
||||
importedCount,
|
||||
availableCount: Math.max(ninetyoneLookupResults.value.length - importedCount, 0),
|
||||
pendingCount,
|
||||
}
|
||||
})
|
||||
|
||||
function createItemFromNinetyoneOrder(item: AdminNinetyoneOrderItem): EditableItem {
|
||||
const productNo = String(item.productNo || '').trim()
|
||||
const productName = String(item.productName || productNo).trim()
|
||||
const defaultShop = config.getDefaultKuaishouConsumeShop ? config.findKuaishouConsumeShop() : null
|
||||
const shopOpts = config.getKuaishouConsumeShopOptions()
|
||||
const shop = defaultShop || (shopOpts.length > 0 ? shopOpts[0] : null)
|
||||
return {
|
||||
localId: crypto.randomUUID(),
|
||||
id: '',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: item.shopId || '91kaquan',
|
||||
internalSkuCode: '',
|
||||
internalSkuName: productName,
|
||||
externalSkuCode: productNo,
|
||||
externalItemId: productNo,
|
||||
externalSkuName: productName,
|
||||
resolvedSkuName: productName,
|
||||
cloudSourceKey: 'default',
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '',
|
||||
vnKey: '1',
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
autoReturnNumberAfterDispatch: false,
|
||||
autoConsumeAfterDispatch: false,
|
||||
kuaishouConsumeShopId: shop?.shopId || '',
|
||||
kuaishouConsumeShopName: shop?.kshopName || '',
|
||||
notes: `从 91卡券订单 ${item.orderNo} 导入`,
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingItemFromNinetyoneOrder(order: AdminNinetyoneOrderItem) {
|
||||
const productNo = String(order.productNo || '').trim()
|
||||
const productName = String(order.productName || '').trim()
|
||||
const shopId = String(order.shopId || '91kaquan').trim()
|
||||
|
||||
return (
|
||||
config.items.value.find((item) => {
|
||||
const sameSource = item.provider.trim() === '91kaquan' && item.platform.trim() === 'kuaishou'
|
||||
const sameShop = !shopId || item.shopId.trim() === shopId
|
||||
const sameProductNo =
|
||||
productNo &&
|
||||
(item.externalSkuCode.trim() === productNo || item.externalItemId.trim() === productNo)
|
||||
const sameName =
|
||||
productName &&
|
||||
[item.externalSkuName, item.internalSkuName, item.resolvedSkuName].some(
|
||||
(value) => value.trim() === productName,
|
||||
)
|
||||
return sameSource && sameShop && (sameProductNo || sameName)
|
||||
}) || null
|
||||
)
|
||||
}
|
||||
|
||||
async function importNinetyoneProduct(item: AdminNinetyoneOrderItem) {
|
||||
config.validationState.value = null
|
||||
const importKey = getNinetyoneOrderImportKey(item)
|
||||
const existing = findExistingItemFromNinetyoneOrder(item)
|
||||
if (existing) {
|
||||
config.setCollapsed(existing.localId, false)
|
||||
if (!importedNinetyoneOrderKeys.value.includes(importKey)) {
|
||||
importedNinetyoneOrderKeys.value = [...importedNinetyoneOrderKeys.value, importKey]
|
||||
}
|
||||
showSuccess('已定位到现有 91卡券规则草稿,直接继续完善即可')
|
||||
await config.focusValidationTarget(existing.localId)
|
||||
return
|
||||
}
|
||||
|
||||
const next = createItemFromNinetyoneOrder(item)
|
||||
config.items.value.unshift(next)
|
||||
config.setCollapsed(next.localId, false)
|
||||
|
||||
if (!importedNinetyoneOrderKeys.value.includes(importKey)) {
|
||||
importedNinetyoneOrderKeys.value = [...importedNinetyoneOrderKeys.value, importKey]
|
||||
}
|
||||
|
||||
await config.focusValidationTarget(next.localId)
|
||||
}
|
||||
|
||||
function getNinetyoneOrderImportKey(item: AdminNinetyoneOrderItem) {
|
||||
return [item.orderNo, item.productNo, item.shopId || '91kaquan']
|
||||
.map((value) => String(value || '').trim())
|
||||
.join(':')
|
||||
}
|
||||
|
||||
function isNinetyoneProductImported(item: AdminNinetyoneOrderItem) {
|
||||
return importedNinetyoneOrderKeys.value.includes(getNinetyoneOrderImportKey(item))
|
||||
}
|
||||
|
||||
async function lookupNinetyoneProducts() {
|
||||
ninetyoneLookupLoading.value = true
|
||||
ninetyoneLookupErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminNinetyoneOrders({
|
||||
page: Number(ninetyoneLookupForm.page || 1),
|
||||
pageSize: Number(ninetyoneLookupForm.pageSize || 20),
|
||||
status: ninetyoneLookupForm.status,
|
||||
})
|
||||
ninetyoneLookupResults.value = response.data.items
|
||||
importedNinetyoneOrderKeys.value = []
|
||||
} catch (error) {
|
||||
ninetyoneLookupResults.value = []
|
||||
ninetyoneLookupErrorMessage.value =
|
||||
error instanceof Error ? error.message : '91卡券订单查询失败'
|
||||
} finally {
|
||||
ninetyoneLookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ninetyoneLookupLoading,
|
||||
ninetyoneLookupErrorMessage,
|
||||
ninetyoneLookupResults,
|
||||
importedNinetyoneOrderKeys,
|
||||
ninetyoneLookupForm,
|
||||
ninetyoneLookupMetrics,
|
||||
importNinetyoneProduct,
|
||||
isNinetyoneProductImported,
|
||||
lookupNinetyoneProducts,
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { fetchAdminCloudtentaclesSkuList } from '@/services/admin'
|
||||
import type { AdminCloudtentaclesSkuItem } from '@/types/admin'
|
||||
|
||||
import type { EditableItem } from './types'
|
||||
|
||||
export function useKuaishouCloudSku() {
|
||||
const cloudSkuCatalogLoading = ref(false)
|
||||
const cloudSkuCatalogErrorMessage = ref('')
|
||||
const cloudSkuCatalog = ref<AdminCloudtentaclesSkuItem[]>([])
|
||||
|
||||
async function ensureCloudSkuCatalogLoaded(force = false) {
|
||||
if (!force && cloudSkuCatalog.value.length > 0) {
|
||||
return cloudSkuCatalog.value
|
||||
}
|
||||
|
||||
cloudSkuCatalogLoading.value = true
|
||||
cloudSkuCatalogErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesSkuList({})
|
||||
cloudSkuCatalog.value = Array.isArray(response.data.items) ? response.data.items : []
|
||||
return cloudSkuCatalog.value
|
||||
} catch (error) {
|
||||
cloudSkuCatalog.value = []
|
||||
cloudSkuCatalogErrorMessage.value =
|
||||
error instanceof Error ? error.message : 'cloud SKU 列表查询失败'
|
||||
throw error
|
||||
} finally {
|
||||
cloudSkuCatalogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCloudSkuKeyword(item: EditableItem) {
|
||||
return (
|
||||
[item.internalSkuCode, item.internalSkuName, item.resolvedSkuName, item.externalSkuName]
|
||||
.map((value) => String(value || '').trim())
|
||||
.find(Boolean) || ''
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function scoreCloudSkuMatch(item: AdminCloudtentaclesSkuItem, keyword: string) {
|
||||
const normalizedKeyword = normalizeSearchText(keyword)
|
||||
if (!normalizedKeyword) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const name = normalizeSearchText(item.name)
|
||||
const description = normalizeSearchText(item.description)
|
||||
|
||||
if (name === normalizedKeyword) {
|
||||
return 120
|
||||
}
|
||||
|
||||
if (name.startsWith(normalizedKeyword)) {
|
||||
return 100
|
||||
}
|
||||
|
||||
if (name.includes(normalizedKeyword)) {
|
||||
return 80
|
||||
}
|
||||
|
||||
if (description.includes(normalizedKeyword)) {
|
||||
return 40
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function getCloudSkuOptions(item: EditableItem) {
|
||||
const keyword = getCloudSkuKeyword(item)
|
||||
const scored = cloudSkuCatalog.value
|
||||
.map((sku) => ({ sku, score: scoreCloudSkuMatch(sku, keyword) }))
|
||||
.filter((entry) => entry.score > 0 || entry.sku.id === Number(item.cloudSkuId || 0))
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score
|
||||
}
|
||||
|
||||
return left.sku.name.localeCompare(right.sku.name, 'zh-CN')
|
||||
})
|
||||
.map((entry) => entry.sku)
|
||||
|
||||
if (scored.length > 0) {
|
||||
return scored.slice(0, 80)
|
||||
}
|
||||
|
||||
return cloudSkuCatalog.value.slice(0, 80)
|
||||
}
|
||||
|
||||
function formatCloudSkuOptionLabel(item: AdminCloudtentaclesSkuItem) {
|
||||
const price = Number(item.price || 0)
|
||||
const inventory = Number(item.inventory || 0)
|
||||
return `${item.name} · ID ${item.id} · 库存 ${inventory} · 价格 ${price}`
|
||||
}
|
||||
|
||||
function handleCloudSkuSelected(item: EditableItem, value: number | string | undefined) {
|
||||
const skuId = Number(value || 0)
|
||||
item.cloudSkuId = Number.isInteger(skuId) && skuId > 0 ? skuId : 0
|
||||
|
||||
if (!item.cloudSkuId) {
|
||||
item.cloudSkuName = ''
|
||||
return
|
||||
}
|
||||
|
||||
const matched = cloudSkuCatalog.value.find((sku) => sku.id === item.cloudSkuId)
|
||||
item.cloudSkuName = matched?.name || item.cloudSkuName || ''
|
||||
}
|
||||
|
||||
async function handleCloudSkuDropdownVisible(item: EditableItem, visible: boolean) {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCloudSkuCatalogLoaded()
|
||||
if (item.cloudSkuId && !item.cloudSkuName) {
|
||||
handleCloudSkuSelected(item, item.cloudSkuId)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : 'cloud SKU 列表查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCloudSkuCatalog() {
|
||||
try {
|
||||
await ensureCloudSkuCatalogLoaded(true)
|
||||
showSuccess(`cloud SKU 列表已刷新,共 ${cloudSkuCatalog.value.length} 条`)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : 'cloud SKU 列表查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cloudSkuCatalogLoading,
|
||||
cloudSkuCatalogErrorMessage,
|
||||
cloudSkuCatalog,
|
||||
getCloudSkuOptions,
|
||||
formatCloudSkuOptionLabel,
|
||||
handleCloudSkuSelected,
|
||||
handleCloudSkuDropdownVisible,
|
||||
refreshCloudSkuCatalog,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
actionLoading: boolean
|
||||
canManageTaskLifecycle: boolean
|
||||
canCloseTasks: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
regenerateClaimLink: []
|
||||
confirmRole: []
|
||||
supportRedeem: []
|
||||
prepareKuaishouCloud: []
|
||||
dispatchKuaishouCloud: []
|
||||
returnKuaishouCloud: []
|
||||
markManualReview: []
|
||||
submitManualDispatch: [outcome: 'delivered' | 'failed']
|
||||
closeTask: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="action-row">
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canRetry"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
重试任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canRegenerateClaimLink"
|
||||
:disabled="!detail.operations.canRegenerateClaimLink"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('regenerateClaimLink')"
|
||||
>
|
||||
重发链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canSupportConfirmRole"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="warning"
|
||||
@click="emit('confirmRole')"
|
||||
>
|
||||
客服确认角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canSupportRedeem"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('supportRedeem')"
|
||||
>
|
||||
客服开始兑换
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('prepareKuaishouCloud')"
|
||||
>
|
||||
准备绑定资源
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canDispatchKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('dispatchKuaishouCloud')"
|
||||
>
|
||||
确认绑定完成并发货
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canReturnKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="warning"
|
||||
@click="emit('returnKuaishouCloud')"
|
||||
>
|
||||
退还号码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canMarkManualReview"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('markManualReview')"
|
||||
>
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('submitManualDispatch', 'delivered')"
|
||||
>
|
||||
人工完成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('submitManualDispatch', 'failed')"
|
||||
>
|
||||
履约失败
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canCloseTasks"
|
||||
:disabled="!detail.operations.canClose"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
@click="emit('closeTask')"
|
||||
>
|
||||
关闭任务
|
||||
</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAutoDeliveryReason, formatAutoDeliveryTrigger } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
autoDeliveryStatus: string
|
||||
autoDeliverySummary: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>咸鱼自动发货</h3>
|
||||
<p class="section-copy">
|
||||
这里展示这条任务最近一次触发咸鱼自动发货的状态、原因和接口回执摘要。
|
||||
</p>
|
||||
|
||||
<div class="redeem-summary-grid">
|
||||
<article class="redeem-summary-card">
|
||||
<span>当前状态</span>
|
||||
<div class="auto-delivery-tag-line">
|
||||
<AdminStatusTag :status="autoDeliveryStatus" />
|
||||
</div>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>触发来源</span>
|
||||
<strong>{{
|
||||
formatAutoDeliveryTrigger(detail.task.agisoAutoDelivery?.trigger || '')
|
||||
}}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>最近执行</span>
|
||||
<strong>{{ formatAdminDateTime(detail.task.agisoAutoDelivery?.updatedAt) }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>接口回执</span>
|
||||
<strong>
|
||||
{{
|
||||
detail.task.agisoAutoDelivery?.requestId ||
|
||||
(detail.task.agisoAutoDelivery?.responseStatus
|
||||
? `HTTP ${detail.task.agisoAutoDelivery.responseStatus}`
|
||||
: '-')
|
||||
}}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="auto-delivery-note">
|
||||
<p>{{ autoDeliverySummary }}</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.reason">
|
||||
原因:{{ formatAutoDeliveryReason(detail.task.agisoAutoDelivery.reason) }}
|
||||
</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.responseStatus">
|
||||
HTTP:{{ detail.task.agisoAutoDelivery.responseStatus }}
|
||||
</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.requestId">
|
||||
请求 ID:{{ detail.task.agisoAutoDelivery.requestId }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatTaskEventPayload, formatTaskEventType } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>任务事件</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr v-for="event in detail.events" :key="event.eventId">
|
||||
<th>{{ formatTaskEventType(event.eventType) }}</th>
|
||||
<td>
|
||||
<div class="event-copy">
|
||||
{{ formatTaskEventPayload(event.payload) }}
|
||||
</div>
|
||||
<div class="event-time">
|
||||
{{ formatAdminDateTime(event.createdAt) }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
import type { SummaryCard } from '../composables/types'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
isKuaishouCloudTask: boolean
|
||||
summaryCards: SummaryCard[]
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Kuaishou Cloud hero card -->
|
||||
<section v-if="isKuaishouCloudTask && detail.kuaishouCloudFulfillment" class="hero-card">
|
||||
<div class="hero-copy">
|
||||
<span class="hero-eyebrow">{{ detail.task.executorKey || 'task' }}</span>
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
订单 {{ detail.order?.platformOrderId || '-' }} · 商品
|
||||
{{ detail.orderItem?.skuName || detail.orderItem?.skuCode || '-' }}
|
||||
· 店铺
|
||||
{{ detail.order?.shopName || detail.order?.shopId || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-meta-grid">
|
||||
<article
|
||||
v-for="card in summaryCards"
|
||||
:key="card.label"
|
||||
:class="['hero-metric', `hero-metric--${card.tone}`]"
|
||||
>
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div class="hero-inline">
|
||||
<span>系统绑定 <AdminStatusTag :status="detail.task.systemBindingStatus" /></span>
|
||||
<span>完整绑定 <AdminStatusTag :status="detail.task.userBindingStatus" /></span>
|
||||
<span v-if="detail.task.lastError">最近异常 {{ detail.task.lastError }}</span>
|
||||
</div>
|
||||
<div v-if="claimUrl" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</div>
|
||||
<div v-else-if="claimLinkInvalid" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<strong>已失效</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Simple info card for other task types -->
|
||||
<section v-else class="info-card">
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
状态:<AdminStatusTag :status="detail.task.status" /> · 订单:{{
|
||||
detail.order?.platformOrderId || '-'
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
执行器:{{ detail.task.executorKey || '-' }} · 履约:<AdminStatusTag
|
||||
:status="detail.task.deliveryStatus"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
系统绑定:<AdminStatusTag :status="detail.task.systemBindingStatus" /> ·
|
||||
完整绑定:<AdminStatusTag :status="detail.task.userBindingStatus" />
|
||||
</p>
|
||||
<p>
|
||||
角色:{{ detail.task.roleName || '-' }} /
|
||||
{{ detail.task.roleId || '-' }}
|
||||
</p>
|
||||
<p>最后错误:{{ detail.task.lastError || '-' }}</p>
|
||||
<p v-if="claimUrl">
|
||||
领取链接:
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</p>
|
||||
<p v-else-if="claimLinkInvalid">领取链接:已失效</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
canViewSensitiveTaskData: boolean
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>任务信息</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<td>{{ detail.orderItem?.skuName || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<td>{{ detail.orderItem?.skuCode || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>任务执行器</th>
|
||||
<td>{{ detail.task.executorKey || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>履约状态</th>
|
||||
<td>
|
||||
<AdminStatusTag :status="detail.task.deliveryStatus || ''" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>结果代码</th>
|
||||
<td>{{ detail.task.resultCode || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>结果说明</th>
|
||||
<td>{{ detail.task.resultMessage || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>库存凭据</th>
|
||||
<td>{{ detail.inventory?.displayValue || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>凭据类型</th>
|
||||
<td>{{ detail.inventory?.credentialType || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>库存状态</th>
|
||||
<td>
|
||||
<AdminStatusTag :status="detail.inventory?.status || ''" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="canViewSensitiveTaskData">
|
||||
<th>Claim Token</th>
|
||||
<td>{{ detail.claimToken?.token || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<th>库存 / Token</th>
|
||||
<td>客服账号不可查看库存凭据与原始领取 Token</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Token 状态</th>
|
||||
<td>
|
||||
<AdminStatusTag :status="detail.claimToken?.status || ''" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Token 过期</th>
|
||||
<td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>领取链接</th>
|
||||
<td>
|
||||
<a
|
||||
v-if="claimUrl"
|
||||
class="claim-link"
|
||||
:href="claimUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ claimUrl }}</a
|
||||
>
|
||||
<span v-else-if="claimLinkInvalid">已失效</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>浏览器会话</th>
|
||||
<td>{{ detail.task.browserSessionId || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>用户打开链接</th>
|
||||
<td>{{ formatAdminDateTime(detail.task.claimedAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>用户提交绑定</th>
|
||||
<td>{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>完整绑定成功</th>
|
||||
<td>{{ formatAdminDateTime(detail.task.redeemedAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>截图路径</th>
|
||||
<td>{{ detail.task.screenshotPath || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
canViewSensitiveTaskData: boolean
|
||||
actionLoading: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
releaseBinding: [bindingId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>库存绑定</h3>
|
||||
<p class="section-copy">
|
||||
这里展示任务当前和历史绑定过的库存项,便于核对多库存任务的真实履约轨迹。
|
||||
</p>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>绑定</th>
|
||||
<th>角色</th>
|
||||
<th>库存项</th>
|
||||
<th>状态</th>
|
||||
<th>凭据内容</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="detail.inventoryBindings.length === 0">
|
||||
<td colspan="7" class="table-empty">当前任务还没有库存绑定记录</td>
|
||||
</tr>
|
||||
<tr v-for="binding in detail.inventoryBindings" :key="binding.bindingId">
|
||||
<td>
|
||||
<div>#{{ binding.bindingId }}</div>
|
||||
<div v-if="binding.isPrimary" class="binding-primary">主库存项</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ binding.roleKey || '-' }}</div>
|
||||
<div class="binding-meta">数量 {{ binding.quantity }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>ID {{ binding.inventoryItemId }}</div>
|
||||
<div>{{ binding.skuCode || '-' }}</div>
|
||||
<div class="binding-meta">
|
||||
{{ binding.credentialType || '-' }} ·
|
||||
{{ binding.batchNo || '无批次' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="status-stack">
|
||||
<AdminStatusTag :status="binding.bindingStatus" />
|
||||
<AdminStatusTag :status="binding.inventoryStatus" />
|
||||
</div>
|
||||
<div v-if="binding.invalidReason" class="binding-meta">
|
||||
{{
|
||||
binding.inventoryStatus === 'invalid'
|
||||
? '作废原因'
|
||||
: binding.inventoryStatus === 'consumed'
|
||||
? '消耗原因'
|
||||
: '处理原因'
|
||||
}}:{{ binding.invalidReason }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ canViewSensitiveTaskData ? binding.displayValue || '-' : '客服不可见' }}
|
||||
</td>
|
||||
<td>
|
||||
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
|
||||
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
|
||||
<div class="binding-meta">释放 {{ formatAdminDateTime(binding.releasedAt) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin') && binding.canRelease"
|
||||
:loading="actionLoading"
|
||||
link
|
||||
@click="emit('releaseBinding', binding.bindingId)"
|
||||
>
|
||||
释放绑定
|
||||
</el-button>
|
||||
<span v-else class="binding-meta">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { ManualDispatchForm } from '../composables/types'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
form: ManualDispatchForm
|
||||
canManageTaskLifecycle: boolean
|
||||
actionLoading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:form': [form: ManualDispatchForm]
|
||||
submit: [outcome: 'delivered' | 'failed']
|
||||
}>()
|
||||
|
||||
function updateField(field: keyof ManualDispatchForm, value: string) {
|
||||
emit('update:form', { ...props.form, [field]: value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>人工履约回写</h3>
|
||||
<div class="manual-grid">
|
||||
<label class="field-block">
|
||||
<span>外部流水 / 单号</span>
|
||||
<el-input
|
||||
:model-value="form.deliveryReference"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="120"
|
||||
placeholder="例如快递单号、平台消息回执号"
|
||||
@update:model-value="updateField('deliveryReference', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>已交付内容</span>
|
||||
<el-input
|
||||
:model-value="form.deliveredCredential"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="500"
|
||||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
@update:model-value="updateField('deliveredCredential', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-block">
|
||||
<span>处理备注</span>
|
||||
<el-input
|
||||
:model-value="form.resultMessage"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="500"
|
||||
placeholder="说明实际履约结果,失败时建议写清原因"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
@update:model-value="updateField('resultMessage', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="manual-hint">回写后会直接更新任务终态,不再走旧式领取链接流程。</p>
|
||||
<div v-if="detail.manualDispatch" class="manual-summary">
|
||||
<p>
|
||||
最近回写:<AdminStatusTag
|
||||
:status="detail.manualDispatch.outcome || detail.task.deliveryStatus"
|
||||
/>
|
||||
· {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}
|
||||
</p>
|
||||
<p>
|
||||
处理人:{{ detail.manualDispatch.completedBy?.username || '-' }} /
|
||||
{{ detail.manualDispatch.completedBy?.role || '-' }}
|
||||
</p>
|
||||
<p>流水号:{{ detail.manualDispatch.deliveryReference || '-' }}</p>
|
||||
<p>交付内容:{{ detail.manualDispatch.deliveredCredential || '-' }}</p>
|
||||
<p>备注:{{ detail.manualDispatch.resultMessage || '-' }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatRedeemOutcomeLabel, formatRedeemResolutionStatus } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>兑换重试链路</h3>
|
||||
<p class="section-copy">
|
||||
这里记录自动兑换时每次使用过的 CDK、判定结果,以及是否切换到了新的同类型凭据。
|
||||
</p>
|
||||
|
||||
<div class="redeem-summary-grid">
|
||||
<article class="redeem-summary-card">
|
||||
<span>处理结果</span>
|
||||
<strong>{{ formatRedeemResolutionStatus(detail.redeemResolution!.status) }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>任务收口状态</span>
|
||||
<strong>{{ detail.redeemResolution!.taskStatus || detail.task.status || '-' }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>更换新码次数</span>
|
||||
<strong>{{ detail.redeemResolution!.replacementCount }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>完成时间</span>
|
||||
<strong>{{ formatAdminDateTime(detail.redeemResolution!.finishedAt) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.redeemResolution!.attempts.length > 0" class="redeem-attempts">
|
||||
<article
|
||||
v-for="attempt in detail.redeemResolution!.attempts"
|
||||
:key="`${attempt.attempt}-${attempt.inventoryItemId || 'na'}`"
|
||||
class="redeem-attempt-card"
|
||||
>
|
||||
<div class="redeem-attempt-head">
|
||||
<strong>第 {{ attempt.attempt }} 次尝试</strong>
|
||||
<span class="redeem-outcome">{{ formatRedeemOutcomeLabel(attempt.outcome) }}</span>
|
||||
</div>
|
||||
<div class="redeem-attempt-grid">
|
||||
<div>
|
||||
<span>库存项</span>
|
||||
<strong>{{ attempt.inventoryItemId ? `#${attempt.inventoryItemId}` : '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>凭据</span>
|
||||
<strong>{{ attempt.codeMasked || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>类型</span>
|
||||
<strong>{{ attempt.credentialType || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>结果码</span>
|
||||
<strong>{{ attempt.resultCode || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="redeem-attempt-copy">
|
||||
{{ attempt.resultMessage || '-' }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
screenshotPreviewUrl: string
|
||||
sectionTitle: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-card">
|
||||
<h3>{{ sectionTitle }}</h3>
|
||||
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取。</p>
|
||||
<a
|
||||
v-else
|
||||
class="screenshot-link"
|
||||
:href="screenshotPreviewUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>打开原图</a
|
||||
>
|
||||
<img
|
||||
v-if="screenshotPreviewUrl"
|
||||
class="screenshot-preview"
|
||||
:src="screenshotPreviewUrl"
|
||||
:alt="sectionTitle"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
export type KuaishouCloudFlow = NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||
|
||||
export type SummaryCard = {
|
||||
label: string
|
||||
value: string
|
||||
tone: 'blue' | 'green' | 'amber' | 'slate'
|
||||
}
|
||||
|
||||
export type RoleInfoEntry = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ChecklistItem = {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type ManualDispatchForm = {
|
||||
deliveryReference: string
|
||||
deliveredCredential: string
|
||||
resultMessage: string
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
fetchAdminTaskScreenshot,
|
||||
fetchAdminTaskDetail,
|
||||
markAdminTaskManualReview,
|
||||
redeemAdminTaskAssisted,
|
||||
regenerateAdminTaskClaimLink,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
retryAdminTask,
|
||||
confirmAdminTaskAssistedRole,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import type { ManualDispatchForm } from './types'
|
||||
|
||||
export function useAdminTaskDetail() {
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<AdminTaskDetail | null>(null)
|
||||
const lastClaimUrl = ref('')
|
||||
const screenshotPreviewUrl = ref('')
|
||||
const manualDispatchForm = ref<ManualDispatchForm>({
|
||||
deliveryReference: '',
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
|
||||
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
|
||||
const canCloseTasks = computed(() => hasAdminRole('support'))
|
||||
const canViewSensitiveTaskData = computed(() =>
|
||||
Boolean(detail.value?.operations.canViewSensitiveTaskData),
|
||||
)
|
||||
|
||||
const claimUrl = computed(() => {
|
||||
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
||||
const taskStatus = String(detail.value?.task.status || '').trim()
|
||||
|
||||
if (tokenStatus !== 'active' || ['closed', 'expired'].includes(taskStatus)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return lastClaimUrl.value || detail.value?.claimToken?.claimUrl || ''
|
||||
})
|
||||
|
||||
const claimLinkInvalid = computed(() => {
|
||||
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
||||
return Boolean(detail.value?.claimToken?.claimUrl) && tokenStatus && tokenStatus !== 'active'
|
||||
})
|
||||
|
||||
const screenshotSectionTitle = computed(() => {
|
||||
if (detail.value?.task.status === 'redeemed') {
|
||||
return '结果截图'
|
||||
}
|
||||
|
||||
return detail.value?.review.required ? '客服复核截图' : '结果截图'
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
|
||||
detail.value = response.data
|
||||
syncManualDispatchForm(response.data)
|
||||
await loadScreenshotPreview(response.data)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) {
|
||||
try {
|
||||
await showConfirm(confirmText, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await action()
|
||||
if (response.data.claimUrl) {
|
||||
lastClaimUrl.value = response.data.claimUrl
|
||||
}
|
||||
showSuccess(successMessage)
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview(taskDetail: AdminTaskDetail) {
|
||||
clearScreenshotPreview()
|
||||
|
||||
if (!taskDetail.screenshotUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await fetchAdminTaskScreenshot(taskDetail.task.taskId)
|
||||
screenshotPreviewUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
if (screenshotPreviewUrl.value) {
|
||||
URL.revokeObjectURL(screenshotPreviewUrl.value)
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
|
||||
manualDispatchForm.value = {
|
||||
deliveryReference: taskDetail.manualDispatch?.deliveryReference || '',
|
||||
deliveredCredential: taskDetail.manualDispatch?.deliveredCredential || '',
|
||||
resultMessage:
|
||||
taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
|
||||
const confirmText =
|
||||
outcome === 'failed'
|
||||
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
|
||||
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
|
||||
|
||||
await runAction(
|
||||
() =>
|
||||
completeAdminTaskManualDispatch(detail.value!.task.taskId, {
|
||||
outcome,
|
||||
resultMessage: manualDispatchForm.value.resultMessage,
|
||||
deliveryReference: manualDispatchForm.value.deliveryReference,
|
||||
deliveredCredential: manualDispatchForm.value.deliveredCredential,
|
||||
}),
|
||||
actionLabel,
|
||||
confirmText,
|
||||
)
|
||||
}
|
||||
|
||||
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 resolveTaskAutoDeliveryStatus(taskDetail: AdminTaskDetail) {
|
||||
if (taskDetail.task.agisoAutoDelivery?.status) {
|
||||
return taskDetail.task.agisoAutoDelivery.status
|
||||
}
|
||||
|
||||
return taskDetail.task.deliveryStatus === 'delivered' ? 'pending' : 'waiting'
|
||||
}
|
||||
|
||||
function resolveTaskAutoDeliverySummary(taskDetail: AdminTaskDetail) {
|
||||
const autoDelivery = taskDetail.task.agisoAutoDelivery
|
||||
|
||||
if (autoDelivery?.errorMessage) {
|
||||
return autoDelivery.errorMessage
|
||||
}
|
||||
|
||||
if (autoDelivery?.reason) {
|
||||
return autoDelivery.reason
|
||||
}
|
||||
|
||||
return taskDetail.task.deliveryStatus === 'delivered'
|
||||
? '当前任务已交付,等待订单下其他任务完成后统一自动发货'
|
||||
: '当前任务尚未完成交付,暂不会触发自动发货'
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearScreenshotPreview)
|
||||
|
||||
return {
|
||||
loading,
|
||||
actionLoading,
|
||||
errorMessage,
|
||||
detail,
|
||||
screenshotPreviewUrl,
|
||||
manualDispatchForm,
|
||||
canManageTaskLifecycle,
|
||||
canCloseTasks,
|
||||
canViewSensitiveTaskData,
|
||||
claimUrl,
|
||||
claimLinkInvalid,
|
||||
screenshotSectionTitle,
|
||||
loadDetail,
|
||||
runAction,
|
||||
submitManualDispatch,
|
||||
copyText,
|
||||
openExternalLink,
|
||||
resolveTaskAutoDeliveryStatus,
|
||||
resolveTaskAutoDeliverySummary,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatKuaishouRoleInfoLabel } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { ChecklistItem, RoleInfoEntry, SummaryCard } from './types'
|
||||
|
||||
export function useAdminTaskKuaishouCloud(
|
||||
detail: Ref<AdminTaskDetail | null>,
|
||||
runAction: (
|
||||
action: () => Promise<{ data: any }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const kuaishouCloudFlow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
||||
const kuaishouCloudRole = computed(() => kuaishouCloudFlow.value?.role || null)
|
||||
const isKuaishouCloudTask = computed(
|
||||
() => detail.value?.task.executorKey === 'kuaishou_ct_assisted',
|
||||
)
|
||||
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
if (!detail.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '任务状态',
|
||||
value: detail.value.task.status || '-',
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
label: '履约状态',
|
||||
value: detail.value.task.deliveryStatus || '-',
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
label: '当前角色',
|
||||
value: kuaishouCloudRole.value?.name || detail.value.task.roleName || '-',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: '角色 ID',
|
||||
value: kuaishouCloudRole.value?.rid || detail.value.task.roleId || '-',
|
||||
tone: 'slate',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const kuaishouRoleInfoEntries = computed<RoleInfoEntry[]>(() => {
|
||||
const rawInfo = kuaishouCloudRole.value?.rawInfo
|
||||
if (!rawInfo) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(rawInfo)
|
||||
.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== '')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: formatKuaishouRoleInfoLabel(key),
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
}))
|
||||
})
|
||||
|
||||
const kuaishouCloudChecklist = computed<ChecklistItem[]>(() => {
|
||||
const flow = kuaishouCloudFlow.value
|
||||
const role = kuaishouCloudRole.value
|
||||
if (!flow) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'ticket',
|
||||
label: '核销码校验',
|
||||
status: flow.ticket.status || 'pending',
|
||||
detail: flow.ticket.verifiedAt
|
||||
? `已于 ${formatAdminDateTime(flow.ticket.verifiedAt)} 校验`
|
||||
: '等待客户提交并校验核销码',
|
||||
},
|
||||
{
|
||||
key: 'bind',
|
||||
label: '绑定资源',
|
||||
status: flow.binding.prepareStatus || 'pending',
|
||||
detail: flow.binding.bindPreparedAt
|
||||
? `绑定资源已准备,VN ${flow.binding.vnId || '-'}`
|
||||
: '等待准备 Cloud 绑定资源',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色识别',
|
||||
status: role?.status || 'pending',
|
||||
detail:
|
||||
role?.name || role?.rid
|
||||
? `${role?.name || '-'} / ${role?.rid || '-'}`
|
||||
: '客户绑定后刷新角色信息',
|
||||
},
|
||||
{
|
||||
key: 'dispatch',
|
||||
label: '发货执行',
|
||||
status: flow.dispatch.status || 'pending',
|
||||
detail: flow.dispatch.dispatchAt
|
||||
? `已于 ${formatAdminDateTime(flow.dispatch.dispatchAt)} 发货`
|
||||
: '等待客服确认绑定并发货',
|
||||
},
|
||||
{
|
||||
key: 'return',
|
||||
label: '退还号码',
|
||||
status: flow.returnNumber.status || 'pending',
|
||||
detail: flow.returnNumber.returnedAt
|
||||
? `已于 ${formatAdminDateTime(flow.returnNumber.returnedAt)} 退号`
|
||||
: '发货后执行退还号码',
|
||||
},
|
||||
{
|
||||
key: 'consume',
|
||||
label: '快手核销',
|
||||
status: flow.consume.status || 'pending',
|
||||
detail: flow.consume.consumedAt
|
||||
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成核销`
|
||||
: flow.consume.errorMessage || '等待退号后核销收口',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
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),
|
||||
'已完成绑定确认并发货',
|
||||
`确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudRefreshRoleInfo() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => refreshAdminTaskKuaishouCloudRoleInfo(detail.value!.task.taskId),
|
||||
'角色信息已刷新',
|
||||
`确认刷新任务 ${detail.value.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 cloudtentacles 的绑定结果。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudReturnNumber() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => returnNumberAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'号码已退还',
|
||||
`确认退还任务 ${detail.value.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
kuaishouCloudFlow,
|
||||
kuaishouCloudRole,
|
||||
isKuaishouCloudTask,
|
||||
summaryCards,
|
||||
kuaishouRoleInfoEntries,
|
||||
kuaishouCloudChecklist,
|
||||
submitKuaishouCloudPrepare,
|
||||
submitKuaishouCloudDispatch,
|
||||
submitKuaishouCloudRefreshRoleInfo,
|
||||
submitKuaishouCloudReturnNumber,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user