feat(frontend): add P2 quality gates

This commit is contained in:
yml2213
2026-05-15 17:35:50 +08:00
parent a65c0713fb
commit 787b01749c
62 changed files with 4907 additions and 1355 deletions
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
src/auto-imports.d.ts
src/components.d.ts
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}
+34
View File
@@ -0,0 +1,34 @@
import js from '@eslint/js'
import prettier from 'eslint-config-prettier'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'src/auto-imports.d.ts', 'src/components.d.ts'],
},
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
prettier,
{
files: ['**/*.{ts,vue}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
},
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: ['.vue'],
},
},
rules: {
'vue/attributes-order': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-mutating-props': ['error', { shallowOnly: true }],
},
},
)
+1284 -2
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -7,7 +7,10 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@types/qrcode": "^1.5.6",
@@ -18,11 +21,18 @@
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.9.1",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"sass": "^1.99.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.59.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.0.0",
"vite": "^8.0.7",
@@ -1,27 +1,79 @@
<script setup lang="ts">
import { formatStatusWithRaw } from '@/utils/admin-display'
const props = withDefaults(defineProps<{
status: string
showRaw?: boolean
}>(), {
showRaw: true,
})
const props = withDefaults(
defineProps<{
status: string
showRaw?: boolean
}>(),
{
showRaw: true,
},
)
type ElTagType = 'success' | 'primary' | 'warning' | 'danger' | 'info'
function resolveType(status: string): ElTagType {
const normalized = String(status || '').trim().toLowerCase()
const normalized = String(status || '')
.trim()
.toLowerCase()
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed', 'completed'].includes(normalized)) {
if (
[
'paid',
'link_generated',
'redeemed',
'available',
'delivered',
'success',
'active',
'system_bound',
'binding_completed',
'completed',
].includes(normalized)
) {
return 'success'
}
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding', 'pending', 'waiting', 'waiting_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',
'waiting_binding',
].includes(normalized)
) {
return 'primary'
}
if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed', 'skipped', 'pending_binding_prepare', 'dispatched_pending_return'].includes(normalized)) {
if (
[
'waiting_inventory',
'retry_pending',
'manual_review',
'invalid',
'expired',
'operator',
'pending_binding',
'binding_exception',
'not_started',
'consumed',
'skipped',
'pending_binding_prepare',
'dispatched_pending_return',
].includes(normalized)
) {
return 'warning'
}
@@ -97,7 +149,11 @@ function resolveLabel(status: string) {
</script>
<template>
<el-tag :type="resolveType(props.status)" size="small" :title="props.showRaw ? undefined : props.status">
<el-tag
:type="resolveType(props.status)"
size="small"
:title="props.showRaw ? undefined : props.status"
>
{{ resolveLabel(props.status) }}
</el-tag>
</template>
@@ -72,7 +72,11 @@ const emit = defineEmits<{
</div>
<div
v-else-if="['logged_in', 'ready_to_redeem', 'redeeming', 'redeemed'].includes(String(props.sessionStatus || ''))"
v-else-if="
['logged_in', 'ready_to_redeem', 'redeeming', 'redeemed'].includes(
String(props.sessionStatus || ''),
)
"
class="status-state success-state"
>
<strong>已完成登录</strong>
@@ -93,7 +97,9 @@ const emit = defineEmits<{
<div class="auth-copy">
<p class="scan-hint">
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
{{
props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化`
}}
</p>
<p
v-if="props.qrCopyEnabled && props.qrImage && props.sessionStatus === 'waiting_scan'"
@@ -154,7 +154,9 @@ export function useTencentBrowserSessionPolling(options: {
}
}
export function isActiveTencentSessionStatus(status: TencentBrowserSessionStatus | null | undefined) {
export function isActiveTencentSessionStatus(
status: TencentBrowserSessionStatus | null | undefined,
) {
return Boolean(status && ACTIVE_TENCENT_SESSION_STATUSES.has(status))
}
@@ -30,7 +30,9 @@ export function useTencentBrowserSessionPresentation(options: {
} = options
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
const scanInstruction = computed(() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`)
const scanInstruction = computed(
() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`,
)
const qrImage = computed(() =>
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
@@ -188,7 +190,7 @@ export function useTencentBrowserSessionPresentation(options: {
const screenshotEmptyMessage = computed(() =>
session.value?.status === 'redeemed'
? '本次兑换可能未生成截图,或截图产物已被后端配置关闭。'
: '如本次兑换生成了结果截图,这里会展示。'
: '如本次兑换生成了结果截图,这里会展示。',
)
return {
@@ -66,21 +66,31 @@ export function useAdminManualRedeemPage() {
)
const hasSession = computed(() => Boolean(session.value?.sessionId))
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '')
const sessionNotice = computed(
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
)
const qrImage = computed(() =>
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
)
const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0)
const reviewReady = computed(() => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed')
const resultReady = computed(() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed')
const reviewReady = computed(
() => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed',
)
const resultReady = computed(
() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed',
)
const loginTabs = [
{ value: 'qq' as const, label: 'QQ账号登录' },
{ value: 'wx' as const, label: '微信账号登录' },
]
const statusLabel = computed(() => resolveTaskStatusLabel(task.value?.status, session.value?.status))
const statusLabel = computed(() =>
resolveTaskStatusLabel(task.value?.status, session.value?.status),
)
const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`)
const scanInstruction = computed(() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`)
const scanInstruction = computed(
() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`,
)
const roleFacts = computed(() => [
{
label: '登录昵称',
@@ -120,18 +130,18 @@ export function useAdminManualRedeemPage() {
])
const canConfirmRole = computed(() =>
Boolean(
task.value
&& task.value.status === 'claimed'
&& hasSession.value
&& roleReady.value
&& !roleConfirmLoading.value,
task.value &&
task.value.status === 'claimed' &&
hasSession.value &&
roleReady.value &&
!roleConfirmLoading.value,
),
)
const canRedeem = computed(() =>
Boolean(
task.value
&& ['role_confirmed', 'redeeming'].includes(task.value.status)
&& !redeemLoading.value,
task.value &&
['role_confirmed', 'redeeming'].includes(task.value.status) &&
!redeemLoading.value,
),
)
const redeemBlockedReason = computed(() => {
@@ -158,13 +168,17 @@ export function useAdminManualRedeemPage() {
return ''
})
const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换'))
const screenshotEmptyTitle = computed(() => (task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图'))
const screenshotEmptyTitle = computed(() =>
task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图',
)
const screenshotEmptyMessage = computed(() =>
task.value?.status === 'redeemed'
? '本次兑换可能未生成最终截图,或截图仍在同步中。'
: '客户登录并识别到角色后,这里会展示给客服复核的角色截图。'
: '客户登录并识别到角色后,这里会展示给客服复核的角色截图。',
)
const canCreateTask = computed(
() => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value,
)
const canCreateTask = computed(() => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value)
const hasActiveTask = computed(() => Boolean(task.value?.taskId))
watch(qrImage, () => {
@@ -538,19 +552,20 @@ export function useAdminManualRedeemPage() {
const anchor = document.createElement('a')
anchor.href = screenshotUrl.value
anchor.download = task.value?.status === 'redeemed'
? `manual-redeem-result-${task.value?.taskNo || 'task'}.png`
: `manual-redeem-review-${task.value?.taskNo || 'task'}.png`
anchor.download =
task.value?.status === 'redeemed'
? `manual-redeem-result-${task.value?.taskNo || 'task'}.png`
: `manual-redeem-review-${task.value?.taskNo || 'task'}.png`
anchor.click()
return true
}
async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) {
if (
typeof navigator === 'undefined'
|| !navigator.clipboard
|| typeof window === 'undefined'
|| !('ClipboardItem' in window)
typeof navigator === 'undefined' ||
!navigator.clipboard ||
typeof window === 'undefined' ||
!('ClipboardItem' in window)
) {
throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载')
}
@@ -569,7 +584,9 @@ export function useAdminManualRedeemPage() {
async function loadScreenshotPreview(nextDetail = detail.value) {
const taskId = Number(nextDetail?.task?.taskId || 0)
const shouldFetch = Boolean(taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady))
const shouldFetch = Boolean(
taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady),
)
if (!shouldFetch) {
clearScreenshotPreview()
@@ -601,7 +618,9 @@ export function useAdminManualRedeemPage() {
function resolveSkuName(skuCode: string) {
const normalized = String(skuCode || '').trim()
const matched = suggestions.value.find((item) => String(item.skuCode || '').trim() === normalized)
const matched = suggestions.value.find(
(item) => String(item.skuCode || '').trim() === normalized,
)
return matched?.skuCode || normalized
}
@@ -682,7 +701,9 @@ export function useAdminManualRedeemPage() {
maxWidth: '100%',
}))
const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`)
const qrPreviewWidth = computed(
() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`,
)
return {
form,
@@ -744,7 +765,10 @@ export function useAdminManualRedeemPage() {
}
}
function syncLoginTypeFromDetail(detail: AdminManualRedeemDetail, fallback: TencentLoginType = DEFAULT_LOGIN_TYPE) {
function syncLoginTypeFromDetail(
detail: AdminManualRedeemDetail,
fallback: TencentLoginType = DEFAULT_LOGIN_TYPE,
) {
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
if (nextLoginType === 'wx') {
return 'wx'
@@ -823,7 +847,10 @@ function mergeDetail(current: AdminManualRedeemDetail | null, next: AdminManualR
}
}
function shouldRefreshQrImage(currentSession: AdminManualRedeemDetail['session'], nextSession: AdminManualRedeemDetail['session']) {
function shouldRefreshQrImage(
currentSession: AdminManualRedeemDetail['session'],
nextSession: AdminManualRedeemDetail['session'],
) {
if (!currentSession || !nextSession || !nextSession.artifacts?.hasQrImage) {
return false
}
+37 -24
View File
@@ -22,11 +22,7 @@ import { notifyTencentActionError } from './tencent/session-errors'
const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
const POLL_INTERVAL_MS = 2500
const POLL_FAILURE_LIMIT = 3
const ACTIVE_TASK_STATUSES = new Set<ClaimTaskStatus>([
'claimed',
'role_confirmed',
'redeeming',
])
const ACTIVE_TASK_STATUSES = new Set<ClaimTaskStatus>(['claimed', 'role_confirmed', 'redeeming'])
export function useClaimPage(token: string) {
const detailLoading = ref(true)
@@ -53,7 +49,9 @@ export function useClaimPage(token: string) {
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
const hasSession = computed(() => Boolean(session.value?.sessionId))
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '')
const sessionNotice = computed(
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
)
const qrImage = computed(() =>
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
)
@@ -64,9 +62,13 @@ export function useClaimPage(token: string) {
{ value: 'wx' as const, label: '微信账号登录' },
]
const statusLabel = computed(() => resolveTaskStatusLabel(task.value?.status, session.value?.status))
const statusLabel = computed(() =>
resolveTaskStatusLabel(task.value?.status, session.value?.status),
)
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
const scanInstruction = computed(() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`)
const scanInstruction = computed(
() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`,
)
const roleFacts = computed(() => [
{
label: '登录昵称',
@@ -106,21 +108,21 @@ export function useClaimPage(token: string) {
])
const canConfirmRole = computed(() =>
Boolean(
task.value
&& !task.value.requiresSupportReview
&& hasSession.value
&& roleReady.value
&& task.value.status === 'claimed',
task.value &&
!task.value.requiresSupportReview &&
hasSession.value &&
roleReady.value &&
task.value.status === 'claimed',
),
)
const canRedeem = computed(() =>
Boolean(
task.value
&& !task.value.requiresSupportReview
&& hasSession.value
&& roleConfirmed.value
&& !redeemLoading.value
&& (task.value.status === 'role_confirmed' || task.value.status === 'redeeming'),
task.value &&
!task.value.requiresSupportReview &&
hasSession.value &&
roleConfirmed.value &&
!redeemLoading.value &&
(task.value.status === 'role_confirmed' || task.value.status === 'redeeming'),
),
)
const redeemBlockedReason = computed(() => {
@@ -158,14 +160,16 @@ export function useClaimPage(token: string) {
return ''
})
const redeemButtonLabel = computed(() => (task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换'))
const redeemButtonLabel = computed(() =>
task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换',
)
const screenshotEmptyTitle = computed(() =>
task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图',
)
const screenshotEmptyMessage = computed(() =>
task.value?.status === 'redeemed'
? '本次兑换可能未生成截图,或截图产物还未同步完成。'
: '领取完成后,如本次生成了结果截图,这里会展示。'
: '领取完成后,如本次生成了结果截图,这里会展示。',
)
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
@@ -183,7 +187,11 @@ export function useClaimPage(token: string) {
activityInfo.value?.role?.partition || '',
].join('|'),
() => {
roleConfirmed.value = Boolean(task.value?.status === 'role_confirmed' || task.value?.status === 'redeeming' || task.value?.status === 'redeemed')
roleConfirmed.value = Boolean(
task.value?.status === 'role_confirmed' ||
task.value?.status === 'redeeming' ||
task.value?.status === 'redeemed',
)
},
{ immediate: true },
)
@@ -514,7 +522,9 @@ export function useClaimPage(token: string) {
maxWidth: '100%',
}))
const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`)
const qrPreviewWidth = computed(
() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`,
)
loadDetail()
@@ -567,7 +577,10 @@ export function useClaimPage(token: string) {
}
}
function syncLoginTypeFromDetail(detail: ClaimDetailData, fallback: TencentLoginType = DEFAULT_LOGIN_TYPE) {
function syncLoginTypeFromDetail(
detail: ClaimDetailData,
fallback: TencentLoginType = DEFAULT_LOGIN_TYPE,
) {
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
if (nextLoginType === 'wx') {
return 'wx'
+5 -1
View File
@@ -44,7 +44,11 @@ export function showError(message: string, options: Partial<MessageOptions> = {}
})
}
export function showConfirm(message: string, title = '确认操作', options: ElMessageBoxOptions = {}) {
export function showConfirm(
message: string,
title = '确认操作',
options: ElMessageBoxOptions = {},
) {
return ElMessageBox.confirm(message, title, {
...baseConfirmOptions,
...options,
+15 -6
View File
@@ -42,7 +42,10 @@ http.interceptors.response.use(
if (status === 401 && shouldClearAdminSession(errorCode)) {
clearAdminSession()
if (window.location.hash.startsWith('#/admin') && !window.location.hash.startsWith('#/admin/login')) {
if (
window.location.hash.startsWith('#/admin') &&
!window.location.hash.startsWith('#/admin/login')
) {
window.location.hash = '#/admin/login'
}
}
@@ -91,21 +94,27 @@ function shouldClearAdminSession(errorCode: string) {
].includes(errorCode)
}
function request<T>(config: Parameters<typeof http.request<ApiEnvelope<T>>>[0]) {
return http.request<ApiEnvelope<T>, ApiEnvelope<T>>(config)
}
export function apiGet<T>(url: string, params?: Record<string, unknown>) {
return http.get<ApiEnvelope<T>>(url, { params }) as unknown as Promise<ApiEnvelope<T>>
return request<T>({ method: 'GET', url, params })
}
export function apiGetBlob(url: string, params?: Record<string, unknown>) {
return http.get<Blob>(url, {
return http.request<Blob, Blob>({
method: 'GET',
url,
params,
responseType: 'blob',
}) as unknown as Promise<Blob>
})
}
export function apiPost<T>(url: string, data?: Record<string, unknown>) {
return http.post<ApiEnvelope<T>>(url, data) as unknown as Promise<ApiEnvelope<T>>
return request<T>({ method: 'POST', url, data })
}
export function apiDelete<T>(url: string) {
return http.delete<ApiEnvelope<T>>(url) as unknown as Promise<ApiEnvelope<T>>
return request<T>({ method: 'DELETE', url })
}
+2 -1
View File
@@ -76,7 +76,8 @@ const router = createRouter({
},
{
path: 'message-deliveries',
component: () => import('@/views/admin/message-deliveries/AdminMessageDeliveriesView.vue'),
component: () =>
import('@/views/admin/message-deliveries/AdminMessageDeliveriesView.vue'),
},
{
path: 'webhook-events',
@@ -2,5 +2,8 @@ import { apiGet } from '@/lib/http'
import type { AdminAuditLogItem, AdminPagination } from '@/types/admin'
export function fetchAdminAuditLogs(params?: Record<string, unknown>) {
return apiGet<{ items: AdminAuditLogItem[]; pagination: AdminPagination }>('/api/v1/admin/audit-logs', params)
return apiGet<{ items: AdminAuditLogItem[]; pagination: AdminPagination }>(
'/api/v1/admin/audit-logs',
params,
)
}
+29 -7
View File
@@ -1,27 +1,49 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminInventoryItemListItem, AdminInventorySkuSuggestion, AdminPagination } from '@/types/admin'
import type {
AdminInventoryItemListItem,
AdminInventorySkuSuggestion,
AdminPagination,
} from '@/types/admin'
export function fetchAdminInventoryItems(params?: Record<string, unknown>) {
return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>('/api/v1/admin/inventory', params)
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)
return apiGet<{ items: AdminInventorySkuSuggestion[] }>(
'/api/v1/admin/inventory/sku-suggestions',
params,
)
}
export function importAdminInventoryItems(payload: Record<string, unknown>) {
return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/inventory/import', payload)
return apiPost<{ total: number; created: number; duplicated: number }>(
'/api/v1/admin/inventory/import',
payload,
)
}
export function createAdminInventoryItem(payload: Record<string, unknown>) {
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>('/api/v1/admin/inventory', payload)
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(
'/api/v1/admin/inventory',
payload,
)
}
export function releaseAdminInventoryItem(inventoryItemId: number | string) {
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(`/api/v1/admin/inventory/${inventoryItemId}/release`, {})
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(
`/api/v1/admin/inventory/${inventoryItemId}/release`,
{},
)
}
export function invalidateAdminInventoryItem(inventoryItemId: number | string, payload?: Record<string, unknown>) {
export function invalidateAdminInventoryItem(
inventoryItemId: number | string,
payload?: Record<string, unknown>,
) {
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(
`/api/v1/admin/inventory/${inventoryItemId}/invalidate`,
payload || {},
@@ -2,7 +2,10 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http'
import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin'
export function fetchAdminManualRedeemSkuSuggestions(params?: Record<string, unknown>) {
return apiGet<{ items: AdminInventorySkuSuggestion[] }>('/api/v1/admin/manual-redeem/sku-suggestions', params)
return apiGet<{ items: AdminInventorySkuSuggestion[] }>(
'/api/v1/admin/manual-redeem/sku-suggestions',
params,
)
}
export function createAdminManualRedeemTask(payload: {
@@ -30,7 +33,10 @@ export function fetchAdminManualRedeemSessionSummary(taskId: number | string) {
}
export function refreshAdminManualRedeemSession(taskId: number | string) {
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session/refresh`, {})
return apiPost<AdminManualRedeemDetail>(
`/api/v1/admin/manual-redeem/${taskId}/session/refresh`,
{},
)
}
export function removeAdminManualRedeemSession(taskId: number | string) {
+4 -1
View File
@@ -2,7 +2,10 @@ import { apiGet } from '@/lib/http'
import type { AdminOrderDetail, AdminOrderListItem, AdminPagination } from '@/types/admin'
export function fetchAdminOrders(params?: Record<string, unknown>) {
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params)
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/orders',
params,
)
}
export function fetchAdminOrderDetail(orderId: number | string) {
@@ -59,12 +59,11 @@ export function saveAdminNotificationConfig(payload: AdminNotificationConfig) {
}>('/api/v1/admin/platform-config/notifications', payload as unknown as Record<string, unknown>)
}
export function testAdminNotification(payload: {
title?: string
body?: string
url?: string
}) {
return apiPost<AdminNotificationTestResult>('/api/v1/admin/platform-config/notifications/test', payload)
export function testAdminNotification(payload: { title?: string; body?: string; url?: string }) {
return apiPost<AdminNotificationTestResult>(
'/api/v1/admin/platform-config/notifications/test',
payload,
)
}
export function fetchAdminScheduledJobsConfig() {
@@ -85,11 +84,13 @@ export function runAdminScheduledJob(jobId: string) {
}>(`/api/v1/admin/platform-config/scheduled-jobs/${jobId}/run`, {})
}
export function fetchAdminNinetyoneOrders(params: {
page?: number
pageSize?: number
status?: string
} = {}) {
export function fetchAdminNinetyoneOrders(
params: {
page?: number
pageSize?: number
status?: string
} = {},
) {
const searchParams = new URLSearchParams()
if (params.page) {
searchParams.set('page', String(params.page))
@@ -131,7 +132,10 @@ export function saveAdminKuaishouEticketSourceConfig(payload: AdminKuaishouEtick
return apiPost<{
filePath: string
source: AdminKuaishouEticketSourceConfig
}>('/api/v1/admin/platform-config/kuaishou-eticket-source', payload as unknown as Record<string, unknown>)
}>(
'/api/v1/admin/platform-config/kuaishou-eticket-source',
payload as unknown as Record<string, unknown>,
)
}
export function queryAdminKuaishouEticketDetail(payload: {
@@ -140,7 +144,10 @@ export function queryAdminKuaishouEticketDetail(payload: {
cookie?: string
eTicketId: string
}) {
return apiPost<AdminKuaishouEticketDetailResult>('/api/v1/admin/platform-config/kuaishou-eticket/query-detail', payload)
return apiPost<AdminKuaishouEticketDetailResult>(
'/api/v1/admin/platform-config/kuaishou-eticket/query-detail',
payload,
)
}
export function queryAdminKuaishouEticketShopInfo(payload: {
@@ -148,7 +155,10 @@ export function queryAdminKuaishouEticketShopInfo(payload: {
shopId?: string
cookie?: string
}) {
return apiPost<AdminKuaishouEticketShopInfoResult>('/api/v1/admin/platform-config/kuaishou-eticket/query-shop-info', payload)
return apiPost<AdminKuaishouEticketShopInfoResult>(
'/api/v1/admin/platform-config/kuaishou-eticket/query-shop-info',
payload,
)
}
export function consumeAdminKuaishouEticket(payload: {
@@ -161,7 +171,10 @@ export function consumeAdminKuaishouEticket(payload: {
num?: number
storeId?: string
}) {
return apiPost<AdminKuaishouEticketConsumeResult>('/api/v1/admin/platform-config/kuaishou-eticket/consume', payload)
return apiPost<AdminKuaishouEticketConsumeResult>(
'/api/v1/admin/platform-config/kuaishou-eticket/consume',
payload,
)
}
export function fetchAdminCloudtentaclesSourceConfig() {
@@ -179,7 +192,10 @@ export function saveAdminCloudtentaclesSourceConfig(payload: AdminCloudtentacles
sessionFilePath: string
source: AdminCloudtentaclesSourceConfig
session: AdminCloudtentaclesPersistedSession
}>('/api/v1/admin/platform-config/cloudtentacles-source', payload as unknown as Record<string, unknown>)
}>(
'/api/v1/admin/platform-config/cloudtentacles-source',
payload as unknown as Record<string, unknown>,
)
}
export function sendAdminCloudtentaclesSmsCode(payload: {
@@ -189,7 +205,10 @@ export function sendAdminCloudtentaclesSmsCode(payload: {
deviceId?: string
deviceType?: number
}) {
return apiPost<AdminCloudtentaclesSendSmsResult>('/api/v1/admin/platform-config/cloudtentacles/send-sms-code', payload)
return apiPost<AdminCloudtentaclesSendSmsResult>(
'/api/v1/admin/platform-config/cloudtentacles/send-sms-code',
payload,
)
}
export function testAdminCloudtentaclesLogin(payload: {
@@ -201,7 +220,10 @@ export function testAdminCloudtentaclesLogin(payload: {
deviceId?: string
deviceType?: number
}) {
return apiPost<AdminCloudtentaclesLoginTestResult>('/api/v1/admin/platform-config/cloudtentacles/test-login', payload)
return apiPost<AdminCloudtentaclesLoginTestResult>(
'/api/v1/admin/platform-config/cloudtentacles/test-login',
payload,
)
}
export function validateAdminCloudtentaclesSession(payload: {
@@ -210,7 +232,10 @@ export function validateAdminCloudtentaclesSession(payload: {
deviceId?: string
deviceType?: number
}) {
return apiPost<AdminCloudtentaclesValidateSessionResult>('/api/v1/admin/platform-config/cloudtentacles/validate-session', payload)
return apiPost<AdminCloudtentaclesValidateSessionResult>(
'/api/v1/admin/platform-config/cloudtentacles/validate-session',
payload,
)
}
export function fetchAdminCloudtentaclesAsset(payload: {
@@ -219,7 +244,10 @@ export function fetchAdminCloudtentaclesAsset(payload: {
deviceId?: string
deviceType?: number
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/asset', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/asset',
payload,
)
}
export function fetchAdminCloudtentaclesCategories(payload: {
@@ -228,7 +256,10 @@ export function fetchAdminCloudtentaclesCategories(payload: {
deviceId?: string
deviceType?: number
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/categories', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/categories',
payload,
)
}
export function fetchAdminCloudtentaclesSkuList(payload: {
@@ -237,7 +268,10 @@ export function fetchAdminCloudtentaclesSkuList(payload: {
deviceId?: string
deviceType?: number
}) {
return apiPost<AdminCloudtentaclesSkuListResult>('/api/v1/admin/platform-config/cloudtentacles/sku/list', payload)
return apiPost<AdminCloudtentaclesSkuListResult>(
'/api/v1/admin/platform-config/cloudtentacles/sku/list',
payload,
)
}
export function buyAdminCloudtentaclesSku(payload: {
@@ -248,7 +282,10 @@ export function buyAdminCloudtentaclesSku(payload: {
id?: number
count?: number
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/sku/buy', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/sku/buy',
payload,
)
}
export function useAdminCloudtentaclesSku(payload: {
@@ -260,7 +297,10 @@ export function useAdminCloudtentaclesSku(payload: {
virtualNumberId?: number
phone?: string
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/sku/use', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/sku/use',
payload,
)
}
export function fetchAdminCloudtentaclesKnapsack(payload: {
@@ -269,7 +309,10 @@ export function fetchAdminCloudtentaclesKnapsack(payload: {
deviceId?: string
deviceType?: number
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/knapsack', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/knapsack',
payload,
)
}
export function fetchAdminCloudtentaclesVnList(payload: {
@@ -279,7 +322,10 @@ export function fetchAdminCloudtentaclesVnList(payload: {
deviceType?: number
key?: string
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/vn/list', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/list',
payload,
)
}
export function appointAdminCloudtentaclesVn(payload: {
@@ -289,7 +335,10 @@ export function appointAdminCloudtentaclesVn(payload: {
deviceType?: number
key?: string
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/vn/appoint', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/appoint',
payload,
)
}
export function generateAdminCloudtentaclesVnLoginCode(payload: {
@@ -300,7 +349,10 @@ export function generateAdminCloudtentaclesVnLoginCode(payload: {
key?: string
id?: number
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/vn/generate-login-code', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/generate-login-code',
payload,
)
}
export function fetchAdminCloudtentaclesVnCode(payload: {
@@ -311,7 +363,10 @@ export function fetchAdminCloudtentaclesVnCode(payload: {
key?: string
phone?: string
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/vn/fetch-code', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/fetch-code',
payload,
)
}
export function verifyAdminCloudtentaclesVnCode(payload: {
@@ -323,7 +378,10 @@ export function verifyAdminCloudtentaclesVnCode(payload: {
id?: number
code?: string
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/vn/verify-code', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/verify-code',
payload,
)
}
export function fetchAdminCloudtentaclesBindUrl(payload: {
@@ -334,7 +392,10 @@ export function fetchAdminCloudtentaclesBindUrl(payload: {
key?: string
id?: number
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/vn/bind-url', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/bind-url',
payload,
)
}
export function backAdminCloudtentaclesVn(payload: {
@@ -345,7 +406,10 @@ export function backAdminCloudtentaclesVn(payload: {
key?: string
id?: number
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/vn/back', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/back',
payload,
)
}
export function runAdminCloudtentaclesFullFlow(payload: {
@@ -357,7 +421,10 @@ export function runAdminCloudtentaclesFullFlow(payload: {
skuCount?: number
vnKey?: string
}) {
return apiPost<Record<string, unknown>>('/api/v1/admin/platform-config/cloudtentacles/debug/full-flow', payload)
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/debug/full-flow',
payload,
)
}
export function fetchAdminFulfillmentBindingConfigs() {
@@ -374,10 +441,15 @@ export function lookupAdminFulfillmentBindingOrder(payload: {
shopId: string
platformOrderId: string
}) {
return apiPost<AdminFulfillmentLookupResult>('/api/v1/admin/platform-config/fulfillment-bindings/lookup-order', payload)
return apiPost<AdminFulfillmentLookupResult>(
'/api/v1/admin/platform-config/fulfillment-bindings/lookup-order',
payload,
)
}
export function saveAdminFulfillmentBindingConfigs(payload: { bindings: Array<Record<string, unknown>> }) {
export function saveAdminFulfillmentBindingConfigs(payload: {
bindings: Array<Record<string, unknown>>
}) {
return apiPost<{
filePath: string
bindings: AdminFulfillmentBindingConfigItem[]
@@ -391,9 +463,14 @@ export function fetchAdminKuaishouCloudFulfillmentConfig() {
}>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment')
}
export function saveAdminKuaishouCloudFulfillmentConfig(payload: AdminKuaishouCloudFulfillmentConfig) {
export function saveAdminKuaishouCloudFulfillmentConfig(
payload: AdminKuaishouCloudFulfillmentConfig,
) {
return apiPost<{
filePath: string
source: AdminKuaishouCloudFulfillmentConfig
}>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment', payload as unknown as Record<string, unknown>)
}>(
'/api/v1/admin/platform-config/kuaishou-cloud-fulfillment',
payload as unknown as Record<string, unknown>,
)
}
+38 -9
View File
@@ -1,8 +1,16 @@
import { apiGet, apiGetBlob, apiPost } from '@/lib/http'
import type { AdminPagination, AdminTaskActionResponse, AdminTaskDetail, AdminTaskListItem } from '@/types/admin'
import type {
AdminPagination,
AdminTaskActionResponse,
AdminTaskDetail,
AdminTaskListItem,
} from '@/types/admin'
export function fetchAdminTasks(params?: Record<string, unknown>) {
return apiGet<{ items: AdminTaskListItem[]; pagination: AdminPagination }>('/api/v1/admin/tasks', params)
return apiGet<{ items: AdminTaskListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/tasks',
params,
)
}
export function fetchAdminTaskDetail(taskId: number | string) {
@@ -21,8 +29,14 @@ export function releaseAdminTaskInventory(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/release-inventory`, {})
}
export function releaseAdminTaskInventoryBinding(taskId: number | string, bindingId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/inventory-bindings/${bindingId}/release`, {})
export function releaseAdminTaskInventoryBinding(
taskId: number | string,
bindingId: number | string,
) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/inventory-bindings/${bindingId}/release`,
{},
)
}
export function regenerateAdminTaskClaimLink(taskId: number | string) {
@@ -54,15 +68,24 @@ export function completeAdminTaskManualDispatch(
deliveredCredential?: string
},
) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/complete-manual-dispatch`, payload)
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/complete-manual-dispatch`,
payload,
)
}
export function prepareAdminTaskKuaishouCloudFulfillment(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/prepare`, {})
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/prepare`,
{},
)
}
export function refreshAdminTaskKuaishouCloudRoleInfo(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/refresh-role-info`, {})
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/refresh-role-info`,
{},
)
}
export function dispatchAdminTaskKuaishouCloudFulfillment(
@@ -71,9 +94,15 @@ export function dispatchAdminTaskKuaishouCloudFulfillment(
ticketCode?: string
} = {},
) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`, payload)
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`,
payload,
)
}
export function returnNumberAdminTaskKuaishouCloudFulfillment(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/return-number`, {})
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/return-number`,
{},
)
}
+22 -5
View File
@@ -2,10 +2,18 @@ import { apiGet, apiPost } from '@/lib/http'
import type { AdminPagination, AdminUserListItem } from '@/types/admin'
export function fetchAdminUsers(params?: Record<string, unknown>) {
return apiGet<{ items: AdminUserListItem[]; pagination: AdminPagination }>('/api/v1/admin/users', params)
return apiGet<{ items: AdminUserListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/users',
params,
)
}
export function createAdminUser(payload: { username: string; password: string; role: string; status?: string }) {
export function createAdminUser(payload: {
username: string
password: string
role: string
status?: string
}) {
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
}
@@ -17,10 +25,19 @@ export function updateAdminUserStatus(userId: number | string, payload: { status
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/status`, payload)
}
export function updateAdminUserInventoryGroups(userId: number | string, payload: { inventoryGroupCodes: string[] }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/inventory-groups`, payload)
export function updateAdminUserInventoryGroups(
userId: number | string,
payload: { inventoryGroupCodes: string[] },
) {
return apiPost<{ user: AdminUserListItem }>(
`/api/v1/admin/users/${userId}/inventory-groups`,
payload,
)
}
export function resetAdminUserPassword(userId: number | string, payload: { password: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/reset-password`, payload)
return apiPost<{ user: AdminUserListItem }>(
`/api/v1/admin/users/${userId}/reset-password`,
payload,
)
}
@@ -1,5 +1,10 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminPagination, AdminWebhookEventDetail, AdminWebhookEventListItem, AdminWebhookReplayResponse } from '@/types/admin'
import type {
AdminPagination,
AdminWebhookEventDetail,
AdminWebhookEventListItem,
AdminWebhookReplayResponse,
} from '@/types/admin'
export function fetchAdminWebhookEvents(params?: Record<string, unknown>) {
return apiGet<{ items: AdminWebhookEventListItem[]; pagination: AdminPagination }>(
@@ -21,7 +21,9 @@ export function fetchTencentBrowserSession(sessionId: string) {
}
export function fetchTencentBrowserSessionSummary(sessionId: string) {
return apiGet<TencentBrowserSessionSummaryData>(`/api/v1/tencent/browser/session/${sessionId}/summary`)
return apiGet<TencentBrowserSessionSummaryData>(
`/api/v1/tencent/browser/session/${sessionId}/summary`,
)
}
export function refreshTencentBrowserSessionPage(sessionId: string) {
+25 -5
View File
@@ -106,11 +106,31 @@
line-height: 1;
}
.summary-metric[data-tone='success'] { color: var(--color-success-dark); background: var(--color-success-light); border-color: #bbf7d0; }
.summary-metric[data-tone='danger'] { color: var(--color-danger-dark); background: var(--color-danger-light); border-color: #fecaca; }
.summary-metric[data-tone='primary'] { color: var(--color-primary-dark); background: var(--color-primary-light); border-color: #bfdbfe; }
.summary-metric[data-tone='warning'] { color: var(--color-warning-dark); background: var(--color-warning-light); border-color: #fed7aa; }
.summary-metric[data-tone='muted'] { color: var(--text-secondary); background: #f8fafc; border-color: var(--border-default); }
.summary-metric[data-tone='success'] {
color: var(--color-success-dark);
background: var(--color-success-light);
border-color: #bbf7d0;
}
.summary-metric[data-tone='danger'] {
color: var(--color-danger-dark);
background: var(--color-danger-light);
border-color: #fecaca;
}
.summary-metric[data-tone='primary'] {
color: var(--color-primary-dark);
background: var(--color-primary-light);
border-color: #bfdbfe;
}
.summary-metric[data-tone='warning'] {
color: var(--color-warning-dark);
background: var(--color-warning-light);
border-color: #fed7aa;
}
.summary-metric[data-tone='muted'] {
color: var(--text-secondary);
background: #f8fafc;
border-color: var(--border-default);
}
/* ---------- 表格工具栏 ---------- */
.table-toolbar {
+4 -2
View File
@@ -37,7 +37,9 @@ export interface TencentBrowserSessionData {
artifacts: TencentBrowserSessionArtifacts
}
export interface TencentBrowserSessionSummaryData
extends Omit<TencentBrowserSessionData, 'qrImageBase64'> {
export interface TencentBrowserSessionSummaryData extends Omit<
TencentBrowserSessionData,
'qrImageBase64'
> {
qrImageBase64?: string
}
+4 -2
View File
@@ -86,7 +86,7 @@ export function formatRedeemOutcomeLabel(value: string) {
}
export function formatRedeemResolutionStatus(value: string) {
return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : (value || '-')
return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : value || '-'
}
export function formatAutoDeliveryTrigger(value: string) {
@@ -161,7 +161,9 @@ export function formatTaskEventPayload(payload: Record<string, unknown>) {
payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : '',
payload.sendType ? `发送类型 ${payload.sendType}` : '',
payload.note ? `备注 ${payload.note}` : '',
payload.previousOutcome ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` : '',
payload.previousOutcome
? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}`
: '',
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
payload.trigger ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ''))}` : '',
+4 -1
View File
@@ -35,7 +35,10 @@ export function normalizeDateTimeValue(value: unknown): unknown {
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, currentValue]) => [key, normalizeDateTimeValue(currentValue)]),
Object.entries(value).map(([key, currentValue]) => [
key,
normalizeDateTimeValue(currentValue),
]),
)
}
@@ -73,18 +73,17 @@ onMounted(loadAuditLogs)
<template>
<div class="audit-page list-page">
<AdminPageHeader title="操作审计" description="集中查看高风险后台动作,便于排查谁在什么时间改了什么。">
<AdminPageHeader
title="操作审计"
description="集中查看高风险后台动作,便于排查谁在什么时间改了什么。"
>
<template #extra>
<span class="total-label">{{ totalLabel }}</span>
</template>
</AdminPageHeader>
<!-- 权限检查 -->
<el-result
v-if="!hasAdminRole('admin')"
icon="warning"
title="仅管理员可以访问操作审计"
/>
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以访问操作审计" />
<template v-else>
<!-- 筛选栏 -->
@@ -98,13 +97,35 @@ onMounted(loadAuditLogs)
@keyup.enter="handlePageChange"
/>
<el-select v-model="action" placeholder="动作" clearable style="width: 160px">
<el-option v-for="opt in adminAuditActionOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-option
v-for="opt in adminAuditActionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="targetType" placeholder="目标类型" clearable style="width: 160px">
<el-option v-for="opt in adminAuditTargetTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-option
v-for="opt in adminAuditTargetTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker v-model="dateFrom" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" style="width: 160px" />
<el-date-picker v-model="dateTo" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 160px" />
<el-date-picker
v-model="dateFrom"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
style="width: 160px"
/>
<el-date-picker
v-model="dateTo"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 160px"
/>
<el-button type="primary" @click="handlePageChange">查询</el-button>
</div>
</el-card>
@@ -47,11 +47,7 @@ async function submitLogin() {
<label class="field">
<span>账号</span>
<el-input
v-model="username"
placeholder="输入账号"
@keyup.enter="submitLogin"
/>
<el-input v-model="username" placeholder="输入账号" @keyup.enter="submitLogin" />
</label>
<label class="field">
@@ -55,7 +55,10 @@ const cards = [
</template>
<style scoped>
.dashboard-page { display: grid; gap: 0; }
.dashboard-page {
display: grid;
gap: 0;
}
.summary-grid {
display: grid;
@@ -82,6 +85,8 @@ const cards = [
}
@media (max-width: 900px) {
.summary-grid { grid-template-columns: 1fr; }
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -88,9 +88,15 @@ const lookupForm = reactive({
const ruleMetrics = computed(() => {
const completedCount = bindings.value.filter(isEditableBindingComplete).length
const manualCount = bindings.value.filter((item) => item.profileKey.trim() === 'manual_review').length
const assistedCount = bindings.value.filter((item) => item.profileKey.trim() === 'tencent_claim_assisted').length
const kuaishouCloudCount = bindings.value.filter((item) => item.profileKey.trim() === 'kuaishou_ct_assisted').length
const manualCount = bindings.value.filter(
(item) => item.profileKey.trim() === 'manual_review',
).length
const assistedCount = bindings.value.filter(
(item) => item.profileKey.trim() === 'tencent_claim_assisted',
).length
const kuaishouCloudCount = bindings.value.filter(
(item) => item.profileKey.trim() === 'kuaishou_ct_assisted',
).length
return {
totalCount: bindings.value.length,
@@ -102,13 +108,20 @@ const ruleMetrics = computed(() => {
}
})
const pendingObservedCount = computed(() => observedProducts.value.filter((item) => !item.configured).length)
const pendingObservedCount = computed(
() => observedProducts.value.filter((item) => !item.configured).length,
)
type ImportableProductCandidate = Pick<
AdminObservedProductItem,
'provider' | 'platform' | 'shopId' | 'shopName' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
> & {
}
| 'provider'
| 'platform'
| 'shopId'
| 'shopName'
| 'externalSkuCode'
| 'externalItemId'
| 'externalSkuName'
> & {}
function createEmptyBinding(): EditableBinding {
return {
@@ -148,12 +161,20 @@ function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBi
}
}
function hasMatchCondition(item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
return Boolean(item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim())
function hasMatchCondition(
item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>,
) {
return Boolean(
item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(),
)
}
function isEditableBindingComplete(item: EditableBinding) {
return Boolean(item.skuCode.trim() && hasMatchCondition(item) && VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'))
return Boolean(
item.skuCode.trim() &&
hasMatchCondition(item) &&
VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'),
)
}
function buildCollapsedIds(items: EditableBinding[]) {
@@ -304,23 +325,27 @@ function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
function hasBindingContent(item: SaveBindingPayload) {
return Boolean(
item.platform
|| item.shopId
|| item.shopName
|| item.skuCode
|| item.skuName
|| item.match.externalSkuCode
|| item.match.externalItemId
|| item.match.externalSkuName
|| item.match.config.resolvedSkuName
|| item.provider !== 'agiso'
|| item.profileKey !== 'manual_review'
|| item.priority !== 100
|| item.enabled !== true,
item.platform ||
item.shopId ||
item.shopName ||
item.skuCode ||
item.skuName ||
item.match.externalSkuCode ||
item.match.externalItemId ||
item.match.externalSkuName ||
item.match.config.resolvedSkuName ||
item.provider !== 'agiso' ||
item.profileKey !== 'manual_review' ||
item.priority !== 100 ||
item.enabled !== true,
)
}
function resolveBindingValidationState(item: SaveBindingPayload, index: number, bindingId: string): ValidationState {
function resolveBindingValidationState(
item: SaveBindingPayload,
index: number,
bindingId: string,
): ValidationState {
const label = `${index + 1} 条规则`
if (!item.skuCode) {
@@ -384,7 +409,10 @@ function isBindingInvalid(bindingId: string) {
return validationState.value?.bindingId === bindingId
}
function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName') {
function isFieldInvalid(
bindingId: string,
field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName',
) {
if (validationState.value?.bindingId !== bindingId) {
return false
}
@@ -552,14 +580,20 @@ onMounted(loadConfigs)
</div>
<div class="section-action-group">
<el-button round @click="addBinding">新增规则</el-button>
<el-button :loading="saving" round type="primary" @click="saveConfigs">保存配置</el-button>
<el-button :loading="saving" round type="primary" @click="saveConfigs"
>保存配置</el-button
>
</div>
</div>
</div>
<p v-if="validationState?.message" class="validation-banner">{{ validationState.message }}</p>
<p v-if="validationState?.message" class="validation-banner">
{{ validationState.message }}
</p>
<div v-if="bindings.length === 0" class="empty-inline">当前还没有履约规则先新增一条</div>
<div v-if="bindings.length === 0" class="empty-inline">
当前还没有履约规则先新增一条
</div>
<div
v-for="(binding, index) in bindings"
@@ -571,21 +605,42 @@ onMounted(loadConfigs)
<div class="binding-summary">
<div class="binding-title-row">
<strong>{{ getBindingTitle(binding, index) }}</strong>
<span :class="['binding-status-chip', { 'binding-status-chip--done': isEditableBindingComplete(binding) }]">
<span
:class="[
'binding-status-chip',
{ 'binding-status-chip--done': isEditableBindingComplete(binding) },
]"
>
{{ getBindingStatusLabel(binding) }}
</span>
</div>
<span class="binding-subtle">{{ getBindingSummary(binding) }}</span>
</div>
<button class="collapse-button" type="button" @click="toggleBindingCollapsed(binding.id)">
<button
class="collapse-button"
type="button"
@click="toggleBindingCollapsed(binding.id)"
>
{{ isBindingCollapsed(binding.id) ? '展开' : '折叠' }}
</button>
</div>
<div v-if="isBindingCollapsed(binding.id)" class="binding-collapsed-preview">
<span>外部商品{{ binding.externalSkuName || binding.externalSkuCode || binding.externalItemId || '-' }}</span>
<span
>外部商品{{
binding.externalSkuName ||
binding.externalSkuCode ||
binding.externalItemId ||
'-'
}}</span
>
<span>内部履约 SKU{{ binding.skuCode || '-' }}</span>
<span>履约方式{{ PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label || binding.profileKey }}</span>
<span
>履约方式{{
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
binding.profileKey
}}</span
>
</div>
<div v-else class="binding-grid">
@@ -613,7 +668,10 @@ onMounted(loadConfigs)
<span>店铺 ID</span>
<input
v-model="binding.shopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') },
]"
data-field="shopId"
placeholder="留空表示跨店铺通用"
@input="clearValidationState"
@@ -632,14 +690,23 @@ onMounted(loadConfigs)
<label class="field-block">
<span>优先级</span>
<input v-model.number="binding.priority" class="text-input" type="number" min="1" @input="clearValidationState" />
<input
v-model.number="binding.priority"
class="text-input"
type="number"
min="1"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>外部 SKU 编码</span>
<input
v-model="binding.externalSkuCode"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') },
]"
data-field="externalSkuCode"
placeholder="例如 6046726460016"
@input="clearValidationState"
@@ -650,7 +717,10 @@ onMounted(loadConfigs)
<span>外部商品 ID</span>
<input
v-model="binding.externalItemId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') },
]"
data-field="externalItemId"
placeholder="例如 1033324289962"
@input="clearValidationState"
@@ -661,7 +731,10 @@ onMounted(loadConfigs)
<span>外部商品名</span>
<input
v-model="binding.externalSkuName"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') },
]"
data-field="externalSkuName"
placeholder="例如 奥利奥动作"
@input="clearValidationState"
@@ -672,24 +745,37 @@ onMounted(loadConfigs)
<span>内部履约 SKU</span>
<input
v-model="binding.skuCode"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') },
]"
data-field="skuCode"
placeholder="例如 sjz_hdl_test01"
@input="clearValidationState"
/>
<small class="field-help">填你们系统内部统一 SKU相同履约商品尽量跨平台共用同一个内部 SKU</small>
<small class="field-help"
>填你们系统内部统一 SKU相同履约商品尽量跨平台共用同一个内部 SKU</small
>
</label>
<label class="field-block">
<span>内部商品名</span>
<input v-model="binding.skuName" class="text-input" placeholder="用于后台展示" @input="clearValidationState" />
<input
v-model="binding.skuName"
class="text-input"
placeholder="用于后台展示"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>履约方式</span>
<select
v-model="binding.profileKey"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') }]"
:class="[
'text-input',
{ 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') },
]"
data-field="profileKey"
@change="clearValidationState"
>
@@ -710,7 +796,9 @@ onMounted(loadConfigs)
</label>
</div>
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">{{ validationState?.message }}</p>
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">
{{ validationState?.message }}
</p>
<div class="binding-actions">
<label class="checkbox-line">
@@ -764,7 +852,9 @@ onMounted(loadConfigs)
<div class="field-block field-block--action">
<span>操作</span>
<el-button :loading="lookupLoading" round type="primary" @click="lookupOrderProducts">查询订单商品</el-button>
<el-button :loading="lookupLoading" round type="primary" @click="lookupOrderProducts"
>查询订单商品</el-button
>
</div>
</div>
@@ -778,17 +868,27 @@ onMounted(loadConfigs)
<div class="lookup-summary-item">
<span class="summary-label">订单</span>
<strong>{{ lookupResult.order.platformOrderId }}</strong>
<span class="cell-subtle">{{ lookupResult.order.shopName || lookupResult.order.shopId || '-' }}</span>
<span class="cell-subtle">{{
lookupResult.order.shopName || lookupResult.order.shopId || '-'
}}</span>
</div>
<div class="lookup-summary-item">
<span class="summary-label">买家</span>
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
<span class="cell-subtle">支付时间{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span>
<span class="cell-subtle"
>支付时间{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span
>
</div>
<div class="lookup-summary-item">
<span class="summary-label">金额</span>
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
<span class="cell-subtle">补查结果{{ lookupResult.order.enriched ? '已补全' : lookupResult.order.enrichReason || '已返回' }}</span>
<span class="cell-subtle"
>补查结果{{
lookupResult.order.enriched
? '已补全'
: lookupResult.order.enrichReason || '已返回'
}}</span
>
</div>
</div>
@@ -805,7 +905,13 @@ onMounted(loadConfigs)
<tr v-for="item in lookupResult.items" :key="item.lineId">
<td>
<div class="cell-stack">
<strong>{{ item.externalSkuName || item.itemTitle || item.externalSkuCode || item.externalItemId || '-' }}</strong>
<strong>{{
item.externalSkuName ||
item.itemTitle ||
item.externalSkuCode ||
item.externalItemId ||
'-'
}}</strong>
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
</div>
@@ -815,7 +921,8 @@ onMounted(loadConfigs)
<div class="cell-stack">
<strong>{{ item.configured ? '已配置' : '未配置' }}</strong>
<span v-if="item.matchedBinding" class="cell-subtle">
{{ item.matchedBinding.skuName || item.matchedBinding.skuCode || '-' }} / {{ item.matchedBinding.profileKey || '-' }}
{{ item.matchedBinding.skuName || item.matchedBinding.skuCode || '-' }} /
{{ item.matchedBinding.profileKey || '-' }}
</span>
<span v-else class="cell-subtle">可直接导入成新规则草稿</span>
</div>
@@ -862,16 +969,23 @@ onMounted(loadConfigs)
</tr>
</thead>
<tbody>
<tr v-for="item in observedProducts" :key="`${item.provider}:${item.platform}:${item.shopId}:${item.externalSkuCode}:${item.externalItemId}:${item.externalSkuName}`">
<tr
v-for="item in observedProducts"
:key="`${item.provider}:${item.platform}:${item.shopId}:${item.externalSkuCode}:${item.externalItemId}:${item.externalSkuName}`"
>
<td>
<div class="cell-stack">
<strong>{{ item.platform || '-' }}</strong>
<span class="cell-subtle">{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span>
<span class="cell-subtle"
>{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span
>
</div>
</td>
<td>
<div class="cell-stack">
<strong>{{ item.externalSkuName || item.externalSkuCode || item.externalItemId || '-' }}</strong>
<strong>{{
item.externalSkuName || item.externalSkuCode || item.externalItemId || '-'
}}</strong>
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
</div>
@@ -724,7 +724,9 @@
background: #fff;
color: #1d3555;
outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.text-input:focus {
@@ -969,4 +971,4 @@
.field-wide {
grid-column: auto;
}
}
}
@@ -65,18 +65,26 @@ const metrics = computed(() => {
}
})
const filteredItems = computed(() => items.value.filter((item) => matchesRuleFilter(item, ruleFilter.value)))
const filteredItems = computed(() =>
items.value.filter((item) => matchesRuleFilter(item, ruleFilter.value)),
)
const ruleFilterOptions = computed<Array<{ value: RuleFilter; label: string; count: number }>>(() => [
{ value: 'all', label: '全部', count: metrics.value.total },
{ value: 'draft', label: '待完善', count: metrics.value.draftCount },
{ value: 'ready', label: '可投产', count: metrics.value.readyCount },
{ value: 'disabled', label: '已停用', count: metrics.value.disabledCount },
])
const ruleFilterOptions = computed<Array<{ value: RuleFilter; label: string; count: number }>>(
() => [
{ value: 'all', label: '全部', count: metrics.value.total },
{ value: 'draft', label: '待完善', count: metrics.value.draftCount },
{ value: 'ready', label: '可投产', count: metrics.value.readyCount },
{ value: 'disabled', label: '已停用', count: metrics.value.disabledCount },
],
)
const ninetyoneLookupMetrics = computed(() => {
const importedCount = ninetyoneLookupResults.value.filter((item) => isNinetyoneProductImported(item)).length
const pendingCount = ninetyoneLookupResults.value.filter((item) => item.orderStatus === 'pending_config').length
const importedCount = ninetyoneLookupResults.value.filter((item) =>
isNinetyoneProductImported(item),
).length
const pendingCount = ninetyoneLookupResults.value.filter(
(item) => item.orderStatus === 'pending_config',
).length
return {
total: ninetyoneLookupResults.value.length,
importedCount,
@@ -116,7 +124,10 @@ function createEmptyItem(): EditableItem {
}
function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem {
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId, item.kuaishouConsumeShopName)
const matchedShop = findKuaishouConsumeShop(
item.kuaishouConsumeShopId,
item.kuaishouConsumeShopName,
)
return {
localId: crypto.randomUUID(),
id: item.id || crypto.randomUUID(),
@@ -146,7 +157,10 @@ function mapEditableItem(item: AdminKuaishouCloudFulfillmentItem): EditableItem
}
function mapSaveItem(item: EditableItem): AdminKuaishouCloudFulfillmentItem {
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId, item.kuaishouConsumeShopName)
const matchedShop = findKuaishouConsumeShop(
item.kuaishouConsumeShopId,
item.kuaishouConsumeShopName,
)
return {
id: item.id.trim() || item.internalSkuCode.trim() || item.localId,
enabled: item.enabled,
@@ -174,43 +188,47 @@ function mapSaveItem(item: EditableItem): AdminKuaishouCloudFulfillmentItem {
}
}
function hasExternalMatch(item: Pick<EditableItem, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
return Boolean(item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim())
function hasExternalMatch(
item: Pick<EditableItem, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>,
) {
return Boolean(
item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(),
)
}
function hasMeaningfulContent(item: EditableItem) {
return Boolean(
item.internalSkuCode.trim()
|| item.internalSkuName.trim()
|| item.externalSkuCode.trim()
|| item.externalItemId.trim()
|| item.externalSkuName.trim()
|| item.resolvedSkuName.trim()
|| item.cloudSourceKey.trim() !== 'default'
|| Number(item.cloudSkuId || 0) > 0
|| item.cloudSkuName.trim()
|| item.vnKey.trim()
|| item.shopId.trim()
|| item.notes.trim()
|| item.provider.trim() !== '91kaquan'
|| item.platform.trim() !== 'kuaishou'
|| item.priority !== 100
|| item.autoBuyEnabled !== true
|| item.minAssetReserve !== 0
|| item.autoReturnNumberAfterDispatch
|| item.autoConsumeAfterDispatch
|| item.kuaishouConsumeShopId.trim()
|| item.kuaishouConsumeShopName.trim()
|| item.enabled !== true,
item.internalSkuCode.trim() ||
item.internalSkuName.trim() ||
item.externalSkuCode.trim() ||
item.externalItemId.trim() ||
item.externalSkuName.trim() ||
item.resolvedSkuName.trim() ||
item.cloudSourceKey.trim() !== 'default' ||
Number(item.cloudSkuId || 0) > 0 ||
item.cloudSkuName.trim() ||
item.vnKey.trim() ||
item.shopId.trim() ||
item.notes.trim() ||
item.provider.trim() !== '91kaquan' ||
item.platform.trim() !== 'kuaishou' ||
item.priority !== 100 ||
item.autoBuyEnabled !== true ||
item.minAssetReserve !== 0 ||
item.autoReturnNumberAfterDispatch ||
item.autoConsumeAfterDispatch ||
item.kuaishouConsumeShopId.trim() ||
item.kuaishouConsumeShopName.trim() ||
item.enabled !== true,
)
}
function isItemComplete(item: EditableItem) {
return Boolean(
item.internalSkuCode.trim()
&& Number(item.cloudSkuId || 0) > 0
&& (!item.autoConsumeAfterDispatch || Boolean(item.kuaishouConsumeShopId.trim()))
&& hasExternalMatch(item),
item.internalSkuCode.trim() &&
Number(item.cloudSkuId || 0) > 0 &&
(!item.autoConsumeAfterDispatch || Boolean(item.kuaishouConsumeShopId.trim())) &&
hasExternalMatch(item),
)
}
@@ -241,7 +259,12 @@ function resolveValidation(item: EditableItem, index: number): ValidationState {
}
function getCardTitle(item: EditableItem, index: number) {
return item.internalSkuName.trim() || item.externalSkuName.trim() || item.internalSkuCode.trim() || `规则 ${index + 1}`
return (
item.internalSkuName.trim() ||
item.externalSkuName.trim() ||
item.internalSkuCode.trim() ||
`规则 ${index + 1}`
)
}
function getCardSummary(item: EditableItem) {
@@ -299,10 +322,13 @@ function getKuaishouConsumeShopOptions() {
function findKuaishouConsumeShop(shopId = '', shopName = '') {
const normalizedShopId = String(shopId || '').trim()
const normalizedShopName = String(shopName || '').trim()
return getKuaishouConsumeShopOptions().find((shop) => (
(normalizedShopId && shop.shopId === normalizedShopId)
|| (normalizedShopName && shop.kshopName === normalizedShopName)
)) || null
return (
getKuaishouConsumeShopOptions().find(
(shop) =>
(normalizedShopId && shop.shopId === normalizedShopId) ||
(normalizedShopName && shop.kshopName === normalizedShopName),
) || null
)
}
function getDefaultKuaishouConsumeShop() {
@@ -319,7 +345,10 @@ function handleKuaishouConsumeShopSelected(item: EditableItem) {
}
function getConsumeShopSummary(item: EditableItem) {
const matchedShop = findKuaishouConsumeShop(item.kuaishouConsumeShopId, item.kuaishouConsumeShopName)
const matchedShop = findKuaishouConsumeShop(
item.kuaishouConsumeShopId,
item.kuaishouConsumeShopName,
)
const name = String(matchedShop?.kshopName || item.kuaishouConsumeShopName).trim()
const id = String(matchedShop?.shopId || item.kuaishouConsumeShopId).trim()
@@ -369,7 +398,9 @@ function expandAllRules() {
}
function collapseReadyRules() {
collapsedIds.value = items.value.filter((item) => isItemComplete(item) || !item.enabled).map((item) => item.localId)
collapsedIds.value = items.value
.filter((item) => isItemComplete(item) || !item.enabled)
.map((item) => item.localId)
}
async function loadConfigs() {
@@ -386,7 +417,9 @@ async function loadConfigs() {
fetchAdminKuaishouCloudFulfillmentConfig(),
fetchAdminKuaishouEticketSourceConfig(),
])
kuaishouConsumeShops.value = Array.isArray(eticketResponse.data.source.shops) ? eticketResponse.data.source.shops : []
kuaishouConsumeShops.value = Array.isArray(eticketResponse.data.source.shops)
? eticketResponse.data.source.shops
: []
filePath.value = response.data.filePath
enabled.value = response.data.source.enabled !== false
items.value = (response.data.source.items || []).map(mapEditableItem)
@@ -450,16 +483,21 @@ function findExistingItemFromNinetyoneOrder(order: AdminNinetyoneOrderItem) {
const productName = String(order.productName || '').trim()
const shopId = String(order.shopId || '91kaquan').trim()
return items.value.find((item) => {
const sameSource = item.provider.trim() === '91kaquan' && item.platform.trim() === 'kuaishou'
const sameShop = !shopId || item.shopId.trim() === shopId
const sameProductNo = productNo && (
item.externalSkuCode.trim() === productNo
|| item.externalItemId.trim() === productNo
)
const sameName = productName && [item.externalSkuName, item.internalSkuName, item.resolvedSkuName].some((value) => value.trim() === productName)
return sameSource && sameShop && (sameProductNo || sameName)
}) || null
return (
items.value.find((item) => {
const sameSource = item.provider.trim() === '91kaquan' && item.platform.trim() === 'kuaishou'
const sameShop = !shopId || item.shopId.trim() === shopId
const sameProductNo =
productNo &&
(item.externalSkuCode.trim() === productNo || item.externalItemId.trim() === productNo)
const sameName =
productName &&
[item.externalSkuName, item.internalSkuName, item.resolvedSkuName].some(
(value) => value.trim() === productName,
)
return sameSource && sameShop && (sameProductNo || sameName)
}) || null
)
}
async function importNinetyoneProduct(item: AdminNinetyoneOrderItem) {
@@ -488,11 +526,7 @@ async function importNinetyoneProduct(item: AdminNinetyoneOrderItem) {
}
function getNinetyoneOrderImportKey(item: AdminNinetyoneOrderItem) {
return [
item.orderNo,
item.productNo,
item.shopId || '91kaquan',
]
return [item.orderNo, item.productNo, item.shopId || '91kaquan']
.map((value) => String(value || '').trim())
.join(':')
}
@@ -515,7 +549,8 @@ async function ensureCloudSkuCatalogLoaded(force = false) {
return cloudSkuCatalog.value
} catch (error) {
cloudSkuCatalog.value = []
cloudSkuCatalogErrorMessage.value = error instanceof Error ? error.message : 'cloud SKU 列表查询失败'
cloudSkuCatalogErrorMessage.value =
error instanceof Error ? error.message : 'cloud SKU 列表查询失败'
throw error
} finally {
cloudSkuCatalogLoading.value = false
@@ -523,18 +558,17 @@ async function ensureCloudSkuCatalogLoaded(force = false) {
}
function getCloudSkuKeyword(item: EditableItem) {
return [
item.internalSkuCode,
item.internalSkuName,
item.resolvedSkuName,
item.externalSkuName,
]
.map((value) => String(value || '').trim())
.find(Boolean) || ''
return (
[item.internalSkuCode, item.internalSkuName, item.resolvedSkuName, item.externalSkuName]
.map((value) => String(value || '').trim())
.find(Boolean) || ''
)
}
function normalizeSearchText(value: string) {
return String(value || '').trim().toLowerCase()
return String(value || '')
.trim()
.toLowerCase()
}
function scoreCloudSkuMatch(item: AdminCloudtentaclesSkuItem, keyword: string) {
@@ -644,7 +678,10 @@ async function focusValidationTarget(localId: string) {
}
async function saveConfigs() {
const invalid = items.value.reduce<ValidationState>((state, item, index) => state || resolveValidation(item, index), null)
const invalid = items.value.reduce<ValidationState>(
(state, item, index) => state || resolveValidation(item, index),
null,
)
if (invalid) {
validationState.value = invalid
setCollapsed(invalid.localId, false)
@@ -692,7 +729,8 @@ async function lookupNinetyoneProducts() {
importedNinetyoneOrderKeys.value = []
} catch (error) {
ninetyoneLookupResults.value = []
ninetyoneLookupErrorMessage.value = error instanceof Error ? error.message : '91卡券订单查询失败'
ninetyoneLookupErrorMessage.value =
error instanceof Error ? error.message : '91卡券订单查询失败'
} finally {
ninetyoneLookupLoading.value = false
}
@@ -770,7 +808,9 @@ onMounted(loadConfigs)
<div class="section-title-row section-title-row--tight">
<div>
<h3>91卡券订单取样导入</h3>
<p>查询已接收的 91卡券订单 productNo 导入为规则草稿再补齐内部 SKU cloud 资源</p>
<p>
查询已接收的 91卡券订单 productNo 导入为规则草稿再补齐内部 SKU cloud 资源
</p>
</div>
<div class="section-mini-stats">
<span class="mini-stat-chip">结果 {{ ninetyoneLookupMetrics.total }}</span>
@@ -790,21 +830,43 @@ onMounted(loadConfigs)
</label>
<label class="field-block">
<span>页码</span>
<input v-model.number="ninetyoneLookupForm.page" class="text-input" min="1" type="number" />
<input
v-model.number="ninetyoneLookupForm.page"
class="text-input"
min="1"
type="number"
/>
</label>
<label class="field-block">
<span>每页条数</span>
<input v-model.number="ninetyoneLookupForm.pageSize" class="text-input" max="100" min="1" type="number" />
<input
v-model.number="ninetyoneLookupForm.pageSize"
class="text-input"
max="100"
min="1"
type="number"
/>
</label>
<div class="field-block field-block--action">
<span>操作</span>
<el-button :loading="ninetyoneLookupLoading" round type="primary" @click="lookupNinetyoneProducts">查询 91卡券订单</el-button>
<el-button
:loading="ninetyoneLookupLoading"
round
type="primary"
@click="lookupNinetyoneProducts"
>查询 91卡券订单</el-button
>
</div>
</div>
<p class="toolbar-hint">导入后会创建 provider=91kaquanplatform=kuaishou外部 SKU=productNo 的规则草稿保存规则后回到平台配置 -> 91卡券接入重试订单</p>
<p class="toolbar-hint">
导入后会创建 provider=91kaquanplatform=kuaishou外部 SKU=productNo
的规则草稿保存规则后回到平台配置 -> 91卡券接入重试订单
</p>
<p v-if="ninetyoneLookupErrorMessage" class="error-copy lookup-error">{{ ninetyoneLookupErrorMessage }}</p>
<p v-if="ninetyoneLookupErrorMessage" class="error-copy lookup-error">
{{ ninetyoneLookupErrorMessage }}
</p>
<div v-else-if="ninetyoneLookupLoading" class="empty-inline">正在查询 91卡券订单</div>
<table v-else class="data-table">
@@ -875,7 +937,11 @@ onMounted(loadConfigs)
<button
v-for="option in ruleFilterOptions"
:key="option.value"
:class="['mini-stat-chip', 'mini-stat-chip--button', { 'mini-stat-chip--active': ruleFilter === option.value }]"
:class="[
'mini-stat-chip',
'mini-stat-chip--button',
{ 'mini-stat-chip--active': ruleFilter === option.value },
]"
type="button"
@click="ruleFilter = option.value"
>
@@ -885,28 +951,46 @@ onMounted(loadConfigs)
<div class="section-action-group">
<el-button round @click="expandAllRules">全部展开</el-button>
<el-button round @click="collapseReadyRules">收起已完成</el-button>
<el-button round :loading="cloudSkuCatalogLoading" @click="refreshCloudSkuCatalog">刷新 cloud SKU</el-button>
<el-button round :loading="cloudSkuCatalogLoading" @click="refreshCloudSkuCatalog"
>刷新 cloud SKU</el-button
>
<el-button round @click="addItem">新增规则</el-button>
<el-button round type="primary" :loading="saving" @click="saveConfigs">保存全部</el-button>
<el-button round type="primary" :loading="saving" @click="saveConfigs"
>保存全部</el-button
>
</div>
</div>
</div>
<p v-if="validationState?.message" class="validation-banner">{{ validationState.message }}</p>
<div v-if="items.length === 0" class="empty-inline">当前还没有新履约规则先新增一条</div>
<div v-else-if="filteredItems.length === 0" class="empty-inline">当前筛选下没有规则</div>
<p v-if="validationState?.message" class="validation-banner">
{{ validationState.message }}
</p>
<div v-if="items.length === 0" class="empty-inline">
当前还没有新履约规则先新增一条
</div>
<div v-else-if="filteredItems.length === 0" class="empty-inline">
当前筛选下没有规则
</div>
<div
v-for="(item, index) in filteredItems"
:key="item.localId"
:data-local-id="item.localId"
:class="['binding-card', { 'binding-card--invalid': validationState?.localId === item.localId }]"
:class="[
'binding-card',
{ 'binding-card--invalid': validationState?.localId === item.localId },
]"
>
<div class="binding-header">
<div class="binding-summary">
<div class="binding-title-row">
<strong>{{ getCardTitle(item, index) }}</strong>
<span :class="['binding-status-chip', { 'binding-status-chip--done': isItemComplete(item) }]">
<span
:class="[
'binding-status-chip',
{ 'binding-status-chip--done': isItemComplete(item) },
]"
>
{{ getRuleState(item).label }}
</span>
</div>
@@ -934,31 +1018,66 @@ onMounted(loadConfigs)
<div class="mapping-grid">
<label class="field-block">
<span>来源 provider</span>
<input v-model="item.provider" class="text-input" maxlength="32" placeholder="默认 91kaquan" />
<input
v-model="item.provider"
class="text-input"
maxlength="32"
placeholder="默认 91kaquan"
/>
</label>
<label class="field-block">
<span>来源 platform</span>
<input v-model="item.platform" class="text-input" maxlength="32" placeholder="默认 kuaishou" />
<input
v-model="item.platform"
class="text-input"
maxlength="32"
placeholder="默认 kuaishou"
/>
</label>
<label class="field-block field-wide">
<span>店铺 ID</span>
<input v-model="item.shopId" class="text-input" maxlength="80" placeholder="留空表示跨店铺共用" />
<input
v-model="item.shopId"
class="text-input"
maxlength="80"
placeholder="留空表示跨店铺共用"
/>
</label>
<label class="field-block">
<span>外部 SKU</span>
<input v-model="item.externalSkuCode" class="text-input" maxlength="120" placeholder="例如 91卡券 productNo" />
<input
v-model="item.externalSkuCode"
class="text-input"
maxlength="120"
placeholder="例如 91卡券 productNo"
/>
</label>
<label class="field-block">
<span>外部商品 ID</span>
<input v-model="item.externalItemId" class="text-input" maxlength="120" placeholder="平台商品 ID" />
<input
v-model="item.externalItemId"
class="text-input"
maxlength="120"
placeholder="平台商品 ID"
/>
</label>
<label class="field-block field-wide">
<span>外部商品名</span>
<input v-model="item.externalSkuName" class="text-input" maxlength="200" placeholder="用于名称匹配" />
<input
v-model="item.externalSkuName"
class="text-input"
maxlength="200"
placeholder="用于名称匹配"
/>
</label>
<label class="field-block field-wide">
<span>解析商品名</span>
<input v-model="item.resolvedSkuName" class="text-input" maxlength="120" placeholder="可作为商品名兜底" />
<input
v-model="item.resolvedSkuName"
class="text-input"
maxlength="120"
placeholder="可作为商品名兜底"
/>
</label>
</div>
</section>
@@ -976,19 +1095,39 @@ onMounted(loadConfigs)
<div class="mapping-grid">
<label class="field-block">
<span>内部 SKU</span>
<input v-model="item.internalSkuCode" class="text-input" maxlength="80" placeholder="必填,用于生成任务" />
<input
v-model="item.internalSkuCode"
class="text-input"
maxlength="80"
placeholder="必填,用于生成任务"
/>
</label>
<label class="field-block">
<span>优先级</span>
<input v-model.number="item.priority" class="text-input" min="1" type="number" />
<input
v-model.number="item.priority"
class="text-input"
min="1"
type="number"
/>
</label>
<label class="field-block field-wide">
<span>内部商品名</span>
<input v-model="item.internalSkuName" class="text-input" maxlength="120" placeholder="展示用,可选但建议填" />
<input
v-model="item.internalSkuName"
class="text-input"
maxlength="120"
placeholder="展示用,可选但建议填"
/>
</label>
<label class="field-block">
<span>cloudSourceKey</span>
<input v-model="item.cloudSourceKey" class="text-input" maxlength="60" placeholder="默认 default" />
<input
v-model="item.cloudSourceKey"
class="text-input"
maxlength="60"
placeholder="默认 default"
/>
</label>
<label class="field-block">
<span>cloud SKU ID</span>
@@ -1007,7 +1146,12 @@ onMounted(loadConfigs)
</label>
<label class="field-block field-wide">
<span>cloud SKU 名称</span>
<input :value="item.cloudSkuName" class="text-input text-input--readonly" placeholder="选择 cloud SKU 后自动带出" readonly />
<input
:value="item.cloudSkuName"
class="text-input text-input--readonly"
placeholder="选择 cloud SKU 后自动带出"
readonly
/>
</label>
<label class="field-block">
<span>虚拟号 VN Key</span>
@@ -1015,7 +1159,12 @@ onMounted(loadConfigs)
</label>
<label class="field-block">
<span>最低保留余额</span>
<input v-model.number="item.minAssetReserve" class="text-input" min="0" type="number" />
<input
v-model.number="item.minAssetReserve"
class="text-input"
min="0"
type="number"
/>
</label>
</div>
</section>
@@ -1038,24 +1187,43 @@ onMounted(loadConfigs)
{{ formatKuaishouConsumeShopOption(shop) }}
</option>
</select>
<small class="field-help">来自平台店铺 -> 快手小店核销配置保存时会自动带出店铺名</small>
<small class="field-help"
>来自平台店铺 -> 快手小店核销配置保存时会自动带出店铺名</small
>
</label>
<label class="field-block">
<span>核销店铺 ID</span>
<input :value="item.kuaishouConsumeShopId || '-'" class="text-input text-input--readonly" readonly />
<input
:value="item.kuaishouConsumeShopId || '-'"
class="text-input text-input--readonly"
readonly
/>
</label>
<label class="field-block">
<span>核销店铺名</span>
<input :value="item.kuaishouConsumeShopName || '-'" class="text-input text-input--readonly" readonly />
<input
:value="item.kuaishouConsumeShopName || '-'"
class="text-input text-input--readonly"
readonly
/>
</label>
<label class="field-block field-wide">
<span>备注</span>
<textarea v-model="item.notes" class="text-input textarea-input" maxlength="400" placeholder="绑定场景、客服注意事项等" />
<textarea
v-model="item.notes"
class="text-input textarea-input"
maxlength="400"
placeholder="绑定场景、客服注意事项等"
/>
</label>
</div>
<p v-if="validationState?.localId === item.localId" class="binding-inline-error">{{ validationState?.message }}</p>
<p v-if="cloudSkuCatalogErrorMessage" class="binding-inline-error">{{ cloudSkuCatalogErrorMessage }}</p>
<p v-if="validationState?.localId === item.localId" class="binding-inline-error">
{{ validationState?.message }}
</p>
<p v-if="cloudSkuCatalogErrorMessage" class="binding-inline-error">
{{ cloudSkuCatalogErrorMessage }}
</p>
<div class="binding-actions">
<label class="checkbox-line">
@@ -6,7 +6,10 @@
gap: var(--space-3);
}
.sku-input-wrap { display: grid; min-width: 0; }
.sku-input-wrap {
display: grid;
min-width: 0;
}
.native-input {
width: 100%;
@@ -19,7 +22,9 @@
color: var(--text-primary);
font-size: var(--text-sm);
outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.native-input:focus {
@@ -35,7 +40,9 @@
align-items: center;
margin-top: 14px;
}
.single-row :deep(.el-input) { min-width: 0; }
.single-row :deep(.el-input) {
min-width: 0;
}
.helper-grid {
display: grid;
@@ -53,8 +60,17 @@
background: rgba(248, 250, 252, 0.72);
}
.helper-title { color: #2559a7; font-size: var(--text-xs); font-weight: 700; }
.helper-copy { min-width: 0; color: #334155; line-height: 1.6; word-break: break-word; }
.helper-title {
color: #2559a7;
font-size: var(--text-xs);
font-weight: 700;
}
.helper-copy {
min-width: 0;
color: #334155;
line-height: 1.6;
word-break: break-word;
}
.sku-helper-bar {
display: flex;
@@ -63,7 +79,11 @@
align-items: center;
margin-top: var(--space-3);
}
.helper-text { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; }
.helper-text {
color: var(--text-secondary);
font-size: var(--text-xs);
margin: 0;
}
.sku-chip-row {
display: flex;
@@ -82,12 +102,32 @@
background: rgba(248, 250, 252, 0.78);
color: var(--text-primary);
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
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 { min-width: 0; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sku-chip-meta { min-width: 0; color: var(--text-secondary); font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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 {
min-width: 0;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sku-chip-meta {
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-grid {
display: grid;
@@ -103,11 +143,29 @@
border-radius: var(--radius-md);
background: rgba(248, 250, 252, 0.72);
}
.stat-label { color: var(--text-secondary); font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.04em; }
.stat-value { font-size: 30px; line-height: 1; color: var(--text-primary); display: block; margin-top: var(--space-2); }
.stat-meta { color: var(--text-secondary); font-size: var(--text-xs); display: block; margin-top: var(--space-1); }
.stat-label {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.04em;
}
.stat-value {
font-size: 30px;
line-height: 1;
color: var(--text-primary);
display: block;
margin-top: var(--space-2);
}
.stat-meta {
color: var(--text-secondary);
font-size: var(--text-xs);
display: block;
margin-top: var(--space-1);
}
.pool-section { margin-bottom: 18px; }
.pool-section {
margin-bottom: 18px;
}
.pool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -123,10 +181,34 @@
background: rgba(248, 250, 252, 0.68);
}
.pool-topline { display: flex; justify-content: space-between; gap: var(--space-3); align-items: flex-start; }
.pool-title-block { display: grid; gap: 6px; min-width: 0; }
.pool-title { min-width: 0; margin: 0; color: var(--text-primary); font-size: var(--text-base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pool-subtitle { min-width: 0; color: var(--text-secondary); font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pool-topline {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: flex-start;
}
.pool-title-block {
display: grid;
gap: 6px;
min-width: 0;
}
.pool-title {
min-width: 0;
margin: 0;
color: var(--text-primary);
font-size: var(--text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-subtitle {
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-total {
display: inline-flex;
flex: 0 0 auto;
@@ -140,7 +222,11 @@
font-size: var(--text-xs);
font-weight: 700;
}
.pool-stats { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.pool-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.pool-stat {
display: inline-flex;
align-items: center;
@@ -152,9 +238,25 @@
font-size: var(--text-xs);
font-weight: 700;
}
.pool-meta { display: grid; gap: 6px; min-width: 0; color: var(--text-secondary); font-size: var(--text-xs); word-break: break-word; }
.pool-suggestion { margin: 0; line-height: 1.6; color: var(--text-secondary); font-size: var(--text-sm); }
.pool-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.pool-meta {
display: grid;
gap: 6px;
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
word-break: break-word;
}
.pool-suggestion {
margin: 0;
line-height: 1.6;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pool-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.table-toolbar {
margin-bottom: var(--space-2);
@@ -165,12 +267,16 @@
word-break: break-word;
}
.item-cell, .sku-cell, .info-cell {
.item-cell,
.sku-cell,
.info-cell {
display: grid;
gap: 6px;
}
.item-title, .sku-title, .link-title {
.item-title,
.sku-title,
.link-title {
color: var(--text-primary);
font-weight: 700;
}
@@ -187,42 +293,72 @@
word-break: break-all;
}
.info-subline, .sku-subline, .link-subline, .time-line, .status-copy {
.info-subline,
.sku-subline,
.link-subline,
.time-line,
.status-copy {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.5;
}
.sku-raw { font-family: var(--font-mono); font-size: var(--text-xs); }
.sku-raw {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.status-stack, .action-stack {
.status-stack,
.action-stack {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.action-stack { justify-content: center; }
.action-stack {
justify-content: center;
}
.status-warning { color: #b54708; font-size: var(--text-xs); }
.status-warning {
color: #b54708;
font-size: var(--text-xs);
}
@media (max-width: 1200px) {
.form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.form-grid,
.summary-grid,
.pool-grid,
.helper-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card-header { flex-direction: column; align-items: flex-start; }
.helper-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 640px) {
.form-grid,
.summary-grid,
.pool-grid,
.helper-grid { grid-template-columns: 1fr; }
.helper-grid {
grid-template-columns: 1fr;
}
.single-row,
.action-row,
.sku-helper-bar { flex-direction: column; align-items: stretch; }
.status-stack, .action-stack { justify-content: flex-start; }
.sku-helper-bar {
flex-direction: column;
align-items: stretch;
}
.status-stack,
.action-stack {
justify-content: flex-start;
}
}
@@ -12,10 +12,17 @@ import {
invalidateAdminInventoryItem,
releaseAdminInventoryItem,
} from '@/services/admin'
import type { AdminInventoryItemListItem, AdminInventorySkuSuggestion, 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 { adminInventoryCredentialTypeOptions, adminInventoryStatusOptions } from '@/utils/admin-options'
import {
adminInventoryCredentialTypeOptions,
adminInventoryStatusOptions,
} from '@/utils/admin-options'
type InventoryImportRow = {
skuCode: string
@@ -76,11 +83,21 @@ const pageSummary = computed(() => {
})
const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
const groupMap = new Map<string, {
skuCode: string; credentialType: string; inventoryGroupCode: string
totalCount: number; availableCount: number; reservedCount: number; deliveredCount: number; invalidCount: number
latestUpdatedAt: string; batches: Set<string>
}>()
const groupMap = new Map<
string,
{
skuCode: string
credentialType: string
inventoryGroupCode: string
totalCount: number
availableCount: number
reservedCount: number
deliveredCount: number
invalidCount: number
latestUpdatedAt: string
batches: Set<string>
}
>()
for (const item of items.value) {
const key = `${item.skuCode}::${item.credentialType || 'tencent_code'}::${item.inventoryGroupCode || ''}`
@@ -88,7 +105,11 @@ const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
skuCode: item.skuCode,
credentialType: item.credentialType || 'tencent_code',
inventoryGroupCode: item.inventoryGroupCode || '',
totalCount: 0, availableCount: 0, reservedCount: 0, deliveredCount: 0, invalidCount: 0,
totalCount: 0,
availableCount: 0,
reservedCount: 0,
deliveredCount: 0,
invalidCount: 0,
latestUpdatedAt: item.updatedAt,
batches: new Set<string>(),
}
@@ -100,22 +121,31 @@ const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
else if (item.status === 'invalid') current.invalidCount += 1
if (item.batchNo) current.batches.add(item.batchNo)
if (String(item.updatedAt || '') > String(current.latestUpdatedAt || '')) current.latestUpdatedAt = item.updatedAt
if (String(item.updatedAt || '') > String(current.latestUpdatedAt || ''))
current.latestUpdatedAt = item.updatedAt
groupMap.set(key, current)
}
return Array.from(groupMap.entries())
.map(([key, value]) => ({
key,
skuCode: value.skuCode, credentialType: value.credentialType, inventoryGroupCode: value.inventoryGroupCode,
totalCount: value.totalCount, availableCount: value.availableCount, reservedCount: value.reservedCount,
deliveredCount: value.deliveredCount, invalidCount: value.invalidCount,
skuCode: value.skuCode,
credentialType: value.credentialType,
inventoryGroupCode: value.inventoryGroupCode,
totalCount: value.totalCount,
availableCount: value.availableCount,
reservedCount: value.reservedCount,
deliveredCount: value.deliveredCount,
invalidCount: value.invalidCount,
latestUpdatedAt: value.latestUpdatedAt,
batchSummary: value.batches.size > 0 ? Array.from(value.batches).slice(0, 3).join(' / ') : '未分批',
batchSummary:
value.batches.size > 0 ? Array.from(value.batches).slice(0, 3).join(' / ') : '未分批',
}))
.sort((left, right) => {
if (right.availableCount !== left.availableCount) return right.availableCount - left.availableCount
if (right.reservedCount !== left.reservedCount) return right.reservedCount - left.reservedCount
if (right.availableCount !== left.availableCount)
return right.availableCount - left.availableCount
if (right.reservedCount !== left.reservedCount)
return right.reservedCount - left.reservedCount
return left.skuCode.localeCompare(right.skuCode)
})
})
@@ -127,7 +157,9 @@ const pageOverview = computed(() => ({
const entrySkuSuggestions = computed(() => {
const keyword = entrySkuCode.value.trim().toLowerCase()
const suggestions = inventorySkuSuggestions.value.filter((item) => item.credentialType === entryCredentialType.value)
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)
})
@@ -140,7 +172,8 @@ function getCredentialTypeLabel(value: string) {
function getCredentialTypeHint(value: string) {
const normalized = String(value || '').trim()
if (normalized === 'tencent_code') return '当前腾讯自动领取链路会优先预占这一类兑换码,三角洲行动这类场景就使用这个类型。内部 SKU 支持中文、英文和数字混用。'
if (normalized === 'tencent_code')
return '当前腾讯自动领取链路会优先预占这一类兑换码,三角洲行动这类场景就使用这个类型。内部 SKU 支持中文、英文和数字混用。'
if (normalized === 'card_password') return '适合卡号 / 卡密一体的库存项。'
if (normalized === 'account_password') return '适合账号密码、账号令牌等组合凭据。'
if (normalized === 'activation_link') return '适合直接发链接的激活类库存。'
@@ -149,7 +182,8 @@ function getCredentialTypeHint(value: string) {
}
function getSkuNamingHint() {
if (entrySkuSuggestions.value.length > 0) return `当前凭据类型下已有 ${entrySkuSuggestions.value.length} 个可复用的内部 SKU,也可以直接输入新的中文或英文名称。`
if (entrySkuSuggestions.value.length > 0)
return `当前凭据类型下已有 ${entrySkuSuggestions.value.length} 个可复用的内部 SKU,也可以直接输入新的中文或英文名称。`
return '内部 SKU 支持中文、英文、数字和常见连接符。建议同一类商品长期使用同一个命名,方便共用库存池。'
}
@@ -161,10 +195,14 @@ function formatInventoryStatus(item: AdminInventoryItemListItem) {
}
function getGroupSuggestion(group: InventoryPoolGroup) {
if (group.availableCount > 0) return `当前还有 ${group.availableCount} 条可直接分配的库存,可以继续承接订单。`
if (group.reservedCount > 0) return `当前没有可用库存,但${group.reservedCount}正在被任务预占,建议优先检查任务进度`
if (group.deliveredCount > 0 && group.invalidCount === 0) return '当前页看到的库存都已经发放完成,若继续售卖需要补新的库存项。'
if (group.invalidCount > 0 && group.totalCount === group.invalidCount) return '当前页这组库存都已作废,建议重新补充可用库存。'
if (group.availableCount > 0)
return `当前${group.availableCount}可直接分配的库存,可以继续承接订单`
if (group.reservedCount > 0)
return `当前没有可用库存,但有 ${group.reservedCount} 条正在被任务预占,建议优先检查任务进度。`
if (group.deliveredCount > 0 && group.invalidCount === 0)
return '当前页看到的库存都已经发放完成,若继续售卖需要补新的库存项。'
if (group.invalidCount > 0 && group.totalCount === group.invalidCount)
return '当前页这组库存都已作废,建议重新补充可用库存。'
return '当前页没有可立即分配的库存,建议补库存或检查对应履约任务。'
}
@@ -179,14 +217,22 @@ function useGroupAsEntry(group: InventoryPoolGroup) {
entrySkuCode.value = group.skuCode
entryCredentialType.value = group.credentialType
entryInventoryGroupCode.value = group.inventoryGroupCode
if (group.batchSummary !== '未分批' && !group.batchSummary.includes(' / ')) entryBatchNo.value = group.batchSummary
showSuccess(`已把 ${group.skuCode} / ${group.credentialType} / ${group.inventoryGroupCode || '未分组'} 填入入库表单`)
if (group.batchSummary !== '未分批' && !group.batchSummary.includes(' / '))
entryBatchNo.value = group.batchSummary
showSuccess(
`已把 ${group.skuCode} / ${group.credentialType} / ${group.inventoryGroupCode || '未分组'} 填入入库表单`,
)
}
function applySkuSuggestion(skuCode: string) { entrySkuCode.value = String(skuCode || '').trim() }
function applySkuSuggestion(skuCode: string) {
entrySkuCode.value = String(skuCode || '').trim()
}
function buildImportRows() {
const lines = bulkText.value.split('\n').map((line) => line.trim()).filter(Boolean)
const lines = bulkText.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const rows: InventoryImportRow[] = []
const defaultSkuCode = entrySkuCode.value.trim()
const defaultCredentialType = entryCredentialType.value.trim() || 'tencent_code'
@@ -196,61 +242,115 @@ function buildImportRows() {
const line = lines[index]
if (line.includes('|')) {
const parts = line.split('|').map((part) => part.trim())
if (parts.length !== 4 && parts.length !== 5) throw new Error(`${index + 1} 行格式不正确,请使用 内部SKU|凭据类型|批次号|库存组|凭据内容`)
const [skuCode, credentialType, batchNo, inventoryGroupCode, displayValue] = parts.length === 5 ? parts : [parts[0], parts[1], parts[2], '', parts[3]]
if (parts.length !== 4 && parts.length !== 5)
throw new Error(
`${index + 1} 行格式不正确,请使用 内部SKU|凭据类型|批次号|库存组|凭据内容`,
)
const [skuCode, credentialType, batchNo, inventoryGroupCode, displayValue] =
parts.length === 5 ? parts : [parts[0], parts[1], parts[2], '', parts[3]]
if (!skuCode || !displayValue) throw new Error(`${index + 1} 行缺少内部 SKU 或凭据内容`)
rows.push({ skuCode, credentialType: credentialType || defaultCredentialType, batchNo, inventoryGroupCode: inventoryGroupCode || entryInventoryGroupCode.value.trim(), displayValue })
rows.push({
skuCode,
credentialType: credentialType || defaultCredentialType,
batchNo,
inventoryGroupCode: inventoryGroupCode || entryInventoryGroupCode.value.trim(),
displayValue,
})
continue
}
if (!defaultSkuCode) throw new Error('存在未带内部 SKU 的行,请先填写默认内部履约 SKU,或改用四列导入格式')
rows.push({ skuCode: defaultSkuCode, credentialType: defaultCredentialType, batchNo: defaultBatchNo, inventoryGroupCode: entryInventoryGroupCode.value.trim(), displayValue: line })
if (!defaultSkuCode)
throw new Error('存在未带内部 SKU 的行,请先填写默认内部履约 SKU,或改用四列导入格式')
rows.push({
skuCode: defaultSkuCode,
credentialType: defaultCredentialType,
batchNo: defaultBatchNo,
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
displayValue: line,
})
}
return rows
}
async function loadInventoryItems(page = pagination.value.page) {
loading.value = true; errorMessage.value = ''
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminInventoryItems({
page, pageSize: pagination.value.pageSize,
skuCode: filterSkuCode.value.trim(), credentialType: filterCredentialType.value.trim(),
status: filterStatus.value.trim(), batchNo: filterBatchNo.value.trim(),
page,
pageSize: pagination.value.pageSize,
skuCode: filterSkuCode.value.trim(),
credentialType: filterCredentialType.value.trim(),
status: filterStatus.value.trim(),
batchNo: filterBatchNo.value.trim(),
inventoryGroupCode: filterInventoryGroupCode.value.trim(),
})
items.value = response.data.items; pagination.value = response.data.pagination
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '读取库存列表失败' }
finally { loading.value = false }
items.value = response.data.items
pagination.value = response.data.pagination
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取库存列表失败'
} finally {
loading.value = false
}
}
async function loadInventorySkuSuggestions() {
skuSuggestionsLoading.value = true
try {
const response = await fetchAdminInventorySkuSuggestions({ credentialType: entryCredentialType.value.trim() || 'tencent_code', inventoryGroupCode: entryInventoryGroupCode.value.trim(), limit: 50 })
const response = await fetchAdminInventorySkuSuggestions({
credentialType: entryCredentialType.value.trim() || 'tencent_code',
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
limit: 50,
})
inventorySkuSuggestions.value = response.data.items
} catch (error) { showError(error instanceof Error ? error.message : '读取内部 SKU 建议失败') }
finally { skuSuggestionsLoading.value = false }
} catch (error) {
showError(error instanceof Error ? error.message : '读取内部 SKU 建议失败')
} finally {
skuSuggestionsLoading.value = false
}
}
function resetFilters() {
filterSkuCode.value = ''; filterCredentialType.value = ''; filterStatus.value = ''
filterBatchNo.value = ''; filterInventoryGroupCode.value = ''
filterSkuCode.value = ''
filterCredentialType.value = ''
filterStatus.value = ''
filterBatchNo.value = ''
filterInventoryGroupCode.value = ''
void loadInventoryItems(1)
}
async function submitCreate() {
if (!entrySkuCode.value.trim() || !singleDisplayValue.value.trim()) { showError('请先填写内部履约 SKU 和凭据内容'); return }
if (!entrySkuCode.value.trim() || !singleDisplayValue.value.trim()) {
showError('请先填写内部履约 SKU 和凭据内容')
return
}
creating.value = true
try {
await createAdminInventoryItem({ skuCode: entrySkuCode.value.trim(), credentialType: entryCredentialType.value.trim() || 'tencent_code', batchNo: entryBatchNo.value.trim(), inventoryGroupCode: entryInventoryGroupCode.value.trim(), displayValue: singleDisplayValue.value.trim() })
showSuccess('库存项已新增'); singleDisplayValue.value = ''
await createAdminInventoryItem({
skuCode: entrySkuCode.value.trim(),
credentialType: entryCredentialType.value.trim() || 'tencent_code',
batchNo: entryBatchNo.value.trim(),
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
displayValue: singleDisplayValue.value.trim(),
})
showSuccess('库存项已新增')
singleDisplayValue.value = ''
await loadInventoryItems(1)
} catch (error) { showError(error instanceof Error ? error.message : '新增库存项失败') }
finally { creating.value = false }
} catch (error) {
showError(error instanceof Error ? error.message : '新增库存项失败')
} finally {
creating.value = false
}
}
async function submitImport() {
const rawLines = bulkText.value.split('\n').map((line) => line.trim()).filter(Boolean)
if (rawLines.length === 0) { showError('请至少输入一条待导入库存'); return }
const rawLines = bulkText.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
if (rawLines.length === 0) {
showError('请至少输入一条待导入库存')
return
}
importing.value = true
try {
const rows = buildImportRows()
@@ -258,23 +358,50 @@ async function submitImport() {
showSuccess(`导入完成,新增 ${response.data.created} 条,重复 ${response.data.duplicated}`)
bulkText.value = ''
await loadInventoryItems(1)
} catch (error) { showError(error instanceof Error ? error.message : '导入失败') }
finally { importing.value = false }
} catch (error) {
showError(error instanceof Error ? error.message : '导入失败')
} finally {
importing.value = false
}
}
async function runRowAction(inventoryItemId: number, action: () => Promise<unknown>, successMessage: string, confirmText: string) {
try { await showConfirm(confirmText, '确认操作', { type: 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' }) }
catch { return }
async function runRowAction(
inventoryItemId: number,
action: () => Promise<unknown>,
successMessage: string,
confirmText: string,
) {
try {
await showConfirm(confirmText, '确认操作', {
type: 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
})
} catch {
return
}
actionLoadingId.value = inventoryItemId
try { await action(); showSuccess(successMessage); await loadInventoryItems() }
catch (error) { showError(error instanceof Error ? error.message : '操作失败') }
finally { actionLoadingId.value = null }
try {
await action()
showSuccess(successMessage)
await loadInventoryItems()
} catch (error) {
showError(error instanceof Error ? error.message : '操作失败')
} finally {
actionLoadingId.value = null
}
}
watch(entryCredentialType, () => { void loadInventorySkuSuggestions() })
watch(entryInventoryGroupCode, () => { void loadInventorySkuSuggestions() })
watch(entryCredentialType, () => {
void loadInventorySkuSuggestions()
})
watch(entryInventoryGroupCode, () => {
void loadInventorySkuSuggestions()
})
onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySkuSuggestions()]) })
onMounted(async () => {
await Promise.all([loadInventoryItems(), loadInventorySkuSuggestions()])
})
</script>
<template>
@@ -293,15 +420,40 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
</div>
</template>
<div class="form-grid">
<el-input v-model="filterSkuCode" placeholder="内部履约 SKU,例如 sjz_hdl_test01" clearable @keyup.enter="loadInventoryItems(1)" />
<el-input
v-model="filterSkuCode"
placeholder="内部履约 SKU,例如 sjz_hdl_test01"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
<el-select v-model="filterCredentialType" placeholder="凭据类型" clearable>
<el-option v-for="opt in adminInventoryCredentialTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-option
v-for="opt in adminInventoryCredentialTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable>
<el-option v-for="opt in adminInventoryStatusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-option
v-for="opt in adminInventoryStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input v-model="filterBatchNo" placeholder="批次号" clearable @keyup.enter="loadInventoryItems(1)" />
<el-input v-model="filterInventoryGroupCode" placeholder="库存组,例如 A组" clearable @keyup.enter="loadInventoryItems(1)" />
<el-input
v-model="filterBatchNo"
placeholder="批次号"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
<el-input
v-model="filterInventoryGroupCode"
placeholder="库存组,例如 A组"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
</div>
<div class="action-row">
<el-button round type="primary" @click="loadInventoryItems(1)">查询列表</el-button>
@@ -313,22 +465,43 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
<template #header>
<div class="card-header">
<span class="card-title">入库与导入</span>
<span class="card-desc">填写内部履约 SKU不是平台侧 sku_code建议同类商品长期复用同一个内部名称</span>
<span class="card-desc"
>填写内部履约 SKU不是平台侧 sku_code建议同类商品长期复用同一个内部名称</span
>
</div>
</template>
<div class="form-grid">
<div class="sku-input-wrap">
<input v-model="entrySkuCode" list="inventory-sku-suggestions" class="native-input" placeholder="内部履约 SKU,可选已有同类型,也可自定义新的中文名" />
<input
v-model="entrySkuCode"
list="inventory-sku-suggestions"
class="native-input"
placeholder="内部履约 SKU,可选已有同类型,也可自定义新的中文名"
/>
<datalist id="inventory-sku-suggestions">
<option v-for="suggestion in entrySkuSuggestions" :key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}`" :value="suggestion.skuCode" :label="`${suggestion.skuCode} · ${suggestion.inventoryGroupCode || '未分组'} · 可用 ${suggestion.availableCount} / 共 ${suggestion.totalCount}`" />
<option
v-for="suggestion in entrySkuSuggestions"
:key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}`"
:value="suggestion.skuCode"
:label="`${suggestion.skuCode} · ${suggestion.inventoryGroupCode || '未分组'} · 可用 ${suggestion.availableCount} / 共 ${suggestion.totalCount}`"
/>
</datalist>
</div>
<el-select v-model="entryCredentialType" placeholder="凭据类型">
<el-option v-for="opt in adminInventoryCredentialTypeOptions.filter((item) => item.value)" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-option
v-for="opt in adminInventoryCredentialTypeOptions.filter((item) => item.value)"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input v-model="entryBatchNo" placeholder="批次号,可选" clearable />
<el-input v-model="entryInventoryGroupCode" placeholder="库存组,例如 A组 / B组" clearable />
<el-input
v-model="entryInventoryGroupCode"
placeholder="库存组,例如 A组 / B组"
clearable
/>
</div>
<div class="sku-helper-bar">
@@ -337,9 +510,18 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
</div>
<div v-if="entrySkuSuggestions.length > 0" class="sku-chip-row">
<button v-for="suggestion in entrySkuSuggestions" :key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}-chip`" type="button" class="sku-chip" @click="applySkuSuggestion(suggestion.skuCode)">
<button
v-for="suggestion in entrySkuSuggestions"
:key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${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.inventoryGroupCode || '未分组' }} · 可用 {{ suggestion.availableCount }} / {{ suggestion.totalCount }}</span>
<span class="sku-chip-meta"
>{{ suggestion.inventoryGroupCode || '未分组' }} · 可用
{{ suggestion.availableCount }} / {{ suggestion.totalCount }}</span
>
</button>
</div>
@@ -359,8 +541,14 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
</div>
<div class="single-row">
<el-input v-model="singleDisplayValue" placeholder="单条新增的凭据内容" @keyup.enter="submitCreate" />
<el-button :loading="creating" round type="success" @click="submitCreate">单条新增</el-button>
<el-input
v-model="singleDisplayValue"
placeholder="单条新增的凭据内容"
@keyup.enter="submitCreate"
/>
<el-button :loading="creating" round type="success" @click="submitCreate"
>单条新增</el-button
>
</div>
<el-input
@@ -370,18 +558,37 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
placeholder="批量导入支持两种格式:&#10;1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组)&#10;2. 每行一条:内部SKU|凭据类型|批次号|库存组|凭据内容"
style="margin-top: 14px"
/>
<p class="helper-text" style="margin-top: 8px">示例sjz_hdl_test01|tencent_code|sjz-20260412|A组|DJQFf7HgXXXXXXXXXX</p>
<p class="helper-text" style="margin-top: 8px">
示例sjz_hdl_test01|tencent_code|sjz-20260412|A组|DJQFf7HgXXXXXXXXXX
</p>
<div class="action-row">
<el-button :loading="importing" round type="primary" @click="submitImport">批量导入</el-button>
<el-button :loading="importing" round type="primary" @click="submitImport"
>批量导入</el-button
>
</div>
</el-card>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" style="margin-top: 16px" />
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
show-icon
:closable="false"
style="margin-top: 16px"
/>
<el-card shadow="never" class="section-card" v-loading="loading" element-loading-text="库存列表加载中">
<el-card
shadow="never"
class="section-card"
v-loading="loading"
element-loading-text="库存列表加载中"
>
<template v-if="items.length === 0 && !loading">
<el-empty description="当前筛选下没有库存项,可以先在上方录入内部履约 SKU 对应的库存。" :image-size="60" />
<el-empty
description="当前筛选下没有库存项,可以先在上方录入内部履约 SKU 对应的库存。"
:image-size="60"
/>
</template>
<template v-else>
@@ -411,14 +618,19 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
<div class="pool-section">
<div class="card-header" style="margin-bottom: 14px">
<span class="card-title">当前页库存池概览</span>
<span class="card-desc">先看哪组内部履约 SKU 还有量再决定补库存查任务还是切去人工履约</span>
<span class="card-desc"
>先看哪组内部履约 SKU 还有量再决定补库存查任务还是切去人工履约</span
>
</div>
<div class="pool-grid">
<article v-for="group in inventoryGroups" :key="group.key" class="pool-card">
<div class="pool-topline">
<div class="pool-title-block">
<h4 class="pool-title">{{ group.skuCode }}</h4>
<span class="pool-subtitle">{{ getCredentialTypeLabel(group.credentialType) }} · {{ group.credentialType }} · {{ group.inventoryGroupCode || '未分组' }}</span>
<span class="pool-subtitle"
>{{ getCredentialTypeLabel(group.credentialType) }} ·
{{ group.credentialType }} · {{ group.inventoryGroupCode || '未分组' }}</span
>
</div>
<span class="pool-total"> {{ group.totalCount }} </span>
</div>
@@ -439,21 +651,30 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
<div class="pool-actions">
<el-button round @click="useGroupAsFilter(group)">只看这个池</el-button>
<el-button round type="primary" @click="useGroupAsEntry(group)">作为入库目标</el-button>
<el-button round type="primary" @click="useGroupAsEntry(group)"
>作为入库目标</el-button
>
</div>
</article>
</div>
</div>
<div class="table-toolbar">
当前筛选共 {{ pagination.total }} 本页可用 {{ pageSummary.availableCount }} 预占 {{ pageSummary.reservedCount }} 已发放 {{ pageSummary.deliveredCount }} 作废 {{ pageSummary.invalidCount }}
当前筛选共 {{ pagination.total }} 本页可用 {{ pageSummary.availableCount }} 预占
{{ pageSummary.reservedCount }} 已发放 {{ pageSummary.deliveredCount }} 作废
{{ pageSummary.invalidCount }}
</div>
<el-table :data="items" stripe size="small" class="inventory-table data-table">
<el-table-column label="库存项" min-width="200">
<template #default="{ row }">
<div class="item-cell">
<RouterLink v-if="row.reservedByTaskNo" :to="`/admin/tasks/${row.reservedByTaskNo}`" class="item-title">#{{ row.inventoryItemId }}</RouterLink>
<RouterLink
v-if="row.reservedByTaskNo"
:to="`/admin/tasks/${row.reservedByTaskNo}`"
class="item-title"
>#{{ row.inventoryItemId }}</RouterLink
>
<span v-else class="item-title">#{{ row.inventoryItemId }}</span>
<code class="item-value">{{ row.displayValue }}</code>
</div>
@@ -474,7 +695,9 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
<span class="info-subline">批次 {{ row.batchNo || '未分批' }}</span>
<span class="info-subline">库存组 {{ row.inventoryGroupCode || '未分组' }}</span>
<span class="status-copy">{{ formatInventoryStatus(row) }}</span>
<span v-if="row.invalidReason" class="status-warning">原因{{ row.invalidReason }}</span>
<span v-if="row.invalidReason" class="status-warning"
>原因{{ row.invalidReason }}</span
>
</div>
</template>
</el-table-column>
@@ -502,15 +725,48 @@ onMounted(async () => { await Promise.all([loadInventoryItems(), loadInventorySk
<div class="info-cell">
<span class="time-line">入库 {{ formatAdminDateTime(row.createdAt) }}</span>
<span class="time-line">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
<span v-if="row.deliveredAt" class="time-line">发放 {{ formatAdminDateTime(row.deliveredAt) }}</span>
<span v-if="row.deliveredAt" class="time-line"
>发放 {{ formatAdminDateTime(row.deliveredAt) }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<div class="action-stack">
<el-button v-if="hasAdminRole('admin') && row.status === 'reserved'" :loading="actionLoadingId === row.inventoryItemId" size="small" @click="runRowAction(row.inventoryItemId, () => releaseAdminInventoryItem(row.inventoryItemId), '库存项已释放', `确认释放库存项 #${row.inventoryItemId} 吗?释放后任务需要重新分配库存。`)">释放</el-button>
<el-button v-if="hasAdminRole('admin') && row.status === 'available'" :loading="actionLoadingId === row.inventoryItemId" size="small" type="danger" plain @click="runRowAction(row.inventoryItemId, () => invalidateAdminInventoryItem(row.inventoryItemId, { reason: '后台手动作废' }), '库存项已作废', `确认作废库存项 #${row.inventoryItemId} 吗?作废后不会再参与自动分配。`)">作废</el-button>
<el-button
v-if="hasAdminRole('admin') && row.status === 'reserved'"
:loading="actionLoadingId === row.inventoryItemId"
size="small"
@click="
runRowAction(
row.inventoryItemId,
() => releaseAdminInventoryItem(row.inventoryItemId),
'库存项已释放',
`确认释放库存项 #${row.inventoryItemId} 吗?释放后任务需要重新分配库存。`,
)
"
>释放</el-button
>
<el-button
v-if="hasAdminRole('admin') && row.status === 'available'"
:loading="actionLoadingId === row.inventoryItemId"
size="small"
type="danger"
plain
@click="
runRowAction(
row.inventoryItemId,
() =>
invalidateAdminInventoryItem(row.inventoryItemId, {
reason: '后台手动作废',
}),
'库存项已作废',
`确认作废库存项 #${row.inventoryItemId} 吗?作废后不会再参与自动分配。`,
)
"
>作废</el-button
>
</div>
</template>
</el-table-column>
@@ -20,7 +20,12 @@ import {
import { showSuccess } from '@/lib/feedback'
import { logoutAdmin } from '@/services/admin'
import { clearAdminSession, getAdminRole, getAdminTokenExpiresAt, getAdminUsername } from '@/utils/admin-auth'
import {
clearAdminSession,
getAdminRole,
getAdminTokenExpiresAt,
getAdminUsername,
} from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
const route = useRoute()
@@ -193,7 +198,9 @@ async function submitLogout() {
margin-bottom: var(--space-4);
}
.brand-copy { min-width: 0; }
.brand-copy {
min-width: 0;
}
.brand-copy strong {
display: block;
@@ -320,7 +327,9 @@ async function submitLogout() {
padding-top: var(--space-4);
}
.sidebar-menu { overflow: visible; }
.sidebar-menu {
overflow: visible;
}
.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item) {
justify-content: flex-start;
@@ -70,9 +70,15 @@ const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
)
const reviewCardTitle = computed(() => (task.value?.status === 'redeemed' ? '兑换截图' : '角色复核图'))
const canStartNextTask = computed(() => Boolean(task.value && ['redeemed', 'closed'].includes(task.value.status)))
const taskDetailLink = computed(() => (task.value?.taskId ? `/admin/tasks/${task.value.taskId}` : ''))
const reviewCardTitle = computed(() =>
task.value?.status === 'redeemed' ? '兑换截图' : '角色复核图',
)
const canStartNextTask = computed(() =>
Boolean(task.value && ['redeemed', 'closed'].includes(task.value.status)),
)
const taskDetailLink = computed(() =>
task.value?.taskId ? `/admin/tasks/${task.value.taskId}` : '',
)
</script>
<template>
@@ -93,12 +99,7 @@ const taskDetailLink = computed(() => (task.value?.taskId ? `/admin/tasks/${task
>
关闭当前任务
</el-button>
<el-button
v-if="canStartNextTask"
plain
type="primary"
@click="resetCurrentTask"
>
<el-button v-if="canStartNextTask" plain type="primary" @click="resetCurrentTask">
开始下一单
</el-button>
</AdminPageHeader>
@@ -180,9 +181,7 @@ const taskDetailLink = computed(() => (task.value?.taskId ? `/admin/tasks/${task
</article>
</section>
<div v-if="detailLoading && !detail" class="empty-block">
人工兑换详情加载中
</div>
<div v-if="detailLoading && !detail" class="empty-block">人工兑换详情加载中</div>
<div v-else-if="detail" class="layout-grid">
<TencentAuthCard
@@ -245,19 +244,13 @@ const taskDetailLink = computed(() => (task.value?.taskId ? `/admin/tasks/${task
<p v-if="reviewReady">
客户登录后,系统会把当前角色截图回传到这里。客服复制后可直接发给客户做二次确认。
</p>
<p v-else-if="resultReady">
最终兑换完成后,客服可以把结果截图直接发给客户确认。
</p>
<p v-else-if="resultReady">最终兑换完成后,客服可以把结果截图直接发给客户确认。</p>
<p v-else>
{{ screenshotEmptyMessage }}
</p>
</div>
<div class="header-actions">
<el-button
:disabled="!screenshotUrl || screenshotLoading"
plain
@click="copyScreenshot"
>
<el-button :disabled="!screenshotUrl || screenshotLoading" plain @click="copyScreenshot">
复制图片
</el-button>
<el-button
@@ -76,7 +76,10 @@ onMounted(loadMessageDeliveries)
<template>
<div class="deliveries-page list-page">
<AdminPageHeader title="消息发送记录" description="查看领取链接消息是否真正发出、平台返回了什么、失败原因是什么。">
<AdminPageHeader
title="消息发送记录"
description="查看领取链接消息是否真正发出、平台返回了什么、失败原因是什么。"
>
<template #extra>
<span class="total-badge"> {{ pagination.total }} 条记录</span>
</template>
@@ -91,16 +94,63 @@ onMounted(loadMessageDeliveries)
</div>
</template>
<div class="filter-grid">
<el-input v-model="provider" class="filter-control" placeholder="服务商,如 agiso" clearable @keyup.enter="loadMessageDeliveries(1)" />
<el-input v-model="platform" class="filter-control" placeholder="业务平台,如 xianyu" clearable @keyup.enter="loadMessageDeliveries(1)" />
<el-input
v-model="provider"
class="filter-control"
placeholder="服务商,如 agiso"
clearable
@keyup.enter="loadMessageDeliveries(1)"
/>
<el-input
v-model="platform"
class="filter-control"
placeholder="业务平台,如 xianyu"
clearable
@keyup.enter="loadMessageDeliveries(1)"
/>
<el-select v-model="status" class="filter-control" placeholder="发送状态" clearable>
<el-option v-for="opt in adminMessageDeliveryStatusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-option
v-for="opt in adminMessageDeliveryStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input v-model="shopId" class="filter-control" placeholder="店铺 ID(精确匹配)" clearable @keyup.enter="loadMessageDeliveries(1)" />
<el-input v-model="platformOrderId" class="filter-control" placeholder="平台订单号" clearable @keyup.enter="loadMessageDeliveries(1)" />
<el-input v-model="taskNo" class="filter-control" placeholder="任务编号" clearable @keyup.enter="loadMessageDeliveries(1)" />
<el-date-picker v-model="dateFrom" class="filter-control" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" />
<el-date-picker v-model="dateTo" class="filter-control" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" />
<el-input
v-model="shopId"
class="filter-control"
placeholder="店铺 ID(精确匹配)"
clearable
@keyup.enter="loadMessageDeliveries(1)"
/>
<el-input
v-model="platformOrderId"
class="filter-control"
placeholder="平台订单号"
clearable
@keyup.enter="loadMessageDeliveries(1)"
/>
<el-input
v-model="taskNo"
class="filter-control"
placeholder="任务编号"
clearable
@keyup.enter="loadMessageDeliveries(1)"
/>
<el-date-picker
v-model="dateFrom"
class="filter-control"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
/>
<el-date-picker
v-model="dateTo"
class="filter-control"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
<div class="filter-buttons">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="loadMessageDeliveries(1)">查询</el-button>
@@ -111,15 +161,33 @@ onMounted(loadMessageDeliveries)
<!-- 摘要 -->
<div v-if="!loading && items.length > 0" class="summary-strip">
<span class="summary-title">当前页</span>
<span class="summary-metric" data-tone="success">发送成功 <strong>{{ summary.successCount }}</strong></span>
<span class="summary-metric" data-tone="danger">发送失败 <strong>{{ summary.failedCount }}</strong></span>
<span class="summary-metric" data-tone="warning">等待中 <strong>{{ summary.pendingCount }}</strong></span>
<span class="summary-metric" data-tone="success"
>发送成功 <strong>{{ summary.successCount }}</strong></span
>
<span class="summary-metric" data-tone="danger"
>发送失败 <strong>{{ summary.failedCount }}</strong></span
>
<span class="summary-metric" data-tone="warning"
>等待中 <strong>{{ summary.pendingCount }}</strong></span
>
</div>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" style="margin-top: 16px" />
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
show-icon
:closable="false"
style="margin-top: 16px"
/>
<!-- 列表 -->
<el-card shadow="never" class="section-card" v-loading="loading" element-loading-text="消息发送记录加载中">
<el-card
shadow="never"
class="section-card"
v-loading="loading"
element-loading-text="消息发送记录加载中"
>
<template v-if="items.length === 0 && !loading">
<el-empty description="当前筛选下还没有消息发送记录。" :image-size="60" />
</template>
@@ -139,7 +207,9 @@ onMounted(loadMessageDeliveries)
<el-table-column label="接收者" min-width="160">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title">{{ row.recipientKey || row.shopName || row.shopId || '-' }}</span>
<span class="cell-title">{{
row.recipientKey || row.shopName || row.shopId || '-'
}}</span>
<span class="cell-subline">{{ row.shopName || row.shopId || '-' }}</span>
</div>
</template>
@@ -164,17 +234,35 @@ onMounted(loadMessageDeliveries)
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title">{{ formatAdminDateTime(row.createdAt) }}</span>
<span v-if="row.sentAt" class="cell-subline">发送 {{ formatAdminDateTime(row.sentAt) }}</span>
<span v-if="row.sentAt" class="cell-subline"
>发送 {{ formatAdminDateTime(row.sentAt) }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="140">
<template #default="{ row }">
<div class="action-stack">
<a v-if="row.claimUrl" class="detail-link" :href="row.claimUrl" target="_blank" rel="noreferrer">Claim 链接</a>
<RouterLink v-if="row.orderId" class="detail-link" :to="`/admin/orders/${row.orderId}`">订单 #{{ row.orderId }}</RouterLink>
<RouterLink v-if="row.taskId" class="detail-link" :to="`/admin/tasks/${row.taskId}`">任务 {{ row.taskNo || row.taskId }}</RouterLink>
<span v-if="!row.claimUrl && !row.orderId && !row.taskId" class="cell-subline">-</span>
<a
v-if="row.claimUrl"
class="detail-link"
:href="row.claimUrl"
target="_blank"
rel="noreferrer"
>Claim 链接</a
>
<RouterLink
v-if="row.orderId"
class="detail-link"
:to="`/admin/orders/${row.orderId}`"
>订单 #{{ row.orderId }}</RouterLink
>
<RouterLink v-if="row.taskId" class="detail-link" :to="`/admin/tasks/${row.taskId}`"
>任务 {{ row.taskNo || row.taskId }}</RouterLink
>
<span v-if="!row.claimUrl && !row.orderId && !row.taskId" class="cell-subline"
>-</span
>
</div>
</template>
</el-table-column>
@@ -92,10 +92,7 @@ function formatTaskAutoDeliveryReason(task: AdminOrderDetail['tasks'][number]) {
<template>
<section class="admin-panel">
<AdminPageHeader
title="订单详情"
description="查看订单原始信息、商品子项和关联任务。"
/>
<AdminPageHeader title="订单详情" description="查看订单原始信息、商品子项和关联任务。" />
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
@@ -106,7 +103,10 @@ function formatTaskAutoDeliveryReason(task: AdminOrderDetail['tasks'][number]) {
<div class="info-card-header">
<div>
<h2>{{ detail.order.platformOrderId }}</h2>
<p class="subtitle">{{ detail.order.platform }} / {{ detail.order.shopName || detail.order.shopId || '未识别店铺' }}</p>
<p class="subtitle">
{{ detail.order.platform }} /
{{ detail.order.shopName || detail.order.shopId || '未识别店铺' }}
</p>
</div>
<div class="status-stack">
<AdminStatusTag :status="detail.order.payStatus" />
@@ -139,13 +139,27 @@ function formatTaskAutoDeliveryReason(task: AdminOrderDetail['tasks'][number]) {
</article>
<article class="info-tile">
<span>任务进度</span>
<strong>{{ detail.order.bindingSummary.completedBindingTaskCount }}/{{ detail.order.bindingSummary.totalTaskCount }} 完整绑定</strong>
<small>{{ detail.order.bindingSummary.systemBoundTaskCount }}/{{ detail.order.bindingSummary.totalTaskCount }} 系统绑定</small>
<strong
>{{ detail.order.bindingSummary.completedBindingTaskCount }}/{{
detail.order.bindingSummary.totalTaskCount
}}
完整绑定</strong
>
<small
>{{ detail.order.bindingSummary.systemBoundTaskCount }}/{{
detail.order.bindingSummary.totalTaskCount
}}
系统绑定</small
>
</article>
<article class="info-tile">
<span>库存绑定</span>
<strong>{{ detail.order.bindingSummary.totalBindingCount }} 条绑定记录</strong>
<small>预占 {{ detail.order.bindingSummary.reservedBindingCount }} · 已消费 {{ detail.order.bindingSummary.consumedBindingCount }} · 已释放 {{ detail.order.bindingSummary.releasedBindingCount }}</small>
<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>
@@ -153,10 +167,16 @@ function formatTaskAutoDeliveryReason(task: AdminOrderDetail['tasks'][number]) {
<AdminStatusTag :status="detail.order.agisoAutoDelivery.status" />
</div>
<strong>
{{ detail.order.agisoAutoDelivery.errorMessage || formatAutoDeliveryReason(detail.order.agisoAutoDelivery.reason) || '全部任务交付后会自动触发' }}
{{
detail.order.agisoAutoDelivery.errorMessage ||
formatAutoDeliveryReason(detail.order.agisoAutoDelivery.reason) ||
'全部任务交付后会自动触发'
}}
</strong>
<small>
已交付 {{ detail.order.agisoAutoDelivery.deliveredTaskCount }}/{{ detail.order.agisoAutoDelivery.totalTaskCount }}
已交付 {{ detail.order.agisoAutoDelivery.deliveredTaskCount }}/{{
detail.order.agisoAutoDelivery.totalTaskCount
}}
· 最近执行 {{ formatAdminDateTime(detail.order.agisoAutoDelivery.updatedAt) }}
</small>
</article>
@@ -234,7 +254,9 @@ function formatTaskAutoDeliveryReason(task: AdminOrderDetail['tasks'][number]) {
<el-table-column label="库存绑定">
<template #default="{ row }">
<div>{{ formatTaskBindingCountSummary(row) }}</div>
<div class="binding-copy">角色 {{ formatBindingRoleSummary(row.bindingSummary.roleKeys) }}</div>
<div class="binding-copy">
角色 {{ formatBindingRoleSummary(row.bindingSummary.roleKeys) }}
</div>
</template>
</el-table-column>
<el-table-column label="登录方式">
@@ -251,14 +273,18 @@ function formatTaskAutoDeliveryReason(task: AdminOrderDetail['tasks'][number]) {
<el-table :data="detail.webhookEvents" size="small">
<el-table-column label="事件 ID">
<template #default="{ row }">
<RouterLink :to="`/admin/webhook-events/${row.eventId}`">{{ row.eventId }}</RouterLink>
<RouterLink :to="`/admin/webhook-events/${row.eventId}`">{{
row.eventId
}}</RouterLink>
</template>
</el-table-column>
<el-table-column label="类型">
<template #default="{ row }">{{ formatAdminWebhookEventType(row.eventType) }}</template>
</el-table-column>
<el-table-column label="平台 / 店铺">
<template #default="{ row }">{{ row.platform }} / {{ row.shopName || row.shopId || '-' }}</template>
<template #default="{ row }"
>{{ row.platform }} / {{ row.shopName || row.shopId || '-' }}</template
>
</el-table-column>
<el-table-column label="验签">
<template #default="{ row }">
@@ -1,24 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAdminListPage } from "@/composables/useAdminListPage";
import AdminPageHeader from "@/components/admin/AdminPageHeader.vue";
import AdminStatusTag from "@/components/admin/AdminStatusTag.vue";
import { fetchAdminOrders } from "@/services/admin";
import type { AdminOrderListItem } from "@/types/admin";
import { adminPayStatusOptions } from "@/utils/admin-options";
import { formatAdminDateTime } from "@/utils/admin-time";
import { useAdminListPage } from '@/composables/useAdminListPage'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { fetchAdminOrders } from '@/services/admin'
import type { AdminOrderListItem } from '@/types/admin'
import { adminPayStatusOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from '@/utils/admin-time'
const route = useRoute();
const router = useRouter();
const route = useRoute()
const router = useRouter()
const platformOrderId = ref(readQueryString("platformOrderId"));
const payStatus = ref(readQueryString("payStatus"));
const skuCode = ref(readQueryString("skuCode"));
const platformOrderId = ref(readQueryString('platformOrderId'))
const payStatus = ref(readQueryString('payStatus'))
const skuCode = ref(readQueryString('skuCode'))
const dateRange = ref<string[]>(
[readQueryString("dateFrom"), readQueryString("dateTo")].filter(Boolean)
);
[readQueryString('dateFrom'), readQueryString('dateTo')].filter(Boolean),
)
const {
loading,
@@ -27,7 +27,7 @@ const {
pagination,
loadPage: loadOrders,
} = useAdminListPage<AdminOrderListItem>({
defaultErrorMessage: "读取订单列表失败",
defaultErrorMessage: '读取订单列表失败',
fetchPage: (page, pageSize) =>
fetchAdminOrders({
page,
@@ -35,124 +35,114 @@ const {
platformOrderId: platformOrderId.value.trim(),
payStatus: payStatus.value.trim(),
skuCode: skuCode.value.trim(),
dateFrom: dateRange.value[0] || "",
dateTo: dateRange.value[1] || "",
dateFrom: dateRange.value[0] || '',
dateTo: dateRange.value[1] || '',
}),
});
})
const summary = computed(() => ({
paidCount: items.value.filter((item) => item.payStatus === "paid").length,
refundedCount: items.value.filter((item) => item.payStatus === "refunded")
.length,
bindingReadyCount: items.value.filter(
(item) => item.completedBindingTaskCount > 0
).length,
paidCount: items.value.filter((item) => item.payStatus === 'paid').length,
refundedCount: items.value.filter((item) => item.payStatus === 'refunded').length,
bindingReadyCount: items.value.filter((item) => item.completedBindingTaskCount > 0).length,
issueCount: items.value.filter((item) => hasOrderIssue(item)).length,
}));
}))
function resetFilters() {
platformOrderId.value = "";
payStatus.value = "";
skuCode.value = "";
dateRange.value = [];
void applyFilters(1);
platformOrderId.value = ''
payStatus.value = ''
skuCode.value = ''
dateRange.value = []
void applyFilters(1)
}
async function applyFilters(page = 1) {
await syncRouteQuery(page);
await loadOrders(page);
await syncRouteQuery(page)
await loadOrders(page)
}
function readQueryString(key: string) {
const value = route.query[key];
const rawValue = Array.isArray(value) ? value[0] : value;
return typeof rawValue === "string" ? rawValue.trim() : "";
const value = route.query[key]
const rawValue = Array.isArray(value) ? value[0] : value
return typeof rawValue === 'string' ? rawValue.trim() : ''
}
function readQueryPage() {
const page = Number(readQueryString("page"));
return Number.isInteger(page) && page > 0 ? page : 1;
const page = Number(readQueryString('page'))
return Number.isInteger(page) && page > 0 ? page : 1
}
function syncRouteQuery(page: number) {
const query: Record<string, string> = {};
addQueryValue(query, "platformOrderId", platformOrderId.value);
addQueryValue(query, "payStatus", payStatus.value);
addQueryValue(query, "skuCode", skuCode.value);
addQueryValue(query, "dateFrom", dateRange.value[0] || "");
addQueryValue(query, "dateTo", dateRange.value[1] || "");
const query: Record<string, string> = {}
addQueryValue(query, 'platformOrderId', platformOrderId.value)
addQueryValue(query, 'payStatus', payStatus.value)
addQueryValue(query, 'skuCode', skuCode.value)
addQueryValue(query, 'dateFrom', dateRange.value[0] || '')
addQueryValue(query, 'dateTo', dateRange.value[1] || '')
if (page > 1) {
query.page = String(page);
query.page = String(page)
}
return router.replace({ query });
return router.replace({ query })
}
function addQueryValue(
query: Record<string, string>,
key: string,
value: string
) {
const trimmed = value.trim();
function addQueryValue(query: Record<string, string>, key: string, value: string) {
const trimmed = value.trim()
if (trimmed) {
query[key] = trimmed;
query[key] = trimmed
}
}
function resolveBuyerLabel(item: AdminOrderListItem) {
return item.buyerName || item.buyerId || "未识别";
return item.buyerName || item.buyerId || '未识别'
}
function resolveShopLabel(item: AdminOrderListItem) {
return item.shopName || item.shopId || "未识别店铺";
return item.shopName || item.shopId || '未识别店铺'
}
function resolveItemSummary(item: AdminOrderListItem) {
return item.itemSummary || "未识别商品";
return item.itemSummary || '未识别商品'
}
function resolveBindingTone(completedCount: number, totalCount: number) {
if (completedCount <= 0) return "warning";
return completedCount === totalCount ? "success" : "primary";
if (completedCount <= 0) return 'warning'
return completedCount === totalCount ? 'success' : 'primary'
}
function resolveBindingSummary(item: AdminOrderListItem) {
return `${item.totalBindingCount} 条 · 预占 ${item.reservedBindingCount} · 已消耗 ${item.consumedBindingCount} · 已释放 ${item.releasedBindingCount}`;
return `${item.totalBindingCount} 条 · 预占 ${item.reservedBindingCount} · 已消耗 ${item.consumedBindingCount} · 已释放 ${item.releasedBindingCount}`
}
function hasOrderIssue(item: AdminOrderListItem) {
return (
["refunded", "failed", "unpaid"].includes(item.payStatus) ||
item.systemBindingStatus === "binding_exception" ||
item.userBindingStatus === "binding_exception"
);
['refunded', 'failed', 'unpaid'].includes(item.payStatus) ||
item.systemBindingStatus === 'binding_exception' ||
item.userBindingStatus === 'binding_exception'
)
}
function resolveFollowUpText(item: AdminOrderListItem) {
if (
item.userBindingStatus === "binding_exception" ||
item.systemBindingStatus === "binding_exception"
item.userBindingStatus === 'binding_exception' ||
item.systemBindingStatus === 'binding_exception'
)
return "绑定异常";
if (item.completedBindingTaskCount <= 0) return "等待完整绑定";
if (item.systemBoundTaskCount < item.taskCount) return "系统绑定未完成";
return "链路正常";
return '绑定异常'
if (item.completedBindingTaskCount <= 0) return '等待完整绑定'
if (item.systemBoundTaskCount < item.taskCount) return '系统绑定未完成'
return '链路正常'
}
function resolveFollowUpTone(item: AdminOrderListItem) {
if (hasOrderIssue(item)) return "danger";
if (
item.completedBindingTaskCount <= 0 ||
item.systemBoundTaskCount < item.taskCount
)
return "warning";
return "success";
if (hasOrderIssue(item)) return 'danger'
if (item.completedBindingTaskCount <= 0 || item.systemBoundTaskCount < item.taskCount)
return 'warning'
return 'success'
}
onMounted(() => {
void applyFilters(readQueryPage());
});
void applyFilters(readQueryPage())
})
</script>
<template>
@@ -170,9 +160,7 @@ onMounted(() => {
<template #header>
<div class="card-header">
<span class="card-title">查询订单</span>
<span class="card-desc"
>按平台订单号支付状态SKU 和日期范围筛选</span
>
<span class="card-desc">按平台订单号支付状态SKU 和日期范围筛选</span>
</div>
</template>
<div class="filter-grid">
@@ -183,12 +171,7 @@ onMounted(() => {
clearable
@keyup.enter="applyFilters(1)"
/>
<el-select
v-model="payStatus"
class="filter-control"
placeholder="支付状态"
clearable
>
<el-select v-model="payStatus" class="filter-control" placeholder="支付状态" clearable>
<el-option
v-for="opt in adminPayStatusOptions"
:key="opt.value"
@@ -214,9 +197,7 @@ onMounted(() => {
/>
<div class="filter-buttons">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="applyFilters(1)"
>查询</el-button
>
<el-button round type="primary" @click="applyFilters(1)">查询</el-button>
</div>
</div>
</el-card>
@@ -232,9 +213,7 @@ onMounted(() => {
<span class="summary-metric" data-tone="primary"
>完整绑定 <strong>{{ summary.bindingReadyCount }}</strong></span
>
<span
class="summary-metric"
:data-tone="summary.issueCount > 0 ? 'warning' : 'muted'"
<span class="summary-metric" :data-tone="summary.issueCount > 0 ? 'warning' : 'muted'"
>需关注 <strong>{{ summary.issueCount }}</strong></span
>
</div>
@@ -261,17 +240,10 @@ onMounted(() => {
<template v-else>
<div class="orders-toolbar table-toolbar">
<strong>订单明细</strong>
<span
>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span
>
<span>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span>
</div>
<el-table
:data="items"
stripe
size="small"
class="orders-table data-table"
>
<el-table :data="items" stripe size="small" class="orders-table data-table">
<el-table-column label="订单信息" min-width="220">
<template #default="{ row }">
<div class="cell-stack">
@@ -283,7 +255,7 @@ onMounted(() => {
{{ row.platformOrderId }}
</RouterLink>
<div class="cell-meta-row">
<span class="compact-chip">{{ row.platform || "-" }}</span>
<span class="compact-chip">{{ row.platform || '-' }}</span>
<span class="compact-chip">{{ resolveShopLabel(row) }}</span>
<span class="compact-chip compact-chip--muted"
>买家 {{ resolveBuyerLabel(row) }}</span
@@ -301,11 +273,9 @@ onMounted(() => {
<span class="cell-subline"
>{{ row.itemCount }} 项,合计 {{ row.totalQuantity }} 件</span
>
<span
class="cell-subline"
:title="resolveBindingSummary(row)"
>{{ resolveBindingSummary(row) }}</span
>
<span class="cell-subline" :title="resolveBindingSummary(row)">{{
resolveBindingSummary(row)
}}</span>
</div>
</template>
</el-table-column>
@@ -323,40 +293,22 @@ onMounted(() => {
<template #default="{ row }">
<div class="cell-stack">
<div class="status-stack">
<AdminStatusTag
:status="row.systemBindingStatus"
:show-raw="false"
/>
<AdminStatusTag
:status="row.userBindingStatus"
:show-raw="false"
/>
<AdminStatusTag :status="row.systemBindingStatus" :show-raw="false" />
<AdminStatusTag :status="row.userBindingStatus" :show-raw="false" />
</div>
<span
class="follow-up-chip"
:data-tone="resolveFollowUpTone(row)"
>{{ resolveFollowUpText(row) }}</span
>
<span class="follow-up-chip" :data-tone="resolveFollowUpTone(row)">{{
resolveFollowUpText(row)
}}</span>
<div class="progress-row-stack">
<span
class="progress-chip"
:data-tone="
resolveBindingTone(
row.completedBindingTaskCount,
row.taskCount
)
"
:data-tone="resolveBindingTone(row.completedBindingTaskCount, row.taskCount)"
>
完整 {{ row.completedBindingTaskCount }}/{{ row.taskCount }}
</span>
<span
class="progress-chip"
:data-tone="
resolveBindingTone(
row.systemBoundTaskCount,
row.taskCount
)
"
:data-tone="resolveBindingTone(row.systemBoundTaskCount, row.taskCount)"
>
系统 {{ row.systemBoundTaskCount }}/{{ row.taskCount }}
</span>
@@ -367,25 +319,15 @@ onMounted(() => {
<el-table-column label="金额 / 时间" min-width="180">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title amount-text"
>{{ row.totalAmount }} {{ row.currency }}</span
>
<span class="cell-subline"
>支付 {{ formatAdminDateTime(row.paidAt) }}</span
>
<span class="cell-subline"
>更新 {{ formatAdminDateTime(row.updatedAt) }}</span
>
<span class="cell-title amount-text">{{ row.totalAmount }} {{ row.currency }}</span>
<span class="cell-subline">支付 {{ formatAdminDateTime(row.paidAt) }}</span>
<span class="cell-subline">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<RouterLink
:to="`/admin/orders/${row.orderId}`"
class="detail-link"
>详情</RouterLink
>
<RouterLink :to="`/admin/orders/${row.orderId}`" class="detail-link">详情</RouterLink>
</template>
</el-table-column>
</el-table>
@@ -405,7 +347,7 @@ onMounted(() => {
</template>
<style scoped>
@import "../../../styles/admin-list-pages.css";
@import '../../../styles/admin-list-pages.css';
.filter-grid {
grid-template-columns:
@@ -436,32 +378,32 @@ onMounted(() => {
border: 1px solid transparent;
white-space: nowrap;
}
.progress-chip[data-tone="success"] {
.progress-chip[data-tone='success'] {
color: #0f766e;
background: var(--color-success-light);
border-color: #a6f4c5;
}
.progress-chip[data-tone="primary"] {
.progress-chip[data-tone='primary'] {
color: var(--color-primary-dark);
background: var(--color-primary-light);
border-color: #b2ddff;
}
.progress-chip[data-tone="warning"] {
.progress-chip[data-tone='warning'] {
color: #b54708;
background: var(--color-warning-light);
border-color: #fedf89;
}
.follow-up-chip[data-tone="success"] {
.follow-up-chip[data-tone='success'] {
color: #0f766e;
background: var(--color-success-light);
border-color: #a6f4c5;
}
.follow-up-chip[data-tone="warning"] {
.follow-up-chip[data-tone='warning'] {
color: #b54708;
background: var(--color-warning-light);
border-color: #fedf89;
}
.follow-up-chip[data-tone="danger"] {
.follow-up-chip[data-tone='danger'] {
color: var(--color-danger-dark);
background: var(--color-danger-light);
border-color: #fecaca;
@@ -206,13 +206,11 @@ onMounted(loadConfigs)
<AdminPageHeader title="平台配置" description="按平台分别管理来源接入、凭据与店铺能力。">
<template #extra>
<el-button circle title="刷新全部配置" @click="loadConfigs">
<template #icon><el-icon><RefreshRight /></el-icon></template>
<template #icon
><el-icon><RefreshRight /></el-icon
></template>
</el-button>
<el-button
v-if="activePlatform === 'agiso'"
type="primary"
@click="addShop"
>
<el-button v-if="activePlatform === 'agiso'" type="primary" @click="addShop">
新增 Agiso 店铺
</el-button>
</template>
@@ -245,11 +243,50 @@ onMounted(loadConfigs)
<div class="overview-grid">
<el-card
v-for="card in [
{ key: 'agiso', label: 'Agiso', tag: 'Webhook / 店铺', value: agisoStats.configuredShopCount, unit: '已配置店铺', meta: `识别到 ${agisoStats.observedShopCount} 家 · 待补 ${agisoStats.pendingObservedCount} 家` },
{ key: 'notifications', label: '内部通知', tag: 'Bark / 值班', value: notificationStats.enabledRecipientCount, unit: '启用接收人', meta: `配置 ${notificationStats.configuredRecipientCount} 人 · 最近成功 ${notificationStats.lastSuccessCount} 个` },
{ key: 'ninetyone', label: '91卡券', tag: '接入 / 待补全', value: ninetyoneStats.pendingCount, unit: '待补全订单', meta: `当前 ${ninetyoneStats.currentCount} 条 · 已生成任务 ${ninetyoneStats.taskReadyCount} 条` },
{ key: 'kuaishouEticket', label: '快手小店核销', tag: 'Cookie / 券核销', value: kuaishouEticketStats.configuredShopCount, unit: '已配置店铺', meta: `Cookie 就绪 ${kuaishouEticketStats.cookieReadyCount} 家 · 当前店铺 ${kuaishouEticketStats.selectedShopReady ? '可调试' : '待补全'}` },
...(isDevMode ? [{ key: 'cloudtentacles', label: 'cloudtentacles', tag: '履约 / 登录', value: cloudtentaclesStats.hasCredential ? '已配置' : '未配置', unit: '履约凭据', meta: `短信 ${cloudtentaclesStats.smsSent ? '已发送' : '未发送'} · 会话 ${cloudtentaclesStats.validated ? '已验证' : '未验证'}` }] : []),
{
key: 'agiso',
label: 'Agiso',
tag: 'Webhook / 店铺',
value: agisoStats.configuredShopCount,
unit: '已配置店铺',
meta: `识别到 ${agisoStats.observedShopCount} 家 · 待补 ${agisoStats.pendingObservedCount} 家`,
},
{
key: 'notifications',
label: '内部通知',
tag: 'Bark / 值班',
value: notificationStats.enabledRecipientCount,
unit: '启用接收人',
meta: `配置 ${notificationStats.configuredRecipientCount} 人 · 最近成功 ${notificationStats.lastSuccessCount} 个`,
},
{
key: 'ninetyone',
label: '91卡券',
tag: '接入 / 待补全',
value: ninetyoneStats.pendingCount,
unit: '待补全订单',
meta: `当前 ${ninetyoneStats.currentCount} 条 · 已生成任务 ${ninetyoneStats.taskReadyCount} 条`,
},
{
key: 'kuaishouEticket',
label: '快手小店核销',
tag: 'Cookie / 券核销',
value: kuaishouEticketStats.configuredShopCount,
unit: '已配置店铺',
meta: `Cookie 就绪 ${kuaishouEticketStats.cookieReadyCount} 家 · 当前店铺 ${kuaishouEticketStats.selectedShopReady ? '可调试' : '待补全'}`,
},
...(isDevMode
? [
{
key: 'cloudtentacles',
label: 'cloudtentacles',
tag: '履约 / 登录',
value: cloudtentaclesStats.hasCredential ? '已配置' : '未配置',
unit: '履约凭据',
meta: `短信 ${cloudtentaclesStats.smsSent ? '已发送' : '未发送'} · 会话 ${cloudtentaclesStats.validated ? '已验证' : '未验证'}`,
},
]
: []),
]"
:key="card.key"
:class="['overview-card', { 'is-active': activePlatform === card.key }]"
@@ -359,11 +396,7 @@ onMounted(loadConfigs)
/>
</el-tab-pane>
<el-tab-pane
v-if="isDevMode"
label="cloudtentacles 履约平台"
name="cloudtentacles"
>
<el-tab-pane v-if="isDevMode" label="cloudtentacles 履约平台" name="cloudtentacles">
<AdminPlatformCloudtentaclesSection
:cloudtentacles-file-path="cloudtentaclesFilePath"
:cloudtentacles-session-file-path="cloudtentaclesSessionFilePath"
@@ -392,7 +425,9 @@ onMounted(loadConfigs)
:handle-cloudtentacles-fetch-knapsack="handleCloudtentaclesFetchKnapsack"
:handle-cloudtentacles-fetch-vn-list="handleCloudtentaclesFetchVnList"
:handle-cloudtentacles-appoint-vn="handleCloudtentaclesAppointVn"
:handle-cloudtentacles-generate-vn-login-code="handleCloudtentaclesGenerateVnLoginCode"
:handle-cloudtentacles-generate-vn-login-code="
handleCloudtentaclesGenerateVnLoginCode
"
:handle-cloudtentacles-fetch-vn-code="handleCloudtentaclesFetchVnCode"
:handle-cloudtentacles-verify-vn-code="handleCloudtentaclesVerifyVnCode"
:handle-cloudtentacles-fetch-bind-url="handleCloudtentaclesFetchBindUrl"
@@ -420,7 +455,9 @@ onMounted(loadConfigs)
}
.overview-card {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.overview-card.is-active {
@@ -46,7 +46,9 @@ defineProps<Props>()
<div class="card-header">
<div>
<span class="card-title">默认消息模板</span>
<p class="card-desc">Agiso 店铺未单独配置时统一使用这里的领取消息与自动发货消息模板</p>
<p class="card-desc">
Agiso 店铺未单独配置时统一使用这里的领取消息与自动发货消息模板
</p>
</div>
</div>
</template>
@@ -76,27 +78,25 @@ defineProps<Props>()
<div class="card-header">
<div>
<span class="card-title">Agiso 店铺列表</span>
<p class="card-desc">每家店铺可单独配置 AccessToken 和模板覆盖建议先用"近期识别到的店铺"导入再补 AccessToken</p>
<p class="card-desc">
每家店铺可单独配置 AccessToken 和模板覆盖建议先用"近期识别到的店铺"导入再补
AccessToken
</p>
</div>
<div class="card-actions">
<el-button :disabled="shops.length === 0" @click="expandAllShops">全部展开</el-button>
<el-button :disabled="shops.length === 0" @click="collapseAllShops">全部折叠</el-button>
<el-button :loading="saving" type="primary" @click="saveAgisoConfigs">保存 Agiso 配置</el-button>
<el-button :loading="saving" type="primary" @click="saveAgisoConfigs"
>保存 Agiso 配置</el-button
>
</div>
</div>
</template>
<el-empty v-if="shops.length === 0" description="当前还没有 Agiso 店铺配置,先新增一条。" />
<el-collapse
v-else
:model-value="shops.filter(s => isShopExpanded(s.id)).map(s => s.id)"
>
<el-collapse-item
v-for="shop in shops"
:key="shop.id"
:name="shop.id"
>
<el-collapse v-else :model-value="shops.filter((s) => isShopExpanded(s.id)).map((s) => s.id)">
<el-collapse-item v-for="shop in shops" :key="shop.id" :name="shop.id">
<template #title>
<div class="shop-title">
<strong>{{ shop.shopName || shop.shopId || '未命名店铺' }}</strong>
@@ -168,7 +168,9 @@ defineProps<Props>()
<template #default="{ row }">
<div>
<div>{{ row.displayShopName || row.shopId }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">ID: {{ row.shopId }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">
ID: {{ row.shopId }}
</div>
</div>
</template>
</el-table-column>
@@ -185,7 +187,12 @@ defineProps<Props>()
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button v-if="!row.configured" size="small" type="primary" @click="importObservedShop(row)">
<el-button
v-if="!row.configured"
size="small"
type="primary"
@click="importObservedShop(row)"
>
导入到店铺
</el-button>
<span v-else class="text-muted">已存在</span>
@@ -83,10 +83,15 @@ defineProps<Props>()
<code>{{ cloudtentaclesSessionFilePath || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="职责">
管理 cloudtentacles 外部履约平台的账号短信验证码登录与 token 探活该区域不处理订单来源同步
管理 cloudtentacles 外部履约平台的账号短信验证码登录与 token
探活该区域不处理订单来源同步
</el-descriptions-item>
<el-descriptions-item label="已保存 token">
{{ cloudtentaclesPersistedSession?.hasToken ? `${cloudtentaclesPersistedSession.tokenMasked} / ${formatAdminDateTime(cloudtentaclesPersistedSession.loggedInAt)}` : '未保存' }}
{{
cloudtentaclesPersistedSession?.hasToken
? `${cloudtentaclesPersistedSession.tokenMasked} / ${formatAdminDateTime(cloudtentaclesPersistedSession.loggedInAt)}`
: '未保存'
}}
</el-descriptions-item>
</el-descriptions>
</el-card>
@@ -97,13 +102,28 @@ defineProps<Props>()
<div class="card-header">
<div>
<span class="card-title">cloudtentacles 履约平台配置</span>
<p class="card-desc">先保存账号密码手机号和设备头再发送短信验证码最后执行登录测试或手动校验 token</p>
<p class="card-desc">
先保存账号密码手机号和设备头再发送短信验证码最后执行登录测试或手动校验 token
</p>
</div>
<div class="card-actions">
<el-button :loading="cloudtentaclesSaving" @click="handleCloudtentaclesSaveSource">保存履约配置</el-button>
<el-button :loading="cloudtentaclesSendingSms" @click="handleCloudtentaclesSendSmsCode">发送短信验证码</el-button>
<el-button :loading="cloudtentaclesTesting" type="primary" @click="handleCloudtentaclesTestLogin">测试登录</el-button>
<el-button :loading="cloudtentaclesValidating" @click="handleCloudtentaclesValidateSession">校验 token</el-button>
<el-button :loading="cloudtentaclesSaving" @click="handleCloudtentaclesSaveSource"
>保存履约配置</el-button
>
<el-button :loading="cloudtentaclesSendingSms" @click="handleCloudtentaclesSendSmsCode"
>发送短信验证码</el-button
>
<el-button
:loading="cloudtentaclesTesting"
type="primary"
@click="handleCloudtentaclesTestLogin"
>测试登录</el-button
>
<el-button
:loading="cloudtentaclesValidating"
@click="handleCloudtentaclesValidateSession"
>校验 token</el-button
>
</div>
</div>
</template>
@@ -115,12 +135,20 @@ defineProps<Props>()
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="账号">
<el-input v-model="cloudtentaclesForm.username" placeholder="cloudtentacles 登录账号" />
<el-input
v-model="cloudtentaclesForm.username"
placeholder="cloudtentacles 登录账号"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="密码">
<el-input v-model="cloudtentaclesForm.password" type="password" show-password placeholder="cloudtentacles 登录密码" />
<el-input
v-model="cloudtentaclesForm.password"
type="password"
show-password
placeholder="cloudtentacles 登录密码"
/>
</el-form-item>
</el-col>
<el-col :span="8">
@@ -142,19 +170,34 @@ defineProps<Props>()
</el-col>
<el-col :span="8">
<el-form-item label="VN ID">
<el-input-number v-model="cloudtentaclesForm.vnId" :min="0" controls-position="right" style="width: 100%" />
<el-input-number
v-model="cloudtentaclesForm.vnId"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="SKU ID">
<el-input-number v-model="cloudtentaclesForm.skuId" :min="0" controls-position="right" style="width: 100%" />
<el-input-number
v-model="cloudtentaclesForm.skuId"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="购买数量">
<el-input-number v-model="cloudtentaclesForm.skuCount" :min="1" controls-position="right" style="width: 100%" />
<el-input-number
v-model="cloudtentaclesForm.skuCount"
:min="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
@@ -166,12 +209,20 @@ defineProps<Props>()
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="deviceType">
<el-input-number v-model="cloudtentaclesForm.deviceType" :min="0" controls-position="right" style="width: 100%" />
<el-input-number
v-model="cloudtentaclesForm.deviceType"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="token(可手动粘贴)">
<el-input v-model="cloudtentaclesForm.token" placeholder="登录成功后可自动回填,也可手动粘贴" />
<el-input
v-model="cloudtentaclesForm.token"
placeholder="登录成功后可自动回填,也可手动粘贴"
/>
</el-form-item>
</el-col>
</el-row>
@@ -189,19 +240,24 @@ defineProps<Props>()
<div class="result-grid" style="margin-top: 16px">
<AdminResultCard label="短信发送" :value="cloudtentaclesSmsResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesSmsResult">
{{ cloudtentaclesSmsResult.phoneMasked }}{{ formatAdminDateTime(cloudtentaclesSmsResult.sentAt) }}
{{ cloudtentaclesSmsResult.phoneMasked }}{{
formatAdminDateTime(cloudtentaclesSmsResult.sentAt)
}}
</p>
<p v-else>先发送短信验证码确认手机号可用</p>
</AdminResultCard>
<AdminResultCard label="登录结果" :value="cloudtentaclesLoginResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesLoginResult">
{{ cloudtentaclesLoginResult.phoneMasked }}{{ formatAdminDateTime(cloudtentaclesLoginResult.loggedInAt) }}
{{ cloudtentaclesLoginResult.phoneMasked }}{{
formatAdminDateTime(cloudtentaclesLoginResult.loggedInAt)
}}
</p>
<p v-else>填写短信验证码后可执行测试登录</p>
</AdminResultCard>
<AdminResultCard label="会话校验" :value="cloudtentaclesValidateResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesValidateResult">
权限 {{ cloudtentaclesValidateResult.session.permissionCount }} 余额 {{ cloudtentaclesValidateResult.asset }}
权限 {{ cloudtentaclesValidateResult.session.permissionCount }} 余额
{{ cloudtentaclesValidateResult.asset }}
</p>
<p v-else>登录成功后可继续校验 token 会话</p>
</AdminResultCard>
@@ -213,29 +269,78 @@ defineProps<Props>()
<template #header>
<div>
<span class="card-title">cloudtentacles 开发调试工具</span>
<p class="card-desc">仅开发模式展示用于调试分类SKU购买发货虚拟号兑换链接和结束退还链路</p>
<p class="card-desc">
仅开发模式展示用于调试分类SKU购买发货虚拟号兑换链接和结束退还链路
</p>
</div>
</template>
<div class="debug-buttons">
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchAsset">余额</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchCategories">分类</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchSkuList">SKU</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesBuySku">购买</el-button>
<el-button :loading="cloudtentaclesDebugLoading" type="primary" @click="handleCloudtentaclesUseSku">发货</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchKnapsack">背包</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnList">虚拟号列表</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesAppointVn">申请虚拟号</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesGenerateVnLoginCode">生成登录码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnCode">获取验证码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesVerifyVnCode">校验验证码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchBindUrl">兑换链接</el-button>
<el-button :loading="cloudtentaclesDebugLoading" type="warning" @click="handleCloudtentaclesBackVn">退还号码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" type="success" @click="handleCloudtentaclesRunFullFlow">完整流程</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchAsset"
>余额</el-button
>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesFetchCategories"
>分类</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchSkuList"
>SKU</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesBuySku"
>购买</el-button
>
<el-button
:loading="cloudtentaclesDebugLoading"
type="primary"
@click="handleCloudtentaclesUseSku"
>发货</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchKnapsack"
>背包</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnList"
>虚拟号列表</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesAppointVn"
>申请虚拟号</el-button
>
<el-button
:loading="cloudtentaclesDebugLoading"
@click="handleCloudtentaclesGenerateVnLoginCode"
>生成登录码</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnCode"
>获取验证码</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesVerifyVnCode"
>校验验证码</el-button
>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchBindUrl"
>兑换链接</el-button
>
<el-button
:loading="cloudtentaclesDebugLoading"
type="warning"
@click="handleCloudtentaclesBackVn"
>退还号码</el-button
>
<el-button
:loading="cloudtentaclesDebugLoading"
type="success"
@click="handleCloudtentaclesRunFullFlow"
>完整流程</el-button
>
</div>
<pre v-if="cloudtentaclesDebugResult" class="debug-pre" style="margin-top: 16px">{{ cloudtentaclesDebugResult }}</pre>
<el-empty v-else description="执行一次调试动作后,这里会显示原始返回数据。" :image-size="60" />
<pre v-if="cloudtentaclesDebugResult" class="debug-pre" style="margin-top: 16px">{{
cloudtentaclesDebugResult
}}</pre>
<el-empty
v-else
description="执行一次调试动作后,这里会显示原始返回数据。"
:image-size="60"
/>
</el-card>
<!-- 调试结果 -->
@@ -243,14 +348,20 @@ defineProps<Props>()
<template #header>
<div>
<span class="card-title">cloudtentacles 调试结果</span>
<p class="card-desc">用于确认当前账号会话权限和用户信息后续发货执行器会复用这里的登录能力</p>
<p class="card-desc">
用于确认当前账号会话权限和用户信息后续发货执行器会复用这里的登录能力
</p>
</div>
</template>
<div class="result-grid">
<AdminResultCard
label="当前 token"
:value="cloudtentaclesValidateResult?.session.tokenMasked || cloudtentaclesLoginResult?.session.tokenMasked || '-'"
:value="
cloudtentaclesValidateResult?.session.tokenMasked ||
cloudtentaclesLoginResult?.session.tokenMasked ||
'-'
"
>
<p>原始 token 已写入上方输入框可继续用于校验或后续调试</p>
</AdminResultCard>
@@ -258,9 +369,20 @@ defineProps<Props>()
label="权限"
:value="`${cloudtentaclesValidateResult?.session.permissionCount || cloudtentaclesLoginResult?.session.permissionCount || 0} 项`"
>
<p>{{ (cloudtentaclesValidateResult?.session.permissions || cloudtentaclesLoginResult?.session.permissions || []).join('、') || '暂无' }}</p>
<p>
{{
(
cloudtentaclesValidateResult?.session.permissions ||
cloudtentaclesLoginResult?.session.permissions ||
[]
).join('、') || '暂无'
}}
</p>
</AdminResultCard>
<AdminResultCard label="余额" :value="cloudtentaclesValidateResult?.asset || cloudtentaclesLoginResult?.asset || 0">
<AdminResultCard
label="余额"
:value="cloudtentaclesValidateResult?.asset || cloudtentaclesLoginResult?.asset || 0"
>
<p>可用于确认当前履约账号状态</p>
</AdminResultCard>
</div>
@@ -66,12 +66,15 @@ defineProps<Props>()
<span class="card-title">说明信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="配置文件">{{ kuaishouEticketFilePath || '-' }}</el-descriptions-item>
<el-descriptions-item label="配置文件">{{
kuaishouEticketFilePath || '-'
}}</el-descriptions-item>
<el-descriptions-item label="职责">
这是独立的新流程只负责快手小店券核销调试与配置不接入当前已完成的订单来源和履约主链路
</el-descriptions-item>
<el-descriptions-item label="建议流程">
先为每个店铺录入 kshopName + Cookie可用 info 接口自动识别店铺名与店铺 ID保存后选择目标店铺再查询券码并执行核销
先为每个店铺录入 kshopName + Cookie可用 info 接口自动识别店铺名与店铺
ID保存后选择目标店铺再查询券码并执行核销
</el-descriptions-item>
</el-descriptions>
</el-card>
@@ -82,56 +85,102 @@ defineProps<Props>()
<div class="card-header">
<div>
<span class="card-title">快手小店店铺配置</span>
<p class="card-desc">支持多店铺配置建议先粘贴 Cookie再用 info 接口自动识别 kshopName / shopId最后统一保存到文件</p>
<p class="card-desc">
支持多店铺配置建议先粘贴 Cookie再用 info 接口自动识别 kshopName /
shopId最后统一保存到文件
</p>
</div>
<div class="card-actions">
<el-button :disabled="kuaishouEticketShops.length === 0" @click="expandAllKuaishouEticketShops">全部展开</el-button>
<el-button :disabled="kuaishouEticketShops.length === 0" @click="collapseAllKuaishouEticketShops">全部折叠</el-button>
<el-button
:disabled="kuaishouEticketShops.length === 0"
@click="expandAllKuaishouEticketShops"
>全部展开</el-button
>
<el-button
:disabled="kuaishouEticketShops.length === 0"
@click="collapseAllKuaishouEticketShops"
>全部折叠</el-button
>
<el-button @click="addKuaishouEticketShop">新增店铺</el-button>
<el-button :loading="kuaishouEticketSaving" type="primary" @click="handleKuaishouEticketSaveSource">保存配置</el-button>
<el-button
:loading="kuaishouEticketSaving"
type="primary"
@click="handleKuaishouEticketSaveSource"
>保存配置</el-button
>
</div>
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-form-item label="Base URL">
<el-input v-model="kuaishouEticketForm.baseUrl" placeholder="https://s.kwaixiaodian.com" />
<el-input
v-model="kuaishouEticketForm.baseUrl"
placeholder="https://s.kwaixiaodian.com"
/>
</el-form-item>
</el-form>
<el-empty v-if="kuaishouEticketShops.length === 0" description="当前还没有快手小店店铺配置,先新增一条。" />
<el-empty
v-if="kuaishouEticketShops.length === 0"
description="当前还没有快手小店店铺配置,先新增一条。"
/>
<div v-else class="shop-panels">
<div class="shop-list">
<el-collapse
:model-value="kuaishouEticketShops.filter(s => isKuaishouEticketShopExpanded(s.id)).map(s => s.id)"
:model-value="
kuaishouEticketShops
.filter((s) => isKuaishouEticketShopExpanded(s.id))
.map((s) => s.id)
"
>
<el-collapse-item
v-for="shop in kuaishouEticketShops"
:key="shop.id"
:name="shop.id"
:class="['shop-panel', {
'is-debug': kuaishouEticketForm.selectedShopId === shop.id,
}]"
:class="[
'shop-panel',
{
'is-debug': kuaishouEticketForm.selectedShopId === shop.id,
},
]"
>
<template #title>
<div class="shop-title-bar">
<div class="shop-avatar">
<img v-if="shop.userAvatar" :src="shop.userAvatar" :alt="shop.kshopName || shop.shopId || '店铺头像'" />
<img
v-if="shop.userAvatar"
:src="shop.userAvatar"
:alt="shop.kshopName || shop.shopId || '店铺头像'"
/>
<span v-else>{{ getKuaishouEticketShopInitial(shop) }}</span>
</div>
<div class="shop-info">
<div class="shop-name-row">
<strong>{{ shop.kshopName || shop.shopId || '未命名店铺' }}</strong>
<el-tag :type="shop.enabled ? 'success' : 'info'" size="small">{{ shop.enabled ? '已启用' : '已停用' }}</el-tag>
<el-tag v-if="kuaishouEticketForm.selectedShopId === shop.id" type="primary" size="small">当前调试</el-tag>
<el-tag :type="shop.enabled ? 'success' : 'info'" size="small">{{
shop.enabled ? '已启用' : '已停用'
}}</el-tag>
<el-tag
v-if="kuaishouEticketForm.selectedShopId === shop.id"
type="primary"
size="small"
>当前调试</el-tag
>
</div>
<div class="shop-meta">
<span>店铺 ID{{ shop.shopId || '等待 info 自动识别' }}</span>
<el-tag :type="Boolean(shop.cookie.trim()) ? 'success' : 'warning'" size="small">
<el-tag
:type="Boolean(shop.cookie.trim()) ? 'success' : 'warning'"
size="small"
>
{{ shop.cookie.trim() ? 'Cookie 已粘贴' : '待补 Cookie' }}
</el-tag>
<el-tag :type="Boolean(shop.userAvatar || shop.shopId.trim()) ? 'success' : 'info'" size="small">
<el-tag
:type="Boolean(shop.userAvatar || shop.shopId.trim()) ? 'success' : 'info'"
size="small"
>
{{ shop.userAvatar || shop.shopId.trim() ? '信息已识别' : '未读取信息' }}
</el-tag>
</div>
@@ -148,7 +197,11 @@ defineProps<Props>()
</el-col>
<el-col :span="12">
<el-form-item label="店铺 ID">
<el-input v-model="shop.shopId" readonly placeholder="通过 info 接口自动识别" />
<el-input
v-model="shop.shopId"
readonly
placeholder="通过 info 接口自动识别"
/>
</el-form-item>
</el-col>
</el-row>
@@ -165,7 +218,9 @@ defineProps<Props>()
<div class="shop-footer">
<div class="footer-left">
<el-switch v-model="shop.enabled" active-text="启用该店铺" />
<span v-if="shop.userAvatar" class="text-muted">已识别头像,可直接作为调试店铺</span>
<span v-if="shop.userAvatar" class="text-muted"
>已识别头像,可直接作为调试店铺</span
>
<span v-else class="text-muted">建议先读取店铺信息确认 Cookie 归属</span>
</div>
<div class="footer-actions">
@@ -181,9 +236,15 @@ defineProps<Props>()
:type="kuaishouEticketForm.selectedShopId === shop.id ? 'success' : 'primary'"
@click="setKuaishouEticketDebugShop(shop.id)"
>
{{ kuaishouEticketForm.selectedShopId === shop.id ? '当前调试店铺' : '设为调试店铺' }}
{{
kuaishouEticketForm.selectedShopId === shop.id
? '当前调试店铺'
: '设为调试店铺'
}}
</el-button>
<el-button size="small" type="danger" @click="removeKuaishouEticketShop(shop.id)">删除</el-button>
<el-button size="small" type="danger" @click="removeKuaishouEticketShop(shop.id)"
>删除</el-button
>
</div>
</div>
</el-collapse-item>
@@ -196,15 +257,29 @@ defineProps<Props>()
</AdminResultCard>
<AdminResultCard
label="当前调试店铺"
:value="activeKuaishouEticketShop?.kshopName || activeKuaishouEticketShop?.shopId || '未选择'"
:value="
activeKuaishouEticketShop?.kshopName || activeKuaishouEticketShop?.shopId || '未选择'
"
>
<p>{{ kuaishouEticketStats.selectedShopReady ? '已具备调试条件,可直接查询核销信息。' : '请选择店铺并补全 Cookie。' }}</p>
<p>
{{
kuaishouEticketStats.selectedShopReady
? '已具备调试条件,可直接查询核销信息。'
: '请选择店铺并补全 Cookie。'
}}
</p>
</AdminResultCard>
<AdminResultCard
label="最近识别结果"
:value="kuaishouEticketShopInfoResult?.shop.kshopName || kuaishouEticketShopInfoResult?.shop.shopId || '未执行'"
:value="
kuaishouEticketShopInfoResult?.shop.kshopName ||
kuaishouEticketShopInfoResult?.shop.shopId ||
'未执行'
"
>
<p v-if="kuaishouEticketShopInfoResult">结算状态 {{ kuaishouEticketShopInfoResult.shop.settleStatus }}</p>
<p v-if="kuaishouEticketShopInfoResult">
结算状态 {{ kuaishouEticketShopInfoResult.shop.settleStatus }}
</p>
<p v-else>可用"读取店铺信息"校验 Cookie 是否属于预期店铺。</p>
</AdminResultCard>
<AdminResultCard label="建议顺序" value="先配置再调试">
@@ -231,11 +306,21 @@ defineProps<Props>()
<div class="card-header">
<div>
<span class="card-title">快手小店核销调试</span>
<p class="card-desc">这里使用"当前调试店铺"的 Cookie 执行查询与核销;eTicketId / oid / formToken 都来自查询结果。</p>
<p class="card-desc">
这里使用"当前调试店铺"的 Cookie 执行查询与核销;eTicketId / oid / formToken
都来自查询结果。
</p>
</div>
<div class="card-actions">
<el-button :loading="kuaishouEticketDetailing" @click="handleKuaishouEticketQueryDetail">查询核销信息</el-button>
<el-button :loading="kuaishouEticketConsuming" type="primary" @click="handleKuaishouEticketConsume">执行核销</el-button>
<el-button :loading="kuaishouEticketDetailing" @click="handleKuaishouEticketQueryDetail"
>查询核销信息</el-button
>
<el-button
:loading="kuaishouEticketConsuming"
type="primary"
@click="handleKuaishouEticketConsume"
>执行核销</el-button
>
</div>
</div>
</template>
@@ -244,19 +329,36 @@ defineProps<Props>()
<div class="debug-main">
<el-form label-width="auto" label-position="top">
<el-form-item label="调试店铺">
<el-select v-model="kuaishouEticketForm.selectedShopId" placeholder="请选择店铺" style="width: 100%">
<el-option v-for="shop in kuaishouEticketShops" :key="shop.id" :label="shop.kshopName || shop.shopId || '未命名店铺'" :value="shop.id" />
<el-select
v-model="kuaishouEticketForm.selectedShopId"
placeholder="请选择店铺"
style="width: 100%"
>
<el-option
v-for="shop in kuaishouEticketShops"
:key="shop.id"
:label="shop.kshopName || shop.shopId || '未命名店铺'"
:value="shop.id"
/>
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="查询券码">
<el-input v-model="kuaishouEticketForm.queryTicketCode" placeholder="用于查询核销信息" />
<el-input
v-model="kuaishouEticketForm.queryTicketCode"
placeholder="用于查询核销信息"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="核销数量">
<el-input-number v-model="kuaishouEticketForm.num" :min="1" controls-position="right" style="width: 100%" />
<el-input-number
v-model="kuaishouEticketForm.num"
:min="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
@@ -279,17 +381,29 @@ defineProps<Props>()
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="eTicketId">
<el-input :model-value="kuaishouEticketForm.eTicketId" readonly placeholder="查询后自动回填" />
<el-input
:model-value="kuaishouEticketForm.eTicketId"
readonly
placeholder="查询后自动回填"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="oid">
<el-input :model-value="kuaishouEticketForm.oid" readonly placeholder="查询后自动回填" />
<el-input
:model-value="kuaishouEticketForm.oid"
readonly
placeholder="查询后自动回填"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="formToken">
<el-input :model-value="kuaishouEticketForm.formToken" readonly placeholder="查询后自动回填" />
<el-input
:model-value="kuaishouEticketForm.formToken"
readonly
placeholder="查询后自动回填"
/>
</el-form-item>
</el-col>
</el-row>
@@ -298,16 +412,41 @@ defineProps<Props>()
</div>
<div class="result-grid" style="margin-top: 16px">
<AdminResultCard label="详情查询" :value="kuaishouEticketDetailResult ? (kuaishouEticketDetailResult.ok ? '成功' : '已返回') : '未执行'">
<AdminResultCard
label="详情查询"
:value="
kuaishouEticketDetailResult
? kuaishouEticketDetailResult.ok
? '成功'
: '已返回'
: '未执行'
"
>
<p v-if="kuaishouEticketDetailResult?.detail">
{{ kuaishouEticketDetailResult.goods?.itemTitle || kuaishouEticketDetailResult.goods?.skuDesc || '-' }}
剩余 {{ kuaishouEticketDetailResult.detail.leftCount }} / {{ kuaishouEticketDetailResult.detail.totalCount }}
{{
kuaishouEticketDetailResult.goods?.itemTitle ||
kuaishouEticketDetailResult.goods?.skuDesc ||
'-'
}} 剩余 {{ kuaishouEticketDetailResult.detail.leftCount }} /
{{ kuaishouEticketDetailResult.detail.totalCount }}
</p>
<p v-else>先通过查询券码获取核销详情并自动带出核销参数。</p>
</AdminResultCard>
<AdminResultCard label="核销执行" :value="kuaishouEticketConsumeResult ? (kuaishouEticketConsumeResult.consumed ? '已处理' : '已返回') : '未执行'">
<AdminResultCard
label="核销执行"
:value="
kuaishouEticketConsumeResult
? kuaishouEticketConsumeResult.consumed
? '已处理'
: '已返回'
: '未执行'
"
>
<p v-if="kuaishouEticketConsumeResult">
{{ kuaishouEticketConsumeResult.errorMessage || (kuaishouEticketConsumeResult.ok ? '核销成功' : '接口已返回结果') }}
{{
kuaishouEticketConsumeResult.errorMessage ||
(kuaishouEticketConsumeResult.ok ? '核销成功' : '接口已返回结果')
}}
</p>
<p v-else>查询详情后再执行核销会更稳妥。</p>
</AdminResultCard>
@@ -16,7 +16,9 @@ type Props = {
taskReadyCount: number
}
loadNinetyoneOrders: (page?: number) => void | Promise<void>
handleNinetyoneStatusChange: (status: 'pending_config' | 'all' | 'manual_failed') => void | Promise<void>
handleNinetyoneStatusChange: (
status: 'pending_config' | 'all' | 'manual_failed',
) => void | Promise<void>
handleNinetyoneRetryOrder: (item: AdminNinetyoneOrderItem) => void | Promise<void>
handleNinetyoneFailOrder: (item: AdminNinetyoneOrderItem) => void | Promise<void>
}
@@ -48,20 +50,28 @@ defineProps<Props>()
<div>
<span class="card-title">91卡券订单队列</span>
<p class="card-desc">
当前筛选 {{ ninetyoneStats.currentCount }} 总计 {{ ninetyoneStats.total }} 已生成任务 {{ ninetyoneStats.taskReadyCount }}
当前筛选 {{ ninetyoneStats.currentCount }} 总计
{{ ninetyoneStats.total }} 已生成任务 {{ ninetyoneStats.taskReadyCount }}
</p>
</div>
<div class="card-actions">
<el-radio-group
:model-value="ninetyoneStatus"
size="small"
@update:model-value="(v: string | number | boolean | undefined) => handleNinetyoneStatusChange(v as 'pending_config' | 'all' | 'manual_failed')"
@update:model-value="
(v: string | number | boolean | undefined) =>
handleNinetyoneStatusChange(v as 'pending_config' | 'all' | 'manual_failed')
"
>
<el-radio-button value="pending_config">待补全</el-radio-button>
<el-radio-button value="manual_failed">失败</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<el-button :loading="ninetyoneLoading" @click="loadNinetyoneOrders(ninetyoneOrders.page)">刷新</el-button>
<el-button
:loading="ninetyoneLoading"
@click="loadNinetyoneOrders(ninetyoneOrders.page)"
>刷新</el-button
>
</div>
</div>
</template>
@@ -86,7 +96,9 @@ defineProps<Props>()
<template #default="{ row }">
<div>
<div style="font-weight: 600">{{ row.orderNo }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">{{ row.outTradeNo || '-' }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">
{{ row.outTradeNo || '-' }}
</div>
</div>
</template>
</el-table-column>
@@ -94,8 +106,12 @@ defineProps<Props>()
<template #default="{ row }">
<div>
<div style="font-weight: 600">{{ row.productName || row.productNo || '-' }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">productNo: {{ row.productNo || '-' }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">数量:{{ row.buyNum || 0 }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">
productNo: {{ row.productNo || '-' }}
</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">
数量:{{ row.buyNum || 0 }}
</div>
</div>
</template>
</el-table-column>
@@ -103,13 +119,22 @@ defineProps<Props>()
<template #default="{ row }">
<div>
<el-tag size="small">{{ row.orderStatus }}</el-tag>
<div style="font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px">任务 {{ row.taskCount }}</div>
<div v-if="row.failReason" style="font-size: var(--text-xs); color: var(--color-danger)">{{ row.failReason }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px">
任务 {{ row.taskCount }}
</div>
<div
v-if="row.failReason"
style="font-size: var(--text-xs); color: var(--color-danger)"
>
{{ row.failReason }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatAdminDateTime(row.updatedAt || row.createdAt) }}</template>
<template #default="{ row }">{{
formatAdminDateTime(row.updatedAt || row.createdAt)
}}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
@@ -121,9 +121,15 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
<span class="card-title">配置信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="职责">内部运营通知仅发送给后台管理员客服或值班人员</el-descriptions-item>
<el-descriptions-item label="配置文件">{{ notificationFilePath || '-' }}</el-descriptions-item>
<el-descriptions-item label="监控任务">{{ scheduledJobsFilePath || '-' }}</el-descriptions-item>
<el-descriptions-item label="职责"
>内部运营通知仅发送给后台管理员客服或值班人员</el-descriptions-item
>
<el-descriptions-item label="配置文件">{{
notificationFilePath || '-'
}}</el-descriptions-item>
<el-descriptions-item label="监控任务">{{
scheduledJobsFilePath || '-'
}}</el-descriptions-item>
</el-descriptions>
</el-card>
@@ -133,11 +139,19 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
<div class="card-header">
<div>
<span class="card-title">Bark 通知</span>
<p class="card-desc">已配置 {{ notificationStats.configuredRecipientCount }} 启用 {{ notificationStats.enabledRecipientCount }} </p>
<p class="card-desc">
已配置 {{ notificationStats.configuredRecipientCount }} 启用
{{ notificationStats.enabledRecipientCount }}
</p>
</div>
<div class="card-actions">
<el-button @click="addNotificationRecipient">新增接收人</el-button>
<el-button :loading="notificationSaving" type="primary" @click="handleNotificationSaveConfig">保存配置</el-button>
<el-button
:loading="notificationSaving"
type="primary"
@click="handleNotificationSaveConfig"
>保存配置</el-button
>
</div>
</div>
</template>
@@ -152,8 +166,12 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
/>
<el-form label-width="auto" label-position="top" inline>
<el-form-item><el-switch v-model="notificationForm.enabled" active-text="启用内部通知" /></el-form-item>
<el-form-item><el-switch v-model="notificationForm.barkEnabled" active-text="启用 Bark 通道" /></el-form-item>
<el-form-item
><el-switch v-model="notificationForm.enabled" active-text="启用内部通知"
/></el-form-item>
<el-form-item
><el-switch v-model="notificationForm.barkEnabled" active-text="启用 Bark 通道"
/></el-form-item>
<el-form-item label="Bark 服务地址" style="width: 100%">
<el-input v-model="notificationForm.barkServerUrl" placeholder="https://api.day.app" />
</el-form-item>
@@ -177,7 +195,9 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="removeNotificationRecipient(row.id)">删除</el-button>
<el-button size="small" type="danger" text @click="removeNotificationRecipient(row.id)"
>删除</el-button
>
</template>
</el-table-column>
<template #empty>
@@ -192,10 +212,17 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
<div class="card-header">
<div>
<span class="card-title">监控任务</span>
<p class="card-desc">间隔表示多久检查一次冷却表示同一个异常在这段时间内最多通知一次</p>
<p class="card-desc">
间隔表示多久检查一次冷却表示同一个异常在这段时间内最多通知一次
</p>
</div>
<div class="card-actions">
<el-button :loading="scheduledJobsSaving" type="primary" @click="handleScheduledJobsSaveConfig">保存任务</el-button>
<el-button
:loading="scheduledJobsSaving"
type="primary"
@click="handleScheduledJobsSaveConfig"
>保存任务</el-button
>
</div>
</div>
</template>
@@ -211,7 +238,12 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
<template #default="{ row: job }">
<div>
<div style="font-weight: 600">{{ formatJobType(job.type) }}</div>
<el-switch v-model="job.enabled" size="small" active-text="启用" style="margin-top: 4px" />
<el-switch
v-model="job.enabled"
size="small"
active-text="启用"
style="margin-top: 4px"
/>
</div>
</template>
</el-table-column>
@@ -220,23 +252,52 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
<div class="job-params">
<div class="param-row">
<span class="param-label">检查间隔</span>
<input v-model.number="bindDuration(job, 'intervalSeconds').value" type="number" min="1" class="mini-input" />
<select v-model.number="bindDurationUnit(job, 'intervalSeconds').value" class="mini-select">
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
<input
v-model.number="bindDuration(job, 'intervalSeconds').value"
type="number"
min="1"
class="mini-input"
/>
<select
v-model.number="bindDurationUnit(job, 'intervalSeconds').value"
class="mini-select"
>
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">
{{ unit.label }}
</option>
</select>
<span class="param-hint"> {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
<span class="param-hint"
> {{ formatDuration(job.intervalSeconds) }} 检查一次</span
>
</div>
<div class="param-row">
<span class="param-label">通知冷却</span>
<input v-model.number="bindDuration(job, 'cooldownSeconds').value" type="number" min="1" class="mini-input" />
<select v-model.number="bindDurationUnit(job, 'cooldownSeconds').value" class="mini-select">
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
<input
v-model.number="bindDuration(job, 'cooldownSeconds').value"
type="number"
min="1"
class="mini-input"
/>
<select
v-model.number="bindDurationUnit(job, 'cooldownSeconds').value"
class="mini-select"
>
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">
{{ unit.label }}
</option>
</select>
<span class="param-hint">同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span>
<span class="param-hint"
>同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span
>
</div>
<div class="param-row">
<span class="param-label">余额阈值</span>
<input v-model.number="job.assetThreshold" type="number" min="0" class="mini-input" />
<input
v-model.number="job.assetThreshold"
type="number"
min="0"
class="mini-input"
/>
<span class="param-hint">低于该值时通知</span>
</div>
</div>
@@ -246,15 +307,29 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
<template #default="{ row: job }">
<div class="runtime-info">
<el-tag
:type="getScheduledJobRuntime(job.id)?.lastStatus === 'success' ? 'success' : 'info'"
:type="
getScheduledJobRuntime(job.id)?.lastStatus === 'success' ? 'success' : 'info'
"
size="small"
>
{{ getScheduledJobRuntime(job.id)?.lastStatus || 'pending' }}
</el-tag>
<span class="param-hint">{{ getScheduledJobRuntime(job.id)?.lastMessage || '-' }}</span>
<span class="param-hint">余额{{ getScheduledJobRuntime(job.id)?.lastAsset ?? '-' }}</span>
<span class="param-hint">上次{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '') }}</span>
<span class="param-hint">下次{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '') }}</span>
<span class="param-hint">{{
getScheduledJobRuntime(job.id)?.lastMessage || '-'
}}</span>
<span class="param-hint"
>余额{{ getScheduledJobRuntime(job.id)?.lastAsset ?? '-' }}</span
>
<span class="param-hint"
>上次{{
formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '')
}}</span
>
<span class="param-hint"
>下次{{
formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '')
}}</span
>
</div>
</template>
</el-table-column>
@@ -331,13 +406,23 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
</el-card>
</div>
<el-table v-if="notificationTestResult" :data="notificationTestResult.results" stripe size="small" style="margin-top: 16px">
<el-table
v-if="notificationTestResult"
:data="notificationTestResult.results"
stripe
size="small"
style="margin-top: 16px"
>
<el-table-column label="接收人">
<template #default="{ row }">{{ row.recipientName || row.recipientKeyMasked || '-' }}</template>
<template #default="{ row }">{{
row.recipientName || row.recipientKeyMasked || '-'
}}</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.ok ? 'success' : 'danger'" size="small">{{ row.ok ? '成功' : '失败' }}</el-tag>
<el-tag :type="row.ok ? 'success' : 'danger'" size="small">{{
row.ok ? '成功' : '失败'
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态码" width="80" align="center" />
@@ -1,4 +1,9 @@
export type PlatformTab = 'agiso' | 'notifications' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles'
export type PlatformTab =
| 'agiso'
| 'notifications'
| 'ninetyone'
| 'kuaishouEticket'
| 'cloudtentacles'
export type EditableDefaults = {
messageTemplate: string
@@ -2,7 +2,11 @@ import { computed, ref } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
import { saveAdminAgisoShopConfigs } from '@/services/admin'
import type { AdminAgisoMessagingDefaults, AdminAgisoObservedShopItem, AdminAgisoShopConfigItem } from '@/types/admin'
import type {
AdminAgisoMessagingDefaults,
AdminAgisoObservedShopItem,
AdminAgisoShopConfigItem,
} from '@/types/admin'
import type { EditableDefaults, EditableShop } from './types'
@@ -60,9 +60,9 @@ export function useAdminCloudtentaclesPlatform() {
const cloudtentaclesStats = computed(() => ({
hasCredential: Boolean(
cloudtentaclesForm.value.username.trim()
&& cloudtentaclesForm.value.password.trim()
&& cloudtentaclesForm.value.phone.trim(),
cloudtentaclesForm.value.username.trim() &&
cloudtentaclesForm.value.password.trim() &&
cloudtentaclesForm.value.phone.trim(),
),
smsSent: Boolean(cloudtentaclesSmsResult.value),
validated: Boolean(cloudtentaclesValidateResult.value || cloudtentaclesLoginResult.value),
@@ -129,9 +129,9 @@ export function useAdminCloudtentaclesPlatform() {
function ensureCloudtentaclesLoginFields() {
if (
!cloudtentaclesForm.value.username.trim()
|| !cloudtentaclesForm.value.password.trim()
|| !cloudtentaclesForm.value.phone.trim()
!cloudtentaclesForm.value.username.trim() ||
!cloudtentaclesForm.value.password.trim() ||
!cloudtentaclesForm.value.phone.trim()
) {
cloudtentaclesResultError.value = '请先填写 cloudtentacles 账号、密码和手机号'
return false
@@ -157,7 +157,8 @@ export function useAdminCloudtentaclesPlatform() {
cloudtentaclesForm.value.token = response.data.session?.token || ''
showSuccess('cloudtentacles 履约平台配置已保存')
} catch (error) {
cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 配置保存失败'
cloudtentaclesResultError.value =
error instanceof Error ? error.message : 'cloudtentacles 配置保存失败'
showError(cloudtentaclesResultError.value)
} finally {
cloudtentaclesSaving.value = false
@@ -177,7 +178,8 @@ export function useAdminCloudtentaclesPlatform() {
cloudtentaclesSmsResult.value = response.data
showSuccess('cloudtentacles 短信验证码已发送')
} catch (error) {
cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 发送短信验证码失败'
cloudtentaclesResultError.value =
error instanceof Error ? error.message : 'cloudtentacles 发送短信验证码失败'
showError(cloudtentaclesResultError.value)
} finally {
cloudtentaclesSendingSms.value = false
@@ -209,7 +211,8 @@ export function useAdminCloudtentaclesPlatform() {
}
showSuccess('cloudtentacles 登录测试成功')
} catch (error) {
cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 登录测试失败'
cloudtentaclesResultError.value =
error instanceof Error ? error.message : 'cloudtentacles 登录测试失败'
showError(cloudtentaclesResultError.value)
} finally {
cloudtentaclesTesting.value = false
@@ -238,7 +241,9 @@ export function useAdminCloudtentaclesPlatform() {
token,
tokenMasked: response.data.session.tokenMasked,
baseUrl: response.data.baseUrl,
username: cloudtentaclesPersistedSession.value?.username || cloudtentaclesForm.value.username.trim(),
username:
cloudtentaclesPersistedSession.value?.username ||
cloudtentaclesForm.value.username.trim(),
phoneMasked: cloudtentaclesPersistedSession.value?.phoneMasked || '',
loggedInAt: response.data.loggedInAt,
deviceId: cloudtentaclesForm.value.deviceId.trim() || '-',
@@ -247,7 +252,8 @@ export function useAdminCloudtentaclesPlatform() {
}
showSuccess('cloudtentacles 会话校验成功')
} catch (error) {
cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 会话校验失败'
cloudtentaclesResultError.value =
error instanceof Error ? error.message : 'cloudtentacles 会话校验失败'
showError(cloudtentaclesResultError.value)
} finally {
cloudtentaclesValidating.value = false
@@ -275,7 +281,10 @@ export function useAdminCloudtentaclesPlatform() {
cloudtentaclesDebugResult.value = `${label}\n${JSON.stringify(data, null, 2)}`
}
async function runCloudtentaclesDebugAction(label: string, runner: () => Promise<{ data: unknown }>) {
async function runCloudtentaclesDebugAction(
label: string,
runner: () => Promise<{ data: unknown }>,
) {
cloudtentaclesDebugLoading.value = true
cloudtentaclesResultError.value = ''
@@ -293,49 +302,63 @@ export function useAdminCloudtentaclesPlatform() {
async function handleCloudtentaclesFetchAsset() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('查询余额', () => fetchAdminCloudtentaclesAsset(buildCloudtentaclesTokenPayload()))
await runCloudtentaclesDebugAction('查询余额', () =>
fetchAdminCloudtentaclesAsset(buildCloudtentaclesTokenPayload()),
)
}
async function handleCloudtentaclesFetchCategories() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('查询分类', () => fetchAdminCloudtentaclesCategories(buildCloudtentaclesTokenPayload()))
await runCloudtentaclesDebugAction('查询分类', () =>
fetchAdminCloudtentaclesCategories(buildCloudtentaclesTokenPayload()),
)
}
async function handleCloudtentaclesFetchSkuList() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('查询SKU列表', () => fetchAdminCloudtentaclesSkuList(buildCloudtentaclesTokenPayload()))
await runCloudtentaclesDebugAction('查询SKU列表', () =>
fetchAdminCloudtentaclesSkuList(buildCloudtentaclesTokenPayload()),
)
}
async function handleCloudtentaclesBuySku() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('购买SKU', () => buyAdminCloudtentaclesSku({
...buildCloudtentaclesTokenPayload(),
id: Number(cloudtentaclesForm.value.skuId || 0),
count: Number(cloudtentaclesForm.value.skuCount || 1),
}))
await runCloudtentaclesDebugAction('购买SKU', () =>
buyAdminCloudtentaclesSku({
...buildCloudtentaclesTokenPayload(),
id: Number(cloudtentaclesForm.value.skuId || 0),
count: Number(cloudtentaclesForm.value.skuCount || 1),
}),
)
}
async function handleCloudtentaclesUseSku() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('执行发货', () => useAdminCloudtentaclesSku({
...buildCloudtentaclesTokenPayload(),
id: Number(cloudtentaclesForm.value.skuId || 0),
virtualNumberId: Number(cloudtentaclesForm.value.vnId || 0),
phone: cloudtentaclesForm.value.phone.trim(),
}))
await runCloudtentaclesDebugAction('执行发货', () =>
useAdminCloudtentaclesSku({
...buildCloudtentaclesTokenPayload(),
id: Number(cloudtentaclesForm.value.skuId || 0),
virtualNumberId: Number(cloudtentaclesForm.value.vnId || 0),
phone: cloudtentaclesForm.value.phone.trim(),
}),
)
}
async function handleCloudtentaclesFetchKnapsack() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('查询背包', () => fetchAdminCloudtentaclesKnapsack(buildCloudtentaclesTokenPayload()))
await runCloudtentaclesDebugAction('查询背包', () =>
fetchAdminCloudtentaclesKnapsack(buildCloudtentaclesTokenPayload()),
)
}
async function handleCloudtentaclesFetchVnList() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('查询虚拟号列表', () => fetchAdminCloudtentaclesVnList({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
}))
await runCloudtentaclesDebugAction('查询虚拟号列表', () =>
fetchAdminCloudtentaclesVnList({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
}),
)
}
async function handleCloudtentaclesAppointVn() {
@@ -345,7 +368,7 @@ export function useAdminCloudtentaclesPlatform() {
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
})
const item = (response.data as { item?: { id?: number, phone?: string } }).item
const item = (response.data as { item?: { id?: number; phone?: string } }).item
if (item?.id) {
cloudtentaclesForm.value.vnId = Number(item.id)
}
@@ -358,11 +381,13 @@ export function useAdminCloudtentaclesPlatform() {
async function handleCloudtentaclesGenerateVnLoginCode() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('生成登录码', () => generateAdminCloudtentaclesVnLoginCode({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
}))
await runCloudtentaclesDebugAction('生成登录码', () =>
generateAdminCloudtentaclesVnLoginCode({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
}),
)
}
async function handleCloudtentaclesFetchVnCode() {
@@ -383,40 +408,48 @@ export function useAdminCloudtentaclesPlatform() {
async function handleCloudtentaclesVerifyVnCode() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('校验验证码', () => verifyAdminCloudtentaclesVnCode({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
code: cloudtentaclesForm.value.code.trim(),
}))
await runCloudtentaclesDebugAction('校验验证码', () =>
verifyAdminCloudtentaclesVnCode({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
code: cloudtentaclesForm.value.code.trim(),
}),
)
}
async function handleCloudtentaclesFetchBindUrl() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('获取兑换链接', () => fetchAdminCloudtentaclesBindUrl({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
}))
await runCloudtentaclesDebugAction('获取兑换链接', () =>
fetchAdminCloudtentaclesBindUrl({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
}),
)
}
async function handleCloudtentaclesBackVn() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('退还号码', () => backAdminCloudtentaclesVn({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
}))
await runCloudtentaclesDebugAction('退还号码', () =>
backAdminCloudtentaclesVn({
...buildCloudtentaclesTokenPayload(),
key: cloudtentaclesForm.value.vnKey.trim(),
id: Number(cloudtentaclesForm.value.vnId || 0),
}),
)
}
async function handleCloudtentaclesRunFullFlow() {
if (!ensureCloudtentaclesToken()) return
await runCloudtentaclesDebugAction('完整测试流程', () => runAdminCloudtentaclesFullFlow({
...buildCloudtentaclesTokenPayload(),
skuId: Number(cloudtentaclesForm.value.skuId || 0),
skuCount: Number(cloudtentaclesForm.value.skuCount || 1),
vnKey: cloudtentaclesForm.value.vnKey.trim(),
}))
await runCloudtentaclesDebugAction('完整测试流程', () =>
runAdminCloudtentaclesFullFlow({
...buildCloudtentaclesTokenPayload(),
skuId: Number(cloudtentaclesForm.value.skuId || 0),
skuCount: Number(cloudtentaclesForm.value.skuCount || 1),
vnKey: cloudtentaclesForm.value.vnKey.trim(),
}),
)
}
return {
@@ -40,9 +40,12 @@ export function useAdminKuaishouEticketPlatform() {
const kuaishouEticketDetailResult = ref<AdminKuaishouEticketDetailResult | null>(null)
const kuaishouEticketConsumeResult = ref<AdminKuaishouEticketConsumeResult | null>(null)
const activeKuaishouEticketShop = computed(() => (
kuaishouEticketShops.value.find((item) => item.id === kuaishouEticketForm.value.selectedShopId) || null
))
const activeKuaishouEticketShop = computed(
() =>
kuaishouEticketShops.value.find(
(item) => item.id === kuaishouEticketForm.value.selectedShopId,
) || null,
)
const kuaishouEticketStats = computed(() => ({
configuredShopCount: kuaishouEticketShops.value.length,
@@ -72,7 +75,9 @@ export function useAdminKuaishouEticketPlatform() {
storeId: '0',
}
kuaishouEticketForm.value.selectedShopId = kuaishouEticketShops.value[0]?.id || ''
expandedKuaishouEticketShopIds.value = kuaishouEticketShops.value[0]?.id ? [kuaishouEticketShops.value[0].id] : []
expandedKuaishouEticketShopIds.value = kuaishouEticketShops.value[0]?.id
? [kuaishouEticketShops.value[0].id]
: []
}
function buildKuaishouEticketConfigPayload(): AdminKuaishouEticketSourceConfig {
@@ -117,7 +122,9 @@ export function useAdminKuaishouEticketPlatform() {
}
function collapseKuaishouEticketShop(id: string) {
expandedKuaishouEticketShopIds.value = expandedKuaishouEticketShopIds.value.filter((item) => item !== id)
expandedKuaishouEticketShopIds.value = expandedKuaishouEticketShopIds.value.filter(
(item) => item !== id,
)
}
function toggleKuaishouEticketShop(id: string) {
@@ -185,7 +192,8 @@ export function useAdminKuaishouEticketPlatform() {
if (!selected) return false
if (!kuaishouEticketDetailResult.value?.detail) {
kuaishouEticketResultError.value = '请先查询核销信息,系统会自动带出 eTicketId、oid 和 formToken'
kuaishouEticketResultError.value =
'请先查询核销信息,系统会自动带出 eTicketId、oid 和 formToken'
return false
}
@@ -197,9 +205,11 @@ export function useAdminKuaishouEticketPlatform() {
return
}
kuaishouEticketForm.value.eTicketId = result.detail.eTicketId || kuaishouEticketForm.value.eTicketId
kuaishouEticketForm.value.eTicketId =
result.detail.eTicketId || kuaishouEticketForm.value.eTicketId
kuaishouEticketForm.value.oid = result.detail.oid || kuaishouEticketForm.value.oid
kuaishouEticketForm.value.formToken = result.detail.formToken || kuaishouEticketForm.value.formToken
kuaishouEticketForm.value.formToken =
result.detail.formToken || kuaishouEticketForm.value.formToken
const leftCount = Number(result.detail.leftCount || 0)
if (leftCount > 0) {
@@ -230,9 +240,12 @@ export function useAdminKuaishouEticketPlatform() {
if (!kuaishouEticketForm.value.selectedShopId) {
setKuaishouEticketDebugShop(shop.id)
}
showSuccess(`已识别店铺:${response.data.shop.kshopName || response.data.shop.shopId || '未知店铺'}`)
showSuccess(
`已识别店铺:${response.data.shop.kshopName || response.data.shop.shopId || '未知店铺'}`,
)
} catch (error) {
kuaishouEticketResultError.value = error instanceof Error ? error.message : '读取快手小店店铺信息失败'
kuaishouEticketResultError.value =
error instanceof Error ? error.message : '读取快手小店店铺信息失败'
showError(kuaishouEticketResultError.value)
} finally {
kuaishouEticketResolvingShopId.value = ''
@@ -244,22 +257,27 @@ export function useAdminKuaishouEticketPlatform() {
kuaishouEticketResultError.value = ''
try {
const response = await saveAdminKuaishouEticketSourceConfig(buildKuaishouEticketConfigPayload())
const response = await saveAdminKuaishouEticketSourceConfig(
buildKuaishouEticketConfigPayload(),
)
kuaishouEticketFilePath.value = response.data.filePath
kuaishouEticketForm.value.baseUrl = response.data.source.baseUrl || 'https://s.kwaixiaodian.com'
kuaishouEticketForm.value.baseUrl =
response.data.source.baseUrl || 'https://s.kwaixiaodian.com'
const previousSelectedShopId = activeKuaishouEticketShop.value?.shopId || ''
kuaishouEticketShops.value = response.data.source.shops.map(mapEditableKuaishouEticketShop)
kuaishouEticketForm.value.selectedShopId = (
kuaishouEticketShops.value.find((item) => item.shopId && item.shopId === previousSelectedShopId)?.id
|| kuaishouEticketShops.value[0]?.id
|| ''
)
kuaishouEticketForm.value.selectedShopId =
kuaishouEticketShops.value.find(
(item) => item.shopId && item.shopId === previousSelectedShopId,
)?.id ||
kuaishouEticketShops.value[0]?.id ||
''
expandedKuaishouEticketShopIds.value = kuaishouEticketForm.value.selectedShopId
? [kuaishouEticketForm.value.selectedShopId]
: []
showSuccess('快手小店核销配置已保存')
} catch (error) {
kuaishouEticketResultError.value = error instanceof Error ? error.message : '快手小店核销配置保存失败'
kuaishouEticketResultError.value =
error instanceof Error ? error.message : '快手小店核销配置保存失败'
showError(kuaishouEticketResultError.value)
} finally {
kuaishouEticketSaving.value = false
@@ -285,9 +303,12 @@ export function useAdminKuaishouEticketPlatform() {
})
kuaishouEticketDetailResult.value = response.data
applyKuaishouEticketDetailToForm(response.data)
showSuccess(response.data.ok ? '核销信息查询成功' : (response.data.errorMessage || '核销信息已返回'))
showSuccess(
response.data.ok ? '核销信息查询成功' : response.data.errorMessage || '核销信息已返回',
)
} catch (error) {
kuaishouEticketResultError.value = error instanceof Error ? error.message : '查询快手小店核销信息失败'
kuaishouEticketResultError.value =
error instanceof Error ? error.message : '查询快手小店核销信息失败'
showError(kuaishouEticketResultError.value)
} finally {
kuaishouEticketDetailing.value = false
@@ -316,9 +337,12 @@ export function useAdminKuaishouEticketPlatform() {
storeId: kuaishouEticketForm.value.storeId.trim() || '0',
})
kuaishouEticketConsumeResult.value = response.data
showSuccess(response.data.ok ? '快手小店核销成功' : (response.data.errorMessage || '核销请求已执行'))
showSuccess(
response.data.ok ? '快手小店核销成功' : response.data.errorMessage || '核销请求已执行',
)
} catch (error) {
kuaishouEticketResultError.value = error instanceof Error ? error.message : '执行快手小店核销失败'
kuaishouEticketResultError.value =
error instanceof Error ? error.message : '执行快手小店核销失败'
showError(kuaishouEticketResultError.value)
} finally {
kuaishouEticketConsuming.value = false
@@ -370,7 +394,9 @@ function createEmptyKuaishouEticketShop(): EditableKuaishouEticketShop {
}
}
function mapEditableKuaishouEticketShop(item: AdminKuaishouEticketShopConfigItem): EditableKuaishouEticketShop {
function mapEditableKuaishouEticketShop(
item: AdminKuaishouEticketShopConfigItem,
): EditableKuaishouEticketShop {
return {
id: crypto.randomUUID(),
shopId: item.shopId,
@@ -21,8 +21,12 @@ export function useAdminNinetyonePlatform() {
})
const ninetyoneStats = computed(() => {
const pendingCount = ninetyoneOrders.value.items.filter((item) => item.orderStatus === 'pending_config').length
const failedCount = ninetyoneOrders.value.items.filter((item) => item.orderStatus === 'manual_failed').length
const pendingCount = ninetyoneOrders.value.items.filter(
(item) => item.orderStatus === 'pending_config',
).length
const failedCount = ninetyoneOrders.value.items.filter(
(item) => item.orderStatus === 'manual_failed',
).length
const taskReadyCount = ninetyoneOrders.value.items.filter((item) => item.taskCount > 0).length
return {
@@ -76,11 +80,15 @@ export function useAdminNinetyonePlatform() {
async function handleNinetyoneFailOrder(item: AdminNinetyoneOrderItem) {
try {
await showConfirm(`确认把 91卡券订单 ${item.orderNo} 标记为无法履约吗?查询接口会返回失败状态。`, '确认标记失败', {
type: 'warning',
confirmButtonText: '标记失败',
cancelButtonText: '取消',
})
await showConfirm(
`确认把 91卡券订单 ${item.orderNo} 标记为无法履约吗?查询接口会返回失败状态。`,
'确认标记失败',
{
type: 'warning',
confirmButtonText: '标记失败',
cancelButtonText: '取消',
},
)
} catch {
return
}
@@ -42,26 +42,29 @@ export function useAdminNotificationPlatform() {
const scheduledJobRunningId = ref('')
const notificationStats = computed(() => {
const enabledRecipients = notificationRecipients.value.filter((item) => item.enabled && item.deviceKey.trim())
const enabledRecipients = notificationRecipients.value.filter(
(item) => item.enabled && item.deviceKey.trim(),
)
return {
configuredRecipientCount: notificationRecipients.value.length,
enabledRecipientCount: enabledRecipients.length,
barkReady: notificationForm.value.enabled && notificationForm.value.barkEnabled && enabledRecipients.length > 0,
barkReady:
notificationForm.value.enabled &&
notificationForm.value.barkEnabled &&
enabledRecipients.length > 0,
lastSuccessCount: notificationTestResult.value?.successCount || 0,
lastFailedCount: notificationTestResult.value?.failedCount || 0,
scheduledJobEnabledCount: scheduledJobs.value.filter((item) => item.enabled).length,
}
})
function hydrateNotificationConfig(data: {
filePath: string
source: AdminNotificationConfig
}) {
function hydrateNotificationConfig(data: { filePath: string; source: AdminNotificationConfig }) {
notificationFilePath.value = data.filePath
notificationForm.value.enabled = data.source.enabled !== false
notificationForm.value.barkEnabled = data.source.channels.bark.enabled !== false
notificationForm.value.barkServerUrl = data.source.channels.bark.serverUrl || 'https://api.day.app'
notificationForm.value.barkServerUrl =
data.source.channels.bark.serverUrl || 'https://api.day.app'
notificationRecipients.value = data.source.channels.bark.recipients.map((item) => ({
id: item.id || crypto.randomUUID(),
name: item.name,
@@ -146,7 +149,8 @@ export function useAdminNotificationPlatform() {
hydrateNotificationConfig(response.data)
showSuccess('内部通知配置已保存')
} catch (error) {
notificationResultError.value = error instanceof Error ? error.message : '内部通知配置保存失败'
notificationResultError.value =
error instanceof Error ? error.message : '内部通知配置保存失败'
showError(notificationResultError.value)
} finally {
notificationSaving.value = false
@@ -191,7 +195,8 @@ export function useAdminNotificationPlatform() {
hydrateScheduledJobsConfig(response.data)
showSuccess('监控任务配置已保存')
} catch (error) {
notificationResultError.value = error instanceof Error ? error.message : '监控任务配置保存失败'
notificationResultError.value =
error instanceof Error ? error.message : '监控任务配置保存失败'
showError(notificationResultError.value)
} finally {
scheduledJobsSaving.value = false
@@ -33,7 +33,7 @@
gap: var(--space-4);
background:
radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 26%),
linear-gradient(135deg, rgba(255,255,255,.98), rgba(239,246,255,.96));
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.96));
}
.hero-copy {
@@ -58,7 +58,7 @@
color: var(--color-primary);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: .08em;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -72,7 +72,7 @@
padding: 14px var(--space-4);
border-radius: 18px;
border: 1px solid var(--border-default);
background: rgba(255,255,255,.82);
background: rgba(255, 255, 255, 0.82);
}
.hero-metric span {
@@ -89,10 +89,18 @@
word-break: break-word;
}
.hero-metric--blue { box-shadow: inset 0 0 0 1px rgba(59,130,246,.08); }
.hero-metric--green { box-shadow: inset 0 0 0 1px rgba(16,185,129,.08); }
.hero-metric--amber { box-shadow: inset 0 0 0 1px rgba(245,158,11,.08); }
.hero-metric--slate { box-shadow: inset 0 0 0 1px rgba(100,116,139,.08); }
.hero-metric--blue {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.08);
}
.hero-metric--green {
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.08);
}
.hero-metric--amber {
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.08);
}
.hero-metric--slate {
box-shadow: inset 0 0 0 1px rgba(100, 116, 139, 0.08);
}
.hero-inline,
.hero-link-row {
@@ -126,7 +134,7 @@
margin-top: 14px;
padding: 14px var(--space-4);
border-radius: var(--radius-md);
background: rgba(248,250,252,.92);
background: rgba(248, 250, 252, 0.92);
border: 1px solid var(--border-default);
}
@@ -174,7 +182,7 @@
padding: 14px var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: rgba(248,250,252,.92);
background: rgba(248, 250, 252, 0.92);
}
.redeem-summary-card span,
@@ -260,7 +268,7 @@
margin-top: var(--space-4);
padding: 14px;
border-radius: var(--radius-md);
background: rgba(248,250,252,.9);
background: rgba(248, 250, 252, 0.9);
}
.manual-summary p {
@@ -277,7 +285,7 @@
gap: var(--space-4);
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 28%),
linear-gradient(180deg, rgba(255,255,255,.96), rgba(248,250,252,.96));
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
}
.flow-card-head {
@@ -304,13 +312,13 @@
padding: var(--space-4);
border-radius: 18px;
border: 1px solid var(--border-default);
background: rgba(248,250,252,.9);
background: rgba(248, 250, 252, 0.9);
}
.flow-block--spotlight {
background:
linear-gradient(160deg, rgba(255,255,255,.98), rgba(239,246,255,.95)),
rgba(248,250,252,.9);
linear-gradient(160deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.95)),
rgba(248, 250, 252, 0.9);
}
.flow-block h4 {
@@ -386,7 +394,7 @@
.checklist-item {
padding: 14px var(--space-4);
border-radius: var(--radius-md);
background: rgba(255,255,255,.84);
background: rgba(255, 255, 255, 0.84);
border: 1px solid var(--border-default);
}
@@ -418,7 +426,7 @@
.role-hero div {
padding: 14px var(--space-4);
border-radius: var(--radius-md);
background: rgba(255,255,255,.88);
background: rgba(255, 255, 255, 0.88);
border: 1px solid var(--border-default);
}
@@ -471,7 +479,7 @@
width: min(100%, 720px);
border-radius: 18px;
border: 1px solid var(--border-default);
box-shadow: 0 20px 48px rgba(15,23,42,.08);
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
}
@media (max-width: 900px) {
@@ -22,7 +22,15 @@ import {
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAutoDeliveryReason, formatAutoDeliveryTrigger, formatKuaishouRoleInfoLabel, formatRedeemOutcomeLabel, formatRedeemResolutionStatus, formatTaskEventPayload, formatTaskEventType } from '@/utils/admin-display'
import {
formatAutoDeliveryReason,
formatAutoDeliveryTrigger,
formatKuaishouRoleInfoLabel,
formatRedeemOutcomeLabel,
formatRedeemResolutionStatus,
formatTaskEventPayload,
formatTaskEventType,
} from '@/utils/admin-display'
import { formatAdminDateTime } from '@/utils/admin-time'
const route = useRoute()
@@ -39,11 +47,15 @@ const manualDispatchForm = reactive({
})
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
const canCloseTasks = computed(() => hasAdminRole('support'))
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
const canViewSensitiveTaskData = computed(() =>
Boolean(detail.value?.operations.canViewSensitiveTaskData),
)
const kuaishouCloudFlow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
const kuaishouCloudRole = computed(() => kuaishouCloudFlow.value?.role || null)
const kuaishouCloudCollapsePanels = ref(['claim', 'ticket', 'cloud'])
const isKuaishouCloudTask = computed(() => detail.value?.task.executorKey === 'kuaishou_ct_assisted')
const isKuaishouCloudTask = computed(
() => detail.value?.task.executorKey === 'kuaishou_ct_assisted',
)
const summaryCards = computed(() => {
if (!detail.value) {
return []
@@ -114,9 +126,10 @@ const kuaishouCloudChecklist = computed(() => {
key: 'role',
label: '角色识别',
status: role?.status || 'pending',
detail: role?.name || role?.rid
? `${role?.name || '-'} / ${role?.rid || '-'}`
: '客户绑定后刷新角色信息',
detail:
role?.name || role?.rid
? `${role?.name || '-'} / ${role?.rid || '-'}`
: '客户绑定后刷新角色信息',
},
{
key: 'dispatch',
@@ -140,7 +153,7 @@ const kuaishouCloudChecklist = computed(() => {
status: flow.consume.status || 'pending',
detail: flow.consume.consumedAt
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成核销`
: (flow.consume.errorMessage || '等待退号后核销收口'),
: flow.consume.errorMessage || '等待退号后核销收口',
},
]
})
@@ -238,7 +251,8 @@ function clearScreenshotPreview() {
function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
manualDispatchForm.deliveryReference = taskDetail.manualDispatch?.deliveryReference || ''
manualDispatchForm.deliveredCredential = taskDetail.manualDispatch?.deliveredCredential || ''
manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
manualDispatchForm.resultMessage =
taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
}
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
@@ -247,17 +261,19 @@ async function submitManualDispatch(outcome: 'delivered' | 'failed') {
}
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
const confirmText = outcome === 'failed'
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放`
const confirmText =
outcome === 'failed'
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因`
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
await runAction(
() => completeAdminTaskManualDispatch(detail.value!.task.taskId, {
outcome,
resultMessage: manualDispatchForm.resultMessage,
deliveryReference: manualDispatchForm.deliveryReference,
deliveredCredential: manualDispatchForm.deliveredCredential,
}),
() =>
completeAdminTaskManualDispatch(detail.value!.task.taskId, {
outcome,
resultMessage: manualDispatchForm.resultMessage,
deliveryReference: manualDispatchForm.deliveryReference,
deliveredCredential: manualDispatchForm.deliveredCredential,
}),
actionLabel,
confirmText,
)
@@ -365,10 +381,7 @@ onBeforeUnmount(clearScreenshotPreview)
<template>
<section class="admin-panel">
<AdminPageHeader
title="任务详情"
description="查看任务全量信息并执行人工操作。"
/>
<AdminPageHeader title="任务详情" description="查看任务全量信息并执行人工操作。" />
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
@@ -380,9 +393,9 @@ onBeforeUnmount(clearScreenshotPreview)
<span class="hero-eyebrow">{{ detail.task.executorKey || 'task' }}</span>
<h2>{{ detail.task.taskNo }}</h2>
<p>
订单 {{ detail.order?.platformOrderId || '-' }} ·
商品 {{ detail.orderItem?.skuName || detail.orderItem?.skuCode || '-' }} ·
店铺 {{ detail.order?.shopName || detail.order?.shopId || '-' }}
订单 {{ detail.order?.platformOrderId || '-' }} · 商品
{{ detail.orderItem?.skuName || detail.orderItem?.skuCode || '-' }} · 店铺
{{ detail.order?.shopName || detail.order?.shopId || '-' }}
</p>
</div>
<div class="hero-meta-grid">
@@ -412,9 +425,20 @@ onBeforeUnmount(clearScreenshotPreview)
<section v-else class="info-card">
<h2>{{ detail.task.taskNo }}</h2>
<p>状态<AdminStatusTag :status="detail.task.status" /> · 订单{{ detail.order?.platformOrderId || '-' }}</p>
<p>执行器{{ detail.task.executorKey || '-' }} · 履约<AdminStatusTag :status="detail.task.deliveryStatus" /></p>
<p>系统绑定<AdminStatusTag :status="detail.task.systemBindingStatus" /> · 完整绑定<AdminStatusTag :status="detail.task.userBindingStatus" /></p>
<p>
状态<AdminStatusTag :status="detail.task.status" /> · 订单{{
detail.order?.platformOrderId || '-'
}}
</p>
<p>
执行器{{ detail.task.executorKey || '-' }} · 履约<AdminStatusTag
:status="detail.task.deliveryStatus"
/>
</p>
<p>
系统绑定<AdminStatusTag :status="detail.task.systemBindingStatus" /> ·
完整绑定<AdminStatusTag :status="detail.task.userBindingStatus" />
</p>
<p>角色{{ detail.task.roleName || '-' }} / {{ detail.task.roleId || '-' }}</p>
<p>最后错误{{ detail.task.lastError || '-' }}</p>
<p v-if="claimUrl">
@@ -431,7 +455,13 @@ onBeforeUnmount(clearScreenshotPreview)
:loading="actionLoading"
round
type="primary"
@click="runAction(() => retryAdminTask(detail!.task.taskId), '任务已重试', `确认重试任务 ${detail!.task.taskNo} 吗?`)"
@click="
runAction(
() => retryAdminTask(detail!.task.taskId),
'任务已重试',
`确认重试任务 ${detail!.task.taskNo} 吗?`,
)
"
>
重试任务
</el-button>
@@ -440,7 +470,13 @@ onBeforeUnmount(clearScreenshotPreview)
:disabled="!detail.operations.canRegenerateClaimLink"
:loading="actionLoading"
round
@click="runAction(() => regenerateAdminTaskClaimLink(detail!.task.taskId), '领取链接已重新生成', `确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效。`)"
@click="
runAction(
() => regenerateAdminTaskClaimLink(detail!.task.taskId),
'领取链接已重新生成',
`确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效。`,
)
"
>
重发链接
</el-button>
@@ -449,11 +485,13 @@ onBeforeUnmount(clearScreenshotPreview)
:loading="actionLoading"
round
type="warning"
@click="runAction(
() => confirmAdminTaskAssistedRole(detail!.task.taskId),
'角色已确认',
`确认以客服身份锁定任务 ${detail!.task.taskNo} 当前识别到的角色信息吗?`
)"
@click="
runAction(
() => confirmAdminTaskAssistedRole(detail!.task.taskId),
'角色已确认',
`确认以客服身份锁定任务 ${detail!.task.taskNo} 当前识别到的角色信息吗?`,
)
"
>
客服确认角色
</el-button>
@@ -462,11 +500,13 @@ onBeforeUnmount(clearScreenshotPreview)
:loading="actionLoading"
round
type="success"
@click="runAction(
() => redeemAdminTaskAssisted(detail!.task.taskId),
'兑换任务已启动',
`确认开始执行任务 ${detail!.task.taskNo} 的自动兑换吗?`
)"
@click="
runAction(
() => redeemAdminTaskAssisted(detail!.task.taskId),
'兑换任务已启动',
`确认开始执行任务 ${detail!.task.taskNo} 的自动兑换吗?`,
)
"
>
客服开始兑换
</el-button>
@@ -502,7 +542,13 @@ onBeforeUnmount(clearScreenshotPreview)
:disabled="!detail.operations.canMarkManualReview"
:loading="actionLoading"
round
@click="runAction(() => markAdminTaskManualReview(detail!.task.taskId), '任务已转人工处理', `确认将任务 ${detail!.task.taskNo} 转为人工处理吗?`)"
@click="
runAction(
() => markAdminTaskManualReview(detail!.task.taskId),
'任务已转人工处理',
`确认将任务 ${detail!.task.taskNo} 转为人工处理吗?`,
)
"
>
转人工
</el-button>
@@ -531,7 +577,13 @@ onBeforeUnmount(clearScreenshotPreview)
:loading="actionLoading"
round
type="danger"
@click="runAction(() => closeAdminTask(detail!.task.taskId), '任务已关闭', `确认关闭任务 ${detail!.task.taskNo} 吗?关闭后不会自动继续推进。`)"
@click="
runAction(
() => closeAdminTask(detail!.task.taskId),
'任务已关闭',
`确认关闭任务 ${detail!.task.taskNo} 吗?关闭后不会自动继续推进。`,
)
"
>
关闭任务
</el-button>
@@ -541,32 +593,93 @@ onBeforeUnmount(clearScreenshotPreview)
<h3>任务信息</h3>
<table class="data-table">
<tbody>
<tr><th>商品</th><td>{{ detail.orderItem?.skuName || '-' }}</td></tr>
<tr><th>SKU</th><td>{{ detail.orderItem?.skuCode || '-' }}</td></tr>
<tr><th>任务执行器</th><td>{{ detail.task.executorKey || '-' }}</td></tr>
<tr><th>履约状态</th><td><AdminStatusTag :status="detail.task.deliveryStatus || ''" /></td></tr>
<tr><th>结果代码</th><td>{{ detail.task.resultCode || '-' }}</td></tr>
<tr><th>结果说明</th><td>{{ detail.task.resultMessage || '-' }}</td></tr>
<tr v-if="canViewSensitiveTaskData"><th>库存凭据</th><td>{{ detail.inventory?.displayValue || '-' }}</td></tr>
<tr v-if="canViewSensitiveTaskData"><th>凭据类型</th><td>{{ detail.inventory?.credentialType || '-' }}</td></tr>
<tr v-if="canViewSensitiveTaskData"><th>库存状态</th><td><AdminStatusTag :status="detail.inventory?.status || ''" /></td></tr>
<tr v-if="canViewSensitiveTaskData"><th>Claim Token</th><td>{{ detail.claimToken?.token || '-' }}</td></tr>
<tr v-else><th>库存 / Token</th><td>客服账号不可查看库存凭据与原始领取 Token</td></tr>
<tr><th>Token 状态</th><td><AdminStatusTag :status="detail.claimToken?.status || ''" /></td></tr>
<tr><th>Token 过期</th><td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td></tr>
<tr>
<th>商品</th>
<td>{{ detail.orderItem?.skuName || '-' }}</td>
</tr>
<tr>
<th>SKU</th>
<td>{{ detail.orderItem?.skuCode || '-' }}</td>
</tr>
<tr>
<th>任务执行器</th>
<td>{{ detail.task.executorKey || '-' }}</td>
</tr>
<tr>
<th>履约状态</th>
<td><AdminStatusTag :status="detail.task.deliveryStatus || ''" /></td>
</tr>
<tr>
<th>结果代码</th>
<td>{{ detail.task.resultCode || '-' }}</td>
</tr>
<tr>
<th>结果说明</th>
<td>{{ detail.task.resultMessage || '-' }}</td>
</tr>
<tr v-if="canViewSensitiveTaskData">
<th>库存凭据</th>
<td>{{ detail.inventory?.displayValue || '-' }}</td>
</tr>
<tr v-if="canViewSensitiveTaskData">
<th>凭据类型</th>
<td>{{ detail.inventory?.credentialType || '-' }}</td>
</tr>
<tr v-if="canViewSensitiveTaskData">
<th>库存状态</th>
<td><AdminStatusTag :status="detail.inventory?.status || ''" /></td>
</tr>
<tr v-if="canViewSensitiveTaskData">
<th>Claim Token</th>
<td>{{ detail.claimToken?.token || '-' }}</td>
</tr>
<tr v-else>
<th>库存 / Token</th>
<td>客服账号不可查看库存凭据与原始领取 Token</td>
</tr>
<tr>
<th>Token 状态</th>
<td><AdminStatusTag :status="detail.claimToken?.status || ''" /></td>
</tr>
<tr>
<th>Token 过期</th>
<td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td>
</tr>
<tr>
<th>领取链接</th>
<td>
<a v-if="claimUrl" class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
<a
v-if="claimUrl"
class="claim-link"
:href="claimUrl"
target="_blank"
rel="noreferrer"
>{{ claimUrl }}</a
>
<span v-else-if="claimLinkInvalid">已失效</span>
<span v-else>-</span>
</td>
</tr>
<tr><th>浏览器会话</th><td>{{ detail.task.browserSessionId || '-' }}</td></tr>
<tr><th>用户打开链接</th><td>{{ formatAdminDateTime(detail.task.claimedAt) }}</td></tr>
<tr><th>用户提交绑定</th><td>{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}</td></tr>
<tr><th>完整绑定成功</th><td>{{ formatAdminDateTime(detail.task.redeemedAt) }}</td></tr>
<tr><th>截图路径</th><td>{{ detail.task.screenshotPath || '-' }}</td></tr>
<tr>
<th>浏览器会话</th>
<td>{{ detail.task.browserSessionId || '-' }}</td>
</tr>
<tr>
<th>用户打开链接</th>
<td>{{ formatAdminDateTime(detail.task.claimedAt) }}</td>
</tr>
<tr>
<th>用户提交绑定</th>
<td>{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}</td>
</tr>
<tr>
<th>完整绑定成功</th>
<td>{{ formatAdminDateTime(detail.task.redeemedAt) }}</td>
</tr>
<tr>
<th>截图路径</th>
<td>{{ detail.task.screenshotPath || '-' }}</td>
</tr>
</tbody>
</table>
</section>
@@ -575,7 +688,10 @@ onBeforeUnmount(clearScreenshotPreview)
<div class="flow-card-head">
<div>
<h3>快手 Cloud 履约面板</h3>
<p class="section-copy">这是一套独立的快手 Cloud 任务面板优先展示角色信息与当前履约推进点次级明细可按需展开</p>
<p class="section-copy">
这是一套独立的快手 Cloud
任务面板优先展示角色信息与当前履约推进点次级明细可按需展开
</p>
</div>
<div class="flow-head-actions">
<el-button
@@ -637,7 +753,9 @@ onBeforeUnmount(clearScreenshotPreview)
<div class="spotlight-head">
<div>
<h4>角色信息</h4>
<p class="flow-copy">角色信息来自 cloudtentacles `vn/bind_info`客服可在客户绑定后手动刷新</p>
<p class="flow-copy">
角色信息来自 cloudtentacles `vn/bind_info`客服可在客户绑定后手动刷新
</p>
</div>
<div class="spotlight-actions">
<AdminStatusTag :status="kuaishouCloudRole?.status || 'pending'" />
@@ -666,8 +784,13 @@ onBeforeUnmount(clearScreenshotPreview)
<strong>{{ formatAdminDateTime(kuaishouCloudRole?.refreshedAt) }}</strong>
</div>
</div>
<p v-if="kuaishouCloudRole?.errorMessage" class="role-warning">{{ kuaishouCloudRole.errorMessage }}</p>
<div v-if="kuaishouRoleInfoEntries.length > 0" class="flow-meta-list flow-meta-list--dense">
<p v-if="kuaishouCloudRole?.errorMessage" class="role-warning">
{{ kuaishouCloudRole.errorMessage }}
</p>
<div
v-if="kuaishouRoleInfoEntries.length > 0"
class="flow-meta-list flow-meta-list--dense"
>
<div v-for="entry in kuaishouRoleInfoEntries" :key="entry.key">
<span>{{ entry.label }}</span>
<strong>{{ entry.value }}</strong>
@@ -677,7 +800,9 @@ onBeforeUnmount(clearScreenshotPreview)
<article class="flow-block flow-block--checklist">
<h4>流程检查</h4>
<p class="flow-copy">客服可以先看这里快速判断卡在哪一步再决定是刷新角色准备资源发货还是退号</p>
<p class="flow-copy">
客服可以先看这里快速判断卡在哪一步再决定是刷新角色准备资源发货还是退号
</p>
<div class="checklist-grid">
<div v-for="item in kuaishouCloudChecklist" :key="item.key" class="checklist-item">
<div class="checklist-item-head">
@@ -693,57 +818,158 @@ onBeforeUnmount(clearScreenshotPreview)
<el-collapse v-model="kuaishouCloudCollapsePanels" class="flow-collapse">
<el-collapse-item name="claim" title="客户领取与绑定">
<div class="flow-meta-list flow-meta-list--two-column">
<div><span>客户链接</span><strong>{{ claimUrl || '-' }}</strong></div>
<div><span>绑定链接</span><strong>{{ kuaishouCloudFlow.binding.bindUrl || '-' }}</strong></div>
<div><span>Token 状态</span><strong>{{ detail.claimToken?.status || '-' }}</strong></div>
<div><span>准备时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.binding.bindPreparedAt) }}</strong></div>
<div><span>首次提交</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}</strong></div>
<div><span>校验时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.verifiedAt) }}</strong></div>
<div><span>虚拟号 ID</span><strong>{{ kuaishouCloudFlow.binding.vnId || '-' }}</strong></div>
<div><span>虚拟号码</span><strong>{{ kuaishouCloudFlow.binding.vnPhone || '-' }}</strong></div>
<div>
<span>客户链接</span><strong>{{ claimUrl || '-' }}</strong>
</div>
<div>
<span>绑定链接</span><strong>{{ kuaishouCloudFlow.binding.bindUrl || '-' }}</strong>
</div>
<div>
<span>Token 状态</span><strong>{{ detail.claimToken?.status || '-' }}</strong>
</div>
<div>
<span>准备时间</span
><strong>{{
formatAdminDateTime(kuaishouCloudFlow.binding.bindPreparedAt)
}}</strong>
</div>
<div>
<span>首次提交</span
><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}</strong>
</div>
<div>
<span>校验时间</span
><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.verifiedAt) }}</strong>
</div>
<div>
<span>虚拟号 ID</span><strong>{{ kuaishouCloudFlow.binding.vnId || '-' }}</strong>
</div>
<div>
<span>虚拟号码</span><strong>{{ kuaishouCloudFlow.binding.vnPhone || '-' }}</strong>
</div>
</div>
<p class="manual-hint">客户会先在领取页查看图文指引并提交核销码只有校验通过后才会自动跳转绑定链接</p>
<p class="manual-hint">
客户会先在领取页查看图文指引并提交核销码只有校验通过后才会自动跳转绑定链接
</p>
</el-collapse-item>
<el-collapse-item name="ticket" title="核销与商品信息">
<div class="flow-meta-list flow-meta-list--two-column">
<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>
<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>
<p class="manual-hint">
"确认绑定完成并发货"已经合并为一个按钮只有核销码通过校验后才能继续
</p>
</el-collapse-item>
<el-collapse-item name="cloud" title="Cloud 资源与发货">
<div class="flow-meta-list flow-meta-list--two-column">
<div><span>cloudSourceKey</span><strong>{{ kuaishouCloudFlow.binding.cloudSourceKey || '-' }}</strong></div>
<div><span>cloud SKU</span><strong>{{ kuaishouCloudFlow.binding.skuId ? `${kuaishouCloudFlow.binding.skuId} / ${kuaishouCloudFlow.binding.skuName || '-'}` : '-' }}</strong></div>
<div><span>VN Key</span><strong>{{ kuaishouCloudFlow.binding.vnKey || '-' }}</strong></div>
<div><span>自动购买</span><strong>{{ kuaishouCloudFlow.purchase.autoBuyEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>背包命中</span><strong>{{ kuaishouCloudFlow.purchase.usedKnapsack ? '是' : '否' }}</strong></div>
<div><span>触发购买</span><strong>{{ kuaishouCloudFlow.purchase.purchaseTriggered ? '是' : '否' }}</strong></div>
<div><span>购买时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.purchase.purchaseAt) }}</strong></div>
<div><span>发货时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.dispatch.dispatchAt) }}</strong></div>
<div><span>发送类型</span><strong>{{ kuaishouCloudFlow.dispatch.sendType || '-' }}</strong></div>
<div><span>结果说明</span><strong>{{ kuaishouCloudFlow.dispatch.note || detail.task.resultMessage || '-' }}</strong></div>
<div>
<span>cloudSourceKey</span
><strong>{{ kuaishouCloudFlow.binding.cloudSourceKey || '-' }}</strong>
</div>
<div>
<span>cloud SKU</span
><strong>{{
kuaishouCloudFlow.binding.skuId
? `${kuaishouCloudFlow.binding.skuId} / ${kuaishouCloudFlow.binding.skuName || '-'}`
: '-'
}}</strong>
</div>
<div>
<span>VN Key</span><strong>{{ kuaishouCloudFlow.binding.vnKey || '-' }}</strong>
</div>
<div>
<span>自动购买</span
><strong>{{ kuaishouCloudFlow.purchase.autoBuyEnabled ? '开启' : '关闭' }}</strong>
</div>
<div>
<span>背包命中</span
><strong>{{ kuaishouCloudFlow.purchase.usedKnapsack ? '是' : '否' }}</strong>
</div>
<div>
<span>触发购买</span
><strong>{{ kuaishouCloudFlow.purchase.purchaseTriggered ? '是' : '否' }}</strong>
</div>
<div>
<span>购买时间</span
><strong>{{ formatAdminDateTime(kuaishouCloudFlow.purchase.purchaseAt) }}</strong>
</div>
<div>
<span>发货时间</span
><strong>{{ formatAdminDateTime(kuaishouCloudFlow.dispatch.dispatchAt) }}</strong>
</div>
<div>
<span>发送类型</span
><strong>{{ kuaishouCloudFlow.dispatch.sendType || '-' }}</strong>
</div>
<div>
<span>结果说明</span
><strong>{{
kuaishouCloudFlow.dispatch.note || detail.task.resultMessage || '-'
}}</strong>
</div>
</div>
</el-collapse-item>
<el-collapse-item name="closure" title="流程收口">
<div class="flow-meta-list flow-meta-list--two-column">
<div><span>退号时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.returnNumber.returnedAt) }}</strong></div>
<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>{{ kuaishouCloudFlow.consume.shopName || '-' }}</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>
<span>退号时间</span
><strong>{{
formatAdminDateTime(kuaishouCloudFlow.returnNumber.returnedAt)
}}</strong>
</div>
<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>{{ kuaishouCloudFlow.consume.shopName || '-' }}</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>
</el-collapse-item>
<el-collapse-item v-if="kuaishouRoleInfoEntries.length > 0" name="raw" title="角色原始字段">
<el-collapse-item
v-if="kuaishouRoleInfoEntries.length > 0"
name="raw"
title="角色原始字段"
>
<div class="flow-meta-list flow-meta-list--two-column">
<div v-for="entry in kuaishouRoleInfoEntries" :key="`raw-${entry.key}`">
<span>{{ entry.label }}</span>
@@ -759,7 +985,9 @@ onBeforeUnmount(clearScreenshotPreview)
class="info-card"
>
<h3>咸鱼自动发货</h3>
<p class="section-copy">这里展示这条任务最近一次触发咸鱼自动发货的状态原因和接口回执摘要</p>
<p class="section-copy">
这里展示这条任务最近一次触发咸鱼自动发货的状态原因和接口回执摘要
</p>
<div class="redeem-summary-grid">
<article class="redeem-summary-card">
@@ -770,7 +998,9 @@ onBeforeUnmount(clearScreenshotPreview)
</article>
<article class="redeem-summary-card">
<span>触发来源</span>
<strong>{{ formatAutoDeliveryTrigger(detail.task.agisoAutoDelivery?.trigger || '') }}</strong>
<strong>{{
formatAutoDeliveryTrigger(detail.task.agisoAutoDelivery?.trigger || '')
}}</strong>
</article>
<article class="redeem-summary-card">
<span>最近执行</span>
@@ -779,22 +1009,35 @@ onBeforeUnmount(clearScreenshotPreview)
<article class="redeem-summary-card">
<span>接口回执</span>
<strong>
{{ detail.task.agisoAutoDelivery?.requestId || (detail.task.agisoAutoDelivery?.responseStatus ? `HTTP ${detail.task.agisoAutoDelivery.responseStatus}` : '-') }}
{{
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?.reason">
原因{{ formatAutoDeliveryReason(detail.task.agisoAutoDelivery.reason) }}
</p>
<p v-if="detail.task.agisoAutoDelivery?.responseStatus">
HTTP{{ detail.task.agisoAutoDelivery.responseStatus }}
</p>
<p v-if="detail.task.agisoAutoDelivery?.requestId">
请求 ID{{ detail.task.agisoAutoDelivery.requestId }}
</p>
</div>
</section>
<section v-if="detail.redeemResolution" class="info-card">
<h3>兑换重试链路</h3>
<p class="section-copy">这里记录自动兑换时每次使用过的 CDK判定结果以及是否切换到了新的同类型凭据</p>
<p class="section-copy">
这里记录自动兑换时每次使用过的 CDK判定结果以及是否切换到了新的同类型凭据
</p>
<div class="redeem-summary-grid">
<article class="redeem-summary-card">
@@ -850,7 +1093,9 @@ onBeforeUnmount(clearScreenshotPreview)
<section v-if="!isKuaishouCloudTask" class="info-card">
<h3>库存绑定</h3>
<p class="section-copy">这里展示任务当前和历史绑定过的库存项便于核对多库存任务的真实履约轨迹</p>
<p class="section-copy">
这里展示任务当前和历史绑定过的库存项便于核对多库存任务的真实履约轨迹
</p>
<table class="data-table">
<thead>
<tr>
@@ -879,7 +1124,9 @@ onBeforeUnmount(clearScreenshotPreview)
<td>
<div>ID {{ binding.inventoryItemId }}</div>
<div>{{ binding.skuCode || '-' }}</div>
<div class="binding-meta">{{ binding.credentialType || '-' }} · {{ binding.batchNo || '无批次' }}</div>
<div class="binding-meta">
{{ binding.credentialType || '-' }} · {{ binding.batchNo || '无批次' }}
</div>
</td>
<td>
<div class="status-stack">
@@ -887,10 +1134,16 @@ onBeforeUnmount(clearScreenshotPreview)
<AdminStatusTag :status="binding.inventoryStatus" />
</div>
<div v-if="binding.invalidReason" class="binding-meta">
{{ binding.inventoryStatus === 'invalid' ? '作废原因' : binding.inventoryStatus === 'consumed' ? '消耗原因' : '处理原因' }}{{ binding.invalidReason }}
{{
binding.inventoryStatus === 'invalid'
? '作废原因'
: binding.inventoryStatus === 'consumed'
? '消耗原因'
: '处理原因'
}}{{ binding.invalidReason }}
</div>
</td>
<td>{{ canViewSensitiveTaskData ? (binding.displayValue || '-') : '客服不可见' }}</td>
<td>{{ canViewSensitiveTaskData ? binding.displayValue || '-' : '客服不可见' }}</td>
<td>
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
@@ -901,11 +1154,14 @@ onBeforeUnmount(clearScreenshotPreview)
v-if="hasAdminRole('admin') && binding.canRelease"
:loading="actionLoading"
link
@click="runAction(
() => releaseAdminTaskInventoryBinding(detail!.task.taskId, binding.bindingId),
'库存绑定已释放',
`确认释放绑定 #${binding.bindingId} 对应的库存项 ${binding.displayValue || binding.inventoryItemId} 吗?`
)"
@click="
runAction(
() =>
releaseAdminTaskInventoryBinding(detail!.task.taskId, binding.bindingId),
'库存绑定已释放',
`确认释放绑定 #${binding.bindingId} 对应的库存项 ${binding.displayValue || binding.inventoryItemId} 吗?`,
)
"
>
释放绑定
</el-button>
@@ -953,8 +1209,16 @@ onBeforeUnmount(clearScreenshotPreview)
</div>
<p class="manual-hint">回写后会直接更新任务终态不再走旧式领取链接流程</p>
<div v-if="detail.manualDispatch" class="manual-summary">
<p>最近回写<AdminStatusTag :status="detail.manualDispatch.outcome || detail.task.deliveryStatus" /> · {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}</p>
<p>处理人{{ detail.manualDispatch.completedBy?.username || '-' }} / {{ detail.manualDispatch.completedBy?.role || '-' }}</p>
<p>
最近回写<AdminStatusTag
:status="detail.manualDispatch.outcome || detail.task.deliveryStatus"
/>
· {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}
</p>
<p>
处理人{{ detail.manualDispatch.completedBy?.username || '-' }} /
{{ detail.manualDispatch.completedBy?.role || '-' }}
</p>
<p>流水号{{ detail.manualDispatch.deliveryReference || '-' }}</p>
<p>交付内容{{ detail.manualDispatch.deliveredCredential || '-' }}</p>
<p>备注{{ detail.manualDispatch.resultMessage || '-' }}</p>
@@ -979,8 +1243,20 @@ onBeforeUnmount(clearScreenshotPreview)
<section v-if="detail.screenshotUrl" class="info-card">
<h3>{{ screenshotSectionTitle }}</h3>
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取</p>
<a v-else class="screenshot-link" :href="screenshotPreviewUrl" target="_blank" rel="noreferrer">打开原图</a>
<img v-if="screenshotPreviewUrl" class="screenshot-preview" :src="screenshotPreviewUrl" :alt="screenshotSectionTitle" />
<a
v-else
class="screenshot-link"
:href="screenshotPreviewUrl"
target="_blank"
rel="noreferrer"
>打开原图</a
>
<img
v-if="screenshotPreviewUrl"
class="screenshot-preview"
:src="screenshotPreviewUrl"
:alt="screenshotSectionTitle"
/>
</section>
</template>
</section>
@@ -74,7 +74,9 @@ function formatBindingCountSummary(item: AdminTaskListItem) {
return `${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`
}
onMounted(() => { void loadTasks() })
onMounted(() => {
void loadTasks()
})
async function runAction(
taskId: number,
@@ -83,8 +85,14 @@ async function runAction(
confirmText: string,
) {
try {
await showConfirm(confirmText, '确认操作', { type: 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' })
} catch { return }
await showConfirm(confirmText, '确认操作', {
type: 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
})
} catch {
return
}
actionLoadingId.value = taskId
try {
@@ -94,23 +102,45 @@ async function runAction(
await loadTasks()
} catch (error) {
showError(error instanceof Error ? error.message : '操作失败')
} finally { actionLoadingId.value = null }
} finally {
actionLoadingId.value = null
}
}
function handleActionCommand(actionKey: AdminTaskActionKey, item: AdminTaskListItem) {
if (actionKey === 'retry') {
void runAction(item.taskId, () => retryAdminTask(item.taskId), '任务已重试', `确认重试任务 ${item.taskNo} 吗?`)
void runAction(
item.taskId,
() => retryAdminTask(item.taskId),
'任务已重试',
`确认重试任务 ${item.taskNo} 吗?`,
)
return
}
if (actionKey === 'regenerate_claim_link') {
void runAction(item.taskId, () => regenerateAdminTaskClaimLink(item.taskId), '领取链接已重新生成', `确认重新生成任务 ${item.taskNo} 的领取链接吗?旧链接会失效。`)
void runAction(
item.taskId,
() => regenerateAdminTaskClaimLink(item.taskId),
'领取链接已重新生成',
`确认重新生成任务 ${item.taskNo} 的领取链接吗?旧链接会失效。`,
)
return
}
if (actionKey === 'manual_review') {
void runAction(item.taskId, () => markAdminTaskManualReview(item.taskId), '任务已转人工处理', `确认将任务 ${item.taskNo} 转为人工处理吗?`)
void runAction(
item.taskId,
() => markAdminTaskManualReview(item.taskId),
'任务已转人工处理',
`确认将任务 ${item.taskNo} 转为人工处理吗?`,
)
return
}
void runAction(item.taskId, () => closeAdminTask(item.taskId), '任务已关闭', `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`)
void runAction(
item.taskId,
() => closeAdminTask(item.taskId),
'任务已关闭',
`确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
)
}
function resetFilters() {
@@ -127,7 +157,10 @@ function resetFilters() {
<template>
<div class="tasks-page list-page">
<AdminPageHeader title="交付任务" description="更紧凑地查看任务状态、角色绑定、库存凭据和异常信息。">
<AdminPageHeader
title="交付任务"
description="更紧凑地查看任务状态、角色绑定、库存凭据和异常信息。"
>
<template #extra>
<span class="total-badge"> {{ pagination.total }} 条任务</span>
</template>
@@ -142,14 +175,44 @@ function resetFilters() {
</template>
<div class="filter-grid">
<el-select v-model="status" placeholder="状态" clearable>
<el-option v-for="opt in adminTaskStatusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-option
v-for="opt in adminTaskStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input v-model="taskNo" placeholder="任务号(模糊匹配)" clearable @keyup.enter="loadTasks(1)" />
<el-input v-model="platformOrderId" placeholder="平台订单号" clearable @keyup.enter="loadTasks(1)" />
<el-input v-model="roleId" placeholder="角色唯一标识" clearable @keyup.enter="loadTasks(1)" />
<el-input
v-model="taskNo"
placeholder="任务号(模糊匹配)"
clearable
@keyup.enter="loadTasks(1)"
/>
<el-input
v-model="platformOrderId"
placeholder="平台订单号"
clearable
@keyup.enter="loadTasks(1)"
/>
<el-input
v-model="roleId"
placeholder="角色唯一标识"
clearable
@keyup.enter="loadTasks(1)"
/>
<el-input v-model="skuCode" placeholder="商品 SKU" clearable @keyup.enter="loadTasks(1)" />
<el-date-picker v-model="dateFrom" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" />
<el-date-picker v-model="dateTo" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" />
<el-date-picker
v-model="dateFrom"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
/>
<el-date-picker
v-model="dateTo"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
<div class="filter-buttons">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="loadTasks(1)">查询</el-button>
@@ -157,10 +220,29 @@ function resetFilters() {
</div>
</el-card>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" style="margin-top: 16px" />
<el-alert v-if="lastClaimUrl" :title="`最近生成的领取链接:${lastClaimUrl}`" type="info" show-icon :closable="false" style="margin-top: 16px" />
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
show-icon
:closable="false"
style="margin-top: 16px"
/>
<el-alert
v-if="lastClaimUrl"
:title="`最近生成的领取链接:${lastClaimUrl}`"
type="info"
show-icon
:closable="false"
style="margin-top: 16px"
/>
<el-card shadow="never" class="section-card" v-loading="loading" element-loading-text="任务列表加载中">
<el-card
shadow="never"
class="section-card"
v-loading="loading"
element-loading-text="任务列表加载中"
>
<template v-if="items.length === 0 && !loading">
<el-empty description="当前筛选条件下暂无任务" :image-size="60" />
</template>
@@ -175,14 +257,22 @@ function resetFilters() {
<el-table-column label="任务 / 商品" min-width="220">
<template #default="{ row }">
<div class="cell-stack">
<RouterLink :to="`/admin/tasks/${row.taskId}`" class="task-no id-link" :title="row.taskNo">
<RouterLink
:to="`/admin/tasks/${row.taskId}`"
class="task-no id-link"
:title="row.taskNo"
>
{{ row.taskNo }}
</RouterLink>
<div class="cell-meta-row">
<span class="compact-chip" :title="row.platformOrderId">订单 {{ row.platformOrderId }}</span>
<span class="compact-chip" :title="row.platformOrderId"
>订单 {{ row.platformOrderId }}</span
>
<span class="compact-chip compact-chip--muted">重试 {{ row.retryCount }} </span>
</div>
<span class="cell-title" :title="row.skuName || row.skuCode">{{ row.skuName || row.skuCode }}</span>
<span class="cell-title" :title="row.skuName || row.skuCode">{{
row.skuName || row.skuCode
}}</span>
<span class="cell-subline">SKU {{ row.skuCode || '-' }}</span>
</div>
</template>
@@ -208,12 +298,24 @@ function resetFilters() {
<el-table-column label="角色 / 凭据" min-width="180">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title" :title="row.roleName || row.roleId || '-'">{{ row.roleName || '-' }}</span>
<span class="cell-subline" :title="row.roleId || '-'">角色ID {{ row.roleId || '-' }}</span>
<span class="cell-title" :title="row.roleName || row.roleId || '-'">{{
row.roleName || '-'
}}</span>
<span class="cell-subline" :title="row.roleId || '-'"
>角色ID {{ row.roleId || '-' }}</span
>
<span class="cell-subline">登录 {{ row.loginType || '-' }}</span>
<span class="cell-subline" :title="row.inventoryDisplayMasked || '-'">凭据 {{ row.inventoryDisplayMasked || '-' }}</span>
<span class="cell-subline" :title="formatBindingRoles(row)">绑定 {{ formatBindingRoles(row) }}</span>
<span class="cell-subline cell-subline--strong" :title="formatBindingCountSummary(row)">{{ formatBindingCountSummary(row) }}</span>
<span class="cell-subline" :title="row.inventoryDisplayMasked || '-'"
>凭据 {{ row.inventoryDisplayMasked || '-' }}</span
>
<span class="cell-subline" :title="formatBindingRoles(row)"
>绑定 {{ formatBindingRoles(row) }}</span
>
<span
class="cell-subline cell-subline--strong"
:title="formatBindingCountSummary(row)"
>{{ formatBindingCountSummary(row) }}</span
>
</div>
</template>
</el-table-column>
@@ -222,25 +324,43 @@ function resetFilters() {
<div class="cell-stack">
<span class="cell-subline">创建 {{ formatAdminDateTime(row.createdAt) }}</span>
<span class="cell-subline">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
<span v-if="row.claimedAt" class="cell-subline">领取 {{ formatAdminDateTime(row.claimedAt) }}</span>
<span v-if="row.roleConfirmedAt" class="cell-subline">确认 {{ formatAdminDateTime(row.roleConfirmedAt) }}</span>
<span v-if="row.redeemedAt" class="cell-subline">兑换 {{ formatAdminDateTime(row.redeemedAt) }}</span>
<span v-if="row.claimedAt" class="cell-subline"
>领取 {{ formatAdminDateTime(row.claimedAt) }}</span
>
<span v-if="row.roleConfirmedAt" class="cell-subline"
>确认 {{ formatAdminDateTime(row.roleConfirmedAt) }}</span
>
<span v-if="row.redeemedAt" class="cell-subline"
>兑换 {{ formatAdminDateTime(row.redeemedAt) }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="异常 / 操作" min-width="220">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-error" :title="row.lastError || '-'">{{ row.lastError || '当前无异常' }}</span>
<span class="cell-error" :title="row.lastError || '-'">{{
row.lastError || '当前无异常'
}}</span>
<div class="action-stack">
<el-button
v-if="canManageTaskLifecycle"
:disabled="row.executorKey === 'manual_dispatch' || !['retry_pending', 'manual_review', 'waiting_inventory'].includes(row.status)"
:disabled="
row.executorKey === 'manual_dispatch' ||
!['retry_pending', 'manual_review', 'waiting_inventory'].includes(row.status)
"
:loading="actionLoadingId === row.taskId"
size="small"
type="danger"
plain
@click="runAction(row.taskId, () => retryAdminTask(row.taskId), '任务已重试', `确认重试任务 ${row.taskNo} 吗?`)"
@click="
runAction(
row.taskId,
() => retryAdminTask(row.taskId),
'任务已重试',
`确认重试任务 ${row.taskNo} 吗?`,
)
"
>
重试
</el-button>
@@ -290,13 +410,31 @@ function resetFilters() {
@import '../../../styles/admin-list-pages.css';
.filter-grid {
grid-template-columns: minmax(150px, 0.75fr) minmax(220px, 1fr) minmax(220px, 1fr) minmax(210px, 0.95fr);
grid-template-columns: minmax(150px, 0.75fr) minmax(220px, 1fr) minmax(220px, 1fr) minmax(
210px,
0.95fr
);
}
.filter-grid :deep(.el-date-editor) {
width: 100%;
}
.filter-grid :deep(.el-date-editor) { width: 100%; }
.progress-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
.progress-row :deep(.el-tag) { min-width: 0; max-width: calc(100% - 42px); }
.progress-row :deep(.el-tag__content) { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.progress-row :deep(.el-tag) {
min-width: 0;
max-width: calc(100% - 42px);
}
.progress-row :deep(.el-tag__content) {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-label {
display: inline-flex;
@@ -314,10 +452,28 @@ function resetFilters() {
flex: 0 0 auto;
}
.cell-error { display: block; min-width: 0; line-height: 1.55; color: #475467; word-break: break-word; font-size: var(--text-xs); }
.cell-error {
display: block;
min-width: 0;
line-height: 1.55;
color: #475467;
word-break: break-word;
font-size: var(--text-xs);
}
.cell-subline--strong { color: var(--text-primary); font-weight: 600; }
.cell-subline--strong {
color: var(--text-primary);
font-weight: 600;
}
.action-stack { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.action-stack :deep(.el-button) { flex: 0 0 auto; margin-left: 0; }
.action-stack {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.action-stack :deep(.el-button) {
flex: 0 0 auto;
margin-left: 0;
}
</style>
@@ -4,7 +4,13 @@ import { onMounted, ref } from 'vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { isFeedbackDismissed, showConfirm, showError, showPrompt, showSuccess } from '@/lib/feedback'
import {
isFeedbackDismissed,
showConfirm,
showError,
showPrompt,
showSuccess,
} from '@/lib/feedback'
import {
createAdminUser,
fetchAdminUsers,
@@ -31,7 +37,9 @@ const createRole = ref<AdminRole>('operator')
const items = ref<AdminUserListItem[]>([])
const pagination = ref<AdminPagination>({ page: 1, pageSize: 20, total: 0 })
function isCurrentUser(userId: number) { return getAdminUserId() === userId }
function isCurrentUser(userId: number) {
return getAdminUserId() === userId
}
function formatRoleLabel(role: AdminRole) {
if (role === 'admin') return '管理员'
@@ -46,63 +54,156 @@ function formatInventoryGroups(item: AdminUserListItem) {
}
async function loadUsers(page = pagination.value.page) {
if (!hasAdminRole('admin')) { loading.value = false; return }
loading.value = true; errorMessage.value = ''
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminUsers({ page, pageSize: pagination.value.pageSize, username: username.value.trim(), role: role.value.trim(), status: status.value.trim() })
items.value = response.data.items; pagination.value = response.data.pagination
const response = await fetchAdminUsers({
page,
pageSize: pagination.value.pageSize,
username: username.value.trim(),
role: role.value.trim(),
status: status.value.trim(),
})
items.value = response.data.items
pagination.value = response.data.pagination
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取后台用户失败'
} finally { loading.value = false }
} finally {
loading.value = false
}
}
async function submitCreate() {
if (!createUsername.value.trim() || !createPassword.value.trim()) { showError('请填写账号和密码'); return }
if (!createUsername.value.trim() || !createPassword.value.trim()) {
showError('请填写账号和密码')
return
}
creating.value = true
try {
await createAdminUser({ username: createUsername.value.trim(), password: createPassword.value.trim(), role: createRole.value, status: 'active' })
showSuccess('后台用户已创建'); createUsername.value = ''; createPassword.value = ''; createRole.value = 'operator'
await createAdminUser({
username: createUsername.value.trim(),
password: createPassword.value.trim(),
role: createRole.value,
status: 'active',
})
showSuccess('后台用户已创建')
createUsername.value = ''
createPassword.value = ''
createRole.value = 'operator'
await loadUsers(1)
} catch (error) { showError(error instanceof Error ? error.message : '创建后台用户失败') }
finally { creating.value = false }
} catch (error) {
showError(error instanceof Error ? error.message : '创建后台用户失败')
} finally {
creating.value = false
}
}
async function updateRole(item: AdminUserListItem, nextRole: AdminRole) {
if (item.role === nextRole) return
try { await showConfirm(`确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`, '确认操作', { type: 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' }) } catch { return }
try {
await showConfirm(
`确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
'确认操作',
{ type: 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' },
)
} catch {
return
}
actionLoadingId.value = item.userId
try { await updateAdminUserRole(item.userId, { role: nextRole }); showSuccess('用户角色已更新'); await loadUsers() }
catch (error) { showError(error instanceof Error ? error.message : '更新用户角色失败') }
finally { actionLoadingId.value = null }
try {
await updateAdminUserRole(item.userId, { role: nextRole })
showSuccess('用户角色已更新')
await loadUsers()
} catch (error) {
showError(error instanceof Error ? error.message : '更新用户角色失败')
} finally {
actionLoadingId.value = null
}
}
async function toggleStatus(item: AdminUserListItem) {
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
try { await showConfirm(`确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`, '确认操作', { type: nextStatus === 'active' ? 'info' : 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' }) } catch { return }
try {
await showConfirm(
`确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
'确认操作',
{
type: nextStatus === 'active' ? 'info' : 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
},
)
} catch {
return
}
actionLoadingId.value = item.userId
try { await updateAdminUserStatus(item.userId, { status: nextStatus }); showSuccess('用户状态已更新'); await loadUsers() }
catch (error) { showError(error instanceof Error ? error.message : '更新用户状态失败') }
finally { actionLoadingId.value = null }
try {
await updateAdminUserStatus(item.userId, { status: nextStatus })
showSuccess('用户状态已更新')
await loadUsers()
} catch (error) {
showError(error instanceof Error ? error.message : '更新用户状态失败')
} finally {
actionLoadingId.value = null
}
}
async function promptResetPassword(item: AdminUserListItem) {
try {
const result = await showPrompt(`请输入 ${item.username} 的新密码`, '重置密码', { inputType: 'password', inputPlaceholder: '至少 8 位', confirmButtonText: '提交', cancelButtonText: '取消', inputValidator: (value) => value.trim().length >= 8 || '密码至少 8 位' })
const result = await showPrompt(`请输入 ${item.username} 的新密码`, '重置密码', {
inputType: 'password',
inputPlaceholder: '至少 8 位',
confirmButtonText: '提交',
cancelButtonText: '取消',
inputValidator: (value) => value.trim().length >= 8 || '密码至少 8 位',
})
actionLoadingId.value = item.userId
await resetAdminUserPassword(item.userId, { password: result.value.trim() }); showSuccess('用户密码已重置'); await loadUsers()
} catch (error) { if (isFeedbackDismissed(error)) return; showError(error instanceof Error ? error.message : '重置密码失败') }
finally { actionLoadingId.value = null }
await resetAdminUserPassword(item.userId, { password: result.value.trim() })
showSuccess('用户密码已重置')
await loadUsers()
} catch (error) {
if (isFeedbackDismissed(error)) return
showError(error instanceof Error ? error.message : '重置密码失败')
} finally {
actionLoadingId.value = null
}
}
async function promptUpdateInventoryGroups(item: AdminUserListItem) {
if (item.role !== 'support') { showError('仅客服账号需要绑定库存组'); return }
if (item.role !== 'support') {
showError('仅客服账号需要绑定库存组')
return
}
try {
const result = await showPrompt(`请输入 ${item.username} 可用的库存组,多个组用英文逗号分隔`, '配置库存组', { inputType: 'text', inputValue: item.inventoryGroupCodes.join(','), inputPlaceholder: '例如 A组,B组,售后组', confirmButtonText: '保存', cancelButtonText: '取消' })
const inventoryGroupCodes = String(result.value || '').split(',').map((v) => v.trim()).filter(Boolean)
const result = await showPrompt(
`请输入 ${item.username} 可用的库存组,多个组用英文逗号分隔`,
'配置库存组',
{
inputType: 'text',
inputValue: item.inventoryGroupCodes.join(','),
inputPlaceholder: '例如 A组,B组,售后组',
confirmButtonText: '保存',
cancelButtonText: '取消',
},
)
const inventoryGroupCodes = String(result.value || '')
.split(',')
.map((v) => v.trim())
.filter(Boolean)
actionLoadingId.value = item.userId
await updateAdminUserInventoryGroups(item.userId, { inventoryGroupCodes }); showSuccess('用户库存组已更新'); await loadUsers()
} catch (error) { if (isFeedbackDismissed(error)) return; showError(error instanceof Error ? error.message : '更新库存组失败') }
finally { actionLoadingId.value = null }
await updateAdminUserInventoryGroups(item.userId, { inventoryGroupCodes })
showSuccess('用户库存组已更新')
await loadUsers()
} catch (error) {
if (isFeedbackDismissed(error)) return
showError(error instanceof Error ? error.message : '更新库存组失败')
} finally {
actionLoadingId.value = null
}
}
onMounted(loadUsers)
@@ -110,42 +211,98 @@ onMounted(loadUsers)
<template>
<div class="users-page list-page">
<AdminPageHeader title="后台用户" description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。" />
<AdminPageHeader
title="后台用户"
description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。"
/>
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以访问用户管理。" />
<template v-else>
<section class="users-tools">
<div class="filter-row">
<el-input v-model="username" class="filter-control filter-control--username" placeholder="账号筛选" clearable @keyup.enter="loadUsers(1)" />
<el-select v-model="role" class="filter-control filter-control--role" placeholder="角色" clearable>
<el-option v-for="opt in adminUserRoleOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-input
v-model="username"
class="filter-control filter-control--username"
placeholder="账号筛选"
clearable
@keyup.enter="loadUsers(1)"
/>
<el-select
v-model="role"
class="filter-control filter-control--role"
placeholder="角色"
clearable
>
<el-option
v-for="opt in adminUserRoleOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="status" class="filter-control filter-control--status" placeholder="状态" clearable>
<el-option v-for="opt in adminUserStatusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-select
v-model="status"
class="filter-control filter-control--status"
placeholder="状态"
clearable
>
<el-option
v-for="opt in adminUserStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button round type="primary" @click="loadUsers(1)">查询列表</el-button>
</div>
<div class="create-row">
<el-input v-model="createUsername" class="filter-control filter-control--username" placeholder="新账号" />
<el-input v-model="createPassword" class="filter-control filter-control--password" type="password" show-password placeholder="新密码,至少 8 位" />
<el-input
v-model="createUsername"
class="filter-control filter-control--username"
placeholder="新账号"
/>
<el-input
v-model="createPassword"
class="filter-control filter-control--password"
type="password"
show-password
placeholder="新密码,至少 8 位"
/>
<el-select v-model="createRole" class="filter-control filter-control--role">
<el-option value="operator" label="普通运营" />
<el-option value="support" label="客服" />
<el-option value="admin" label="管理员" />
</el-select>
<el-button round :loading="creating" type="primary" @click="submitCreate">新增用户</el-button>
<el-button round :loading="creating" type="primary" @click="submitCreate"
>新增用户</el-button
>
</div>
</section>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" style="margin-top: 16px" />
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
show-icon
:closable="false"
style="margin-top: 16px"
/>
<el-card shadow="never" class="section-card">
<div class="users-table-toolbar table-toolbar">
<strong>用户列表</strong>
<span> {{ pagination.total }} 个账号</span>
</div>
<el-table :data="items" stripe size="small" v-loading="loading" element-loading-text="用户列表加载中" class="users-table data-table" style="width: 100%">
<el-table
:data="items"
stripe
size="small"
v-loading="loading"
element-loading-text="用户列表加载中"
class="users-table data-table"
style="width: 100%"
>
<el-table-column prop="userId" label="ID" width="52" align="center" />
<el-table-column prop="username" label="账号" width="112" show-overflow-tooltip />
<el-table-column label="角色" width="82">
@@ -154,7 +311,13 @@ onMounted(loadUsers)
<el-table-column label="库存组" width="112">
<template #default="{ row }">
<div class="inventory-tags">
<el-tag v-for="(code, i) in formatInventoryGroups(row)" :key="i" size="small" :type="(code === '未绑定' || code === '不限库存组') ? 'info' : undefined">{{ code }}</el-tag>
<el-tag
v-for="(code, i) in formatInventoryGroups(row)"
:key="i"
size="small"
:type="code === '未绑定' || code === '不限库存组' ? 'info' : undefined"
>{{ code }}</el-tag
>
</div>
</template>
</el-table-column>
@@ -162,29 +325,65 @@ onMounted(loadUsers)
<template #default="{ row }"><AdminStatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column label="创建时间" width="154">
<template #default="{ row }"><span class="date-cell">{{ formatAdminDateTime(row.createdAt) }}</span></template>
<template #default="{ row }"
><span class="date-cell">{{ formatAdminDateTime(row.createdAt) }}</span></template
>
</el-table-column>
<el-table-column label="更新时间" width="154">
<template #default="{ row }"><span class="date-cell">{{ formatAdminDateTime(row.updatedAt) }}</span></template>
<template #default="{ row }"
><span class="date-cell">{{ formatAdminDateTime(row.updatedAt) }}</span></template
>
</el-table-column>
<el-table-column label="操作" min-width="356">
<template #default="{ row }">
<div class="action-stack">
<el-dropdown trigger="click" :disabled="isCurrentUser(row.userId)" @command="(cmd: string) => updateRole(row, cmd as AdminRole)">
<el-button :loading="actionLoadingId === row.userId" size="small" :disabled="isCurrentUser(row.userId)">
<el-dropdown
trigger="click"
:disabled="isCurrentUser(row.userId)"
@command="(cmd: string) => updateRole(row, cmd as AdminRole)"
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="isCurrentUser(row.userId)"
>
改角色<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="admin" :disabled="row.role === 'admin'">设为管理员</el-dropdown-item>
<el-dropdown-item command="operator" :disabled="row.role === 'operator'">设为运营</el-dropdown-item>
<el-dropdown-item command="support" :disabled="row.role === 'support'">设为客服</el-dropdown-item>
<el-dropdown-item command="admin" :disabled="row.role === 'admin'"
>设为管理员</el-dropdown-item
>
<el-dropdown-item command="operator" :disabled="row.role === 'operator'"
>设为运营</el-dropdown-item
>
<el-dropdown-item command="support" :disabled="row.role === 'support'"
>设为客服</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button :loading="actionLoadingId === row.userId" size="small" :disabled="isCurrentUser(row.userId)" @click="toggleStatus(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
<el-button :loading="actionLoadingId === row.userId" size="small" :disabled="row.role !== 'support'" @click="promptUpdateInventoryGroups(row)">配库存组</el-button>
<el-button :loading="actionLoadingId === row.userId" size="small" type="primary" @click="promptResetPassword(row)">重置密码</el-button>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="isCurrentUser(row.userId)"
@click="toggleStatus(row)"
>{{ row.status === 'active' ? '停用' : '启用' }}</el-button
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="row.role !== 'support'"
@click="promptUpdateInventoryGroups(row)"
>配库存组</el-button
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
type="primary"
@click="promptResetPassword(row)"
>重置密码</el-button
>
<el-tag v-if="isCurrentUser(row.userId)" size="small" type="info">当前账号</el-tag>
</div>
</template>
@@ -224,11 +423,19 @@ onMounted(loadUsers)
padding-top: var(--space-3);
border-top: 1px solid rgba(86, 108, 138, 0.12);
}
.filter-control { min-width: 0; }
.filter-control--username { width: 150px; }
.filter-control {
min-width: 0;
}
.filter-control--username {
width: 150px;
}
.filter-control--role,
.filter-control--status { width: 128px; }
.filter-control--password { width: 210px; }
.filter-control--status {
width: 128px;
}
.filter-control--password {
width: 210px;
}
.users-table-toolbar {
display: flex;
align-items: center;
@@ -258,13 +465,39 @@ onMounted(loadUsers)
.users-table :deep(.el-table__body tr:hover > td.el-table__cell) {
background: var(--el-table-row-hover-bg-color);
}
.inventory-tags { display: flex; gap: 4px; align-items: center; white-space: nowrap; }
.inventory-tags :deep(.el-tag) { flex: 0 0 auto; }
.date-cell { display: inline-block; white-space: nowrap; font-size: var(--text-xs); }
.action-stack { display: flex; flex-wrap: nowrap; gap: 6px; align-items: center; white-space: nowrap; }
.action-stack :deep(.el-button) { flex: 0 0 auto; margin-left: 0; padding: 5px 8px; }
.action-stack :deep(.el-tag) { flex: 0 0 auto; padding: 0 7px; }
.action-stack :deep(.el-icon--right) { margin-left: 4px; }
.inventory-tags {
display: flex;
gap: 4px;
align-items: center;
white-space: nowrap;
}
.inventory-tags :deep(.el-tag) {
flex: 0 0 auto;
}
.date-cell {
display: inline-block;
white-space: nowrap;
font-size: var(--text-xs);
}
.action-stack {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
white-space: nowrap;
}
.action-stack :deep(.el-button) {
flex: 0 0 auto;
margin-left: 0;
padding: 5px 8px;
}
.action-stack :deep(.el-tag) {
flex: 0 0 auto;
padding: 0 7px;
}
.action-stack :deep(.el-icon--right) {
margin-left: 4px;
}
@media (max-width: 640px) {
.filter-control,
@@ -89,11 +89,15 @@ onMounted(loadDetail)
<template>
<section class="admin-panel">
<AdminPageHeader
title="Webhook 详情"
description="查看原始请求并手动重放处理流程。"
>
<el-button v-if="hasAdminRole('admin')" :loading="replayLoading" round type="primary" @click="submitReplay">重放处理</el-button>
<AdminPageHeader title="Webhook 详情" description="查看原始请求并手动重放处理流程。">
<el-button
v-if="hasAdminRole('admin')"
:loading="replayLoading"
round
type="primary"
@click="submitReplay"
>重放处理</el-button
>
</AdminPageHeader>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
@@ -131,7 +135,9 @@ onMounted(loadDetail)
<div class="summary-item">
<span class="summary-label">关联订单</span>
<strong v-if="detail.relatedOrderId">
<RouterLink class="inline-link" :to="`/admin/orders/${detail.relatedOrderId}`">{{ detail.relatedOrderId }}</RouterLink>
<RouterLink class="inline-link" :to="`/admin/orders/${detail.relatedOrderId}`">{{
detail.relatedOrderId
}}</RouterLink>
</strong>
<strong v-else>-</strong>
<span class="summary-note">任务数: {{ detail.taskCount }}</span>
@@ -175,12 +181,17 @@ onMounted(loadDetail)
<h3>最近一次重放结果</h3>
<p class="result-copy">
{{ lastReplayResult.provider }} / {{ lastReplayResult.platform }}
<template v-if="lastReplayResult.platformRaw && lastReplayResult.platformRaw !== lastReplayResult.platform">
<template
v-if="
lastReplayResult.platformRaw &&
lastReplayResult.platformRaw !== lastReplayResult.platform
"
>
· 原始平台 {{ lastReplayResult.platformRaw }}
</template>
· 平台订单 {{ lastReplayResult.platformOrderId }}
· 事件 {{ formatAdminWebhookEventType(lastReplayResult.eventType) }}
· 任务数 {{ lastReplayResult.taskCount }}
· 平台订单 {{ lastReplayResult.platformOrderId }} · 事件
{{ formatAdminWebhookEventType(lastReplayResult.eventType) }} · 任务数
{{ lastReplayResult.taskCount }}
</p>
<el-table :data="lastReplayResult.tasks" size="small">
<el-table-column prop="taskId" label="任务 ID" />
@@ -1,38 +1,32 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAdminListPage } from "@/composables/useAdminListPage";
import AdminPageHeader from "@/components/admin/AdminPageHeader.vue";
import AdminStatusTag from "@/components/admin/AdminStatusTag.vue";
import { showConfirm, showError, showSuccess } from "@/lib/feedback";
import {
fetchAdminWebhookEvents,
replayAdminWebhookEvent,
} from "@/services/admin";
import type { AdminWebhookEventListItem } from "@/types/admin";
import { hasAdminRole } from "@/utils/admin-auth";
import { formatAdminDateTime } from "@/utils/admin-time";
import { formatAdminWebhookEventType } from "@/utils/admin-webhook";
import {
adminWebhookProcessedOptions,
adminWebhookVisibilityOptions,
} from "@/utils/admin-options";
import { useAdminListPage } from '@/composables/useAdminListPage'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
import { fetchAdminWebhookEvents, replayAdminWebhookEvent } from '@/services/admin'
import type { AdminWebhookEventListItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
import { adminWebhookProcessedOptions, adminWebhookVisibilityOptions } from '@/utils/admin-options'
const route = useRoute();
const router = useRouter();
const route = useRoute()
const router = useRouter()
const actionLoadingId = ref<number | null>(null);
const provider = ref(readQueryString("provider"));
const platform = ref(readQueryString("platform"));
const platformOrderId = ref(readQueryString("platformOrderId"));
const processed = ref(readQueryString("processed"));
const visibility = ref(readQueryString("visibility") || "important");
const relatedOrderId = ref(readQueryString("relatedOrderId"));
const actionLoadingId = ref<number | null>(null)
const provider = ref(readQueryString('provider'))
const platform = ref(readQueryString('platform'))
const platformOrderId = ref(readQueryString('platformOrderId'))
const processed = ref(readQueryString('processed'))
const visibility = ref(readQueryString('visibility') || 'important')
const relatedOrderId = ref(readQueryString('relatedOrderId'))
const dateRange = ref<string[]>(
[readQueryString("dateFrom"), readQueryString("dateTo")].filter(Boolean)
);
const canReplay = hasAdminRole("admin");
[readQueryString('dateFrom'), readQueryString('dateTo')].filter(Boolean),
)
const canReplay = hasAdminRole('admin')
const {
loading,
@@ -41,7 +35,7 @@ const {
pagination,
loadPage: loadEvents,
} = useAdminListPage<AdminWebhookEventListItem>({
defaultErrorMessage: "读取 webhook 列表失败",
defaultErrorMessage: '读取 webhook 列表失败',
fetchPage: (page, pageSize) =>
fetchAdminWebhookEvents({
page,
@@ -52,107 +46,99 @@ const {
processed: processed.value.trim(),
visibility: visibility.value.trim(),
relatedOrderId: relatedOrderId.value.trim(),
dateFrom: dateRange.value[0] || "",
dateTo: dateRange.value[1] || "",
dateFrom: dateRange.value[0] || '',
dateTo: dateRange.value[1] || '',
}),
});
})
const summary = computed(() => ({
processedCount: items.value.filter((item) => item.processed).length,
failedCount: items.value.filter(
(item) => !item.processed || Boolean(item.processError)
).length,
importantCount: items.value.filter(
(item) => item.visibilityLevel === "important"
).length,
}));
failedCount: items.value.filter((item) => !item.processed || Boolean(item.processError)).length,
importantCount: items.value.filter((item) => item.visibilityLevel === 'important').length,
}))
async function submitReplay(item: AdminWebhookEventListItem) {
try {
await showConfirm(`确认重放 webhook #${item.eventId} 吗?`, "确认重放", {
type: "warning",
confirmButtonText: "继续重放",
cancelButtonText: "取消",
});
await showConfirm(`确认重放 webhook #${item.eventId} 吗?`, '确认重放', {
type: 'warning',
confirmButtonText: '继续重放',
cancelButtonText: '取消',
})
} catch {
return;
return
}
actionLoadingId.value = item.eventId;
actionLoadingId.value = item.eventId
try {
await replayAdminWebhookEvent(item.eventId);
showSuccess("Webhook 已重放");
await loadEvents();
await replayAdminWebhookEvent(item.eventId)
showSuccess('Webhook 已重放')
await loadEvents()
} catch (error) {
showError(error instanceof Error ? error.message : "重放 webhook 失败");
showError(error instanceof Error ? error.message : '重放 webhook 失败')
} finally {
actionLoadingId.value = null;
actionLoadingId.value = null
}
}
function resetFilters() {
provider.value = "";
platform.value = "";
platformOrderId.value = "";
processed.value = "";
visibility.value = "important";
relatedOrderId.value = "";
dateRange.value = [];
void applyFilters(1);
provider.value = ''
platform.value = ''
platformOrderId.value = ''
processed.value = ''
visibility.value = 'important'
relatedOrderId.value = ''
dateRange.value = []
void applyFilters(1)
}
async function applyFilters(page = 1) {
await syncRouteQuery(page);
await loadEvents(page);
await syncRouteQuery(page)
await loadEvents(page)
}
function readQueryString(key: string) {
const value = route.query[key];
const rawValue = Array.isArray(value) ? value[0] : value;
return typeof rawValue === "string" ? rawValue.trim() : "";
const value = route.query[key]
const rawValue = Array.isArray(value) ? value[0] : value
return typeof rawValue === 'string' ? rawValue.trim() : ''
}
function readQueryPage() {
const page = Number(readQueryString("page"));
return Number.isInteger(page) && page > 0 ? page : 1;
const page = Number(readQueryString('page'))
return Number.isInteger(page) && page > 0 ? page : 1
}
function syncRouteQuery(page: number) {
const query: Record<string, string> = {};
addQueryValue(query, "provider", provider.value);
addQueryValue(query, "platform", platform.value);
addQueryValue(query, "platformOrderId", platformOrderId.value);
addQueryValue(query, "processed", processed.value);
addQueryValue(query, "visibility", visibility.value);
addQueryValue(query, "relatedOrderId", relatedOrderId.value);
addQueryValue(query, "dateFrom", dateRange.value[0] || "");
addQueryValue(query, "dateTo", dateRange.value[1] || "");
const query: Record<string, string> = {}
addQueryValue(query, 'provider', provider.value)
addQueryValue(query, 'platform', platform.value)
addQueryValue(query, 'platformOrderId', platformOrderId.value)
addQueryValue(query, 'processed', processed.value)
addQueryValue(query, 'visibility', visibility.value)
addQueryValue(query, 'relatedOrderId', relatedOrderId.value)
addQueryValue(query, 'dateFrom', dateRange.value[0] || '')
addQueryValue(query, 'dateTo', dateRange.value[1] || '')
if (page > 1) {
query.page = String(page);
query.page = String(page)
}
return router.replace({ query });
return router.replace({ query })
}
function addQueryValue(
query: Record<string, string>,
key: string,
value: string
) {
const trimmed = value.trim();
function addQueryValue(query: Record<string, string>, key: string, value: string) {
const trimmed = value.trim()
if (trimmed) {
query[key] = trimmed;
query[key] = trimmed
}
}
function formatVisibilityLabel(value: string) {
return value === "ignored" ? "已忽略" : "重要";
return value === 'ignored' ? '已忽略' : '重要'
}
onMounted(() => {
void applyFilters(readQueryPage());
});
void applyFilters(readQueryPage())
})
</script>
<template>
@@ -171,9 +157,7 @@ onMounted(() => {
<template #header>
<div class="card-header">
<span class="card-title">筛选 Webhook</span>
<span class="card-desc"
>按服务商平台处理状态可见级别等条件筛选</span
>
<span class="card-desc">按服务商平台处理状态可见级别等条件筛选</span>
</div>
</template>
<div class="filter-grid">
@@ -191,12 +175,7 @@ onMounted(() => {
clearable
@keyup.enter="applyFilters(1)"
/>
<el-select
v-model="processed"
class="filter-control"
placeholder="处理结果"
clearable
>
<el-select v-model="processed" class="filter-control" placeholder="处理结果" clearable>
<el-option
v-for="opt in adminWebhookProcessedOptions"
:key="opt.value"
@@ -204,12 +183,7 @@ onMounted(() => {
:value="opt.value"
/>
</el-select>
<el-select
v-model="visibility"
class="filter-control"
placeholder="可见级别"
clearable
>
<el-select v-model="visibility" class="filter-control" placeholder="可见级别" clearable>
<el-option
v-for="opt in adminWebhookVisibilityOptions"
:key="opt.value"
@@ -242,9 +216,7 @@ onMounted(() => {
/>
<div class="filter-buttons">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="applyFilters(1)"
>查询</el-button
>
<el-button round type="primary" @click="applyFilters(1)">查询</el-button>
</div>
</div>
</el-card>
@@ -280,32 +252,19 @@ onMounted(() => {
element-loading-text="Webhook 列表加载中"
>
<template v-if="items.length === 0 && !loading">
<el-empty
description="当前筛选条件下暂无 Webhook 事件"
:image-size="60"
/>
<el-empty description="当前筛选条件下暂无 Webhook 事件" :image-size="60" />
</template>
<template v-else>
<div class="orders-toolbar table-toolbar">
<strong>Webhook 列表</strong>
<span
> {{ pagination.page }} 当前展示 {{ items.length }} </span
>
<span> {{ pagination.page }} 当前展示 {{ items.length }} </span>
</div>
<el-table
:data="items"
stripe
size="small"
class="orders-table data-table"
>
<el-table :data="items" stripe size="small" class="orders-table data-table">
<el-table-column label="事件ID" width="100">
<template #default="{ row }">
<RouterLink
:to="`/admin/webhook-events/${row.eventId}`"
class="order-no id-link"
>
<RouterLink :to="`/admin/webhook-events/${row.eventId}`" class="order-no id-link">
#{{ row.eventId }}
</RouterLink>
</template>
@@ -313,26 +272,22 @@ onMounted(() => {
<el-table-column label="事件类型" min-width="200">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title">{{
formatAdminWebhookEventType(row.eventType)
}}</span>
<span class="cell-title">{{ formatAdminWebhookEventType(row.eventType) }}</span>
<div class="cell-meta-row">
<span class="compact-chip">{{ row.provider || "-" }}</span>
<span class="compact-chip">{{ row.platform || "-" }}</span>
<span class="compact-chip">{{ row.provider || '-' }}</span>
<span class="compact-chip">{{ row.platform || '-' }}</span>
<span class="compact-chip compact-chip--muted">{{
row.shopName || row.shopId || "-"
row.shopName || row.shopId || '-'
}}</span>
</div>
<span v-if="row.aopic" class="cell-subline"
>aopic {{ row.aopic }}</span
>
<span v-if="row.aopic" class="cell-subline">aopic {{ row.aopic }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="平台订单号" min-width="180">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title">{{ row.platformOrderId || "-" }}</span>
<span class="cell-title">{{ row.platformOrderId || '-' }}</span>
<span v-if="row.totalAmount" class="cell-subline"
>{{ row.totalAmount }} {{ row.currency }}</span
>
@@ -353,28 +308,18 @@ onMounted(() => {
<template #default="{ row }">
<div class="cell-stack">
<div class="status-stack">
<AdminStatusTag
:status="row.processed ? 'success' : 'failed'"
/>
<AdminStatusTag
:status="row.signatureValid ? 'success' : 'failed'"
/>
<AdminStatusTag :status="row.processed ? 'success' : 'failed'" />
<AdminStatusTag :status="row.signatureValid ? 'success' : 'failed'" />
</div>
<span class="cell-subline">{{
formatVisibilityLabel(row.visibilityLevel)
}}</span>
<span v-if="row.processError" class="error-inline">{{
row.processError
}}</span>
<span class="cell-subline">{{ formatVisibilityLabel(row.visibilityLevel) }}</span>
<span v-if="row.processError" class="error-inline">{{ row.processError }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title">{{
formatAdminDateTime(row.createdAt)
}}</span>
<span class="cell-title">{{ formatAdminDateTime(row.createdAt) }}</span>
<span class="cell-subline">#{{ row.eventId }}</span>
</div>
</template>
@@ -382,9 +327,7 @@ onMounted(() => {
<el-table-column label="操作" width="130" align="center">
<template #default="{ row }">
<div class="action-stack">
<RouterLink
:to="`/admin/webhook-events/${row.eventId}`"
class="detail-link"
<RouterLink :to="`/admin/webhook-events/${row.eventId}`" class="detail-link"
>详情</RouterLink
>
<el-button
@@ -414,7 +357,7 @@ onMounted(() => {
</template>
<style scoped>
@import "../../../styles/admin-list-pages.css";
@import '../../../styles/admin-list-pages.css';
.filter-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
+3 -1
View File
@@ -19,7 +19,9 @@ async function bootstrap() {
try {
const response = await fetchClaimDetail(token.value)
flowType.value = String(response.data.flowType || response.data.task.executorKey || 'tencent_claim').trim()
flowType.value = String(
response.data.flowType || response.data.task.executorKey || 'tencent_claim',
).trim()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
} finally {
@@ -2,7 +2,14 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import QRCode from 'qrcode'
import { ElMessageBox } from 'element-plus'
import { CircleCheck, CircleClose, Clock, Loading, RefreshRight, Warning } from '@element-plus/icons-vue'
import {
CircleCheck,
CircleClose,
Clock,
Loading,
RefreshRight,
Warning,
} from '@element-plus/icons-vue'
import { showError, showSuccess } from '@/lib/feedback'
import {
@@ -45,22 +52,35 @@ const isBindUrlExpired = computed(() => {
const expiresTime = Date.parse(expiresAt)
return Number.isFinite(expiresTime) && expiresTime <= Date.now()
})
const isBindingPrepared = computed(() => (
flow.value?.binding.prepareStatus === 'ready'
&& Boolean(String(flow.value?.binding.bindUrl || '').trim())
&& !isBindUrlExpired.value
))
const isBindingPrepared = computed(
() =>
flow.value?.binding.prepareStatus === 'ready' &&
Boolean(String(flow.value?.binding.bindUrl || '').trim()) &&
!isBindUrlExpired.value,
)
const isBindingPreparing = computed(() => flow.value?.binding.prepareStatus === 'pending')
const canEnterBindingStep = computed(() => isTicketVerified.value)
const isRoleReady = computed(() => Boolean(roleName.value || roleId.value))
const isRoleConfirmed = computed(() => String(task.value?.status || '').trim() === 'role_confirmed')
const isDispatched = computed(() => String(flow.value?.dispatch.status || '').trim() === 'success')
const isCompleted = computed(() => String(task.value?.status || '').trim() === 'completed' || String(flow.value?.consume.status || '').trim() === 'success')
const isCompleted = computed(
() =>
String(task.value?.status || '').trim() === 'completed' ||
String(flow.value?.consume.status || '').trim() === 'success',
)
const hasRedeemResult = computed(() => {
const status = String(task.value?.status || '').trim()
return isDispatched.value || ['dispatched_pending_return', 'completed', 'manual_review', 'failed'].includes(status)
return (
isDispatched.value ||
['dispatched_pending_return', 'completed', 'manual_review', 'failed'].includes(status)
)
})
const canSubmitTicket = computed(() => !['completed', 'manual_review', 'closed', 'expired'].includes(String(task.value?.status || '').trim()) && !submitting.value)
const canSubmitTicket = computed(
() =>
!['completed', 'manual_review', 'closed', 'expired'].includes(
String(task.value?.status || '').trim(),
) && !submitting.value,
)
const currentStep = computed(() => {
if (hasRedeemResult.value) {
return 4
@@ -187,7 +207,9 @@ async function refreshRole() {
if (isRoleReady.value) {
showSuccess('角色信息已刷新')
} else {
showError(flow.value?.role.errorMessage || '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试')
showError(
flow.value?.role.errorMessage || '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试',
)
}
} finally {
refreshingRole.value = false
@@ -261,9 +283,8 @@ function stopPolling() {
function syncPolling() {
const taskStatus = String(task.value?.status || '').trim()
const shouldPoll = Boolean(flow.value)
&& !hasRedeemResult.value
&& !['closed', 'expired'].includes(taskStatus)
const shouldPoll =
Boolean(flow.value) && !hasRedeemResult.value && !['closed', 'expired'].includes(taskStatus)
if (!shouldPoll) {
stopPolling()
@@ -316,7 +337,12 @@ onBeforeUnmount(() => {
<p>订单号{{ order?.platformOrderId || '-' }}</p>
</div>
<div v-if="!loading && !errorMessage" class="step-indicator">
<div v-for="step in 4" :key="step" class="step-dot" :class="{ active: currentStep >= step }">
<div
v-for="step in 4"
:key="step"
class="step-dot"
:class="{ active: currentStep >= step }"
>
{{ step }}
</div>
</div>
@@ -351,7 +377,8 @@ onBeforeUnmount(() => {
<section v-if="currentStep === 1" class="content-card">
<h2> 1 提交核销码</h2>
<p class="muted">
请先从快手小店复制核销码验证通过后系统会自动准备 Cloud 绑定资源不需要再让客服手工点准备绑定资源
请先从快手小店复制核销码验证通过后系统会自动准备 Cloud
绑定资源不需要再让客服手工点准备绑定资源
</p>
<el-input
@@ -374,14 +401,22 @@ onBeforeUnmount(() => {
<div class="status-banner info">
<el-icon><Clock /></el-icon>
<span>{{ isBindingPreparing ? '绑定资源会在核销通过后自动准备。' : '核销完成后会自动进入扫码绑定步骤。' }}</span>
<span>{{
isBindingPreparing
? '绑定资源会在核销通过后自动准备。'
: '核销完成后会自动进入扫码绑定步骤。'
}}</span>
</div>
<div v-if="flow.guideImages.length > 0" class="guide-section">
<el-collapse>
<el-collapse-item title="查看核销码图文指引" name="guide">
<div class="guide-grid">
<figure v-for="(imageUrl, index) in flow.guideImages" :key="imageUrl" class="guide-figure">
<figure
v-for="(imageUrl, index) in flow.guideImages"
:key="imageUrl"
class="guide-figure"
>
<img :src="imageUrl" :alt="`步骤 ${index + 1}`" />
<figcaption>步骤 {{ index + 1 }}</figcaption>
</figure>
@@ -403,9 +438,7 @@ onBeforeUnmount(() => {
<el-button type="primary" size="large" @click="openBindUrl()">
新窗口打开绑定页
</el-button>
<el-button size="large" plain @click="openBindUrl(true)">
当前页面打开
</el-button>
<el-button size="large" plain @click="openBindUrl(true)"> 当前页面打开 </el-button>
</div>
</div>
@@ -421,19 +454,16 @@ onBeforeUnmount(() => {
如果当前角色不正确请重新打开绑定页完成重新绑定然后刷新角色信息
</p>
<p class="muted meta-line">
最近刷新{{ formatDateTime(flow.role.refreshedAt) }}链接有效期{{ formatDateTime(flow.binding.bindExpiresAt) }}
最近刷新{{ formatDateTime(flow.role.refreshedAt) }}链接有效期{{
formatDateTime(flow.binding.bindExpiresAt)
}}
</p>
<p v-if="flow.binding.bindProbeMessage" class="muted meta-line">
链接检测{{ flow.binding.bindProbeStatus || '-' }} / {{ flow.binding.bindProbeMessage }}
</p>
<div class="action-row">
<el-button
size="large"
plain
:loading="refreshingRole"
@click="refreshRole"
>
<el-button size="large" plain :loading="refreshingRole" @click="refreshRole">
<el-icon><RefreshRight /></el-icon>
刷新角色信息
</el-button>
@@ -477,12 +507,7 @@ onBeforeUnmount(() => {
<span>兑换后不可取消也不可退货</span>
</div>
<el-button
type="primary"
size="large"
:loading="redeeming"
@click="confirmRedeem"
>
<el-button type="primary" size="large" :loading="redeeming" @click="confirmRedeem">
确认兑换
</el-button>
</section>
@@ -79,7 +79,9 @@ const qrFigureStyle = computed(() => ({
maxWidth: '100%',
}))
const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`)
const qrPreviewWidth = computed(
() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`,
)
const showScreenshot = computed(() => Boolean(screenshotUrl.value))
</script>
+1 -4
View File
@@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}