后端可视化调试
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
is_true() {
|
||||
normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')
|
||||
case "$normalized" in
|
||||
1|true|yes|on)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
start_browser_debug_services() {
|
||||
display="${TENCENT_BROWSER_DISPLAY:-:99}"
|
||||
resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1440x1280x24}"
|
||||
|
||||
export DISPLAY="$display"
|
||||
Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
||||
|
||||
if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then
|
||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled"
|
||||
return
|
||||
fi
|
||||
|
||||
vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}"
|
||||
novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}"
|
||||
|
||||
x11vnc \
|
||||
-display "$display" \
|
||||
-rfbport "$vnc_port" \
|
||||
-forever \
|
||||
-shared \
|
||||
-nopw \
|
||||
-listen 0.0.0.0 \
|
||||
-xkb >/tmp/x11vnc.log 2>&1 &
|
||||
|
||||
if command -v novnc_proxy >/dev/null 2>&1; then
|
||||
novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then
|
||||
/usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then
|
||||
websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
else
|
||||
echo "[backend-dev] noVNC requested but novnc/websockify not found"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html"
|
||||
}
|
||||
|
||||
npm install --no-fund --no-audit
|
||||
python3 -m pip install --no-cache-dir --break-system-packages -e /app/subservices/ocr-worker
|
||||
|
||||
if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then
|
||||
start_browser_debug_services
|
||||
fi
|
||||
|
||||
exec npm run dev
|
||||
@@ -193,6 +193,54 @@ export async function listInventoryItems({
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInventorySkuSuggestions({
|
||||
credentialType = '',
|
||||
keyword = '',
|
||||
limit = 50,
|
||||
} = {}) {
|
||||
const filters = []
|
||||
const params = []
|
||||
|
||||
if (credentialType) {
|
||||
params.push(credentialType)
|
||||
filters.push(`credential_type = $${params.length}`)
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
filters.push(`sku_code ILIKE $${params.length}`)
|
||||
}
|
||||
|
||||
const normalizedLimit = Number.isFinite(Number(limit))
|
||||
? Math.max(1, Math.min(Number(limit), 100))
|
||||
: 50
|
||||
|
||||
params.push(normalizedLimit)
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
sku_code,
|
||||
credential_type,
|
||||
COUNT(*)::int AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'available')::int AS available_count,
|
||||
MAX(updated_at) AS latest_updated_at
|
||||
FROM inventory_items
|
||||
${whereClause}
|
||||
GROUP BY sku_code, credential_type
|
||||
ORDER BY
|
||||
COUNT(*) FILTER (WHERE status = 'available') DESC,
|
||||
COUNT(*) DESC,
|
||||
MAX(updated_at) DESC,
|
||||
sku_code ASC
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function createInventoryItems(rows) {
|
||||
let created = 0
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from 'express'
|
||||
import {
|
||||
createAdminInventoryItem,
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
releaseAdminInventoryItem,
|
||||
@@ -22,6 +23,15 @@ router.get('/inventory', createJsonHandler(
|
||||
},
|
||||
))
|
||||
|
||||
router.get('/inventory/sku-suggestions', createJsonHandler(
|
||||
(req) => getAdminInventorySkuSuggestions(req.query),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取内部 SKU 建议失败',
|
||||
scope: '[admin/inventory/sku-suggestions]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/inventory', createJsonHandler(
|
||||
(req) => createAdminInventoryItem(req.body),
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getInventoryItemById,
|
||||
invalidateInventoryItem,
|
||||
listInventoryItems,
|
||||
listInventorySkuSuggestions,
|
||||
markInventoryItemDelivered,
|
||||
releaseReservedInventoryItem,
|
||||
} from '../../repositories/inventory-repo.js'
|
||||
@@ -155,6 +156,7 @@ export async function getAdminOrderDetail(orderId) {
|
||||
itemSummary,
|
||||
rawPayload: safeParseJson(order.raw_payload_json),
|
||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
@@ -424,6 +426,30 @@ export async function createAdminInventoryItem(payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminInventorySkuSuggestions(query = {}) {
|
||||
const credentialType = String(query.credentialType || '').trim()
|
||||
const keyword = String(query.keyword || '').trim()
|
||||
const requestedLimit = Number(query.limit)
|
||||
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
|
||||
? Math.min(100, Math.floor(requestedLimit))
|
||||
: 50
|
||||
const items = await listInventorySkuSuggestions({
|
||||
credentialType,
|
||||
keyword,
|
||||
limit,
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
skuCode: String(item.sku_code || '').trim(),
|
||||
credentialType: String(item.credential_type || '').trim() || 'tencent_code',
|
||||
totalCount: Number(item.total_count || 0),
|
||||
availableCount: Number(item.available_count || 0),
|
||||
latestUpdatedAt: item.latest_updated_at || null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function importAdminInventoryItems(payload = {}) {
|
||||
const rows = normalizeInventoryImportRows(payload)
|
||||
|
||||
@@ -1286,10 +1312,12 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess
|
||||
|
||||
function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummary()) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
deliveryStatus: task.delivery_status || '',
|
||||
status: task.task_status,
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
@@ -1301,6 +1329,7 @@ function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummar
|
||||
lastError: task.last_error,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
@@ -1606,6 +1635,7 @@ async function getRequiredInventoryItem(inventoryItemId) {
|
||||
|
||||
function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary(), viewerContext = createAdminViewerContext()) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -1629,6 +1659,7 @@ function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSumma
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
@@ -1892,6 +1923,61 @@ function buildOrderBindingSummary(tasks, taskBindingSummaryMap = new Map()) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrderAgisoAutoDeliverySummary(order, tasks = []) {
|
||||
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
|
||||
const latest = normalizedTasks.reduce((best, task) => {
|
||||
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
|
||||
|
||||
if (!autoDelivery) {
|
||||
return best
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
...autoDelivery,
|
||||
sourceTaskId: Number(task.id || 0) || null,
|
||||
sourceTaskNo: String(task.task_no || '').trim(),
|
||||
}
|
||||
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
|
||||
const bestTime = Date.parse(String(best?.updatedAt || ''))
|
||||
|
||||
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return best
|
||||
}, null)
|
||||
|
||||
if (latest) {
|
||||
return {
|
||||
...latest,
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
|
||||
trigger: '',
|
||||
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
|
||||
platformOrderId: String(order?.platform_order_id || '').trim(),
|
||||
responseStatus: 0,
|
||||
errorMessage: '',
|
||||
requestId: '',
|
||||
aldsType: null,
|
||||
updatedAt: null,
|
||||
sourceTaskId: null,
|
||||
sourceTaskNo: '',
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskBindingState(task) {
|
||||
const normalizedStatus = String(task?.task_status || '').trim()
|
||||
|
||||
@@ -2018,6 +2104,24 @@ function mapManualDispatchContext(value, viewerContext = createAdminViewerContex
|
||||
}
|
||||
}
|
||||
|
||||
function mapAgisoAutoDeliveryContext(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
status: String(value.status || '').trim(),
|
||||
trigger: String(value.trigger || '').trim(),
|
||||
reason: String(value.reason || '').trim(),
|
||||
platformOrderId: String(value.platformOrderId || '').trim(),
|
||||
responseStatus: Number(value.responseStatus || 0),
|
||||
errorMessage: String(value.errorMessage || '').trim(),
|
||||
requestId: String(value.requestId || '').trim(),
|
||||
aldsType: Number(value.aldsType || 0) || null,
|
||||
updatedAt: value.updatedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapRedeemResolutionContext(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
|
||||
@@ -12,11 +12,11 @@ function resolveTone(status: string) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) {
|
||||
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding', 'pending', 'waiting'].includes(normalized)) {
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed'].includes(normalized)) {
|
||||
if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed', 'skipped'].includes(normalized)) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ function resolveLabel(status: string) {
|
||||
revoked: '已撤销',
|
||||
disabled: '已停用',
|
||||
success: '成功',
|
||||
skipped: '已跳过',
|
||||
pending: '待触发',
|
||||
waiting: '等待中',
|
||||
processing: '处理中',
|
||||
system_bound: '系统已绑定',
|
||||
pending_binding: '待系统绑定',
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AdminFulfillmentLookupResult,
|
||||
AdminFulfillmentBindingConfigItem,
|
||||
AdminInventoryItemListItem,
|
||||
AdminInventorySkuSuggestion,
|
||||
AdminMessageDeliveryListItem,
|
||||
AdminObservedProductItem,
|
||||
AdminTaskActionResponse,
|
||||
@@ -173,6 +174,10 @@ export function fetchAdminInventoryItems(params?: Record<string, unknown>) {
|
||||
return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>('/api/v1/admin/inventory', params)
|
||||
}
|
||||
|
||||
export function fetchAdminInventorySkuSuggestions(params?: Record<string, unknown>) {
|
||||
return apiGet<{ items: AdminInventorySkuSuggestion[] }>('/api/v1/admin/inventory/sku-suggestions', params)
|
||||
}
|
||||
|
||||
export function fetchAdminMessageDeliveries(params?: Record<string, unknown>) {
|
||||
return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>(
|
||||
'/api/v1/admin/message-deliveries',
|
||||
|
||||
@@ -160,6 +160,25 @@ export interface AdminOrderBindingSummary {
|
||||
userBindingStatus: string
|
||||
}
|
||||
|
||||
export interface AdminAgisoAutoDeliveryStatus {
|
||||
status: string
|
||||
trigger: string
|
||||
reason: string
|
||||
platformOrderId: string
|
||||
responseStatus: number
|
||||
errorMessage: string
|
||||
requestId: string
|
||||
aldsType: number | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminOrderAgisoAutoDeliverySummary extends AdminAgisoAutoDeliveryStatus {
|
||||
totalTaskCount: number
|
||||
deliveredTaskCount: number
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
}
|
||||
|
||||
export interface AdminOrderListItem {
|
||||
orderId: number
|
||||
provider: string
|
||||
@@ -221,6 +240,7 @@ export interface AdminTaskListItem {
|
||||
inventoryCredentialType: string
|
||||
claimToken: string
|
||||
screenshotPath: string
|
||||
agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null
|
||||
}
|
||||
|
||||
export interface AdminTaskOperations {
|
||||
@@ -274,6 +294,7 @@ export interface AdminOrderDetail {
|
||||
itemSummary: string
|
||||
rawPayload: Record<string, unknown>
|
||||
bindingSummary: AdminOrderBindingSummary
|
||||
agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null
|
||||
}
|
||||
items: Array<{
|
||||
orderItemId: number
|
||||
@@ -284,23 +305,7 @@ export interface AdminOrderDetail {
|
||||
deliveryMode: string
|
||||
spec: Record<string, unknown>
|
||||
}>
|
||||
tasks: Array<{
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: string
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
loginType: string
|
||||
browserSessionId: string
|
||||
claimedAt: string | null
|
||||
roleConfirmedAt: string | null
|
||||
redeemedAt: string | null
|
||||
lastError: string
|
||||
retryCount: number
|
||||
bindingSummary: AdminTaskBindingSummary
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}>
|
||||
tasks: AdminTaskListItem[]
|
||||
webhookEvents: Array<{
|
||||
eventId: number
|
||||
provider: string
|
||||
@@ -320,6 +325,10 @@ export interface AdminTaskDetail {
|
||||
task: AdminTaskListItem
|
||||
order: null | {
|
||||
orderId: number
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
platformOrderId: string
|
||||
payStatus: string
|
||||
orderStatus: string
|
||||
@@ -409,6 +418,14 @@ export interface AdminTaskDetail {
|
||||
operations: AdminTaskOperations
|
||||
}
|
||||
|
||||
export interface AdminInventorySkuSuggestion {
|
||||
skuCode: string
|
||||
credentialType: string
|
||||
totalCount: number
|
||||
availableCount: number
|
||||
latestUpdatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminInventoryItemListItem {
|
||||
inventoryItemId: number
|
||||
skuCode: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
@@ -7,11 +7,12 @@ import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
createAdminInventoryItem,
|
||||
fetchAdminInventoryItems,
|
||||
fetchAdminInventorySkuSuggestions,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
releaseAdminInventoryItem,
|
||||
} from '@/services/admin'
|
||||
import type { AdminInventoryItemListItem, AdminPagination } from '@/types/admin'
|
||||
import type { AdminInventoryItemListItem, AdminInventorySkuSuggestion, AdminPagination } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import {
|
||||
@@ -55,6 +56,8 @@ const entryCredentialType = ref('tencent_code')
|
||||
const entryBatchNo = ref('')
|
||||
const singleDisplayValue = ref('')
|
||||
const bulkText = ref('')
|
||||
const skuSuggestionsLoading = ref(false)
|
||||
const inventorySkuSuggestions = ref<AdminInventorySkuSuggestion[]>([])
|
||||
|
||||
const items = ref<AdminInventoryItemListItem[]>([])
|
||||
const pagination = ref<AdminPagination>({
|
||||
@@ -158,6 +161,19 @@ const pageOverview = computed(() => ({
|
||||
groupCount: inventoryGroups.value.length,
|
||||
}))
|
||||
|
||||
const entrySkuSuggestions = computed(() => {
|
||||
const keyword = entrySkuCode.value.trim().toLowerCase()
|
||||
const suggestions = inventorySkuSuggestions.value.filter((item) => item.credentialType === entryCredentialType.value)
|
||||
|
||||
if (!keyword) {
|
||||
return suggestions.slice(0, 12)
|
||||
}
|
||||
|
||||
return suggestions
|
||||
.filter((item) => item.skuCode.toLowerCase().includes(keyword))
|
||||
.slice(0, 12)
|
||||
})
|
||||
|
||||
function getCredentialTypeLabel(value: string) {
|
||||
const normalized = String(value || '').trim()
|
||||
const option = adminInventoryCredentialTypeOptions.find((item) => item.value === normalized)
|
||||
@@ -168,7 +184,7 @@ function getCredentialTypeHint(value: string) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
if (normalized === 'tencent_code') {
|
||||
return '当前腾讯自动领取链路会优先预占这一类兑换码,三角洲行动这类场景就使用这个类型。'
|
||||
return '当前腾讯自动领取链路会优先预占这一类兑换码,三角洲行动这类场景就使用这个类型。内部 SKU 支持中文、英文和数字混用。'
|
||||
}
|
||||
|
||||
if (normalized === 'card_password') {
|
||||
@@ -190,6 +206,14 @@ function getCredentialTypeHint(value: string) {
|
||||
return '库存预占时会按凭据类型与内部履约 SKU 一起匹配。'
|
||||
}
|
||||
|
||||
function getSkuNamingHint() {
|
||||
if (entrySkuSuggestions.value.length > 0) {
|
||||
return `当前凭据类型下已有 ${entrySkuSuggestions.value.length} 个可复用的内部 SKU,也可以直接输入新的中文或英文名称。`
|
||||
}
|
||||
|
||||
return '内部 SKU 支持中文、英文、数字和常见连接符。建议同一类商品长期使用同一个命名,方便共用库存池。'
|
||||
}
|
||||
|
||||
function formatInventoryStatus(item: AdminInventoryItemListItem) {
|
||||
if (item.status === 'consumed') {
|
||||
return '已完成发放'
|
||||
@@ -241,6 +265,10 @@ function useGroupAsEntry(group: InventoryPoolGroup) {
|
||||
showSuccess(`已把 ${group.skuCode} / ${group.credentialType} 填入入库表单`)
|
||||
}
|
||||
|
||||
function applySkuSuggestion(skuCode: string) {
|
||||
entrySkuCode.value = String(skuCode || '').trim()
|
||||
}
|
||||
|
||||
function buildImportRows() {
|
||||
const lines = bulkText.value
|
||||
.split('\n')
|
||||
@@ -312,6 +340,22 @@ async function loadInventoryItems(page = pagination.value.page) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInventorySkuSuggestions() {
|
||||
skuSuggestionsLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetchAdminInventorySkuSuggestions({
|
||||
credentialType: entryCredentialType.value.trim() || 'tencent_code',
|
||||
limit: 50,
|
||||
})
|
||||
inventorySkuSuggestions.value = response.data.items
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '读取内部 SKU 建议失败')
|
||||
} finally {
|
||||
skuSuggestionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterSkuCode.value = ''
|
||||
filterCredentialType.value = ''
|
||||
@@ -402,7 +446,16 @@ async function runRowAction(
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadInventoryItems)
|
||||
watch(entryCredentialType, () => {
|
||||
void loadInventorySkuSuggestions()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadInventoryItems(),
|
||||
loadInventorySkuSuggestions(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -463,12 +516,27 @@ onMounted(loadInventoryItems)
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>入库与导入</h2>
|
||||
<p>这里填写的是内部履约 SKU,不是平台侧 `sku_code`。例如三角洲行动自动领取统一使用 `sjz_hdl_test01`,这样同类订单才能共用一个库存池。</p>
|
||||
<p>这里填写的是内部履约 SKU,不是平台侧 `sku_code`。内部 SKU 支持中文命名;同一类商品建议长期复用同一个名字,这样订单才能稳定共用库存池。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<input v-model="entrySkuCode" class="text-input" placeholder="内部履约 SKU,例如 sjz_hdl_test01" />
|
||||
<div class="sku-input-wrap">
|
||||
<input
|
||||
v-model="entrySkuCode"
|
||||
list="inventory-sku-suggestions"
|
||||
class="text-input"
|
||||
placeholder="内部履约 SKU,可选已有同类型,也可自定义新的中文名"
|
||||
/>
|
||||
<datalist id="inventory-sku-suggestions">
|
||||
<option
|
||||
v-for="suggestion in entrySkuSuggestions"
|
||||
:key="`${suggestion.credentialType}-${suggestion.skuCode}`"
|
||||
:value="suggestion.skuCode"
|
||||
:label="`${suggestion.skuCode} · 可用 ${suggestion.availableCount} / 共 ${suggestion.totalCount}`"
|
||||
/>
|
||||
</datalist>
|
||||
</div>
|
||||
<select v-model="entryCredentialType" class="text-input select-input">
|
||||
<option
|
||||
v-for="option in adminInventoryCredentialTypeOptions.filter((item) => item.value)"
|
||||
@@ -481,6 +549,24 @@ onMounted(loadInventoryItems)
|
||||
<input v-model="entryBatchNo" class="text-input" placeholder="批次号,可选" />
|
||||
</div>
|
||||
|
||||
<div class="sku-helper-bar">
|
||||
<span class="section-tip sku-helper-copy">{{ getSkuNamingHint() }}</span>
|
||||
<span v-if="skuSuggestionsLoading" class="section-tip">正在加载同类型内部 SKU…</span>
|
||||
</div>
|
||||
|
||||
<div v-if="entrySkuSuggestions.length > 0" class="sku-chip-row">
|
||||
<button
|
||||
v-for="suggestion in entrySkuSuggestions"
|
||||
:key="`${suggestion.credentialType}-${suggestion.skuCode}-chip`"
|
||||
type="button"
|
||||
class="sku-chip"
|
||||
@click="applySkuSuggestion(suggestion.skuCode)"
|
||||
>
|
||||
<span class="sku-chip-title">{{ suggestion.skuCode }}</span>
|
||||
<span class="sku-chip-meta">可用 {{ suggestion.availableCount }} / 共 {{ suggestion.totalCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="helper-grid">
|
||||
<div class="helper-item">
|
||||
<span class="helper-title">当前默认匹配</span>
|
||||
@@ -756,6 +842,10 @@ onMounted(loadInventoryItems)
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sku-input-wrap {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.single-row,
|
||||
.action-row {
|
||||
display: flex;
|
||||
@@ -771,6 +861,52 @@ onMounted(loadInventoryItems)
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.sku-helper-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sku-helper-copy {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sku-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sku-chip {
|
||||
display: inline-grid;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.16);
|
||||
background: linear-gradient(180deg, rgba(244, 248, 255, 0.92), rgba(255, 255, 255, 0.98));
|
||||
color: #1d3555;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.sku-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(37, 89, 167, 0.26);
|
||||
box-shadow: 0 12px 24px rgba(37, 89, 167, 0.08);
|
||||
}
|
||||
|
||||
.sku-chip-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sku-chip-meta {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.helper-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -1057,7 +1193,8 @@ onMounted(loadInventoryItems)
|
||||
}
|
||||
|
||||
.single-row,
|
||||
.action-row {
|
||||
.action-row,
|
||||
.sku-helper-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,49 @@ function formatTaskBindingCountSummary(task: AdminOrderDetail['tasks'][number])
|
||||
const summary = task.bindingSummary
|
||||
return `共 ${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`
|
||||
}
|
||||
|
||||
function formatAutoDeliveryReason(value: string) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
switch (normalized) {
|
||||
case 'waiting_other_tasks':
|
||||
return '等待订单下其他任务全部交付后再统一触发'
|
||||
case 'already_sent':
|
||||
return '该订单已经执行过自动发货'
|
||||
case 'auto_delivery_disabled':
|
||||
return '自动发货开关已关闭'
|
||||
case 'missing_config':
|
||||
return '缺少 Agiso 自动发货配置'
|
||||
case 'request_failed':
|
||||
return 'Agiso 接口返回失败'
|
||||
case 'request_error':
|
||||
return '请求 Agiso 接口时发生异常'
|
||||
default:
|
||||
return normalized || '-'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTaskAutoDeliveryStatus(task: AdminOrderDetail['tasks'][number]) {
|
||||
if (task.agisoAutoDelivery?.status) {
|
||||
return task.agisoAutoDelivery.status
|
||||
}
|
||||
|
||||
return task.deliveryStatus === 'delivered' ? 'pending' : 'waiting'
|
||||
}
|
||||
|
||||
function formatTaskAutoDeliveryReason(task: AdminOrderDetail['tasks'][number]) {
|
||||
if (task.agisoAutoDelivery?.errorMessage) {
|
||||
return task.agisoAutoDelivery.errorMessage
|
||||
}
|
||||
|
||||
if (task.agisoAutoDelivery?.reason) {
|
||||
return formatAutoDeliveryReason(task.agisoAutoDelivery.reason)
|
||||
}
|
||||
|
||||
return task.deliveryStatus === 'delivered'
|
||||
? '当前任务已交付,等待订单统一自动发货'
|
||||
: '当前任务尚未完成交付'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,6 +148,19 @@ function formatTaskBindingCountSummary(task: AdminOrderDetail['tasks'][number])
|
||||
<strong>{{ detail.order.bindingSummary.totalBindingCount }} 条绑定记录</strong>
|
||||
<small>预占 {{ detail.order.bindingSummary.reservedBindingCount }} · 已消费 {{ detail.order.bindingSummary.consumedBindingCount }} · 已释放 {{ detail.order.bindingSummary.releasedBindingCount }}</small>
|
||||
</article>
|
||||
<article v-if="detail.order.agisoAutoDelivery" class="info-tile">
|
||||
<span>自动发货</span>
|
||||
<div class="tile-tag-line">
|
||||
<AdminStatusTag :status="detail.order.agisoAutoDelivery.status" />
|
||||
</div>
|
||||
<strong>
|
||||
{{ detail.order.agisoAutoDelivery.errorMessage || formatAutoDeliveryReason(detail.order.agisoAutoDelivery.reason) || '全部任务交付后会自动触发' }}
|
||||
</strong>
|
||||
<small>
|
||||
已交付 {{ detail.order.agisoAutoDelivery.deliveredTaskCount }}/{{ detail.order.agisoAutoDelivery.totalTaskCount }}
|
||||
· 最近执行 {{ formatAdminDateTime(detail.order.agisoAutoDelivery.updatedAt) }}
|
||||
</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<table class="meta-table">
|
||||
@@ -151,6 +207,7 @@ function formatTaskBindingCountSummary(task: AdminOrderDetail['tasks'][number])
|
||||
<th>状态</th>
|
||||
<th>系统绑定</th>
|
||||
<th>完整绑定</th>
|
||||
<th>自动发货</th>
|
||||
<th>库存绑定</th>
|
||||
<th>登录方式</th>
|
||||
<th>错误</th>
|
||||
@@ -164,6 +221,12 @@ function formatTaskBindingCountSummary(task: AdminOrderDetail['tasks'][number])
|
||||
<td><AdminStatusTag :status="task.status" /></td>
|
||||
<td><AdminStatusTag :status="task.systemBindingStatus" /></td>
|
||||
<td><AdminStatusTag :status="task.userBindingStatus" /></td>
|
||||
<td>
|
||||
<div class="tile-tag-line">
|
||||
<AdminStatusTag :status="resolveTaskAutoDeliveryStatus(task)" />
|
||||
</div>
|
||||
<div class="binding-copy">{{ formatTaskAutoDeliveryReason(task) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ formatTaskBindingCountSummary(task) }}</div>
|
||||
<div class="binding-copy">角色 {{ formatBindingRoleSummary(task.bindingSummary.roleKeys) }}</div>
|
||||
@@ -267,6 +330,10 @@ function formatTaskBindingCountSummary(task: AdminOrderDetail['tasks'][number])
|
||||
.info-tile strong {
|
||||
color: #1d3555;
|
||||
}
|
||||
.tile-tag-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.binding-copy {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
|
||||
@@ -155,9 +155,11 @@ function formatTaskEventPayload(payload: Record<string, unknown>) {
|
||||
payload.previousOutcome ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` : '',
|
||||
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
|
||||
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
|
||||
payload.trigger ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ''))}` : '',
|
||||
payload.reason ? `原因 ${payload.reason}` : '',
|
||||
payload.responseStatus ? `HTTP ${payload.responseStatus}` : '',
|
||||
payload.requestId ? `请求 ${payload.requestId}` : '',
|
||||
payload.aldsType ? `发货类型 ${payload.aldsType}` : '',
|
||||
payload.errorMessage ? `错误 ${payload.errorMessage}` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
@@ -191,6 +193,70 @@ function formatRedeemResolutionStatus(value: string) {
|
||||
return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : (value || '-')
|
||||
}
|
||||
|
||||
function formatAutoDeliveryTrigger(value: string) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
switch (normalized) {
|
||||
case 'claim_redeemed':
|
||||
return '用户兑换完成后触发'
|
||||
case 'manual_dispatch_completed':
|
||||
return '人工履约完成后触发'
|
||||
case 'task_delivered':
|
||||
return '任务交付完成后触发'
|
||||
default:
|
||||
return normalized || '-'
|
||||
}
|
||||
}
|
||||
|
||||
function formatAutoDeliveryReason(value: string) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
switch (normalized) {
|
||||
case 'waiting_other_tasks':
|
||||
return '订单下仍有任务未完成交付'
|
||||
case 'already_sent':
|
||||
return '该订单已经执行过自动发货'
|
||||
case 'auto_delivery_disabled':
|
||||
return '自动发货开关已关闭'
|
||||
case 'missing_config':
|
||||
return '缺少 Agiso 自动发货配置'
|
||||
case 'request_failed':
|
||||
return 'Agiso 接口返回失败'
|
||||
case 'request_error':
|
||||
return '请求 Agiso 接口时发生异常'
|
||||
case 'task_not_delivered':
|
||||
return '当前任务尚未进入已交付状态'
|
||||
case 'not_supported':
|
||||
return '当前订单不支持 Agiso 咸鱼自动发货'
|
||||
default:
|
||||
return normalized || '-'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTaskAutoDeliveryStatus(taskDetail: AdminTaskDetail) {
|
||||
if (taskDetail.task.agisoAutoDelivery?.status) {
|
||||
return taskDetail.task.agisoAutoDelivery.status
|
||||
}
|
||||
|
||||
return taskDetail.task.deliveryStatus === 'delivered' ? 'pending' : 'waiting'
|
||||
}
|
||||
|
||||
function resolveTaskAutoDeliverySummary(taskDetail: AdminTaskDetail) {
|
||||
const autoDelivery = taskDetail.task.agisoAutoDelivery
|
||||
|
||||
if (autoDelivery?.errorMessage) {
|
||||
return autoDelivery.errorMessage
|
||||
}
|
||||
|
||||
if (autoDelivery?.reason) {
|
||||
return formatAutoDeliveryReason(autoDelivery.reason)
|
||||
}
|
||||
|
||||
return taskDetail.task.deliveryStatus === 'delivered'
|
||||
? '当前任务已交付,等待订单下其他任务完成后统一自动发货'
|
||||
: '当前任务尚未完成交付,暂不会触发自动发货'
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
onBeforeUnmount(clearScreenshotPreview)
|
||||
</script>
|
||||
@@ -306,8 +372,8 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
</el-button>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>任务信息</h3>
|
||||
<section class="table-card">
|
||||
<h3>任务信息</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr><th>商品</th><td>{{ detail.orderItem?.skuName || '-' }}</td></tr>
|
||||
@@ -337,9 +403,48 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
<tr><th>截图路径</th><td>{{ detail.task.screenshotPath || '-' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="detail.order?.provider === 'agiso' && detail.order?.platform === 'xianyu'"
|
||||
class="table-card"
|
||||
>
|
||||
<h3>咸鱼自动发货</h3>
|
||||
<p class="section-copy">这里展示这条任务最近一次触发咸鱼自动发货的状态、原因和接口回执摘要。</p>
|
||||
|
||||
<div class="redeem-summary-grid">
|
||||
<article class="redeem-summary-card">
|
||||
<span>当前状态</span>
|
||||
<div class="auto-delivery-tag-line">
|
||||
<AdminStatusTag :status="resolveTaskAutoDeliveryStatus(detail)" />
|
||||
</div>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>触发来源</span>
|
||||
<strong>{{ formatAutoDeliveryTrigger(detail.task.agisoAutoDelivery?.trigger || '') }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>最近执行</span>
|
||||
<strong>{{ formatAdminDateTime(detail.task.agisoAutoDelivery?.updatedAt) }}</strong>
|
||||
</article>
|
||||
<article class="redeem-summary-card">
|
||||
<span>接口回执</span>
|
||||
<strong>
|
||||
{{ detail.task.agisoAutoDelivery?.requestId || (detail.task.agisoAutoDelivery?.responseStatus ? `HTTP ${detail.task.agisoAutoDelivery.responseStatus}` : '-') }}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="auto-delivery-note">
|
||||
<p>{{ resolveTaskAutoDeliverySummary(detail) }}</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.reason">原因:{{ formatAutoDeliveryReason(detail.task.agisoAutoDelivery.reason) }}</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.responseStatus">HTTP:{{ detail.task.agisoAutoDelivery.responseStatus }}</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.requestId">请求 ID:{{ detail.task.agisoAutoDelivery.requestId }}</p>
|
||||
<p v-if="detail.task.agisoAutoDelivery?.aldsType">发货类型:{{ detail.task.agisoAutoDelivery.aldsType }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="detail.redeemResolution" class="table-card">
|
||||
<section v-if="detail.redeemResolution" class="table-card">
|
||||
<h3>兑换重试链路</h3>
|
||||
<p class="section-copy">这里记录自动兑换时每次使用过的 CDK、判定结果,以及是否切换到了新的同类型凭据。</p>
|
||||
|
||||
@@ -545,6 +650,16 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
.action-row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.table-card h3 { margin: 0 0 12px; color: #1d3555; }
|
||||
.section-copy { margin: 0 0 12px; color: #64748b; }
|
||||
.auto-delivery-tag-line { display: flex; align-items: center; }
|
||||
.auto-delivery-note {
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(248,250,252,.92);
|
||||
border: 1px solid rgba(86,108,138,.08);
|
||||
}
|
||||
.auto-delivery-note p { margin: 0 0 8px; color: #334155; }
|
||||
.auto-delivery-note p:last-child { margin-bottom: 0; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); }
|
||||
.data-table th { width: 160px; color: #64748b; }
|
||||
|
||||
Reference in New Issue
Block a user