feat(frontend): add P2 quality gates

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