From a77198b21893b0949f636d1e3fd4db4d183f4a6c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 17 May 2026 09:26:13 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E4=BC=98=E5=8C=96=20-=20=E6=B6=88=E7=81=AD?= =?UTF-8?q?=E5=B7=A8=E7=9F=B3=E6=96=87=E4=BB=B6=EF=BC=8C=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 验证 --- apps/frontend/src/composables/shared/index.ts | 17 + .../composables/shared/mergeSessionDetail.ts | 82 ++ .../composables/shared/sessionConstants.ts | 7 + .../composables/shared/useSessionPolling.ts | 93 ++ .../shared/useSessionPresentation.ts | 244 ++++ .../composables/shared/useSessionQrDisplay.ts | 63 + .../useTencentBrowserSessionPolling.ts | 13 +- .../composables/useAdminManualRedeemPage.ts | 498 +++---- apps/frontend/src/composables/useClaimPage.ts | 443 ++---- .../useTencentBrowserSessionPage.ts | 12 - apps/frontend/src/lib/http.ts | 21 +- .../src/services/admin/platform-config.ts | 476 ------- .../services/admin/platform-config/agiso.ts | 26 + .../admin/platform-config/cloudtentacles.ts | 256 ++++ .../platform-config/fulfillment-bindings.ts | 35 + .../services/admin/platform-config/index.ts | 8 + .../kuaishou-cloud-fulfillment.ts | 18 + .../admin/platform-config/kuaishou-eticket.ts | 60 + .../admin/platform-config/ninetyone.ts | 42 + .../admin/platform-config/notifications.ts | 23 + .../admin/platform-config/scheduled-jobs.ts | 20 + apps/frontend/src/services/tencent/redeem.ts | 2 +- apps/frontend/src/types/admin.ts | 924 ------------- apps/frontend/src/types/admin/audit-logs.ts | 13 + apps/frontend/src/types/admin/auth.ts | 24 + apps/frontend/src/types/admin/common.ts | 5 + apps/frontend/src/types/admin/dashboard.ts | 8 + apps/frontend/src/types/admin/fulfillment.ts | 27 + apps/frontend/src/types/admin/index.ts | 98 ++ apps/frontend/src/types/admin/inventory.ts | 27 + .../frontend/src/types/admin/manual-redeem.ts | 9 + .../src/types/admin/message-deliveries.ts | 24 + apps/frontend/src/types/admin/orders.ts | 102 ++ .../src/types/admin/platform-config/agiso.ts | 26 + .../admin/platform-config/cloudtentacles.ts | 79 ++ .../platform-config/fulfillment-bindings.ts | 36 + .../src/types/admin/platform-config/index.ts | 54 + .../kuaishou-cloud-fulfillment.ts | 30 + .../admin/platform-config/kuaishou-eticket.ts | 83 ++ .../types/admin/platform-config/ninetyone.ts | 32 + .../admin/platform-config/notifications.ts | 37 + .../admin/platform-config/scheduled-jobs.ts | 35 + apps/frontend/src/types/admin/tasks.ts | 246 ++++ apps/frontend/src/types/admin/users.ts | 11 + .../src/types/admin/webhook-events.ts | 60 + .../AdminFulfillmentBindingsView.vue | 989 ------------- .../AdminFulfillmentConfigHubView.vue | 4 +- .../AdminKuaishouCloudFulfillmentView.vue | 1224 ----------------- .../AdminFulfillmentBindings.css | 2 +- .../bindings/AdminFulfillmentBindingsView.vue | 76 + .../components/AdminBindingRuleCard.vue | 225 +++ .../components/AdminBindingsLookupSection.vue | 154 +++ .../AdminBindingsObservedSection.vue | 82 ++ .../AdminBindingsOverviewSection.vue | 53 + .../components/AdminBindingsRuleSection.vue | 87 ++ .../fulfillment/bindings/composables/types.ts | 66 + .../composables/useFulfillmentBindings.ts | 453 ++++++ .../useFulfillmentBindingsLookup.ts | 71 + .../AdminKuaishouCloudFulfillment.css | 2 +- .../AdminKuaishouCloudFulfillmentView.vue | 86 ++ .../AdminKuaishouCloudNinetyoneSection.vue | 149 ++ .../AdminKuaishouCloudOverviewSection.vue | 50 + .../components/AdminKuaishouCloudRuleCard.vue | 299 ++++ .../AdminKuaishouCloudRuleSection.vue | 120 ++ .../kuaishou-cloud/composables/types.ts | 12 + .../composables/useKuaishouCloudConfig.ts | 527 +++++++ .../composables/useKuaishouCloudNinetyone.ts | 162 +++ .../composables/useKuaishouCloudSku.ts | 152 ++ .../views/admin/tasks/AdminTaskDetailView.vue | 1061 ++------------ .../tasks/components/AdminTaskActionBar.vue | 134 ++ .../AdminTaskAutoDeliverySection.vue | 68 + .../components/AdminTaskEventsSection.vue | 34 + .../tasks/components/AdminTaskHeroSection.vue | 86 ++ .../tasks/components/AdminTaskInfoSection.vue | 119 ++ .../AdminTaskInventoryBindingsSection.vue | 99 ++ .../AdminTaskManualDispatchSection.vue | 85 ++ .../AdminTaskRedeemResolutionSection.vue | 75 + .../components/AdminTaskScreenshotSection.vue | 31 + .../views/admin/tasks/composables/types.ts | 28 + .../tasks/composables/useAdminTaskDetail.ts | 239 ++++ .../composables/useAdminTaskKuaishouCloud.ts | 193 +++ 81 files changed, 6498 insertions(+), 5248 deletions(-) create mode 100644 apps/frontend/src/composables/shared/index.ts create mode 100644 apps/frontend/src/composables/shared/mergeSessionDetail.ts create mode 100644 apps/frontend/src/composables/shared/sessionConstants.ts create mode 100644 apps/frontend/src/composables/shared/useSessionPolling.ts create mode 100644 apps/frontend/src/composables/shared/useSessionPresentation.ts create mode 100644 apps/frontend/src/composables/shared/useSessionQrDisplay.ts delete mode 100644 apps/frontend/src/services/admin/platform-config.ts create mode 100644 apps/frontend/src/services/admin/platform-config/agiso.ts create mode 100644 apps/frontend/src/services/admin/platform-config/cloudtentacles.ts create mode 100644 apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts create mode 100644 apps/frontend/src/services/admin/platform-config/index.ts create mode 100644 apps/frontend/src/services/admin/platform-config/kuaishou-cloud-fulfillment.ts create mode 100644 apps/frontend/src/services/admin/platform-config/kuaishou-eticket.ts create mode 100644 apps/frontend/src/services/admin/platform-config/ninetyone.ts create mode 100644 apps/frontend/src/services/admin/platform-config/notifications.ts create mode 100644 apps/frontend/src/services/admin/platform-config/scheduled-jobs.ts delete mode 100644 apps/frontend/src/types/admin.ts create mode 100644 apps/frontend/src/types/admin/audit-logs.ts create mode 100644 apps/frontend/src/types/admin/auth.ts create mode 100644 apps/frontend/src/types/admin/common.ts create mode 100644 apps/frontend/src/types/admin/dashboard.ts create mode 100644 apps/frontend/src/types/admin/fulfillment.ts create mode 100644 apps/frontend/src/types/admin/index.ts create mode 100644 apps/frontend/src/types/admin/inventory.ts create mode 100644 apps/frontend/src/types/admin/manual-redeem.ts create mode 100644 apps/frontend/src/types/admin/message-deliveries.ts create mode 100644 apps/frontend/src/types/admin/orders.ts create mode 100644 apps/frontend/src/types/admin/platform-config/agiso.ts create mode 100644 apps/frontend/src/types/admin/platform-config/cloudtentacles.ts create mode 100644 apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts create mode 100644 apps/frontend/src/types/admin/platform-config/index.ts create mode 100644 apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts create mode 100644 apps/frontend/src/types/admin/platform-config/kuaishou-eticket.ts create mode 100644 apps/frontend/src/types/admin/platform-config/ninetyone.ts create mode 100644 apps/frontend/src/types/admin/platform-config/notifications.ts create mode 100644 apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts create mode 100644 apps/frontend/src/types/admin/tasks.ts create mode 100644 apps/frontend/src/types/admin/users.ts create mode 100644 apps/frontend/src/types/admin/webhook-events.ts delete mode 100644 apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindingsView.vue delete mode 100644 apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillmentView.vue rename apps/frontend/src/views/admin/fulfillment/{ => bindings}/AdminFulfillmentBindings.css (99%) create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts create mode 100644 apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts rename apps/frontend/src/views/admin/fulfillment/{ => kuaishou-cloud}/AdminKuaishouCloudFulfillment.css (99%) create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudNinetyoneSection.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudOverviewSection.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleSection.vue create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/types.ts create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudConfig.ts create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudNinetyone.ts create mode 100644 apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudSku.ts create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskActionBar.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskAutoDeliverySection.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskEventsSection.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskHeroSection.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskInfoSection.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskInventoryBindingsSection.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskManualDispatchSection.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskRedeemResolutionSection.vue create mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskScreenshotSection.vue create mode 100644 apps/frontend/src/views/admin/tasks/composables/types.ts create mode 100644 apps/frontend/src/views/admin/tasks/composables/useAdminTaskDetail.ts create mode 100644 apps/frontend/src/views/admin/tasks/composables/useAdminTaskKuaishouCloud.ts diff --git a/apps/frontend/src/composables/shared/index.ts b/apps/frontend/src/composables/shared/index.ts new file mode 100644 index 00000000..ab934b7f --- /dev/null +++ b/apps/frontend/src/composables/shared/index.ts @@ -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' diff --git a/apps/frontend/src/composables/shared/mergeSessionDetail.ts b/apps/frontend/src/composables/shared/mergeSessionDetail.ts new file mode 100644 index 00000000..d476a9df --- /dev/null +++ b/apps/frontend/src/composables/shared/mergeSessionDetail.ts @@ -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( + current: T | null, + next: T, + extraSessionFields?: ( + currentSession: NonNullable, + nextSession: NonNullable, + ) => Partial>, +): 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 + + const extras = extraSessionFields + ? extraSessionFields(current.session as NonNullable, next.session as NonNullable) + : {} + + 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(detail: T | null): boolean { + if (!detail?.session?.sessionId) { + return false + } + + return ACTIVE_TASK_STATUSES.has(detail.task.status as ClaimTaskStatus) +} diff --git a/apps/frontend/src/composables/shared/sessionConstants.ts b/apps/frontend/src/composables/shared/sessionConstants.ts new file mode 100644 index 00000000..b328b05a --- /dev/null +++ b/apps/frontend/src/composables/shared/sessionConstants.ts @@ -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(['claimed', 'role_confirmed', 'redeeming']) diff --git a/apps/frontend/src/composables/shared/useSessionPolling.ts b/apps/frontend/src/composables/shared/useSessionPolling.ts new file mode 100644 index 00000000..c4399c11 --- /dev/null +++ b/apps/frontend/src/composables/shared/useSessionPolling.ts @@ -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 { + /** Whether there is an active session – used as a guard inside the poll loop. */ + hasSession: ComputedRef + /** Called on each poll tick (typically refreshSessionSummary). */ + onPollTick: () => Promise + /** Optional: determines whether polling should continue. Defaults to checking hasSession. */ + shouldKeepPolling?: ComputedRef + /** Context-specific warning message prefix when polling fails repeatedly. */ + pollWarningPrefix: string +} + +export function useSessionPolling(options: UseSessionPollingOptions) { + const { hasSession, onPollTick, pollWarningPrefix } = options + + const pollWarningMessage = ref('') + + let pollTimer: ReturnType | 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, + } +} diff --git a/apps/frontend/src/composables/shared/useSessionPresentation.ts b/apps/frontend/src/composables/shared/useSessionPresentation.ts new file mode 100644 index 00000000..71b164b3 --- /dev/null +++ b/apps/frontend/src/composables/shared/useSessionPresentation.ts @@ -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( + 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 + /** Context-specific default values for roleFacts */ + roleFactDefaults: { + nickname: string + } +} + +export function useSessionRoleFacts(options: UseSessionPresentationOptions) { + const { activityInfo, statusLabel, roleFactDefaults } = options + + const roleFacts = computed(() => [ + { + 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 + } +} + +export function useSessionResultFacts(options: UseSessionResultFactsOptions) { + const { order, orderItem, result, resultFactOverrides } = options + + const resultFacts = computed(() => [ + { + 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 + hasSession: ComputedRef + loginTypeLabel: ComputedRef + redeemLoading: ComputedRef + roleReady: ComputedRef + roleConfirmed: ComputedRef + sessionNotice: ComputedRef + context: 'claim' | 'admin' + /** Claim-specific fields */ + tokenStatus?: ComputedRef + requiresSupportReview?: ComputedRef +}): 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 '' +} diff --git a/apps/frontend/src/composables/shared/useSessionQrDisplay.ts b/apps/frontend/src/composables/shared/useSessionQrDisplay.ts new file mode 100644 index 00000000..364fdcad --- /dev/null +++ b/apps/frontend/src/composables/shared/useSessionQrDisplay.ts @@ -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, + } +} diff --git a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts index 1068a0fb..4064734e 100644 --- a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts +++ b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts @@ -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() } diff --git a/apps/frontend/src/composables/useAdminManualRedeemPage.ts b/apps/frontend/src/composables/useAdminManualRedeemPage.ts index e2c96ee0..4c4948a8 100644 --- a/apps/frontend/src/composables/useAdminManualRedeemPage.ts +++ b/apps/frontend/src/composables/useAdminManualRedeemPage.ts @@ -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([]) + + // ── Core state ──────────────────────────────────────────────────── const detail = ref(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(DEFAULT_LOGIN_TYPE) + + // ── Screenshot state (admin-only) ────────────────────────────────── const screenshotBlob = ref(null) const screenshotUrl = ref('') const screenshotLoading = ref(false) - const loginType = ref(DEFAULT_LOGIN_TYPE) - - let pollTimer: ReturnType | 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) -} diff --git a/apps/frontend/src/composables/useClaimPage.ts b/apps/frontend/src/composables/useClaimPage.ts index ed2239c7..e698b826 100644 --- a/apps/frontend/src/composables/useClaimPage.ts +++ b/apps/frontend/src/composables/useClaimPage.ts @@ -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(['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(DEFAULT_LOGIN_TYPE) const detail = ref(null) const roleConfirmed = ref(false) - const pollWarningMessage = ref('') - const qrImageNaturalWidth = ref(0) - - let pollTimer: ReturnType | 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,78 +115,46 @@ 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 && - !task.value.requiresSupportReview && - hasSession.value && - roleReady.value && - task.value.status === 'claimed', + !task.value.requiresSupportReview && + hasSession.value && + roleReady.value && + task.value.status === 'claimed', ), ) const canRedeem = computed(() => Boolean( task.value && - !task.value.requiresSupportReview && - hasSession.value && - roleConfirmed.value && - !redeemLoading.value && - (task.value.status === 'role_confirmed' || task.value.status === 'redeeming'), + !task.value.requiresSupportReview && + hasSession.value && + roleConfirmed.value && + !redeemLoading.value && + (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( () => [ @@ -189,28 +181,34 @@ export function useClaimPage(token: string) { () => { roleConfirmed.value = Boolean( task.value?.status === 'role_confirmed' || - task.value?.status === 'redeeming' || - task.value?.status === 'redeemed', + task.value?.status === 'redeeming' || + task.value?.status === 'redeemed', ) }, { 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) -} diff --git a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts index e4c4bad7..9070d492 100644 --- a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts +++ b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts @@ -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) { diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index ff572d83..13e55903 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -17,7 +17,22 @@ const http = axios.create({ }) http.interceptors.response.use( - (response) => response.data, + (response) => { + const data = response.data as ApiEnvelope + + // 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) { }) } -export function apiPost(url: string, data?: Record) { - return request({ method: 'POST', url, data }) +export function apiPost(url: string, data?: unknown) { + return request({ method: 'POST', url, data: data as Record }) } export function apiDelete(url: string) { diff --git a/apps/frontend/src/services/admin/platform-config.ts b/apps/frontend/src/services/admin/platform-config.ts deleted file mode 100644 index 129487ec..00000000 --- a/apps/frontend/src/services/admin/platform-config.ts +++ /dev/null @@ -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> -}) { - 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) -} - -export function testAdminNotification(payload: { title?: string; body?: string; url?: string }) { - return apiPost( - '/api/v1/admin/platform-config/notifications/test', - payload, - ) -} - -export function fetchAdminScheduledJobsConfig() { - return apiGet('/api/v1/admin/platform-config/scheduled-jobs') -} - -export function saveAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig) { - return apiPost( - '/api/v1/admin/platform-config/scheduled-jobs', - payload as unknown as Record, - ) -} - -export function runAdminScheduledJob(jobId: string) { - return apiPost<{ - result: Record - 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( - `/api/v1/admin/platform-config/ninetyone/orders${queryString ? `?${queryString}` : ''}`, - ) -} - -export function retryAdminNinetyoneOrder(orderId: number | string) { - return apiPost( - `/api/v1/admin/platform-config/ninetyone/orders/${orderId}/retry`, - {}, - ) -} - -export function failAdminNinetyoneOrder(orderId: number | string, payload: { reason?: string }) { - return apiPost( - `/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, - ) -} - -export function queryAdminKuaishouEticketDetail(payload: { - baseUrl?: string - shopId?: string - cookie?: string - eTicketId: string -}) { - return apiPost( - '/api/v1/admin/platform-config/kuaishou-eticket/query-detail', - payload, - ) -} - -export function queryAdminKuaishouEticketShopInfo(payload: { - baseUrl?: string - shopId?: string - cookie?: string -}) { - return apiPost( - '/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( - '/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, - ) -} - -export function sendAdminCloudtentaclesSmsCode(payload: { - baseUrl?: string - username?: string - phone?: string - deviceId?: string - deviceType?: number -}) { - return apiPost( - '/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( - '/api/v1/admin/platform-config/cloudtentacles/test-login', - payload, - ) -} - -export function validateAdminCloudtentaclesSession(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number -}) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles/validate-session', - payload, - ) -} - -export function fetchAdminCloudtentaclesAsset(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/asset', - payload, - ) -} - -export function fetchAdminCloudtentaclesCategories(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/categories', - payload, - ) -} - -export function fetchAdminCloudtentaclesSkuList(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number -}) { - return apiPost( - '/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>( - '/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>( - '/api/v1/admin/platform-config/cloudtentacles/sku/use', - payload, - ) -} - -export function fetchAdminCloudtentaclesKnapsack(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/knapsack', - payload, - ) -} - -export function fetchAdminCloudtentaclesVnList(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string -}) { - return apiPost>( - '/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>( - '/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>( - '/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>( - '/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>( - '/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>( - '/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>( - '/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>( - '/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( - '/api/v1/admin/platform-config/fulfillment-bindings/lookup-order', - payload, - ) -} - -export function saveAdminFulfillmentBindingConfigs(payload: { - bindings: Array> -}) { - 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, - ) -} diff --git a/apps/frontend/src/services/admin/platform-config/agiso.ts b/apps/frontend/src/services/admin/platform-config/agiso.ts new file mode 100644 index 00000000..cc7ee38e --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/agiso.ts @@ -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> +}) { + return apiPost<{ + filePath: string + defaults: AdminAgisoMessagingDefaults + shops: AdminAgisoShopConfigItem[] + }>('/api/v1/admin/platform-config/agiso-shops', payload) +} diff --git a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts new file mode 100644 index 00000000..24faaea9 --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts @@ -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( + '/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( + '/api/v1/admin/platform-config/cloudtentacles/test-login', + payload, + ) +} + +export function validateAdminCloudtentaclesSession(payload: { + baseUrl?: string + token?: string + deviceId?: string + deviceType?: number +}) { + return apiPost( + '/api/v1/admin/platform-config/cloudtentacles/validate-session', + payload, + ) +} + +export function fetchAdminCloudtentaclesAsset(payload: { + baseUrl?: string + token?: string + deviceId?: string + deviceType?: number +}) { + return apiPost>( + '/api/v1/admin/platform-config/cloudtentacles/asset', + payload, + ) +} + +export function fetchAdminCloudtentaclesCategories(payload: { + baseUrl?: string + token?: string + deviceId?: string + deviceType?: number +}) { + return apiPost>( + '/api/v1/admin/platform-config/cloudtentacles/categories', + payload, + ) +} + +export function fetchAdminCloudtentaclesSkuList(payload: { + baseUrl?: string + token?: string + deviceId?: string + deviceType?: number +}) { + return apiPost( + '/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>( + '/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>( + '/api/v1/admin/platform-config/cloudtentacles/sku/use', + payload, + ) +} + +export function fetchAdminCloudtentaclesKnapsack(payload: { + baseUrl?: string + token?: string + deviceId?: string + deviceType?: number +}) { + return apiPost>( + '/api/v1/admin/platform-config/cloudtentacles/knapsack', + payload, + ) +} + +export function fetchAdminCloudtentaclesVnList(payload: { + baseUrl?: string + token?: string + deviceId?: string + deviceType?: number + key?: string +}) { + return apiPost>( + '/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>( + '/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>( + '/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>( + '/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>( + '/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>( + '/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>( + '/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>( + '/api/v1/admin/platform-config/cloudtentacles/debug/full-flow', + payload, + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts b/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts new file mode 100644 index 00000000..918f347b --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts @@ -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( + '/api/v1/admin/platform-config/fulfillment-bindings/lookup-order', + payload, + ) +} + +export function saveAdminFulfillmentBindingConfigs(payload: { + bindings: Array> +}) { + return apiPost<{ + filePath: string + bindings: AdminFulfillmentBindingConfigItem[] + }>('/api/v1/admin/platform-config/fulfillment-bindings', payload) +} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts new file mode 100644 index 00000000..f7260eb5 --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -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' diff --git a/apps/frontend/src/services/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/frontend/src/services/admin/platform-config/kuaishou-cloud-fulfillment.ts new file mode 100644 index 00000000..0680176c --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/kuaishou-cloud-fulfillment.ts @@ -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) +} diff --git a/apps/frontend/src/services/admin/platform-config/kuaishou-eticket.ts b/apps/frontend/src/services/admin/platform-config/kuaishou-eticket.ts new file mode 100644 index 00000000..cd324b2e --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/kuaishou-eticket.ts @@ -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( + '/api/v1/admin/platform-config/kuaishou-eticket/query-detail', + payload, + ) +} + +export function queryAdminKuaishouEticketShopInfo(payload: { + baseUrl?: string + shopId?: string + cookie?: string +}) { + return apiPost( + '/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( + '/api/v1/admin/platform-config/kuaishou-eticket/consume', + payload, + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/ninetyone.ts b/apps/frontend/src/services/admin/platform-config/ninetyone.ts new file mode 100644 index 00000000..c5ef2aeb --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/ninetyone.ts @@ -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( + `/api/v1/admin/platform-config/ninetyone/orders${queryString ? `?${queryString}` : ''}`, + ) +} + +export function retryAdminNinetyoneOrder(orderId: number | string) { + return apiPost( + `/api/v1/admin/platform-config/ninetyone/orders/${orderId}/retry`, + {}, + ) +} + +export function failAdminNinetyoneOrder(orderId: number | string, payload: { reason?: string }) { + return apiPost( + `/api/v1/admin/platform-config/ninetyone/orders/${orderId}/fail`, + payload, + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/notifications.ts b/apps/frontend/src/services/admin/platform-config/notifications.ts new file mode 100644 index 00000000..f5056037 --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/notifications.ts @@ -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( + '/api/v1/admin/platform-config/notifications/test', + payload, + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/scheduled-jobs.ts b/apps/frontend/src/services/admin/platform-config/scheduled-jobs.ts new file mode 100644 index 00000000..55089011 --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/scheduled-jobs.ts @@ -0,0 +1,20 @@ +import { apiGet, apiPost } from '@/lib/http' +import type { AdminScheduledJobsConfig, AdminScheduledJobsResponse } from '@/types/admin' + +export function fetchAdminScheduledJobsConfig() { + return apiGet('/api/v1/admin/platform-config/scheduled-jobs') +} + +export function saveAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig) { + return apiPost( + '/api/v1/admin/platform-config/scheduled-jobs', + payload, + ) +} + +export function runAdminScheduledJob(jobId: string) { + return apiPost<{ + result: Record + runtime: AdminScheduledJobsResponse['runtime'] + }>(`/api/v1/admin/platform-config/scheduled-jobs/${jobId}/run`, {}) +} diff --git a/apps/frontend/src/services/tencent/redeem.ts b/apps/frontend/src/services/tencent/redeem.ts index 3deb1026..f63ad7c9 100644 --- a/apps/frontend/src/services/tencent/redeem.ts +++ b/apps/frontend/src/services/tencent/redeem.ts @@ -12,6 +12,6 @@ export function redeemTencentBrowserSession( ) { return apiPost( `/api/v1/tencent/browser/session/${sessionId}/redeem`, - payload as unknown as Record, + payload, ) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts deleted file mode 100644 index d1f6a307..00000000 --- a/apps/frontend/src/types/admin.ts +++ /dev/null @@ -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 - 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 -} - -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 -} - -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 -} - -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> -} - -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 - asset: number -} - -export interface AdminCloudtentaclesValidateSessionResult { - baseUrl: string - loggedInAt: string - session: AdminCloudtentaclesSessionSummary - userInfo: Record - 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 -} - -export interface AdminCloudtentaclesSkuListResult { - baseUrl: string - itemCount: number - items: AdminCloudtentaclesSkuItem[] - rawItems: Record[] -} - -export interface AdminFulfillmentBindingConfigItem { - provider: string - platform: string - shopId: string - shopName: string - skuCode: string - skuName: string - profileKey: string - enabled: boolean - priority: number - config: Record - match: { - externalSkuCode: string - externalItemId: string - externalSkuName: string - config: Record - } -} - -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 - bindingSummary: AdminOrderBindingSummary - agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null - } - items: Array<{ - orderItemId: number - skuCode: string - skuName: string - itemTitle: string - quantity: number - deliveryMode: string - spec: Record - }> - 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 - isPrimary: boolean - canRelease: boolean - }> - artifacts: Record - 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 | 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 - 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 - 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 - query: Record - body: Record - payload: Record -} - -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 - }> - } -} diff --git a/apps/frontend/src/types/admin/audit-logs.ts b/apps/frontend/src/types/admin/audit-logs.ts new file mode 100644 index 00000000..d54fc10b --- /dev/null +++ b/apps/frontend/src/types/admin/audit-logs.ts @@ -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 + createdAt: string +} diff --git a/apps/frontend/src/types/admin/auth.ts b/apps/frontend/src/types/admin/auth.ts new file mode 100644 index 00000000..385922ab --- /dev/null +++ b/apps/frontend/src/types/admin/auth.ts @@ -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[] + } +} diff --git a/apps/frontend/src/types/admin/common.ts b/apps/frontend/src/types/admin/common.ts new file mode 100644 index 00000000..29821fba --- /dev/null +++ b/apps/frontend/src/types/admin/common.ts @@ -0,0 +1,5 @@ +export interface AdminPagination { + page: number + pageSize: number + total: number +} diff --git a/apps/frontend/src/types/admin/dashboard.ts b/apps/frontend/src/types/admin/dashboard.ts new file mode 100644 index 00000000..b360c3d7 --- /dev/null +++ b/apps/frontend/src/types/admin/dashboard.ts @@ -0,0 +1,8 @@ +export interface AdminDashboardSummary { + todayOrders: number + paidPendingClaim: number + claimingTasks: number + redeemedToday: number + abnormalTasks: number + skuWithInventory: number +} diff --git a/apps/frontend/src/types/admin/fulfillment.ts b/apps/frontend/src/types/admin/fulfillment.ts new file mode 100644 index 00000000..8f674424 --- /dev/null +++ b/apps/frontend/src/types/admin/fulfillment.ts @@ -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[] +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts new file mode 100644 index 00000000..06bc2685 --- /dev/null +++ b/apps/frontend/src/types/admin/index.ts @@ -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' diff --git a/apps/frontend/src/types/admin/inventory.ts b/apps/frontend/src/types/admin/inventory.ts new file mode 100644 index 00000000..74325a87 --- /dev/null +++ b/apps/frontend/src/types/admin/inventory.ts @@ -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 +} diff --git a/apps/frontend/src/types/admin/manual-redeem.ts b/apps/frontend/src/types/admin/manual-redeem.ts new file mode 100644 index 00000000..026f53e0 --- /dev/null +++ b/apps/frontend/src/types/admin/manual-redeem.ts @@ -0,0 +1,9 @@ +import type { ClaimDetailData } from '../claim' + +export interface AdminManualRedeemDetail extends ClaimDetailData { + manualRequest: { + sourceType: string + proofValue: string + remark: string + } +} diff --git a/apps/frontend/src/types/admin/message-deliveries.ts b/apps/frontend/src/types/admin/message-deliveries.ts new file mode 100644 index 00000000..893f2441 --- /dev/null +++ b/apps/frontend/src/types/admin/message-deliveries.ts @@ -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 + errorMessage: string + sentAt: string | null + createdAt: string + updatedAt: string +} diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts new file mode 100644 index 00000000..8f16c568 --- /dev/null +++ b/apps/frontend/src/types/admin/orders.ts @@ -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 + bindingSummary: AdminOrderBindingSummary + agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null + } + items: Array<{ + orderItemId: number + skuCode: string + skuName: string + itemTitle: string + quantity: number + deliveryMode: string + spec: Record + }> + 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 + }> +} diff --git a/apps/frontend/src/types/admin/platform-config/agiso.ts b/apps/frontend/src/types/admin/platform-config/agiso.ts new file mode 100644 index 00000000..7288a5e1 --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/agiso.ts @@ -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 +} diff --git a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts new file mode 100644 index 00000000..1585f8dd --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts @@ -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 + asset: number +} + +export interface AdminCloudtentaclesValidateSessionResult { + baseUrl: string + loggedInAt: string + session: AdminCloudtentaclesSessionSummary + userInfo: Record + 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 +} + +export interface AdminCloudtentaclesSkuListResult { + baseUrl: string + itemCount: number + items: AdminCloudtentaclesSkuItem[] + rawItems: Record[] +} diff --git a/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts b/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts new file mode 100644 index 00000000..3aa383ea --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts @@ -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 + match: { + externalSkuCode: string + externalItemId: string + externalSkuName: string + config: Record + } +} + +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 + } +} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts new file mode 100644 index 00000000..773cb4fb --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -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' diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts new file mode 100644 index 00000000..cf8a6224 --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts @@ -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[] +} diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-eticket.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-eticket.ts new file mode 100644 index 00000000..046bca9c --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/kuaishou-eticket.ts @@ -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 +} + +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 +} + +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 +} diff --git a/apps/frontend/src/types/admin/platform-config/ninetyone.ts b/apps/frontend/src/types/admin/platform-config/ninetyone.ts new file mode 100644 index 00000000..19df7c49 --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/ninetyone.ts @@ -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> +} + +export interface AdminNinetyoneOrderListResult { + page: number + pageSize: number + total: number + items: AdminNinetyoneOrderItem[] +} + +export interface AdminNinetyoneOrderActionResult { + orderId: number + orderNo: string + orderStatus: string + orderItemCount: number + taskCount: number +} diff --git a/apps/frontend/src/types/admin/platform-config/notifications.ts b/apps/frontend/src/types/admin/platform-config/notifications.ts new file mode 100644 index 00000000..64bd35db --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/notifications.ts @@ -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[] +} diff --git a/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts b/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts new file mode 100644 index 00000000..8256bf8b --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts @@ -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[] +} diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts new file mode 100644 index 00000000..65ae3afe --- /dev/null +++ b/apps/frontend/src/types/admin/tasks.ts @@ -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 + isPrimary: boolean + canRelease: boolean + }> + artifacts: Record + 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 | 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 + createdAt: string + }> + operations: AdminTaskOperations +} diff --git a/apps/frontend/src/types/admin/users.ts b/apps/frontend/src/types/admin/users.ts new file mode 100644 index 00000000..e8ad7774 --- /dev/null +++ b/apps/frontend/src/types/admin/users.ts @@ -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 +} diff --git a/apps/frontend/src/types/admin/webhook-events.ts b/apps/frontend/src/types/admin/webhook-events.ts new file mode 100644 index 00000000..7d11d078 --- /dev/null +++ b/apps/frontend/src/types/admin/webhook-events.ts @@ -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 + query: Record + body: Record + payload: Record +} + +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 + }> + } +} diff --git a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindingsView.vue deleted file mode 100644 index c748d39c..00000000 --- a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindingsView.vue +++ /dev/null @@ -1,989 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue b/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue index 8c88c159..05d7e7f2 100644 --- a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue +++ b/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue @@ -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' diff --git a/apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillmentView.vue b/apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillmentView.vue deleted file mode 100644 index ecbec490..00000000 --- a/apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillmentView.vue +++ /dev/null @@ -1,1224 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindings.css b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css similarity index 99% rename from apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindings.css rename to apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css index 8fbc1038..a55f88e9 100644 --- a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindings.css +++ b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css @@ -1,4 +1,4 @@ -@import '../../../styles/admin-config-pages.css'; +@import '../../../../styles/admin-config-pages.css'; /* ============================================================ AdminFulfillmentBindings — 绿色主题覆盖 & 页面特有样式 diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue new file mode 100644 index 00000000..007b7d37 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue new file mode 100644 index 00000000..3e0ca2fd --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue @@ -0,0 +1,225 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue new file mode 100644 index 00000000..42d375ee --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue @@ -0,0 +1,154 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue new file mode 100644 index 00000000..dbc2af28 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue new file mode 100644 index 00000000..ec694bfc --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue new file mode 100644 index 00000000..4a5a1abb --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts new file mode 100644 index 00000000..d197c0f3 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts @@ -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)) diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts new file mode 100644 index 00000000..b75019d8 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts @@ -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([]) + const observedProducts = ref([]) + const validationState = ref(null) + const collapsedBindingIds = ref([]) + + // ── 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, + ) { + 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(`[data-binding-id="${state.bindingId}"]`) + if (!card) { + return + } + + card.scrollIntoView({ behavior: 'smooth', block: 'center' }) + + const selectors: Record = { + shopId: '[data-field="shopId"]', + skuCode: '[data-field="skuCode"]', + match: '[data-field="externalSkuCode"]', + profileKey: '[data-field="profileKey"]', + } + + const target = card.querySelector(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((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, + } +} diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts new file mode 100644 index 00000000..272a7a6e --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts @@ -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, +) { + const lookupLoading = ref(false) + const lookupErrorMessage = ref('') + const lookupResult = ref(null) + const importedLookupLineIds = ref([]) + 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, + } +} diff --git a/apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillment.css b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css similarity index 99% rename from apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillment.css rename to apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css index 316578c2..04004f8d 100644 --- a/apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillment.css +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css @@ -1,4 +1,4 @@ -@import '../../../styles/admin-config-pages.css'; +@import '../../../../styles/admin-config-pages.css'; /* ============================================================ AdminKuaishouCloudFulfillment — 蓝色主题覆盖 & 双栏映射布局 diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue new file mode 100644 index 00000000..694ad41e --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudNinetyoneSection.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudNinetyoneSection.vue new file mode 100644 index 00000000..e3337ab2 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudNinetyoneSection.vue @@ -0,0 +1,149 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudOverviewSection.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudOverviewSection.vue new file mode 100644 index 00000000..42d93f16 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudOverviewSection.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue new file mode 100644 index 00000000..c6a5c9bb --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue @@ -0,0 +1,299 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleSection.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleSection.vue new file mode 100644 index 00000000..e5350a48 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleSection.vue @@ -0,0 +1,120 @@ + + + diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/types.ts b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/types.ts new file mode 100644 index 00000000..a65bc7ed --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/types.ts @@ -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' diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudConfig.ts b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudConfig.ts new file mode 100644 index 00000000..8ca68729 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudConfig.ts @@ -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(null) + const filePath = ref('') + const enabled = ref(true) + const items = ref([]) + const collapsedIds = ref([]) + const ruleFilter = ref('all') + const kuaishouConsumeShops = ref([]) + + // ── 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>( + () => [ + { 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, + ) { + 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(`[data-local-id="${localId}"]`) + if (!card) { + return + } + + card.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + + // ── save ────────────────────────────────────────────── + + async function saveConfigs() { + const invalid = items.value.reduce( + (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, + } +} diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudNinetyone.ts b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudNinetyone.ts new file mode 100644 index 00000000..a82a3afc --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudNinetyone.ts @@ -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, +) { + const ninetyoneLookupLoading = ref(false) + const ninetyoneLookupErrorMessage = ref('') + const ninetyoneLookupResults = ref([]) + const importedNinetyoneOrderKeys = ref([]) + 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, + } +} diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudSku.ts b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudSku.ts new file mode 100644 index 00000000..c79342f9 --- /dev/null +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/composables/useKuaishouCloudSku.ts @@ -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([]) + + 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, + } +} diff --git a/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue index b57bb07b..2f086853 100644 --- a/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue @@ -1,383 +1,64 @@