重构:前端架构优化 - 消灭巨石文件,统一代码规范

Phase 1 - 消灭巨石页面:
- 拆分 AdminKuaishouCloudFulfillmentView (1,224行→kuaishou-cloud/子目录)
- 拆分 AdminFulfillmentBindingsView (989行→bindings/子目录)
- 拆分 AdminTaskDetailView (1,007行→9个子组件+2个composable)
- 合并去重 useClaimPage + useAdminManualRedeemPage (1,554行→共享模块+差异化薄层)
- 拆分 services/admin/platform-config.ts (477行→8个领域子模块)

Phase 2 - 架构收口:
- 拆分 types/admin.ts (925行→13个子文件+platform-config/子目录)
- 统一 API code 检查(http.ts拦截器统一处理业务错误)
- 修改 apiPost 签名消除 as unknown as 类型断言(6处)
- 新增 BusinessError 类型便于错误分类处理

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