feat(frontend): add P2 quality gates
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
src/auto-imports.d.ts
|
||||
src/components.d.ts
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
)
|
||||
Generated
+1284
-2
File diff suppressed because it is too large
Load Diff
@@ -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<{
|
||||
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,7 +552,8 @@ export function useAdminManualRedeemPage() {
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = screenshotUrl.value
|
||||
anchor.download = task.value?.status === 'redeemed'
|
||||
anchor.download =
|
||||
task.value?.status === 'redeemed'
|
||||
? `manual-redeem-result-${task.value?.taskNo || 'task'}.png`
|
||||
: `manual-redeem-review-${task.value?.taskNo || 'task'}.png`
|
||||
anchor.click()
|
||||
@@ -547,10 +562,10 @@ export function useAdminManualRedeemPage() {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
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>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 || ''))}` : '',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }>>(() => [
|
||||
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) => {
|
||||
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 sameProductNo =
|
||||
productNo &&
|
||||
(item.externalSkuCode.trim() === productNo || item.externalItemId.trim() === productNo)
|
||||
const sameName =
|
||||
productName &&
|
||||
[item.externalSkuName, item.internalSkuName, item.resolvedSkuName].some(
|
||||
(value) => value.trim() === productName,
|
||||
)
|
||||
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,
|
||||
]
|
||||
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=91kaquan、platform=kuaishou、外部 SKU=productNo 的规则草稿;保存规则后,回到“平台配置 -> 91卡券接入”重试订单。</p>
|
||||
<p class="toolbar-hint">
|
||||
导入后会创建 provider=91kaquan、platform=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="批量导入支持两种格式: 1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组) 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 {
|
||||
|
||||
+21
-14
@@ -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>
|
||||
|
||||
+160
-38
@@ -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>
|
||||
|
||||
+178
-39
@@ -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', {
|
||||
: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>
|
||||
|
||||
+35
-10
@@ -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 }">
|
||||
|
||||
+113
-28
@@ -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'
|
||||
|
||||
|
||||
+66
-33
@@ -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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
await runCloudtentaclesDebugAction('获取兑换链接', () =>
|
||||
fetchAdminCloudtentaclesBindUrl({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
id: Number(cloudtentaclesForm.value.vnId || 0),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesBackVn() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('退还号码', () => backAdminCloudtentaclesVn({
|
||||
await runCloudtentaclesDebugAction('退还号码', () =>
|
||||
backAdminCloudtentaclesVn({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
id: Number(cloudtentaclesForm.value.vnId || 0),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesRunFullFlow() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('完整测试流程', () => runAdminCloudtentaclesFullFlow({
|
||||
await runCloudtentaclesDebugAction('完整测试流程', () =>
|
||||
runAdminCloudtentaclesFullFlow({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
skuId: Number(cloudtentaclesForm.value.skuId || 0),
|
||||
skuCount: Number(cloudtentaclesForm.value.skuCount || 1),
|
||||
vnKey: cloudtentaclesForm.value.vnKey.trim(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+49
-23
@@ -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,
|
||||
|
||||
+12
-4
@@ -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} 标记为无法履约吗?查询接口会返回失败状态。`, '确认标记失败', {
|
||||
await showConfirm(
|
||||
`确认把 91卡券订单 ${item.orderNo} 标记为无法履约吗?查询接口会返回失败状态。`,
|
||||
'确认标记失败',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '标记失败',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
+14
-9
@@ -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,7 +126,8 @@ const kuaishouCloudChecklist = computed(() => {
|
||||
key: 'role',
|
||||
label: '角色识别',
|
||||
status: role?.status || 'pending',
|
||||
detail: role?.name || role?.rid
|
||||
detail:
|
||||
role?.name || role?.rid
|
||||
? `${role?.name || '-'} / ${role?.rid || '-'}`
|
||||
: '客户绑定后刷新角色信息',
|
||||
},
|
||||
@@ -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,12 +261,14 @@ async function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||||
}
|
||||
|
||||
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
|
||||
const confirmText = outcome === 'failed'
|
||||
const confirmText =
|
||||
outcome === 'failed'
|
||||
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
|
||||
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
|
||||
|
||||
await runAction(
|
||||
() => completeAdminTaskManualDispatch(detail.value!.task.taskId, {
|
||||
() =>
|
||||
completeAdminTaskManualDispatch(detail.value!.task.taskId, {
|
||||
outcome,
|
||||
resultMessage: manualDispatchForm.resultMessage,
|
||||
deliveryReference: manualDispatchForm.deliveryReference,
|
||||
@@ -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(
|
||||
@click="
|
||||
runAction(
|
||||
() => confirmAdminTaskAssistedRole(detail!.task.taskId),
|
||||
'角色已确认',
|
||||
`确认以客服身份锁定任务 ${detail!.task.taskNo} 当前识别到的角色信息吗?`
|
||||
)"
|
||||
`确认以客服身份锁定任务 ${detail!.task.taskNo} 当前识别到的角色信息吗?`,
|
||||
)
|
||||
"
|
||||
>
|
||||
客服确认角色
|
||||
</el-button>
|
||||
@@ -462,11 +500,13 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="runAction(
|
||||
@click="
|
||||
runAction(
|
||||
() => redeemAdminTaskAssisted(detail!.task.taskId),
|
||||
'兑换任务已启动',
|
||||
`确认开始执行任务 ${detail!.task.taskNo} 的自动兑换吗?`
|
||||
)"
|
||||
`确认开始执行任务 ${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>
|
||||
<p class="manual-hint">客户会先在领取页查看图文指引并提交核销码,只有校验通过后才会自动跳转绑定链接。</p>
|
||||
<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>
|
||||
</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>
|
||||
<p class="manual-hint">"确认绑定完成并发货"已经合并为一个按钮,只有核销码通过校验后才能继续。</p>
|
||||
<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>
|
||||
</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),
|
||||
@click="
|
||||
runAction(
|
||||
() =>
|
||||
releaseAdminTaskInventoryBinding(detail!.task.taskId, binding.bindingId),
|
||||
'库存绑定已释放',
|
||||
`确认释放绑定 #${binding.bindingId} 对应的库存项 ${binding.displayValue || binding.inventoryItemId} 吗?`
|
||||
)"
|
||||
`确认释放绑定 #${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="查看原始请求并手动重放处理流程。"
|
||||
<AdminPageHeader title="Webhook 详情" description="查看原始请求并手动重放处理流程。">
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
:loading="replayLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="submitReplay"
|
||||
>重放处理</el-button
|
||||
>
|
||||
<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));
|
||||
|
||||
@@ -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,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user