前面的ok了, 绑定与角色
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"syncFromCreatedAt": "2026-05-02T10:53:28.550Z",
|
||||
"lastRunStartedAt": "2026-05-03T11:11:55.949Z",
|
||||
"lastRunFinishedAt": "2026-05-03T11:11:56.571Z",
|
||||
"lastRunStartedAt": "2026-05-03T12:57:26.689Z",
|
||||
"lastRunFinishedAt": "2026-05-03T12:57:27.309Z",
|
||||
"lastRunStatus": "success",
|
||||
"lastErrorMessage": "",
|
||||
"fetchedCount": 40,
|
||||
"syncedCount": 0,
|
||||
"ignoredCount": 40,
|
||||
"lastOrderCreatedAt": "2026-05-03T11:11:29.000Z"
|
||||
"syncedCount": 1,
|
||||
"ignoredCount": 39,
|
||||
"lastOrderCreatedAt": "2026-05-03T12:57:20.000Z"
|
||||
}
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
{
|
||||
"provider": "khhao",
|
||||
"platform": "kuaishou",
|
||||
"shopId": "10",
|
||||
"shopId": "4269276762",
|
||||
"shopName": "稚嫩游戏交易店",
|
||||
"khhaoShopId": "10",
|
||||
"skuCode": "套装-浪漫天命",
|
||||
"skuName": "套装-浪漫天命",
|
||||
"profileKey": "kuaishou_ct_assisted",
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"config": {},
|
||||
"config": {
|
||||
"kuaishouShop": {
|
||||
"shopId": "4269276762",
|
||||
"shopName": "稚嫩游戏交易店",
|
||||
"khhaoShopId": "10"
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"externalSkuCode": "830",
|
||||
"externalItemId": "830",
|
||||
@@ -22,6 +30,8 @@
|
||||
"provider": "agiso",
|
||||
"platform": "xianyu",
|
||||
"shopId": "2209880145223",
|
||||
"shopName": "",
|
||||
"khhaoShopId": "",
|
||||
"skuCode": "海底捞第五人格皮肤",
|
||||
"skuName": "海底捞第五人格皮肤",
|
||||
"profileKey": "tencent_claim_redeem",
|
||||
@@ -41,6 +51,8 @@
|
||||
"provider": "agiso",
|
||||
"platform": "xianyu",
|
||||
"shopId": "2209880145223",
|
||||
"shopName": "",
|
||||
"khhaoShopId": "",
|
||||
"skuCode": "三角洲海底捞动作",
|
||||
"skuName": "海底捞干员庆生动作1个",
|
||||
"profileKey": "tencent_claim_redeem",
|
||||
|
||||
@@ -23,6 +23,35 @@ export async function findOrderByPlatformOrderId({ provider = 'agiso', platform,
|
||||
return /** @type {OrderRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderRow | null>} */
|
||||
export async function findOrderByPlatformOrderIdCandidates({
|
||||
provider = 'agiso',
|
||||
platform,
|
||||
shopIds = [],
|
||||
platformOrderId,
|
||||
}) {
|
||||
const normalizedShopIds = [...new Set((Array.isArray(shopIds) ? shopIds : [])
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean))]
|
||||
|
||||
if (!platform || !platformOrderId || normalizedShopIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
SELECT *
|
||||
FROM orders
|
||||
WHERE provider = $1 AND platform = $2 AND shop_id = ANY($3::text[]) AND platform_order_id = $4
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[provider, platform, normalizedShopIds, platformOrderId],
|
||||
)
|
||||
|
||||
return /** @type {OrderRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderRow | null>} */
|
||||
/** @param {OrderUpsertInput} input */
|
||||
export async function createOrder(input) {
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
reloadClaimSession,
|
||||
redeemClaimTask,
|
||||
} from '../services/claim/claim-session-service.js'
|
||||
import {
|
||||
getKuaishouCloudClaimGuideAssetPath,
|
||||
verifyKuaishouCloudClaimTicket,
|
||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
||||
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -77,6 +81,15 @@ router.post('/:token/redeem', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:token/kuaishou-cloud/verify-ticket', async (req, res) => {
|
||||
try {
|
||||
const data = await verifyKuaishouCloudClaimTicket(req.params.token, req.body)
|
||||
res.json(buildSuccessPayload(data, '核销码校验成功'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '校验核销码失败', '[claims/:token/kuaishou-cloud/verify-ticket]')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:token/screenshot', async (req, res) => {
|
||||
try {
|
||||
const screenshotPath = await getClaimScreenshotPath(req.params.token)
|
||||
@@ -86,6 +99,15 @@ router.get('/:token/screenshot', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/assets/kuaishou-cloud/:filename', async (req, res) => {
|
||||
try {
|
||||
const filePath = await getKuaishouCloudClaimGuideAssetPath(req.params.filename)
|
||||
res.sendFile(filePath)
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '读取指引图片失败', '[claims/assets/kuaishou-cloud/:filename]')
|
||||
}
|
||||
})
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
getKuaishouEticketSourceFilePath,
|
||||
listKuaishouEticketShopConfigs,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
saveKuaishouEticketSourceConfig,
|
||||
} from '../platforms/kuaishou-eticket/source-config-service.js'
|
||||
import { queryKuaishouEticketStableInfo } from '../platforms/kuaishou-eticket/info-service.js'
|
||||
@@ -991,8 +992,8 @@ export async function updateAdminFulfillmentBindingConfigs(
|
||||
payload = /** @type {AdminFulfillmentBindingConfigSaveInput} */ ({}),
|
||||
) {
|
||||
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
|
||||
await validateAdminFulfillmentBindingConfigs(bindingsInput)
|
||||
const saved = saveOrderFulfillmentBindingConfigs(bindingsInput)
|
||||
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput)
|
||||
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
@@ -1010,6 +1011,8 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||
}
|
||||
|
||||
const seenKeys = new Set()
|
||||
const kuaishouEticketSource = getKuaishouEticketSourceConfig()
|
||||
const normalizedBindings = []
|
||||
|
||||
for (const [index, rawBinding] of bindings.entries()) {
|
||||
if (!isPlainObject(rawBinding)) {
|
||||
@@ -1021,13 +1024,53 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||
|
||||
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawBinding.platform || '').trim()
|
||||
const shopId = String(rawBinding.shopId || '').trim()
|
||||
let shopId = String(rawBinding.shopId || '').trim()
|
||||
let shopName = String(rawBinding.shopName || '').trim()
|
||||
const khhaoShopId = String(rawBinding.khhaoShopId || '').trim()
|
||||
const skuCode = String(rawBinding.skuCode || '').trim()
|
||||
const skuName = String(rawBinding.skuName || '').trim()
|
||||
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
|
||||
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
|
||||
|
||||
if (provider === 'khhao' && platform === 'kuaishou') {
|
||||
if (!khhaoShopId) {
|
||||
throw createHttpError(`第 ${index + 1} 条快手规则缺少 khhao 店铺 ID`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_khhao_shop_id',
|
||||
})
|
||||
}
|
||||
|
||||
const matchedShop = resolveKuaishouEticketShopConfig(
|
||||
{
|
||||
shopId,
|
||||
shopName,
|
||||
},
|
||||
kuaishouEticketSource,
|
||||
)
|
||||
|
||||
if (!matchedShop?.shopId || !matchedShop?.kshopName) {
|
||||
throw createHttpError(
|
||||
`第 ${index + 1} 条快手规则的店铺未匹配到已配置的快手官方店铺,请先到“平台配置”补齐 Cookie 后再选择`,
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_kuaishou_shop_not_configured',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
shopId = String(matchedShop.shopId || '').trim()
|
||||
shopName = String(matchedShop.kshopName || '').trim()
|
||||
config.kuaishouShop = {
|
||||
...(isPlainObject(config.kuaishouShop) ? config.kuaishouShop : {}),
|
||||
shopId,
|
||||
shopName,
|
||||
khhaoShopId,
|
||||
}
|
||||
}
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, {
|
||||
@@ -1054,7 +1097,12 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||
const uniqueKey = [
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
resolveFulfillmentBindingMatchShopId({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
khhaoShopId,
|
||||
}),
|
||||
externalItemId,
|
||||
externalSkuCode,
|
||||
normalizeProductName(externalSkuName),
|
||||
@@ -1069,7 +1117,29 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||
}
|
||||
|
||||
seenKeys.add(uniqueKey)
|
||||
|
||||
normalizedBindings.push({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName,
|
||||
khhaoShopId,
|
||||
skuCode,
|
||||
skuName,
|
||||
profileKey,
|
||||
enabled: rawBinding.enabled !== false,
|
||||
priority: rawBinding.priority,
|
||||
config,
|
||||
match: {
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
externalSkuName,
|
||||
config: isPlainObject(match.config) ? match.config : {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return normalizedBindings
|
||||
}
|
||||
|
||||
function mapAdminFulfillmentBindingConfigItem(item) {
|
||||
@@ -1078,6 +1148,8 @@ function mapAdminFulfillmentBindingConfigItem(item) {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
shopName: String(item?.shopName || '').trim(),
|
||||
khhaoShopId: String(item?.khhaoShopId || '').trim(),
|
||||
skuCode: String(item?.skuCode || '').trim(),
|
||||
skuName: String(item?.skuName || '').trim(),
|
||||
profileKey: String(item?.profileKey || '').trim(),
|
||||
@@ -1093,6 +1165,19 @@ function mapAdminFulfillmentBindingConfigItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFulfillmentBindingMatchShopId({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
khhaoShopId = '',
|
||||
} = {}) {
|
||||
if (String(provider || '').trim() === 'khhao' && String(platform || '').trim() === 'kuaishou') {
|
||||
return String(khhaoShopId || shopId || '').trim()
|
||||
}
|
||||
|
||||
return String(shopId || '').trim()
|
||||
}
|
||||
|
||||
function matchesObservedProduct(binding, observed) {
|
||||
const provider = String(binding?.provider || '').trim()
|
||||
const platform = String(binding?.platform || '').trim()
|
||||
|
||||
@@ -221,6 +221,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
|
||||
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext))
|
||||
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment)
|
||||
|
||||
return {
|
||||
task: mapAdminTaskListItem({
|
||||
@@ -279,7 +280,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
|
||||
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
||||
},
|
||||
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
|
||||
kuaishouCloudFulfillment: mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment),
|
||||
kuaishouCloudFulfillment,
|
||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
||||
events: taskEvents.map(mapAdminTaskEvent),
|
||||
operations: {
|
||||
@@ -293,6 +294,8 @@ export async function getAdminTaskDetail(taskId, session = null) {
|
||||
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
|
||||
canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
||||
&& String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending'
|
||||
&& String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending'
|
||||
&& ['pending_binding_prepare', 'manual_review', 'failed'].includes(String(task.task_status || '').trim()),
|
||||
canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
||||
|
||||
@@ -45,6 +45,10 @@ export function mapKuaishouCloudFulfillmentContext(value) {
|
||||
ticket: {
|
||||
code: String(ticket.code || '').trim(),
|
||||
capturedAt: ticket.capturedAt || null,
|
||||
status: String(ticket.status || 'pending').trim() || 'pending',
|
||||
verifiedAt: ticket.verifiedAt || null,
|
||||
goodsTitle: String(ticket.goodsTitle || '').trim(),
|
||||
leftCount: Number(ticket.leftCount || 0) || 0,
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
|
||||
@@ -81,6 +85,8 @@ export function mapKuaishouCloudFulfillmentContext(value) {
|
||||
status: String(consume.status || 'pending').trim() || 'pending',
|
||||
shopId: String(consume.shopId || '').trim(),
|
||||
autoConsumeEnabled: consume.autoConsumeEnabled === true,
|
||||
consumedAt: consume.consumedAt || null,
|
||||
errorMessage: String(consume.errorMessage || '').trim(),
|
||||
},
|
||||
notes: String(value.notes || '').trim(),
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '../../repositories/task-inventory-binding-repo.js'
|
||||
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||
import { getWebhookEventById } from '../../repositories/webhook-event-repo.js'
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js'
|
||||
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
useCloudtentaclesSku,
|
||||
} from '../platforms/cloudtentacles/catalog-service.js'
|
||||
import { getCloudtentaclesKnapsack } from '../platforms/cloudtentacles/knapsack-service.js'
|
||||
import { consumeKuaishouEticket } from '../platforms/kuaishou-eticket/consume-service.js'
|
||||
import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
} from '../platforms/kuaishou-eticket/source-config-service.js'
|
||||
import {
|
||||
appointCloudtentaclesVirtualNumber,
|
||||
backCloudtentaclesVirtualNumber,
|
||||
@@ -755,6 +760,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session =
|
||||
let purchaseTriggered = false
|
||||
let assetBefore = 0
|
||||
let assetAfter = 0
|
||||
const claimLinkState = await ensureTaskClaimLink(task)
|
||||
|
||||
if (!usedKnapsack) {
|
||||
if (!flowWithResolvedBinding.purchase.autoBuyEnabled) {
|
||||
@@ -833,6 +839,8 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session =
|
||||
task_status: 'waiting_binding',
|
||||
inventory_status: 'not_required',
|
||||
user_action_status: 'pending',
|
||||
claim_token: claimLinkState.token || task.claim_token || '',
|
||||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||||
last_error: '',
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
@@ -852,6 +860,8 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session =
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
claimUrl: claimLinkState.claimUrl,
|
||||
token: claimLinkState.token,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,6 +900,15 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload
|
||||
}
|
||||
|
||||
const ticketCode = String(payload.ticketCode || '').trim()
|
||||
const persistedTicketCode = String(flow.ticket.code || '').trim()
|
||||
|
||||
if (!persistedTicketCode && !ticketCode) {
|
||||
throw createHttpError('客户还没有在领取页提交核销码,暂时不能直接发货', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_kuaishou_cloud_missing_ticket_code',
|
||||
})
|
||||
}
|
||||
|
||||
const dispatchResult = await useCloudtentaclesSku({
|
||||
...cloudContext,
|
||||
id: flow.binding.skuId,
|
||||
@@ -903,7 +922,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload
|
||||
...flow,
|
||||
ticket: {
|
||||
...flow.ticket,
|
||||
code: ticketCode || flow.ticket.code,
|
||||
code: ticketCode || persistedTicketCode,
|
||||
capturedAt: ticketCode ? now : flow.ticket.capturedAt,
|
||||
capturedBy: ticketCode && session
|
||||
? {
|
||||
@@ -941,7 +960,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload
|
||||
})
|
||||
|
||||
await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', {
|
||||
ticketCodeMasked: maskCode(ticketCode),
|
||||
ticketCodeMasked: maskCode(ticketCode || persistedTicketCode),
|
||||
skuId: flow.binding.skuId,
|
||||
vnId: flow.binding.vnId,
|
||||
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
|
||||
@@ -993,6 +1012,59 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, sess
|
||||
id: flow.binding.vnId,
|
||||
})
|
||||
|
||||
const order = await getOrderById(task.order_id)
|
||||
const ticketCode = String(flow.ticket.code || '').trim()
|
||||
const shopId = String(order?.shop_id || '').trim()
|
||||
const eticketSource = getKuaishouEticketSourceConfig()
|
||||
const shopConfig = resolveKuaishouEticketShopConfig({
|
||||
shopId,
|
||||
shopName: String(order?.shop_name || '').trim(),
|
||||
})
|
||||
let consumeStatus = 'pending'
|
||||
let consumeErrorMessage = ''
|
||||
let consumedAt = null
|
||||
let nextTaskStatus = 'completed'
|
||||
let nextResultCode = 'kuaishou_cloud_completed'
|
||||
let nextResultMessage = 'cloudtentacles 发货、退号并完成快手核销'
|
||||
|
||||
if (!order) {
|
||||
consumeStatus = 'failed'
|
||||
consumeErrorMessage = '任务关联订单不存在,无法执行快手核销'
|
||||
} else if (!ticketCode) {
|
||||
consumeStatus = 'failed'
|
||||
consumeErrorMessage = '客户未提交有效核销码,无法执行快手核销'
|
||||
} else if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) {
|
||||
consumeStatus = 'failed'
|
||||
consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销'
|
||||
} else {
|
||||
try {
|
||||
const consumeResult = await consumeKuaishouEticket({
|
||||
baseUrl: eticketSource.baseUrl,
|
||||
cookie: shopConfig.cookie,
|
||||
eTicketId: ticketCode,
|
||||
oid: String(flow.ticket.oid || '').trim(),
|
||||
formToken: String(flow.ticket.formToken || '').trim(),
|
||||
})
|
||||
|
||||
if (consumeResult.consumed) {
|
||||
consumeStatus = 'success'
|
||||
consumedAt = now
|
||||
} else {
|
||||
consumeStatus = 'failed'
|
||||
consumeErrorMessage = String(consumeResult.errorMessage || '快手核销失败').trim()
|
||||
}
|
||||
} catch (error) {
|
||||
consumeStatus = 'failed'
|
||||
consumeErrorMessage = error instanceof Error ? error.message : '快手核销失败'
|
||||
}
|
||||
}
|
||||
|
||||
if (consumeStatus !== 'success') {
|
||||
nextTaskStatus = 'manual_review'
|
||||
nextResultCode = 'kuaishou_cloud_consume_failed'
|
||||
nextResultMessage = consumeErrorMessage || '号码已退还,但快手核销未完成,请人工处理'
|
||||
}
|
||||
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouCloudFulfillment: {
|
||||
@@ -1009,16 +1081,24 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, sess
|
||||
}
|
||||
: null,
|
||||
},
|
||||
consume: {
|
||||
...flow.consume,
|
||||
status: consumeStatus,
|
||||
shopId: shopId || flow.consume.shopId,
|
||||
autoConsumeEnabled: flow.consume.autoConsumeEnabled === true,
|
||||
consumedAt,
|
||||
errorMessage: consumeErrorMessage,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'completed',
|
||||
task_status: nextTaskStatus,
|
||||
delivery_status: 'delivered',
|
||||
result_code: 'kuaishou_cloud_completed',
|
||||
result_message: 'cloudtentacles 发货并退号完成',
|
||||
redeemed_at: now,
|
||||
last_error: '',
|
||||
result_code: nextResultCode,
|
||||
result_message: nextResultMessage,
|
||||
redeemed_at: consumeStatus === 'success' ? now : task.redeemed_at,
|
||||
last_error: consumeErrorMessage,
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
@@ -1028,6 +1108,18 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, sess
|
||||
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
|
||||
}, now)
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
consumeStatus === 'success' ? 'kuaishou_cloud_consumed' : 'kuaishou_cloud_consume_failed',
|
||||
{
|
||||
ticketCodeMasked: maskCode(ticketCode),
|
||||
shopId,
|
||||
consumeStatus,
|
||||
errorMessage: consumeErrorMessage,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
@@ -1123,8 +1215,14 @@ function normalizeKuaishouCloudFlow(value) {
|
||||
internalSkuName: String(source.internalSkuName || '').trim(),
|
||||
ticket: {
|
||||
code: String(ticket.code || '').trim(),
|
||||
status: String(ticket.status || 'pending').trim() || 'pending',
|
||||
capturedAt: ticket.capturedAt || null,
|
||||
capturedBy: ticket.capturedBy || null,
|
||||
verifiedAt: ticket.verifiedAt || null,
|
||||
oid: String(ticket.oid || '').trim(),
|
||||
formToken: String(ticket.formToken || '').trim(),
|
||||
leftCount: Number(ticket.leftCount || 0) || 0,
|
||||
goodsTitle: String(ticket.goodsTitle || '').trim(),
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
|
||||
@@ -1163,6 +1261,8 @@ function normalizeKuaishouCloudFlow(value) {
|
||||
status: String(consume.status || 'pending').trim() || 'pending',
|
||||
shopId: String(consume.shopId || '').trim(),
|
||||
autoConsumeEnabled: consume.autoConsumeEnabled === true,
|
||||
consumedAt: consume.consumedAt || null,
|
||||
errorMessage: String(consume.errorMessage || '').trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1462,3 +1562,33 @@ function normalizeManualDispatchOutcome(value) {
|
||||
function getTaskClaimExpiresAt(task) {
|
||||
return task?.claim_expires_at || task?.primary_claim_expires_at || null
|
||||
}
|
||||
|
||||
async function ensureTaskClaimLink(task) {
|
||||
const tokenStatus = String(task?.primary_claim_token_status || '').trim()
|
||||
const token = String(task?.primary_claim_token || task?.claim_token || '').trim()
|
||||
const expiredAt = getTaskClaimExpiresAt(task)
|
||||
|
||||
if (tokenStatus === 'active' && token && !isClaimExpired(expiredAt)) {
|
||||
return {
|
||||
token,
|
||||
expiredAt,
|
||||
claimUrl: buildClaimUrl(token),
|
||||
}
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
return {
|
||||
token: claimToken.token,
|
||||
expiredAt: claimToken.expired_at,
|
||||
claimUrl: claimToken.claimUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function isClaimExpired(expiredAt) {
|
||||
if (!expiredAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
const timestamp = new Date(expiredAt).getTime()
|
||||
return Number.isFinite(timestamp) && timestamp <= Date.now()
|
||||
}
|
||||
|
||||
@@ -120,15 +120,18 @@ export async function syncConfiguredFulfillmentBindings(profileMap = {}) {
|
||||
continue
|
||||
}
|
||||
|
||||
const matchShopId = resolveBindingMatchShopId(binding)
|
||||
const bindingConfig = resolveBindingRuntimeConfig(binding)
|
||||
|
||||
await upsertSkuFulfillmentBinding({
|
||||
skuCode: binding.skuCode,
|
||||
provider: binding.provider,
|
||||
platform: binding.platform,
|
||||
shopId: binding.shopId,
|
||||
shopId: matchShopId,
|
||||
profileId: profile.id,
|
||||
enabled: binding.enabled,
|
||||
priority: binding.priority,
|
||||
configJson: JSON.stringify(binding.config || {}),
|
||||
configJson: JSON.stringify(bindingConfig),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
@@ -145,7 +148,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap = {}) {
|
||||
await upsertProductMatchRule({
|
||||
provider: binding.provider,
|
||||
platform: binding.platform,
|
||||
shopId: binding.shopId,
|
||||
shopId: matchShopId,
|
||||
externalItemId,
|
||||
externalSkuCode,
|
||||
externalSkuName,
|
||||
@@ -159,3 +162,30 @@ export async function syncConfiguredFulfillmentBindings(profileMap = {}) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBindingMatchShopId(binding = {}) {
|
||||
if (String(binding.provider || '').trim() === 'khhao' && String(binding.platform || '').trim() === 'kuaishou') {
|
||||
return String(binding.khhaoShopId || binding.shopId || '').trim()
|
||||
}
|
||||
|
||||
return String(binding.shopId || '').trim()
|
||||
}
|
||||
|
||||
function resolveBindingRuntimeConfig(binding = {}) {
|
||||
const baseConfig = isPlainObject(binding.config) ? { ...binding.config } : {}
|
||||
|
||||
if (String(binding.provider || '').trim() === 'khhao' && String(binding.platform || '').trim() === 'kuaishou') {
|
||||
baseConfig.kuaishouShop = {
|
||||
...(isPlainObject(baseConfig.kuaishouShop) ? baseConfig.kuaishouShop : {}),
|
||||
shopId: String(binding.shopId || '').trim(),
|
||||
shopName: String(binding.shopName || '').trim(),
|
||||
khhaoShopId: String(binding.khhaoShopId || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { nowIso } from '../../utils/time.js'
|
||||
|
||||
const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
||||
const REDEEM_REPLACEMENT_LIMIT = 10
|
||||
const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide'
|
||||
|
||||
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
@@ -574,7 +575,7 @@ export async function getClaimScreenshotPath(token) {
|
||||
return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id)
|
||||
}
|
||||
|
||||
async function getClaimContext(token) {
|
||||
export async function getClaimContext(token) {
|
||||
const normalized = String(token || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
@@ -818,10 +819,12 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }
|
||||
const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot)
|
||||
const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : ''
|
||||
const finalRedeem = session?.redeem?.final?.redeem || null
|
||||
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim',
|
||||
task: {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
@@ -853,6 +856,7 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }
|
||||
quantity: orderItem.quantity,
|
||||
},
|
||||
session,
|
||||
kuaishouCloudFulfillment,
|
||||
result: task.redeemed_at || session?.status === 'redeemed'
|
||||
? {
|
||||
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''),
|
||||
@@ -864,6 +868,62 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }
|
||||
}
|
||||
}
|
||||
|
||||
function mapClaimKuaishouCloudFulfillment(task, order) {
|
||||
if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||
return null
|
||||
}
|
||||
|
||||
const source = parseTaskContext(task).kuaishouCloudFulfillment
|
||||
if (!source || typeof source !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
|
||||
const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {}
|
||||
const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {}
|
||||
const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {}
|
||||
const consume = source.consume && typeof source.consume === 'object' ? source.consume : {}
|
||||
|
||||
return {
|
||||
flowType: 'kuaishou_cloud',
|
||||
shopId: String(order?.shop_id || '').trim(),
|
||||
shopName: String(order?.shop_name || '').trim(),
|
||||
guideImages: [
|
||||
`${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/1.png`,
|
||||
`${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/2.png`,
|
||||
`${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/3.png`,
|
||||
],
|
||||
ticket: {
|
||||
code: String(ticket.code || '').trim(),
|
||||
status: String(ticket.status || 'pending').trim() || 'pending',
|
||||
capturedAt: ticket.capturedAt || null,
|
||||
verifiedAt: ticket.verifiedAt || null,
|
||||
goodsTitle: String(ticket.goodsTitle || '').trim(),
|
||||
leftCount: Number(ticket.leftCount || 0) || 0,
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
|
||||
bindUrl: String(binding.bindUrl || '').trim(),
|
||||
bindPreparedAt: binding.bindPreparedAt || null,
|
||||
vnPhone: String(binding.vnPhone || '').trim(),
|
||||
},
|
||||
dispatch: {
|
||||
status: String(dispatch.status || 'pending').trim() || 'pending',
|
||||
dispatchAt: dispatch.dispatchAt || null,
|
||||
note: String(dispatch.note || '').trim(),
|
||||
},
|
||||
returnNumber: {
|
||||
status: String(returnNumber.status || 'pending').trim() || 'pending',
|
||||
returnedAt: returnNumber.returnedAt || null,
|
||||
},
|
||||
consume: {
|
||||
status: String(consume.status || 'pending').trim() || 'pending',
|
||||
consumedAt: consume.consumedAt || null,
|
||||
errorMessage: String(consume.errorMessage || '').trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemClaimTaskWithInventoryFallback(context, initialInventoryItem) {
|
||||
return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../repositories/task-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
} from '../platforms/kuaishou-eticket/source-config-service.js'
|
||||
import { queryKuaishouEticketConsumeDetail } from '../platforms/kuaishou-eticket/consume-service.js'
|
||||
import { getClaimContext, getClaimDetail } from './claim-session-service.js'
|
||||
|
||||
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
|
||||
const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png'])
|
||||
|
||||
export async function verifyKuaishouCloudClaimTicket(token, payload = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
const now = nowIso()
|
||||
|
||||
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_not_kuaishou_cloud',
|
||||
})
|
||||
}
|
||||
|
||||
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
|
||||
if (!flow.binding.bindUrl || flow.binding.prepareStatus !== 'ready') {
|
||||
throw createHttpError('当前任务还没有准备好绑定资源,请联系客服稍后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_kuaishou_cloud_binding_not_ready',
|
||||
})
|
||||
}
|
||||
|
||||
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
|
||||
if (!ticketCode) {
|
||||
throw createHttpError('请先粘贴快手小店核销码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'claim_kuaishou_cloud_missing_ticket_code',
|
||||
})
|
||||
}
|
||||
|
||||
const shopId = String(flow.consume.shopId || context.order.shop_id || '').trim()
|
||||
const shopConfig = resolveKuaishouEticketShopConfig({
|
||||
shopId,
|
||||
shopName: String(context.order.shop_name || '').trim(),
|
||||
})
|
||||
if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) {
|
||||
throw createHttpError('这笔订单对应的快手小店还没有配置可用 Cookie,请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_kuaishou_cloud_shop_cookie_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const eticketSource = getKuaishouEticketSourceConfig()
|
||||
const detailResult = await queryKuaishouEticketConsumeDetail({
|
||||
baseUrl: eticketSource.baseUrl,
|
||||
cookie: shopConfig.cookie,
|
||||
eTicketId: ticketCode,
|
||||
})
|
||||
|
||||
if (!detailResult.ok || detailResult.alreadyConsumed || !detailResult.detail) {
|
||||
throw createHttpError(detailResult.errorMessage || '核销码校验失败,请确认是否复制完整', {
|
||||
statusCode: 409,
|
||||
errorCode: detailResult.alreadyConsumed
|
||||
? 'claim_kuaishou_cloud_ticket_already_consumed'
|
||||
: 'claim_kuaishou_cloud_ticket_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const nextContext = {
|
||||
...parseTaskContext(context.task),
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
ticket: {
|
||||
...flow.ticket,
|
||||
code: detailResult.eTicketId || ticketCode,
|
||||
status: 'verified',
|
||||
capturedAt: flow.ticket.capturedAt || now,
|
||||
capturedBy: flow.ticket.capturedBy || {
|
||||
source: 'claim_page',
|
||||
},
|
||||
verifiedAt: now,
|
||||
oid: String(detailResult.detail.oid || '').trim(),
|
||||
formToken: String(detailResult.detail.formToken || '').trim(),
|
||||
leftCount: Number(detailResult.detail.leftCount || 0) || 0,
|
||||
goodsTitle: String(detailResult.goods?.itemTitle || '').trim(),
|
||||
},
|
||||
consume: {
|
||||
...flow.consume,
|
||||
shopId,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await updateTask(context.task.id, {
|
||||
claim_token: context.claimToken.token,
|
||||
claim_expires_at: context.claimToken.expired_at,
|
||||
claimed_at: context.task.claimed_at || now,
|
||||
context_json: JSON.stringify(nextContext),
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', {
|
||||
ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode),
|
||||
shopId,
|
||||
shopName: String(context.order.shop_name || '').trim(),
|
||||
goodsTitle: String(detailResult.goods?.itemTitle || '').trim(),
|
||||
}, now)
|
||||
|
||||
return getClaimDetail(token, { includeQrImage: false })
|
||||
}
|
||||
|
||||
export async function getKuaishouCloudClaimGuideAssetPath(filename) {
|
||||
const normalized = String(filename || '').trim()
|
||||
if (!ALLOWED_GUIDE_FILES.has(normalized)) {
|
||||
throw createHttpError('指引图片不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_kuaishou_cloud_asset_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const filePath = path.resolve(KUAISHOU_CLOUD_GUIDE_DIR, normalized)
|
||||
if (!filePath.startsWith(KUAISHOU_CLOUD_GUIDE_DIR) || !fs.existsSync(filePath)) {
|
||||
throw createHttpError('指引图片不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_kuaishou_cloud_asset_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
function normalizeKuaishouCloudFlow(value) {
|
||||
const source = value && typeof value === 'object' ? value : {}
|
||||
const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
|
||||
const consume = source.consume && typeof source.consume === 'object' ? source.consume : {}
|
||||
const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {}
|
||||
|
||||
return {
|
||||
...source,
|
||||
ticket: {
|
||||
code: String(ticket.code || '').trim(),
|
||||
status: String(ticket.status || 'pending').trim() || 'pending',
|
||||
capturedAt: ticket.capturedAt || null,
|
||||
capturedBy: ticket.capturedBy || null,
|
||||
verifiedAt: ticket.verifiedAt || null,
|
||||
oid: String(ticket.oid || '').trim(),
|
||||
formToken: String(ticket.formToken || '').trim(),
|
||||
leftCount: Number(ticket.leftCount || 0) || 0,
|
||||
goodsTitle: String(ticket.goodsTitle || '').trim(),
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
|
||||
bindUrl: String(binding.bindUrl || '').trim(),
|
||||
},
|
||||
consume: {
|
||||
status: String(consume.status || 'pending').trim() || 'pending',
|
||||
shopId: String(consume.shopId || '').trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseTaskContext(task) {
|
||||
const rawValue = task?.context_json
|
||||
if (!rawValue) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'object') {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(rawValue || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function maskCode(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized.length <= 6) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 3)}***${normalized.slice(-3)}`
|
||||
}
|
||||
@@ -42,6 +42,9 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
|
||||
const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null
|
||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||
const fulfillmentConfig = parseJsonObject(profile.config_json)
|
||||
const kuaishouShopConfig = fulfillmentConfig.kuaishouShop && typeof fulfillmentConfig.kuaishouShop === 'object'
|
||||
? fulfillmentConfig.kuaishouShop
|
||||
: {}
|
||||
|
||||
for (let index = 0; index < quantity; index += 1) {
|
||||
const createdAt = nowIso()
|
||||
@@ -93,8 +96,14 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
|
||||
internalSkuName: item.sku_name,
|
||||
ticket: {
|
||||
code: '',
|
||||
status: 'pending',
|
||||
capturedAt: null,
|
||||
capturedBy: null,
|
||||
verifiedAt: null,
|
||||
oid: '',
|
||||
formToken: '',
|
||||
leftCount: 0,
|
||||
goodsTitle: '',
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: 'pending',
|
||||
@@ -131,8 +140,10 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
|
||||
},
|
||||
consume: {
|
||||
status: 'pending',
|
||||
shopId: String(fulfillmentConfig.kuaishouConsume?.shopId || '').trim(),
|
||||
shopId: String(fulfillmentConfig.kuaishouConsume?.shopId || kuaishouShopConfig.shopId || '').trim(),
|
||||
autoConsumeEnabled: fulfillmentConfig.kuaishouConsume?.autoConsumeAfterDispatch === true,
|
||||
consumedAt: null,
|
||||
errorMessage: '',
|
||||
},
|
||||
notes: String(fulfillmentConfig.notes || '').trim(),
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ function normalizeOrderFulfillmentBinding(rawValue) {
|
||||
const provider = String(rawValue.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawValue.platform || '').trim()
|
||||
const shopId = String(rawValue.shopId || '').trim()
|
||||
const shopName = String(rawValue.shopName || '').trim()
|
||||
const khhaoShopId = String(rawValue.khhaoShopId || '').trim()
|
||||
const skuCode = String(rawValue.skuCode || '').trim()
|
||||
const skuName = String(rawValue.skuName || '').trim()
|
||||
const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review'
|
||||
@@ -72,6 +74,8 @@ function normalizeOrderFulfillmentBinding(rawValue) {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName,
|
||||
khhaoShopId,
|
||||
skuCode,
|
||||
skuName,
|
||||
profileKey,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createOrder, findOrderByPlatformOrderId, updateOrder } from '../../repositories/order-repo.js'
|
||||
import {
|
||||
createOrder,
|
||||
findOrderByPlatformOrderId,
|
||||
findOrderByPlatformOrderIdCandidates,
|
||||
updateOrder,
|
||||
} from '../../repositories/order-repo.js'
|
||||
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
||||
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||
@@ -14,12 +19,18 @@ export async function upsertOrderFromWebhook(event) {
|
||||
|
||||
export async function upsertOrderFromSource(event, { sourceLabel = 'source' } = {}) {
|
||||
const now = nowIso()
|
||||
const existing = await findOrderByPlatformOrderId({
|
||||
const exactExisting = await findOrderByPlatformOrderId({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
shopId: event.shopId,
|
||||
platformOrderId: event.platformOrderId,
|
||||
})
|
||||
const existing = exactExisting || await findOrderByPlatformOrderIdCandidates({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
shopIds: resolveEventShopIdCandidates(event),
|
||||
platformOrderId: event.platformOrderId,
|
||||
})
|
||||
|
||||
logWebhook('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, {
|
||||
provider: event.provider,
|
||||
@@ -155,6 +166,13 @@ export async function upsertOrderFromSource(event, { sourceLabel = 'source' } =
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEventShopIdCandidates(event) {
|
||||
return [...new Set([
|
||||
String(event?.shopId || '').trim(),
|
||||
...(Array.isArray(event?.shopIdAliases) ? event.shopIdAliases : []).map((item) => String(item || '').trim()),
|
||||
].filter(Boolean))]
|
||||
}
|
||||
|
||||
const ORDER_STATUS_PRIORITY = {
|
||||
created: 0,
|
||||
paid: 1,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-check
|
||||
|
||||
import { parseAmountToFen } from '../../../utils/money.js'
|
||||
import { resolveKuaishouEticketShopConfig } from '../kuaishou-eticket/source-config-service.js'
|
||||
|
||||
export function mapKhhaoOrderPreviewList(items = []) {
|
||||
return (Array.isArray(items) ? items : []).map((item) => mapKhhaoOrderPreview(item))
|
||||
@@ -10,11 +11,13 @@ export function mapKhhaoOrderToSourceEvent(item = {}) {
|
||||
const preview = mapKhhaoOrderPreview(item)
|
||||
const orderStatus = resolveKhhaoOrderStatus(preview.status)
|
||||
const payStatus = resolveKhhaoPayStatus(preview.status)
|
||||
const sourceShopId = String(preview.khhaoShopId || preview.shopId || '').trim()
|
||||
|
||||
return {
|
||||
provider: 'khhao',
|
||||
platform: preview.platform || 'unknown',
|
||||
shopId: preview.shopId,
|
||||
shopId: sourceShopId,
|
||||
shopIdAliases: uniqueNonEmptyValues([sourceShopId, preview.shopId, ...(preview.shopIdAliases || [])]),
|
||||
shopName: preview.shopName,
|
||||
platformOrderId: preview.platformOrderId,
|
||||
orderStatus,
|
||||
@@ -46,14 +49,25 @@ export function mapKhhaoOrderPreview(item = {}) {
|
||||
const raw = isPlainObject(item) ? item : {}
|
||||
const platform = resolveKhhaoPlatform(raw.pingtai)
|
||||
const quantity = normalizeQuantity(raw.num)
|
||||
const internalShopId = String(raw.shopid || '').trim()
|
||||
const shopName = String(raw.shopName || '').trim()
|
||||
const resolvedShopIdentity = resolveKhhaoShopIdentity({
|
||||
platform,
|
||||
internalShopId,
|
||||
shopName,
|
||||
})
|
||||
|
||||
return {
|
||||
provider: 'khhao',
|
||||
platform,
|
||||
platformLabel: String(raw.pingtaiName || '').trim(),
|
||||
platformOrderId: String(raw.ordersn || '').trim(),
|
||||
shopId: String(raw.shopid || '').trim(),
|
||||
shopName: String(raw.shopName || '').trim(),
|
||||
shopId: resolvedShopIdentity.shopId,
|
||||
kuaishouShopId: resolvedShopIdentity.officialShopId,
|
||||
shopIdAliases: resolvedShopIdentity.shopIdAliases,
|
||||
khhaoShopId: internalShopId,
|
||||
internalShopId,
|
||||
shopName,
|
||||
itemId: String(raw.goodid || '').trim(),
|
||||
itemTitle: String(raw.goodName || '').trim(),
|
||||
skuCode: String(raw.sku || '').trim(),
|
||||
@@ -66,6 +80,32 @@ export function mapKhhaoOrderPreview(item = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKhhaoShopIdentity({ platform = '', internalShopId = '', shopName = '' } = {}) {
|
||||
const fallbackShopId = String(internalShopId || '').trim()
|
||||
const normalizedShopName = String(shopName || '').trim()
|
||||
|
||||
if (String(platform || '').trim() !== 'kuaishou') {
|
||||
return {
|
||||
shopId: fallbackShopId,
|
||||
officialShopId: '',
|
||||
shopIdAliases: fallbackShopId ? [fallbackShopId] : [],
|
||||
}
|
||||
}
|
||||
|
||||
const matchedShop = resolveKuaishouEticketShopConfig({
|
||||
shopId: fallbackShopId,
|
||||
shopName: normalizedShopName,
|
||||
})
|
||||
const resolvedShopId = String(matchedShop?.shopId || '').trim() || fallbackShopId
|
||||
const shopIdAliases = uniqueNonEmptyValues([resolvedShopId, fallbackShopId])
|
||||
|
||||
return {
|
||||
shopId: resolvedShopId,
|
||||
officialShopId: String(matchedShop?.shopId || '').trim(),
|
||||
shopIdAliases,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveKhhaoPlatform(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
@@ -121,3 +161,9 @@ function normalizePaidAt(value) {
|
||||
function isPlainObject(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
function uniqueNonEmptyValues(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean))]
|
||||
}
|
||||
|
||||
@@ -36,6 +36,32 @@ export function findKuaishouEticketShopConfig(shopId, source = getKuaishouEticke
|
||||
return listKuaishouEticketShopConfigs(source).find((item) => String(item.shopId || '').trim() === normalizedShopId) || null
|
||||
}
|
||||
|
||||
export function findKuaishouEticketShopConfigByName(kshopName, source = getKuaishouEticketSourceConfig()) {
|
||||
const normalizedName = String(kshopName || '').trim()
|
||||
if (!normalizedName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return listKuaishouEticketShopConfigs(source).find((item) => String(item.kshopName || '').trim() === normalizedName) || null
|
||||
}
|
||||
|
||||
export function resolveKuaishouEticketShopConfig(
|
||||
{ shopId = '', kshopName = '', shopName = '' } = {},
|
||||
source = getKuaishouEticketSourceConfig(),
|
||||
) {
|
||||
const byId = findKuaishouEticketShopConfig(shopId, source)
|
||||
if (byId) {
|
||||
return byId
|
||||
}
|
||||
|
||||
const normalizedName = String(kshopName || shopName || '').trim()
|
||||
if (!normalizedName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return findKuaishouEticketShopConfigByName(normalizedName, source)
|
||||
}
|
||||
|
||||
export function getFirstAvailableKuaishouEticketShop(source = getKuaishouEticketSourceConfig()) {
|
||||
return listKuaishouEticketShopConfigs(source).find((item) => item.enabled !== false && String(item.cookie || '').trim()) || null
|
||||
}
|
||||
|
||||
@@ -300,6 +300,8 @@ export {}
|
||||
* provider?: string
|
||||
* platform?: string
|
||||
* shopId?: string
|
||||
* shopName?: string
|
||||
* khhaoShopId?: string
|
||||
* skuCode?: string
|
||||
* skuName?: string
|
||||
* profileKey?: string
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Vendored
+2
@@ -15,6 +15,8 @@ declare module 'vue' {
|
||||
AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
|
||||
@@ -508,7 +508,7 @@ export function dispatchAdminTaskKuaishouCloudFulfillment(
|
||||
taskId: number | string,
|
||||
payload: {
|
||||
ticketCode?: string
|
||||
},
|
||||
} = {},
|
||||
) {
|
||||
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`, payload)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export function fetchClaimSessionSummary(token: string) {
|
||||
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}/session/summary`)
|
||||
}
|
||||
|
||||
export function verifyKuaishouCloudClaimTicket(
|
||||
token: string,
|
||||
payload: {
|
||||
ticketCode: string
|
||||
},
|
||||
) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/verify-ticket`, payload)
|
||||
}
|
||||
|
||||
export function refreshClaimSession(token: string) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/session/refresh`, {})
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@ export interface AdminKhhaoOrderPreview {
|
||||
platformLabel: string
|
||||
platformOrderId: string
|
||||
shopId: string
|
||||
kuaishouShopId: string
|
||||
khhaoShopId: string
|
||||
shopName: string
|
||||
itemId: string
|
||||
itemTitle: string
|
||||
@@ -321,6 +323,8 @@ export interface AdminFulfillmentBindingConfigItem {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
khhaoShopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
@@ -693,6 +697,10 @@ export interface AdminTaskDetail {
|
||||
ticket: {
|
||||
code: string
|
||||
capturedAt: string | null
|
||||
status: string
|
||||
verifiedAt: string | null
|
||||
goodsTitle: string
|
||||
leftCount: number
|
||||
}
|
||||
binding: {
|
||||
prepareStatus: string
|
||||
@@ -729,6 +737,8 @@ export interface AdminTaskDetail {
|
||||
status: string
|
||||
shopId: string
|
||||
autoConsumeEnabled: boolean
|
||||
consumedAt: string | null
|
||||
errorMessage: string
|
||||
}
|
||||
notes: string
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export type ClaimTaskStatus =
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
| 'waiting_inventory'
|
||||
| 'pending_binding_prepare'
|
||||
| 'waiting_binding'
|
||||
| 'dispatched_pending_return'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cdk_reserved'
|
||||
| 'link_generated'
|
||||
| 'claimed'
|
||||
@@ -58,12 +63,49 @@ export interface ClaimResultInfo {
|
||||
screenshotUrl: string
|
||||
}
|
||||
|
||||
export interface ClaimKuaishouCloudFlowInfo {
|
||||
flowType: 'kuaishou_cloud'
|
||||
shopId: string
|
||||
shopName: string
|
||||
guideImages: string[]
|
||||
ticket: {
|
||||
code: string
|
||||
status: string
|
||||
capturedAt: string | null
|
||||
verifiedAt: string | null
|
||||
goodsTitle: string
|
||||
leftCount: number
|
||||
}
|
||||
binding: {
|
||||
prepareStatus: string
|
||||
bindUrl: string
|
||||
bindPreparedAt: string | null
|
||||
vnPhone: string
|
||||
}
|
||||
dispatch: {
|
||||
status: string
|
||||
dispatchAt: string | null
|
||||
note: string
|
||||
}
|
||||
returnNumber: {
|
||||
status: string
|
||||
returnedAt: string | null
|
||||
}
|
||||
consume: {
|
||||
status: string
|
||||
consumedAt: string | null
|
||||
errorMessage: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClaimDetailData {
|
||||
tokenStatus: ClaimTokenStatus
|
||||
claimUrl: string
|
||||
flowType: 'tencent_claim' | 'kuaishou_cloud' | (string & {})
|
||||
task: ClaimTaskInfo
|
||||
order: ClaimOrderInfo
|
||||
orderItem: ClaimOrderItemInfo
|
||||
session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null
|
||||
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
|
||||
result: ClaimResultInfo | null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminFulfillmentBindingConfigs,
|
||||
fetchAdminKhhaoSourceConfig,
|
||||
fetchAdminKuaishouEticketSourceConfig,
|
||||
queryAdminKhhaoOrders,
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
saveAdminFulfillmentBindingConfigs,
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
AdminFulfillmentLookupItem,
|
||||
AdminFulfillmentLookupResult,
|
||||
AdminKhhaoOrderPreview,
|
||||
AdminKuaishouEticketShopConfigItem,
|
||||
AdminObservedProductItem,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
@@ -24,6 +26,8 @@ type EditableBinding = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
khhaoShopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
@@ -39,6 +43,8 @@ type SaveBindingPayload = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
khhaoShopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
profileKey: string
|
||||
@@ -54,7 +60,7 @@ type SaveBindingPayload = {
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationField = 'skuCode' | 'match' | 'profileKey'
|
||||
type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId' | 'khhaoShopId'
|
||||
|
||||
type ValidationState = {
|
||||
bindingId: string
|
||||
@@ -75,6 +81,7 @@ const errorMessage = ref('')
|
||||
const filePath = ref('')
|
||||
const bindings = ref<EditableBinding[]>([])
|
||||
const observedProducts = ref<AdminObservedProductItem[]>([])
|
||||
const kuaishouShopOptions = ref<AdminKuaishouEticketShopConfigItem[]>([])
|
||||
const lookupLoading = ref(false)
|
||||
const lookupErrorMessage = ref('')
|
||||
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
|
||||
@@ -119,8 +126,10 @@ const pendingObservedCount = computed(() => observedProducts.value.filter((item)
|
||||
|
||||
type ImportableProductCandidate = Pick<
|
||||
AdminObservedProductItem,
|
||||
'provider' | 'platform' | 'shopId' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
|
||||
>
|
||||
'provider' | 'platform' | 'shopId' | 'shopName' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
|
||||
> & {
|
||||
khhaoShopId?: string
|
||||
}
|
||||
|
||||
function createEmptyBinding(): EditableBinding {
|
||||
return {
|
||||
@@ -128,6 +137,8 @@ function createEmptyBinding(): EditableBinding {
|
||||
provider: 'agiso',
|
||||
platform: '',
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
khhaoShopId: '',
|
||||
skuCode: '',
|
||||
skuName: '',
|
||||
profileKey: 'manual_review',
|
||||
@@ -141,11 +152,14 @@ function createEmptyBinding(): EditableBinding {
|
||||
}
|
||||
|
||||
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
|
||||
return {
|
||||
const matchedShop = resolveKuaishouShopById(item.shopId) || resolveKuaishouShopByName(item.shopName || '')
|
||||
const binding = {
|
||||
id: crypto.randomUUID(),
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
shopId: item.shopId,
|
||||
shopId: matchedShop?.shopId || item.shopId,
|
||||
shopName: item.shopName || matchedShop?.kshopName || '',
|
||||
khhaoShopId: item.khhaoShopId || '',
|
||||
skuCode: item.skuCode,
|
||||
skuName: item.skuName,
|
||||
profileKey: item.profileKey || 'manual_review',
|
||||
@@ -156,6 +170,9 @@ function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBi
|
||||
externalSkuName: item.match.externalSkuName,
|
||||
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
|
||||
}
|
||||
|
||||
syncBindingShopSelection(binding)
|
||||
return binding
|
||||
}
|
||||
|
||||
function hasMatchCondition(item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
|
||||
@@ -203,16 +220,64 @@ function getBindingStatusLabel(binding: EditableBinding) {
|
||||
}
|
||||
|
||||
function getBindingSummary(binding: EditableBinding) {
|
||||
const shopLabel = isKhhaoKuaishouBinding(binding)
|
||||
? [
|
||||
binding.shopName.trim() || binding.shopId.trim() || '未选快手店铺',
|
||||
binding.khhaoShopId.trim() ? `khhao#${binding.khhaoShopId.trim()}` : '',
|
||||
].filter(Boolean).join(' / ')
|
||||
: (binding.shopName.trim() || binding.shopId.trim() || '跨店铺')
|
||||
const parts = [
|
||||
binding.provider.trim() || 'agiso',
|
||||
binding.platform.trim() || '未选平台',
|
||||
binding.shopId.trim() || '跨店铺',
|
||||
shopLabel,
|
||||
binding.profileKey.trim() || 'manual_review',
|
||||
]
|
||||
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
function isKhhaoKuaishouBinding(binding: Pick<EditableBinding, 'provider' | 'platform'>) {
|
||||
return binding.provider.trim() === 'khhao' && binding.platform.trim() === 'kuaishou'
|
||||
}
|
||||
|
||||
function resolveKuaishouShopById(shopId: string) {
|
||||
const normalizedShopId = shopId.trim()
|
||||
return kuaishouShopOptions.value.find((item) => item.shopId === normalizedShopId) || null
|
||||
}
|
||||
|
||||
function resolveKuaishouShopByName(shopName: string) {
|
||||
const normalizedShopName = shopName.trim()
|
||||
return kuaishouShopOptions.value.find((item) => item.kshopName === normalizedShopName) || null
|
||||
}
|
||||
|
||||
function getKuaishouShopOptionLabel(item: AdminKuaishouEticketShopConfigItem) {
|
||||
return item.kshopName.trim()
|
||||
? `${item.kshopName} (${item.shopId})`
|
||||
: item.shopId
|
||||
}
|
||||
|
||||
function syncBindingShopSelection(binding: EditableBinding) {
|
||||
if (!isKhhaoKuaishouBinding(binding)) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedById = resolveKuaishouShopById(binding.shopId)
|
||||
if (matchedById) {
|
||||
binding.shopId = matchedById.shopId
|
||||
binding.shopName = matchedById.kshopName
|
||||
return
|
||||
}
|
||||
|
||||
const matchedByName = resolveKuaishouShopByName(binding.shopName)
|
||||
if (matchedByName) {
|
||||
binding.shopId = matchedByName.shopId
|
||||
binding.shopName = matchedByName.kshopName
|
||||
return
|
||||
}
|
||||
|
||||
binding.shopId = ''
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
@@ -223,11 +288,13 @@ async function loadConfigs() {
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [response, khhaoSourceResponse] = await Promise.all([
|
||||
const [response, khhaoSourceResponse, kuaishouSourceResponse] = await Promise.all([
|
||||
fetchAdminFulfillmentBindingConfigs(),
|
||||
fetchAdminKhhaoSourceConfig(),
|
||||
fetchAdminKuaishouEticketSourceConfig(),
|
||||
])
|
||||
filePath.value = response.data.filePath
|
||||
kuaishouShopOptions.value = kuaishouSourceResponse.data.source.shops.filter((item) => item.enabled !== false)
|
||||
const nextBindings = response.data.bindings.map(mapEditableBinding)
|
||||
bindings.value = nextBindings
|
||||
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
|
||||
@@ -259,11 +326,13 @@ function removeBinding(id: string) {
|
||||
}
|
||||
|
||||
function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding {
|
||||
return {
|
||||
const binding = {
|
||||
id: crypto.randomUUID(),
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
shopId: item.shopId,
|
||||
shopName: item.shopName,
|
||||
khhaoShopId: item.khhaoShopId || '',
|
||||
skuCode: '',
|
||||
skuName: item.externalSkuName,
|
||||
profileKey: 'manual_review',
|
||||
@@ -274,6 +343,9 @@ function createBindingFromProductCandidate(item: ImportableProductCandidate): Ed
|
||||
externalSkuName: item.externalSkuName,
|
||||
resolvedSkuName: item.externalSkuName,
|
||||
}
|
||||
|
||||
syncBindingShopSelection(binding)
|
||||
return binding
|
||||
}
|
||||
|
||||
function importObservedProduct(item: ImportableProductCandidate) {
|
||||
@@ -298,7 +370,11 @@ function importKhhaoProduct(item: AdminKhhaoOrderPreview) {
|
||||
importObservedProduct({
|
||||
provider: item.provider || 'khhao',
|
||||
platform: item.platform || 'unknown',
|
||||
shopId: item.shopId,
|
||||
shopId: isKhhaoKuaishouBinding({ provider: item.provider || 'khhao', platform: item.platform || 'unknown' })
|
||||
? (item.kuaishouShopId || resolveKuaishouShopByName(item.shopName)?.shopId || '')
|
||||
: item.shopId,
|
||||
shopName: item.shopName,
|
||||
khhaoShopId: item.khhaoShopId || item.shopId,
|
||||
externalSkuCode: item.skuCode,
|
||||
externalItemId: item.itemId,
|
||||
externalSkuName: item.itemTitle,
|
||||
@@ -314,10 +390,14 @@ function isKhhaoProductImported(platformOrderId: string) {
|
||||
}
|
||||
|
||||
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
|
||||
syncBindingShopSelection(item)
|
||||
|
||||
return {
|
||||
provider: item.provider.trim() || 'agiso',
|
||||
platform: item.platform.trim(),
|
||||
shopId: item.shopId.trim(),
|
||||
shopName: item.shopName.trim(),
|
||||
khhaoShopId: item.khhaoShopId.trim(),
|
||||
skuCode: item.skuCode.trim(),
|
||||
skuName: item.skuName.trim(),
|
||||
profileKey: item.profileKey.trim() || 'manual_review',
|
||||
@@ -338,6 +418,8 @@ function hasBindingContent(item: SaveBindingPayload) {
|
||||
return Boolean(
|
||||
item.platform
|
||||
|| item.shopId
|
||||
|| item.shopName
|
||||
|| item.khhaoShopId
|
||||
|| item.skuCode
|
||||
|| item.skuName
|
||||
|| item.match.externalSkuCode
|
||||
@@ -353,6 +435,24 @@ function hasBindingContent(item: SaveBindingPayload) {
|
||||
|
||||
function resolveBindingValidationState(item: SaveBindingPayload, index: number, bindingId: string): ValidationState {
|
||||
const label = `第 ${index + 1} 条规则`
|
||||
const provider = item.provider.trim() || 'agiso'
|
||||
const platform = item.platform.trim()
|
||||
|
||||
if (provider === 'khhao' && platform === 'kuaishou' && (!item.shopId || !item.shopName)) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'shopId',
|
||||
message: `${label} 需要选择已配置的快手官方店铺`,
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'khhao' && platform === 'kuaishou' && !item.khhaoShopId) {
|
||||
return {
|
||||
bindingId,
|
||||
field: 'khhaoShopId',
|
||||
message: `${label} 需要填写 khhao 店铺 ID`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.skuCode) {
|
||||
return {
|
||||
@@ -397,6 +497,8 @@ async function focusValidationTarget(state: ValidationState) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
const selectors: Record<ValidationField, string> = {
|
||||
khhaoShopId: '[data-field="khhaoShopId"]',
|
||||
shopId: '[data-field="shopId"]',
|
||||
skuCode: '[data-field="skuCode"]',
|
||||
match: '[data-field="externalSkuCode"]',
|
||||
profileKey: '[data-field="profileKey"]',
|
||||
@@ -426,6 +528,16 @@ function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSku
|
||||
return validationState.value.field === field
|
||||
}
|
||||
|
||||
function handleBindingProviderPlatformChange(binding: EditableBinding) {
|
||||
syncBindingShopSelection(binding)
|
||||
clearValidationState()
|
||||
}
|
||||
|
||||
function handleKuaishouShopChange(binding: EditableBinding) {
|
||||
syncBindingShopSelection(binding)
|
||||
clearValidationState()
|
||||
}
|
||||
|
||||
async function saveConfigs() {
|
||||
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
|
||||
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
|
||||
@@ -649,18 +761,90 @@ onMounted(loadConfigs)
|
||||
<div v-else class="binding-grid">
|
||||
<label class="field-block">
|
||||
<span>渠道</span>
|
||||
<input v-model="binding.provider" class="text-input" placeholder="agiso" @input="clearValidationState" />
|
||||
<input
|
||||
v-model="binding.provider"
|
||||
class="text-input"
|
||||
placeholder="agiso"
|
||||
@input="handleBindingProviderPlatformChange(binding)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>平台</span>
|
||||
<input v-model="binding.platform" class="text-input" placeholder="xianyu / taobao / pdd" @input="clearValidationState" />
|
||||
<input
|
||||
v-model="binding.platform"
|
||||
class="text-input"
|
||||
placeholder="xianyu / taobao / pdd"
|
||||
@input="handleBindingProviderPlatformChange(binding)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<input v-model="binding.shopId" class="text-input" placeholder="留空表示跨店铺通用" @input="clearValidationState" />
|
||||
</label>
|
||||
<template v-if="isKhhaoKuaishouBinding(binding)">
|
||||
<label class="field-block">
|
||||
<span>khhao 店铺 ID</span>
|
||||
<input
|
||||
v-model="binding.khhaoShopId"
|
||||
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'khhaoShopId') }]"
|
||||
data-field="khhaoShopId"
|
||||
placeholder="例如 12 / 13 / 14"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
<small class="field-help">这里填 khhao 系统里的店铺编号,专门用于同步订单命中规则。</small>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>快手店铺</span>
|
||||
<select
|
||||
v-model="binding.shopId"
|
||||
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
|
||||
data-field="shopId"
|
||||
@change="handleKuaishouShopChange(binding)"
|
||||
>
|
||||
<option value="">请选择已配置的快手官方店铺</option>
|
||||
<option
|
||||
v-for="shop in kuaishouShopOptions"
|
||||
:key="shop.shopId"
|
||||
:value="shop.shopId"
|
||||
>
|
||||
{{ getKuaishouShopOptionLabel(shop) }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="field-help">这里会直接写入快手官方店铺 ID,不再使用 khhao 内部店铺编号。</small>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>快手店铺名</span>
|
||||
<input
|
||||
:value="binding.shopName || ''"
|
||||
class="text-input text-input--readonly"
|
||||
placeholder="选择店铺后自动带出"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<label class="field-block">
|
||||
<span>店铺 ID</span>
|
||||
<input
|
||||
v-model="binding.shopId"
|
||||
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
|
||||
data-field="shopId"
|
||||
placeholder="留空表示跨店铺通用"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field-block">
|
||||
<span>店铺名称</span>
|
||||
<input
|
||||
v-model="binding.shopName"
|
||||
class="text-input"
|
||||
placeholder="用于保存和识别店铺"
|
||||
@input="clearValidationState"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<label class="field-block">
|
||||
<span>优先级</span>
|
||||
@@ -825,7 +1009,8 @@ onMounted(loadConfigs)
|
||||
<td>
|
||||
<div class="cell-stack">
|
||||
<strong>{{ item.shopName || item.shopId || '-' }}</strong>
|
||||
<span class="cell-subtle">ID: {{ item.shopId || '-' }}</span>
|
||||
<span class="cell-subtle">khhao ID: {{ item.khhaoShopId || item.shopId || '-' }}</span>
|
||||
<span v-if="item.kuaishouShopId" class="cell-subtle">快手 ID: {{ item.kuaishouShopId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -1379,6 +1564,11 @@ onMounted(loadConfigs)
|
||||
box-shadow: 0 0 0 3px rgba(254, 226, 226, 0.95);
|
||||
}
|
||||
|
||||
.text-input--readonly {
|
||||
color: #475467;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.binding-inline-error {
|
||||
margin: 10px 0 0;
|
||||
color: #b42318;
|
||||
|
||||
@@ -36,9 +36,6 @@ const manualDispatchForm = reactive({
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
const kuaishouCloudForm = reactive({
|
||||
ticketCode: '',
|
||||
})
|
||||
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
|
||||
const canCloseTasks = computed(() => hasAdminRole('support'))
|
||||
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
|
||||
@@ -74,7 +71,6 @@ async function loadDetail() {
|
||||
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
|
||||
detail.value = response.data
|
||||
syncManualDispatchForm(response.data)
|
||||
syncKuaishouCloudForm(response.data)
|
||||
await loadScreenshotPreview(response.data)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
|
||||
@@ -142,10 +138,6 @@ function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
|
||||
manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
|
||||
}
|
||||
|
||||
function syncKuaishouCloudForm(taskDetail: AdminTaskDetail) {
|
||||
kuaishouCloudForm.ticketCode = taskDetail.kuaishouCloudFulfillment?.ticket.code || ''
|
||||
}
|
||||
|
||||
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||||
if (!detail.value) {
|
||||
return
|
||||
@@ -186,9 +178,7 @@ async function submitKuaishouCloudDispatch() {
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId, {
|
||||
ticketCode: kuaishouCloudForm.ticketCode,
|
||||
}),
|
||||
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'已完成绑定确认并发货',
|
||||
`确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把“确认绑定完成”和“发货”合并为一步。`,
|
||||
)
|
||||
@@ -611,17 +601,28 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="flow-form-card">
|
||||
<label class="field-block">
|
||||
<span>客户提供的卡券号</span>
|
||||
<el-input
|
||||
v-model="kuaishouCloudForm.ticketCode"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="120"
|
||||
placeholder="客户下单后提供的卡券号,可在发货时一并记录"
|
||||
/>
|
||||
</label>
|
||||
<p class="manual-hint">“确认绑定完成并发货”已经合并为一个按钮,不再拆成两步。</p>
|
||||
<div class="flow-grid">
|
||||
<article class="flow-block">
|
||||
<h4>客户领取页</h4>
|
||||
<div class="flow-meta-list">
|
||||
<div><span>客户链接</span><strong>{{ claimUrl || '-' }}</strong></div>
|
||||
<div><span>Token 状态</span><strong>{{ detail.claimToken?.status || '-' }}</strong></div>
|
||||
<div><span>首次提交</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}</strong></div>
|
||||
<div><span>校验时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.verifiedAt) }}</strong></div>
|
||||
</div>
|
||||
<p class="manual-hint">客户会先在这个页面查看图文指引并提交核销码,校验通过后才会自动跳转绑定链接。</p>
|
||||
</article>
|
||||
|
||||
<article class="flow-block">
|
||||
<h4>核销码状态</h4>
|
||||
<div class="flow-meta-list">
|
||||
<div><span>核销码</span><strong>{{ kuaishouCloudFlow.ticket.code || '-' }}</strong></div>
|
||||
<div><span>校验状态</span><strong>{{ kuaishouCloudFlow.ticket.status || '-' }}</strong></div>
|
||||
<div><span>商品标题</span><strong>{{ kuaishouCloudFlow.ticket.goodsTitle || '-' }}</strong></div>
|
||||
<div><span>剩余次数</span><strong>{{ kuaishouCloudFlow.ticket.leftCount || 0 }}</strong></div>
|
||||
</div>
|
||||
<p class="manual-hint">“确认绑定完成并发货”已经合并为一个按钮,只有客户提交并校验过核销码后才能继续。</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="flow-grid">
|
||||
@@ -643,6 +644,8 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
<div><span>自动退号</span><strong>{{ kuaishouCloudFlow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}</strong></div>
|
||||
<div><span>自动核销</span><strong>{{ kuaishouCloudFlow.consume.autoConsumeEnabled ? '开启' : '关闭' }}</strong></div>
|
||||
<div><span>核销店铺</span><strong>{{ kuaishouCloudFlow.consume.shopId || '-' }}</strong></div>
|
||||
<div><span>核销时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.consume.consumedAt) }}</strong></div>
|
||||
<div><span>核销异常</span><strong>{{ kuaishouCloudFlow.consume.errorMessage || '-' }}</strong></div>
|
||||
<div><span>备注</span><strong>{{ kuaishouCloudFlow.notes || '-' }}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
|
||||
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
|
||||
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
|
||||
import { useClaimPage } from '@/composables/useClaimPage'
|
||||
|
||||
const route = useRoute()
|
||||
const token = computed(() => String(route.params.token || '').trim())
|
||||
|
||||
const {
|
||||
detailLoading,
|
||||
roleConfirmLoading,
|
||||
sessionLoading,
|
||||
redeemLoading,
|
||||
loginType,
|
||||
loginTypeLabel,
|
||||
loginTabs,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
hasSession,
|
||||
qrImage,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
statusLabel,
|
||||
session,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
canConfirmRole,
|
||||
canRedeem,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
initButtonLabel,
|
||||
scanInstruction,
|
||||
screenshotEmptyTitle,
|
||||
screenshotEmptyMessage,
|
||||
screenshotUrl,
|
||||
showScreenshot,
|
||||
createSessionFlow,
|
||||
reloadSessionPage,
|
||||
closeSessionFlow,
|
||||
switchLoginType,
|
||||
confirmRoleNow,
|
||||
redeemNow,
|
||||
handleQrImageLoad,
|
||||
} = useClaimPage(token.value)
|
||||
|
||||
const qrPreviewVisible = ref(false)
|
||||
const screenshotPreviewVisible = ref(false)
|
||||
|
||||
const currentLoginTabLabel = computed(
|
||||
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
|
||||
)
|
||||
|
||||
const helperText = computed(() => {
|
||||
if (!task.value) {
|
||||
return '正在加载领取任务'
|
||||
}
|
||||
|
||||
if (task.value.requiresSupportReview) {
|
||||
return `订单 ${order.value?.platformOrderId || '-'} · 登录后请联系人工客服继续确认和兑换`
|
||||
}
|
||||
|
||||
return `订单 ${order.value?.platformOrderId || '-'} · 任务 ${task.value.taskNo}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="claim-page">
|
||||
<section class="workspace">
|
||||
<div class="page-toolbar">
|
||||
<div>
|
||||
<div class="brand-chip">订单领取</div>
|
||||
<p class="helper-copy">{{ helperText }}</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<div class="status-badge">{{ statusLabel }}</div>
|
||||
<RouterLink class="back-link" to="/tx/browser">调试页</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="order-card">
|
||||
<div class="order-meta">
|
||||
<article>
|
||||
<span>订单号</span>
|
||||
<strong>{{ order?.platformOrderId || '-' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>商品</span>
|
||||
<strong>{{ orderItem?.skuName || '-' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>数量</span>
|
||||
<strong>{{ orderItem?.quantity || 0 }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>任务号</span>
|
||||
<strong>{{ task?.taskNo || '-' }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="detailLoading" class="empty-block loading-block">
|
||||
<strong>领取信息加载中</strong>
|
||||
<p>正在校验链接并同步任务状态。</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="layout-grid">
|
||||
<TencentAuthCard
|
||||
:current-login-tab-label="currentLoginTabLabel"
|
||||
:has-session="hasSession"
|
||||
:init-button-label="initButtonLabel"
|
||||
:login-tabs="loginTabs"
|
||||
:login-type="loginType"
|
||||
:login-type-label="loginTypeLabel"
|
||||
:qr-figure-style="qrFigureStyle"
|
||||
:qr-image="qrImage"
|
||||
:scan-instruction="scanInstruction"
|
||||
:session-status="session?.status || ''"
|
||||
:session-id="session?.sessionId || ''"
|
||||
:session-loading="sessionLoading"
|
||||
:session-notice="sessionNotice"
|
||||
@create-session="createSessionFlow"
|
||||
@close-session="closeSessionFlow"
|
||||
@open-qr-preview="qrPreviewVisible = true"
|
||||
@qr-image-load="handleQrImageLoad"
|
||||
@reload-session="reloadSessionPage"
|
||||
@switch-login-type="switchLoginType"
|
||||
/>
|
||||
|
||||
<div class="control-column">
|
||||
<TencentRedeemPanel
|
||||
:can-redeem="canRedeem"
|
||||
:can-confirm-role="canConfirmRole"
|
||||
confirm-action-label="确认当前角色并继续"
|
||||
:confirm-action-loading="roleConfirmLoading"
|
||||
:confirm-action-visible="!roleConfirmed && !task?.requiresSupportReview"
|
||||
:confirm-readonly="true"
|
||||
:hide-redeem-form="true"
|
||||
:login-type-label="loginTypeLabel"
|
||||
:max-attempts="6"
|
||||
:redeem-blocked-reason="redeemBlockedReason"
|
||||
:redeem-button-label="redeemButtonLabel"
|
||||
:redeem-code="''"
|
||||
:redeem-loading="redeemLoading"
|
||||
:role-confirmed="roleConfirmed"
|
||||
:role-facts="roleFacts"
|
||||
:role-ready="roleReady"
|
||||
:status-label="statusLabel"
|
||||
@confirm-role="confirmRoleNow"
|
||||
@redeem="redeemNow"
|
||||
@update:max-attempts="() => undefined"
|
||||
@update:redeem-code="() => undefined"
|
||||
@update:role-confirmed="() => undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TencentResultPanel
|
||||
:result-facts="resultFacts"
|
||||
:screenshot-empty-message="screenshotEmptyMessage"
|
||||
:screenshot-empty-title="screenshotEmptyTitle"
|
||||
:screenshot-url="screenshotUrl"
|
||||
:show-screenshot="showScreenshot"
|
||||
@open-screenshot-preview="screenshotPreviewVisible = true"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="qrPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
:width="qrPreviewWidth"
|
||||
>
|
||||
<div class="qr-preview-body">
|
||||
<img
|
||||
class="preview-image qr-preview-image"
|
||||
:src="qrImage"
|
||||
:alt="`Claim ${loginTypeLabel} QR Preview`"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="screenshotPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
width="1080px"
|
||||
>
|
||||
<img class="preview-image" :src="screenshotUrl" alt="Claim Screenshot Preview" />
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.claim-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(73, 136, 255, 0.12), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(87, 194, 150, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f4f8fc 0%, #eef4fb 100%);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.page-toolbar,
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand-chip,
|
||||
.status-badge,
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(86, 108, 138, 0.12);
|
||||
color: #27405e;
|
||||
box-shadow: 0 12px 32px rgba(30, 49, 78, 0.08);
|
||||
}
|
||||
|
||||
.brand-chip {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.helper-copy {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
padding: 18px 22px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: 0 22px 60px rgba(30, 41, 59, 0.08);
|
||||
}
|
||||
|
||||
.order-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.order-meta article {
|
||||
padding: 14px 16px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.92));
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.order-meta span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.order-meta strong {
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.95fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.control-column {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.loading-block strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.preview-dialog :deep(.el-dialog__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.qr-preview-body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
|
||||
}
|
||||
|
||||
.qr-preview-image {
|
||||
max-width: 100%;
|
||||
max-height: min(78vh, 920px);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.claim-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-toolbar,
|
||||
.toolbar-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.order-meta,
|
||||
.layout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,374 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
|
||||
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
|
||||
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
|
||||
import { useClaimPage } from '@/composables/useClaimPage'
|
||||
import { fetchClaimDetail } from '@/services/claim'
|
||||
|
||||
import ClaimTencentView from './ClaimTencentView.vue'
|
||||
import KuaishouCloudClaimView from './KuaishouCloudClaimView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const token = computed(() => String(route.params.token || '').trim())
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const flowType = ref('tencent_claim')
|
||||
|
||||
const {
|
||||
detailLoading,
|
||||
roleConfirmLoading,
|
||||
sessionLoading,
|
||||
redeemLoading,
|
||||
loginType,
|
||||
loginTypeLabel,
|
||||
loginTabs,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
hasSession,
|
||||
qrImage,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
statusLabel,
|
||||
session,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
canConfirmRole,
|
||||
canRedeem,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
initButtonLabel,
|
||||
scanInstruction,
|
||||
screenshotEmptyTitle,
|
||||
screenshotEmptyMessage,
|
||||
screenshotUrl,
|
||||
showScreenshot,
|
||||
createSessionFlow,
|
||||
reloadSessionPage,
|
||||
closeSessionFlow,
|
||||
switchLoginType,
|
||||
confirmRoleNow,
|
||||
redeemNow,
|
||||
handleQrImageLoad,
|
||||
} = useClaimPage(token.value)
|
||||
async function bootstrap() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const qrPreviewVisible = ref(false)
|
||||
const screenshotPreviewVisible = ref(false)
|
||||
try {
|
||||
const response = await fetchClaimDetail(token.value)
|
||||
flowType.value = String(response.data.flowType || response.data.task.executorKey || 'tencent_claim').trim()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const currentLoginTabLabel = computed(
|
||||
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
|
||||
const isKuaishouCloudFlow = computed(
|
||||
() => flowType.value === 'kuaishou_cloud' || flowType.value === 'kuaishou_ct_assisted',
|
||||
)
|
||||
|
||||
const helperText = computed(() => {
|
||||
if (!task.value) {
|
||||
return '正在加载领取任务'
|
||||
}
|
||||
|
||||
if (task.value.requiresSupportReview) {
|
||||
return `订单 ${order.value?.platformOrderId || '-'} · 登录后请联系人工客服继续确认和兑换`
|
||||
}
|
||||
|
||||
return `订单 ${order.value?.platformOrderId || '-'} · 任务 ${task.value.taskNo}`
|
||||
onMounted(() => {
|
||||
void bootstrap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="claim-page">
|
||||
<section class="workspace">
|
||||
<div class="page-toolbar">
|
||||
<div>
|
||||
<div class="brand-chip">订单领取</div>
|
||||
<p class="helper-copy">{{ helperText }}</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<div class="status-badge">{{ statusLabel }}</div>
|
||||
<RouterLink class="back-link" to="/tx/browser">调试页</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="order-card">
|
||||
<div class="order-meta">
|
||||
<article>
|
||||
<span>订单号</span>
|
||||
<strong>{{ order?.platformOrderId || '-' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>商品</span>
|
||||
<strong>{{ orderItem?.skuName || '-' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>数量</span>
|
||||
<strong>{{ orderItem?.quantity || 0 }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>任务号</span>
|
||||
<strong>{{ task?.taskNo || '-' }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="detailLoading" class="empty-block loading-block">
|
||||
<strong>领取信息加载中</strong>
|
||||
<p>正在校验链接并同步任务状态。</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="layout-grid">
|
||||
<TencentAuthCard
|
||||
:current-login-tab-label="currentLoginTabLabel"
|
||||
:has-session="hasSession"
|
||||
:init-button-label="initButtonLabel"
|
||||
:login-tabs="loginTabs"
|
||||
:login-type="loginType"
|
||||
:login-type-label="loginTypeLabel"
|
||||
:qr-figure-style="qrFigureStyle"
|
||||
:qr-image="qrImage"
|
||||
:scan-instruction="scanInstruction"
|
||||
:session-status="session?.status || ''"
|
||||
:session-id="session?.sessionId || ''"
|
||||
:session-loading="sessionLoading"
|
||||
:session-notice="sessionNotice"
|
||||
@create-session="createSessionFlow"
|
||||
@close-session="closeSessionFlow"
|
||||
@open-qr-preview="qrPreviewVisible = true"
|
||||
@qr-image-load="handleQrImageLoad"
|
||||
@reload-session="reloadSessionPage"
|
||||
@switch-login-type="switchLoginType"
|
||||
/>
|
||||
|
||||
<div class="control-column">
|
||||
<TencentRedeemPanel
|
||||
:can-redeem="canRedeem"
|
||||
:can-confirm-role="canConfirmRole"
|
||||
confirm-action-label="确认当前角色并继续"
|
||||
:confirm-action-loading="roleConfirmLoading"
|
||||
:confirm-action-visible="!roleConfirmed && !task?.requiresSupportReview"
|
||||
:confirm-readonly="true"
|
||||
:hide-redeem-form="true"
|
||||
:login-type-label="loginTypeLabel"
|
||||
:max-attempts="6"
|
||||
:redeem-blocked-reason="redeemBlockedReason"
|
||||
:redeem-button-label="redeemButtonLabel"
|
||||
:redeem-code="''"
|
||||
:redeem-loading="redeemLoading"
|
||||
:role-confirmed="roleConfirmed"
|
||||
:role-facts="roleFacts"
|
||||
:role-ready="roleReady"
|
||||
:status-label="statusLabel"
|
||||
@confirm-role="confirmRoleNow"
|
||||
@redeem="redeemNow"
|
||||
@update:max-attempts="() => undefined"
|
||||
@update:redeem-code="() => undefined"
|
||||
@update:role-confirmed="() => undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TencentResultPanel
|
||||
:result-facts="resultFacts"
|
||||
:screenshot-empty-message="screenshotEmptyMessage"
|
||||
:screenshot-empty-title="screenshotEmptyTitle"
|
||||
:screenshot-url="screenshotUrl"
|
||||
:show-screenshot="showScreenshot"
|
||||
@open-screenshot-preview="screenshotPreviewVisible = true"
|
||||
/>
|
||||
</template>
|
||||
<main v-if="loading" class="claim-shell loading-shell">
|
||||
<section class="shell-card">
|
||||
<strong>页面加载中</strong>
|
||||
<p>正在识别领取流程类型。</p>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="qrPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
:width="qrPreviewWidth"
|
||||
>
|
||||
<div class="qr-preview-body">
|
||||
<img
|
||||
class="preview-image qr-preview-image"
|
||||
:src="qrImage"
|
||||
:alt="`Claim ${loginTypeLabel} QR Preview`"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="screenshotPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
width="1080px"
|
||||
>
|
||||
<img class="preview-image" :src="screenshotUrl" alt="Claim Screenshot Preview" />
|
||||
</el-dialog>
|
||||
</main>
|
||||
|
||||
<main v-else-if="errorMessage" class="claim-shell error-shell">
|
||||
<section class="shell-card">
|
||||
<strong>无法打开领取页</strong>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<KuaishouCloudClaimView v-else-if="isKuaishouCloudFlow" :token="token" />
|
||||
<ClaimTencentView v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.claim-page {
|
||||
.claim-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(73, 136, 255, 0.12), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(87, 194, 150, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f4f8fc 0%, #eef4fb 100%);
|
||||
radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.page-toolbar,
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand-chip,
|
||||
.status-badge,
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(86, 108, 138, 0.12);
|
||||
color: #27405e;
|
||||
box-shadow: 0 12px 32px rgba(30, 49, 78, 0.08);
|
||||
}
|
||||
|
||||
.brand-chip {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.helper-copy {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
padding: 18px 22px;
|
||||
.shell-card {
|
||||
width: min(520px, 100%);
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
|
||||
}
|
||||
|
||||
.order-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-meta article {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.order-meta span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #708096;
|
||||
}
|
||||
|
||||
.order-meta strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #1f324a;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-column {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
min-height: 220px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px dashed #d5dfec;
|
||||
}
|
||||
|
||||
.loading-block strong {
|
||||
color: #2c4058;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-block p {
|
||||
margin: 8px 0 0;
|
||||
color: #6b7a91;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
.shell-card strong {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 10px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
:deep(.preview-dialog .el-dialog) {
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.preview-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.qr-preview-body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.qr-preview-image {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
@media (max-width: 1140px) {
|
||||
.layout-grid,
|
||||
.order-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.claim-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-toolbar,
|
||||
.toolbar-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.shell-card p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { fetchClaimDetail, verifyKuaishouCloudClaimTicket } from '@/services/claim'
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
|
||||
const props = defineProps<{
|
||||
token: string
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<ClaimDetailData | null>(null)
|
||||
const ticketCode = ref('')
|
||||
const expandedPanels = ref(['guide'])
|
||||
let pollTimer = 0
|
||||
|
||||
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
||||
const order = computed(() => detail.value?.order || null)
|
||||
const orderItem = computed(() => detail.value?.orderItem || null)
|
||||
const task = computed(() => detail.value?.task || null)
|
||||
const isCompleted = computed(() => flow.value?.consume.status === 'success' || task.value?.status === 'completed')
|
||||
const isWaitingSupport = computed(
|
||||
() => flow.value?.ticket.status === 'verified' && !isCompleted.value && flow.value?.dispatch.status !== 'success',
|
||||
)
|
||||
const canSubmitTicket = computed(() => {
|
||||
if (!flow.value?.binding.bindUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !['completed', 'manual_review', 'closed', 'expired'].includes(String(task.value?.status || '').trim())
|
||||
})
|
||||
const bindButtonLabel = computed(() => {
|
||||
if (flow.value?.ticket.status === 'verified') {
|
||||
return '继续打开绑定链接'
|
||||
}
|
||||
|
||||
return '校验后继续绑定'
|
||||
})
|
||||
const pageStatusText = computed(() => {
|
||||
if (isCompleted.value) {
|
||||
return '流程已完成'
|
||||
}
|
||||
|
||||
if (flow.value?.consume.status === 'failed') {
|
||||
return '核销异常'
|
||||
}
|
||||
|
||||
if (flow.value?.dispatch.status === 'success') {
|
||||
return '已发货'
|
||||
}
|
||||
|
||||
if (flow.value?.ticket.status === 'verified') {
|
||||
return '等待客服确认'
|
||||
}
|
||||
|
||||
if (flow.value?.binding.prepareStatus === 'ready') {
|
||||
return '等待填写核销码'
|
||||
}
|
||||
|
||||
return '等待系统准备'
|
||||
})
|
||||
|
||||
async function loadDetail(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchClaimDetail(props.token)
|
||||
detail.value = response.data
|
||||
if (!ticketCode.value && response.data.kuaishouCloudFulfillment?.ticket.code) {
|
||||
ticketCode.value = response.data.kuaishouCloudFulfillment.ticket.code
|
||||
}
|
||||
syncPolling()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
|
||||
stopPolling()
|
||||
} finally {
|
||||
if (!options.silent) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTicket() {
|
||||
const normalizedTicketCode = ticketCode.value.trim()
|
||||
if (!normalizedTicketCode) {
|
||||
showError('请先粘贴快手小店核销码')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
const response = await verifyKuaishouCloudClaimTicket(props.token, {
|
||||
ticketCode: normalizedTicketCode,
|
||||
})
|
||||
detail.value = response.data
|
||||
ticketCode.value = response.data.kuaishouCloudFulfillment?.ticket.code || normalizedTicketCode
|
||||
showSuccess('核销码校验成功,正在跳转绑定页')
|
||||
syncPolling()
|
||||
|
||||
const bindUrl = String(response.data.kuaishouCloudFulfillment?.binding.bindUrl || '').trim()
|
||||
if (bindUrl) {
|
||||
window.setTimeout(() => {
|
||||
window.location.assign(bindUrl)
|
||||
}, 600)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '核销码校验失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openBindUrl() {
|
||||
const bindUrl = String(flow.value?.binding.bindUrl || '').trim()
|
||||
if (!bindUrl) {
|
||||
showError('当前还没有可用绑定链接')
|
||||
return
|
||||
}
|
||||
|
||||
window.open(bindUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
function syncPolling() {
|
||||
const taskStatus = String(task.value?.status || '').trim()
|
||||
const shouldPoll = Boolean(flow.value) && !['completed', 'manual_review', 'failed', 'closed', 'expired'].includes(taskStatus)
|
||||
|
||||
if (!shouldPoll) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
if (pollTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = window.setInterval(() => {
|
||||
void loadDetail({ silent: true })
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDetail()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="kuaishou-claim-page">
|
||||
<section class="hero-card">
|
||||
<div class="hero-copy">
|
||||
<span class="eyebrow">快手小店客户领取</span>
|
||||
<h1>先获取核销码,再继续绑定游戏资源</h1>
|
||||
<p>
|
||||
订单 {{ order?.platformOrderId || '-' }} · 商品 {{ orderItem?.skuName || '-' }}。
|
||||
你只需要按图片指引找到快手小店核销码,提交成功后页面会自动跳转到绑定链接。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<span class="status-label">当前状态</span>
|
||||
<strong>{{ pageStatusText }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="placeholder-card">
|
||||
<strong>页面加载中</strong>
|
||||
<p>正在检查领取链接和当前任务进度。</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="placeholder-card error-card">
|
||||
<strong>无法继续处理</strong>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail && flow">
|
||||
<section class="info-grid">
|
||||
<article class="info-card">
|
||||
<span>核销码校验</span>
|
||||
<strong>{{ flow.ticket.status === 'verified' ? '已通过' : '待提交' }}</strong>
|
||||
<p>{{ flow.ticket.code || '还没有提交核销码' }}</p>
|
||||
</article>
|
||||
<article class="info-card">
|
||||
<span>绑定链接</span>
|
||||
<strong>{{ flow.binding.prepareStatus === 'ready' ? '已准备' : '准备中' }}</strong>
|
||||
<p>{{ flow.binding.bindPreparedAt || '系统准备完成后即可继续' }}</p>
|
||||
</article>
|
||||
<article class="info-card">
|
||||
<span>发货状态</span>
|
||||
<strong>{{ flow.dispatch.status === 'success' ? '已发货' : '等待客服' }}</strong>
|
||||
<p>{{ flow.dispatch.dispatchAt || flow.dispatch.note || '客服确认绑定信息无误后会执行发货' }}</p>
|
||||
</article>
|
||||
<article class="info-card">
|
||||
<span>快手核销</span>
|
||||
<strong>{{ flow.consume.status === 'success' ? '已完成' : flow.consume.status === 'failed' ? '异常' : '待完成' }}</strong>
|
||||
<p>{{ flow.consume.errorMessage || flow.consume.consumedAt || '发货和退号完成后会自动核销' }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="form-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>第 1 步:复制并粘贴快手小店核销码</h2>
|
||||
<p>核销码会使用这笔订单对应的快手店铺进行校验,校验通过后才会继续绑定。</p>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="flow.ticket.status === 'verified'"
|
||||
round
|
||||
type="primary"
|
||||
plain
|
||||
@click="openBindUrl"
|
||||
>
|
||||
{{ bindButtonLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<label class="field-block">
|
||||
<span>核销码</span>
|
||||
<el-input
|
||||
v-model="ticketCode"
|
||||
maxlength="120"
|
||||
placeholder="请粘贴快手小店里的核销码"
|
||||
:disabled="submitting || !canSubmitTicket"
|
||||
@keyup.enter="submitTicket"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button
|
||||
round
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmitTicket"
|
||||
@click="submitTicket"
|
||||
>
|
||||
{{ bindButtonLabel }}
|
||||
</el-button>
|
||||
<span class="action-tip">
|
||||
校验成功后会自动跳转到绑定页;绑定完成后请等待客服继续处理。
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="isWaitingSupport || flow.dispatch.status === 'success' || isCompleted" class="state-card">
|
||||
<h2>第 2 步:完成绑定后等待客服处理</h2>
|
||||
<p v-if="isWaitingSupport">核销码已经通过校验。请在外部绑定页完成绑定,然后等待客服确认绑定信息并执行发货。</p>
|
||||
<p v-else-if="flow.dispatch.status === 'success' && !isCompleted">客服已经执行发货,系统正在进行退号和快手核销收口。</p>
|
||||
<p v-else>所有步骤都已经完成,没有检测到异常,可以回到快手小店查看最终状态。</p>
|
||||
</section>
|
||||
|
||||
<section class="guide-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>图文指引</h2>
|
||||
<p>如果你不确定核销码在哪里,可以展开下面的步骤图对照操作。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="expandedPanels">
|
||||
<el-collapse-item name="guide" title="展开核销码获取步骤">
|
||||
<div class="guide-grid">
|
||||
<figure
|
||||
v-for="(imageUrl, index) in flow.guideImages"
|
||||
:key="imageUrl"
|
||||
class="guide-figure"
|
||||
>
|
||||
<img :src="imageUrl" :alt="`快手核销码步骤 ${index + 1}`" />
|
||||
<figcaption>步骤 {{ index + 1 }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="placeholder-card error-card">
|
||||
<strong>流程数据不完整</strong>
|
||||
<p>当前领取链接没有查到可用的快手 Cloud 履约上下文,请联系客服处理。</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kuaishou-claim-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 182, 132, 0.24), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(98, 176, 255, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, #fff7ef 0%, #f6fbff 100%);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.placeholder-card,
|
||||
.form-card,
|
||||
.state-card,
|
||||
.guide-card,
|
||||
.info-card {
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 18px;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) 240px;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 146, 60, 0.14);
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hero-copy h1,
|
||||
.form-card h2,
|
||||
.state-card h2,
|
||||
.guide-card h2 {
|
||||
margin: 14px 0 10px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.card-head p,
|
||||
.state-card p,
|
||||
.placeholder-card p,
|
||||
.action-tip {
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #101828 0%, #1f3f5b 100%);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.hero-status strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.status-label,
|
||||
.info-card span,
|
||||
.field-block span {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.placeholder-card,
|
||||
.form-card,
|
||||
.state-card,
|
||||
.guide-card {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 18px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
border-color: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.info-card strong {
|
||||
display: block;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 19px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.info-card p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.guide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.guide-figure {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.guide-figure img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.guide-figure figcaption {
|
||||
margin-top: 10px;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.kuaishou-claim-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.info-grid,
|
||||
.guide-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user